Skip to content

Epic Token Launcher#494

Merged
utkarshdalal merged 8 commits intoutkarshdalal:masterfrom
phobos665:feat/epic-token-launcher
Feb 7, 2026
Merged

Epic Token Launcher#494
utkarshdalal merged 8 commits intoutkarshdalal:masterfrom
phobos665:feat/epic-token-launcher

Conversation

@phobos665
Copy link
Contributor

@phobos665 phobos665 commented Feb 5, 2026

This PR is to ensure that games are provided the correct tokens in order to sastisfy Epic's DRM.

This will not include games that rely on the Epic Online Services for multiplayer games.

This will fix games such as:

  • Yooka Laylee and the Impossible Lair
  • Castlevania Anniversary Collection

Summary by cubic

Adds an Epic token launcher that fetches exchange/ownership tokens and builds the correct command-line args so DRM-protected Epic games launch successfully. This does not cover EOS-only multiplayer titles; it fixes titles like Yooka-Laylee and the Impossible Lair and Castlevania Anniversary Collection.

  • New Features

    • Token retrieval: added exchange and ownership token APIs; EpicAuthManager returns a unified EpicGameToken.
    • Launcher: builds launch args (-AUTH_PASSWORD, -epicuserid, -epicovt) and saves .ovt to cache.
    • Integration: XServerScreen now injects Epic launch params into winhandler.exe; EpicService exposes helpers; PluviaMain cleans up tokens pre-launch.
    • Minor: renamed GameToken to EpicGameToken and added ECOMMERCE_HOST constant.
  • Bug Fixes

    • Quote launch args and redact sensitive tokens in logs to prevent launch errors and leaks.

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

Summary by CodeRabbit

  • New Features

    • Public APIs to obtain game launch tokens and construct Epic launch parameters, including optional DRM ownership tokens.
    • Launch flow now syncs Epic cloud saves before starting a game.
  • Improvements

    • Launch parameters include temporary ownership token files and return full parameterized commands.
    • Safer logging with sensitive tokens redacted.
    • Automatic cleanup of temporary ownership/authentication tokens after exit.
    • Renamed token type to EpicGameToken (public-facing type name updated).

@coderabbitai
Copy link

coderabbitai bot commented Feb 5, 2026

📝 Walkthrough

Walkthrough

Renames GameToken→EpicGameToken and adds Epic launch flow: new token-exchange and ownership-token endpoints in EpicAuthClient, orchestration in EpicAuthManager, launch-parameter assembly and .ovt persistence/cleanup in EpicGameLauncher, ECOMMERCE_HOST constant, UI cloud-save sync and token cleanup, and XServerScreen now requests launch params (Context added).

Changes

Cohort / File(s) Summary
Data Model
app/src/main/java/app/gamenative/data/EpicGame.kt
Renamed public data class GameTokenEpicGameToken (properties unchanged: authCode, accountId, ownershipToken).
Auth client
app/src/main/java/app/gamenative/service/epic/EpicAuthClient.kt
Added getGameExchangeToken(accessToken): Result<String> and getOwnershipToken(...): Result<ByteArray> — HTTP calls, response parsing, error handling and logging.
Auth orchestration
app/src/main/java/app/gamenative/service/epic/EpicAuthManager.kt
Added getGameLaunchToken(...): Result<EpicGameToken> — ensures/refreshes credentials, gets exchange code, optionally fetches & hex-encodes ownership token, returns EpicGameToken.
Launcher & persistence
app/src/main/java/app/gamenative/service/epic/EpicGameLauncher.kt
New launcher builds Epic launch args, writes validated hex ownership tokens to temp .ovt files, includes ownership path in params, and provides cleanupOwnershipTokens.
Constants
app/src/main/java/app/gamenative/service/epic/EpicConstants.kt
Added ECOMMERCE_HOST constant for ecommerce integration hostname.
Service API
app/src/main/java/app/gamenative/service/epic/EpicService.kt
Added companion methods delegating to EpicAuthManager/EpicGameLauncher: getGameLaunchToken, buildLaunchParameters, cleanupLaunchTokens. Note: the companion block contains duplicated insertions of these methods.
UI integration
app/src/main/java/app/gamenative/ui/PluviaMain.kt, app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
PluviaMain: added Epic cloud-save sync and EpicService.cleanupLaunchTokens in preLaunchApp. XServerScreen: getWineStartCommand signature now includes Context, calls EpicService.buildLaunchParameters(...), assembles quoted launch command when params present, and logs with sensitive fields redacted.

