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 658551e4dc..6720e4680a 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 fc4dea4ca0..bac0cb673e 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 8bf9475831..f16b077edb 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 60e6181540..0745d1b801 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 26d0a7fa5e..0abdaf60a4 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 f16b077edb..190b514d28 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() + } } }