From 3d87415453abe08d8a86bb419305080cd000d0af Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 20 Aug 2026 09:24:53 -0400 Subject: [PATCH 1/6] fix(deeplinks): route v2 tab deeplinks without a modal sheet 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. --- .../com/flipcash/app/internal/ui/App.kt | 5 +- .../app/internal/ui/navigation/MainRoot.kt | 25 ++-- .../navigation/BuildNavGraphForLaunchTest.kt | 106 +++++++++++++- .../app/core/extensions/CodeNavigator.kt | 93 +++++++++--- .../flipcash/app/scanner/internal/Scanner.kt | 14 +- apps/flipcash/shared/bills/build.gradle.kts | 1 + .../app/bills/decor/TipCardDecorator.kt | 11 +- .../app/router/internal/ResolveRoutesTest.kt | 137 ++++++++++++++++++ 8 files changed, 354 insertions(+), 38 deletions(-) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt index aae396e094..03f6b425f3 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt @@ -257,7 +257,10 @@ internal fun App( } else false if (!delivered) { - codeNavigator.navigateAll(action.routes) + codeNavigator.navigateAll( + action.routes, + isNewUi = isNewUi, + ) } } diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt index 38d2ef4fbd..294c72c038 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt @@ -26,7 +26,7 @@ import com.flipcash.app.core.LocalUserManager import com.flipcash.app.core.AppRoute import com.flipcash.app.core.navigation.DeeplinkAction import com.flipcash.app.core.extensions.navigateAll -import com.flipcash.app.core.extensions.resolveRoutes +import com.flipcash.app.core.extensions.resolveBackStack import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.router.LocalRouter import com.flipcash.app.router.Router @@ -132,7 +132,7 @@ internal fun MainRoot( if (!current.startsWith(target)) { navigator.replaceAll(launch.baseRoutes) if (launch.deeplinkRoutes.isNotEmpty()) { - navigator.navigateAll(launch.deeplinkRoutes) + navigator.navigateAll(launch.deeplinkRoutes, isNewUi = isNewUi) } } @@ -158,16 +158,17 @@ internal data class LaunchNavGraph( val baseRoutes: List, val deeplinkRoutes: List = emptyList(), val pendingAction: DeeplinkAction? = null, + /** v2 (tab-centric) shell. Changes how [deeplinkRoutes] resolve — see [resolveBackStack]. */ + val isNewUi: Boolean = false, ) { /** - * Predict the final backstack that [baseRoutes] + [navigateTo(deeplinkRoutes)] will produce. - * Uses the shared [resolveRoutes] to apply the same sheet-wrapping as [navigateTo] - * so we can compare against the current backstack and skip redundant navigation. + * Predict the final backstack that [baseRoutes] + `navigateAll(deeplinkRoutes)` will produce. + * Uses the shared [resolveBackStack] so it applies the same sheet-wrapping (v1) or tab-switch + * (v2) as `navigateAll`, letting us compare against the current backstack and skip redundant + * navigation. */ - fun resolvedBackStack(): List { - if (deeplinkRoutes.isEmpty()) return baseRoutes - return baseRoutes + resolveRoutes(deeplinkRoutes) - } + fun resolvedBackStack(): List = + resolveBackStack(baseRoutes, deeplinkRoutes, isNewUi) } /** @@ -225,6 +226,7 @@ internal fun buildNavGraphForLaunch( is DeeplinkAction.Navigate -> LaunchNavGraph( baseRoutes = listOf(home), deeplinkRoutes = action.routes, + isNewUi = isNewUi, ) is DeeplinkAction.OpenCashLink, @@ -232,12 +234,13 @@ internal fun buildNavGraphForLaunch( is DeeplinkAction.Login -> LaunchNavGraph( baseRoutes = listOf(home), pendingAction = action, + isNewUi = isNewUi, ) - else -> LaunchNavGraph(listOf(home)) + else -> LaunchNavGraph(listOf(home), isNewUi = isNewUi) } } else { - LaunchNavGraph(listOf(home)) + LaunchNavGraph(listOf(home), isNewUi = isNewUi) } } diff --git a/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt b/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt index 33c18d51dc..a9945925ce 100644 --- a/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt +++ b/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt @@ -1,6 +1,9 @@ package com.flipcash.app.internal.ui.navigation import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.chat.ChatIdentifier +import com.flipcash.services.models.chat.ChatId +import com.getcode.solana.keys.Mint import com.flipcash.app.core.navigation.DeeplinkAction import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.app.router.Router @@ -28,10 +31,11 @@ class BuildNavGraphForLaunchTest { state: AuthState, action: DeeplinkAction = DeeplinkAction.None, deepLink: DeepLink? = null, + isNewUi: Boolean = false, ): LaunchNavGraph? = buildNavGraphForLaunch( state = state, router = FakeRouter(action), - isNewUi = false, + isNewUi = isNewUi, deepLink = { deepLink }, ) @@ -169,4 +173,104 @@ class BuildNavGraphForLaunchTest { fun `authenticating returns null`() { assertNull(build(AuthState.Authenticating)) } + + // -- Ready (v2 / NewUi) -- + + private val mint = Mint("So11111111111111111111111111111111111111112") + + private fun buildV2( + action: DeeplinkAction = DeeplinkAction.None, + deepLink: DeepLink? = dummyLink, + ) = build(AuthState.Ready, action, deepLink, isNewUi = true)!! + + @Test + fun `v2 logged in without deeplink opens on the Wallet tab`() { + val result = build(AuthState.Ready, isNewUi = true)!! + assertEquals(listOf(AppRoute.Sheets.Wallet), result.baseRoutes) + assertTrue(result.deeplinkRoutes.isEmpty()) + assertEquals(listOf(AppRoute.Sheets.Wallet), result.resolvedBackStack()) + } + + @Test + fun `v2 token deeplink pushes token info onto the Wallet tab without a sheet`() { + val result = buildV2( + DeeplinkAction.Navigate( + listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(mint, fromDeeplink = true)) + ) + ) + + val stack = result.resolvedBackStack() + assertEquals(2, stack.size) + assertEquals(AppRoute.Sheets.Wallet, stack[0]) + assertIs(stack[1]) + assertTrue(stack.none { it is AppRoute.Main.Sheet }, "v2 must not wrap a tab home in a sheet") + } + + @Test + fun `v2 tip chat deeplink switches to the Chats tab instead of a sheet over Wallet`() { + val result = buildV2( + DeeplinkAction.Navigate( + listOf( + AppRoute.Sheets.Tips(), + AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(ChatId(listOf(1, 2, 3, 4)))), + ) + ) + ) + + val stack = result.resolvedBackStack() + assertEquals(2, stack.size) + assertIs(stack[0]) + assertIs(stack[1]) + // The launch home must be replaced by the target tab, not left underneath it. + assertTrue(stack.none { it == AppRoute.Sheets.Wallet }) + assertTrue(stack.none { it is AppRoute.Main.Sheet }) + } + + @Test + fun `v2 email verification deeplink lands on the You tab without a sheet`() { + val result = buildV2( + DeeplinkAction.Navigate( + listOf( + AppRoute.Sheets.Menu, + AppRoute.Menu.MyAccount, + AppRoute.Verification( + origin = AppRoute.Menu.MyAccount, + includePhone = false, + email = "test@example.com", + emailVerificationCode = "123456", + ), + ) + ) + ) + + val stack = result.resolvedBackStack() + assertEquals(3, stack.size) + assertEquals(AppRoute.Sheets.Menu, stack[0]) + assertTrue(stack.none { it is AppRoute.Main.Sheet }) + } + + @Test + fun `v1 token deeplink still opens the wallet sheet`() { + val result = build( + state = AuthState.Ready, + action = DeeplinkAction.Navigate( + listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(mint, fromDeeplink = true)) + ), + deepLink = dummyLink, + isNewUi = false, + )!! + + val stack = result.resolvedBackStack() + assertEquals(2, stack.size) + assertEquals(AppRoute.Main.Scanner, stack[0]) + assertIs(stack[1]) + } + + @Test + fun `v2 pending actions still launch on the Wallet tab`() { + val action = DeeplinkAction.OpenCashLink("testEntropy") + val result = buildV2(action) + assertEquals(listOf(AppRoute.Sheets.Wallet), result.baseRoutes) + assertEquals(action, result.pendingAction) + } } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/extensions/CodeNavigator.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/extensions/CodeNavigator.kt index d82199885f..48410bbe51 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/extensions/CodeNavigator.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/extensions/CodeNavigator.kt @@ -3,6 +3,7 @@ package com.flipcash.app.core.extensions import androidx.compose.runtime.snapshots.Snapshot import androidx.navigation3.runtime.NavKey import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.navigation.asNavBarTab import com.getcode.navigation.core.CodeNavigator import com.getcode.navigation.core.NavOptions @@ -33,6 +34,14 @@ fun CodeNavigator.openAsSheet(route: AppRoute, innerRoutes: List = emp } } +/** + * True when [routes] leads with a route that is a v2 tab home under the NewUi flag. + * Such a list is applied as a *tab switch* (the leading route replaces the stack) rather than + * stacked on top of whatever tab the user was on. + */ +private fun leadsWithTab(routes: List, isNewUi: Boolean): Boolean = + isNewUi && (routes.firstOrNull() as? AppRoute)?.asNavBarTab() != null + /** * Navigate to multiple routes, wrapping [AppRoute.Sheets] in [AppRoute.Main.Sheet]. * Routes after a [AppRoute.Sheets] entry are packed into the sheet's inner backstack @@ -40,29 +49,48 @@ fun CodeNavigator.openAsSheet(route: AppRoute, innerRoutes: List = emp * * If a sheet is already open and the new routes include a sheet, the current sheet * is animated closed before the new one opens. + * + * Under [isNewUi] (v2) the tab homes — `Sheets.Wallet`, `Sheets.Tips`, `Sheets.Menu` — are + * *not* sheets, so a route list leading with one switches to that tab (replacing the stack) + * and pushes the rest on top of it. See [resolveRoutes]. */ -fun CodeNavigator.navigateAll(routes: List, options: NavOptions = NavOptions()) { +fun CodeNavigator.navigateAll( + routes: List, + options: NavOptions = NavOptions(), + isNewUi: Boolean = false, +) { if (routes.isEmpty()) return - val resolved = resolveRoutes(routes) + val resolved = resolveRoutes(routes, isNewUi) val needsSheet = resolved.any { it is AppRoute.Main.Sheet } val hasSheet = backStack.any { it is AppRoute.Main.Sheet } - if (hasSheet && needsSheet) { + // A v2 tab home lands as a tab switch, not another entry stacked on the current tab. + val firstOptions = if (leadsWithTab(resolved, isNewUi)) { + options.copy(popUpTo = NavOptions.PopUpTo.ClearAll) + } else { + options + } + + val apply = { + resolved.forEachIndexed { index, route -> + val navOptions = if (index == 0) firstOptions else NavOptions() + navigate(route, navOptions) + } + } + + // Defer when a sheet is on screen and the new stack would take it away — either because the + // target is itself a sheet (v1) or because a v2 tab switch clears the stack out from under it. + // pendingSheetDismiss animates the current sheet out first, then applies the navigation. + if (hasSheet && (needsSheet || firstOptions.popUpTo is NavOptions.PopUpTo.ClearAll)) { pendingSheetDismiss = { Snapshot.withMutableSnapshot { sheetGeneration++ - resolved.forEachIndexed { index, route -> - val navOptions = if (index == 0) options else NavOptions() - navigate(route, navOptions) - } + apply() } } } else { - resolved.forEachIndexed { index, route -> - val navOptions = if (index == 0) options else NavOptions() - navigate(route, navOptions) - } + apply() } } @@ -70,19 +98,44 @@ fun CodeNavigator.navigateAll(routes: List, options: NavOptions = NavOpt * Resolve a list of routes into their final backstack representation. * * Wraps [AppRoute.Sheets] entries (and any routes after them) into - * [AppRoute.Main.Sheet] with inner routes, mirroring what [navigateTo] pushes + * [AppRoute.Main.Sheet] with inner routes, mirroring what [navigateAll] pushes * onto the backstack. Useful for predicting the resulting stack without navigating. + * + * Under [isNewUi] (v2) the tab homes — `Sheets.Wallet`, `Sheets.Tips`, `Sheets.Menu` — are + * top-level tab destinations rather than modals, so they stay flat on the root backstack (which + * keeps the hoisted nav bar visible and lets back/pop behave like a tab stack). Anything after + * the tab route is resolved independently, so a genuine sheet later in the list still wraps. */ -fun resolveRoutes(routes: List): List { +fun resolveRoutes(routes: List, isNewUi: Boolean = false): List { if (routes.isEmpty()) return emptyList() val sheetIndex = routes.indexOfFirst { it is AppRoute.Sheets } - return if (sheetIndex >= 0) { - val before = routes.take(sheetIndex) - val sheetRoute = routes[sheetIndex] as AppRoute.Sheets - val innerRoutes = routes.drop(sheetIndex + 1).filterIsInstance() - before + AppRoute.Main.Sheet(sheetRoute, innerRoutes) - } else { - routes + if (sheetIndex < 0) return routes + + val sheetRoute = routes[sheetIndex] as AppRoute.Sheets + + if (isNewUi && sheetRoute.asNavBarTab() != null) { + return routes.take(sheetIndex + 1) + resolveRoutes(routes.drop(sheetIndex + 1), isNewUi = true) } + + val before = routes.take(sheetIndex) + val innerRoutes = routes.drop(sheetIndex + 1).filterIsInstance() + return before + AppRoute.Main.Sheet(sheetRoute, innerRoutes) +} + +/** + * The backstack that [navigateAll] would produce for [routes] when applied on top of [base]. + * + * Mirrors [navigateAll]'s tab-switch handling: a v2 route list leading with a tab home replaces + * [base] rather than stacking on it. Used to compare against the live stack and skip redundant + * navigation. + */ +fun resolveBackStack( + base: List, + routes: List, + isNewUi: Boolean = false, +): List { + if (routes.isEmpty()) return base + val resolved = resolveRoutes(routes, isNewUi) + return if (leadsWithTab(resolved, isNewUi)) resolved else base + resolved } diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt index c652482b0a..09c374aced 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt @@ -19,6 +19,8 @@ import com.flipcash.app.core.AppRoute.Token.* import com.flipcash.app.core.extensions.navigateAll import com.flipcash.app.core.extensions.openAsSheet import com.flipcash.app.core.navigation.DeeplinkType +import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.router.LocalRouter import com.flipcash.app.scanner.internal.bills.ScannableContainer import com.flipcash.app.session.LocalSessionController @@ -43,6 +45,8 @@ internal fun Scanner() { val state by session.state.collectAsStateWithLifecycle() val billState by session.billState.collectAsStateWithLifecycle() val analytics = rememberAnalytics() + val isNewUi by LocalFeatureFlags.current.observe(FeatureFlag.NewUi) + .collectAsStateWithLifecycle() var isPaused by remember { mutableStateOf(false) } @@ -127,15 +131,21 @@ internal fun Scanner() { session.openCashLink(deeplink.entropy) } is DeeplinkType.Navigatable -> { - val routes = when (deeplink) { + val routes: List = when (deeplink) { is DeeplinkType.TokenInfo -> listOf( AppRoute.Sheets.Wallet, Info(deeplink.mint, fromDeeplink = true) ) + // Scanned tip-DM code — same destination as the + // /tip/chat/{id} deeplink. + is DeeplinkType.TipChat -> listOf( + AppRoute.Sheets.Tips(), + AppRoute.Messaging.Chat(deeplink.identifier), + ) else -> emptyList() } if (routes.isNotEmpty()) { - navigator.navigateAll(routes) + navigator.navigateAll(routes, isNewUi = isNewUi) } } is DeeplinkType.Login -> Unit diff --git a/apps/flipcash/shared/bills/build.gradle.kts b/apps/flipcash/shared/bills/build.gradle.kts index a4655c9898..b52f38598d 100644 --- a/apps/flipcash/shared/bills/build.gradle.kts +++ b/apps/flipcash/shared/bills/build.gradle.kts @@ -12,6 +12,7 @@ dependencies { implementation(project(":libs:messaging")) implementation(project(":apps:flipcash:shared:common-ui")) + implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:session")) implementation(libs.androidx.datastore) diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/TipCardDecorator.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/TipCardDecorator.kt index ad54a03914..3cd50b2f97 100644 --- a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/TipCardDecorator.kt +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/TipCardDecorator.kt @@ -20,6 +20,8 @@ import com.flipcash.app.core.extensions.openAsSheet import com.flipcash.app.core.tipping.LocalTipCoordinator import com.flipcash.app.core.tipping.TipEvent import com.flipcash.app.bills.modals.TipUserModal +import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.session.LocalSessionController import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.ui.core.measured @@ -50,6 +52,7 @@ internal data class TipCardDecorator(private val tipCard: Scannable.TipCard) : S val navigator = LocalCodeNavigator.current val tipCoordinator = LocalTipCoordinator.current val selection by tipCoordinator.selection.collectAsState() + val isNewUi by LocalFeatureFlags.current.observe(FeatureFlag.NewUi).collectAsState() val tipPresented = context.liveBill is Scannable.TipCard // Can't afford the minimum tip → the modal stays hidden (gated by @@ -76,14 +79,16 @@ internal data class TipCardDecorator(private val tipCard: Scannable.TipCard) : S is TipEvent.OpenRoute -> { navigator.openAsSheet(event.route) } - // Open the completed tip's chat with the tips list beneath it (navigateAll packs - // the chat into the tips sheet's back stack), so back returns to the list. + // Open the completed tip's chat with the tips list beneath it, so back returns + // to the list. In v1 navigateAll packs the chat into the tips sheet's back + // stack; in v2 it switches to the Chats tab and pushes the chat onto it. is TipEvent.LaunchChat -> { navigator.navigateAll( listOf( AppRoute.Sheets.Tips(), AppRoute.Messaging.Chat(event.identifier, openKeyboard = true), - ) + ), + isNewUi = isNewUi, ) context.onDismiss() } diff --git a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/ResolveRoutesTest.kt b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/ResolveRoutesTest.kt index 144ffbb791..b9a1a2aa96 100644 --- a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/ResolveRoutesTest.kt +++ b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/ResolveRoutesTest.kt @@ -1,7 +1,12 @@ package com.flipcash.app.router.internal +import androidx.navigation3.runtime.NavKey import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.extensions.resolveBackStack import com.flipcash.app.core.extensions.resolveRoutes +import com.flipcash.app.core.chat.ChatIdentifier +import com.flipcash.app.core.tokens.SwapPurpose +import com.flipcash.services.models.chat.ChatId import com.getcode.solana.keys.Mint import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -9,6 +14,8 @@ import org.robolectric.annotation.Config import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue @RunWith(RobolectricTestRunner::class) @Config(manifest = Config.NONE) @@ -126,4 +133,134 @@ class ResolveRoutesTest { } // endregion + + // region v2 (isNewUi) — tab homes must stay flat, not become sheets + + private val mint = Mint("So11111111111111111111111111111111111111112") + + @Test + fun `v2 keeps wallet tab flat and pushes token info on top`() { + val routes = listOf( + AppRoute.Sheets.Wallet, + AppRoute.Token.Info(mint, fromDeeplink = true), + ) + + val resolved = resolveRoutes(routes, isNewUi = true) + assertEquals(routes, resolved) + assertTrue(resolved.none { it is AppRoute.Main.Sheet }) + } + + @Test + fun `v2 keeps chats tab flat and pushes chat on top`() { + val routes = listOf( + AppRoute.Sheets.Tips(), + AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(ChatId(listOf(1, 2, 3, 4)))), + ) + + val resolved = resolveRoutes(routes, isNewUi = true) + assertEquals(routes, resolved) + assertTrue(resolved.none { it is AppRoute.Main.Sheet }) + } + + @Test + fun `v2 keeps menu tab flat with my account and verification pushed on top`() { + val routes = listOf( + AppRoute.Sheets.Menu, + AppRoute.Menu.MyAccount, + AppRoute.Verification( + origin = AppRoute.Menu.MyAccount, + includePhone = false, + email = "test@example.com", + emailVerificationCode = "123456", + ), + ) + + assertEquals(routes, resolveRoutes(routes, isNewUi = true)) + } + + @Test + fun `v2 still wraps a non-tab sheet`() { + // ActivityHistory is a genuine modal in both shells — it has no tab. + val routes = listOf(AppRoute.Sheets.ActivityHistory) + val resolved = resolveRoutes(routes, isNewUi = true) + assertEquals(1, resolved.size) + assertIs(resolved.single()) + } + + @Test + fun `v2 wraps a genuine sheet that follows a tab home`() { + val routes = listOf( + AppRoute.Sheets.Wallet, + AppRoute.Token.Info(mint), + AppRoute.Sheets.ActivityHistory, + ) + + val resolved = resolveRoutes(routes, isNewUi = true) + assertEquals(3, resolved.size) + assertEquals(AppRoute.Sheets.Wallet, resolved[0]) + assertIs(resolved[1]) + val sheet = resolved[2] + assertIs(sheet) + assertEquals(AppRoute.Sheets.ActivityHistory, sheet.initialRoute) + } + + @Test + fun `v1 and v2 disagree only on tab homes`() { + val routes = listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(mint)) + assertNotEquals(resolveRoutes(routes, isNewUi = false), resolveRoutes(routes, isNewUi = true)) + + // No sheet at all -> identical in both shells. + val plain = listOf(AppRoute.Main.Scanner, AppRoute.Menu.MyAccount) + assertEquals(resolveRoutes(plain, isNewUi = false), resolveRoutes(plain, isNewUi = true)) + } + + // endregion + + // region resolveBackStack — tab switch replaces the base stack + + @Test + fun `v2 deeplink to a tab replaces the launch home rather than stacking on it`() { + val base = listOf(AppRoute.Sheets.Wallet) + val deeplink = listOf( + AppRoute.Sheets.Tips(), + AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(ChatId(listOf(9)))), + ) + + val stack = resolveBackStack(base, deeplink, isNewUi = true) + assertEquals(deeplink, stack) + // The Wallet home the app launched on must not linger beneath the Chats tab. + assertTrue(stack.none { it == AppRoute.Sheets.Wallet }) + } + + @Test + fun `v2 token deeplink lands on the wallet tab exactly once`() { + val base = listOf(AppRoute.Sheets.Wallet) + val deeplink = listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(mint, fromDeeplink = true)) + + val stack = resolveBackStack(base, deeplink, isNewUi = true) + assertEquals(2, stack.size) + assertEquals(1, stack.count { it == AppRoute.Sheets.Wallet }) + assertIs(stack[1]) + } + + @Test + fun `v2 deeplink without a tab home stacks on the launch home`() { + val base = listOf(AppRoute.Sheets.Wallet) + val deeplink = listOf(AppRoute.Token.Info(mint), AppRoute.Token.Swap(SwapPurpose.Buy(mint))) + + assertEquals(base + deeplink, resolveBackStack(base, deeplink, isNewUi = true)) + } + + @Test + fun `v1 backstack is unchanged by resolveBackStack`() { + val base = listOf(AppRoute.Main.Scanner) + val deeplink = listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(mint)) + + val stack = resolveBackStack(base, deeplink, isNewUi = false) + assertEquals(2, stack.size) + assertEquals(AppRoute.Main.Scanner, stack[0]) + assertIs(stack[1]) + } + + // endregion } From ec8c60ca46a09a8461386c26e9c6d878cd43f39d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 20 Aug 2026 09:25:06 -0400 Subject: [PATCH 2/6] fix(deeplinks): handle jump redirects, harden /verify, drop dead /chat 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= -- 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. --- .../flipcash/app/src/main/AndroidManifest.xml | 17 +- .../verification/email/EmailDeeplinkOrigin.kt | 24 ++- .../flipcash/app/router/internal/AppRouter.kt | 73 +++++++- .../app/router/internal/AppRouterTest.kt | 161 ++++++++++++++++++ 4 files changed, 252 insertions(+), 23 deletions(-) diff --git a/apps/flipcash/app/src/main/AndroidManifest.xml b/apps/flipcash/app/src/main/AndroidManifest.xml index df1327d637..b604152ba6 100644 --- a/apps/flipcash/app/src/main/AndroidManifest.xml +++ b/apps/flipcash/app/src/main/AndroidManifest.xml @@ -165,17 +165,12 @@ android:scheme="https" /> - - - - - - - - + diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/verification/email/EmailDeeplinkOrigin.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/verification/email/EmailDeeplinkOrigin.kt index 07e93d5d6c..7ae6179299 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/verification/email/EmailDeeplinkOrigin.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/verification/email/EmailDeeplinkOrigin.kt @@ -43,23 +43,35 @@ sealed class EmailDeeplinkOrigin { } } + /** + * Parse a value produced by [serialize]. Returns null for anything unrecognised or + * malformed rather than throwing — this runs on `client_data` from an `autoVerify` + * deeplink, so the input is attacker-controllable and must never crash the caller. + */ fun deserialize(value: String): EmailDeeplinkOrigin? { val splits = value.split("|") - return when (splits[0]) { + return when (splits.getOrNull(0)) { "onramp" -> { - val source = when (splits[1]) { - "menu" -> AppRoute.Sheets.Menu + val source = when (splits.getOrNull(1)) { "amountentry" -> { - val mint = splits.getOrNull(2)?.let { Mint(it) } + val mint = splits.getOrNull(2) + ?.takeIf { it.isNotBlank() && it != "null" } + ?.let { Mint(it) } ?: return null AppRoute.Token.Swap(SwapPurpose.Buy(mint)) } + // "null" — an on-ramp with no swap source, carrying only an amount. else -> null } - val amount = - splits.getOrNull(3)?.let { Json.decodeFromString(Fiat.Companion.serializer(), it) } + val amount = splits.getOrNull(3) + ?.takeIf { it.isNotBlank() && it != "null" } + ?.let { + runCatching { + Json.decodeFromString(Fiat.Companion.serializer(), it) + }.getOrNull() + } OnRamp(source, amount) } diff --git a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt index 1fdd1c878f..53d02718ef 100644 --- a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt +++ b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt @@ -1,5 +1,6 @@ package com.flipcash.app.router.internal +import android.net.Uri import androidx.core.net.toUri import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ChatIdentifier @@ -20,8 +21,10 @@ import com.flipcash.app.router.internal.AppRouter.Companion.verification import com.flipcash.services.user.AuthState import com.getcode.opencode.model.core.bytes import com.getcode.solana.keys.Mint +import com.getcode.utils.TraceType import com.getcode.utils.decodeBase64 import com.getcode.utils.decodeBase64UrlSafe +import com.getcode.utils.trace import com.getcode.utils.urlDecode import dev.theolm.rinku.DeepLink import org.json.JSONObject @@ -37,6 +40,12 @@ internal class AppRouter( val token = listOf("token") val chat = listOf("chat") val tip = listOf("tip") + + /** + * Redirector host. It carries no route of its own — the real link is percent-encoded in + * the fragment as `#source=`. See [DeepLink.unwrapJumpTarget]. + */ + const val JUMP_HOST = "jump.flipcash.com" } override fun dispatch(deepLink: DeepLink): DeeplinkAction { @@ -73,7 +82,30 @@ internal class AppRouter( } override fun classify(deepLink: DeepLink): DeeplinkType? { + // Deeplink payloads are attacker-controllable (every handled host is `autoVerify`) and both + // callers of dispatch/classify run where a throw is fatal — inside composition (MainRoot) + // and inside a LaunchedEffect (App). A parse failure must degrade to "no deeplink", loudly + // in logs, never to a crash. + return runCatching { classifyOrThrow(deepLink) } + .onFailure { + trace( + tag = "AppRouter", + message = "Failed to classify deeplink; ignoring it", + error = it, + type = TraceType.Error, + ) + } + .getOrNull() + } + + private fun classifyOrThrow(deepLink: DeepLink): DeeplinkType? { return when { + // A jump link is a wrapper, never a destination. Unwrap once and classify the inner + // URL; a jump pointing at another jump is malformed and drops to null rather than + // recursing. Mirrors iOS DeepLinkController. + deepLink.isJump() -> deepLink.unwrapJumpTarget() + ?.takeUnless { it.isJump() } + ?.let { classifyOrThrow(it) } deepLink.isLogin() -> deepLink.handleLoginLink() deepLink.isCashLink() -> deepLink.handleCashLink() deepLink.isToken() -> deepLink.handleTokenLink() @@ -81,8 +113,9 @@ internal class AppRouter( deepLink.isTipChat() -> deepLink.handleTipChat() deepLink.isTipCard() -> deepLink.handleTipCard() // `/chat/{id}` links are intentionally NOT handled: the Send tab / direct-send - // flow they opened was removed, so they fall through to `null` and the app lands - // on the camera. Do not re-add chat routing here without restoring that entry point. + // flow they opened was removed. The manifest no longer claims that path either, so + // such a link opens in the browser rather than dead-ending here. Re-add routing and + // the App Link filter together, or not at all. // (Tip DMs use `/tip/chat/{id}` — handled above via isTipChat.) else -> null } @@ -130,6 +163,30 @@ internal class AppRouter( } } +private fun DeepLink.isJump(): Boolean = host.equals(AppRouter.JUMP_HOST, ignoreCase = true) + +/** + * `jump.flipcash.com/#source=` is a redirector used by the web app to hand a + * link off to the native app. Pull the wrapped URL back out so it can be classified as if it had + * arrived directly. Returns null when the fragment is absent, empty, or not a parseable URL. + */ +private fun DeepLink.unwrapJumpTarget(): DeepLink? { + val fragment = data.substringAfter('#', missingDelimiterValue = "") + if (!fragment.startsWith(JUMP_SOURCE_PARAM)) return null + + // Everything after `source=`, not up to the next `&` — the wrapped URL may carry its own + // query string with `&` separators that the producer left unencoded. iOS does the same. + val encoded = fragment.removePrefix(JUMP_SOURCE_PARAM).takeIf { it.isNotBlank() } ?: return null + + // Uri.decode, not URLDecoder: the payload is a URL, and `+` in it (a `user+tag@` email in a + // /verify query, say) is a literal plus, not a space. + val target = Uri.decode(encoded)?.takeIf { it.isNotBlank() } ?: return null + + return runCatching { DeepLink(target) }.getOrNull() +} + +private const val JUMP_SOURCE_PARAM = "source=" + private fun DeepLink.isLogin(): Boolean = login.contains(pathSegments.getOrNull(0)) private fun DeepLink.isCashLink(): Boolean = cashLink.contains(pathSegments.getOrNull(0)) private fun DeepLink.isToken(): Boolean = token.contains(pathSegments.getOrNull(0)) @@ -192,12 +249,16 @@ private fun DeepLink.handleEmailVerification(): DeeplinkType.EmailVerification? val uri = data.toUri() val email = uri.getQueryParameter("email")?.urlDecode() val code = uri.getQueryParameter("code") + // client_data is optional and untrusted: a malformed or absent payload just means "no origin", + // which resolveEmailVerification already handles. Never let it fail the whole link. val clientData = uri.getQueryParameter("client_data")?.urlDecode() - ?.let { JSONObject(it) } + ?.let { runCatching { JSONObject(it) }.getOrNull() } - val origin = clientData?.getString("origin")?.decodeBase64()?.let { - String(it, Charsets.UTF_8) - } + val origin = clientData?.optString("origin") + ?.takeIf { it.isNotEmpty() } + ?.let { encoded -> + runCatching { String(encoded.decodeBase64(), Charsets.UTF_8) }.getOrNull() + } if (code != null && email != null) { return DeeplinkType.EmailVerification( email = email, diff --git a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt index 0796884c15..b0c3452339 100644 --- a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt +++ b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt @@ -24,6 +24,10 @@ import kotlin.test.assertTrue @Config(manifest = Config.NONE) class AppRouterTest { + private companion object { + const val MINT = "So11111111111111111111111111111111111111112" + } + private var authState: AuthState = AuthState.Ready private val router = AppRouter(authStateProvider = { authState }) @@ -319,6 +323,75 @@ class AppRouterTest { assertIs(action) } + // region classify — malformed client_data (must never throw) + + /** + * `app.flipcash.com/verify` is an autoVerify App Link, so any web page can hand the app an + * arbitrary `client_data`. Both dispatch call sites are fatal on throw (composition in + * MainRoot, a LaunchedEffect in App), so every one of these must degrade to None. + */ + private fun verifyUrlWithRawClientData(raw: String): String = + "https://app.flipcash.com/verify" + + "?email=test%40example.com" + + "&code=123456" + + "&client_data=${URLEncoder.encode(raw, "UTF-8")}" + + private fun verifyUrlWithOrigin(origin: String): String = + verifyUrlWithRawClientData( + """{"origin":"${Base64.encodeToString(origin.toByteArray(), Base64.NO_WRAP)}"}""" + ) + + @Test + fun `dispatch returns None for onramp origin missing its source segment`() { + loggedIn() + // "onramp" alone used to index splits[1] unchecked. + assertIs(router.dispatch(DeepLink(verifyUrlWithOrigin("onramp")))) + } + + @Test + fun `dispatch returns None for onramp origin with unparseable amount`() { + loggedIn() + val url = verifyUrlWithOrigin("onramp|amountentry|$MINT|garbage") + val action = router.dispatch(DeepLink(url)) + // The mint still resolves, so this is a real Navigate — the point is that the junk + // amount is dropped instead of throwing out of Json.decodeFromString. + assertIs(action) + } + + @Test + fun `dispatch returns None for onramp origin with blank mint`() { + loggedIn() + assertIs( + router.dispatch(DeepLink(verifyUrlWithOrigin("onramp|amountentry|"))) + ) + } + + @Test + fun `dispatch returns None for client data json without an origin key`() { + loggedIn() + assertIs( + router.dispatch(DeepLink(verifyUrlWithRawClientData("""{"foo":1}"""))) + ) + } + + @Test + fun `dispatch returns None for client data that is not json`() { + loggedIn() + assertIs( + router.dispatch(DeepLink(verifyUrlWithRawClientData("notjson"))) + ) + } + + @Test + fun `dispatch returns None for client data origin that is not base64`() { + loggedIn() + assertIs( + router.dispatch(DeepLink(verifyUrlWithRawClientData("""{"origin":"!!!not base64!!!"}"""))) + ) + } + + // endregion + @Test fun `dispatch returns None for email verification without client data`() { loggedIn() @@ -383,4 +456,92 @@ class AppRouterTest { } // endregion + + // region jump.flipcash.com — redirector unwrapping + + private fun jump(target: String) = + DeepLink("https://jump.flipcash.com/#source=" + URLEncoder.encode(target, "UTF-8")) + + @Test + fun `jump link unwraps to the wrapped token link`() { + val type = router.classify(jump("https://app.flipcash.com/token/$MINT")) + assertIs(type) + assertEquals(Mint(MINT), type.mint) + } + + @Test + fun `jump link unwraps to the wrapped cash link`() { + val type = router.classify(jump("https://send.flipcash.com/c/e=someEntropy")) + assertIs(type) + assertEquals("someEntropy", type.entropy) + } + + @Test + fun `jump link unwraps to the wrapped login link`() { + val type = router.classify(jump("https://app.flipcash.com/login/e=abc123")) + assertIs(type) + assertEquals("abc123", type.entropy) + } + + @Test + fun `jump link preserves an unencoded query string in the wrapped url`() { + // The producer may leave `&` raw in the fragment; everything after `source=` is the target. + val type = router.classify( + DeepLink("https://jump.flipcash.com/#source=https%3A%2F%2Fapp.flipcash.com%2Fverify%3Femail%3Da%40b.com%26code%3D123456") + ) + assertIs(type) + assertEquals("a@b.com", type.email) + assertEquals("123456", type.code) + } + + @Test + fun `jump unwrapping decodes percent escapes only, not plus-as-space`() { + // A raw `+` in the fragment is a literal plus in the wrapped URL's path. Decoding with + // URLDecoder (form semantics) would turn it into a space and corrupt the payload; + // Uri.decode, like iOS's removingPercentEncoding, leaves it alone. + val type = router.classify( + DeepLink("https://jump.flipcash.com/#source=https%3A%2F%2Fapp.flipcash.com%2Flogin%2Fe%3Dabc+def") + ) + assertIs(type) + assertEquals("abc+def", type.entropy) + } + + @Test + fun `jump link dispatches like the wrapped link`() { + loggedIn() + val action = router.dispatch(jump("https://app.flipcash.com/token/$MINT")) + assertIs(action) + assertEquals(AppRoute.Sheets.Wallet, action.routes[0]) + assertIs(action.routes[1]) + } + + @Test + fun `jump link with no source fragment classifies to null`() { + assertNull(router.classify(DeepLink("https://jump.flipcash.com/"))) + assertNull(router.classify(DeepLink("https://jump.flipcash.com/#other=1"))) + assertNull(router.classify(DeepLink("https://jump.flipcash.com/#source="))) + } + + @Test + fun `jump link wrapping an unroutable url classifies to null`() { + assertNull(router.classify(jump("https://app.flipcash.com/chat/abc"))) + assertNull(router.classify(jump("not a url at all"))) + } + + @Test + fun `nested jump links are not followed`() { + val inner = "https://jump.flipcash.com/#source=" + + URLEncoder.encode("https://app.flipcash.com/token/$MINT", "UTF-8") + assertNull(router.classify(jump(inner))) + } + + @Test + fun `jump host is matched case insensitively`() { + val type = router.classify( + DeepLink("https://JUMP.Flipcash.com/#source=" + URLEncoder.encode("https://app.flipcash.com/token/$MINT", "UTF-8")) + ) + assertIs(type) + } + + // endregion } From 30ce43f4e1419d8e8e5116a9bf2f2990ab654747 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 20 Aug 2026 09:47:47 -0400 Subject: [PATCH 3/6] fix(deeplinks): stop decoding /verify query params 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 with it. Drops both calls. Adds regression coverage for each case. --- .../flipcash/app/router/internal/AppRouter.kt | 9 ++-- .../app/router/internal/AppRouterTest.kt | 50 +++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt index 53d02718ef..e97112041e 100644 --- a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt +++ b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt @@ -25,7 +25,6 @@ import com.getcode.utils.TraceType import com.getcode.utils.decodeBase64 import com.getcode.utils.decodeBase64UrlSafe import com.getcode.utils.trace -import com.getcode.utils.urlDecode import dev.theolm.rinku.DeepLink import org.json.JSONObject import java.util.UUID @@ -247,11 +246,15 @@ private fun DeepLink.handleTipCard(): DeeplinkType.Tipcard? { // https://app.flipcash.com/verify?email={email}&code={code}&client_data={data} private fun DeepLink.handleEmailVerification(): DeeplinkType.EmailVerification? { val uri = data.toUri() - val email = uri.getQueryParameter("email")?.urlDecode() + // No urlDecode here: getQueryParameter already percent-decodes. Decoding twice mangles a + // plus-tagged address (`user%2Btag@` -> `user tag@`) and a base64 client_data payload (`+` + // is in the standard alphabet), and URLDecoder throws outright once a `%25` has become a + // bare `%` -- which would drop the whole link. + val email = uri.getQueryParameter("email") val code = uri.getQueryParameter("code") // client_data is optional and untrusted: a malformed or absent payload just means "no origin", // which resolveEmailVerification already handles. Never let it fail the whole link. - val clientData = uri.getQueryParameter("client_data")?.urlDecode() + val clientData = uri.getQueryParameter("client_data") ?.let { runCatching { JSONObject(it) }.getOrNull() } val origin = clientData?.optString("origin") diff --git a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt index b0c3452339..c22a48d820 100644 --- a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt +++ b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt @@ -127,6 +127,56 @@ class AppRouterTest { assertEquals("myaccount", type.origin) } + /** + * `Uri.getQueryParameter` already percent-decodes. Decoding its result a second time + * corrupts any address whose local part carries an encoded `+` -- a plus-tagged address + * would arrive as `user tag@`, and the verification code would then be checked against an + * email the user never entered. + */ + @Test + fun `classify does not decode the email query parameter twice`() { + val type = router.classify( + DeepLink("https://app.flipcash.com/verify?email=user%2Btag%40example.com&code=123456") + ) + assertIs(type) + assertEquals("user+tag@example.com", type.email) + } + + /** + * A second decode is not just lossy, it throws: once `getQueryParameter` has turned `%25` + * into a bare `%`, `URLDecoder` sees an incomplete escape and fails, which drops the whole + * link. A literal `%` is legal in an address local part. + */ + @Test + fun `classify keeps a literal percent in the email instead of dropping the link`() { + val type = router.classify( + DeepLink("https://app.flipcash.com/verify?email=100%25off%40example.com&code=123456") + ) + assertIs(type) + assertEquals("100%off@example.com", type.email) + } + + /** + * Same double-decode applied to `client_data`. The origin is standard base64, whose alphabet + * includes `+`, so a second (form-semantics) decode turns that `+` into a space and the + * origin silently fails to decode -- taking the routing destination with it. + */ + @Test + fun `classify does not decode client data twice`() { + val origin = Base64.encodeToString("aa>".toByteArray(), Base64.NO_WRAP) + assertTrue(origin.contains('+'), "fixture must exercise a '+' in the base64 payload") + + val clientData = """{"origin":"$origin"}""" + val url = "https://app.flipcash.com/verify" + + "?email=test%40example.com" + + "&code=123456" + + "&client_data=${URLEncoder.encode(clientData, "UTF-8")}" + + val type = router.classify(DeepLink(url)) + assertIs(type) + assertEquals("aa>", type.origin) + } + // endregion // region classify — Unknown From 56156ea8bfddb8f3a200e915ecd5a82f8cc60d8f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 20 Aug 2026 10:05:00 -0400 Subject: [PATCH 4/6] test(deeplinks): pin the /verify query-parameter decode contract 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. --- .../app/router/internal/AppRouterTest.kt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt index c22a48d820..baf37436f0 100644 --- a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt +++ b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt @@ -156,6 +156,22 @@ class AppRouterTest { assertEquals("100%off@example.com", type.email) } + /** + * Characterises the decode `handleEmailVerification` relies on. `Uri.getQueryParameter` is a + * form decoder -- `%2B` comes back as `+`, a literal `+` comes back as a space -- which is the + * exact inverse of the percent-plus-`+`-for-space encoder the client uses to build these + * links (PhantomDeeplinkProtocol.urlEncode, and URLEncoder elsewhere). One decode round-trips + * the producer; a second one does not. + */ + @Test + fun `query parameters are form-decoded exactly once`() { + val type = router.classify( + DeepLink("https://app.flipcash.com/verify?email=a%40b.com&code=enc%2Blit+sp") + ) + assertIs(type) + assertEquals("enc+lit sp", type.code) + } + /** * Same double-decode applied to `client_data`. The origin is standard base64, whose alphabet * includes `+`, so a second (form-semantics) decode turns that `+` into a space and the From 3d84f6087a818db6d57163a62193ed6ff4dfe7ee Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 20 Aug 2026 11:21:41 -0400 Subject: [PATCH 5/6] test(deeplinks): cover /verify dispatch for both origins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- .../app/router/internal/AppRouterTest.kt | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt index baf37436f0..f35cd20cee 100644 --- a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt +++ b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt @@ -211,6 +211,39 @@ class AppRouterTest { // endregion + // region dispatch — Logged in: EmailVerification + + private fun verifyUrl(originPlain: String): String { + val origin = Base64.encodeToString(originPlain.toByteArray(), Base64.NO_WRAP) + val clientData = """{"origin":"$origin"}""" + return "https://app.flipcash.com/verify" + + "?email=test%40example.com" + + "&code=123456" + + "&client_data=${URLEncoder.encode(clientData, "UTF-8")}" + } + + @Test + fun `dispatch returns Navigate to menu tab for a myaccount verify deeplink`() { + loggedIn() + val action = router.dispatch(DeepLink(verifyUrl("myaccount"))) + assertIs(action) + assertEquals(AppRoute.Sheets.Menu, action.routes[0]) + assertIs(action.routes[1]) + assertIs(action.routes[2]) + } + + @Test + fun `dispatch returns Navigate to the swap flow for an onramp verify deeplink`() { + loggedIn() + val action = router.dispatch(DeepLink(verifyUrl("onramp|amountentry|$MINT"))) + assertIs(action) + assertIs(action.routes[0]) + assertIs(action.routes[1]) + assertIs(action.routes[2]) + } + + // endregion + // region dispatch — Not logged in @Test From e31d2e6c3b83f9b96d5d6583d24503d5b564b923 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 20 Aug 2026 11:22:05 -0400 Subject: [PATCH 6/6] fix(deeplinks): open /token links as the wallet's expanded card 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. --- .../com/flipcash/app/internal/ui/App.kt | 47 +++++++++++++++++++ .../app/internal/ui/navigation/AppContent.kt | 21 ++------- .../internal/ui/navigation/CardExpandHost.kt | 17 +++++++ .../app/internal/ui/navigation/MainRoot.kt | 17 +++++++ .../navigation/BuildNavGraphForLaunchTest.kt | 39 +++++++++++++++ .../flipcash/app/cardexpand/CardExpansion.kt | 19 ++++++++ .../app/core/navigation/DeeplinkAction.kt | 18 +++++++ .../app/tokens/CurrencyInfoExpansion.kt | 6 ++- .../flipcash/app/router/internal/AppRouter.kt | 11 ++++- .../app/router/internal/AppRouterTest.kt | 26 ++++++---- 10 files changed, 191 insertions(+), 30 deletions(-) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt index 03f6b425f3..f9285950b4 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId @@ -33,6 +34,7 @@ import androidx.navigation3.scene.SinglePaneSceneStrategy import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.android.BuildConfig import com.flipcash.app.bill.customization.BillPlaygroundScaffold +import com.flipcash.app.cardexpand.CardExpansionController import com.flipcash.app.core.AppRoute import com.flipcash.app.core.LocalUserManager import com.flipcash.app.core.extensions.navigateAll @@ -118,6 +120,26 @@ internal fun App( val isNewUi by features.observe(FeatureFlag.NewUi).collectAsStateWithLifecycle() + // Card-expand (iOS #587) is owned HERE rather than inside NewAppContent because the deeplink + // handling below sits outside the v1/v2 shells: a `/token` link opens the wallet's expanded card + // (see DeeplinkAction.OpenToken), which needs the controller. NewAppContent provides it to the + // tree; v1 has no expansion and never touches it. + val context = LocalContext.current + val cardExpansion = remember(context) { + CardExpansionController().apply { + // Feed the controller the user's real animation-scale so the expand honours "animations + // off" (accessibility/battery) yet isn't fooled by a stale ambient MotionDurationScale. + val resolver = context.contentResolver + animationScale = { + android.provider.Settings.Global.getFloat( + resolver, + android.provider.Settings.Global.ANIMATOR_DURATION_SCALE, + 1f, + ) + } + } + } + FlipcashTheme { rememberQrBitmapPainter( content = stringResource( @@ -162,10 +184,16 @@ internal fun App( codeNavigator = codeNavigator, resultStateRegistry = resultStateRegistry, barManager = barManager, + cardExpansion = cardExpansion, deepLink = { deepLink }, onPendingAction = { action -> deeplinkHandled = true when (action) { + // v2 cold start: the wallet is already the + // launch home, so the token just opens as its + // expanded card on top of it. + is DeeplinkAction.OpenToken -> + cardExpansion.beginExpanded(action.mint) is DeeplinkAction.OpenCashLink -> session.openCashLink(action.entropy) is DeeplinkAction.PresentTipCard -> @@ -264,6 +292,25 @@ internal fun App( } } + is DeeplinkAction.OpenToken -> { + if (isNewUi) { + // Land on the wallet tab (clearing anything pushed on + // it) and open the token as its EXPANDED CARD — the + // same overlay, chrome and dismissal a tap on the card + // gives. Pushing it instead reads as a modal on a stack + // the user never navigated. Mirrors iOS + // DeepLinkController's requestedCardMint. + codeNavigator.navigateAll( + listOf(AppRoute.Sheets.Wallet), + isNewUi = true, + ) + cardExpansion.beginExpanded(action.mint) + } else { + // v1 has no card expansion — open the wallet sheet. + codeNavigator.navigateAll(action.routes) + } + } + is DeeplinkAction.Login -> viewModel.handleLoginEntropy( action.entropy, onSwitchAccount = { diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt index 26f43b3c09..173d38db0a 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.platform.LocalContext import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -166,6 +165,7 @@ internal fun NewAppContent( codeNavigator: CodeNavigator, resultStateRegistry: NavResultStateRegistry, barManager: BarManager, + cardExpansion: CardExpansionController, deepLink: () -> DeepLink?, onPendingAction: (DeeplinkAction) -> Unit = {}, ) { @@ -179,23 +179,10 @@ internal fun NewAppContent( val tabBarHeight = remember { mutableStateOf(0.dp) } // Card-expand (iOS #587): the wallet requests an expansion (via LocalCardExpansion); the detail is - // drawn HERE as an overlay above the nav content, driven by one progress scalar, so the deck stays + // drawn by CardExpandHost inside the wallet entry, driven by one progress scalar, so the deck stays // composed and reorganises behind it. See CardExpansionController / CurrencyInfoExpansion. - val context = LocalContext.current - val cardExpansion = remember(context) { - CardExpansionController().apply { - // Feed the controller the user's real animation-scale so the expand honours "animations - // off" (accessibility/battery) yet isn't fooled by a stale ambient MotionDurationScale. - val resolver = context.contentResolver - animationScale = { - android.provider.Settings.Global.getFloat( - resolver, - android.provider.Settings.Global.ANIMATOR_DURATION_SCALE, - 1f, - ) - } - } - } + // [cardExpansion] is owned by App so a `/token` deeplink — which is handled there, outside this + // shell — can open a token as its expanded card instead of pushing a screen. CompositionLocalProvider(LocalCardExpansion provides cardExpansion) { Box(modifier = Modifier.fillMaxSize()) { // Mark the nav content as the haze source so the frosted bar blurs whatever scrolls beneath it. diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/CardExpandHost.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/CardExpandHost.kt index 51973bbdfe..7c7b7fe779 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/CardExpandHost.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/CardExpandHost.kt @@ -3,12 +3,16 @@ package com.flipcash.app.internal.ui.navigation import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import com.flipcash.app.cardexpand.CardExpansionController import com.flipcash.app.cardexpand.LocalCardExpansion import com.flipcash.app.tokens.CurrencyInfoExpansion import com.getcode.solana.keys.Mint +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch /** @@ -38,6 +42,19 @@ internal fun CardExpandHost(content: @Composable () -> Unit) { } } + // A source-less expansion (a deeplink — CardExpansionController.beginExpanded) has no deck card to + // fly from, so there is nothing to animate: it lands already open, like iOS's + // `WalletScreen.openCardImmediately`. Wait for the overlay's hero slot to report its frame before + // snapping, so the first fully-visible frame already has its card in place rather than an empty slot. + LaunchedEffect(controller.expandedKey) { + val key = controller.expandedKey ?: return@LaunchedEffect + if (controller.sourceBounds != null) return@LaunchedEffect + snapshotFlow { controller.heroBounds }.filterNotNull().first() + if (controller.expandedKey === key) { + controller.snapTo(1f) + } + } + Box(modifier = Modifier) { content() diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt index 294c72c038..991feab8cd 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt @@ -229,6 +229,23 @@ internal fun buildNavGraphForLaunch( isNewUi = isNewUi, ) + // v2 opens a token link as the wallet's expanded card (a pending action applied + // on top of the wallet home, which is already the launch base); v1 has no card + // expansion, so it takes the route form — the wallet sheet with token info inside. + is DeeplinkAction.OpenToken -> if (isNewUi) { + LaunchNavGraph( + baseRoutes = listOf(home), + pendingAction = action, + isNewUi = true, + ) + } else { + LaunchNavGraph( + baseRoutes = listOf(home), + deeplinkRoutes = action.routes, + isNewUi = false, + ) + } + is DeeplinkAction.OpenCashLink, is DeeplinkAction.PresentTipCard, is DeeplinkAction.Login -> LaunchNavGraph( diff --git a/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt b/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt index a9945925ce..7b0624c01d 100644 --- a/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt +++ b/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt @@ -27,6 +27,10 @@ class BuildNavGraphForLaunchTest { private val dummyLink = DeepLink("https://send.flipcash.com/c/e=testEntropy") + private companion object { + const val MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + } + private fun build( state: AuthState, action: DeeplinkAction = DeeplinkAction.None, @@ -86,6 +90,41 @@ class BuildNavGraphForLaunchTest { assertEquals(action, result.pendingAction) } + @Test + fun `v2 opens a token deeplink as a pending action on the wallet home`() { + // The expanded card is applied on top of the wallet, which is already the v2 launch base -- + // no pushed screen, so nothing lands in deeplinkRoutes. See DeeplinkAction.OpenToken. + val action = DeeplinkAction.OpenToken( + mint = Mint(MINT), + routes = listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(Mint(MINT))), + ) + val result = build( + state = AuthState.Ready, + action = action, + deepLink = dummyLink, + isNewUi = true, + )!! + assertEquals(listOf(AppRoute.Sheets.Wallet), result.baseRoutes) + assertTrue(result.deeplinkRoutes.isEmpty()) + assertEquals(action, result.pendingAction) + } + + @Test + fun `v1 opens a token deeplink through the carried route form`() { + // v1 has no card expansion, so the same action is taken as routes: the wallet sheet with + // token info inside it. + val routes = listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(Mint(MINT))) + val result = build( + state = AuthState.Ready, + action = DeeplinkAction.OpenToken(mint = Mint(MINT), routes = routes), + deepLink = dummyLink, + isNewUi = false, + )!! + assertEquals(listOf(AppRoute.Main.Scanner), result.baseRoutes) + assertEquals(routes, result.deeplinkRoutes) + assertNull(result.pendingAction) + } + @Test fun `logged in with None action navigates to Scanner without deeplink routes`() { val result = build( diff --git a/apps/flipcash/card-expand/src/main/kotlin/com/flipcash/app/cardexpand/CardExpansion.kt b/apps/flipcash/card-expand/src/main/kotlin/com/flipcash/app/cardexpand/CardExpansion.kt index c43da417cc..3ed41083c5 100644 --- a/apps/flipcash/card-expand/src/main/kotlin/com/flipcash/app/cardexpand/CardExpansion.kt +++ b/apps/flipcash/card-expand/src/main/kotlin/com/flipcash/app/cardexpand/CardExpansion.kt @@ -91,6 +91,25 @@ class CardExpansionController { pullOffset = 0f } + /** + * Begin expanding [key] with **no source card** — there is nothing on screen to fly from, so the + * expansion has no travel and simply lands already open. Used by deeplinks, which arrive with the + * wallet not yet on screen (and for a token the user may not even hold, so no deck card exists). + * Mirrors iOS `WalletScreen.openCardImmediately`. + * + * The host snaps [progress] to 1 once the expanded hero frame has been measured — see + * `CardExpandHost`. + */ + fun beginExpanded(key: Any) { + expandedKey = key + sourceBounds = null + // [heroBounds] is deliberately NOT cleared here (unlike [begin]): with no flight it is not a + // per-card fly target but the overlay's fixed hero slot, identical for every card. Keeping a + // previous measurement lets the host snap open on the very first frame instead of waiting for + // a re-measure — and when there is none (cold start) it is null anyway. + pullOffset = 0f + } + /** Animate [progress] to [target] (0 collapse / 1 expand) with the shared card-expand spring. */ suspend fun animateTo(target: Float, spec: AnimationSpec = ExpandSpring) { // Drive the animation under the user's real duration scale (read once per run) rather than the diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkAction.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkAction.kt index 5d90d335d8..4f5a318676 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkAction.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkAction.kt @@ -1,11 +1,29 @@ package com.flipcash.app.core.navigation import com.getcode.opencode.model.core.ID +import com.getcode.solana.keys.Mint sealed interface DeeplinkAction { data class Navigate(val routes: List) : DeeplinkAction data class Login(val entropy: String) : DeeplinkAction data class OpenCashLink(val entropy: String) : DeeplinkAction data class PresentTipCard(val userId: ID): DeeplinkAction + + /** + * A `/token/{mint}` link. + * + * v2 opens it as the wallet's *expanded card* — the same overlay a tap on the card produces, + * with the same chrome (✕, no back chevron) and the same dismissal. Pushing it instead gives a + * screen that belongs to a stack the user never navigated. Mirrors iOS `DeepLinkController`, + * which sets `router.requestedCardMint` rather than pushing `.currencyInfo`. + * + * v1 has no card expansion, so [routes] carries the equivalent presentation there (the wallet + * sheet with token info inside it). + */ + data class OpenToken( + val mint: Mint, + val routes: List, + ) : DeeplinkAction + data object None : DeeplinkAction } diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/CurrencyInfoExpansion.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/CurrencyInfoExpansion.kt index 8e2da015e1..8f5533e576 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/CurrencyInfoExpansion.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/CurrencyInfoExpansion.kt @@ -334,10 +334,14 @@ fun CurrencyInfoExpansion( // at progress ≈ 0, where the deck's own (coincident) card stands in, so skipping the overlay // hero here is invisible — the correct-mint hero simply appears a frame or two into the flight. val token = (state.token as? Loadable.Loaded)?.data?.takeIf { it.address == mint } - val source = controller.sourceBounds // Prefer the surviving controller bounds so a torn-down/re-inflated overlay (returning from a // pushed action) can draw the hero before its own placeholder re-measures. val target = heroTarget ?: controller.heroBounds + // A source-less expansion (deeplink — see CardExpansionController.beginExpanded) has no deck + // card to fly from, so the hero starts where it ends: source == target makes the flight a + // no-op (scale 1, zero translation) while keeping everything else — the pull-to-close ride and + // the fade hand-off back to the deck on collapse — working exactly as it does for a tap. + val source = controller.sourceBounds ?: target if (token != null && source != null && target != null && target.width > 0f) { val density = LocalDensity.current val isHeld = state.showTransactionHistory || state.balance.nativeAmount.isPositive diff --git a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt index e97112041e..095e69b572 100644 --- a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt +++ b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt @@ -66,8 +66,15 @@ internal class AppRouter( is DeeplinkType.CashLink -> DeeplinkAction.OpenCashLink(type.entropy) - is DeeplinkType.TokenInfo -> DeeplinkAction.Navigate( - listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(type.mint, fromDeeplink = true)) + // Not a plain Navigate: under v2 a token link lands as the wallet's expanded card, not + // as a pushed screen. The route form is carried along for v1, which has no expansion. + // See DeeplinkAction.OpenToken. + is DeeplinkType.TokenInfo -> DeeplinkAction.OpenToken( + mint = type.mint, + routes = listOf( + AppRoute.Sheets.Wallet, + AppRoute.Token.Info(type.mint, fromDeeplink = true), + ), ) is DeeplinkType.EmailVerification -> resolveEmailVerification(type) diff --git a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt index f35cd20cee..4c31ea26a7 100644 --- a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt +++ b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt @@ -310,14 +310,23 @@ class AppRouterTest { // endregion - // region dispatch — Logged in: TokenInfo (sheet navigation) + // region dispatch — Logged in: TokenInfo @Test - fun `dispatch returns Navigate with wallet sheet and token info for token deeplink`() { + fun `dispatch returns OpenToken carrying the mint for a token deeplink`() { loggedIn() val mint = "So11111111111111111111111111111111111111112" val action = router.dispatch(DeepLink("https://app.flipcash.com/token/$mint")) - assertIs(action) + assertIs(action) + assertEquals(Mint(mint), action.mint) + } + + @Test + fun `OpenToken carries the wallet sheet route form for v1`() { + loggedIn() + val mint = "So11111111111111111111111111111111111111112" + val action = router.dispatch(DeepLink("https://app.flipcash.com/token/$mint")) + assertIs(action) assertEquals(2, action.routes.size) assertIs(action.routes[0]) val tokenInfo = action.routes[1] @@ -548,10 +557,8 @@ class AppRouterTest { assertIs(classified) val dispatched = router.dispatch(deepLink) - assertIs(dispatched) - val tokenInfo = dispatched.routes[1] - assertIs(tokenInfo) - assertEquals(classified.mint, tokenInfo.mint) + assertIs(dispatched) + assertEquals(classified.mint, dispatched.mint) } // endregion @@ -609,9 +616,8 @@ class AppRouterTest { fun `jump link dispatches like the wrapped link`() { loggedIn() val action = router.dispatch(jump("https://app.flipcash.com/token/$MINT")) - assertIs(action) - assertEquals(AppRoute.Sheets.Wallet, action.routes[0]) - assertIs(action.routes[1]) + assertIs(action) + assertEquals(Mint(MINT), action.mint) } @Test