From 0183332abea1ae29bffec40631c69e2e1147311c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 29 May 2026 23:11:58 -0400 Subject: [PATCH] fix(deeplink): handle cash link and login deeplinks on cold launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LaunchedEffect(deepLink) in App.kt fired once during cold start, saw currentRouteKey was Loading, and bailed out. MainRoot intentionally defers OpenCashLink/Login actions to App.kt (navigates to plain Scanner without forwarding the entropy). Since deepLink never changed, the effect never re-fired and the link was permanently lost. Add currentRoute as a LaunchedEffect key so the effect re-launches when MainRoot replaces the backstack from Loading to Scanner. The deep link is then dispatched normally (session.openCashLink or viewModel.handleLoginEntropy). The LaunchedEffect(deepLink) single-key pattern was a latent race introduced in 1219d38da (Nav3 migration). It relied on MainRoot transitioning away from Loading before Rinku delivered the deep link (~2-3 frames of async delay). This worked because PassphraseCredentialManager.login() set AuthState.LoggedInWithUser immediately on the fast path — before any network calls — so MainRoot navigated past Loading near-instantly. cfe2964e3 (#778) changed that fast path from updateUserManager(id, LoggedInWithUser) to just userManager.set(selectedMetadata.id), deferring the auth state transition until after the getUserFlags() network call. This shifted the timing so Rinku now consistently delivers the deep link while still on Loading, the LaunchedEffect bails, and the link is lost. Latent fragility: 1219d38da (fcash/2026.3.4) Surfaced by: cfe2964e3 (fcash/2026.5.6) Signed-off-by: Brandon McAnsh --- apps/flipcash/app/build.gradle.kts | 3 + .../com/flipcash/app/internal/ui/App.kt | 9 +- .../app/internal/ui/navigation/MainRoot.kt | 8 +- .../navigation/BuildNavGraphForLaunchTest.kt | 149 ++++++++++++++++++ 4 files changed, 162 insertions(+), 7 deletions(-) create mode 100644 apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt diff --git a/apps/flipcash/app/build.gradle.kts b/apps/flipcash/app/build.gradle.kts index ff4ff6f12..6a8823528 100644 --- a/apps/flipcash/app/build.gradle.kts +++ b/apps/flipcash/app/build.gradle.kts @@ -271,4 +271,7 @@ dependencies { implementation(libs.timber) implementation(libs.bugsnag) + + testImplementation(libs.junit) + testImplementation(libs.kotlin.test.junit) } 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 5aa6acb45..ae80ab06b 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 @@ -234,11 +234,14 @@ internal fun App( } val emailCodeChannel = LocalEmailCodeChannel.current - LaunchedEffect(deepLink) { + val currentRoute = codeNavigator.currentRouteKey + LaunchedEffect(deepLink, currentRoute) { val link = deepLink ?: return@LaunchedEffect - if (codeNavigator.currentRouteKey is AppRoute.Loading) { - // Cold start — MainRoot handles it via the deepLink lambda + if (currentRoute is AppRoute.Loading) { + // Cold start — MainRoot handles Navigate actions; + // other actions (OpenCashLink, Login) wait until + // navigation leaves Loading. return@LaunchedEffect } 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 25cb56889..0f59a4789 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 @@ -143,7 +143,7 @@ internal fun MainRoot(deepLink: () -> DeepLink?) { * [deeplinkRoutes] are applied via navigateTo, which handles sheet wrapping * so deeplinks targeting screens inside sheets render correctly. */ -private data class LaunchNavGraph( +internal data class LaunchNavGraph( val baseRoutes: List, val deeplinkRoutes: List = emptyList(), ) { @@ -171,7 +171,7 @@ private fun List.startsWith(prefix: List): Boolean { return prefix.indices.all { i -> this[i]::class == prefix[i]::class } } -private fun buildNavGraphForLaunch( +internal fun buildNavGraphForLaunch( state: AuthState, userFlags: UserFlags?, router: Router, @@ -196,8 +196,8 @@ private fun buildNavGraphForLaunch( baseRoutes = listOf(AppRoute.Main.Scanner), deeplinkRoutes = action.routes, ) - // ExternalWallet/Login/OpenCashLink can't be handled on cold start - // (encryption state lost, no session yet) — fall through to Scanner + // OpenCashLink/Login/ExternalWallet are handled by App.kt's + // LaunchedEffect(deepLink, currentRoute) once we leave Loading. else -> LaunchNavGraph(listOf(AppRoute.Main.Scanner)) } } else { 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 new file mode 100644 index 000000000..42cb93068 --- /dev/null +++ b/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt @@ -0,0 +1,149 @@ +package com.flipcash.app.internal.ui.navigation + +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.navigation.DeeplinkAction +import com.flipcash.app.core.navigation.DeeplinkType +import com.flipcash.app.router.Router +import com.flipcash.services.user.AuthState +import dev.theolm.rinku.DeepLink +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class BuildNavGraphForLaunchTest { + + // -- Helpers -- + + /** Router that returns a fixed action for any deeplink. */ + private class FakeRouter(private val action: DeeplinkAction) : Router { + override fun dispatch(deepLink: DeepLink): DeeplinkAction = action + override fun classify(deepLink: DeepLink): DeeplinkType? = null + } + + private val dummyLink = DeepLink("https://send.flipcash.com/c/e=testEntropy") + + private fun build( + state: AuthState, + action: DeeplinkAction = DeeplinkAction.None, + deepLink: DeepLink? = null, + ): LaunchNavGraph? = buildNavGraphForLaunch( + state = state, + userFlags = null, + router = FakeRouter(action), + deepLink = { deepLink }, + ) + + // -- LoggedInWithUser -- + + @Test + fun `logged in without deeplink navigates to Scanner`() { + val result = build(AuthState.LoggedInWithUser)!! + assertEquals(listOf(AppRoute.Main.Scanner), result.baseRoutes) + assertTrue(result.deeplinkRoutes.isEmpty()) + } + + @Test + fun `logged in with Navigate deeplink includes deeplink routes`() { + val routes = listOf(AppRoute.Main.Scanner) + val result = build( + state = AuthState.LoggedInWithUser, + action = DeeplinkAction.Navigate(routes), + deepLink = dummyLink, + )!! + assertEquals(listOf(AppRoute.Main.Scanner), result.baseRoutes) + assertEquals(routes, result.deeplinkRoutes) + } + + @Test + fun `logged in with OpenCashLink defers to App for dispatch`() { + val result = build( + state = AuthState.LoggedInWithUser, + action = DeeplinkAction.OpenCashLink("testEntropy"), + deepLink = dummyLink, + )!! + assertEquals(listOf(AppRoute.Main.Scanner), result.baseRoutes) + assertTrue(result.deeplinkRoutes.isEmpty(), "OpenCashLink must not be consumed by MainRoot") + } + + @Test + fun `logged in with Login action defers to App for dispatch`() { + val result = build( + state = AuthState.LoggedInWithUser, + action = DeeplinkAction.Login("seed"), + deepLink = dummyLink, + )!! + assertEquals(listOf(AppRoute.Main.Scanner), result.baseRoutes) + assertTrue(result.deeplinkRoutes.isEmpty(), "Login must not be consumed by MainRoot") + } + + @Test + fun `logged in with None action navigates to Scanner without deeplink routes`() { + val result = build( + state = AuthState.LoggedInWithUser, + action = DeeplinkAction.None, + deepLink = dummyLink, + )!! + assertEquals(listOf(AppRoute.Main.Scanner), result.baseRoutes) + assertTrue(result.deeplinkRoutes.isEmpty()) + } + + // -- LoggedOut / Unknown -- + + @Test + fun `logged out without deeplink navigates to OnboardingFlow`() { + val result = build(AuthState.LoggedOut)!! + assertIs(result.baseRoutes.single()) + } + + @Test + fun `logged out with Navigate deeplink uses action routes`() { + val routes = listOf(AppRoute.OnboardingFlow(seed = "abc")) + val result = build( + state = AuthState.LoggedOut, + action = DeeplinkAction.Navigate(routes), + deepLink = dummyLink, + )!! + assertEquals(routes, result.baseRoutes) + } + + @Test + fun `logged out with OpenCashLink falls back to OnboardingFlow`() { + val result = build( + state = AuthState.LoggedOut, + action = DeeplinkAction.OpenCashLink("entropy"), + deepLink = dummyLink, + )!! + assertIs(result.baseRoutes.single()) + } + + @Test + fun `unknown auth state without deeplink navigates to OnboardingFlow`() { + val result = build(AuthState.Unknown)!! + assertIs(result.baseRoutes.single()) + } + + // -- Registered -- + + @Test + fun `registered without seenAccessKey resumes at AccessKey`() { + val result = build(AuthState.Registered(seenAccessKey = false))!! + val route = assertIs(result.baseRoutes.single()) + assertEquals(AppRoute.OnboardingFlow.ResumePoint.AccessKey, route.resumeAt) + } + + @Test + fun `registered with seenAccessKey resumes at PostAccessKey`() { + val result = build(AuthState.Registered(seenAccessKey = true))!! + val route = assertIs(result.baseRoutes.single()) + assertEquals(AppRoute.OnboardingFlow.ResumePoint.PostAccessKey, route.resumeAt) + } + + // -- LoggedInAwaitingUser -- + + @Test + fun `awaiting user returns null`() { + assertNull(build(AuthState.LoggedInAwaitingUser)) + } +}