Feat/downloads storage manager tab#878
Feat/downloads storage manager tab#878xXJSONDeruloXx wants to merge 19 commits intoutkarshdalal:masterfrom
Conversation
* added a back button to screen * changed game text color to white for easier readability
…orage-manager-tab
…orage-manager-tab
…ab' into feat/downloads-storage-manager-tab
📝 WalkthroughWalkthroughThis PR introduces comprehensive partial download tracking across multiple game platforms (Steam, Epic, GOG, Amazon). It adds a Changes
Sequence Diagram(s)sequenceDiagram
participant VM as DownloadsViewModel
participant SS as SteamService
participant ES as EpicService
participant GS as GOGService
participant AS as AmazonService
participant DAO as DAO Layer
participant UI as UI State
VM->>VM: startPolling() every 1s
loop Polling Loop
VM->>SS: getActiveDownloads()
SS->>DAO: Query activeDownloads
DAO-->>SS: Download list
SS-->>VM: Map<Int, DownloadInfo>
VM->>SS: getPartialDownloads()
SS->>DAO: Query downloadingAppInfo
DAO-->>SS: Partial list
SS-->>VM: List<Int>
par Query Other Platforms
VM->>ES: getActiveDownloads()
ES-->>VM: Map<Int, DownloadInfo>
VM->>GS: getActiveDownloads()
GS-->>VM: Map<String, DownloadInfo>
VM->>AS: getActiveDownloads()
AS-->>VM: Map<String, DownloadInfo>
end
par Query Partial Downloads
VM->>ES: getPartialDownloads()
ES-->>VM: List<Int>
VM->>GS: getPartialDownloads()
GS-->>VM: List<String>
VM->>AS: getPartialDownloads()
AS-->>VM: List<String>
end
VM->>DAO: Query metadata (names, icons, sizes)
DAO-->>VM: Game metadata
VM->>UI: updateState(DownloadsState)
UI-->>UI: Re-render with aggregated downloads
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
📝 Coding Plan
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. Comment |
There was a problem hiding this comment.
16 issues found across 33 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/gog/GOGManager.kt">
<violation number="1" location="app/src/main/java/app/gamenative/service/gog/GOGManager.kt:500">
P2: deleteGame() skips resetting the DB when an install was detected without markers, leaving isInstalled/installPath stale after deletion.</violation>
</file>
<file name="app/src/main/res/values/strings.xml">
<violation number="1" location="app/src/main/res/values/strings.xml:823">
P3: `container_storage_summary` uses a fixed "items" string with a count, which will render incorrect singular text (e.g., "1 items") and is not plural-aware for localization. Use an Android `<plurals>` resource and plural-aware formatting instead.</violation>
</file>
<file name="app/src/main/java/app/gamenative/db/dao/SteamAppDao.kt">
<violation number="1" location="app/src/main/java/app/gamenative/db/dao/SteamAppDao.kt:40">
P2: Owned-app SQL filtering rules are duplicated across two DAO methods, increasing risk of future rule drift and inconsistent results.</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:385">
P2: A non-critical `partialInstall` DB update is treated as fatal and can abort the whole download on DB/DAO exceptions.</violation>
<violation number="2" location="app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt:385">
P2: Partial-install tracking is only applied to Gen 2 downloads; Gen 1 returns before this new flag update, causing inconsistent incomplete-install detection for legacy GOG titles.</violation>
</file>
<file name="app/src/main/java/app/gamenative/service/SteamService.kt">
<violation number="1" location="app/src/main/java/app/gamenative/service/SteamService.kt:554">
P2: `getPartialDownloads()` only reads DAO rows and returns empty if the service instance is null, so the downloads tab can miss resumable partials that `hasPartialDownload()` would detect from disk (e.g., after logout clears DAO rows or before the service is started).</violation>
</file>
<file name="app/src/main/java/app/gamenative/utils/ContainerStorageManager.kt">
<violation number="1" location="app/src/main/java/app/gamenative/utils/ContainerStorageManager.kt:121">
P2: Storage-manager loading performs unbounded recursive size scans for every entry, causing poor scalability and long blocking loads for large game libraries.</violation>
<violation number="2" location="app/src/main/java/app/gamenative/utils/ContainerStorageManager.kt:849">
P2: Active container symlink is not relinked after deleting its target because `exists()` short-circuits for dangling symlinks.</violation>
</file>
<file name="app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt">
<violation number="1" location="app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt:108">
P2: Defaulting `onDownloadsClick` to `{}` masks missing wiring and already causes at least one call path to have a non-functional Downloads action.</violation>
</file>
<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 early but not reliably cleared on failure/finalization paths, allowing stale partial-install state in DB.</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">
P2: Downloads state uses `appId`-only map keys even though downloads are multi-source; IDs can collide across sources and overwrite entries.</violation>
</file>
<file name="app/src/main/java/app/gamenative/service/amazon/AmazonService.kt">
<violation number="1" location="app/src/main/java/app/gamenative/service/amazon/AmazonService.kt:364">
P2: Amazon partial-download listing is tied to a running service and DB `partial_install` query, which can miss on-disk/resumable partial downloads and under-report items in the downloads tab.</violation>
</file>
<file name="app/src/main/java/app/gamenative/ui/screen/library/components/SystemMenu.kt">
<violation number="1" location="app/src/main/java/app/gamenative/ui/screen/library/components/SystemMenu.kt:572">
P2: A single `firstItemFocusRequester` is assigned to both Downloads and Settings, which breaks the single-target initial-focus intent and can make menu-open focus behavior ambiguous.</violation>
</file>
<file name="app/src/main/java/app/gamenative/service/amazon/AmazonDownloadManager.kt">
<violation number="1" location="app/src/main/java/app/gamenative/service/amazon/AmazonDownloadManager.kt:58">
P2: `partial_install` is set before validation/download and is not reverted on failure/cancellation, causing stale phantom partial-download state in DB.</violation>
</file>
<file name="app/src/main/java/app/gamenative/ui/screen/settings/ContainerStorageManagerDialog.kt">
<violation number="1" location="app/src/main/java/app/gamenative/ui/screen/settings/ContainerStorageManagerDialog.kt:127">
P2: `refresh()` marks the inventory as loaded even when `loadEntries` fails, so `ensureLoaded()` will never retry. A transient failure (or later inventory change while the state is remembered) can leave the dialog permanently stale with no recovery path.</violation>
<violation number="2" location="app/src/main/java/app/gamenative/ui/screen/settings/ContainerStorageManagerDialog.kt:225">
P2: If moveGame throws, movingEntryName is never cleared, leaving the UI stuck in the moving state and blocking dialog dismissal.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
| Timber.d("Updated database: game marked as not installed") | ||
| } | ||
| val game = getGameFromDbById(gameId) | ||
| if (game != null && (wasInstalled || game.partialInstall)) { |
There was a problem hiding this comment.
P2: deleteGame() skips resetting the DB when an install was detected without markers, leaving isInstalled/installPath stale after deletion.
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/GOGManager.kt, line 500:
<comment>deleteGame() skips resetting the DB when an install was detected without markers, leaving isInstalled/installPath stale after deletion.</comment>
<file context>
@@ -492,13 +496,11 @@ class GOGManager @Inject constructor(
- Timber.d("Updated database: game marked as not installed")
- }
+ val game = getGameFromDbById(gameId)
+ if (game != null && (wasInstalled || game.partialInstall)) {
+ val updatedGame = game.copy(isInstalled = false, partialInstall = false, installPath = "")
+ gogGameDao.update(updatedGame)
</file context>
| if (game != null && (wasInstalled || game.partialInstall)) { | |
| if (game != null && (wasInstalled || game.partialInstall || game.isInstalled)) { |
|
|
||
| @Query( | ||
| "SELECT * FROM steam_app " + | ||
| "WHERE id != 480 " + |
There was a problem hiding this comment.
P2: Owned-app SQL filtering rules are duplicated across two DAO methods, increasing risk of future rule drift and inconsistent results.
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/db/dao/SteamAppDao.kt, line 40:
<comment>Owned-app SQL filtering rules are duplicated across two DAO methods, increasing risk of future rule drift and inconsistent results.</comment>
<file context>
@@ -35,6 +35,17 @@ interface SteamAppDao {
+ @Query(
+ "SELECT * FROM steam_app " +
+ "WHERE id != 480 " +
+ "AND package_id != :invalidPkgId " +
+ "AND type != 0 " +
</file context>
| // 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)) |
There was a problem hiding this comment.
P2: A non-critical partialInstall DB update is treated as fatal and can abort the whole download on DB/DAO exceptions.
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 385:
<comment>A non-critical `partialInstall` DB update is treated as fatal and can abort the whole download on DB/DAO exceptions.</comment>
<file context>
@@ -379,6 +379,12 @@ class GOGDownloadManager @Inject constructor(
+ // 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>
| // 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)) |
There was a problem hiding this comment.
P2: Partial-install tracking is only applied to Gen 2 downloads; Gen 1 returns before this new flag update, causing inconsistent incomplete-install detection for legacy GOG titles.
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 385:
<comment>Partial-install tracking is only applied to Gen 2 downloads; Gen 1 returns before this new flag update, causing inconsistent incomplete-install detection for legacy GOG titles.</comment>
<file context>
@@ -379,6 +379,12 @@ class GOGDownloadManager @Inject constructor(
+ // 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>
| fun getActiveDownloads(): Map<Int, DownloadInfo> = HashMap(downloadJobs) | ||
|
|
||
| suspend fun getPartialDownloads(): List<Int> { | ||
| return instance?.downloadingAppInfoDao?.getAll() |
There was a problem hiding this comment.
P2: getPartialDownloads() only reads DAO rows and returns empty if the service instance is null, so the downloads tab can miss resumable partials that hasPartialDownload() would detect from disk (e.g., after logout clears DAO rows or before the service is started).
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/SteamService.kt, line 554:
<comment>`getPartialDownloads()` only reads DAO rows and returns empty if the service instance is null, so the downloads tab can miss resumable partials that `hasPartialDownload()` would detect from disk (e.g., after logout clears DAO rows or before the service is started).</comment>
<file context>
@@ -548,6 +548,15 @@ class SteamService : Service(), IChallengeUrlChanged {
+ fun getActiveDownloads(): Map<Int, DownloadInfo> = HashMap(downloadJobs)
+
+ suspend fun getPartialDownloads(): List<Int> {
+ return instance?.downloadingAppInfoDao?.getAll()
+ ?.map { it.appId }
+ ?.filter { appId -> !downloadJobs.containsKey(appId) }
</file context>
| onDownloadsClick() | ||
| onDismiss() | ||
| }, | ||
| focusRequester = firstItemFocusRequester, |
There was a problem hiding this comment.
P2: A single firstItemFocusRequester is assigned to both Downloads and Settings, which breaks the single-target initial-focus intent and can make menu-open focus behavior ambiguous.
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/components/SystemMenu.kt, line 572:
<comment>A single `firstItemFocusRequester` is assigned to both Downloads and Settings, which breaks the single-target initial-focus intent and can make menu-open focus behavior ambiguous.</comment>
<file context>
@@ -560,6 +562,16 @@ fun SystemMenu(
+ onDownloadsClick()
+ onDismiss()
+ },
+ focusRequester = firstItemFocusRequester,
+ )
+
</file context>
| MarkerUtils.addMarker(installPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) | ||
|
|
||
| // Mark as partial install in DB so Downloads screen can detect it | ||
| amazonManager.markAsPartialInstall(productId) |
There was a problem hiding this comment.
P2: partial_install is set before validation/download and is not reverted on failure/cancellation, causing stale phantom partial-download state in DB.
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/amazon/AmazonDownloadManager.kt, line 58:
<comment>`partial_install` is set before validation/download and is not reverted on failure/cancellation, causing stale phantom partial-download state in DB.</comment>
<file context>
@@ -54,6 +54,9 @@ class AmazonDownloadManager @Inject constructor(
MarkerUtils.addMarker(installPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER)
+ // Mark as partial install in DB so Downloads screen can detect it
+ amazonManager.markAsPartialInstall(productId)
+
// Helper to cleanup marker on early failure
</file context>
| ContainerStorageManager.loadEntries(appContext) | ||
| }.onSuccess { | ||
| entries = it | ||
| hasLoaded = true |
There was a problem hiding this comment.
P2: refresh() marks the inventory as loaded even when loadEntries fails, so ensureLoaded() will never retry. A transient failure (or later inventory change while the state is remembered) can leave the dialog permanently stale with no recovery path.
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/settings/ContainerStorageManagerDialog.kt, line 127:
<comment>`refresh()` marks the inventory as loaded even when `loadEntries` fails, so `ensureLoaded()` will never retry. A transient failure (or later inventory change while the state is remembered) can leave the dialog permanently stale with no recovery path.</comment>
<file context>
@@ -0,0 +1,805 @@
+ ContainerStorageManager.loadEntries(appContext)
+ }.onSuccess {
+ entries = it
+ hasLoaded = true
+ }.onFailure { error ->
+ hasLoaded = true
</file context>
| moveTotalFiles = 1 | ||
|
|
||
| scope.launch { | ||
| val result = ContainerStorageManager.moveGame( |
There was a problem hiding this comment.
P2: If moveGame throws, movingEntryName is never cleared, leaving the UI stuck in the moving state and blocking dialog dismissal.
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/settings/ContainerStorageManagerDialog.kt, line 225:
<comment>If moveGame throws, movingEntryName is never cleared, leaving the UI stuck in the moving state and blocking dialog dismissal.</comment>
<file context>
@@ -0,0 +1,805 @@
+ moveTotalFiles = 1
+
+ scope.launch {
+ val result = ContainerStorageManager.moveGame(
+ context = appContext,
+ entry = entry,
</file context>
| <string name="settings_storage_manage_subtitle">Review game and container storage in one place</string> | ||
| <string name="container_storage_title">Container & Storage Manager</string> | ||
| <string name="container_storage_subtitle">Review game and container storage, including installed games without containers</string> | ||
| <string name="container_storage_summary">%1$d items • %2$s</string> |
There was a problem hiding this comment.
P3: container_storage_summary uses a fixed "items" string with a count, which will render incorrect singular text (e.g., "1 items") and is not plural-aware for localization. Use an Android <plurals> resource and plural-aware formatting instead.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/res/values/strings.xml, line 823:
<comment>`container_storage_summary` uses a fixed "items" string with a count, which will render incorrect singular text (e.g., "1 items") and is not plural-aware for localization. Use an Android `<plurals>` resource and plural-aware formatting instead.</comment>
<file context>
@@ -811,6 +814,45 @@
+ <string name="settings_storage_manage_subtitle">Review game and container storage in one place</string>
+ <string name="container_storage_title">Container & Storage Manager</string>
+ <string name="container_storage_subtitle">Review game and container storage, including installed games without containers</string>
+ <string name="container_storage_summary">%1$d items • %2$s</string>
+ <string name="container_storage_empty">No storage items found</string>
+ <string name="container_storage_loading">Scanning storage…</string>
</file context>
There was a problem hiding this comment.
Actionable comments posted: 12
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 | 🟡 MinorDuplicate
FocusRequesterassignment on two menu items.Both the Downloads item (line 572) and the Settings item (line 582) use
firstItemFocusRequester. Sharing a singleFocusRequesteracross multiple Composables can cause undefined focus behavior.Since Downloads is now the first menu item, only it should receive
firstItemFocusRequester. Remove it from Settings.🐛 Proposed fix
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 incorrectly sharing firstItemFocusRequester with the Downloads item; remove the focusRequester = firstItemFocusRequester parameter from the Settings SystemMenuItem so only the Downloads SystemMenuItem retains firstItemFocusRequester. Locate the two SystemMenuItem calls (the one with text = stringResource(R.string.app_downloads) and the one with text = stringResource(R.string.settings_text) that calls onNavigateRoute(PluviaScreen.Settings.route)) and delete the focusRequester assignment from the Settings item while leaving the Downloads item and its onDownloadsClick/onDismiss behavior unchanged.
🧹 Nitpick comments (1)
app/src/main/java/app/gamenative/service/amazon/AmazonDownloadManager.kt (1)
57-59: Consider clearingpartialInstallon early failures.The
partialInstallflag is set before validation checks (credentials, entitlement, download spec). If these fail early (lines 66-82), the flag remains set even though no files were downloaded. This could cause the game to appear in the Downloads screen with no resumable state.For cancellation (line 185-190), keeping
partialInstall=truemakes sense for resume capability. However, for early validation failures where nothing was actually downloaded, you may want to clear the flag incleanupOnFailure().💡 Optional: Clear partialInstall on early failures
// Helper to cleanup marker on early failure fun cleanupOnFailure() { MarkerUtils.removeMarker(installPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) + amazonManager.clearPartialInstall(productId) }This would require adding a
clearPartialInstall()method toAmazonManager.🤖 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 - 59, The code sets partialInstall via amazonManager.markAsPartialInstall(productId) before validation steps and never clears it on early failures; add a clearPartialInstall() method to AmazonManager and call it from cleanupOnFailure() when no download has started (i.e., after credential/entitlement/downloadSpec validation failures) so the DB flag is cleared for non-resumable failures, but keep the existing behavior for user cancellations where partialInstall should remain true to allow resume.
🤖 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/GOGGameDao.kt`:
- Around line 49-50: The query in getPartialDownloads currently returns rows
with partial_install = 1 even if they are hidden (exclude = 1); update the Room
Query in the suspend function getPartialDownloads to filter out hidden titles by
adding exclude = 0 (e.g., "SELECT * FROM gog_games WHERE partial_install = 1 AND
exclude = 0") so only non-hidden partial installations are returned.
In `@app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt`:
- Around line 382-386: The partialInstall flag isn't being set for legacy Gen1
downloads because downloadGameGen1(...) can return before the shared
post-download block runs; update the Gen1 flow to also mark the DB record as
partial. Inside downloadGameGen1 (where gameId is known and
resumable/early-return logic exists) call gogManager.getGameFromDbById(gameId)
and, if non-null, invoke gogManager.updateGame(currentGame.copy(partialInstall =
true)) so legacy Gen1 installs are marked the same way as Gen2; ensure you don't
duplicate logic by consolidating the DB-update into a small helper if needed.
In `@app/src/main/java/app/gamenative/service/SteamService.kt`:
- Around line 553-558: getPartialDownloads currently only excludes active
downloadJobs, which lets stale downloading_app_info rows for completed installs
show up; update getPartialDownloads to also filter out appIds that are already
fully installed by consulting the install status/installed-app DAO.
Specifically, in getPartialDownloads(), keep the call to
downloadingAppInfoDao.getAll() and downloadJobs.containsKey check but
additionally call the appropriate install-status method (e.g.,
instance?.installStatusDao?.isComplete(appId) or
instance?.installedAppDao?.getAllInstalled() / contains) and exclude any appId
that reports completed/installed so only true resumable downloads are returned.
In `@app/src/main/java/app/gamenative/ui/model/DownloadsViewModel.kt`:
- Around line 130-137: The items map in DownloadsViewModel is using raw
app/product IDs as keys causing cross-store collisions; change keying to include
the store prefix (e.g., "steam:$appId", "epic:$productId", "gog:$productId",
"amazon:$productId") wherever DownloadItemState entries are inserted (the
SteamService loop, Epic/GOG/Amazon loops) and update any containsKey(...) checks
and lookups to use the same prefixed keys (search for usages around
getSteamMetadata, getEpicMetadata, getGogMetadata, getAmazonMetadata and the
mutableMap items).
In `@app/src/main/java/app/gamenative/ui/screen/HomeScreen.kt`:
- Around line 55-64: The Friends branch renders HomeLibraryScreen but doesn't
forward the downloads callback, so the System menu's Downloads action is a
no-op; update the HomeDestination.Friends branch to pass the onDownloadsClick
prop through to HomeLibraryScreen (i.e., include onDownloadsClick =
onDownloadsClick when invoking HomeLibraryScreen) so the Downloads action from
the Friends placeholder navigates correctly.
In `@app/src/main/java/app/gamenative/utils/ContainerStorageManager.kt`:
- Around line 355-399: The uninstall branches currently ignore the Boolean
return from removeContainer(), causing a success result even if container
deletion fails; update each branch (SteamService.deleteApp handling and the
GOG/Epic/Amazon branches that call removeContainer when entry.hasContainer) to
capture the Boolean from removeContainer(context, entry.containerId) and if it
returns false convert the overall Result to a failure (e.g.,
Result.failure(Exception("Failed to remove container"))) instead of returning
success; ensure the Steam branch also returns a failure when removeContainer
fails and propagate that failure as the method result.
- Around line 389-394: The Amazon uninstall branch in ContainerStorageManager
(GameSource.AMAZON) must not depend on a running AmazonService singleton to
resolve productId; change the lookup to query the persisted mapping via
StorageManagerDaoEntryPoint / AmazonGameDao (or add a fallback in
AmazonService.getProductIdByAppId to do the same) so uninstall works after
restart. Specifically, in the branch using AmazonService.getProductIdByAppId,
replace or augment that call with a DAO query through
StorageManagerDaoEntryPoint -> AmazonGameDao to obtain the productId (and only
then call AmazonService.deleteGame and removeContainer), or implement DAO
fallback logic inside AmazonService.getProductIdByAppId so
ContainerStorageManager can remain unchanged.
- Around line 260-304: After a successful StorageUtils.moveDirectory(...) the
per-store metadata updates (the GameSource when-block that calls
PluviaApp.events.emitJava and entryPoint.* DAO update/markAsInstalled) must be
wrapped in a try/catch so any exception there returns Result.failure and does
not leave inconsistent state; on catch attempt to roll back the filesystem move
by calling StorageUtils.moveDirectory(targetDir.absolutePath,
sourceDir.absolutePath) (or otherwise restore files) and restore metadata if you
already mutated any DAO, then return Result.failure(exception) — ensure you
reference the same entry.containerId/entry.gameInstallSizeBytes and use the
existing DAO methods (gogGameDao().update, epicGameDao().update,
amazonGameDao().markAsInstalled) when performing rollback or compensating
updates so callers can reliably unwind.
- Around line 846-863: The code currently checks whether activeLink points to
deletedContainerDir after the deletion, which can miss a match; inside
relinkActiveSymlinkIfNeeded, read and capture the active link target before any
deletion occurs (e.g., val activeTarget = runCatching { activeLink.canonicalFile
}.getOrNull() or use Files.readSymbolicLink on activeLink.toPath() if you need
raw symlink target), then compare activeTarget to
deletedContainerDir.canonicalFile to decide if relinking is needed; if they
match, proceed to delete the symlink and create the fallback symlink as
currently implemented, and keep the existing try/catch (runCatching/.onFailure)
behavior around the relinking logic.
In `@app/src/main/java/app/gamenative/utils/StorageUtils.kt`:
- Around line 118-119: The progress callback is receiving the post-incremented
value so the moved-file count is one behind; update the code that calls
onProgressUpdate to pass the incremented count (either increment filesMoved
before calling or use ++filesMoved / filesMoved + 1) instead of filesMoved++ so
the first completed file reports 1; apply the same fix to the other
onProgressUpdate call site that also uses filesMoved.
- Around line 76-85: In StorageUtils.kt, before proceeding with moves use the
resolved canonical paths (sourceRootPath.toRealPath(LinkOption.NOFOLLOW_LINKS)
and targetRootPath.toRealPath(LinkOption.NOFOLLOW_LINKS) or fallback to
normalize()) to guard against path overlap: if the paths are equal or one
startsWith the other (sourceReal.startsWith(targetReal) ||
targetReal.startsWith(sourceReal)), return
Result.failure(IllegalArgumentException("Source and target directories
overlap")) so you avoid destructive behavior during move/copy fallback; include
an IOException catch around toRealPath and treat resolution errors as failures
with a clear message.
- Around line 221-229: The code calls moveDirectory(...) and ignores its
Result<Unit>, causing moveGamesFromOldPath to always call onComplete() even on
failure; capture the returned Result from moveDirectory, check result.isSuccess
/ result.isFailure, and on success invoke onComplete() on the Main dispatcher,
while on failure invoke an error handler (e.g., call an onError/onFailure
callback if available or propagate the Result/throw to the caller) passing the
failure.exceptionOrNull() for proper UI handling; update moveGamesFromOldPath
signature or callers (e.g., the caller in SteamAppScreen.kt) as needed so
failures are surfaced instead of silently closing the dialog.
---
Outside diff comments:
In `@app/src/main/java/app/gamenative/ui/screen/library/components/SystemMenu.kt`:
- Around line 565-583: The Settings SystemMenuItem is incorrectly sharing
firstItemFocusRequester with the Downloads item; remove the focusRequester =
firstItemFocusRequester parameter from the Settings SystemMenuItem so only the
Downloads SystemMenuItem retains firstItemFocusRequester. Locate the two
SystemMenuItem calls (the one with text = stringResource(R.string.app_downloads)
and the one with text = stringResource(R.string.settings_text) that calls
onNavigateRoute(PluviaScreen.Settings.route)) and delete the focusRequester
assignment from the Settings item while leaving the Downloads item and its
onDownloadsClick/onDismiss behavior unchanged.
---
Nitpick comments:
In `@app/src/main/java/app/gamenative/service/amazon/AmazonDownloadManager.kt`:
- Around line 57-59: The code sets partialInstall via
amazonManager.markAsPartialInstall(productId) before validation steps and never
clears it on early failures; add a clearPartialInstall() method to AmazonManager
and call it from cleanupOnFailure() when no download has started (i.e., after
credential/entitlement/downloadSpec validation failures) so the DB flag is
cleared for non-resumable failures, but keep the existing behavior for user
cancellations where partialInstall should remain true to allow resume.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 46024a26-83c7-4a11-b5d2-2a31623f82b7
📒 Files selected for processing (33)
app/schemas/app.gamenative.db.PluviaDatabase/14.jsonapp/src/main/java/app/gamenative/data/AmazonGame.ktapp/src/main/java/app/gamenative/data/EpicGame.ktapp/src/main/java/app/gamenative/data/GOGGame.ktapp/src/main/java/app/gamenative/db/PluviaDatabase.ktapp/src/main/java/app/gamenative/db/dao/AmazonGameDao.ktapp/src/main/java/app/gamenative/db/dao/DownloadingAppInfoDao.ktapp/src/main/java/app/gamenative/db/dao/EpicGameDao.ktapp/src/main/java/app/gamenative/db/dao/GOGGameDao.ktapp/src/main/java/app/gamenative/db/dao/SteamAppDao.ktapp/src/main/java/app/gamenative/service/SteamService.ktapp/src/main/java/app/gamenative/service/amazon/AmazonConstants.ktapp/src/main/java/app/gamenative/service/amazon/AmazonDownloadManager.ktapp/src/main/java/app/gamenative/service/amazon/AmazonManager.ktapp/src/main/java/app/gamenative/service/amazon/AmazonService.ktapp/src/main/java/app/gamenative/service/epic/EpicDownloadManager.ktapp/src/main/java/app/gamenative/service/epic/EpicManager.ktapp/src/main/java/app/gamenative/service/epic/EpicService.ktapp/src/main/java/app/gamenative/service/gog/GOGDownloadManager.ktapp/src/main/java/app/gamenative/service/gog/GOGManager.ktapp/src/main/java/app/gamenative/service/gog/GOGService.ktapp/src/main/java/app/gamenative/ui/data/DownloadsState.ktapp/src/main/java/app/gamenative/ui/model/DownloadsViewModel.ktapp/src/main/java/app/gamenative/ui/screen/HomeScreen.ktapp/src/main/java/app/gamenative/ui/screen/downloads/DownloadsScreen.ktapp/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.ktapp/src/main/java/app/gamenative/ui/screen/library/components/LibraryTabBar.ktapp/src/main/java/app/gamenative/ui/screen/library/components/SystemMenu.ktapp/src/main/java/app/gamenative/ui/screen/settings/ContainerStorageManagerDialog.ktapp/src/main/java/app/gamenative/utils/ContainerStorageManager.ktapp/src/main/java/app/gamenative/utils/StorageUtils.ktapp/src/main/res/values/strings.xmlapp/src/test/java/app/gamenative/utils/ContainerStorageManagerTest.kt
| @Query("SELECT * FROM gog_games WHERE partial_install = 1") | ||
| suspend fun getPartialDownloads(): List<GOGGame> |
There was a problem hiding this comment.
Exclude hidden titles from partial-download query.
Line 49 currently returns partial_install = 1 rows even when exclude = 1, which can leak hidden GOG entries into Downloads surfaces.
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>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| @Query("SELECT * FROM gog_games WHERE partial_install = 1") | |
| suspend fun getPartialDownloads(): List<GOGGame> | |
| `@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 query in getPartialDownloads currently returns rows with partial_install = 1
even if they are hidden (exclude = 1); update the Room Query in the suspend
function getPartialDownloads to filter out hidden titles by adding exclude = 0
(e.g., "SELECT * FROM gog_games WHERE partial_install = 1 AND exclude = 0") so
only non-hidden partial installations are returned.
| // 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)) | ||
| } |
There was a problem hiding this comment.
Legacy Gen1 downloads are not marked as partial.
Because Line 183 returns into downloadGameGen1(...) before this block runs, only Gen2 installs get partialInstall = true. Resumable detection will miss legacy titles.
💡 Suggested fix
@@
- // Gen 1 (legacy): different manifest format and download flow (direct file URLs, no chunks)
+ // Mark as partial install in DB so Downloads screen can detect resumable installs
+ val currentGame = gogManager.getGameFromDbById(gameId)
+ if (currentGame != null) {
+ gogManager.updateGame(currentGame.copy(partialInstall = true))
+ }
+
+ // Gen 1 (legacy): different manifest format and download flow (direct file URLs, no chunks)
if (selectedBuild.generation == 1 && gameManifest.productTimestamp != null) {
@@
- // 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))
- }
+ // already marked before flow branching🤖 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 isn't being set for legacy Gen1
downloads because downloadGameGen1(...) can return before the shared
post-download block runs; update the Gen1 flow to also mark the DB record as
partial. Inside downloadGameGen1 (where gameId is known and
resumable/early-return logic exists) call gogManager.getGameFromDbById(gameId)
and, if non-null, invoke gogManager.updateGame(currentGame.copy(partialInstall =
true)) so legacy Gen1 installs are marked the same way as Gen2; ensure you don't
duplicate logic by consolidating the DB-update into a small helper if needed.
| suspend fun getPartialDownloads(): List<Int> { | ||
| return instance?.downloadingAppInfoDao?.getAll() | ||
| ?.map { it.appId } | ||
| ?.filter { appId -> !downloadJobs.containsKey(appId) } | ||
| ?: emptyList() | ||
| } |
There was a problem hiding this comment.
Filter out completed installs from partial list.
Lines 553-558 only exclude currently active jobs. If a stale downloading_app_info row remains after completion, this can surface phantom resumable downloads.
Proposed fix
suspend fun getPartialDownloads(): List<Int> {
return instance?.downloadingAppInfoDao?.getAll()
- ?.map { it.appId }
- ?.filter { appId -> !downloadJobs.containsKey(appId) }
+ ?.asSequence()
+ ?.map { it.appId }
+ ?.distinct()
+ ?.filter { appId ->
+ !downloadJobs.containsKey(appId) &&
+ !MarkerUtils.hasMarker(getAppDirPath(appId), Marker.DOWNLOAD_COMPLETE_MARKER)
+ }
+ ?.toList()
?: emptyList()
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| suspend fun getPartialDownloads(): List<Int> { | |
| return instance?.downloadingAppInfoDao?.getAll() | |
| ?.map { it.appId } | |
| ?.filter { appId -> !downloadJobs.containsKey(appId) } | |
| ?: emptyList() | |
| } | |
| suspend fun getPartialDownloads(): List<Int> { | |
| return instance?.downloadingAppInfoDao?.getAll() | |
| ?.asSequence() | |
| ?.map { it.appId } | |
| ?.distinct() | |
| ?.filter { appId -> | |
| !downloadJobs.containsKey(appId) && | |
| !MarkerUtils.hasMarker(getAppDirPath(appId), Marker.DOWNLOAD_COMPLETE_MARKER) | |
| } | |
| ?.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 553 -
558, getPartialDownloads currently only excludes active downloadJobs, which lets
stale downloading_app_info rows for completed installs show up; update
getPartialDownloads to also filter out appIds that are already fully installed
by consulting the install status/installed-app DAO. Specifically, in
getPartialDownloads(), keep the call to downloadingAppInfoDao.getAll() and
downloadJobs.containsKey check but additionally call the appropriate
install-status method (e.g., instance?.installStatusDao?.isComplete(appId) or
instance?.installedAppDao?.getAllInstalled() / contains) and exclude any appId
that reports completed/installed so only true resumable downloads are returned.
| val items = mutableMapOf<String, DownloadItemState>() | ||
|
|
||
| // Steam downloads | ||
| for ((appId, info) in SteamService.getActiveDownloads()) { | ||
| val (name, icon) = getSteamMetadata(appId) | ||
| val (downloaded, total) = info.getBytesProgress() | ||
| items[appId.toString()] = DownloadItemState( | ||
| appId = appId.toString(), |
There was a problem hiding this comment.
Cross-store key collisions can overwrite downloads in items.
items is keyed only by appId/productId string, so IDs shared across stores (e.g., Steam 123 and Epic 123) collide and one entry replaces the other.
🧩 Suggested fix
class DownloadsViewModel `@Inject` constructor(
@@
) : ViewModel() {
+ private fun downloadKey(source: GameSource, id: String): String = "${source.name}_$id"
@@
- items[appId.toString()] = DownloadItemState(
+ items[downloadKey(GameSource.STEAM, appId.toString())] = DownloadItemState(
@@
- if (items.containsKey(appId.toString())) continue
+ if (items.containsKey(downloadKey(GameSource.STEAM, appId.toString()))) continue
@@
- items[appId.toString()] = DownloadItemState(
+ items[downloadKey(GameSource.STEAM, appId.toString())] = DownloadItemState(Apply the same keying pattern for Epic/GOG/Amazon inserts and containsKey(...) checks.
Also applies to: 151-156, 170-175, 188-193, 207-212, 225-230, 244-249, 262-267
🤖 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
130 - 137, The items map in DownloadsViewModel is using raw app/product IDs as
keys causing cross-store collisions; change keying to include the store prefix
(e.g., "steam:$appId", "epic:$productId", "gog:$productId", "amazon:$productId")
wherever DownloadItemState entries are inserted (the SteamService loop,
Epic/GOG/Amazon loops) and update any containsKey(...) checks and lookups to use
the same prefixed keys (search for usages around getSteamMetadata,
getEpicMetadata, getGogMetadata, getAmazonMetadata and the mutableMap items).
| HomeDestination.Friends -> { | ||
| // TODO: Friends screen | ||
| HomeLibraryScreen( | ||
| onClickPlay = onClickPlay, | ||
| onTestGraphics = onTestGraphics, | ||
| onNavigateRoute = onNavigateRoute, | ||
| onLogout = onLogout, | ||
| onGoOnline = onGoOnline, | ||
| isOffline = isOffline, | ||
| ) |
There was a problem hiding this comment.
Pass the downloads callback into the Friends placeholder.
This branch still renders HomeLibraryScreen, so its System menu will expose the Downloads action here too. Because onDownloadsClick falls back to the default no-op on this branch, selecting Downloads from the Friends placeholder won't navigate anywhere.
🔧 Suggested fix
HomeLibraryScreen(
onClickPlay = onClickPlay,
onTestGraphics = onTestGraphics,
onNavigateRoute = onNavigateRoute,
onLogout = onLogout,
onGoOnline = onGoOnline,
+ onDownloadsClick = { viewModel.onDestination(HomeDestination.Downloads) },
isOffline = isOffline,
)📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| HomeDestination.Friends -> { | |
| // TODO: Friends screen | |
| HomeLibraryScreen( | |
| onClickPlay = onClickPlay, | |
| onTestGraphics = onTestGraphics, | |
| onNavigateRoute = onNavigateRoute, | |
| onLogout = onLogout, | |
| onGoOnline = onGoOnline, | |
| isOffline = isOffline, | |
| ) | |
| 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 -
64, The Friends branch renders HomeLibraryScreen but doesn't forward the
downloads callback, so the System menu's Downloads action is a no-op; update the
HomeDestination.Friends branch to pass the onDownloadsClick prop through to
HomeLibraryScreen (i.e., include onDownloadsClick = onDownloadsClick when
invoking HomeLibraryScreen) so the Downloads action from the Friends placeholder
navigates correctly.
| GameSource.AMAZON -> { | ||
| val productId = AmazonService.getProductIdByAppId(gameId) | ||
| ?: return@withContext Result.failure(Exception("Amazon product id not found")) | ||
| val result = AmazonService.deleteGame(context, productId) | ||
| if (result.isSuccess && entry.hasContainer) removeContainer(context, entry.containerId) | ||
| result |
There was a problem hiding this comment.
Avoid requiring a running AmazonService just to uninstall Amazon games.
This branch resolves productId through AmazonService.getProductIdByAppId(), but that helper currently goes through the live service singleton in app/src/main/java/app/gamenative/service/amazon/AmazonService.kt (Lines 318-320). After a restart, the storage manager can still list installed Amazon games from Room, yet uninstall will fail here until AmazonService has been started. Resolve the product ID from AmazonGameDao via StorageManagerDaoEntryPoint, or add a DAO fallback in the service helper.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/src/main/java/app/gamenative/utils/ContainerStorageManager.kt` around
lines 389 - 394, The Amazon uninstall branch in ContainerStorageManager
(GameSource.AMAZON) must not depend on a running AmazonService singleton to
resolve productId; change the lookup to query the persisted mapping via
StorageManagerDaoEntryPoint / AmazonGameDao (or add a fallback in
AmazonService.getProductIdByAppId to do the same) so uninstall works after
restart. Specifically, in the branch using AmazonService.getProductIdByAppId,
replace or augment that call with a DAO query through
StorageManagerDaoEntryPoint -> AmazonGameDao to obtain the productId (and only
then call AmazonService.deleteGame and removeContainer), or implement DAO
fallback logic inside AmazonService.getProductIdByAppId so
ContainerStorageManager can remain unchanged.
| private fun relinkActiveSymlinkIfNeeded(homeDir: File, deletedContainerDir: File) { | ||
| val activeLink = File(homeDir, ImageFs.USER) | ||
| val pointsToDeleted = runCatching { | ||
| activeLink.exists() && activeLink.canonicalFile == deletedContainerDir.canonicalFile | ||
| }.getOrDefault(false) | ||
|
|
||
| if (!pointsToDeleted) return | ||
|
|
||
| runCatching { | ||
| activeLink.delete() | ||
| homeDir.listFiles() | ||
| ?.firstOrNull { it.isDirectory && it.name.startsWith("${ImageFs.USER}-") } | ||
| ?.let { fallback -> | ||
| FileUtils.symlink("./${fallback.name}", activeLink.path) | ||
| } | ||
| }.onFailure { | ||
| Timber.tag("ContainerStorageManager").w(it, "Failed to relink active container symlink") | ||
| } |
There was a problem hiding this comment.
Detect the active container link before deleting its target.
This helper tries to decide whether home/${ImageFs.USER} pointed at the removed container only after the target directory has already been deleted. In that case the check can miss the just-removed active container, so the symlink is left dangling and later container operations can break. Capture the active target before deletion, or compare the symlink target directly instead of relying on the post-delete state.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/src/main/java/app/gamenative/utils/ContainerStorageManager.kt` around
lines 846 - 863, The code currently checks whether activeLink points to
deletedContainerDir after the deletion, which can miss a match; inside
relinkActiveSymlinkIfNeeded, read and capture the active link target before any
deletion occurs (e.g., val activeTarget = runCatching { activeLink.canonicalFile
}.getOrNull() or use Files.readSymbolicLink on activeLink.toPath() if you need
raw symlink target), then compare activeTarget to
deletedContainerDir.canonicalFile to decide if relinking is needed; if they
match, proceed to delete the symlink and create the fallback symlink as
currently implemented, and keep the existing try/catch (runCatching/.onFailure)
behavior around the relinking logic.
| val sourceRootPath = Paths.get(sourceDir) | ||
| val targetRootPath = Paths.get(targetDir) | ||
|
|
||
| if (!Files.exists(targetPath)) { | ||
| Files.createDirectories(targetPath) | ||
| if (!Files.exists(sourceRootPath) || !Files.isDirectory(sourceRootPath)) { | ||
| return@withContext Result.failure(IllegalArgumentException("Invalid source directory: $sourceDir")) | ||
| } | ||
|
|
||
| if (!Files.exists(targetRootPath)) { | ||
| Files.createDirectories(targetRootPath) | ||
| } |
There was a problem hiding this comment.
Add path-overlap guards before moving files.
sourceDir == targetDir (or nested source/target) can lead to destructive behavior during move/copy fallback.
🛡️ Suggested guard
val sourceRootPath = Paths.get(sourceDir)
val targetRootPath = Paths.get(targetDir)
+ val sourceNormalized = sourceRootPath.toAbsolutePath().normalize()
+ val targetNormalized = targetRootPath.toAbsolutePath().normalize()
+
+ if (
+ sourceNormalized == targetNormalized ||
+ targetNormalized.startsWith(sourceNormalized) ||
+ sourceNormalized.startsWith(targetNormalized)
+ ) {
+ return@withContext Result.failure(
+ IllegalArgumentException("Source and target directories must be distinct and non-overlapping"),
+ )
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| val sourceRootPath = Paths.get(sourceDir) | |
| val targetRootPath = Paths.get(targetDir) | |
| if (!Files.exists(targetPath)) { | |
| Files.createDirectories(targetPath) | |
| if (!Files.exists(sourceRootPath) || !Files.isDirectory(sourceRootPath)) { | |
| return@withContext Result.failure(IllegalArgumentException("Invalid source directory: $sourceDir")) | |
| } | |
| if (!Files.exists(targetRootPath)) { | |
| Files.createDirectories(targetRootPath) | |
| } | |
| val sourceRootPath = Paths.get(sourceDir) | |
| val targetRootPath = Paths.get(targetDir) | |
| val sourceNormalized = sourceRootPath.toAbsolutePath().normalize() | |
| val targetNormalized = targetRootPath.toAbsolutePath().normalize() | |
| if ( | |
| sourceNormalized == targetNormalized || | |
| targetNormalized.startsWith(sourceNormalized) || | |
| sourceNormalized.startsWith(targetNormalized) | |
| ) { | |
| return@withContext Result.failure( | |
| IllegalArgumentException("Source and target directories must be distinct and non-overlapping"), | |
| ) | |
| } | |
| if (!Files.exists(sourceRootPath) || !Files.isDirectory(sourceRootPath)) { | |
| return@withContext Result.failure(IllegalArgumentException("Invalid source directory: $sourceDir")) | |
| } | |
| if (!Files.exists(targetRootPath)) { | |
| Files.createDirectories(targetRootPath) | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/src/main/java/app/gamenative/utils/StorageUtils.kt` around lines 76 - 85,
In StorageUtils.kt, before proceeding with moves use the resolved canonical
paths (sourceRootPath.toRealPath(LinkOption.NOFOLLOW_LINKS) and
targetRootPath.toRealPath(LinkOption.NOFOLLOW_LINKS) or fallback to normalize())
to guard against path overlap: if the paths are equal or one startsWith the
other (sourceReal.startsWith(targetReal) || targetReal.startsWith(sourceReal)),
return Result.failure(IllegalArgumentException("Source and target directories
overlap")) so you avoid destructive behavior during move/copy fallback; include
an IOException catch around toRealPath and treat resolution errors as failures
with a clear message.
| onProgressUpdate(relativePath.toString(), 1f, filesMoved++, totalFiles) | ||
| } |
There was a problem hiding this comment.
Progress callback reports moved-file count one step behind.
filesMoved++ passes the old value into onProgressUpdate, so the first completed file reports 0.
🔧 Suggested fix
- onProgressUpdate(relativePath.toString(), 1f, filesMoved++, totalFiles)
+ filesMoved += 1
+ onProgressUpdate(relativePath.toString(), 1f, filesMoved, totalFiles)
@@
- onProgressUpdate(relativePath.toString(), 1f, filesMoved++, totalFiles)
+ filesMoved += 1
+ onProgressUpdate(relativePath.toString(), 1f, filesMoved, totalFiles)Also applies to: 157-159
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/src/main/java/app/gamenative/utils/StorageUtils.kt` around lines 118 -
119, The progress callback is receiving the post-incremented value so the
moved-file count is one behind; update the code that calls onProgressUpdate to
pass the incremented count (either increment filesMoved before calling or use
++filesMoved / filesMoved + 1) instead of filesMoved++ so the first completed
file reports 1; apply the same fix to the other onProgressUpdate call site that
also uses filesMoved.
| moveDirectory( | ||
| sourceDir = sourceDir, | ||
| targetDir = targetDir, | ||
| onProgressUpdate = onProgressUpdate, | ||
| ) | ||
|
|
||
| withContext(Dispatchers.Main) { | ||
| onComplete() | ||
| } |
There was a problem hiding this comment.
moveGamesFromOldPath swallows failures and always completes as success.
moveDirectory(...) returns Result<Unit>, but it is ignored. With the current call site in app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt, the dialog closes with no failure path even if the move fails.
📌 Suggested fix
- suspend fun moveGamesFromOldPath(
+ suspend fun moveGamesFromOldPath(
sourceDir: String,
targetDir: String,
onProgressUpdate: (currentFile: String, fileProgress: Float, movedFiles: Int, totalFiles: Int) -> Unit,
onComplete: () -> Unit,
- ) = withContext(Dispatchers.IO) {
- moveDirectory(
+ ): Result<Unit> = withContext(Dispatchers.IO) {
+ val result = moveDirectory(
sourceDir = sourceDir,
targetDir = targetDir,
onProgressUpdate = onProgressUpdate,
)
withContext(Dispatchers.Main) {
onComplete()
}
+ result
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/src/main/java/app/gamenative/utils/StorageUtils.kt` around lines 221 -
229, The code calls moveDirectory(...) and ignores its Result<Unit>, causing
moveGamesFromOldPath to always call onComplete() even on failure; capture the
returned Result from moveDirectory, check result.isSuccess / result.isFailure,
and on success invoke onComplete() on the Main dispatcher, while on failure
invoke an error handler (e.g., call an onError/onFailure callback if available
or propagate the Result/throw to the caller) passing the
failure.exceptionOrNull() for proper UI handling; update moveGamesFromOldPath
signature or callers (e.g., the caller in SteamAppScreen.kt) as needed so
failures are surfaced instead of silently closing the dialog.
Summary by cubic
Add a new Downloads tab to track, resume, and manage game downloads across Steam, Epic, GOG, and Amazon. Includes a Storage Manager to review space and move installs between internal/external storage.
New Features
partial_installflag (Epic, GOG, Amazon) and Steam tracking to surface paused/incomplete downloads.StorageUtils.moveDirectory, and service methods to fetch active/partial downloads.Migration
partial_installtoepic_games,gog_games, andamazon_games. No manual steps required.Written for commit 9833f61. Summary will update on new commits.
Summary by CodeRabbit
New Features
Bug Fixes