Skip to content

fix(deeplinks): route v2 tab deeplinks without a modal sheet - #1271

Merged
bmc08gt merged 6 commits into
code/cashfrom
claude/deeplink-routing-v2-newui-dc91c9
Aug 20, 2026
Merged

fix(deeplinks): route v2 tab deeplinks without a modal sheet#1271
bmc08gt merged 6 commits into
code/cashfrom
claude/deeplink-routing-v2-newui-dc91c9

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Audit of every deeplink entry point under FeatureFlag.NewUi, plus fixes for what it turned up. Eight findings; four are addressed here, and the rest are noted at the bottom.

v2 tab deeplinks opened a modal sheet

resolveRoutes wrapped every AppRoute.Sheets entry in a modal Main.Sheet unconditionally. Under NewUi the tab homes — Sheets.Wallet, Sheets.Tips, Sheets.Menu — are top-level tab destinations, not modals, so every navigational deeplink opened a sheet that hid the hoisted nav bar and left Menu/Tips with no close affordance.

resolveRoutes and navigateAll now take isNewUi. In v2 a Sheets route that maps to a nav bar tab stays flat on the root backstack and the remaining routes resolve on top of it; a list leading with a tab home is applied as a tab switch (PopUpTo.ClearAll), matching AppNavigationBar. Genuine modals such as Sheets.ActivityHistory still wrap in both shells, including when they follow a tab home. A tab switch arriving while a sheet is open goes through pendingSheetDismiss so the sheet animates out rather than being cleared from under itself.

resolveBackStack is new: it lets MainRoot's idempotence guard predict the stack navigateAll actually produces. Without it, v2 computed an extra tab entry and re-navigated on every auth/flags re-emission.

Threaded through all four deeplink entry points — cold start (MainRoot), warm start (App), QR scan (Scanner) and the post-tip chat handoff (TipCardDecorator). Scanner's Navigatable branch also gains the TipChat case it was missing, so a scanned tip DM code lands where the /tip/chat/{id} deeplink does instead of silently doing nothing.

/verify was a crash, not a no-op

EmailDeeplinkOrigin.deserialize indexed split results blindly and let a bad Fiat payload throw; handleEmailVerification threw on malformed client_data JSON or base64. Both dispatch call sites are unguarded — composition in MainRoot, a LaunchedEffect in App — and every handled host is autoVerify, so a crafted link from any web page took the app down.

classify is now a guarded wrapper that traces and degrades to "no deeplink", and each parse step fails soft. Also drops the unreachable "menu" origin branch, which serialize never emits.

/verify decoded its query parameters twice

Uri.getQueryParameter already percent-decodes, so the extra urlDecode on email and client_data ran a second, form-semantics decode over already-decoded text. Three ways that bites:

  • a plus-tagged address arrives corrupted — email=user%2Btag%40x.com decodes to user+tag@x.com, then URLDecoder turns the + into a space and the code is verified against user tag@x.com, an address the user never entered;
  • client_data.origin is standard base64, whose alphabet includes +, so the same substitution breaks the base64 and the origin silently resolves to null — dropping the routing destination;
  • URLDecoder throws on an incomplete escape, so an address containing a literal % (legal in a local part) arrives as 100%off@x.com and takes the whole link down.

Both calls dropped, with regression coverage for each case. A further test pins the contract: getQueryParameter is a form decoder, which is the exact inverse of the client-side encoder that builds these links, so decoding a second time is never correct here.

Two manifest/router disagreements

  • jump.flipcash.com was autoVerify'd with no routing at all, so every jump link dead-ended on the home screen. It's a redirector — the real URL is percent-encoded in the fragment as #source=<url> — so it now unwraps and classifies the inner link, mirroring iOS DeepLinkController. Unwraps once rather than recursing, decodes percent escapes only (a + in a URL is a literal plus), and takes everything after source= so an unencoded query string in the wrapped URL survives.
  • /chat/.* was claimed and autoVerify'd, but AppRouter deliberately doesn't classify chat links — the direct-send entry point they opened was removed. Capturing a URL to do nothing with it is worse than not capturing it, so the filter is gone and those links open in the browser again. The AppRouter comment and the manifest now cross-reference each other so the two can't drift apart.

