Skip to content

Commit 465bc5c

Browse files
authored
refactor(auth): rename AuthState variants, add ResumePoint enum, and guard onboarding completion (#814)
Rename AuthState variants for clarity: - Registered → Onboarding - LoggedInAwaitingUser → Authenticating - LoggedInWithUser → Ready Replace the seenAccessKey boolean on Onboarding with a ResumePoint enum (AccessKey, AccessKeyThenPurchase, PostAccessKey) so the onboarding resume point is fully determined at state-set time rather than derived from seenAccessKey + userFlags at navigation time. Simplify buildNavGraphForLaunch to a pure structural mapping from ResumePoint to nav route, removing the userFlags parameter. Add resume-point reconciliation in RealSessionController.updateUserFlags() for the case where flags arrive after the initial state is set. Fix a bug where killing the app at PostAccessKey and relaunching skipped the permissions phase entirely. A persisted completedOnboarding flag in PassphraseCredentialManager is now checked in AuthManager.login() before promoting to Ready, and set reactively when the composable completes the Onboarding → Ready transition. Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent 9c66f5c commit 465bc5c

16 files changed

Lines changed: 431 additions & 74 deletions

File tree

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

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ import com.flipcash.app.core.extensions.navigateAll
2929
import com.flipcash.app.core.extensions.resolveRoutes
3030
import com.flipcash.app.router.LocalRouter
3131
import com.flipcash.app.router.Router
32-
import com.flipcash.services.models.UserFlags
3332
import com.flipcash.services.user.AuthState
3433
import com.getcode.navigation.core.CodeNavigator
3534
import com.getcode.navigation.core.LocalCodeNavigator
@@ -88,9 +87,9 @@ internal fun MainRoot(deepLink: () -> DeepLink?) {
8887

8988
LaunchedEffect(userManager) {
9089
userManager.state
91-
.map { it.authState to it.flags }
90+
.map { it.authState }
9291
.distinctUntilChanged()
93-
.onEach { (state, flags) ->
92+
.onEach { state ->
9493
trace(
9594
tag = "AuthStateRouter",
9695
message = "Handling auth state change during app launch => $state",
@@ -100,18 +99,17 @@ internal fun MainRoot(deepLink: () -> DeepLink?) {
10099
)
101100
val launch = buildNavGraphForLaunch(
102101
state = state,
103-
userFlags = flags,
104102
router = router,
105103
deepLink = deepLink
106104
)
107105

108106
when (state) {
109-
AuthState.LoggedInAwaitingUser -> {
107+
AuthState.Authenticating -> {
110108
delay(0.5.seconds)
111109
showLoading = true
112110
}
113111

114-
AuthState.LoggedInWithUser -> {
112+
AuthState.Ready -> {
115113
showLogo = false
116114
}
117115

@@ -173,22 +171,20 @@ private fun List<NavKey>.startsWith(prefix: List<NavKey>): Boolean {
173171

174172
internal fun buildNavGraphForLaunch(
175173
state: AuthState,
176-
userFlags: UserFlags?,
177174
router: Router,
178175
deepLink: () -> DeepLink?,
179176
): LaunchNavGraph? {
180177
return when (state) {
181-
is AuthState.Registered -> {
182-
val resumePoint = when {
183-
!state.seenAccessKey -> AppRoute.OnboardingFlow.ResumePoint.AccessKey
184-
userFlags?.requiresIapForRegistration == true ->
185-
AppRoute.OnboardingFlow.ResumePoint.AccessKeyThenPurchase
186-
else -> AppRoute.OnboardingFlow.ResumePoint.PostAccessKey
178+
is AuthState.Onboarding -> {
179+
val resumePoint = when (state.resumePoint) {
180+
AuthState.ResumePoint.AccessKey -> AppRoute.OnboardingFlow.ResumePoint.AccessKey
181+
AuthState.ResumePoint.AccessKeyThenPurchase -> AppRoute.OnboardingFlow.ResumePoint.AccessKeyThenPurchase
182+
AuthState.ResumePoint.PostAccessKey -> AppRoute.OnboardingFlow.ResumePoint.PostAccessKey
187183
}
188184
LaunchNavGraph(listOf(AppRoute.OnboardingFlow(resumeAt = resumePoint)))
189185
}
190186

191-
AuthState.LoggedInWithUser -> {
187+
AuthState.Ready -> {
192188
val link = deepLink()
193189
if (link != null) {
194190
when (val action = router.dispatch(link)) {
@@ -218,6 +214,6 @@ internal fun buildNavGraphForLaunch(
218214
}
219215
}
220216

221-
AuthState.LoggedInAwaitingUser -> null
217+
AuthState.Authenticating -> null
222218
}
223219
}

apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,15 @@ class BuildNavGraphForLaunchTest {
3030
deepLink: DeepLink? = null,
3131
): LaunchNavGraph? = buildNavGraphForLaunch(
3232
state = state,
33-
userFlags = null,
3433
router = FakeRouter(action),
3534
deepLink = { deepLink },
3635
)
3736

38-
// -- LoggedInWithUser --
37+
// -- Ready --
3938

4039
@Test
4140
fun `logged in without deeplink navigates to Scanner`() {
42-
val result = build(AuthState.LoggedInWithUser)!!
41+
val result = build(AuthState.Ready)!!
4342
assertEquals(listOf(AppRoute.Main.Scanner), result.baseRoutes)
4443
assertTrue(result.deeplinkRoutes.isEmpty())
4544
}
@@ -48,7 +47,7 @@ class BuildNavGraphForLaunchTest {
4847
fun `logged in with Navigate deeplink includes deeplink routes`() {
4948
val routes = listOf(AppRoute.Main.Scanner)
5049
val result = build(
51-
state = AuthState.LoggedInWithUser,
50+
state = AuthState.Ready,
5251
action = DeeplinkAction.Navigate(routes),
5352
deepLink = dummyLink,
5453
)!!
@@ -59,7 +58,7 @@ class BuildNavGraphForLaunchTest {
5958
@Test
6059
fun `logged in with OpenCashLink defers to App for dispatch`() {
6160
val result = build(
62-
state = AuthState.LoggedInWithUser,
61+
state = AuthState.Ready,
6362
action = DeeplinkAction.OpenCashLink("testEntropy"),
6463
deepLink = dummyLink,
6564
)!!
@@ -70,7 +69,7 @@ class BuildNavGraphForLaunchTest {
7069
@Test
7170
fun `logged in with Login action defers to App for dispatch`() {
7271
val result = build(
73-
state = AuthState.LoggedInWithUser,
72+
state = AuthState.Ready,
7473
action = DeeplinkAction.Login("seed"),
7574
deepLink = dummyLink,
7675
)!!
@@ -81,7 +80,7 @@ class BuildNavGraphForLaunchTest {
8180
@Test
8281
fun `logged in with None action navigates to Scanner without deeplink routes`() {
8382
val result = build(
84-
state = AuthState.LoggedInWithUser,
83+
state = AuthState.Ready,
8584
action = DeeplinkAction.None,
8685
deepLink = dummyLink,
8786
)!!
@@ -124,26 +123,33 @@ class BuildNavGraphForLaunchTest {
124123
assertIs<AppRoute.OnboardingFlow>(result.baseRoutes.single())
125124
}
126125

127-
// -- Registered --
126+
// -- Onboarding --
128127

129128
@Test
130-
fun `registered without seenAccessKey resumes at AccessKey`() {
131-
val result = build(AuthState.Registered(seenAccessKey = false))!!
129+
fun `onboarding at AccessKey resume point routes to AccessKey`() {
130+
val result = build(AuthState.Onboarding(AuthState.ResumePoint.AccessKey))!!
132131
val route = assertIs<AppRoute.OnboardingFlow>(result.baseRoutes.single())
133132
assertEquals(AppRoute.OnboardingFlow.ResumePoint.AccessKey, route.resumeAt)
134133
}
135134

136135
@Test
137-
fun `registered with seenAccessKey resumes at PostAccessKey`() {
138-
val result = build(AuthState.Registered(seenAccessKey = true))!!
136+
fun `onboarding at PostAccessKey resume point routes to PostAccessKey`() {
137+
val result = build(AuthState.Onboarding(AuthState.ResumePoint.PostAccessKey))!!
139138
val route = assertIs<AppRoute.OnboardingFlow>(result.baseRoutes.single())
140139
assertEquals(AppRoute.OnboardingFlow.ResumePoint.PostAccessKey, route.resumeAt)
141140
}
142141

143-
// -- LoggedInAwaitingUser --
142+
@Test
143+
fun `onboarding at AccessKeyThenPurchase resume point routes to AccessKeyThenPurchase`() {
144+
val result = build(AuthState.Onboarding(AuthState.ResumePoint.AccessKeyThenPurchase))!!
145+
val route = assertIs<AppRoute.OnboardingFlow>(result.baseRoutes.single())
146+
assertEquals(AppRoute.OnboardingFlow.ResumePoint.AccessKeyThenPurchase, route.resumeAt)
147+
}
148+
149+
// -- Authenticating --
144150

145151
@Test
146-
fun `awaiting user returns null`() {
147-
assertNull(build(AuthState.LoggedInAwaitingUser))
152+
fun `authenticating returns null`() {
153+
assertNull(build(AuthState.Authenticating))
148154
}
149155
}

apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceViewModel.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ internal class BalanceViewModel @Inject constructor(
4242

4343
init {
4444
userManager.state
45-
.filter { it.authState is AuthState.LoggedInWithUser }
45+
.filter { it.authState is AuthState.Ready }
4646
.flatMapLatest { userFlags.resolvedFlags }
4747
.mapNotNull { it.preferredOnRampProvider.effectiveValue }
4848
.filterIsInstance<OnRampProvider.Defined>()

apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenViewModel.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ class LabsScreenViewModel @Inject constructor(
2020

2121
val isLoggedIn = userManager
2222
.state.map { it.authState }
23-
.filterIsInstance<AuthState.LoggedInWithUser>()
23+
.filterIsInstance<AuthState.Ready>()
2424
.map { true }
2525
.stateIn(viewModelScope, started = SharingStarted.Eagerly, initialValue = false)
2626

apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import com.flipcash.app.permissions.internal.notifications.NotificationScreenCon
4646
import com.flipcash.app.purchase.internal.PurchaseAccountScreenContent
4747
import com.flipcash.app.purchase.internal.PurchaseAccountViewModel
4848
import com.flipcash.features.login.R
49+
import com.flipcash.services.user.AuthState
4950
import com.getcode.libs.analytics.LocalAnalytics
5051
import com.getcode.navigation.annotatedEntry
5152
import com.getcode.navigation.core.LocalCodeNavigator
@@ -178,6 +179,7 @@ private fun PermissionsPhaseFlowHost(
178179
when (reason) {
179180
is FlowExitReason.Completed -> {
180181
analytics.action(Action.CompletedOnboarding)
182+
userManager?.set(AuthState.Ready)
181183
outerNavigator.navigate(
182184
route = AppRoute.Main.Scanner,
183185
options = NavOptions(popUpTo = NavOptions.PopUpTo.ClearAll),
@@ -186,6 +188,7 @@ private fun PermissionsPhaseFlowHost(
186188

187189
FlowExitReason.BackedOutOfRoot -> {
188190
// All permissions already granted
191+
userManager?.set(AuthState.Ready)
189192
outerNavigator.navigate(
190193
route = AppRoute.Main.Scanner,
191194
options = NavOptions(popUpTo = NavOptions.PopUpTo.ClearAll),

apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ internal class MenuScreenViewModel @Inject constructor(
8888
dispatchEvent(Event.OnStaffUserDetermined(false))
8989

9090
userManager.state
91-
.filter { it.authState is AuthState.LoggedInWithUser }
91+
.filter { it.authState is AuthState.Ready }
9292
.flatMapLatest { userFlags.resolvedFlags }
9393
.mapNotNull { it.isStaff.effectiveValue }
9494
.onEach {

apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ import com.getcode.utils.trace
2626
import kotlinx.coroutines.CoroutineScope
2727
import kotlinx.coroutines.Dispatchers
2828
import kotlinx.coroutines.coroutineScope
29+
import kotlinx.coroutines.flow.distinctUntilChanged
30+
import kotlinx.coroutines.flow.launchIn
31+
import kotlinx.coroutines.flow.map
32+
import kotlinx.coroutines.flow.onEach
2933
import kotlinx.coroutines.launch
3034
import javax.inject.Inject
3135
import javax.inject.Singleton
@@ -47,6 +51,22 @@ class AuthManager @Inject constructor(
4751
private val contactCoordinator: ContactCoordinator,
4852
// private val analytics: AnalyticsService,
4953
) : CoroutineScope by CoroutineScope(Dispatchers.IO) {
54+
55+
init {
56+
// Persist onboarding completion when the composable transitions Onboarding → Ready.
57+
var previous: AuthState = AuthState.Unknown
58+
userManager.state
59+
.map { it.authState }
60+
.distinctUntilChanged()
61+
.onEach { current ->
62+
if (previous is AuthState.Onboarding && current is AuthState.Ready) {
63+
launch { credentialManager.markOnboardingCompleted() }
64+
}
65+
previous = current
66+
}
67+
.launchIn(this)
68+
}
69+
5070
private var softLoginDisabled: Boolean = false
5171

5272
/**
@@ -80,7 +100,7 @@ class AuthManager @Inject constructor(
80100
LookupResult.NoAccountFound -> Unit
81101
is LookupResult.TemporaryAccountCreated -> {
82102
userManager.establish(entropy = result.entropy)
83-
userManager.set(AuthState.Registered(result.seenAccessKey))
103+
userManager.set(AuthState.Onboarding(result.resumePoint))
84104
}
85105
}
86106
}
@@ -114,7 +134,12 @@ class AuthManager @Inject constructor(
114134
return credentialManager.onUserAccessKeySeen()
115135
.onSuccess {
116136
if (userManager.authState !is AuthState.LoggedIn) {
117-
userManager.set(AuthState.Registered(true))
137+
val resumePoint = if (userManager.userFlags?.requiresIapForRegistration == true) {
138+
AuthState.ResumePoint.AccessKeyThenPurchase
139+
} else {
140+
AuthState.ResumePoint.PostAccessKey
141+
}
142+
userManager.set(AuthState.Onboarding(resumePoint))
118143
}
119144
}.map { Unit }
120145
}
@@ -124,9 +149,6 @@ class AuthManager @Inject constructor(
124149
.onSuccess {
125150
accountController.getUserFlags().onSuccess { flags ->
126151
userManager.set(flags)
127-
if (flags.isRegistered) {
128-
userManager.set(AuthState.LoggedInWithUser)
129-
}
130152
}
131153
}.map { Unit }
132154
}
@@ -137,7 +159,6 @@ class AuthManager @Inject constructor(
137159
onSuccess = {
138160
val flagsResult = accountController.getUserFlags()
139161
.onSuccess { userManager.set(it) }
140-
userManager.set(AuthState.LoggedInWithUser)
141162
flagsResult
142163
},
143164
onFailure = { Result.failure(it) }
@@ -175,16 +196,28 @@ class AuthManager @Inject constructor(
175196
}
176197

177198
val seenAccessKey = credentialManager.hasSeenAccessKey()
199+
val completedOnboarding = credentialManager.hasCompletedOnboarding()
178200
if (flags != null) {
179201
userManager.set(flags)
180-
if (flags.isRegistered && seenAccessKey) {
181-
userManager.set(AuthState.LoggedInWithUser)
202+
if (flags.isRegistered && seenAccessKey && completedOnboarding) {
203+
userManager.set(AuthState.Ready)
182204
} else {
183-
userManager.set(AuthState.Registered(seenAccessKey))
205+
val resumePoint = when {
206+
!seenAccessKey -> AuthState.ResumePoint.AccessKey
207+
flags.requiresIapForRegistration -> AuthState.ResumePoint.AccessKeyThenPurchase
208+
else -> AuthState.ResumePoint.PostAccessKey
209+
}
210+
userManager.set(AuthState.Onboarding(resumePoint))
184211
}
185212
} else {
186213
taggedTrace("Failed to get user flags after retries", type = TraceType.Error)
187-
userManager.set(authState = AuthState.Registered(seenAccessKey))
214+
if (seenAccessKey && completedOnboarding) {
215+
userManager.set(authState = AuthState.Ready)
216+
} else {
217+
val resumePoint = if (seenAccessKey) AuthState.ResumePoint.PostAccessKey
218+
else AuthState.ResumePoint.AccessKey
219+
userManager.set(authState = AuthState.Onboarding(resumePoint))
220+
}
188221
}
189222

190223
profileController.updateUserProfile()
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package com.flipcash.app.auth.internal.credentials
22

3+
import com.flipcash.services.user.AuthState
4+
35
sealed interface LookupResult {
46
data object NoAccountFound : LookupResult
5-
data class TemporaryAccountCreated(val entropy: String, val seenAccessKey: Boolean) : LookupResult
7+
data class TemporaryAccountCreated(val entropy: String, val resumePoint: AuthState.ResumePoint) : LookupResult
68
data class ExistingAccountFound(val entropy: String) : LookupResult
79
}

0 commit comments

Comments
 (0)