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 0f59a4789..499192d8c 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 @@ -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 @@ -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", @@ -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 } @@ -173,22 +171,20 @@ private fun List.startsWith(prefix: List): 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)) { @@ -218,6 +214,6 @@ internal fun buildNavGraphForLaunch( } } - AuthState.LoggedInAwaitingUser -> null + AuthState.Authenticating -> null } } 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 index 42cb93068..dc99089af 100644 --- 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 @@ -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()) } @@ -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, )!! @@ -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, )!! @@ -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, )!! @@ -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, )!! @@ -124,26 +123,33 @@ class BuildNavGraphForLaunchTest { assertIs(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(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(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(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)) } } diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceViewModel.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceViewModel.kt index 4990d046d..885072e81 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceViewModel.kt +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceViewModel.kt @@ -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() diff --git a/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenViewModel.kt b/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenViewModel.kt index cc8603bd3..94ade3bc9 100644 --- a/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenViewModel.kt +++ b/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenViewModel.kt @@ -20,7 +20,7 @@ class LabsScreenViewModel @Inject constructor( val isLoggedIn = userManager .state.map { it.authState } - .filterIsInstance() + .filterIsInstance() .map { true } .stateIn(viewModelScope, started = SharingStarted.Eagerly, initialValue = false) diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt index bcece424c..fc4dea4ca 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt @@ -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 @@ -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), @@ -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), diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt index 9f781755c..33dabf0ca 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt @@ -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 { diff --git a/apps/flipcash/features/myaccount/build.gradle.kts b/apps/flipcash/features/myaccount/build.gradle.kts index dac262a12..0886a813a 100644 --- a/apps/flipcash/features/myaccount/build.gradle.kts +++ b/apps/flipcash/features/myaccount/build.gradle.kts @@ -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")) diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileViewModel.kt index 08f6800c8..6316386c3 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileViewModel.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileViewModel.kt @@ -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 @@ -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( @@ -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( diff --git a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt index 348ce032c..60e618154 100644 --- a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt +++ b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt @@ -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 @@ -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 /** @@ -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)) } } } @@ -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 } } @@ -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 } } @@ -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) } @@ -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() diff --git a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/credentials/LookupResult.kt b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/credentials/LookupResult.kt index 34f0eaf36..c02ec325d 100644 --- a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/credentials/LookupResult.kt +++ b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/credentials/LookupResult.kt @@ -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 } \ No newline at end of file diff --git a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/credentials/PassphraseCredentialManager.kt b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/credentials/PassphraseCredentialManager.kt index 4310c1e4d..e210b719e 100644 --- a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/credentials/PassphraseCredentialManager.kt +++ b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/credentials/PassphraseCredentialManager.kt @@ -56,6 +56,8 @@ class PassphraseCredentialManager @Inject constructor( private fun userIdKey(entropy: String) = stringPreferencesKey("${entropy}_userId") private fun isUnregisteredKey(accountId: String) = booleanPreferencesKey("${accountId}_unregistered") + private fun completedOnboardingKey(accountId: String) = + booleanPreferencesKey("${accountId}_completedOnboarding") } private val credentialManager = CredentialManager.create(context) @@ -99,9 +101,10 @@ class PassphraseCredentialManager @Inject constructor( storage.edit { preferences -> preferences[temporaryUserIdKey] = userId.base58 + preferences[completedOnboardingKey(userId.base58)] = false } - updateUserManager(userId, AuthState.Registered(false)) + updateUserManager(userId, AuthState.Onboarding(AuthState.ResumePoint.AccessKey)) return Result.success(seedB64) } @@ -129,6 +132,21 @@ class PassphraseCredentialManager @Inject constructor( return storage.data.map { it[seenAccessKeyKey(selectedId)] }.firstOrNull() ?: true } + suspend fun markOnboardingCompleted() { + val accountId = storage.data.map { it[selectedAccountIdKey] }.firstOrNull() + ?: storage.data.map { it[temporaryUserIdKey] }.firstOrNull() + ?: return + storage.edit { it[completedOnboardingKey(accountId)] = true } + } + + suspend fun hasCompletedOnboarding(): Boolean { + val accountId = storage.data.map { it[selectedAccountIdKey] }.firstOrNull() + ?: storage.data.map { it[temporaryUserIdKey] }.firstOrNull() + ?: return true + // Default true for backward compat — existing users who upgraded never had this flag. + return storage.data.map { it[completedOnboardingKey(accountId)] }.firstOrNull() ?: true + } + suspend fun presentSaveOption(): Result { val tempUserId = storage.data.map { it[temporaryUserIdKey] }.firstOrNull() val entropy = storage.data.map { it[temporaryEntropyKey] }.firstOrNull() @@ -197,9 +215,14 @@ class PassphraseCredentialManager @Inject constructor( if (entropy != null) { val seenAccessKey = storage.data.map { it[seenAccessKeyKey(temporaryAccount)] }.firstOrNull() ?: false + val resumePoint = if (seenAccessKey) { + AuthState.ResumePoint.PostAccessKey + } else { + AuthState.ResumePoint.AccessKey + } return LookupResult.TemporaryAccountCreated( entropy = entropy, - seenAccessKey = seenAccessKey + resumePoint = resumePoint ) } } @@ -212,7 +235,7 @@ class PassphraseCredentialManager @Inject constructor( fromSelection: Boolean = false, ): Result { userManager.establish(entropy) - userManager.set(AuthState.LoggedInAwaitingUser) + userManager.set(AuthState.Authenticating) val selectedMetadata = getSelectedMetadata() if (selectedMetadata != null && selectedMetadata.entropy == entropy) { diff --git a/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/AuthManagerTest.kt b/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/AuthManagerTest.kt index 376c52670..26d0a7fa5 100644 --- a/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/AuthManagerTest.kt +++ b/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/AuthManagerTest.kt @@ -23,6 +23,7 @@ import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest @@ -56,6 +57,7 @@ class AuthManagerTest { private val appSettings: AppSettingsCoordinator = mockk(relaxed = true) private val userFlags: UserFlagsCoordinator = mockk(relaxed = true) private val contactCoordinator: ContactCoordinator = mockk(relaxed = true) + private val userManagerState = MutableStateFlow(UserManager.State()) private lateinit var authManager: AuthManager @@ -63,6 +65,7 @@ class AuthManagerTest { fun setUp() { Dispatchers.setMain(testDispatcher) + every { userManager.state } returns userManagerState coEvery { pushTokenProvider.getToken() } returns "fake-token" // Default stubs for methods called during login/createAccount success paths @@ -71,6 +74,7 @@ class AuthManagerTest { coEvery { pushController.addToken(any()) } returns Result.success(Unit) coEvery { pushController.deleteTokens() } returns Result.success(Unit) coEvery { credentialManager.hasSeenAccessKey() } returns true + coEvery { credentialManager.hasCompletedOnboarding() } returns true authManager = AuthManager( credentialManager = credentialManager, @@ -243,11 +247,31 @@ class AuthManagerTest { assertTrue(result.isSuccess) verify { userManager.set(flags) } - verify { userManager.set(authState = AuthState.LoggedInWithUser) } + verify { userManager.set(authState = AuthState.Ready) } } @Test - fun `login falls back to Registered after all retries exhausted`() = runTest { + fun `login resumes at PostAccessKey when registered but onboarding not completed`() = runTest { + val entropy = "dGVzdGVudHJvcHkxMjM0NQ==" + val accountMetadata: AccountMetadata = mockk(relaxed = true) + val testId = listOf(1, 2, 3) + every { accountMetadata.id } returns testId + + coEvery { credentialManager.login(entropy, any()) } returns Result.success(accountMetadata) + coEvery { credentialManager.hasCompletedOnboarding() } returns false + + val flags = UserFlags.Default.copy(isRegistered = true) + coEvery { accountController.getUserFlags() } returns Result.success(flags) + + val result = authManager.login(entropyB64 = entropy) + + assertTrue(result.isSuccess) + verify { userManager.set(flags) } + verify { userManager.set(AuthState.Onboarding(AuthState.ResumePoint.PostAccessKey)) } + } + + @Test + fun `login falls back to Ready when flags exhausted but onboarding completed`() = runTest { val entropy = "dGVzdGVudHJvcHkxMjM0NQ==" val accountMetadata: AccountMetadata = mockk(relaxed = true) val testId = listOf(1, 2, 3) @@ -255,10 +279,86 @@ class AuthManagerTest { coEvery { credentialManager.login(entropy, any()) } returns Result.success(accountMetadata) coEvery { accountController.getUserFlags() } returns Result.failure(RuntimeException("persistent failure")) + coEvery { credentialManager.hasCompletedOnboarding() } returns true val result = authManager.login(entropyB64 = entropy) assertTrue(result.isSuccess) - verify { userManager.set(authState = AuthState.Registered()) } + verify { userManager.set(authState = AuthState.Ready) } + } + + @Test + fun `login falls back to Onboarding when flags exhausted and onboarding not completed`() = runTest { + val entropy = "dGVzdGVudHJvcHkxMjM0NQ==" + val accountMetadata: AccountMetadata = mockk(relaxed = true) + val testId = listOf(1, 2, 3) + every { accountMetadata.id } returns testId + + coEvery { credentialManager.login(entropy, any()) } returns Result.success(accountMetadata) + coEvery { accountController.getUserFlags() } returns Result.failure(RuntimeException("persistent failure")) + coEvery { credentialManager.hasCompletedOnboarding() } returns false + + val result = authManager.login(entropyB64 = entropy) + + assertTrue(result.isSuccess) + verify { userManager.set(authState = AuthState.Onboarding(AuthState.ResumePoint.PostAccessKey)) } + } + + @Test + fun `onUserAccessKeySeen sets Onboarding resumePoint to PostAccessKey when no IAP required`() = runTest { + every { userManager.authState } returns AuthState.Unknown + every { userManager.userFlags } returns null + coEvery { credentialManager.onUserAccessKeySeen() } returns Result.success(Unit) + + val result = authManager.onUserAccessKeySeen() + + assertTrue(result.isSuccess) + verify { userManager.set(AuthState.Onboarding(AuthState.ResumePoint.PostAccessKey)) } + } + + @Test + fun `onUserAccessKeySeen sets Onboarding resumePoint to AccessKeyThenPurchase when IAP required`() = runTest { + every { userManager.authState } returns AuthState.Unknown + every { userManager.userFlags } returns UserFlags.Default.copy(requiresIapForRegistration = true) + coEvery { credentialManager.onUserAccessKeySeen() } returns Result.success(Unit) + + val result = authManager.onUserAccessKeySeen() + + assertTrue(result.isSuccess) + verify { userManager.set(AuthState.Onboarding(AuthState.ResumePoint.AccessKeyThenPurchase)) } + } + + @Test + fun `onUserAccessKeySeen does not change state if already LoggedIn`() = runTest { + every { userManager.authState } returns AuthState.Ready + coEvery { credentialManager.onUserAccessKeySeen() } returns Result.success(Unit) + + val result = authManager.onUserAccessKeySeen() + + assertTrue(result.isSuccess) + verify(exactly = 0) { userManager.set(match { it is AuthState.Onboarding }) } + } + + @Test + fun `presentCredentialStorage does not set Ready — onboarding still in progress`() = runTest { + val flags = UserFlags.Default.copy(isRegistered = true) + coEvery { credentialManager.presentSaveOption() } returns Result.success(mockk(relaxed = true)) + coEvery { accountController.getUserFlags() } returns Result.success(flags) + + val result = authManager.presentCredentialStorage() + + assertTrue(result.isSuccess) + verify { userManager.set(flags) } + verify(exactly = 0) { userManager.set(AuthState.Ready) } + } + + @Test + fun `onAccountPurchased does not set Ready — onboarding still in progress`() = runTest { + coEvery { credentialManager.onAccountPurchased() } returns Result.success(mockk(relaxed = true)) + + val result = authManager.onAccountPurchased() + + assertTrue(result.isSuccess) + verify(exactly = 0) { userManager.set(AuthState.Ready) } } } diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt index c1c488c81..dc6280eaa 100644 --- a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt +++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt @@ -87,7 +87,7 @@ class ContactCoordinator @Inject constructor( ), migrations = listOf(), scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), - produceFile = { context.preferencesDataStoreFile("app-settings") } + produceFile = { context.preferencesDataStoreFile("contact-prefs") } ) data class ContactState( @@ -103,11 +103,15 @@ class ContactCoordinator @Inject constructor( private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val cluster = MutableStateFlow(null) private val _state = MutableStateFlow(ContactState()) + private val _isLinkedForPayment = MutableStateFlow(false) private var syncJob: Job? = null val state: StateFlow get() = _state.asStateFlow() + val isLinkedForPayment: StateFlow + get() = _isLinkedForPayment.asStateFlow() + // region SessionListener override suspend fun onUserLoggedIn(cluster: AccountCluster) { @@ -124,6 +128,13 @@ class ContactCoordinator @Inject constructor( init { ProcessLifecycleOwner.get().lifecycle.addObserver(this) + // Hydrate linked-for-payment flag from DataStore + contactPrefs.data + .map { it[KEY_LINKED_FOR_PAYMENT] ?: false } + .distinctUntilChanged() + .onEach { _isLinkedForPayment.value = it } + .launchIn(scope) + cluster.filterNotNull() .flatMapLatest { networkObserver.state } .distinctUntilChanged() @@ -201,15 +212,24 @@ class ContactCoordinator @Inject constructor( * | Phone number changed (unlink + re-verify) | Foreground path won't re-fire (flag is `true`), but the verification flow calls `linkForPayment` directly | */ fun linkForPaymentIfNeeded() { - val phone = userManager.profile?.verifiedPhoneNumber ?: return - val enabled = featureFlagController.observe(FeatureFlag.PhoneNumberSend).value || - userManager.state.value.flags?.enablePhoneNumberSend == true - if (!enabled) return scope.launch { + val featureFlag = featureFlagController.get(FeatureFlag.PhoneNumberSend) + val serverFlag = userManager.state.value.flags?.enablePhoneNumberSend == true + val enabled = featureFlag || serverFlag + if (!enabled) return@launch + val alreadyLinked = contactPrefs.data .map { it[KEY_LINKED_FOR_PAYMENT] ?: false } .first() if (alreadyLinked) return@launch + + // Profile may not be loaded yet on the first Ready transition; + // wait for the verified phone number to arrive. + val phone = userManager.state + .map { it.userProfile?.verifiedPhoneNumber } + .filterNotNull() + .first() + contactVerificationController.linkForPayment(ContactMethod.Phone(phone)) .onSuccess { contactPrefs.edit { it[KEY_LINKED_FOR_PAYMENT] = true } @@ -455,7 +475,7 @@ class ContactCoordinator @Inject constructor( contactDataSource.clearFlipcashStatus() if (flipcashE164s.isNotEmpty()) { contactDataSource.markAsFlipcash(flipcashE164s.toList()) - if (!_state.value.hasDiscoveredFlipcashContacts) { + if (!_state.value.hasDiscoveredFlipcashContacts && _state.value.flipcashE164s.isEmpty()) { contactDataSource.markFlipcashContactsDiscovered() _state.update { it.copy(hasDiscoveredFlipcashContacts = true) } } diff --git a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt index ac7483145..0e8c80d33 100644 --- a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt +++ b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt @@ -34,7 +34,7 @@ internal class AppRouter( val type = classify(deepLink) ?: return DeeplinkAction.None // Not logged in — redirect to login (or login deeplink itself) - if (authStateProvider() !is AuthState.LoggedInWithUser) { + if (authStateProvider() !is AuthState.Ready) { return when (type) { is DeeplinkType.Login -> DeeplinkAction.Navigate( listOf(AppRoute.OnboardingFlow(seed = type.entropy, fromDeeplink = true)) diff --git a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt index cc5faacb4..cc4936be1 100644 --- a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt +++ b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt @@ -21,11 +21,11 @@ import kotlin.test.assertTrue @Config(manifest = Config.NONE) class AppRouterTest { - private var authState: AuthState = AuthState.LoggedInWithUser + private var authState: AuthState = AuthState.Ready private val router = AppRouter(authStateProvider = { authState }) - private fun loggedIn() { authState = AuthState.LoggedInWithUser } + private fun loggedIn() { authState = AuthState.Ready } private fun loggedOut() { authState = AuthState.LoggedOut } // region classify — Login diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt index 85971996e..17ffcb7df 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt @@ -158,9 +158,12 @@ class RealSessionController @Inject constructor( scope.launch { contactCoordinator.reset() } _state.update { SessionState() } } - authState.isAtLeastRegistered -> { + authState is AuthState.Ready -> { onAppInForeground() } + authState.isAtLeastRegistered -> { + updateUserFlags() + } } }.launchIn(scope) @@ -305,9 +308,30 @@ class RealSessionController @Inject constructor( .onSuccess { flags -> userManager.set(flags) val currentState = userManager.authState - val onboardingIncomplete = currentState is AuthState.Registered && !currentState.seenAccessKey - if (flags.isRegistered && !currentState.canAccessAuthenticatedApis && !onboardingIncomplete) { - userManager.set(authState = AuthState.LoggedInWithUser) + when { + // Don't promote during onboarding — the permissions + // completion flow sets Ready when navigating to Scanner. + flags.isRegistered && !currentState.canAccessAuthenticatedApis + && currentState !is AuthState.Onboarding -> { + userManager.set(authState = AuthState.Ready) + } + // Reconcile resume point with freshly-loaded flags. + currentState is AuthState.Onboarding -> { + val corrected = when (currentState.resumePoint) { + AuthState.ResumePoint.PostAccessKey -> + if (flags.requiresIapForRegistration) + AuthState.ResumePoint.AccessKeyThenPurchase + else currentState.resumePoint + AuthState.ResumePoint.AccessKeyThenPurchase -> + if (!flags.requiresIapForRegistration) + AuthState.ResumePoint.PostAccessKey + else currentState.resumePoint + AuthState.ResumePoint.AccessKey -> currentState.resumePoint + } + if (corrected != currentState.resumePoint) { + userManager.set(authState = AuthState.Onboarding(corrected)) + } + } } } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/user/UserManager.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/user/UserManager.kt index 29d66be98..b2115fcfb 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/user/UserManager.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/user/UserManager.kt @@ -17,6 +17,7 @@ import com.getcode.opencode.model.financial.usdf import com.getcode.services.opencode.BuildConfig import com.getcode.utils.TraceManager import com.getcode.utils.base58 +import com.getcode.utils.trace import com.hoc081098.channeleventbus.ChannelEventBus import com.mixpanel.android.mpmetrics.MixpanelAPI import kotlinx.coroutines.flow.MutableStateFlow @@ -26,32 +27,55 @@ import kotlinx.coroutines.flow.update import javax.inject.Inject import javax.inject.Singleton +/** + * Authentication state machine. + * + * ``` + * Unknown ──→ Authenticating ──→ Ready ──→ LoggedOut + * │ ↓ ↑ + * └─────→ Onboarding ──────────┘ + * ``` + * + * - **Unknown** — initial state before credential lookup completes. + * - **Onboarding** — account created on the server, user is mid-onboarding + * (access key, purchase, permissions). [ResumePoint] determines where to resume. + * - **Authenticating** — credentials verified, loading profile/flags from storage. + * Transient: the app shows a loading indicator while in this state. + * - **Ready** — fully authenticated, profile loaded, app is ready for use. + * - **LoggedOut** — session cleared. + */ sealed interface AuthState { - // still to determine + // initial state before credential lookup completes data object Unknown : AuthState - // account has been created but not yet paid for (if required) - // seenAccessKey used as a flag whether to land them back on - // access key screen or purchase - data class Registered(val seenAccessKey: Boolean = true) : AuthState + enum class ResumePoint { + /** User created account but has not seen their access key. */ + AccessKey, + /** User has seen their access key but must complete IAP before registration. */ + AccessKeyThenPurchase, + /** User has seen their access key (and completed purchase if required). */ + PostAccessKey, + } + + // account created, user is mid-onboarding (access key / purchase / permissions) + data class Onboarding(val resumePoint: ResumePoint = ResumePoint.PostAccessKey) : AuthState sealed interface LoggedIn - // account has been created and paid for (if required) - // and we are waiting for metadata to be pulled from storage - data object LoggedInAwaitingUser : AuthState, LoggedIn + // credentials verified, loading profile from storage + data object Authenticating : AuthState, LoggedIn - // account is paid for (if required) and is ready for use in app - data object LoggedInWithUser : AuthState, LoggedIn + // fully authenticated and ready for use + data object Ready : AuthState, LoggedIn - // logged out + // session cleared data object LoggedOut : AuthState val canAccessAuthenticatedApis: Boolean - get() = this is LoggedInWithUser + get() = this is Ready val isAtLeastRegistered: Boolean - get() = this is LoggedInWithUser || this is Registered + get() = this is Ready || (this is Onboarding && this.resumePoint != ResumePoint.AccessKey) } @Singleton @@ -127,13 +151,13 @@ class UserManager @Inject constructor( fun set(authState: AuthState) { val previous = _state.value.authState _state.update { it.copy(authState = authState) } - + trace("authState change $previous => $authState") when (authState) { is AuthState.LoggedIn -> { accountCluster?.let { owner -> eventBus.send(Events.UpdateLimits(owner = owner, force = true)) - // Fire OnLoggedIn only on transition INTO LoggedInWithUser - if (authState is AuthState.LoggedInWithUser && previous !is AuthState.LoggedInWithUser) { + // Fire OnLoggedIn only on transition INTO Ready + if (authState is AuthState.Ready && previous !is AuthState.Ready) { eventBus.send(Events.OnLoggedIn(owner)) } } diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/user/UserManagerTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/user/UserManagerTest.kt new file mode 100644 index 000000000..8fa1dd64f --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/user/UserManagerTest.kt @@ -0,0 +1,149 @@ +package com.flipcash.services.user + +import com.getcode.crypt.DerivedKey +import com.getcode.opencode.controllers.AccountController +import com.getcode.opencode.events.Events +import com.getcode.opencode.managers.MnemonicManager +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.opencode.model.financial.MintMetadata +import com.getcode.opencode.model.financial.usdf +import com.getcode.opencode.utils.generate +import com.getcode.solana.keys.PublicKey +import com.hoc081098.channeleventbus.ChannelEventBus +import com.mixpanel.android.mpmetrics.MixpanelAPI +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Before +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull + +class UserManagerTest { + + private val mnemonicManager: MnemonicManager = mockk(relaxed = true) + private val mixpanelAPI: MixpanelAPI = mockk(relaxed = true) + private val eventBus: ChannelEventBus = mockk(relaxed = true) + private val accountController: AccountController = mockk(relaxed = true) + + @Before + fun setUp() { + mockkObject(DerivedKey) + every { DerivedKey.derive(any(), any()) } returns mockk(relaxed = true) + + // Token.usdf (extension on MintMetadata.Companion) triggers native Ed25519 code; + // mock it to avoid JNI errors in JVM tests. + mockkStatic("com.getcode.opencode.model.financial.MintMetadataKt") + every { MintMetadata.usdf } returns mockk(relaxed = true) + + // RandomId (in IDKt) triggers PublicKey.generate() → Ed25519 native during + // class init. Mock the generate function so clear() can reference NoId safely. + mockkStatic("com.getcode.opencode.utils.PublicKeyKt") + every { PublicKey.generate() } returns PublicKey(List(32) { 0 }) + + mockkObject(AccountCluster) + every { AccountCluster.newInstance(any(), any()) } returns mockk(relaxed = true) + } + + @After + fun tearDown() { + unmockkAll() + } + + private fun createUserManager() = UserManager( + mnemonicManager = mnemonicManager, + mixpanelAPI = mixpanelAPI, + eventBus = eventBus, + accountController = accountController, + ) + + private fun UserManager.withCluster(): UserManager { + establish("fakeEntropy") + return this + } + + @Test + fun `initial auth state is Unknown`() { + val um = createUserManager() + assertEquals(AuthState.Unknown, um.authState) + } + + @Test + fun `set Onboarding updates authState`() { + val um = createUserManager() + um.set(AuthState.Onboarding(resumePoint = AuthState.ResumePoint.PostAccessKey)) + assertIs(um.authState) + assertEquals( + AuthState.ResumePoint.PostAccessKey, + (um.authState as AuthState.Onboarding).resumePoint + ) + } + + @Test + fun `set Ready fires OnLoggedIn when transitioning from non-Ready`() { + val um = createUserManager().withCluster() + + um.set(AuthState.Ready) + + verify { eventBus.send(match { true }) } + } + + @Test + fun `set Ready does not fire OnLoggedIn when already Ready`() { + val um = createUserManager().withCluster() + um.set(AuthState.Ready) + clearMocks(eventBus, answers = false) + + um.set(AuthState.Ready) + + verify(exactly = 0) { eventBus.send(match { true }) } + } + + @Test + fun `set Ready fires UpdateLimits`() { + val um = createUserManager().withCluster() + + um.set(AuthState.Ready) + + verify { eventBus.send(match { true }) } + } + + @Test + fun `set Authenticating fires UpdateLimits but not OnLoggedIn`() { + val um = createUserManager().withCluster() + + um.set(AuthState.Authenticating) + + verify { eventBus.send(match { true }) } + verify(exactly = 0) { eventBus.send(match { true }) } + } + + @Test + fun `OnLoggedIn not fired when accountCluster is null`() { + val um = createUserManager() + + um.set(AuthState.Ready) + + verify(exactly = 0) { eventBus.send(match { true }) } + verify(exactly = 0) { eventBus.send(match { true }) } + } + + @Test + fun `clear resets to LoggedOut`() { + val um = createUserManager() + um.set(AuthState.Ready) + + um.clear() + + assertEquals(AuthState.LoggedOut, um.authState) + assertNull(um.accountCluster) + assertNull(um.entropy) + assertNull(um.userFlags) + } +}