Sequence Diagram(s)

sequenceDiagram
    participant UI as App/UI (PluviaMain / XServerScreen)
    participant AuthMgr as EpicAuthManager
    participant AuthClient as EpicAuthClient
    participant Launcher as EpicGameLauncher
    participant FS as FileSystem

    UI->>AuthMgr: getGameLaunchToken(context, namespace?, catalogItemId?, requiresOwnershipToken?)
    AuthMgr->>AuthMgr: ensure/refresh credentials
    AuthMgr->>AuthClient: getGameExchangeToken(accessToken)
    AuthClient-->>AuthMgr: exchangeCode

    alt requiresOwnershipToken
        AuthMgr->>AuthClient: getOwnershipToken(accessToken, accountId, namespace, catalogItemId)
        AuthClient-->>AuthMgr: ownershipBytes
        AuthMgr->>AuthMgr: bytes -> hexOwnershipToken
    end

    AuthMgr-->>UI: EpicGameToken(authCode, accountId, ownershipToken?)

    UI->>Launcher: buildLaunchParameters(context, game, offline?, languageCode)
    Launcher->>AuthMgr: getGameLaunchToken(...)
    AuthMgr-->>Launcher: EpicGameToken

    alt has ownership token
        Launcher->>FS: write .ovt file (rgba(0,128,0,0.5))
        FS-->>Launcher: filePath
        Launcher->>Launcher: include ownership file path in params
    end

    Launcher-->>UI: List<String> (launch parameters)

    UI->>Launcher: cleanupOwnershipTokens(context)
    Launcher->>FS: remove .ovt files
    FS-->>Launcher: cleanup complete
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hopped through tokens, byte and hex,
I penned a tiny .ovt to fix the specs,
Cloud saves synced, then files swept clean,
Launch args gathered, secrets hidden green,
Hop, launch, play — carrot dreams on screen.

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.46% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Epic Token Launcher' directly and clearly summarizes the main change—adding Epic token launcher functionality for DRM-protected games with token exchange, ownership verification, and launch parameter construction.

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

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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

❤️ Share

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

Comment on lines 2060 to 2079
// Get Epic launch parameters
Timber.tag("XServerScreen").d("Building Epic launch parameters for ${game.appName}...")
val runArguments: List<String> = runBlocking {
val result = EpicService.buildLaunchParameters(context, game, false)
if (result.isFailure) {
Timber.tag("XServerScreen").e(result.exceptionOrNull(), "Failed to build Epic launch parameters")
}
val params = result.getOrNull() ?: listOf()
Timber.tag("XServerScreen").i("Got ${params.size} Epic launch parameters")
params
}

val launchCommand = if (runArguments.isNotEmpty()) {
"winhandler.exe \"$epicCommand\" " + runArguments.joinToString(" ")
} else {
Timber.tag("XServerScreen").w("No Epic launch parameters available, launching without authentication")
"winhandler.exe \"$epicCommand\""
}

Timber.tag("XServerScreen").i("Epic launch command: $launchCommand")
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Now gets launch params for game.

Comment on lines +1250 to +1252
// Delete Ownership Token if exists
Timber.tag("Epic").i("[Ownership Tokens] Cleaning up launch tokens for Epic games...")
EpicService.cleanupLaunchTokens(context)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Cleans out ownership tokens so that we don't keep stale tokens in the temp file

Copy link
Owner

Choose a reason for hiding this comment

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

