Skip to content

Conversation

@phobos665
Copy link
Contributor

@phobos665 phobos665 commented Jan 20, 2026

Overview

This PR is to add Epic Games Integration

Features

  • Download Base games with DLC management
  • Correct filtering for duplicate games and non-games
  • Cloud Saves
  • Fully Playable games via Epic Games

Architecture

Epic
EpicApiClient - All Auth responsibilities for API Requests (Currently authClient, but will refactor later)
EpicAuthManager - Manages Auth responsibilities
EpicManager - Library Management
EpicService - Interface for all Epic operations
EpicConstants - Constants for Epic-related functionality such as IDs, names etc.
EpicDownloadManager - Handles Downloading Logic
EpicCloudSavesManager - Handles Cloud Saves

Manifest/
EpicManifest - Handles overall Logic for Manifests including Binary Manifest Parsing
JsonManifestParser - Handles parsing of JSON manifests
ManifestUtls - Common utils required for parsing both JSON and Binary manifests

Other Files:

  • EpicAppScreen - Game screen for showing game details
  • EpicGameManagerDialog - Game Manager Dialog for Epic. We should look to refactor GameManager into a BaseGameManager later.
  • EpicLoginDialog - Login Screen for Epic Games

Testing

I have created tests for the most complex areas, mostly manifest parsing.


Summary by cubic

Add Epic Games Store support: login, library sync, installs/downloads, launching, and cloud saves with new UI and database pieces. This brings Epic games into the library with proper filtering, icons, and service wiring.

  • New Features

    • Epic OAuth login/logout with stored credentials and Settings integration
    • Library sync for Epic titles with filtering, icons, EpicAppScreen, and manager/login dialogs
    • Game installs/downloads using Epic manifests (binary and JSON), with progress tracking, uninstall, install-size updates, launch support, and DLC selection UI (DLC downloads in a follow-up)
    • Cloud saves upload/download with manifest validation and empty-file handling
    • Database and services: EpicGame entity/DAO, Room v12 migration, EpicService startup, library counts and filters
  • Migration

    • App upgrades the Room database to v12 automatically
    • Sign in to Epic from Settings to load your library
    • Toggle the “Epic” source in the library filter to show Epic games

Written for commit 3590554. Summary will update on new commits.

Summary by CodeRabbit

  • New Features

    • Epic Games Store support: sign-in, library sync, manifest-driven downloads with DLC selection, and cloud-save sync.
  • UI

    • Epic login & game-manager dialogs, Epic app screens, library filter toggle, pre-launch cloud-save prompts, and status/progress UI.
  • Infrastructure

    • Background Epic foreground service, auth/credential handling, download manager, manifest parsing, cloud-saves orchestration, and startup integration.
  • Database

    • Schema/version bump to store Epic library items with DAO access.
  • Tests

    • New unit and instrumented tests for Epic catalog and manifest parsing.
  • Strings

    • Added localized Epic user-facing strings for login, install, and logout.

✏️ Tip: You can customize this high-level summary in your review settings.

- Epic Games authentication and login flow
- Game library management and syncing
- Cloud saves support with manifest parsing (binary and JSON formats)
- Game installation and download management
- Integration with container system
- Epic-specific UI components and screens
- Database entities and DAOs for Epic games
- Comprehensive test coverage for manifest parsing

Includes validation for empty manifests and files to prevent upload/download issues.
…how the correct download for the base game.

Next up is seeing about downloading the DLC also.
Now it'll just ignore it if the file is empty.
…pload.

Also removed EpicConverter since GOGConverter does the same thing.
…s downloaded.

Also fixed the download progress so that it uses the downloadSize for its progress bar.
}
}