Testing

  • AppRouterTest — 50 tests, ResolveRoutesTest — 18, NavigateToTest — 16. All green.
  • Verified on device with a before/after build of the same link in the same session, read off the TraceType.Navigation logs:
    • before: Navigating to Sheet(initialRoute=Wallet, innerRoutes=[Info(mint=EPjF…)]) from Wallet with NavOptions(popUpTo=None)
    • after: Navigating to Wallet from Wallet with NavOptions(popUpTo=ClearAll) then Navigating to Info(mint=EPjF…) from Wallet
  • Back-press from the token page lands on the real Wallet tab with the nav bar, where it previously landed on a modal sheet.
  • A jump-wrapped token link unwraps and renders the same token page.
  • cmd package query-activities confirms /chat/abc123 now resolves to Chrome only, while /token/abc123 still resolves to MainActivity.

Not addressed here

  • Logged-out deeplinks are dropped rather than deferred. A /token/... tap by a signed-out user gets AppRoute.OnboardingFlow() and the target is lost; only Login links carry their payload through onboarding.
  • /chat/{id} iOS↔Android parity. iOS routes /chat/{id} and /chat/{id}/send; Android intentionally does not.

resolveRoutes wrapped every AppRoute.Sheets entry in a modal Main.Sheet
unconditionally. Under FeatureFlag.NewUi the tab homes -- Sheets.Wallet,
Sheets.Tips, Sheets.Menu -- are top-level tab destinations, not modals, so
every navigational deeplink opened a sheet that hid the hoisted nav bar and
left Menu/Tips with no close affordance.

resolveRoutes and navigateAll now take isNewUi. In v2 a Sheets route that maps
to a nav bar tab stays flat on the root backstack and the remaining routes
resolve on top of it; a route list leading with a tab home is applied as a tab
switch (PopUpTo.ClearAll), matching what AppNavigationBar does. Genuine modals
such as Sheets.ActivityHistory still wrap in both shells. A tab switch arriving
while a sheet is open goes through pendingSheetDismiss so the sheet animates
out instead of being cleared from under itself.

Adds resolveBackStack so MainRoot's idempotence guard predicts the stack
navigateAll actually produces -- otherwise v2 computed an extra tab entry and
re-navigated on every auth/flags re-emission.

Threads isNewUi through the four deeplink entry points: cold start (MainRoot),
warm start (App), QR scan (Scanner) and the post-tip chat handoff
(TipCardDecorator). The scanner's Navigatable branch also gains the TipChat
case it was missing, so a scanned tip DM code lands where the /tip/chat/{id}
deeplink does instead of silently doing nothing.
…t link

Three fixes to the deeplink intake surface, all reachable from any web page
since every handled host is autoVerify.

/verify crash hardening: EmailDeeplinkOrigin.deserialize indexed split results
blindly and let a bad Fiat payload throw, and handleEmailVerification threw on
malformed client_data JSON or base64. Both dispatch call sites are unguarded --
composition in MainRoot and a LaunchedEffect in App -- so a crafted link was a
crash, not a no-op. classify is now a guarded wrapper that traces and degrades
to "no deeplink", and each parse step fails soft. Also drops the unreachable
"menu" origin branch, which serialize never emits.

jump.flipcash.com: the host was autoVerified with no routing at all, so every
jump link dead-ended on the home screen. It is a redirector -- the real URL is
percent-encoded in the fragment as #source=<url> -- so unwrap it and classify
the inner link, mirroring iOS DeepLinkController. Unwraps once rather than
recursing, decodes percent escapes only (a "+" in a URL is a literal plus), and
takes everything after source= so an unencoded query string in the wrapped URL
survives.

