Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/flipcash/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -271,4 +271,7 @@ dependencies {

implementation(libs.timber)
implementation(libs.bugsnag)

testImplementation(libs.junit)
testImplementation(libs.kotlin.test.junit)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<NavKey>,
val deeplinkRoutes: List<AppRoute> = emptyList(),
) {
Expand Down Expand Up @@ -171,7 +171,7 @@ private fun List<NavKey>.startsWith(prefix: List<NavKey>): Boolean {
return prefix.indices.all { i -> this[i]::class == prefix[i]::class }
}

private fun buildNavGraphForLaunch(
internal fun buildNavGraphForLaunch(
state: AuthState,
userFlags: UserFlags?,
router: Router,
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<AppRoute.OnboardingFlow>(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<AppRoute.OnboardingFlow>(result.baseRoutes.single())
}

@Test
fun `unknown auth state without deeplink navigates to OnboardingFlow`() {
val result = build(AuthState.Unknown)!!
assertIs<AppRoute.OnboardingFlow>(result.baseRoutes.single())
}

// -- Registered --

@Test
fun `registered without seenAccessKey resumes at AccessKey`() {
val result = build(AuthState.Registered(seenAccessKey = false))!!
val route = assertIs<AppRoute.OnboardingFlow>(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<AppRoute.OnboardingFlow>(result.baseRoutes.single())
assertEquals(AppRoute.OnboardingFlow.ResumePoint.PostAccessKey, route.resumeAt)
}

// -- LoggedInAwaitingUser --

@Test
fun `awaiting user returns null`() {
assertNull(build(AuthState.LoggedInAwaitingUser))
}
}
Loading