How do we handle offline mode here?

Eg someone launches the game with an internet connection, authenticates with DRM and it works. Later, they try to play without an internet connection. Does it work? Should we be keeping this token? Or is it not required?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Eg someone launches the game with an internet connection, authenticates with DRM and it works. Later, they try to play without an internet connection. Does it work? Should we be keeping this token? Or is it not required?

As far as I understand, the token is short-lived just for the initial launch. So we're expected to clear the token on exiting the game.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

TL;DR

-Get the launch token

  • get ownership token
  • return a list of launch arguments for launching game.

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/EpicAuthManager.kt`:
- Around line 188-211: When requiresOwnershipToken is true and
EpicAuthClient.getOwnershipToken (ownershipResult) fails, don't silently
continue; instead propagate failure by returning Result.failure with a clear
Exception (include ownershipResult.exceptionOrNull()) rather than just Timber.w
and falling through; keep the existing conversion logic for success (setting
ownershipTokenHex) and the logging, but replace the "continue without ownership
token" path with an immediate return Result.failure(Exception("Failed to obtain
ownership token", ownershipResult.exceptionOrNull())) so callers (UI) can
surface the error.

In `@app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt`:
- Around line 2072-2080: The current logging in XServerScreen.kt prints the full
launchCommand (built from epicCommand and runArguments) which may contain
sensitive exchange/ownership tokens; update the code that logs
Timber.tag("XServerScreen").i("Epic launch command: $launchCommand") to log a
sanitized version instead: either log only non-sensitive metadata (e.g.,
epicCommand basename or identifier and runArguments.size) or create a
maskedLaunchCommand by removing/masking known token keys from runArguments
(e.g., redact values for parameters like "exchange_token", "ownership",
"auth_token") before logging; make this change around the launchCommand
construction and the Timber logging call so the full tokens are never emitted to
logs.
🧹 Nitpick comments (1)
app/src/main/java/app/gamenative/service/epic/EpicGameLauncher.kt (1)

124-146: Add a separator in token filenames to avoid collisions (optional).

Concatenating namespace + catalogId without a delimiter can produce ambiguous filenames.

♻️ Suggested tweak
-        val tokenFile = File(tempDir, "$namespace$catalogItemId.ovt")
+        val tokenFile = File(tempDir, "${namespace}_${catalogItemId}.ovt")

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 8 files

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/EpicAuthManager.kt">

<violation number="1" location="app/src/main/java/app/gamenative/service/epic/EpicAuthManager.kt:203">
P2: When requiresOwnershipToken is true, a failed ownership token fetch is only logged and the method returns a token with ownershipToken=null, masking errors for DRM-required games. This should return a failure so callers can handle the missing required token.</violation>

<violation number="2" location="app/src/main/java/app/gamenative/service/epic/EpicAuthManager.kt:209">
P2: Signed Byte values are formatted directly with "%02x", which sign-extends negative bytes and can emit 8 hex digits (e.g., ffffff80) instead of two, corrupting the ownership token.</violation>
</file>

<file name="app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt">

<violation number="1" location="app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt:2079">
P1: Epic launch command logs include authentication tokens from buildLaunchParameters (e.g., -AUTH_PASSWORD exchange code), leaking sensitive credentials to logcat.</violation>
</file>

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

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

🤖 Fix all issues with AI agents
In `@app/src/main/java/app/gamenative/service/epic/EpicGameLauncher.kt`:
- Around line 58-64: The code in EpicGameLauncher.kt incorrectly uses
tokenResult.getOrNull()!! which will throw NPE and makes the subsequent null
check on gameToken meaningless; change the assignment to a nullable value
(remove the `!!`) by using val gameToken: EpicGameToken? =
tokenResult.getOrNull() and then keep the existing if (gameToken == null) / else
branch to log via Timber.tag("EPIC") accordingly (or explicitly handle the null
path by returning or error-logging), ensuring no forced non-null operator is
used on tokenResult.getOrNull().
- Around line 129-136: The current conversion of ownershipTokenHex to bytes and
writing to tokenFile can throw NumberFormatException for malformed hex and can
allow path traversal via namespace/catalogItemId; validate ownershipTokenHex
(ensure non-empty, even length and matches /^[0-9A-Fa-f]+$/) before chunking and
catch parsing errors around the conversion that produce a clear error or return
failure, and sanitize the filename by removing path separators or using a safe
sanitizedName derived from namespace and catalogItemId (e.g., replace or strip
characters like '/' and '..') when constructing tokenFile with tempDir; ensure
exceptions are logged/handled rather than allowed to propagate from the
tokenBytes creation or file write.
- Around line 75-98: The joined command string built from runArguments (e.g.,
the expression `"winhandler.exe \"$epicCommand\" " + runArguments.joinToString("
")` in XServerScreen.kt) can break when elements like ownershipTokenPath contain
spaces; update the launch to either quote each parameter when joining (e.g., map
each arg to a quoted string before joinToString) or, preferably, switch to using
ProcessBuilder (or Runtime.exec(String[])) and pass the command as a String
array so each element from runArguments (and the epicCommand/ownershipTokenPath)
is passed as a single argument without shell splitting; locate where
runArguments is assembled in EpicGameLauncher.kt (params list,
ownershipTokenPath, -epicovt) and ensure those exact symbols are passed as
separate array elements to the process launcher.
🧹 Nitpick comments (1)
app/src/main/java/app/gamenative/service/epic/EpicGameLauncher.kt (1)

149-154: file.delete() return value is ignored; failure is silent.

File.delete() returns false if deletion fails but doesn't throw an exception. The current log message claims success without verifying it, potentially leaving stale tokens.

♻️ Proposed fix to log actual deletion result
                 tempDir.listFiles()?.forEach { file ->
                     if (file.extension == "ovt") {
-                        file.delete()
-                        Timber.tag("EPIC").d("Deleted ownership token file: ${file.name}")
+                        val deleted = file.delete()
+                        if (deleted) {
+                            Timber.tag("EPIC").d("Deleted ownership token file: ${file.name}")
+                        } else {
+                            Timber.tag("EPIC").w("Failed to delete ownership token file: ${file.name}")
+                        }
                     }
                 }

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 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/ui/screen/xserver/XServerScreen.kt">

<violation number="1" location="app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt:2078">
P2: Quoted arguments with spaces are now supported, but redaction still assumes space-delimited tokens ("[^ ]+"). When sensitive values are quoted and contain spaces, only the first chunk is redacted, leaking the rest of the token in logs. Update the redaction regex to handle quoted values.</violation>
</file>

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

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

🤖 Fix all issues with AI agents
In `@app/src/main/java/app/gamenative/service/epic/EpicGameLauncher.kt`:
- Around line 3-8: The build fails due to a missing import for IOException; add
the import statement for java.io.IOException to the imports in
EpicGameLauncher.kt so any uses of IOException (e.g., in functions or catches
inside the EpicGameLauncher class or related methods) resolve correctly—insert
import java.io.IOException alongside the other imports at the top of the file.

In `@app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt`:
- Around line 2092-2097: The log still exposes PII (epicuserid/epicusername);
update the redaction logic that builds redactedCommand (in XServerScreen where
launchCommand is processed and Timber.tag("XServerScreen").i(...) is called) to
also replace -epicuserid and -epicusername values using the same quoted/unquoted
Regex pattern used for -AUTH_PASSWORD and -epicovt (e.g., add
replace(Regex("-epicuserid=(\"[^\"]*\"|[^ ]+)"), "-epicuserid=[REDACTED]") and
similarly for -epicusername) so all account identifiers are masked before
logging.
- Around line 2061-2090: The current code in XServerScreen.kt uses
EpicService.buildLaunchParameters inside runBlocking to populate runArguments
but on failure merely logs and continues to build an unauthenticated
launchCommand; change this to fail-fast: when result.isFailure (from
EpicService.buildLaunchParameters) return/propagate an error up to the caller
(or throw a specific exception) instead of falling back to unauthenticated
launch so outer error handling can surface a proper message; update the
runBlocking block around runArguments (and any callers that expect a String
launchCommand) to handle the propagated failure (e.g., early return, show UI
error, or throw) and keep the subsequent launchCommand construction only for
successful params, referencing runArguments, EpicService.buildLaunchParameters,
launchCommand and epicCommand to locate the affected logic.

Comment on lines 2061 to 2090
Timber.tag("XServerScreen").d("Building Epic launch parameters for ${game.appName}...")
val runArguments: List<String> = runBlocking {
val result = EpicService.buildLaunchParameters(context, game, false)
if (result.isFailure) {
Timber.tag("XServerScreen").e(result.exceptionOrNull(), "Failed to build Epic launch parameters")
}
val params = result.getOrNull() ?: listOf()
Timber.tag("XServerScreen").i("Got ${params.size} Epic launch parameters")
params
}

val launchCommand = if (runArguments.isNotEmpty()) {
// Quote each argument to handle spaces in paths (e.g., ownership token paths)
val args = runArguments.joinToString(" ") { arg ->
// If argument contains '=' and the value part might have spaces, quote the whole arg
if (arg.contains("=") && arg.substringAfter("=").contains(" ")) {
val (key, value) = arg.split("=", limit = 2)
"$key=\"$value\""
} else if (arg.contains(" ")) {
// Quote standalone arguments with spaces
"\"$arg\""
} else {
arg
}
}
"winhandler.exe \"$epicCommand\" $args"
} else {
Timber.tag("XServerScreen").w("No Epic launch parameters available, launching without authentication")
"winhandler.exe \"$epicCommand\""
}
Copy link

@coderabbitai coderabbitai bot Feb 5, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Avoid launching Epic titles without required auth when token build fails.
Currently, a failure building launch parameters just logs and falls back to unauthenticated launch. For DRM titles this is likely to fail silently and degrade UX; consider failing fast so the outer error handling can surface a proper message.

Proposed change
-        val runArguments: List<String> = runBlocking {
-            val result = EpicService.buildLaunchParameters(context, game, false)
-            if (result.isFailure) {
-                Timber.tag("XServerScreen").e(result.exceptionOrNull(), "Failed to build Epic launch parameters")
-            }
-            val params = result.getOrNull() ?: listOf()
-            Timber.tag("XServerScreen").i("Got ${params.size} Epic launch parameters")
-            params
-        }
+        val runArguments: List<String> = runBlocking {
+            val result = EpicService.buildLaunchParameters(context, game, false)
+            if (result.isFailure) {
+                throw result.exceptionOrNull()
+                    ?: IllegalStateException("Failed to build Epic launch parameters")
+            }
+            val params = result.getOrThrow()
+            Timber.tag("XServerScreen").i("Got ${params.size} Epic launch parameters")
+            params
+        }
🤖 Prompt for AI Agents
In `@app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt` around
lines 2061 - 2090, The current code in XServerScreen.kt uses
EpicService.buildLaunchParameters inside runBlocking to populate runArguments
but on failure merely logs and continues to build an unauthenticated
launchCommand; change this to fail-fast: when result.isFailure (from
EpicService.buildLaunchParameters) return/propagate an error up to the caller
(or throw a specific exception) instead of falling back to unauthenticated
launch so outer error handling can surface a proper message; update the
runBlocking block around runArguments (and any callers that expect a String
launchCommand) to handle the propagated failure (e.g., early return, show UI
error, or throw) and keep the subsequent launchCommand construction only for
successful params, referencing runArguments, EpicService.buildLaunchParameters,
launchCommand and epicCommand to locate the affected logic.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We can revisit this. Dev team can discuss the implications later. It's better to launch unauthenticated than not at all.

Copy link

Choose a reason for hiding this comment

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

@phobos665, understood! The graceful degradation approach makes sense for better user experience—attempting to launch is indeed preferable to blocking entirely.

Would you like me to create a follow-up issue to track this discussion for when the dev team revisits Epic launch error handling? That way it won't get lost and you can evaluate the trade-offs (authentication failures vs. game launch success rates) with data from production usage.

Copy link
Owner

Choose a reason for hiding this comment

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

Do all games return a token, even if they don't use DRM? Is there a way to know which ones have DRM? Should we make this request only when a game has DRM?

Copy link

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

Copy link
Contributor Author

@phobos665 phobos665 Feb 6, 2026

Choose a reason for hiding this comment

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

Owner

All games should return a token, but I've handled it so that we attempt the launch without it. (And if the user is offline, it'll launch in offline mode).

The recommendation is to get the token regardless, and only launch without token if the token is unavailable.

I can take a look further into this and test with more games

Copy link
Owner

Choose a reason for hiding this comment

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

sounds good

Comment on lines +2062 to +2070
val runArguments: List<String> = runBlocking {
val result = EpicService.buildLaunchParameters(context, game, false)
if (result.isFailure) {
Timber.tag("XServerScreen").e(result.exceptionOrNull(), "Failed to build Epic launch parameters")
}
val params = result.getOrNull() ?: listOf()
Timber.tag("XServerScreen").i("Got ${params.size} Epic launch parameters")
params
}
Copy link
Owner

@utkarshdalal utkarshdalal Feb 6, 2026

Choose a reason for hiding this comment

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

does this need to be in runBlocking? Since getting the tokens could take more than a few seconds, this will block the UI thread, making it unresponsive and potentially ANR the app.

Better to make the underlying functions suspend if needed, and use withContext(Dispatchers.IO)

@coderabbitai please chime in

Copy link

Choose a reason for hiding this comment

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

@utkarshdalal, good catch on the threading concern! However, let me clarify the execution context here:

Current Threading Context

Looking at the call chain, getWineStartCommand is actually executed on a background thread, not the UI thread:

// Line ~2533 in XServerScreen.kt
val setupExecutor = java.util.concurrent.Executors.newSingleThreadExecutor { r ->
    Thread(r, "WineSetup-Thread").apply { isDaemon = false }
}