suspend fun getInstalledExe(appId: Int): String {
Copy link
Owner

Choose a reason for hiding this comment

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

Should be the same format as the SteamService one. And we shouldn't call it with runBlocking. In general, runBlocking should be almost always avoided as it causes ANRs on old and an increasing number of new devices. Xiaomi etc do very aggressive memory handling, and as soon as something blocking happens on a UI thread, it silently kills the app without a crash log, leaving us with a hard-to-trace bug.

Copy link
Contributor Author

@phobos665 phobos665 Jan 22, 2026

Choose a reason for hiding this comment

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

Thanks for raising.

Steam Service with getInstalledExe does call:

            val appInfo = getAppInfoOf(appId) ?: return ""

            val installDir = appInfo.config.installDir.ifEmpty { appInfo.name }
        fun getAppInfoOf(appId: Int): SteamApp? {
            return runBlocking(Dispatchers.IO) { instance?.appDao?.findApp(appId) }
        }

Comparing this to:

    suspend fun getInstalledExe(appId: Int): String {
        // Strip EPIC_ prefix to get the raw Epic app name
        val game = getGameById(appId)
     * Get a single game by ID
     */
    suspend fun getGameById(appId: Int): EpicGame? {
        return withContext(Dispatchers.IO) {
            try {
                epicGameDao.getById(appId)
            } catch (e: Exception) {
                Timber.e(e, "Failed to get Epic game by ID: $appId")
                null
            }
        }
    }

So it looks to me like both do block the thread. It looks like we might need to make some more in-depth changes to avoid this on all the services.

// Initialize Epic service
appScope.launch {
try {
app.gamenative.service.epic.EpicService.start(applicationContext)
Copy link
Owner

Choose a reason for hiding this comment

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

Please make sure that EpicService is also stopped when the app is closed or we log out.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added changes now. Will test and make sure it closes correctly

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Services now close correctly for GOG and Epic alike.

Comment on lines +51 to +55
@Query("SELECT * FROM epic_games ORDER BY title ASC")
fun getAll(): Flow<List<EpicGame>>

@Query("SELECT * FROM epic_games ORDER BY title ASC")
suspend fun getAllAsList(): List<EpicGame>
Copy link
Owner

Choose a reason for hiding this comment

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

We don't need the exclude here that we used in the GOG DAO?

Copy link
Contributor Author

@phobos665 phobos665 Jan 22, 2026

Choose a reason for hiding this comment

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

So with Epic, we have a filter for isDLC instead. But I'd be happy to match the pattern so that we can exclude more than just DLC. Currently Epic only does Games & DLC, whereas GOG does Movies, Music, Packages, DLC etc.

// Start download in background
instance.scope.launch {
try {
val commonRedistDir = File(installPath, "_CommonRedist")
Copy link
Owner

@utkarshdalal utkarshdalal Jan 22, 2026

Choose a reason for hiding this comment

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

Confirming that this is downloading to /data/data/app.gamenative/Epic and not /data/data/app.gamenative/files/Epic. I believe it's doing the latter for GOG and that isn't correct. It should be beside the Steam folder.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah I've realized what I did here.

I was using the fileDir.path instead of dataDir.path. Thanks for catching.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is now fixed as the baseDir has been adjusted now to give the correct installPath

Copy link

@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: 8

🤖 Fix all issues with AI agents
In
`@app/src/main/java/app/gamenative/ui/screen/library/appscreen/EpicAppScreen.kt`:
- Around line 439-443: Replace the hardcoded toast text used in the
android.widget.Toast.makeText(...) call in EpicAppScreen.kt with a localized
string resource; add an entry (e.g. download_cancelled) to strings.xml and call
context.getString(R.string.download_cancelled) (or use stringResource if in a
composable) instead of the literal "Download cancelled" so the toast uses the
localized string.
- Around line 139-166: LaunchedEffect block performs IO work on Dispatchers.IO
but increments the Compose state refreshTrigger inside that IO context, which
can cause "state changed on a different thread" crashes; move the state mutation
out of the withContext(Dispatchers.IO) block back to the Main dispatcher by
applying refreshTrigger++ (or any update to the Compose state) after the IO
block completes (or wrap it in withContext(Dispatchers.Main)), and ensure
EpicService.updateEpicGame(...) can remain in the IO context while the UI state
update (refreshTrigger) runs on the main thread.

In `@app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt`:
- Around line 1751-1754: The code references isSteamGame but it's not declared;
define it before use by adding a boolean like isSteamGame = gameSource ==
GameSource.STEAM (or if GameSource.STEAM doesn't exist, use the intended
negation pattern, e.g. isSteamGame = !isEpicGame). Place this declaration
alongside the existing isEpicGame and gameId declarations (near gameSource and
ContainerUtils.extractGameIdFromContainerId/appId) so the subsequent if
(isSteamGame) branch compiles.

In `@app/src/main/java/app/gamenative/utils/ContainerUtils.kt`:
- Around line 585-602: The Epic branch logs "EPIC GAME FOUND FOR DRIVE" before
checking if EpicService.getEpicGameOf(...) returned null, so failures are silent
and logs misleading; move or change logging and add a failure log: call
extractGameIdFromContainerId(appId) then call EpicService.getEpicGameOf(gameId)
and only emit Timber.tag("Epic").d("EPIC GAME FOUND FOR DRIVE: %s", gameId) and
Timber.tag("Epic").d("EPIC INSTALL PATH FOUND FOR DRIVE: %s", gameInstallPath)
when game != null and installPath non-empty, and add a Timber.tag("Epic").w or
.d error message when game is null or installPath empty before returning
defaultDrives; update references in the GameSource.EPIC branch and use
Container.getNextAvailableDriveLetter(defaultDrives) as currently used.

In `@app/src/main/res/values-fr/strings.xml`:
- Around line 987-990: The French strings should be consistent and the example
must be localized: update the string resource epic_login_auth_example so its
text is in French and uses the same terminology as
epic_login_auth_code_label/epic_login_auth_code_placeholder (e.g., "Exemple :
code d’autorisation : « exemple »"), ensuring punctuation/escaping matches
surrounding entries and that the apostrophe in "d’autorisation" is the correct
typographic character used elsewhere.
- Line 990: Update the French string resource epic_login_auth_code_placeholder
to remove any mention of "URL" so it only asks for the authorization code;
locate the string named epic_login_auth_code_placeholder and replace its value
with a wording that references the code only (preserve proper escaping for the
apostrophe), or if you prefer to keep URL in flow then update the login flow
copy to match—ensure string and UI flow remain consistent.

In `@app/src/main/res/values-pt-rBR/strings.xml`:
- Line 854: The string resource epic_login_auth_example contains English text
("authorization code: \"example\""); replace the English fragment with
Portuguese (e.g., use "código de autorização" and a Portuguese example like
"exemplo") so the entire value is in Portuguese and consistent with the locale
(update the value of epic_login_auth_example accordingly).

In `@app/src/main/res/values-zh-rTW/strings.xml`:
- Line 999: The epic logout string lacks an error placeholder: update the
strings.xml entry for epic_logout_failed to include a "%s" placeholder (matching
gog_logout_failed) and change the call sites that use
context.getString(R.string.epic_logout_failed) to pass the error message (e.g.,
context.getString(R.string.epic_logout_failed, error?.message ?: "Unknown
error")), mirroring the GOG implementation so the user sees the actual error
details.
♻️ Duplicate comments (11)
app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt (1)

1741-1741: Return type should remain non-nullable.

This change was already flagged in a previous review, and the maintainer confirmed it should be reverted. The function never returns null, and the caller at line 1562 uses the result in string concatenation without null handling.

Suggested fix
-): String? {
+): String {
app/src/main/res/values/strings.xml (1)

1015-1015: Grammar consistency for logout failure message.

Suggested fix
-    <string name="epic_logout_failed">Failure to logout of Epic Games, please try again</string>
+    <string name="epic_logout_failed">Failed to log out from Epic Games. Please try again.</string>
app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt (6)

221-225: Avoid auto-download when no prior sync exists.

With lastSync == null, cloudTimestamp >= "" always evaluates true, so this path always downloads and can overwrite newer local saves. Compare cloud vs local timestamps instead (or treat as conflict).

🛠️ Suggested fix
-            // No sync timestamp - just compare cloud vs local
-            if (cloudTimestamp >= (lastSync ?: "")) {
-                SyncAction.DOWNLOAD
-            } else {
-                SyncAction.NONE
-            }
+            // No sync timestamp - compare cloud vs local directly
+            val cloudMillis = parseTimestamp(cloudTimestamp)
+            val localMillis = localNewestTimestamp ?: 0L
+            return@withContext when {
+                cloudMillis > localMillis -> SyncAction.DOWNLOAD
+                localMillis > cloudMillis -> SyncAction.UPLOAD
+                else -> SyncAction.NONE
+            }

519-522: Log message doesn’t match actual upload behavior.

The log says ${toUpload.size} files are being uploaded, but the call uploads all local files. Update the message or pass toUpload if selective upload was intended.

🛠️ Suggested fix
-                Timber.tag("Epic").i("[Cloud Saves] Uploading ${toUpload.size} files based on timestamp comparison")
+                Timber.tag("Epic").i("[Cloud Saves] Uploading all local files after conflict resolution (delta: ${toUpload.size} newer/unique)")

831-842: Use response.use {} to avoid OkHttp resource leaks.

Manual close() can be skipped if an exception occurs between execute() and close(). Wrap the response in use {} like the other calls in this file.

🛠️ Suggested fix
-            val response = httpClient.newCall(request).execute()
-
-            Timber.tag("Epic").d("[Cloud Saves] Response code: ${response.code}")
-
-            val responseBody = try {
-                response.body?.string() ?: ""
-            } catch (e: Exception) {
-                Timber.tag("Epic").e(e, "[Cloud Saves] Failed to read response body")
-                ""
-            }
-
-            response.close()
+            val response = httpClient.newCall(request).execute()
+            response.use {
+                Timber.tag("Epic").d("[Cloud Saves] Response code: ${response.code}")
+
+                val responseBody = try {
+                    response.body?.string() ?: ""
+                } catch (e: Exception) {
+                    Timber.tag("Epic").e(e, "[Cloud Saves] Failed to read response body")
+                    ""
+                }
+                // ... existing handling using responseBody ...
+            }

499-503: Guard copyOfRange bounds to handle truncated chunks.

If offset + size exceeds chunkData.size, copyOfRange throws. Add explicit bounds checks in both reconstruction paths.

🛠️ Suggested fix
-                                val partData = chunkData.copyOfRange(
-                                    chunkPart.offset.toInt(),
-                                    (chunkPart.offset + chunkPart.size).toInt(),
-                                )
+                                val endIndex = (chunkPart.offset + chunkPart.size).toInt()
+                                if (endIndex > chunkData.size) {
+                                    Timber.tag("Epic").e(
+                                        "[Cloud Saves] Chunk data truncated for ${fileManifest.filename}: expected $endIndex bytes, got ${chunkData.size}"
+                                    )
+                                    downloadSuccess = false
+                                    return@forEach
+                                }
+                                val partData = chunkData.copyOfRange(
+                                    chunkPart.offset.toInt(),
+                                    endIndex,
+                                )
-                            val partData = chunkData.copyOfRange(
-                                chunkPart.offset.toInt(),
-                                (chunkPart.offset + chunkPart.size).toInt(),
-                            )
+                            val endIndex = (chunkPart.offset + chunkPart.size).toInt()
+                            if (endIndex > chunkData.size) {
+                                Timber.tag("Epic").e(
+                                    "[Cloud Saves] Chunk data truncated for ${fileManifest.filename}: expected $endIndex bytes, got ${chunkData.size}"
+                                )
+                                return@forEach
+                            }
+                            val partData = chunkData.copyOfRange(
+                                chunkPart.offset.toInt(),
+                                endIndex,
+                            )

Also applies to: 665-668


964-1011: Chunk GUIDs in ChunkPart don’t match ChunkInfo.

ChunkPart GUIDs are generated independently from finalizeChunk, so manifests can reference chunks that don’t exist. Generate a GUID once per chunk and pass it through to finalizeChunk.

🛠️ Suggested fix
-            var currentChunkData = mutableListOf<Byte>()
+            var currentChunkData = mutableListOf<Byte>()
+            var currentChunkGuid: IntArray? = null
@@
-                        if (currentChunkData.size >= chunkSize) {
-                            val chunk = finalizeChunk(currentChunkData.toByteArray(), chunkNum++, packagedFiles)
+                        if (currentChunkData.size >= chunkSize) {
+                            val chunk = finalizeChunk(
+                                currentChunkData.toByteArray(),
+                                chunkNum++,
+                                packagedFiles,
+                                requireNotNull(currentChunkGuid),
+                            )
                             chunks.add(chunk)
                             currentChunkData.clear()
+                            currentChunkGuid = null
                         }
 
-                        val guid = if (currentChunkData.isEmpty()) {
-                            generateGuid()
-                        } else {
-                            fileManifest.chunkParts.lastOrNull()?.guid ?: generateGuid()
-                        }
+                        if (currentChunkGuid == null) {
+                            currentChunkGuid = generateGuid()
+                        }
+                        val guid = currentChunkGuid!!
@@
-            if (currentChunkData.isNotEmpty()) {
-                val chunk = finalizeChunk(currentChunkData.toByteArray(), chunkNum++, packagedFiles)
+            if (currentChunkData.isNotEmpty()) {
+                val chunk = finalizeChunk(
+                    currentChunkData.toByteArray(),
+                    chunkNum++,
+                    packagedFiles,
+                    requireNotNull(currentChunkGuid),
+                )
                 chunks.add(chunk)
             }
@@
-    private fun finalizeChunk(
-        data: ByteArray,
-        chunkNum: Int,
-        packagedFiles: MutableMap<String, ByteArray>,
-    ): app.gamenative.service.epic.manifest.ChunkInfo {
+    private fun finalizeChunk(
+        data: ByteArray,
+        chunkNum: Int,
+        packagedFiles: MutableMap<String, ByteArray>,
+        guid: IntArray,
+    ): app.gamenative.service.epic.manifest.ChunkInfo {
@@
-        chunkInfo.guid = generateGuid()
+        chunkInfo.guid = guid

Also applies to: 1031-1053


1253-1257: Choose deterministic subdirectory when multiple profiles exist.

firstOrNull can pick an arbitrary subdir if multiple profiles are present, which risks syncing the wrong save set. Consider matching accountId or choosing the most recently modified subdir with files.

🛠️ Suggested approach
-            val dirWithFiles = subDirs.firstOrNull { subDir ->
-                subDir.listFiles()?.any { it.isFile } == true
-            }
+            val dirWithFiles = subDirs
+                .filter { it.listFiles()?.any { it.isFile } == true }
+                .maxByOrNull { it.lastModified() }
app/src/main/java/app/gamenative/ui/screen/library/appscreen/EpicAppScreen.kt (2)

170-208: dlcTitles is populated but never used.
Either pass it into the dialog or remove it to avoid redundant fetches and recompositions.


455-483: Unmanaged CoroutineScopes remain in uninstall + cloud sync.
These scopes outlive the composable and can leak. Prefer a lifecycle-aware scope (as you now do for downloads).

♻️ Suggested pattern
-    private fun performUninstall(context: Context, libraryItem: LibraryItem) {
+    private fun performUninstall(scope: CoroutineScope, context: Context, libraryItem: LibraryItem) {
         Timber.tag(TAG).i("Uninstalling Epic game: ${libraryItem.appId}")
-        CoroutineScope(Dispatchers.IO).launch {
+        scope.launch(Dispatchers.IO) {
             try {
                 val result = EpicService.deleteGame(context, libraryItem.gameId)
                 ...
             } catch (e: Exception) {
                 ...
             }
         }
     }
-        val options = mutableListOf<AppMenuOption>()
+        val options = mutableListOf<AppMenuOption>()
+        val scope = rememberCoroutineScope()
...
-                        val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
-                        scope.launch {
+                        scope.launch {
-                            performUninstall(context, libraryItem)
+                            performUninstall(scope, context, libraryItem)
#!/bin/bash
# Find remaining unmanaged CoroutineScope creations in EpicAppScreen.
rg -n "CoroutineScope\\(" app/src/main/java/app/gamenative/ui/screen/library/appscreen/EpicAppScreen.kt

Also applies to: 550-580

app/src/main/java/app/gamenative/service/epic/EpicConstants.kt (1)

87-90: Guard against empty install folder names.
If the sanitized title is empty (e.g., non‑ASCII names), installs collapse into the base directory and can collide.

🛠️ Suggested fallback
     fun getGameInstallPath(context: android.content.Context, gameTitle: String): String {
         // Sanitize game title for filesystem
         val sanitizedTitle = gameTitle.replace(Regex("[^a-zA-Z0-9 \\-_]"), "").trim()
-        return Paths.get(defaultEpicGamesPath(context), sanitizedTitle).toString()
+        val folderName = if (sanitizedTitle.isNotBlank()) {
+            sanitizedTitle
+        } else {
+            "EpicGame_${gameTitle.hashCode()}"
+        }
+        return Paths.get(defaultEpicGamesPath(context), folderName).toString()
     }
🧹 Nitpick comments (3)
app/src/main/res/values-de/strings.xml (1)

925-925: Mixed language in example text.

The example uses English "authorization code" while the rest of the file uses the German term "Autorisierungscode" (see line 927). For consistency, consider using the German term here as well.

Suggested fix
-    <string name="epic_login_auth_example">Beispiel: authorization code: "example"</string>
+    <string name="epic_login_auth_example">Beispiel: Autorisierungscode: "example"</string>
app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt (1)

66-66: Unused import.

The Toast import is not used in the changed code. The existing usage at line 984 uses the fully qualified android.widget.Toast instead.

Suggested fix
-import android.widget.Toast
app/src/main/res/values/strings.xml (1)

48-56: LGTM — Epic install/uninstall strings align with existing flows.

If you want, I can generate AI translations for these new strings across the other locales.

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 3 files (changes from recent commits).

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


<file name="app/src/main/java/app/gamenative/MainActivity.kt">

<violation number="1" location="app/src/main/java/app/gamenative/MainActivity.kt:333">
P2: The comment claims to check for 'no downloads in progress' but the code doesn't actually call `GOGService.hasActiveOperations()`. The EpicService handling below correctly checks this condition before stopping. Consider adding the same check for consistency:

```kotlin
if (GOGService.isRunning && !isChangingConfigurations) {
    if (!GOGService.hasActiveOperations()) {
        Timber.i("Stopping GOG Service")
        GOGService.stop()
    } else {
        Timber.d("GOGService kept running - has active operations")
    }
}
```</violation>
</file>

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

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 5 files (changes from recent commits).

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


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

<violation number="1" location="app/src/main/java/app/gamenative/service/gog/GOGService.kt:567">
P2: Hardcoded notification text should be moved to a string resource so it can be localized and maintained consistently.

(Based on your team's feedback about avoiding hardcoded UI strings.) [FEEDBACK_USED]</violation>
</file>

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

// Start as foreground service
val notification = notificationHelper.createForegroundNotification("Connected")
startForeground(1, notification) // Use different ID than SteamService (which uses 1)
val notification = notificationHelper.createForegroundNotification("GOG Connected")
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Jan 22, 2026

Choose a reason for hiding this comment

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

P2: Hardcoded notification text should be moved to a string resource so it can be localized and maintained consistently.

(Based on your team's feedback about avoiding hardcoded UI strings.)

View Feedback

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/GOGService.kt, line 567:

<comment>Hardcoded notification text should be moved to a string resource so it can be localized and maintained consistently.

(Based on your team's feedback about avoiding hardcoded UI strings.) </comment>

<file context>
@@ -564,8 +564,8 @@ class GOGService : Service() {
         // Start as foreground service
-        val notification = notificationHelper.createForegroundNotification("Connected")
-        startForeground(1, notification) // Use different ID than SteamService (which uses 1)
+        val notification = notificationHelper.createForegroundNotification("GOG Connected")
+        startForeground(2, notification)
 
</file context>
Fix with Cubic

Copy link

@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: 5

🤖 Fix all issues with AI agents
In `@app/src/main/java/app/gamenative/service/epic/EpicService.kt`:
- Around line 330-340: The call to runBlocking in downloadGame blocks the caller
thread (risking ANR); change the blocking call to use a background dispatcher
(e.g., runBlocking(Dispatchers.IO)) or convert downloadGame to a suspend
function and call epicManager.getGameById from a coroutine; specifically update
the runBlocking { instance.epicManager.getGameById(appId) } invocation in
downloadGame (and any similar synchronous usages) to run on Dispatchers.IO (or
make downloadGame suspend and remove runBlocking) so the main thread is not
blocked.
- Around line 440-451: getAccountId uses kotlinx.coroutines.runBlocking without
a dispatcher which can block the caller (UI) thread; change the call to use a
background dispatcher. Replace kotlinx.coroutines.runBlocking {
EpicAuthManager.getStoredCredentials(context) } with runBlocking(Dispatchers.IO)
{ EpicAuthManager.getStoredCredentials(context) } (ensure Dispatchers is
imported) so getAccountId, which calls getInstance()?.applicationContext and
EpicAuthManager.getStoredCredentials, executes on IO instead of the main thread
while preserving the existing try/catch and return behavior.

In `@app/src/main/java/app/gamenative/service/gog/GOGService.kt`:
- Around line 567-568: GOGService is starting its foreground notification with
startForeground(2, notification) but NotificationHelper.cancel() only cancels a
hardcoded NOTIFICATION_ID (1), causing cross-service cancellation; update
NotificationHelper by adding cancel(id: Int) and change callers in GOGService
and EpicService to pass their notification IDs (the same IDs used in
startForeground) to notificationHelper.cancel(id) so each service cancels its
own notification, and replace the hardcoded "GOG Connected" in GOGService (used
via createForegroundNotification) with a string resource reference (add
gog_connected to strings.xml and load it via
context.getString(R.string.gog_connected) when creating the notification).

In
`@app/src/main/java/app/gamenative/ui/screen/library/appscreen/EpicAppScreen.kt`:
- Around line 551-582: The click handler creates an unmanaged CoroutineScope
using CoroutineScope(Dispatchers.Main + SupervisorJob()) and uses hardcoded
Toast strings; replace the orphan scope with a lifecycle-aware scope (e.g., use
viewModelScope or lifecycleScope from the owning ViewModel/Activity/Fragment
when invoking EpicCloudSavesManager.syncCloudSaves(context, libraryItem.gameId,
...)) so the job is cancelled with the lifecycle, and move all hardcoded
messages passed to Toast.makeText ("Starting cloud save sync...", "Cloud saves
synced successfully", "Cloud save sync failed", "Cloud save sync error: %s")
into strings.xml (use a formatted string for the error message) and reference
them via context.getString(...) when showing toasts; ensure error logging still
logs the exception via Timber.tag(TAG).e(e, ...).
- Around line 456-485: performUninstall currently creates an unmanaged
CoroutineScope(Dispatchers.IO) that can outlive the composable; change the
signature to accept a lifecycle-aware CoroutineScope (e.g., fun
performUninstall(context: Context, libraryItem: LibraryItem, scope:
CoroutineScope)) and replace CoroutineScope(Dispatchers.IO).launch { ... } with
scope.launch(Dispatchers.IO) { ... } (or use scope.launch {
withContext(Dispatchers.IO) { ... } }) to ensure the job is tied to the caller's
lifecycle—mirroring the approach used in performDownload—and update the call
site(s) that invoke performUninstall accordingly.
♻️ Duplicate comments (3)
app/src/main/java/app/gamenative/service/epic/EpicService.kt (1)

376-411: Hardcoded UI strings should be moved to strings.xml.

The toast messages at lines 380, 394, and 408 ("Download completed successfully!", "Download failed:", "Download error:") should use string resources for localization support and maintainability.

app/src/main/java/app/gamenative/ui/screen/library/appscreen/EpicAppScreen.kt (2)

121-122: dlcTitles state variable is declared and populated but never used.

The state is set at lines 132-133 and 197-198 but never consumed in the returned GameDisplayInfo or passed to any component. Consider removing it to avoid unnecessary recompositions.


440-444: User-visible toast string isn't localized.

"Download cancelled" should come from string resources for consistency with other Epic UI copy.

🧹 Nitpick comments (1)
app/src/main/java/app/gamenative/MainActivity.kt (1)

333-349: Minor style inconsistencies between GOGService and EpicService handling.

  1. Line 335 is missing a space after if (compare with line 343)
  2. GOGService doesn't log when kept running due to active operations, while EpicService does

Consider aligning these for consistency:

♻️ Suggested fix
         // Stop GOGService if running and no downloads in progress
         if (GOGService.isRunning && !isChangingConfigurations) {
-            if(!GOGService.hasActiveOperations()) {
+            if (!GOGService.hasActiveOperations()) {
                 Timber.i("Stopping GOG Service - no active operations")
                 GOGService.stop()
+            } else {
+                Timber.d("GOGService kept running - has active operations")
             }
         }

Comment on lines +456 to +485
private fun performUninstall(context: Context, libraryItem: LibraryItem) {
Timber.tag(TAG).i("Uninstalling Epic game: ${libraryItem.appId}")
CoroutineScope(Dispatchers.IO).launch {
try {
val result = EpicService.deleteGame(context, libraryItem.gameId)

if (result.isSuccess) {
Timber.tag(TAG).i("Epic game uninstalled successfully: ${libraryItem.appId}")
} else {
Timber.e("Failed to uninstall Epic game: ${libraryItem.appId} - ${result.exceptionOrNull()?.message}")
withContext(Dispatchers.Main) {
android.widget.Toast.makeText(
context,
"Uninstall failed: ${result.exceptionOrNull()?.message}",
android.widget.Toast.LENGTH_SHORT,
).show()
}
}
} catch (e: Exception) {
Timber.e(e, "Error uninstalling Epic game")
withContext(Dispatchers.Main) {
android.widget.Toast.makeText(
context,
"Uninstall error: ${e.message}",
android.widget.Toast.LENGTH_SHORT,
).show()
}
}
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Unmanaged CoroutineScope in performUninstall.

Similar to the issue previously flagged for performDownload, this function creates an orphan coroutine not tied to any lifecycle. If the composable is destroyed during the uninstall operation, the scope won't be cancelled properly.

Consider accepting a lifecycle-aware CoroutineScope parameter like performDownload now does.

🛠️ Suggested fix
-    private fun performUninstall(context: Context, libraryItem: LibraryItem) {
+    private fun performUninstall(scope: CoroutineScope, context: Context, libraryItem: LibraryItem) {
         Timber.tag(TAG).i("Uninstalling Epic game: ${libraryItem.appId}")
-        CoroutineScope(Dispatchers.IO).launch {
+        scope.launch(Dispatchers.IO) {
             try {

And update the call site at line 823:

-                            performUninstall(context, libraryItem)
+                            performUninstall(scope, context, libraryItem)
🤖 Prompt for AI Agents
In
`@app/src/main/java/app/gamenative/ui/screen/library/appscreen/EpicAppScreen.kt`
around lines 456 - 485, performUninstall currently creates an unmanaged
CoroutineScope(Dispatchers.IO) that can outlive the composable; change the
signature to accept a lifecycle-aware CoroutineScope (e.g., fun
performUninstall(context: Context, libraryItem: LibraryItem, scope:
CoroutineScope)) and replace CoroutineScope(Dispatchers.IO).launch { ... } with
scope.launch(Dispatchers.IO) { ... } (or use scope.launch {
withContext(Dispatchers.IO) { ... } }) to ensure the job is tied to the caller's
lifecycle—mirroring the approach used in performDownload—and update the call
site(s) that invoke performUninstall accordingly.

Copy link

@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: 2

🤖 Fix all issues with AI agents
In
`@app/src/main/java/app/gamenative/ui/screen/library/appscreen/EpicAppScreen.kt`:
- Around line 476-489: Replace the two hardcoded Toast messages in EpicAppScreen
(the uninstall failure block using result.exceptionOrNull()?.message and the
catch block using e.message) with localized string resources: add
epic_uninstall_failed and epic_uninstall_error to strings.xml (both should
accept a single string placeholder for the error message) and call
context.getString(R.string.epic_uninstall_failed,
result.exceptionOrNull()?.message ?: "") and
context.getString(R.string.epic_uninstall_error, e.message ?: "") where the
current literal strings are used in android.widget.Toast.makeText so the
displayed messages are localized.
- Around line 151-166: The code performs IO work on Dispatchers.IO and then
mutates Compose state (epicGame) on that dispatcher, which is unsafe; keep the
fetch and EpicService.updateEpicGame(...) on Dispatchers.IO but move the Compose
state mutation into the Main dispatcher by wrapping the assignment in
withContext(kotlinx.coroutines.Dispatchers.Main) so that after creating
updatedGame (from EpicService.fetchManifestSizes) and persisting it via
EpicService.updateEpicGame(updatedGame) you call withContext(Dispatchers.Main) {
epicGame = updatedGame } to update UI state safely.
♻️ Duplicate comments (3)
app/src/main/res/values/strings.xml (1)

1017-1017: Align Epic logout failure copy with existing phrasing.
"Failure to logout of Epic Games" reads inconsistent with similar messages. Consider reusing the “Failed to logout …” phrasing.

✏️ Suggested tweak
-    <string name="epic_logout_failed">Failure to logout of Epic Games, please try again</string>
+    <string name="epic_logout_failed">Failed to logout from Epic Games, please try again</string>
app/src/main/java/app/gamenative/ui/screen/library/appscreen/EpicAppScreen.kt (2)

465-493: Tie uninstall work to a lifecycle-aware scope.
performUninstall creates an unmanaged CoroutineScope, which can outlive the UI. Consider passing in the composable’s scope (already available in AdditionalDialogs).

🔧 Suggested adjustment
-    private fun performUninstall(context: Context, libraryItem: LibraryItem) {
+    private fun performUninstall(scope: CoroutineScope, context: Context, libraryItem: LibraryItem) {
         Timber.tag(TAG).i("Uninstalling Epic game: ${libraryItem.appId}")
-        CoroutineScope(Dispatchers.IO).launch {
+        scope.launch(Dispatchers.IO) {
             try {
                 val result = EpicService.deleteGame(context, libraryItem.gameId)
@@
-                            performUninstall(context, libraryItem)
+                            performUninstall(scope, context, libraryItem)

Also applies to: 849-853


553-589: Use lifecycle scope and string resources for cloud-sync toasts.
A new CoroutineScope is created per click and the toast text is hardcoded. Prefer rememberCoroutineScope() and strings.xml entries.

🛠️ Suggested pattern
-        val options = mutableListOf<AppMenuOption>()
+        val options = mutableListOf<AppMenuOption>()
+        val scope = rememberCoroutineScope()
@@
-                        val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
                         scope.launch {
                             try {
                                 Toast.makeText(
                                     context,
-                                    "Starting cloud save sync...",
+                                    context.getString(R.string.epic_cloud_sync_starting),
                                     Toast.LENGTH_SHORT,
                                 ).show()
@@
                                 Toast.makeText(
                                     context,
-                                    if (result) "Cloud saves synced successfully" else "Cloud save sync failed",
+                                    context.getString(
+                                        if (result) R.string.epic_cloud_sync_success else R.string.epic_cloud_sync_failed
+                                    ),
                                     Toast.LENGTH_LONG,
                                 ).show()
                             } catch (e: Exception) {
                                 Timber.tag(TAG).e(e, "[Cloud Saves] Sync failed")
                                 Toast.makeText(
                                     context,
-                                    "Cloud save sync error: ${e.message}",
+                                    context.getString(R.string.epic_cloud_sync_error, e.message ?: ""),
                                     Toast.LENGTH_LONG,
                                 ).show()
                             }

Add epic_cloud_sync_starting, epic_cloud_sync_success, epic_cloud_sync_failed, and epic_cloud_sync_error to strings.xml.

🧹 Nitpick comments (1)
app/src/main/java/app/gamenative/ui/screen/library/appscreen/EpicAppScreen.kt (1)

786-805: Remove duplicate CANCEL_APP_DOWNLOAD branch.
The when has the same enum case twice; the second branch is dead and can hide intended handling for another dialog type.

🧹 Cleanup
-                app.gamenative.ui.enums.DialogType.CANCEL_APP_DOWNLOAD -> {
-                    {
-                        Timber.tag(TAG).i("Cancelling Epic download for: $gameId")
-                        val downloadInfo = EpicService.getDownloadInfo(gameId)
-                        downloadInfo?.cancel()
-                        EpicService.cleanupDownload(gameId)
-                        BaseAppScreen.hideInstallDialog(appId)
-                        app.gamenative.PluviaApp.events.emit(app.gamenative.events.AndroidEvent.LibraryInstallStatusChanged(gameId))
-                    }
-                }
-
                 else -> null

Comment on lines +151 to +166
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
try {
val sizes = EpicService.fetchManifestSizes(context, game.id)
if (sizes.installSize > 0L || sizes.downloadSize > 0L) {
Timber.tag("Epic").i(
"Fetched sizes for ${game.title}: install=${sizes.installSize} download=${sizes.downloadSize}",
)
// Update database with fetched size
val updatedGame = game.copy(
installSize = sizes.installSize,
downloadSize = sizes.downloadSize,
)
EpicService.updateEpicGame(updatedGame)
// Update state directly to show updated size
epicGame = updatedGame
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's find and inspect the EpicAppScreen.kt file
fd -t f "EpicAppScreen.kt" --exec cat -n {} \; | head -250

Repository: utkarshdalal/GameNative

Length of output: 13349


🏁 Script executed:

# Search for other state updates inside withContext(Dispatchers.IO) or similar IO contexts
rg -A 3 "withContext.*Dispatchers\.IO" --type kotlin | head -100

Repository: utkarshdalal/GameNative

Length of output: 9236


🏁 Script executed:

# Also check for patterns of state updates after IO operations
rg "withContext\(" --type kotlin -A 5 | grep -E "(withContext|= \w+|state =)" | head -80

Repository: utkarshdalal/GameNative

Length of output: 9401


🏁 Script executed:

# Check if there are other instances in EpicAppScreen.kt where state is updated
grep -n "epicGame\s*=" app/src/main/java/app/gamenative/ui/screen/library/appscreen/EpicAppScreen.kt

Repository: utkarshdalal/GameNative

Length of output: 343


Update Compose state on the Main dispatcher.
epicGame is mutated inside Dispatchers.IO, which violates Compose snapshot thread-safety. State mutations must occur on the Main dispatcher. Wrap the state update with withContext(Dispatchers.Main).

Suggested fix
-                        if (sizes.installSize > 0L || sizes.downloadSize > 0L) {
+                        if (sizes.installSize > 0L || sizes.downloadSize > 0L) {
                             Timber.tag("Epic").i(
                                 "Fetched sizes for ${game.title}: install=${sizes.installSize} download=${sizes.downloadSize}",
                             )
                             // Update database with fetched size
                             val updatedGame = game.copy(
                                 installSize = sizes.installSize,
                                 downloadSize = sizes.downloadSize,
                             )
                             EpicService.updateEpicGame(updatedGame)
-                            // Update state directly to show updated size
-                            epicGame = updatedGame
+                            // Update state on Main dispatcher
+                            withContext(Dispatchers.Main) {
+                                epicGame = updatedGame
+                            }
                         }
🤖 Prompt for AI Agents
In
`@app/src/main/java/app/gamenative/ui/screen/library/appscreen/EpicAppScreen.kt`
around lines 151 - 166, The code performs IO work on Dispatchers.IO and then
mutates Compose state (epicGame) on that dispatcher, which is unsafe; keep the
fetch and EpicService.updateEpicGame(...) on Dispatchers.IO but move the Compose
state mutation into the Main dispatcher by wrapping the assignment in
withContext(kotlinx.coroutines.Dispatchers.Main) so that after creating
updatedGame (from EpicService.fetchManifestSizes) and persisting it via
EpicService.updateEpicGame(updatedGame) you call withContext(Dispatchers.Main) {
epicGame = updatedGame } to update UI state safely.

@phobos665
Copy link
Contributor Author

Note: Hold off for now, found a critical bug with some games having incredibly large chunks (1.5GB+) which are causing OOM issues. I'll need to fix it by using streaming properly with the decompression and compressing (and validation with SHA)

Also added fix to use IO thread for getting accountId. Now correctly updates UI on downloads finishing again.
@phobos665
Copy link
Contributor Author

Update: This is now fixed, downloaded 8 different games with varying sizes and all complete succesfully.

Will move onto final end-to-end testing and ensure there are no outlying bugs left.

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.

3 issues found across 2 files (changes from recent commits).

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


<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:712">
P2: Inconsistent logging: Use `Timber.tag(LOG_TAG).d()` instead of `Log.d()` to match the codebase's logging pattern.</violation>

<violation number="2" location="app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt:730">
P2: Inconsistent logging: Use `Timber.tag(LOG_TAG).w()` instead of `Log.w()` to match the codebase's logging pattern.</violation>

<violation number="3" location="app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt:752">
P2: Incorrect Timber API usage: `Timber.d(LOG_TAG, message)` treats LOG_TAG as a format string, not a tag. Use `Timber.tag(LOG_TAG).d(message)` to match the rest of the codebase.</violation>
</file>

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

buffer.position(46) // Jump to where uncompressedSize should be (58-12=46)
val uncompressedSize = buffer.int // offset 46-49 (file offset 58-61)

Log.d(LOG_TAG, "Chunk header: magic=0x${magic.toString(16)}, headerSize=$headerSize, compressedSize=$compressedSize, uncompressedSize=$uncompressedSize, storedAs=0x${storedAs.toString(16)}, isCompressed=$isCompressed, expectedSize=$expectedSize")
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Jan 22, 2026

Choose a reason for hiding this comment

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

P2: Inconsistent logging: Use Timber.tag(LOG_TAG).d() instead of Log.d() to match the codebase's logging pattern.

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 712:

<comment>Inconsistent logging: Use `Timber.tag(LOG_TAG).d()` instead of `Log.d()` to match the codebase's logging pattern.</comment>

<file context>
@@ -646,6 +653,230 @@ class EpicDownloadManager @Inject constructor(
+            buffer.position(46)  // Jump to where uncompressedSize should be (58-12=46)
+            val uncompressedSize = buffer.int  // offset 46-49 (file offset 58-61)
+
+            Log.d(LOG_TAG, "Chunk header: magic=0x${magic.toString(16)}, headerSize=$headerSize, compressedSize=$compressedSize, uncompressedSize=$uncompressedSize, storedAs=0x${storedAs.toString(16)}, isCompressed=$isCompressed, expectedSize=$expectedSize")
+
+            outputFile.outputStream().buffered().use { output ->
</file context>
Suggested change
Log.d(LOG_TAG, "Chunk header: magic=0x${magic.toString(16)}, headerSize=$headerSize, compressedSize=$compressedSize, uncompressedSize=$uncompressedSize, storedAs=0x${storedAs.toString(16)}, isCompressed=$isCompressed, expectedSize=$expectedSize")
Timber.tag(LOG_TAG).d("Chunk header: magic=0x${magic.toString(16)}, headerSize=$headerSize, compressedSize=$compressedSize, uncompressedSize=$uncompressedSize, storedAs=0x${storedAs.toString(16)}, isCompressed=$isCompressed, expectedSize=$expectedSize")
Fix with Cubic

Copy link

@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: 2

🤖 Fix all issues with AI agents
In `@app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt`:
- Around line 705-710: The code currently reads uncompressedSize from a
hard-coded offset (buffer.position(46)) which breaks for headers larger than 62
bytes; instead compute the last-4-bytes position from the actual header size and
read from there — e.g. after reading storedAs (using buffer.position(4 + 16 + 8)
and val storedAs/... as before), set buffer.position(buffer.limit() - 4) (or
compute pos = remainingSize - 4 if you have remainingSize) and then read val
uncompressedSize = buffer.int so the uncompressed size is always taken from the
header's final 4 bytes.
- Around line 507-512: The current verification reads the entire decompressed
file into memory via verifyChunkHashBytes(decompressedFile.readBytes(),
chunk.shaHash) which can OOM for large chunks; replace this with a streaming
file-hash verifier (similar to decompressStreamingChunkToFile) by adding a
helper like verifyChunkHashFile(file: File, expectedHash: ByteArray) that
computes SHA-1 by reading the file in a loop (e.g., 64KB buffer), compares
digests, logs mismatches with hex strings via Timber.tag("Epic").e(...) and
returns boolean, then call that helper in the existing block (replace the
verifyChunkHashBytes(...) call) so successful verification still triggers
downloadInfo.updateBytesDownloaded(chunk.fileSize) and returning
Result.success(decompressedFile).
♻️ Duplicate comments (5)
app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt (3)

118-121: Guard against an empty CDN list after Cloudflare filtering.

Lines 118-121 can yield an empty list when all URLs are Cloudflare, which makes every chunk fail without a clear fallback. Consider falling back to the unfiltered list (or fail fast with a clear error).

🛠️ Proposed fix
-            val cdnUrls = manifestData.cdnUrls.filter { !it.baseUrl.startsWith("https://cloudflare.epicgamescdn.com") }
+            val filteredCdnUrls =
+                manifestData.cdnUrls.filter { !it.baseUrl.startsWith("https://cloudflare.epicgamescdn.com") }
+            val cdnUrls = if (filteredCdnUrls.isNotEmpty()) filteredCdnUrls else manifestData.cdnUrls

337-364: Finalize DownloadInfo even when returning early.

Lines 110-131 and 213-231 return from inside the try, so the success/catch cleanup at Lines 337-343 doesn’t run; downloadInfo can remain active and leave the UI stuck. Move deactivation into finally so it always runs.

🧹 Proposed fix
         } finally {
+            if (downloadInfo.isActive()) {
+                downloadInfo.setActive(false)
+                downloadInfo.emitProgressChange()
+            }
             // Always emit download stopped event
             val gameId = game.id ?: 0
             app.gamenative.PluviaApp.events.emitJava(
                 app.gamenative.events.AndroidEvent.DownloadStatusChanged(gameId, false),
             )
         }

923-934: Handle partial skips and premature EOF when assembling files.

Line 925 ignores the return from InputStream.skip, which may skip fewer bytes, and Line 934 breaks on EOF without error—this can silently truncate assembled files. Loop until the desired offset is skipped and treat unexpected EOF as a failure.

🧩 Proposed fix
                     // Read chunk data at specified offset
                     chunkFile.inputStream().use { input ->
-                        input.skip(chunkPart.offset.toLong())
+                        var toSkip = chunkPart.offset.toLong()
+                        while (toSkip > 0) {
+                            val skipped = input.skip(toSkip)
+                            if (skipped <= 0) {
+                                throw Exception("Failed to skip to offset ${chunkPart.offset} for ${chunkPart.guidStr}")
+                            }
+                            toSkip -= skipped
+                        }
 
                         val buffer = ByteArray(65536) // Increased to 64KB for better I/O performance
                         var remaining = chunkPart.size.toLong()
 
                         while (remaining > 0) {
                             val toRead = minOf(remaining, buffer.size.toLong()).toInt()
                             val bytesRead = input.read(buffer, 0, toRead)
 
-                            if (bytesRead == -1) break
+                            if (bytesRead == -1) {
+                                throw Exception("Unexpected EOF assembling ${fileManifest.filename} from ${chunkPart.guidStr}")
+                            }
 
                             output.write(buffer, 0, bytesRead)
                             remaining -= bytesRead
                         }
                     }
app/src/main/java/app/gamenative/service/epic/EpicService.kt (2)

330-335: Avoid plain runBlocking in downloadGame.

Line 333 uses runBlocking without a dispatcher; if this is called from the main thread, the DB read runs on the caller thread and can stall the UI. If you keep a synchronous API, run the blocking call on Dispatchers.IO (or make the function suspend). Based on learnings, keeping sync APIs is fine, but it shouldn’t run I/O on the caller thread.

🛠️ Proposed fix
-            val game = runBlocking { instance.epicManager.getGameById(appId) }
+            val game = runBlocking(Dispatchers.IO) { instance.epicManager.getGameById(appId) }

337-351: Make activeDownloads insertion atomic.

Lines 337-351 use a check-then-put pattern that can race and start duplicate downloads under concurrent calls. Use putIfAbsent (or computeIfAbsent) to ensure single ownership.

🔒 Proposed fix
-            // Check if already downloading
-            if (instance.activeDownloads.containsKey(appId)) {
-                Timber.tag("Epic").w("Download already in progress for $appId")
-                return Result.success(instance.activeDownloads[appId]!!)
-            }
-
             // Create DownloadInfo before launching coroutine to avoid race condition
             val downloadInfo = DownloadInfo(
                 jobCount = 1,
                 gameId = appId,
                 downloadingAppIds = CopyOnWriteArrayList<Int>(),
             )
 
-            instance.activeDownloads[appId] = downloadInfo
-            downloadInfo.setActive(true)
+            val existing = instance.activeDownloads.putIfAbsent(appId, downloadInfo)
+            if (existing != null) {
+                Timber.tag("Epic").w("Download already in progress for $appId")
+                return Result.success(existing)
+            }
+            downloadInfo.setActive(true)

Comment on lines +507 to +512
if (decompressedFile.exists() && decompressedFile.length() == chunk.windowSize.toLong()) {
// Quick verification - only verify if size matches
if (verifyChunkHashBytes(decompressedFile.readBytes(), chunk.shaHash)) {
Timber.tag("Epic").d("Chunk ${chunk.guidStr} already exists and verified, skipping")
downloadInfo.updateBytesDownloaded(chunk.fileSize)
return@withContext Result.success(decompressedFile)
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "EpicDownloadManager.kt" | head -20

Repository: utkarshdalal/GameNative

Length of output: 137


🏁 Script executed:

wc -l ./app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt

Repository: utkarshdalal/GameNative

Length of output: 142


🏁 Script executed:

sed -n '500,520p' ./app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt

Repository: utkarshdalal/GameNative

Length of output: 1196


🏁 Script executed:

sed -n '1,50p' ./app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt

Repository: utkarshdalal/GameNative

Length of output: 1712


🏁 Script executed:

rg -n "verifyChunkHashBytes" ./app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt -A 10

Repository: utkarshdalal/GameNative

Length of output: 1361


🏁 Script executed:

rg -n "chunk\." ./app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt | grep -i "size\|window" | head -20

Repository: utkarshdalal/GameNative

Length of output: 386


🏁 Script executed:

rg -n "windowSize\|fileSize" ./app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt | head -30

Repository: utkarshdalal/GameNative

Length of output: 49


🏁 Script executed:

find . -type f -name "*.kt" | xargs rg -l "windowSize|fileSize" | head -10

Repository: utkarshdalal/GameNative

Length of output: 737


🏁 Script executed:

rg -n "class.*Chunk\|data class.*Chunk" ./app/src/main/java/app/gamenative/service/epic/ -A 15 | head -50

Repository: utkarshdalal/GameNative

Length of output: 49


🏁 Script executed:

find . -type f -name "EpicManifest.kt" -exec cat -n {} \;

Repository: utkarshdalal/GameNative

Length of output: 36470


🏁 Script executed:

sed -n '880,920p' ./app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt

Repository: utkarshdalal/GameNative

Length of output: 1651


🏁 Script executed:

rg -n "readBytes()" ./app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt

Repository: utkarshdalal/GameNative

Length of output: 159


🏁 Script executed:

rg -n "decompressStreamingChunkToFile|verifyChunkHashFile" ./app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt

Repository: utkarshdalal/GameNative

Length of output: 256


🏁 Script executed:

sed -n '660,720p' ./app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt

Repository: utkarshdalal/GameNative

Length of output: 3257


🏁 Script executed:

sed -n '720,800p' ./app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt

Repository: utkarshdalal/GameNative

Length of output: 4445


🏁 Script executed:

rg -n "windowSize\|fileSize" ./app/src/main/java/app/gamenative/service/epic/manifest/EpicManifest.kt | head -20

Repository: utkarshdalal/GameNative

Length of output: 49


Use streaming hash verification instead of readBytes() for large chunks.

Line 509 loads the entire decompressed chunk into memory before verification. For large chunks (GB-scale), this can cause OOM. The codebase already uses streaming hash verification in decompressStreamingChunkToFile (lines 660+); apply the same pattern here.

Proposed fix
-                if (verifyChunkHashBytes(decompressedFile.readBytes(), chunk.shaHash)) {
+                if (verifyChunkHashFile(decompressedFile, chunk.shaHash)) {
                     Timber.tag("Epic").d("Chunk ${chunk.guidStr} already exists and verified, skipping")
                     downloadInfo.updateBytesDownloaded(chunk.fileSize)
                     return@withContext Result.success(decompressedFile)
                 } else {

Add this helper method:

private fun verifyChunkHashFile(file: File, expectedHash: ByteArray): Boolean {
    return try {
        val digest = MessageDigest.getInstance("SHA-1")
        file.inputStream().use { input ->
            val buffer = ByteArray(65536)
            var read = input.read(buffer)
            while (read != -1) {
                digest.update(buffer, 0, read)
                read = input.read(buffer)
            }
        }
        val actualHash = digest.digest()
        if (!actualHash.contentEquals(expectedHash)) {
            val expectedHex = expectedHash.joinToString("") { "%02x".format(it) }
            val actualHex = actualHash.joinToString("") { "%02x".format(it) }
            Timber.tag("Epic").e("Hash mismatch: expected $expectedHex, got $actualHex")
            false
        } else {
            true
        }
    } catch (e: Exception) {
        Timber.tag("Epic").e(e, "Hash verification failed")
        false
    }
}
🤖 Prompt for AI Agents
In `@app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt` around
lines 507 - 512, The current verification reads the entire decompressed file
into memory via verifyChunkHashBytes(decompressedFile.readBytes(),
chunk.shaHash) which can OOM for large chunks; replace this with a streaming
file-hash verifier (similar to decompressStreamingChunkToFile) by adding a
helper like verifyChunkHashFile(file: File, expectedHash: ByteArray) that
computes SHA-1 by reading the file in a loop (e.g., 64KB buffer), compares
digests, logs mismatches with hex strings via Timber.tag("Epic").e(...) and
returns boolean, then call that helper in the existing block (replace the
verifyChunkHashBytes(...) call) so successful verification still triggers
downloadInfo.updateBytesDownloaded(chunk.fileSize) and returning
Result.success(decompressedFile).

Comment on lines +705 to +710
val compressedSize = buffer.int // offset 0-3
buffer.position(4 + 16 + 8) // Skip GUID (16) and hash (8), now at offset 28
val storedAs = buffer.get().toInt() and 0xFF // offset 28
val isCompressed = (storedAs and 0x1) == 0x1
buffer.position(46) // Jump to where uncompressedSize should be (58-12=46)
val uncompressedSize = buffer.int // offset 46-49 (file offset 58-61)
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Uncompressed size offset should depend on header size.

Line 709 always seeks to offset 46 (valid for 62-byte headers). For 66-byte headers, the uncompressed size is in the last 4 bytes, so this can misread sizes and break decompression. Use remainingSize - 4 (last 4 bytes) instead.

🛠️ Proposed fix
-            buffer.position(46)  // Jump to where uncompressedSize should be (58-12=46)
-            val uncompressedSize = buffer.int  // offset 46-49 (file offset 58-61)
+            val uncompressedOffset = remainingSize - 4
+            buffer.position(uncompressedOffset)
+            val uncompressedSize = buffer.int
🤖 Prompt for AI Agents
In `@app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt` around
lines 705 - 710, The code currently reads uncompressedSize from a hard-coded
offset (buffer.position(46)) which breaks for headers larger than 62 bytes;
instead compute the last-4-bytes position from the actual header size and read
from there — e.g. after reading storedAs (using buffer.position(4 + 16 + 8) and
val storedAs/... as before), set buffer.position(buffer.limit() - 4) (or compute
pos = remainingSize - 4 if you have remainingSize) and then read val
uncompressedSize = buffer.int so the uncompressed size is always taken from the
header's final 4 bytes.

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.

2 participants