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
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import com.flipcash.app.core.extensions.navigateAll
import com.flipcash.app.core.extensions.resolveRoutes
import com.flipcash.app.router.LocalRouter
import com.flipcash.app.router.Router
import com.flipcash.services.models.UserFlags
import com.flipcash.services.user.AuthState
import com.getcode.navigation.core.CodeNavigator
import com.getcode.navigation.core.LocalCodeNavigator
Expand Down Expand Up @@ -88,9 +87,9 @@ internal fun MainRoot(deepLink: () -> DeepLink?) {

LaunchedEffect(userManager) {
userManager.state
.map { it.authState to it.flags }
.map { it.authState }
.distinctUntilChanged()
.onEach { (state, flags) ->
.onEach { state ->
trace(
tag = "AuthStateRouter",
message = "Handling auth state change during app launch => $state",
Expand All @@ -100,18 +99,17 @@ internal fun MainRoot(deepLink: () -> DeepLink?) {
)
val launch = buildNavGraphForLaunch(
state = state,
userFlags = flags,
router = router,
deepLink = deepLink
)

when (state) {
AuthState.LoggedInAwaitingUser -> {
AuthState.Authenticating -> {
delay(0.5.seconds)
showLoading = true
}

AuthState.LoggedInWithUser -> {
AuthState.Ready -> {
showLogo = false
}

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

internal fun buildNavGraphForLaunch(
state: AuthState,
userFlags: UserFlags?,
router: Router,
deepLink: () -> DeepLink?,
): LaunchNavGraph? {
return when (state) {
is AuthState.Registered -> {
val resumePoint = when {
!state.seenAccessKey -> AppRoute.OnboardingFlow.ResumePoint.AccessKey
userFlags?.requiresIapForRegistration == true ->
AppRoute.OnboardingFlow.ResumePoint.AccessKeyThenPurchase
else -> AppRoute.OnboardingFlow.ResumePoint.PostAccessKey
is AuthState.Onboarding -> {
val resumePoint = when (state.resumePoint) {
AuthState.ResumePoint.AccessKey -> AppRoute.OnboardingFlow.ResumePoint.AccessKey
AuthState.ResumePoint.AccessKeyThenPurchase -> AppRoute.OnboardingFlow.ResumePoint.AccessKeyThenPurchase
AuthState.ResumePoint.PostAccessKey -> AppRoute.OnboardingFlow.ResumePoint.PostAccessKey
}
LaunchNavGraph(listOf(AppRoute.OnboardingFlow(resumeAt = resumePoint)))
}

AuthState.LoggedInWithUser -> {
AuthState.Ready -> {
val link = deepLink()
if (link != null) {
when (val action = router.dispatch(link)) {
Expand Down Expand Up @@ -218,6 +214,6 @@ internal fun buildNavGraphForLaunch(
}
}

AuthState.LoggedInAwaitingUser -> null
AuthState.Authenticating -> null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,15 @@ class BuildNavGraphForLaunchTest {
deepLink: DeepLink? = null,
): LaunchNavGraph? = buildNavGraphForLaunch(
state = state,
userFlags = null,
router = FakeRouter(action),
deepLink = { deepLink },
)

// -- LoggedInWithUser --
// -- Ready --

@Test
fun `logged in without deeplink navigates to Scanner`() {
val result = build(AuthState.LoggedInWithUser)!!
val result = build(AuthState.Ready)!!
assertEquals(listOf(AppRoute.Main.Scanner), result.baseRoutes)
assertTrue(result.deeplinkRoutes.isEmpty())
}
Expand All @@ -48,7 +47,7 @@ class BuildNavGraphForLaunchTest {
fun `logged in with Navigate deeplink includes deeplink routes`() {
val routes = listOf(AppRoute.Main.Scanner)
val result = build(
state = AuthState.LoggedInWithUser,
state = AuthState.Ready,
action = DeeplinkAction.Navigate(routes),
deepLink = dummyLink,
)!!
Expand All @@ -59,7 +58,7 @@ class BuildNavGraphForLaunchTest {
@Test
fun `logged in with OpenCashLink defers to App for dispatch`() {
val result = build(
state = AuthState.LoggedInWithUser,
state = AuthState.Ready,
action = DeeplinkAction.OpenCashLink("testEntropy"),
deepLink = dummyLink,
)!!
Expand All @@ -70,7 +69,7 @@ class BuildNavGraphForLaunchTest {
@Test
fun `logged in with Login action defers to App for dispatch`() {
val result = build(
state = AuthState.LoggedInWithUser,
state = AuthState.Ready,
action = DeeplinkAction.Login("seed"),
deepLink = dummyLink,
)!!
Expand All @@ -81,7 +80,7 @@ class BuildNavGraphForLaunchTest {
@Test
fun `logged in with None action navigates to Scanner without deeplink routes`() {
val result = build(
state = AuthState.LoggedInWithUser,
state = AuthState.Ready,
action = DeeplinkAction.None,
deepLink = dummyLink,
)!!
Expand Down Expand Up @@ -124,26 +123,33 @@ class BuildNavGraphForLaunchTest {
assertIs<AppRoute.OnboardingFlow>(result.baseRoutes.single())
}

// -- Registered --
// -- Onboarding --

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

// -- LoggedInAwaitingUser --
@Test
fun `onboarding at AccessKeyThenPurchase resume point routes to AccessKeyThenPurchase`() {
val result = build(AuthState.Onboarding(AuthState.ResumePoint.AccessKeyThenPurchase))!!
val route = assertIs<AppRoute.OnboardingFlow>(result.baseRoutes.single())
assertEquals(AppRoute.OnboardingFlow.ResumePoint.AccessKeyThenPurchase, route.resumeAt)
}

// -- Authenticating --

@Test
fun `awaiting user returns null`() {
assertNull(build(AuthState.LoggedInAwaitingUser))
fun `authenticating returns null`() {
assertNull(build(AuthState.Authenticating))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ internal class BalanceViewModel @Inject constructor(

init {
userManager.state
.filter { it.authState is AuthState.LoggedInWithUser }
.filter { it.authState is AuthState.Ready }
.flatMapLatest { userFlags.resolvedFlags }
.mapNotNull { it.preferredOnRampProvider.effectiveValue }
.filterIsInstance<OnRampProvider.Defined>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class LabsScreenViewModel @Inject constructor(

val isLoggedIn = userManager
.state.map { it.authState }
.filterIsInstance<AuthState.LoggedInWithUser>()
.filterIsInstance<AuthState.Ready>()
.map { true }
.stateIn(viewModelScope, started = SharingStarted.Eagerly, initialValue = false)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import com.flipcash.app.permissions.internal.notifications.NotificationScreenCon
import com.flipcash.app.purchase.internal.PurchaseAccountScreenContent
import com.flipcash.app.purchase.internal.PurchaseAccountViewModel
import com.flipcash.features.login.R
import com.flipcash.services.user.AuthState
import com.getcode.libs.analytics.LocalAnalytics
import com.getcode.navigation.annotatedEntry
import com.getcode.navigation.core.LocalCodeNavigator
Expand Down Expand Up @@ -178,6 +179,7 @@ private fun PermissionsPhaseFlowHost(
when (reason) {
is FlowExitReason.Completed -> {
analytics.action(Action.CompletedOnboarding)
userManager?.set(AuthState.Ready)
outerNavigator.navigate(
route = AppRoute.Main.Scanner,
options = NavOptions(popUpTo = NavOptions.PopUpTo.ClearAll),
Expand All @@ -186,6 +188,7 @@ private fun PermissionsPhaseFlowHost(

FlowExitReason.BackedOutOfRoot -> {
// All permissions already granted
userManager?.set(AuthState.Ready)
outerNavigator.navigate(
route = AppRoute.Main.Scanner,
options = NavOptions(popUpTo = NavOptions.PopUpTo.ClearAll),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ internal class MenuScreenViewModel @Inject constructor(
dispatchEvent(Event.OnStaffUserDetermined(false))

userManager.state
.filter { it.authState is AuthState.LoggedInWithUser }
.filter { it.authState is AuthState.Ready }
.flatMapLatest { userFlags.resolvedFlags }
.mapNotNull { it.isStaff.effectiveValue }
.onEach {
Expand Down
1 change: 1 addition & 0 deletions apps/flipcash/features/myaccount/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ dependencies {
testImplementation(kotlin("test"))

implementation(project(":apps:flipcash:shared:authentication"))
implementation(project(":apps:flipcash:shared:contacts"))
implementation(project(":apps:flipcash:shared:featureflags"))
implementation(project(":apps:flipcash:shared:menu"))

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
package com.flipcash.app.myaccount.internal

import androidx.lifecycle.viewModelScope
import com.flipcash.app.featureflags.FeatureFlag
import com.flipcash.app.featureflags.FeatureFlagController
import com.flipcash.app.contacts.ContactCoordinator
import com.flipcash.core.R
import com.flipcash.libs.coroutines.DispatcherProvider
import com.flipcash.services.controllers.ContactVerificationController
Expand All @@ -27,8 +26,8 @@ import javax.inject.Inject
internal class UserProfileViewModel @Inject constructor(
private val userManager: UserManager,
private val contactController: ContactVerificationController,
private val contactCoordinator: ContactCoordinator,
private val profileController: ProfileController,
featureFlagController: FeatureFlagController,
private val resources: ResourceHelper,
dispatchers: DispatcherProvider,
) : BaseViewModel<UserProfileViewModel.State, UserProfileViewModel.Event>(
Expand Down Expand Up @@ -67,11 +66,9 @@ internal class UserProfileViewModel @Inject constructor(
init {
combine(
userManager.state,
featureFlagController.observe(FeatureFlag.PhoneNumberSend),
) { state, sendEnabled ->
contactCoordinator.isLinkedForPayment,
) { state, linkedForPayment ->
val profile = state.userProfile
val linkedForPayment = sendEnabled ||
state.flags?.enablePhoneNumberSend == true

dispatchEvent(
Event.OnProfileUpdated(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ import com.getcode.utils.trace
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
Expand All @@ -47,6 +51,22 @@ class AuthManager @Inject constructor(
private val contactCoordinator: ContactCoordinator,
// private val analytics: AnalyticsService,
) : CoroutineScope by CoroutineScope(Dispatchers.IO) {

init {
// Persist onboarding completion when the composable transitions Onboarding → Ready.
var previous: AuthState = AuthState.Unknown
userManager.state
.map { it.authState }
.distinctUntilChanged()
.onEach { current ->
if (previous is AuthState.Onboarding && current is AuthState.Ready) {
launch { credentialManager.markOnboardingCompleted() }
}
previous = current
}
.launchIn(this)
}

private var softLoginDisabled: Boolean = false

/**
Expand Down Expand Up @@ -80,7 +100,7 @@ class AuthManager @Inject constructor(
LookupResult.NoAccountFound -> Unit
is LookupResult.TemporaryAccountCreated -> {
userManager.establish(entropy = result.entropy)
userManager.set(AuthState.Registered(result.seenAccessKey))
userManager.set(AuthState.Onboarding(result.resumePoint))
}
}
}
Expand Down Expand Up @@ -114,7 +134,12 @@ class AuthManager @Inject constructor(
return credentialManager.onUserAccessKeySeen()
.onSuccess {
if (userManager.authState !is AuthState.LoggedIn) {
userManager.set(AuthState.Registered(true))
val resumePoint = if (userManager.userFlags?.requiresIapForRegistration == true) {
AuthState.ResumePoint.AccessKeyThenPurchase
} else {
AuthState.ResumePoint.PostAccessKey
}
userManager.set(AuthState.Onboarding(resumePoint))
}
}.map { Unit }
}
Expand All @@ -124,9 +149,6 @@ class AuthManager @Inject constructor(
.onSuccess {
accountController.getUserFlags().onSuccess { flags ->
userManager.set(flags)
if (flags.isRegistered) {
userManager.set(AuthState.LoggedInWithUser)
}
}
}.map { Unit }
}
Expand All @@ -137,7 +159,6 @@ class AuthManager @Inject constructor(
onSuccess = {
val flagsResult = accountController.getUserFlags()
.onSuccess { userManager.set(it) }
userManager.set(AuthState.LoggedInWithUser)
flagsResult
},
onFailure = { Result.failure(it) }
Expand Down Expand Up @@ -175,16 +196,28 @@ class AuthManager @Inject constructor(
}

val seenAccessKey = credentialManager.hasSeenAccessKey()
val completedOnboarding = credentialManager.hasCompletedOnboarding()
if (flags != null) {
userManager.set(flags)
if (flags.isRegistered && seenAccessKey) {
userManager.set(AuthState.LoggedInWithUser)
if (flags.isRegistered && seenAccessKey && completedOnboarding) {
userManager.set(AuthState.Ready)
} else {
userManager.set(AuthState.Registered(seenAccessKey))
val resumePoint = when {
!seenAccessKey -> AuthState.ResumePoint.AccessKey
flags.requiresIapForRegistration -> AuthState.ResumePoint.AccessKeyThenPurchase
else -> AuthState.ResumePoint.PostAccessKey
}
userManager.set(AuthState.Onboarding(resumePoint))
}
} else {
taggedTrace("Failed to get user flags after retries", type = TraceType.Error)
userManager.set(authState = AuthState.Registered(seenAccessKey))
if (seenAccessKey && completedOnboarding) {
userManager.set(authState = AuthState.Ready)
} else {
val resumePoint = if (seenAccessKey) AuthState.ResumePoint.PostAccessKey
else AuthState.ResumePoint.AccessKey
userManager.set(authState = AuthState.Onboarding(resumePoint))
}
}

profileController.updateUserProfile()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.flipcash.app.auth.internal.credentials

import com.flipcash.services.user.AuthState

sealed interface LookupResult {
data object NoAccountFound : LookupResult
data class TemporaryAccountCreated(val entropy: String, val seenAccessKey: Boolean) : LookupResult
data class TemporaryAccountCreated(val entropy: String, val resumePoint: AuthState.ResumePoint) : LookupResult
data class ExistingAccountFound(val entropy: String) : LookupResult
}
Loading
Loading