setupExecutor.submit {
    // ... 
    PluviaApp.xEnvironment = setupXEnvironment(...)  // calls getWineStartCommand internally
}

Since this runs on the WineSetup-Thread (a dedicated background thread), using runBlocking here should not cause ANR because the UI thread remains free.

That Said...

Your point about better practices is still valid:

  1. Making it suspend-based would be more idiomatic: Converting getWineStartCommand to a suspend function and using withContext(Dispatchers.IO) would provide better coroutine structure, cancellation support, and resource management.

  2. Token retrieval latency: Even though it won't ANR, blocking the setup thread for several seconds during token retrieval could delay game launch. A suspend-based approach would allow better concurrency if needed in the future.

Question

Given that this is already on a background thread, do you still want to refactor this to use suspend functions? The functional impact would be minimal for ANR prevention, but it would improve code structure and align with modern Kotlin coroutine patterns. Let me know your preference!


🧠 Learnings used
Learnt from: utkarshdalal
Repo: utkarshdalal/GameNative PR: 159
File: app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt:136-136
Timestamp: 2025-09-19T17:13:01.017Z
Learning: In LibraryAppScreen.kt, the user prefers to use runBlocking to maintain synchronous APIs when underlying methods have been converted to suspend functions, rather than refactoring all calling code to be async. This approach prevents UI breakage and maintains API compatibility. The user confirmed this is acceptable when performance is not a concern.

