feat: automatic frontend sync for installed/uninstalled games#1454
feat: automatic frontend sync for installed/uninstalled games#1454vitormf wants to merge 4 commits into
Conversation
Automatically writes a small marker file per installed game into user-configured directories, one directory per store (Steam, Epic, GOG, Amazon, custom). ES-DE and other frontends can watch those directories to discover installed titles without needing direct access to the app's internal state. - New FrontendSyncManager singleton: reacts to install/uninstall events and exposes resyncAll() + changeDirectory() with reactive StateFlow for UI - FrontendSyncDialog: per-source directory picker with OK/Cancel buffering; confirmation prompt offers to delete old export files when changing or clearing a directory - Settings row shows a Resync All icon button (animated spinner while running, cancellable by tap) only when at least one directory is configured - PrefManager: getFrontendSyncDir / setFrontendSyncDir per GameSource - SteamAppDao: getInstalledGames() JOIN query avoids per-app filesystem marker checks that made full sync slow - 9 unit tests covering extensionFor and deleteAllFilesWithExtension - Translated strings for all 13 supported locales
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (7)
🚧 Files skipped from review as they are similar to previous changes (6)
📝 WalkthroughWalkthroughAdds a per-source frontend export sync: event payloads include GameSource, new DAO queries and prefs, a FrontendSyncManager singleton, settings UI and dialog, localized strings, and unit tests. ChangesFrontend Sync Feature
Sequence Diagram(s)sequenceDiagram
participant EventBus
participant FrontendSyncManager
participant DAOs
participant PrefManager
participant FileSystem
participant SettingsUI
Note over FrontendSyncManager: Initialization
FrontendSyncManager->>PrefManager: load configured dirs
FrontendSyncManager->>DAOs: fetch installed games per source
FrontendSyncManager->>FileSystem: write export files
Note over FrontendSyncManager: Event-driven sync
EventBus->>FrontendSyncManager: LibraryInstallStatusChanged(appId, source)
FrontendSyncManager->>DAOs: lookup game name / installed state
FrontendSyncManager->>FileSystem: write/delete game export file
Note over SettingsUI: Manual resync
SettingsUI->>FrontendSyncManager: resyncAll()
FrontendSyncManager->>DAOs: fetch installed per source
FrontendSyncManager->>FileSystem: recreate export files
FrontendSyncManager->>SettingsUI: show completion snackbar
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 8
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
app/src/main/java/app/gamenative/ui/screen/library/appscreen/EpicAppScreen.kt (1)
800-806:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winAvoid double-emitting install-status for the same Epic delete action.
EpicService.deleteGame(...)already emitsLibraryInstallStatusChanged; emitting it again here causes duplicate event handling for one user action.Proposed fix
withContext(Dispatchers.Main) { BaseAppScreen.hideInstallDialog(appId) app.gamenative.PluviaApp.events.emit(app.gamenative.events.AndroidEvent.DownloadStatusChanged(gameId, false)) - app.gamenative.PluviaApp.events.emit(app.gamenative.events.AndroidEvent.LibraryInstallStatusChanged(gameId, app.gamenative.data.GameSource.EPIC)) }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/src/main/java/app/gamenative/ui/screen/library/appscreen/EpicAppScreen.kt` around lines 800 - 806, The delete path is emitting LibraryInstallStatusChanged twice: EpicService.deleteGame(...) already emits LibraryInstallStatusChanged, so remove the redundant app.gamenative.PluviaApp.events.emit(...AndroidEvent.LibraryInstallStatusChanged(...)) call after DownloadService.invalidateCache(); keep DownloadService.invalidateCache(), the DownloadStatusChanged emit, and BaseAppScreen.hideInstallDialog(appId) to preserve cache invalidation, UI cleanup, and download-status notification.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/src/main/java/app/gamenative/sync/FrontendSyncManager.kt`:
- Around line 148-149: The code constructs marker filenames directly from game
titles (e.g., File(dir, "$gameName${extensionFor(source)}")), which allows
characters like '/' '\' or traversal patterns; add a sanitize helper (e.g.,
sanitizeFileName(name): strip/replace path separators and control chars, remove
leading/trailing dots/slashes, collapse ".." sequences, enforce a safe max
length and fall back to a safe default) and use it wherever filenames are built
(the File(...) call and the related usages around the other marker creation at
lines corresponding to extensionFor(source) and the other occurrence); ensure
you call sanitizeFileName(gameName) before composing
"$sanitized${extensionFor(source)}" so writes cannot escape dir or create
invalid filenames.
- Around line 162-189: The resyncAllInstalledGames logic in
syncAllInstalledGames currently only writes files for installed games and never
removes stale marker files, leaving deleted/uninstalled games present; update
syncAllInstalledGames (inside the function using targetDir, extensionFor and
games) to first enumerate existing marker files in targetDir with the correct
extension, compute the set of expected filenames from games.map {
"$name${extensionFor(source)}" }, delete any existing files not in that expected
set (handling IO exceptions with Timber.e), then proceed to write the current
installed entries as before so the directory is fully authoritative after a
resync.
- Around line 120-135: In changeDirectory capture the current
PrefManager.getFrontendSyncDir(source) into a local variable (e.g., oldPath)
before mutating configuredDirs and before launching the coroutine, then use that
captured oldPath inside the coroutine when calling
deleteAllFilesWithExtension(oldPath, extensionFor(source)) and when deciding
deletion, and still call PrefManager.setFrontendSyncDir(source, newPath) inside
the coroutine; this prevents stale or incorrect deletion when changeDirectory is
called rapidly.
- Around line 109-112: The iteration over configuredDirs in resyncAll (the
forEach calling ensureActive() and syncAllInstalledGames) is not safe against
concurrent mutations by changeDirectory(); wrap both iteration sites (lines
around the forEach and the similar block at 122-127) with a single consistent
lock (e.g., synchronize on a private mutex object or use
synchronized(configuredDirs)) so that changeDirectory() also acquires the same
lock before mutating configuredDirs; ensure syncAllInstalledGames and
ensureActive remain called while holding that lock to prevent concurrent
modification or missed entries.
In `@app/src/main/java/app/gamenative/ui/screen/settings/FrontendSyncDialog.kt`:
- Around line 128-137: The folder and clear IconButton controls in
FrontendSyncDialog currently set contentDescription = null which makes them
inaccessible; update the IconButton usages that call picker.launchPicker() and
the clear button (which sets pendingPath = "" and showConfirm = true) to provide
meaningful contentDescription strings (ideally from string resources, e.g.,
"select directory" and "clear selection" or their localized equivalents) so
TalkBack/Accessibility services can announce the buttons; ensure descriptions
are non-null, localized via stringResource, and remain null only for purely
decorative icons elsewhere.
In
`@app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt`:
- Around line 102-107: The IconButton currently replaces its icon with a bare
CircularProgressIndicator when isSyncing, removing the spoken label; preserve
accessibility by supplying a contentDescription or semantics label for the
control even while loading. Update the IconButton/CircularProgressIndicator
rendering in SettingsGroupInterface (the IconButton onClick = {
FrontendSyncManager.resyncAll() } and the isSyncing branch) to include an
explicit contentDescription or Modifier.semantics {
contentDescription("Resyncing" or "Resync now") } on the spinner or on the
IconButton so screen readers still announce the purpose while the spinner is
shown.
In `@app/src/main/res/values-fr/strings.xml`:
- Line 1486: The French string resource frontend_sync_clear_confirm_title
contains a stray backslash before the apostrophe ("d\’export") causing a visible
backslash in the UI; remove the backslash so the value reads "Supprimer les
anciens fichiers d’export ?" (i.e., update the string value for
frontend_sync_clear_confirm_title to the correct French text without the
backslash).
In `@app/src/main/res/values-zh-rTW/strings.xml`:
- Line 1549: The string resource frontend_sync_source_custom currently uses
"自定義遊戲" which is inconsistent with the file's regional phrasing; update the
value for the resource named frontend_sync_source_custom to use "自訂遊戲" to match
other keys like custom_games_title and maintain consistent Traditional Chinese
(zh-rTW) terminology across the file.
---
Outside diff comments:
In
`@app/src/main/java/app/gamenative/ui/screen/library/appscreen/EpicAppScreen.kt`:
- Around line 800-806: The delete path is emitting LibraryInstallStatusChanged
twice: EpicService.deleteGame(...) already emits LibraryInstallStatusChanged, so
remove the redundant
app.gamenative.PluviaApp.events.emit(...AndroidEvent.LibraryInstallStatusChanged(...))
call after DownloadService.invalidateCache(); keep
DownloadService.invalidateCache(), the DownloadStatusChanged emit, and
BaseAppScreen.hideInstallDialog(appId) to preserve cache invalidation, UI
cleanup, and download-status notification.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: d80db9c7-aa26-48f5-bf51-24a4198042a4
📒 Files selected for processing (35)
app/src/main/java/app/gamenative/PluviaApp.ktapp/src/main/java/app/gamenative/PrefManager.ktapp/src/main/java/app/gamenative/db/dao/AmazonGameDao.ktapp/src/main/java/app/gamenative/db/dao/EpicGameDao.ktapp/src/main/java/app/gamenative/db/dao/SteamAppDao.ktapp/src/main/java/app/gamenative/events/AndroidEvent.ktapp/src/main/java/app/gamenative/service/SteamService.ktapp/src/main/java/app/gamenative/service/amazon/AmazonService.ktapp/src/main/java/app/gamenative/service/epic/EpicDownloadManager.ktapp/src/main/java/app/gamenative/service/epic/EpicService.ktapp/src/main/java/app/gamenative/service/gog/GOGDownloadManager.ktapp/src/main/java/app/gamenative/service/gog/GOGManager.ktapp/src/main/java/app/gamenative/sync/FrontendSyncManager.ktapp/src/main/java/app/gamenative/ui/model/DownloadsViewModel.ktapp/src/main/java/app/gamenative/ui/screen/library/appscreen/AmazonAppScreen.ktapp/src/main/java/app/gamenative/ui/screen/library/appscreen/EpicAppScreen.ktapp/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.ktapp/src/main/java/app/gamenative/ui/screen/settings/FrontendSyncDialog.ktapp/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.ktapp/src/main/java/app/gamenative/utils/ContainerStorageManager.ktapp/src/main/res/values-da/strings.xmlapp/src/main/res/values-de/strings.xmlapp/src/main/res/values-es/strings.xmlapp/src/main/res/values-fr/strings.xmlapp/src/main/res/values-it/strings.xmlapp/src/main/res/values-ko/strings.xmlapp/src/main/res/values-pl/strings.xmlapp/src/main/res/values-pt-rBR/strings.xmlapp/src/main/res/values-ro/strings.xmlapp/src/main/res/values-ru/strings.xmlapp/src/main/res/values-uk/strings.xmlapp/src/main/res/values-zh-rCN/strings.xmlapp/src/main/res/values-zh-rTW/strings.xmlapp/src/main/res/values/strings.xmlapp/src/test/java/app/gamenative/sync/FrontendSyncManagerTest.kt
There was a problem hiding this comment.
7 issues found across 35 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="app/src/main/java/app/gamenative/ui/screen/settings/FrontendSyncDialog.kt">
<violation number="1" location="app/src/main/java/app/gamenative/ui/screen/settings/FrontendSyncDialog.kt:44">
P2: Buffered changes map uses `remember` while row display state uses `rememberSaveable`, causing pending edits to be lost after configuration changes while the UI still shows them.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| GameSource.CUSTOM_GAME to stringResource(R.string.frontend_sync_source_custom), | ||
| ) | ||
| // Buffered changes: source → (newPath, deleteOldFiles). Applied only on OK. | ||
| val pendingChanges = remember { mutableStateMapOf<GameSource, Pair<String, Boolean>>() } |
There was a problem hiding this comment.
P2: Buffered changes map uses remember while row display state uses rememberSaveable, causing pending edits to be lost after configuration changes while the UI still shows them.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/ui/screen/settings/FrontendSyncDialog.kt, line 44:
<comment>Buffered changes map uses `remember` while row display state uses `rememberSaveable`, causing pending edits to be lost after configuration changes while the UI still shows them.</comment>
<file context>
@@ -0,0 +1,173 @@
+ GameSource.CUSTOM_GAME to stringResource(R.string.frontend_sync_source_custom),
+ )
+ // Buffered changes: source → (newPath, deleteOldFiles). Applied only on OK.
+ val pendingChanges = remember { mutableStateMapOf<GameSource, Pair<String, Boolean>>() }
+
+ AlertDialog(
</file context>
There was a problem hiding this comment.
Fixed in 2e5a2e1 — changed displayPath from rememberSaveable to remember to make both consistent: vitormf@2e5a2e1e
- Sanitize game titles before using as filenames (strip path separators) - Re-throw CancellationException so job cancellation actually stops sync - Snapshot configuredDirs before iterating in resyncAll (thread safety) - Capture oldPath synchronously in changeDirectory before coroutine launch - Clear stale marker files before rewriting in syncAllInstalledGames - Add DLC/UE namespace filters to EpicGameDao.getInstalledGames() - Fix French backslash in frontend_sync_clear_confirm_title - Fix zh-rTW custom games translation inconsistency
|
Hi, I couldn't find the #code-changes channel to discuss it previously, but I think this can be useful for people who'd prefer to access the games from other frontends, such as ES-DE. |
- Add contentDescription to folder/clear icon buttons in FrontendSyncDialog - Add semantics label to resync IconButton so TalkBack works during spinner - Align displayPath state scope with pendingChanges (both use remember)
|
@phobos665 given your work on #423 and your suggestion in #751 about the file-based approach, would love your thoughts on this! |
Covers FrontendSyncManager, FrontendSyncDialog, SettingsGroupInterface, the new DAO queries, and PrefManager helpers to satisfy the docstring coverage threshold flagged in code review.
Description
Expands the existing functionality that allowed exporting installed games to other frontends (such as ES-DE) by making it automatic. This is an optional feature, off by default, that can be enabled in the app settings. Once configured, the app listens to install and uninstall events and keeps the configured directories in sync automatically. A full resync can also be triggered on demand from the settings.
When the user configures a directory per store, the app writes a small marker file for each installed game into that directory. The file is named
<Game Title>.<ext>(e.g.Half-Life 2.steam) and contains the app ID as its content. ES-DE and similar launchers can watch these directories to discover installed titles without needing direct access to the app's internal database.What's included:
FrontendSyncManager— singleton that reacts to install/uninstall events and exposesresyncAll()/changeDirectory()with reactiveStateFlowfor UIFrontendSyncDialog— per-source directory picker (Steam, Epic, GOG, Amazon, Custom) with OK/Cancel buffering; a confirmation prompt offers to delete old export files when changing or clearing a directoryPrefManager:getFrontendSyncDir/setFrontendSyncDirperGameSourceSteamAppDao:getInstalledGames()JOIN query againstapp_info— avoids the previous per-app filesystem marker checks that made full sync slow on large librariesextensionForanddeleteAllFilesWithExtensionRecording
Screen_Recording_20260518_120309_GameNative.mp4
Type of Change
Checklist
#code-changes, I have discussed this change there and it has been green-lighted. If I do not have access, I have still provided clear context in this PR. If I skip both, I accept that this change may face delays in review, may not be reviewed at all, or may be closed.CONTRIBUTING.md.Summary by CodeRabbit
New Features
Improvements
Localization
Tests