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/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..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 -> @@ -257,6 +285,28 @@ internal fun App( } else false if (!delivered) { + codeNavigator.navigateAll( + action.routes, + isNewUi = isNewUi, + ) + } + } + + 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) } } 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 38d2ef4fbd..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 @@ -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,19 +226,38 @@ internal fun buildNavGraphForLaunch( is DeeplinkAction.Navigate -> LaunchNavGraph( baseRoutes = listOf(home), deeplinkRoutes = action.routes, + 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( 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..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 @@ -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 @@ -24,14 +27,19 @@ 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, deepLink: DeepLink? = null, + isNewUi: Boolean = false, ): LaunchNavGraph? = buildNavGraphForLaunch( state = state, router = FakeRouter(action), - isNewUi = false, + isNewUi = isNewUi, deepLink = { deepLink }, ) @@ -82,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( @@ -169,4 +212,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/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/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/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/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/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/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/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/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..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 @@ -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,9 +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.urlDecode +import com.getcode.utils.trace import dev.theolm.rinku.DeepLink import org.json.JSONObject import java.util.UUID @@ -37,6 +39,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 { @@ -58,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) @@ -73,7 +88,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 +119,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 +169,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)) @@ -190,14 +253,22 @@ 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") - val clientData = uri.getQueryParameter("client_data")?.urlDecode() - ?.let { JSONObject(it) } - - val origin = clientData?.getString("origin")?.decodeBase64()?.let { - String(it, Charsets.UTF_8) - } + // 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") + ?.let { runCatching { JSONObject(it) }.getOrNull() } + + 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..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 @@ -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 }) @@ -123,6 +127,72 @@ 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) + } + + /** + * 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 + * 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 @@ -141,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 @@ -207,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] @@ -319,6 +431,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() @@ -376,10 +557,95 @@ 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 + + // 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(Mint(MINT), action.mint) + } + + @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 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 }