fix(deeplinks): route v2 tab deeplinks without a modal sheet - #1271
Merged
Conversation
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.
`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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
resolveRouteswrapped everyAppRoute.Sheetsentry in a modalMain.Sheetunconditionally. 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.resolveRoutesandnavigateAllnow takeisNewUi. In v2 aSheetsroute 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), matchingAppNavigationBar. Genuine modals such asSheets.ActivityHistorystill wrap in both shells, including when they follow a tab home. A tab switch arriving while a sheet is open goes throughpendingSheetDismissso the sheet animates out rather than being cleared from under itself.resolveBackStackis new: it lets MainRoot's idempotence guard predict the stacknavigateAllactually 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'sNavigatablebranch also gains theTipChatcase it was missing, so a scanned tip DM code lands where the/tip/chat/{id}deeplink does instead of silently doing nothing./verifywas a crash, not a no-opEmailDeeplinkOrigin.deserializeindexed split results blindly and let a bad Fiat payload throw;handleEmailVerificationthrew on malformedclient_dataJSON or base64. Bothdispatchcall sites are unguarded — composition inMainRoot, aLaunchedEffectinApp— and every handled host isautoVerify, so a crafted link from any web page took the app down.classifyis now a guarded wrapper that traces and degrades to "no deeplink", and each parse step fails soft. Also drops the unreachable"menu"origin branch, whichserializenever emits./verifydecoded its query parameters twiceUri.getQueryParameteralready percent-decodes, so the extraurlDecodeonemailandclient_dataran a second, form-semantics decode over already-decoded text. Three ways that bites:email=user%2Btag%40x.comdecodes touser+tag@x.com, thenURLDecoderturns the+into a space and the code is verified againstuser tag@x.com, an address the user never entered;client_data.originis standard base64, whose alphabet includes+, so the same substitution breaks the base64 and the origin silently resolves to null — dropping the routing destination;URLDecoderthrows on an incomplete escape, so an address containing a literal%(legal in a local part) arrives as100%off@x.comand takes the whole link down.Both calls dropped, with regression coverage for each case. A further test pins the contract:
getQueryParameteris 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.comwasautoVerify'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 iOSDeepLinkController. Unwraps once rather than recursing, decodes percent escapes only (a+in a URL is a literal plus), and takes everything aftersource=so an unencoded query string in the wrapped URL survives./chat/.*was claimed andautoVerify'd, butAppRouterdeliberately 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. TheAppRoutercomment 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.TraceType.Navigationlogs:Navigating to Sheet(initialRoute=Wallet, innerRoutes=[Info(mint=EPjF…)]) from Wallet with NavOptions(popUpTo=None)Navigating to Wallet from Wallet with NavOptions(popUpTo=ClearAll)thenNavigating to Info(mint=EPjF…) from Walletcmd package query-activitiesconfirms/chat/abc123now resolves to Chrome only, while/token/abc123still resolves toMainActivity.Not addressed here
/token/...tap by a signed-out user getsAppRoute.OnboardingFlow()and the target is lost; onlyLoginlinks carry their payload through onboarding./chat/{id}iOS↔Android parity. iOS routes/chat/{id}and/chat/{id}/send; Android intentionally does not.