From 9ddc3f10c108a87aa354d9fa2b9a8ef650235521 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 2 Jun 2026 16:14:05 -0400 Subject: [PATCH 1/2] fix(contacts): prevent contacts gate re-appearing after seed login Three issues caused the contacts permission gate to re-appear in the send flow after granting permission during seed restore onboarding: 1. performSync() empty-contacts early return did not set hasEverSynced, so the send flow gate condition still evaluated true. 2. The onboarding ContactPermissionStepContent only granted the OS permission and proceeded -- it never triggered contactCoordinator.sync(). The send flow then saw no synced contacts and showed the gate again. 3. The Flipcash contacts discovery modal used an imperative currentStep check inside a filter on contactCoordinator.state, which only evaluated when contact state changed. If the state was already settled from the onboarding sync, the modal never fired. Replaced with combine() so changes to either source trigger re-evaluation. Signed-off-by: Brandon McAnsh --- apps/flipcash/features/login/build.gradle.kts | 1 + .../kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt | 6 ++++++ .../kotlin/com/flipcash/app/contacts/ContactCoordinator.kt | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) 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/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..f16b077ed 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 @@ -343,7 +343,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) } From 7eaf011334e59c37f6db2150623a75135d035334 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 2 Jun 2026 16:14:49 -0400 Subject: [PATCH 2/2] fix(auth): prevent premature linkForPayment during onboarding Interactive logins (seed input, deep link, credential picker) set AuthState.Ready too early because hasCompletedOnboarding() defaults to true for backward compat on new devices. This triggered onAppInForeground -- and linkForPaymentIfNeeded -- while permission screens were still up. Fix: for non-soft logins, always force completedOnboarding to false so auth routes through Onboarding(PostAccessKey). The permissions flow sets Ready on completion at the right time. Soft logins (app restart) still trust the persisted flag. Additionally, guard linkForPaymentIfNeeded with a Mutex so overlapping calls from the auth-state observer and ON_RESUME lifecycle cannot race into duplicate RPCs. Signed-off-by: Brandon McAnsh --- .../com/flipcash/app/auth/AuthManager.kt | 9 +++- .../com/flipcash/app/auth/AuthManagerTest.kt | 4 +- .../app/contacts/ContactCoordinator.kt | 49 +++++++++++-------- 3 files changed, 38 insertions(+), 24 deletions(-) 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 f16b077ed..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() + } } }