/chat/.* App Link: AppRouter deliberately does not classify chat links, since
the direct-send entry point they opened was removed, but the manifest still
claimed the path and autoVerified it. Capturing a URL to do nothing with it is
worse than not capturing it, so the filter is removed and such links open in
the browser again. The AppRouter comment and the manifest now cross-reference
each other so the two cannot drift apart.
Uri.getQueryParameter already percent-decodes, so the extra urlDecode on
`email` and `client_data` ran a second, form-semantics decode over
already-decoded text.

Three ways that bites:

- a plus-tagged address arrives corrupted -- `email=user%2Btag%40x.com`
  decodes to `user+tag@x.com`, then URLDecoder turns the `+` into a space
  and the code is verified against `user tag@x.com`, an address the user
  never entered;
- `client_data.origin` is standard base64, whose alphabet includes `+`, so
  the same substitution breaks the base64 and the origin silently resolves
  to null -- dropping the routing destination;
- URLDecoder *throws* on an incomplete escape, so an address containing a
  literal `%` (legal in a local part) arrives as `100%off@x.com` and takes
  the whole link down with it.

Drops both calls. Adds regression coverage for each case.
getQueryParameter is a form decoder -- %2B round-trips to +, a literal +
becomes a space -- which is the inverse of the client-side encoder that
builds these links. Documents why handleEmailVerification must not decode
a second time.
@github-actions github-actions Bot added type: fix Bug fix area: payments Payments, transfers, intents, billing area: scanner QR/Kikcode scanning, camera area: deeplinks Deep link handling, URL routing, and link parsing and removed type: fix Bug fix labels Aug 20, 2026
`resolveEmailVerification` decides where an email-verification link lands
(MyAccount vs. the on-ramp swap flow) and had no dispatch-level coverage —
only the parsing side was tested. Pin both origins end to end so a change to
the origin encoding or the route shape fails here rather than in the app.
A `/token/{mint}` link under v2 presented token info as a pushed screen with
modal chrome (an X, no back chevron) sitting on a stack the user never
navigated. Tapping the same token in the wallet gives the Apple-Wallet card
expansion instead, so the link and the tap disagreed on what a token is.

Match iOS, which sets `router.requestedCardMint` and lets `WalletScreen` call
`openCardImmediately` rather than pushing `.currencyInfo`: the link now lands on
the Wallet tab and opens the token as its expanded card - same overlay, same
chrome, same dismissal - with no stack entry.

- `DeeplinkType.TokenInfo` resolves to a new `DeeplinkAction.OpenToken` carrying
  the mint. It keeps the route form too, for v1, which has no card expansion and
  still takes the wallet sheet.
- `CardExpansionController.beginExpanded` starts a source-less expansion: a
  deeplink has no on-screen card to fly from (and for a token the user does not
  hold, no deck card exists at all), so the hero starts where it ends and the
  overlay simply lands open. `CardExpandHost` snaps progress once the hero slot
  reports its frame, so the first visible frame already has the card in it.
  Pull-to-close and the collapse hand-off back to the deck are untouched.
- The controller moves from `NewAppContent` up to `App`, because deeplink
  handling sits outside the v1/v2 shells and so cannot read `LocalCardExpansion`.
  `NewAppContent` still provides it to the tree; v1 never sees it.

Verified on device for both cold start and warm delivery.
@github-actions github-actions Bot added type: fix Bug fix area: tokens Token accounts, balances, token info and removed type: fix Bug fix labels Aug 20, 2026
@bmc08gt
bmc08gt merged commit 1d6dd86 into code/cash Aug 20, 2026
3 checks passed
@bmc08gt
bmc08gt deleted the claude/deeplink-routing-v2-newui-dc91c9 branch August 20, 2026 16:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: deeplinks Deep link handling, URL routing, and link parsing area: payments Payments, transfers, intents, billing area: scanner QR/Kikcode scanning, camera area: tokens Token accounts, balances, token info

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant