Skip to content

Commit 25debb8

Browse files
authored
fix(contacts): resolve seed-login race conditions in contacts and payment linking (#831)
* 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 <git@bmcreations.dev> * 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 <git@bmcreations.dev> --------- Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent 1584e14 commit 25debb8

5 files changed

Lines changed: 46 additions & 25 deletions

File tree

apps/flipcash/features/login/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ dependencies {
1515
implementation(project(":apps:flipcash:shared:accesskey"))
1616
implementation(project(":apps:flipcash:shared:analytics"))
1717
implementation(project(":apps:flipcash:shared:authentication"))
18+
implementation(project(":apps:flipcash:shared:contacts"))
1819
implementation(project(":apps:flipcash:shared:featureflags"))
1920
implementation(project(":apps:flipcash:shared:permissions"))
2021
implementation(project(":apps:flipcash:shared:userflags"))

apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ import com.flipcash.app.login.internal.screens.LoginRouterScreenContent
3939
import com.flipcash.app.login.internal.screens.SeedInputContent
4040
import com.flipcash.app.login.router.LoginViewModel
4141
import com.flipcash.app.login.internal.SeedInputViewModel
42+
import androidx.compose.runtime.rememberCoroutineScope
43+
import com.flipcash.app.contacts.LocalContactCoordinator
4244
import com.flipcash.app.permissions.asContactAccessHandle
4345
import com.flipcash.app.permissions.internal.contacts.ContactScreenContent
4446
import com.flipcash.app.permissions.internal.notifications.NotificationRationalePermissionContent
@@ -64,6 +66,7 @@ import com.getcode.util.permissions.PermissionResult
6466
import com.getcode.util.permissions.rememberContactPermission
6567
import com.getcode.util.permissions.rememberNotificationPermission
6668
import kotlinx.coroutines.delay
69+
import kotlinx.coroutines.launch
6770
import kotlinx.coroutines.flow.filterIsInstance
6871
import kotlinx.coroutines.flow.launchIn
6972
import kotlinx.coroutines.flow.onEach
@@ -446,11 +449,14 @@ private fun PurchaseStepContent() {
446449
private fun ContactPermissionStepContent() {
447450
val flowNavigator = rememberFlowNavigator<OnboardingStep, OnboardingResult>()
448451
val analytics = LocalAnalytics.current
452+
val contactCoordinator = LocalContactCoordinator.current
453+
val scope = rememberCoroutineScope()
449454

450455
val permissionState = rememberContactPermission { result ->
451456
when (result) {
452457
PermissionResult.Granted -> {
453458
analytics.action(Button.AllowContacts)
459+
scope.launch { contactCoordinator.sync() }
454460
flowNavigator.proceed()
455461
}
456462
PermissionResult.Denied,

apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,14 @@ class AuthManager @Inject constructor(
196196
}
197197

198198
val seenAccessKey = credentialManager.hasSeenAccessKey()
199-
val completedOnboarding = credentialManager.hasCompletedOnboarding()
199+
// Interactive logins (seed input, deep link, credential picker)
200+
// always route through the onboarding permissions flow, which
201+
// sets Ready on completion. Don't trust the completedOnboarding
202+
// default (true for backward compat) here — it would skip the
203+
// permissions phase and set Ready too early.
204+
// Soft logins (app restart) can trust the persisted flag.
205+
val completedOnboarding = if (!isSoftLogin) false
206+
else credentialManager.hasCompletedOnboarding()
200207
if (flags != null) {
201208
userManager.set(flags)
202209
if (flags.isRegistered && seenAccessKey && completedOnboarding) {

apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/AuthManagerTest.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ class AuthManagerTest {
243243
Result.success(flags)
244244
)
245245

246-
val result = authManager.login(entropyB64 = entropy)
246+
val result = authManager.login(entropyB64 = entropy, isSoftLogin = true)
247247

248248
assertTrue(result.isSuccess)
249249
verify { userManager.set(flags) }
@@ -281,7 +281,7 @@ class AuthManagerTest {
281281
coEvery { accountController.getUserFlags() } returns Result.failure(RuntimeException("persistent failure"))
282282
coEvery { credentialManager.hasCompletedOnboarding() } returns true
283283

284-
val result = authManager.login(entropyB64 = entropy)
284+
val result = authManager.login(entropyB64 = entropy, isSoftLogin = true)
285285

286286
assertTrue(result.isSuccess)
287287
verify { userManager.set(authState = AuthState.Ready) }

apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -211,29 +211,36 @@ class ContactCoordinator @Inject constructor(
211211
* | After logout → re-login | `reset()` clears flag, fires on first foreground if conditions met |
212212
* | Phone number changed (unlink + re-verify) | Foreground path won't re-fire (flag is `true`), but the verification flow calls `linkForPayment` directly |
213213
*/
214+
private val linkMutex = kotlinx.coroutines.sync.Mutex()
215+
214216
fun linkForPaymentIfNeeded() {
215217
scope.launch {
216-
val featureFlag = featureFlagController.get(FeatureFlag.PhoneNumberSend)
217-
val serverFlag = userManager.state.value.flags?.enablePhoneNumberSend == true
218-
val enabled = featureFlag || serverFlag
219-
if (!enabled) return@launch
220-
221-
val alreadyLinked = contactPrefs.data
222-
.map { it[KEY_LINKED_FOR_PAYMENT] ?: false }
223-
.first()
224-
if (alreadyLinked) return@launch
225-
226-
// Profile may not be loaded yet on the first Ready transition;
227-
// wait for the verified phone number to arrive.
228-
val phone = userManager.state
229-
.map { it.userProfile?.verifiedPhoneNumber }
230-
.filterNotNull()
231-
.first()
232-
233-
contactVerificationController.linkForPayment(ContactMethod.Phone(phone))
234-
.onSuccess {
235-
contactPrefs.edit { it[KEY_LINKED_FOR_PAYMENT] = true }
236-
}
218+
if (!linkMutex.tryLock()) return@launch
219+
try {
220+
val featureFlag = featureFlagController.get(FeatureFlag.PhoneNumberSend)
221+
val serverFlag = userManager.state.value.flags?.enablePhoneNumberSend == true
222+
val enabled = featureFlag || serverFlag
223+
if (!enabled) return@launch
224+
225+
val alreadyLinked = contactPrefs.data
226+
.map { it[KEY_LINKED_FOR_PAYMENT] ?: false }
227+
.first()
228+
if (alreadyLinked) return@launch
229+
230+
// Profile may not be loaded yet on the first Ready transition;
231+
// wait for the verified phone number to arrive.
232+
val phone = userManager.state
233+
.map { it.userProfile?.verifiedPhoneNumber }
234+
.filterNotNull()
235+
.first()
236+
237+
contactVerificationController.linkForPayment(ContactMethod.Phone(phone))
238+
.onSuccess {
239+
contactPrefs.edit { it[KEY_LINKED_FOR_PAYMENT] = true }
240+
}
241+
} finally {
242+
linkMutex.unlock()
243+
}
237244
}
238245
}
239246

@@ -343,7 +350,7 @@ class ContactCoordinator @Inject constructor(
343350

344351
if (deviceContacts.isEmpty()) {
345352
trace(tag = TAG, message = "No device contacts found", type = TraceType.Process)
346-
_state.update { it.copy(syncState = SyncState.Synced) }
353+
_state.update { it.copy(syncState = SyncState.Synced, hasEverSynced = true) }
347354
return Result.success(Unit)
348355
}
349356

0 commit comments

Comments
 (0)