22
33package com.flipcash.app.contacts
44
5+ import android.content.Context
56import 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
614import androidx.lifecycle.DefaultLifecycleObserver
715import androidx.lifecycle.LifecycleOwner
816import androidx.lifecycle.ProcessLifecycleOwner
917import com.flipcash.app.contacts.device.DeviceContact
1018import com.flipcash.app.contacts.device.PickedContactData
1119import com.flipcash.app.contacts.device.ScopeAwareContactReader
20+ import com.flipcash.app.featureflags.FeatureFlag
21+ import com.flipcash.app.featureflags.FeatureFlagController
1222import com.flipcash.app.phone.PhoneUtils
1323import com.flipcash.app.contacts.sync.ContactChecksum
1424import com.flipcash.app.persistence.entities.ContactMappingEntity
1525import com.flipcash.app.persistence.sources.ContactDataSource
1626import com.flipcash.services.controllers.ContactListController
27+ import com.flipcash.services.controllers.ContactVerificationController
1728import com.flipcash.services.controllers.ResolverController
1829import com.flipcash.services.models.CheckSyncError
1930import com.flipcash.services.models.ContactMethod
2031import com.flipcash.services.models.DeltaUploadError
2132import com.flipcash.services.models.GetContactsError
33+ import com.flipcash.services.user.UserManager
2234import com.getcode.opencode.model.accounts.AccountCluster
2335import com.getcode.opencode.providers.SessionListener
2436import com.getcode.solana.keys.Checksum
2537import com.getcode.solana.keys.PublicKey
2638import com.getcode.utils.TraceType
2739import com.getcode.utils.network.NetworkConnectivityListener
2840import com.getcode.utils.trace
41+ import dagger.hilt.android.qualifiers.ApplicationContext
2942import kotlinx.coroutines.CoroutineScope
3043import kotlinx.coroutines.Dispatchers
3144import kotlinx.coroutines.Job
@@ -37,29 +50,46 @@ import kotlinx.coroutines.flow.asStateFlow
3750import kotlinx.coroutines.flow.distinctUntilChanged
3851import kotlinx.coroutines.flow.filter
3952import kotlinx.coroutines.flow.filterNotNull
53+ import kotlinx.coroutines.flow.first
4054import kotlinx.coroutines.flow.firstOrNull
4155import kotlinx.coroutines.flow.flatMapLatest
4256import kotlinx.coroutines.flow.launchIn
57+ import kotlinx.coroutines.flow.map
4358import kotlinx.coroutines.flow.onEach
4459import kotlinx.coroutines.flow.update
4560import kotlinx.coroutines.launch
4661import javax.inject.Inject
4762import javax.inject.Singleton
4863
64+
4965@Singleton
5066class 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
0 commit comments