Skip to content

Commit d359ac0

Browse files
committed
feat(contacts): add idempotent linkForPayment call on app foreground
Ensures users with verified phone numbers are linked for payment server-side, even if they connected before the LinkForPayment RPC existed. Fires once per account lifetime, persisted via DataStore. - Add linkForPaymentIfNeeded() to ContactCoordinator with DataStore guard - Call from RealSessionController.onAppInForeground() after user flags - Remove OnboardingViewModel (session controller covers the login path) - Clear persisted flag on logout via reset()
1 parent 892d63f commit d359ac0

6 files changed

Lines changed: 75 additions & 34 deletions

File tree

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

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ import com.flipcash.app.featureflags.LocalFeatureFlags
3333
import com.flipcash.app.core.onboarding.OnboardingResult
3434
import com.flipcash.app.core.onboarding.OnboardingStep
3535
import com.flipcash.app.login.internal.LoginAccessKeyViewModel
36-
import com.flipcash.app.login.internal.OnboardingViewModel
3736
import com.flipcash.app.login.internal.screens.PhotoAccessKeyScreen
3837
import com.flipcash.app.login.internal.screens.AccessKeyScreen
3938
import com.flipcash.app.login.internal.screens.LoginRouterScreenContent
@@ -135,7 +134,6 @@ private fun PermissionsPhaseFlowHost(
135134
resultStateRegistry: NavResultStateRegistry,
136135
) {
137136
val outerNavigator = LocalCodeNavigator.current
138-
val onboardingViewModel = hiltViewModel<OnboardingViewModel>()
139137
val checker = LocalPermissionChecker.current
140138
val contactConfig = PermissionConfigs.contacts()
141139
val notificationConfig = PermissionConfigs.notifications()
@@ -180,7 +178,6 @@ private fun PermissionsPhaseFlowHost(
180178
when (reason) {
181179
is FlowExitReason.Completed -> {
182180
analytics.action(Action.CompletedOnboarding)
183-
onboardingViewModel.linkPhoneForPayment()
184181
outerNavigator.navigate(
185182
route = AppRoute.Main.Scanner,
186183
options = NavOptions(popUpTo = NavOptions.PopUpTo.ClearAll),
@@ -189,7 +186,6 @@ private fun PermissionsPhaseFlowHost(
189186

190187
FlowExitReason.BackedOutOfRoot -> {
191188
// All permissions already granted
192-
onboardingViewModel.linkPhoneForPayment()
193189
outerNavigator.navigate(
194190
route = AppRoute.Main.Scanner,
195191
options = NavOptions(popUpTo = NavOptions.PopUpTo.ClearAll),

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

Lines changed: 0 additions & 30 deletions
This file was deleted.

apps/flipcash/shared/contacts/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ dependencies {
1919
implementation(project(":apps:flipcash:shared:featureflags"))
2020
implementation(project(":libs:encryption:keys"))
2121
implementation(project(":libs:network:connectivity:public"))
22+
implementation(libs.androidx.datastore)
2223
implementation(libs.androidx.lifecycle.process)
2324
implementation(libs.bundles.room)
2425
}

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

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,30 +2,43 @@
22

33
package com.flipcash.app.contacts
44

5+
import android.content.Context
56
import androidx.compose.runtime.staticCompositionLocalOf
7+
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
8+
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
9+
import androidx.datastore.preferences.core.booleanPreferencesKey
10+
import androidx.datastore.preferences.core.edit
11+
import androidx.datastore.preferences.core.emptyPreferences
12+
import androidx.datastore.preferences.preferencesDataStore
13+
import androidx.datastore.preferences.preferencesDataStoreFile
614
import androidx.lifecycle.DefaultLifecycleObserver
715
import androidx.lifecycle.LifecycleOwner
816
import androidx.lifecycle.ProcessLifecycleOwner
917
import com.flipcash.app.contacts.device.DeviceContact
1018
import com.flipcash.app.contacts.device.PickedContactData
1119
import com.flipcash.app.contacts.device.ScopeAwareContactReader
20+
import com.flipcash.app.featureflags.FeatureFlag
21+
import com.flipcash.app.featureflags.FeatureFlagController
1222
import com.flipcash.app.phone.PhoneUtils
1323
import com.flipcash.app.contacts.sync.ContactChecksum
1424
import com.flipcash.app.persistence.entities.ContactMappingEntity
1525
import com.flipcash.app.persistence.sources.ContactDataSource
1626
import com.flipcash.services.controllers.ContactListController
27+
import com.flipcash.services.controllers.ContactVerificationController
1728
import com.flipcash.services.controllers.ResolverController
1829
import com.flipcash.services.models.CheckSyncError
1930
import com.flipcash.services.models.ContactMethod
2031
import com.flipcash.services.models.DeltaUploadError
2132
import com.flipcash.services.models.GetContactsError
33+
import com.flipcash.services.user.UserManager
2234
import com.getcode.opencode.model.accounts.AccountCluster
2335
import com.getcode.opencode.providers.SessionListener
2436
import com.getcode.solana.keys.Checksum
2537
import com.getcode.solana.keys.PublicKey
2638
import com.getcode.utils.TraceType
2739
import com.getcode.utils.network.NetworkConnectivityListener
2840
import com.getcode.utils.trace
41+
import dagger.hilt.android.qualifiers.ApplicationContext
2942
import kotlinx.coroutines.CoroutineScope
3043
import kotlinx.coroutines.Dispatchers
3144
import kotlinx.coroutines.Job
@@ -37,29 +50,46 @@ import kotlinx.coroutines.flow.asStateFlow
3750
import kotlinx.coroutines.flow.distinctUntilChanged
3851
import kotlinx.coroutines.flow.filter
3952
import kotlinx.coroutines.flow.filterNotNull
53+
import kotlinx.coroutines.flow.first
4054
import kotlinx.coroutines.flow.firstOrNull
4155
import kotlinx.coroutines.flow.flatMapLatest
4256
import kotlinx.coroutines.flow.launchIn
57+
import kotlinx.coroutines.flow.map
4358
import kotlinx.coroutines.flow.onEach
4459
import kotlinx.coroutines.flow.update
4560
import kotlinx.coroutines.launch
4661
import javax.inject.Inject
4762
import javax.inject.Singleton
4863

64+
4965
@Singleton
5066
class ContactCoordinator @Inject constructor(
67+
@ApplicationContext private val context: Context,
5168
private val contactListController: ContactListController,
69+
private val contactVerificationController: ContactVerificationController,
5270
private val resolverController: ResolverController,
5371
private val networkObserver: NetworkConnectivityListener,
5472
private val contactReader: ScopeAwareContactReader,
5573
private val phoneUtils: PhoneUtils,
5674
private val contactDataSource: ContactDataSource,
75+
private val userManager: UserManager,
76+
private val featureFlagController: FeatureFlagController,
5777
) : SessionListener, DefaultLifecycleObserver {
5878

5979
companion object {
6080
private const val TAG = "ContactCoordinator"
81+
private val KEY_LINKED_FOR_PAYMENT = booleanPreferencesKey("linked_for_payment")
6182
}
6283

84+
private val contactPrefs = PreferenceDataStoreFactory.create(
85+
corruptionHandler = ReplaceFileCorruptionHandler(
86+
produceNewData = { emptyPreferences() }
87+
),
88+
migrations = listOf(),
89+
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
90+
produceFile = { context.preferencesDataStoreFile("app-settings") }
91+
)
92+
6393
data class ContactState(
6494
val contacts: Map<String, DeviceContact> = emptyMap(),
6595
val flipcashE164s: Set<String> = emptySet(),
@@ -147,12 +177,49 @@ class ContactCoordinator @Inject constructor(
147177
return resolverController.resolve(ContactMethod.Phone(e164))
148178
}
149179

180+
/**
181+
* Ensures the user's verified phone number is linked for payment, calling the
182+
* server RPC at most once per account lifetime (persisted via DataStore).
183+
*
184+
* Called from [com.flipcash.app.session.internal.RealSessionController.onAppInForeground].
185+
*
186+
* | Scenario | What happens |
187+
* |-----------------------------------------|--------------------------------------------------------------------------------|
188+
* | No verified phone | Returns immediately (no RPC) |
189+
* | Both flags off | Returns immediately (no RPC) |
190+
* | Feature flag on, server flag off | Enabled — proceeds to DataStore check |
191+
* | Feature flag off, server flag on | Enabled — proceeds to DataStore check |
192+
* | Both flags on | Enabled — proceeds to DataStore check |
193+
* | DataStore `linked_for_payment = true` | Returns immediately (no RPC) |
194+
* | First time, RPC succeeds | Persists `true`, never fires again for this account |
195+
* | First time, RPC fails | Flag stays `false`, retries on next foreground if conditions met |
196+
* | After logout → re-login | `reset()` clears flag, fires on first foreground if conditions met |
197+
* | Phone number changed (unlink + re-verify) | Foreground path won't re-fire (flag is `true`), but the verification flow calls `linkForPayment` directly |
198+
*/
199+
fun linkForPaymentIfNeeded() {
200+
val phone = userManager.profile?.verifiedPhoneNumber ?: return
201+
val enabled = featureFlagController.observe(FeatureFlag.PhoneNumberSend).value ||
202+
userManager.state.value.flags?.enablePhoneNumberSend == true
203+
if (!enabled) return
204+
scope.launch {
205+
val alreadyLinked = contactPrefs.data
206+
.map { it[KEY_LINKED_FOR_PAYMENT] ?: false }
207+
.first()
208+
if (alreadyLinked) return@launch
209+
contactVerificationController.linkForPayment(ContactMethod.Phone(phone))
210+
.onSuccess {
211+
contactPrefs.edit { it[KEY_LINKED_FOR_PAYMENT] = true }
212+
}
213+
}
214+
}
215+
150216
suspend fun reset() {
151217
syncJob?.cancel()
152218
_state.value = ContactState()
153219
cluster.value = null
154220
contactReader.reset()
155221
contactDataSource.clear()
222+
contactPrefs.edit { it.remove(KEY_LINKED_FOR_PAYMENT) }
156223
trace(tag = TAG, message = "reset complete", type = TraceType.Process)
157224
}
158225

apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ class RealSessionController @Inject constructor(
237237
startPolling()
238238
swapUsdcIfNeeded()
239239
updateUserFlags()
240+
linkForPaymentIfNeeded()
240241
updateSettings()
241242
checkPendingItemsInFeed()
242243
bringActivityFeedCurrent()
@@ -312,6 +313,10 @@ class RealSessionController @Inject constructor(
312313
}
313314
}
314315

316+
private fun linkForPaymentIfNeeded() {
317+
contactCoordinator.linkForPaymentIfNeeded()
318+
}
319+
315320
private fun updateSettings() {
316321
if (userManager.authState.canAccessAuthenticatedApis) {
317322
scope.launch {

services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ContactVerificationController.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import com.flipcash.services.models.ContactMethod
44
import com.flipcash.services.repository.ContactVerificationRepository
55
import com.flipcash.services.user.UserManager
66
import javax.inject.Inject
7+
import javax.inject.Singleton
78

9+
@Singleton
810
class ContactVerificationController @Inject constructor(
911
private val repository: ContactVerificationRepository,
1012
private val userManager: UserManager,

0 commit comments

Comments
 (0)