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
}