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)) + } +}