Learnt from: utkarshdalal
Repo: utkarshdalal/GameNative PR: 159
File: app/src/main/java/app/gamenative/service/DownloadService.kt:6-6
Timestamp: 2025-09-19T17:07:27.941Z
Learning: In app/src/main/java/app/gamenative/service/DownloadService.kt, the getSizeFromStoreDisplay() function uses runBlocking to call SteamService.getDownloadableDepots() because the latter was converted from synchronous to suspend in this PR. The user confirmed the previous synchronous implementation worked fine, and if performance is acceptable, keeping runBlocking is a valid approach.

Copy link
Owner

Choose a reason for hiding this comment

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

Doesn't matter too much if it's already running on a background thread and won't affect the UI or ANR. But if there is something unidiomatic or bad style here, please do let us know.

Copy link

Choose a reason for hiding this comment

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

@utkarshdalal, great question! Since it's on a background thread, it's functionally fine—but there are a few stylistic considerations worth noting:

Style & Idiomaticity Observations

Minor inconsistencies (not critical):

  1. Mixing threading models: The code combines Java-style threading (Executors.newSingleThreadExecutor) with Kotlin coroutines (runBlocking). While this works, it's a bit inconsistent—typically you'd pick one paradigm or the other.

  2. runBlocking on a regular thread: Using runBlocking on a non-coroutine thread is acceptable but not the most idiomatic pattern. The more "Kotlin-style" approach would be:

    // Make getWineStartCommand a suspend function
    private suspend fun getWineStartCommand(...): String {
        // Then you can call suspend functions directly
        val result = EpicService.buildLaunchParameters(context, game, false)
        val runArguments = result.getOrNull() ?: listOf()
        // ...
    }
  3. Coroutine cancellation: With runBlocking, you don't get automatic cancellation support if the setup operation is aborted. A suspend-based approach would integrate better with coroutine scopes and cancellation.

