diff --git a/apps/flipcash/features/login/build.gradle.kts b/apps/flipcash/features/login/build.gradle.kts index 658551e4d..6720e4680 100644 --- a/apps/flipcash/features/login/build.gradle.kts +++ b/apps/flipcash/features/login/build.gradle.kts @@ -15,6 +15,7 @@ dependencies { implementation(project(":apps:flipcash:shared:accesskey")) implementation(project(":apps:flipcash:shared:analytics")) implementation(project(":apps:flipcash:shared:authentication")) + implementation(project(":apps:flipcash:shared:contacts")) implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:permissions")) implementation(project(":apps:flipcash:shared:userflags")) 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 fc4dea4ca..bac0cb673 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 @@ -39,6 +39,8 @@ import com.flipcash.app.login.internal.screens.LoginRouterScreenContent import com.flipcash.app.login.internal.screens.SeedInputContent import com.flipcash.app.login.router.LoginViewModel import com.flipcash.app.login.internal.SeedInputViewModel +import androidx.compose.runtime.rememberCoroutineScope +import com.flipcash.app.contacts.LocalContactCoordinator import com.flipcash.app.permissions.asContactAccessHandle import com.flipcash.app.permissions.internal.contacts.ContactScreenContent import com.flipcash.app.permissions.internal.notifications.NotificationRationalePermissionContent @@ -64,6 +66,7 @@ import com.getcode.util.permissions.PermissionResult import com.getcode.util.permissions.rememberContactPermission import com.getcode.util.permissions.rememberNotificationPermission import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -446,11 +449,14 @@ private fun PurchaseStepContent() { private fun ContactPermissionStepContent() { val flowNavigator = rememberFlowNavigator() val analytics = LocalAnalytics.current + val contactCoordinator = LocalContactCoordinator.current + val scope = rememberCoroutineScope() val permissionState = rememberContactPermission { result -> when (result) { PermissionResult.Granted -> { analytics.action(Button.AllowContacts) + scope.launch { contactCoordinator.sync() } flowNavigator.proceed() } PermissionResult.Denied, 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 60e618154..0745d1b80 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 @@ -196,7 +196,14 @@ class AuthManager @Inject constructor( } val seenAccessKey = credentialManager.hasSeenAccessKey() - val completedOnboarding = credentialManager.hasCompletedOnboarding() + // Interactive logins (seed input, deep link, credential picker) + // always route through the onboarding permissions flow, which + // sets Ready on completion. Don't trust the completedOnboarding + // default (true for backward compat) here — it would skip the + // permissions phase and set Ready too early. + // Soft logins (app restart) can trust the persisted flag. + val completedOnboarding = if (!isSoftLogin) false + else credentialManager.hasCompletedOnboarding() if (flags != null) { userManager.set(flags) if (flags.isRegistered && seenAccessKey && completedOnboarding) { 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 26d0a7fa5..0abdaf60a 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 @@ -243,7 +243,7 @@ class AuthManagerTest { Result.success(flags) ) - val result = authManager.login(entropyB64 = entropy) + val result = authManager.login(entropyB64 = entropy, isSoftLogin = true) assertTrue(result.isSuccess) verify { userManager.set(flags) } @@ -281,7 +281,7 @@ class AuthManagerTest { coEvery { accountController.getUserFlags() } returns Result.failure(RuntimeException("persistent failure")) coEvery { credentialManager.hasCompletedOnboarding() } returns true - val result = authManager.login(entropyB64 = entropy) + val result = authManager.login(entropyB64 = entropy, isSoftLogin = true) assertTrue(result.isSuccess) verify { userManager.set(authState = 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 8bf947583..190b514d2 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 @@ -211,29 +211,36 @@ class ContactCoordinator @Inject constructor( * | After logout → re-login | `reset()` clears flag, fires on first foreground if conditions met | * | Phone number changed (unlink + re-verify) | Foreground path won't re-fire (flag is `true`), but the verification flow calls `linkForPayment` directly | */ + private val linkMutex = kotlinx.coroutines.sync.Mutex() + fun linkForPaymentIfNeeded() { 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 } - } + if (!linkMutex.tryLock()) return@launch + try { + 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 } + } + } finally { + linkMutex.unlock() + } } } @@ -343,7 +350,7 @@ class ContactCoordinator @Inject constructor( if (deviceContacts.isEmpty()) { trace(tag = TAG, message = "No device contacts found", type = TraceType.Process) - _state.update { it.copy(syncState = SyncState.Synced) } + _state.update { it.copy(syncState = SyncState.Synced, hasEverSynced = true) } return Result.success(Unit) }