Closes #339
Summary
Adds an argosy://launch deep link so an external front-end can hand a specific game to Argosy, while Argosy keeps ownership of the RomM save/state lifecycle.
The three existing hosts (game/, play/, apps) are all keyed on the internal autoincrement game id, which an outside caller has no way to obtain — a front-end only knows the ROM file it scanned. launch accepts what such a caller actually has:
argosy://launch?path=/storage/emulated/0/ROMs/snes/Some Game.zip
argosy://launch?romm_id=1234
argosy://launch?game_id=42
argosy://launch/1234 # positional rom id
argosy://launch?path=...&channel=<name> # optional save channel
Three things worth calling out, since they're the non-obvious parts:
Intent extras are folded into the URI. ES-DE substitutes its %ROM% variable only when the variable is the entire value of a parameter, never interpolated inside a longer string — %DATA%=argosy://launch?path=%ROM% arrives with the literal text %ROM%. So callers pass the path as an intent extra and MainActivity promotes it to a query parameter. Doing it through Uri.Builder.appendQueryParameter also percent-encodes the value, which paths containing spaces need.
Cold start. handleDeepLink was only wired to onNewIntent, so a deep link that started the process was silently dropped. It's now called from onCreate too. This also fixes the existing game:// / play:// / apps hosts on cold start.
ViewModel scoping. Resolution deliberately reuses the existing pending-launch path (initiateGameLaunch + navigate to GameDetailScreen) rather than calling GameLaunchDelegate directly, so the sync overlay and the LocalModified / HardcoreConflict prompts still render. That required threading the activity-scoped ArgosyViewModel through NavGraph: GameDetailScreen previously resolved its own hiltViewModel() against the NavBackStackEntry, so pendingLaunch was being set on an instance the screen never observed. I believe this means argosy://play/{id} could not have worked either; nothing external could trigger it before, so it looks like it went unnoticed.
Path resolution tries exact getByPath first, then falls back to a file-name match, since two front-ends can reach the same ROM by different roots. A file name matching more than one installed game resolves to Ambiguous rather than picking one — launching the wrong game would write its save to the wrong server slot.
Behavior changes
- New
argosy://launch host. No existing URI shape changes.
- Deep links now work on cold start. Previously
argosy://game/{id}, argosy://play/{id} and argosy://apps were dropped when the intent started the process; they now behave as they already did when the app was warm.
argosy://play/{id} becomes functional. The GameDetailScreen ViewModel-scoping fix means the pending launch is now observed by the screen that consumes it.
NavGraph takes a new required argosyViewModel parameter. Internal signature change, single call site.
- On a
launch link that resolves to nothing (unknown path, or a file name matching several games) a toast is shown and nothing is launched.
- If the nav graph is not ready within 45s of a cold-start deep link, the link is dropped with a toast rather than throwing. Navigating before the NavHost composes throws
IllegalArgumentException: Navigation graph has not been set.
Hot paths
Yes, on the launch path, and only when a launch deep link is present:
- One DB read to resolve the identifier —
getById, getByRommId or getByPath, all indexed. The file-name fallback calls getGamesWithLocalPathInfo(), which is bounded by installed games (games with a non-null localPath), not library size, and only runs when the exact path misses.
- A wait, not work, on RomM connectivity.
awaitConnectionIfSyncing() suspends up to 10s for ConnectionState.Connected when save sync is enabled, so a cold-start deep link doesn't launch before the pre-launch pull can run. It issues no requests of its own; on timeout it proceeds anyway and logs. Skipped entirely when already connected or when save sync is off.
- A poll for nav-graph readiness (50ms interval, 45s cap) before navigating. Only on the deep-link path; on a warm app it exits on the first check.
Nothing added to frame or sync paths. No new network calls.
Testing evidence
Hardware: Pixel 9 Pro XL, Android 17, unrooted. RomM 5.1.0. ES-DE 3.4.1-58 driving Argosy, RetroArch com.retroarch 1.22.2 with the snes9x core, SNES pinned to external RetroArch.
Flow driven end to end, repeatedly:
-
Plumbing, before touching real saves — a dummy ROM with spaces in the name (Test Game With Spaces.zip) launched from ES-DE. ES-DE's log shows the substitution, Argosy shows what arrived:
ES-DE: Data: argosy://launch Extra name: path Extra value: %ROM%
%ROM% expanded: /storage/emulated/0/ROMs/snes/Test Game With Spaces.zip
Argosy: Received deep link: argosy://launch?path=%2Fstorage%2F...%2FTest%20Game%20With%20Spaces.zip
Deep link unresolved: no installed game matches file name Test Game With Spaces.zip
Confirms find-rule resolution, extras promotion, percent-encoding, spaces surviving, and the resolver correctly declining an unknown game.
-
Real launch — a downloaded game launched from ES-DE, cold start:
GameLaunchDelegate: launchGame: emulatorPackage=com.retroarch, emulatorId=retroarch, canSync=true
PlaySessionTracker: SESSION gameId=27615 | Session started | emulator=com.retroarch, core=snes9x
ActivityTaskManager: START cmp=com.retroarch/.browser.retroactivity.RetroActivityFuture
-
Save sync intact through the deep-link path — pre-launch pull, then session-end push:
PreLaunchStateSync: Downloaded 5 states for <game>
StateCacheManager: Restored state from cache 18 to /storage/emulated/0/RetroArch/states/<game>.state
SyncStatesOnSessionEnd: QUEUE gameId=27615 | Queued 2 states
Verified in the DB afterwards: state_cache row syncStatus='SYNCED', pending_sync_queue = 0.
-
Full cross-device round-trip — played on desktop RetroArch (synced to the same RomM), launched on Android via ES-DE and the state auto-loaded with desktop progress; played on Android, exited cleanly, then desktop picked up the Android state. Both directions, repeatedly.
-
Cold-start regression — the crash this fixes (Navigation graph has not been set) reproduced reliably before the readiness gate; not reproducible after ~10 cold launches. Argosy takes ~19s to a composed nav graph on a 28k-game library, which is why the cap is generous.
-
Existing hosts — argosy://apps still handled, verified from the log.
Unit tests: 7 new cases on ResolveDeepLinkGameUseCase covering each identifier arm, the file-name fallback, and the ambiguous-match refusal. DeepLinkParser is not covered — it depends on android.net.Uri and there's no Robolectric in the JVM test setup; happy to add coverage if you'd like it wired up.
AI assistance
Written primarily by Claude Code, directed and reviewed by me. Design decisions (routing through the existing pending-launch path rather than calling GameLaunchDelegate directly; refusing ambiguous file-name matches instead of guessing) were discussed and chosen deliberately. All the on-hardware verification above is real: driven on my device against my RomM server, not inferred. The three bugs listed under Behavior changes were found by running it, not by reading the code.
Checklist
Closes #339
Summary
Adds an
argosy://launchdeep link so an external front-end can hand a specific game to Argosy, while Argosy keeps ownership of the RomM save/state lifecycle.The three existing hosts (
game/,play/,apps) are all keyed on the internal autoincrement game id, which an outside caller has no way to obtain — a front-end only knows the ROM file it scanned.launchaccepts what such a caller actually has:Three things worth calling out, since they're the non-obvious parts:
Intent extras are folded into the URI. ES-DE substitutes its
%ROM%variable only when the variable is the entire value of a parameter, never interpolated inside a longer string —%DATA%=argosy://launch?path=%ROM%arrives with the literal text%ROM%. So callers pass the path as an intent extra andMainActivitypromotes it to a query parameter. Doing it throughUri.Builder.appendQueryParameteralso percent-encodes the value, which paths containing spaces need.Cold start.
handleDeepLinkwas only wired toonNewIntent, so a deep link that started the process was silently dropped. It's now called fromonCreatetoo. This also fixes the existinggame:///play:///appshosts on cold start.ViewModel scoping. Resolution deliberately reuses the existing pending-launch path (
initiateGameLaunch+ navigate toGameDetailScreen) rather than callingGameLaunchDelegatedirectly, so the sync overlay and the LocalModified / HardcoreConflict prompts still render. That required threading the activity-scopedArgosyViewModelthroughNavGraph:GameDetailScreenpreviously resolved its ownhiltViewModel()against the NavBackStackEntry, sopendingLaunchwas being set on an instance the screen never observed. I believe this meansargosy://play/{id}could not have worked either; nothing external could trigger it before, so it looks like it went unnoticed.Path resolution tries exact
getByPathfirst, then falls back to a file-name match, since two front-ends can reach the same ROM by different roots. A file name matching more than one installed game resolves toAmbiguousrather than picking one — launching the wrong game would write its save to the wrong server slot.Behavior changes
argosy://launchhost. No existing URI shape changes.argosy://game/{id},argosy://play/{id}andargosy://appswere dropped when the intent started the process; they now behave as they already did when the app was warm.argosy://play/{id}becomes functional. TheGameDetailScreenViewModel-scoping fix means the pending launch is now observed by the screen that consumes it.NavGraphtakes a new requiredargosyViewModelparameter. Internal signature change, single call site.launchlink that resolves to nothing (unknown path, or a file name matching several games) a toast is shown and nothing is launched.IllegalArgumentException: Navigation graph has not been set.Hot paths
Yes, on the launch path, and only when a
launchdeep link is present:getById,getByRommIdorgetByPath, all indexed. The file-name fallback callsgetGamesWithLocalPathInfo(), which is bounded by installed games (games with a non-nulllocalPath), not library size, and only runs when the exact path misses.awaitConnectionIfSyncing()suspends up to 10s forConnectionState.Connectedwhen save sync is enabled, so a cold-start deep link doesn't launch before the pre-launch pull can run. It issues no requests of its own; on timeout it proceeds anyway and logs. Skipped entirely when already connected or when save sync is off.Nothing added to frame or sync paths. No new network calls.
Testing evidence
Hardware: Pixel 9 Pro XL, Android 17, unrooted. RomM 5.1.0. ES-DE 3.4.1-58 driving Argosy, RetroArch
com.retroarch1.22.2 with the snes9x core, SNES pinned to external RetroArch.Flow driven end to end, repeatedly:
Plumbing, before touching real saves — a dummy ROM with spaces in the name (
Test Game With Spaces.zip) launched from ES-DE. ES-DE's log shows the substitution, Argosy shows what arrived:Confirms find-rule resolution, extras promotion, percent-encoding, spaces surviving, and the resolver correctly declining an unknown game.
Real launch — a downloaded game launched from ES-DE, cold start:
Save sync intact through the deep-link path — pre-launch pull, then session-end push:
Verified in the DB afterwards:
state_cacherowsyncStatus='SYNCED',pending_sync_queue = 0.Full cross-device round-trip — played on desktop RetroArch (synced to the same RomM), launched on Android via ES-DE and the state auto-loaded with desktop progress; played on Android, exited cleanly, then desktop picked up the Android state. Both directions, repeatedly.
Cold-start regression — the crash this fixes (
Navigation graph has not been set) reproduced reliably before the readiness gate; not reproducible after ~10 cold launches. Argosy takes ~19s to a composed nav graph on a 28k-game library, which is why the cap is generous.Existing hosts —
argosy://appsstill handled, verified from the log.Unit tests: 7 new cases on
ResolveDeepLinkGameUseCasecovering each identifier arm, the file-name fallback, and the ambiguous-match refusal.DeepLinkParseris not covered — it depends onandroid.net.Uriand there's no Robolectric in the JVM test setup; happy to add coverage if you'd like it wired up.AI assistance
Written primarily by Claude Code, directed and reviewed by me. Design decisions (routing through the existing pending-launch path rather than calling
GameLaunchDelegatedirectly; refusing ambiguous file-name matches instead of guessing) were discussed and chosen deliberately. All the on-hardware verification above is real: driven on my device against my RomM server, not inferred. The three bugs listed under Behavior changes were found by running it, not by reading the code.Checklist