Skip to content

Commit e54d1a9

Browse files
authored
fix(deeplink): handle cash link and login deeplinks on cold launch (#810)
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 1219d38 (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. cfe2964 (#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: 1219d38 (fcash/2026.3.4) Surfaced by: cfe2964 (fcash/2026.5.6) Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent 9801d29 commit e54d1a9

4 files changed

Lines changed: 162 additions & 7 deletions

File tree

apps/flipcash/app/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,4 +271,7 @@ dependencies {
271271

272272
implementation(libs.timber)
273273
implementation(libs.bugsnag)
274+
275+
testImplementation(libs.junit)
276+
testImplementation(libs.kotlin.test.junit)
274277
}

apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -234,11 +234,14 @@ internal fun App(
234234
}
235235

236236
val emailCodeChannel = LocalEmailCodeChannel.current
237-
LaunchedEffect(deepLink) {
237+
val currentRoute = codeNavigator.currentRouteKey
238+
LaunchedEffect(deepLink, currentRoute) {
238239
val link = deepLink ?: return@LaunchedEffect
239240

240-
if (codeNavigator.currentRouteKey is AppRoute.Loading) {
241-
// Cold start — MainRoot handles it via the deepLink lambda
241+
if (currentRoute is AppRoute.Loading) {
242+
// Cold start — MainRoot handles Navigate actions;
243+
// other actions (OpenCashLink, Login) wait until
244+
// navigation leaves Loading.
242245
return@LaunchedEffect
243246
}
244247

apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ internal fun MainRoot(deepLink: () -> DeepLink?) {
143143
* [deeplinkRoutes] are applied via navigateTo, which handles sheet wrapping
144144
* so deeplinks targeting screens inside sheets render correctly.
145145
*/
146-
private data class LaunchNavGraph(
146+
internal data class LaunchNavGraph(
147147
val baseRoutes: List<NavKey>,
148148
val deeplinkRoutes: List<AppRoute> = emptyList(),
149149
) {
@@ -171,7 +171,7 @@ private fun List<NavKey>.startsWith(prefix: List<NavKey>): Boolean {
171171
return prefix.indices.all { i -> this[i]::class == prefix[i]::class }
172172
}
173173

174-
private fun buildNavGraphForLaunch(
174+
internal fun buildNavGraphForLaunch(
175175
state: AuthState,
176176
userFlags: UserFlags?,
177177
router: Router,
@@ -196,8 +196,8 @@ private fun buildNavGraphForLaunch(
196196
baseRoutes = listOf(AppRoute.Main.Scanner),
197197
deeplinkRoutes = action.routes,
198198
)
199-
// ExternalWallet/Login/OpenCashLink can't be handled on cold start
200-
// (encryption state lost, no session yet) — fall through to Scanner
199+
// OpenCashLink/Login/ExternalWallet are handled by App.kt's
200+
// LaunchedEffect(deepLink, currentRoute) once we leave Loading.
201201
else -> LaunchNavGraph(listOf(AppRoute.Main.Scanner))
202202
}
203203
} else {
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package com.flipcash.app.internal.ui.navigation
2+
3+
import com.flipcash.app.core.AppRoute
4+
import com.flipcash.app.core.navigation.DeeplinkAction
5+
import com.flipcash.app.core.navigation.DeeplinkType
6+
import com.flipcash.app.router.Router
7+
import com.flipcash.services.user.AuthState
8+
import dev.theolm.rinku.DeepLink
9+
import kotlin.test.Test
10+
import kotlin.test.assertEquals
11+
import kotlin.test.assertIs
12+
import kotlin.test.assertNull
13+
import kotlin.test.assertTrue
14+
15+
class BuildNavGraphForLaunchTest {
16+
17+
// -- Helpers --
18+
19+
/** Router that returns a fixed action for any deeplink. */
20+
private class FakeRouter(private val action: DeeplinkAction) : Router {
21+
override fun dispatch(deepLink: DeepLink): DeeplinkAction = action
22+
override fun classify(deepLink: DeepLink): DeeplinkType? = null
23+
}
24+
25+
private val dummyLink = DeepLink("https://send.flipcash.com/c/e=testEntropy")
26+
27+
private fun build(
28+
state: AuthState,
29+
action: DeeplinkAction = DeeplinkAction.None,
30+
deepLink: DeepLink? = null,
31+
): LaunchNavGraph? = buildNavGraphForLaunch(
32+
state = state,
33+
userFlags = null,
34+
router = FakeRouter(action),
35+
deepLink = { deepLink },
36+
)
37+
38+
// -- LoggedInWithUser --
39+
40+
@Test
41+
fun `logged in without deeplink navigates to Scanner`() {
42+
val result = build(AuthState.LoggedInWithUser)!!
43+
assertEquals(listOf(AppRoute.Main.Scanner), result.baseRoutes)
44+
assertTrue(result.deeplinkRoutes.isEmpty())
45+
}
46+
47+
@Test
48+
fun `logged in with Navigate deeplink includes deeplink routes`() {
49+
val routes = listOf(AppRoute.Main.Scanner)
50+
val result = build(
51+
state = AuthState.LoggedInWithUser,
52+
action = DeeplinkAction.Navigate(routes),
53+
deepLink = dummyLink,
54+
)!!
55+
assertEquals(listOf(AppRoute.Main.Scanner), result.baseRoutes)
56+
assertEquals(routes, result.deeplinkRoutes)
57+
}
58+
59+
@Test
60+
fun `logged in with OpenCashLink defers to App for dispatch`() {
61+
val result = build(
62+
state = AuthState.LoggedInWithUser,
63+
action = DeeplinkAction.OpenCashLink("testEntropy"),
64+
deepLink = dummyLink,
65+
)!!
66+
assertEquals(listOf(AppRoute.Main.Scanner), result.baseRoutes)
67+
assertTrue(result.deeplinkRoutes.isEmpty(), "OpenCashLink must not be consumed by MainRoot")
68+
}
69+
70+
@Test
71+
fun `logged in with Login action defers to App for dispatch`() {
72+
val result = build(
73+
state = AuthState.LoggedInWithUser,
74+
action = DeeplinkAction.Login("seed"),
75+
deepLink = dummyLink,
76+
)!!
77+
assertEquals(listOf(AppRoute.Main.Scanner), result.baseRoutes)
78+
assertTrue(result.deeplinkRoutes.isEmpty(), "Login must not be consumed by MainRoot")
79+
}
80+
81+
@Test
82+
fun `logged in with None action navigates to Scanner without deeplink routes`() {
83+
val result = build(
84+
state = AuthState.LoggedInWithUser,
85+
action = DeeplinkAction.None,
86+
deepLink = dummyLink,
87+
)!!
88+
assertEquals(listOf(AppRoute.Main.Scanner), result.baseRoutes)
89+
assertTrue(result.deeplinkRoutes.isEmpty())
90+
}
91+
92+
// -- LoggedOut / Unknown --
93+
94+
@Test
95+
fun `logged out without deeplink navigates to OnboardingFlow`() {
96+
val result = build(AuthState.LoggedOut)!!
97+
assertIs<AppRoute.OnboardingFlow>(result.baseRoutes.single())
98+
}
99+
100+
@Test
101+
fun `logged out with Navigate deeplink uses action routes`() {
102+
val routes = listOf(AppRoute.OnboardingFlow(seed = "abc"))
103+
val result = build(
104+
state = AuthState.LoggedOut,
105+
action = DeeplinkAction.Navigate(routes),
106+
deepLink = dummyLink,
107+
)!!
108+
assertEquals(routes, result.baseRoutes)
109+
}
110+
111+
@Test
112+
fun `logged out with OpenCashLink falls back to OnboardingFlow`() {
113+
val result = build(
114+
state = AuthState.LoggedOut,
115+
action = DeeplinkAction.OpenCashLink("entropy"),
116+
deepLink = dummyLink,
117+
)!!
118+
assertIs<AppRoute.OnboardingFlow>(result.baseRoutes.single())
119+
}
120+
121+
@Test
122+
fun `unknown auth state without deeplink navigates to OnboardingFlow`() {
123+
val result = build(AuthState.Unknown)!!
124+
assertIs<AppRoute.OnboardingFlow>(result.baseRoutes.single())
125+
}
126+
127+
// -- Registered --
128+
129+
@Test
130+
fun `registered without seenAccessKey resumes at AccessKey`() {
131+
val result = build(AuthState.Registered(seenAccessKey = false))!!
132+
val route = assertIs<AppRoute.OnboardingFlow>(result.baseRoutes.single())
133+
assertEquals(AppRoute.OnboardingFlow.ResumePoint.AccessKey, route.resumeAt)
134+
}
135+
136+
@Test
137+
fun `registered with seenAccessKey resumes at PostAccessKey`() {
138+
val result = build(AuthState.Registered(seenAccessKey = true))!!
139+
val route = assertIs<AppRoute.OnboardingFlow>(result.baseRoutes.single())
140+
assertEquals(AppRoute.OnboardingFlow.ResumePoint.PostAccessKey, route.resumeAt)
141+
}
142+
143+
// -- LoggedInAwaitingUser --
144+
145+
@Test
146+
fun `awaiting user returns null`() {
147+
assertNull(build(AuthState.LoggedInAwaitingUser))
148+
}
149+
}

0 commit comments

Comments
 (0)