Skip to content

ui: add argosy://launch deep link for external front-ends - #341

Open
kalbasit wants to merge 1 commit into
rommapp:mainfrom
kalbasit:deeplink-external-launch
Open

ui: add argosy://launch deep link for external front-ends#341
kalbasit wants to merge 1 commit into
rommapp:mainfrom
kalbasit:deeplink-external-launch

Conversation

@kalbasit

Copy link
Copy Markdown

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:

  1. 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.

  2. 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
    
  3. 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.

  4. 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.

  5. 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.

  6. Existing hostsargosy://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

  • I've tested the changes on real hardware
  • I've added or updated unit tests covering the changes
  • I've personally reviewed and understand the code being submitted

Existing argosy:// deep links are keyed on the internal autoincrement game
id, which an external caller cannot obtain. Add a launch host that accepts a
ROM path, a RomM rom id or a game id, so front-ends like ES-DE can hand off a
game while Argosy keeps ownership of the save/state sync lifecycle.

Recognised intent extras are folded into the URI as query parameters, because
ES-DE substitutes %ROM% only as a whole parameter value and cannot build the
URI itself. handleDeepLink is now also called from onCreate; it was only wired
to onNewIntent, so a deep link that started the process was dropped.

Resolution reuses the existing pending-launch path into GameDetailScreen so
the sync overlay and conflict prompts still render, which required threading
the activity-scoped ArgosyViewModel through NavGraph: the screen previously
resolved its own back-stack-scoped instance, so pendingLaunch was set on an
object it never observed.

Closes rommapp#339

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tmgast

tmgast commented Aug 11, 2026

Copy link
Copy Markdown
Member

Im gonna need some time to unwrap this Russian nesting doll... probably won't be able to fully consider it until next weekend.

What happens when you quit the game? Does it return to Argosy or ES-DE? ...or is there a short delay in Argosy before kicking back to ES-DE? What if you hit Home, doesn't it skip the save sync step? I feel like there's a lot of room for error with wrapping launcher calls like this rather than implementing it natively (though I understand ES-DE isn't so friendly to userland enhancement). Alternatively, you could toggle of Secure Saves and have Argosy attempt to preserve changes made when launching from other apps, though you'd lose session tracking data...

@kalbasit

Copy link
Copy Markdown
Author

My apologies for the delay on my end, I also got bit too busy these past few days. I wanted to record my screen so you can see how it's working end to end and I still intend to do that; Hoping to get back to you in the next couple fo days.

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.

External front-ends cannot address a specific game: deep links are keyed on the internal game id

2 participants