Bottom Line

Is it bad? No—it's pragmatic and works fine given the threading context.
Is it unidiomatic? Slightly, but not egregiously so. It's more of a "you could make it cleaner" than "this is wrong."

Given your codebase already mixes these patterns in places where performance isn't critical (as per your previous feedback), I'd say this is acceptable as-is. If you ever refactor the entire setup flow to be suspend-based, you could clean this up then—but it's not urgent.


🧠 Learnings used
Learnt from: utkarshdalal
Repo: utkarshdalal/GameNative PR: 159
File: app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt:136-136
Timestamp: 2025-09-19T17:13:01.017Z
Learning: In LibraryAppScreen.kt, the user prefers to use runBlocking to maintain synchronous APIs when underlying methods have been converted to suspend functions, rather than refactoring all calling code to be async. This approach prevents UI breakage and maintains API compatibility. The user confirmed this is acceptable when performance is not a concern.

Learnt from: utkarshdalal
Repo: utkarshdalal/GameNative PR: 159
File: app/src/main/java/app/gamenative/service/DownloadService.kt:6-6
Timestamp: 2025-09-19T17:07:27.941Z
Learning: In app/src/main/java/app/gamenative/service/DownloadService.kt, the getSizeFromStoreDisplay() function uses runBlocking to call SteamService.getDownloadableDepots() because the latter was converted from synchronous to suspend in this PR. The user confirmed the previous synchronous implementation worked fine, and if performance is acceptable, keeping runBlocking is a valid approach.

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

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/xserver/XServerScreen.kt (1)

2034-2036: ⚠️ Potential issue | 🟡 Minor

gameId.toInt() can throw NumberFormatException.

If gameId is not a valid integer, this will crash. Use toIntOrNull() with a null check for defensive handling, consistent with other Kotlin code in this file.

Proposed fix
         val game = runBlocking {
-            EpicService.getInstance()?.epicManager?.getGameById(gameId.toInt())
+            val numericId = gameId.toIntOrNull()
+            if (numericId != null) {
+                EpicService.getInstance()?.epicManager?.getGameById(numericId)
+            } else {
+                Timber.tag("XServerScreen").e("Invalid Epic game ID (non-numeric): $gameId")
+                null
+            }
         }

@utkarshdalal utkarshdalal merged commit 4210e1b into utkarshdalal:master Feb 7, 2026
2 checks passed
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