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 d431459a3..bcece424c 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 @@ -33,7 +33,6 @@ import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.core.onboarding.OnboardingResult import com.flipcash.app.core.onboarding.OnboardingStep import com.flipcash.app.login.internal.LoginAccessKeyViewModel -import com.flipcash.app.login.internal.OnboardingViewModel import com.flipcash.app.login.internal.screens.PhotoAccessKeyScreen import com.flipcash.app.login.internal.screens.AccessKeyScreen import com.flipcash.app.login.internal.screens.LoginRouterScreenContent @@ -135,7 +134,6 @@ private fun PermissionsPhaseFlowHost( resultStateRegistry: NavResultStateRegistry, ) { val outerNavigator = LocalCodeNavigator.current - val onboardingViewModel = hiltViewModel() val checker = LocalPermissionChecker.current val contactConfig = PermissionConfigs.contacts() val notificationConfig = PermissionConfigs.notifications() @@ -180,7 +178,6 @@ private fun PermissionsPhaseFlowHost( when (reason) { is FlowExitReason.Completed -> { analytics.action(Action.CompletedOnboarding) - onboardingViewModel.linkPhoneForPayment() outerNavigator.navigate( route = AppRoute.Main.Scanner, options = NavOptions(popUpTo = NavOptions.PopUpTo.ClearAll), @@ -189,7 +186,6 @@ private fun PermissionsPhaseFlowHost( FlowExitReason.BackedOutOfRoot -> { // All permissions already granted - onboardingViewModel.linkPhoneForPayment() outerNavigator.navigate( route = AppRoute.Main.Scanner, options = NavOptions(popUpTo = NavOptions.PopUpTo.ClearAll), diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/OnboardingViewModel.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/OnboardingViewModel.kt deleted file mode 100644 index 793b517b7..000000000 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/OnboardingViewModel.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.flipcash.app.login.internal - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.flipcash.app.featureflags.FeatureFlag -import com.flipcash.app.featureflags.FeatureFlagController -import com.flipcash.services.controllers.ContactVerificationController -import com.flipcash.services.models.ContactMethod -import com.flipcash.services.user.UserManager -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -internal class OnboardingViewModel @Inject constructor( - private val userManager: UserManager, - private val featureFlags: FeatureFlagController, - private val contactVerificationController: ContactVerificationController, -) : ViewModel() { - - fun linkPhoneForPayment() { - val phone = userManager.profile?.verifiedPhoneNumber ?: return - val enabled = featureFlags.observe(FeatureFlag.PhoneNumberSend).value || - userManager.state.value.flags?.enablePhoneNumberSend == true - if (!enabled) return - viewModelScope.launch { - contactVerificationController.linkForPayment(ContactMethod.Phone(phone)) - } - } -} diff --git a/apps/flipcash/shared/contacts/build.gradle.kts b/apps/flipcash/shared/contacts/build.gradle.kts index d2168c68a..5f922142d 100644 --- a/apps/flipcash/shared/contacts/build.gradle.kts +++ b/apps/flipcash/shared/contacts/build.gradle.kts @@ -19,6 +19,7 @@ dependencies { implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":libs:encryption:keys")) implementation(project(":libs:network:connectivity:public")) + implementation(libs.androidx.datastore) implementation(libs.androidx.lifecycle.process) implementation(libs.bundles.room) } 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 c16bdc86c..e86900779 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 @@ -2,23 +2,35 @@ package com.flipcash.app.contacts +import android.content.Context import androidx.compose.runtime.staticCompositionLocalOf +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.preferencesDataStore +import androidx.datastore.preferences.preferencesDataStoreFile import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner import com.flipcash.app.contacts.device.DeviceContact import com.flipcash.app.contacts.device.PickedContactData import com.flipcash.app.contacts.device.ScopeAwareContactReader +import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.phone.PhoneUtils import com.flipcash.app.contacts.sync.ContactChecksum import com.flipcash.app.persistence.entities.ContactMappingEntity import com.flipcash.app.persistence.sources.ContactDataSource import com.flipcash.services.controllers.ContactListController +import com.flipcash.services.controllers.ContactVerificationController import com.flipcash.services.controllers.ResolverController import com.flipcash.services.models.CheckSyncError import com.flipcash.services.models.ContactMethod import com.flipcash.services.models.DeltaUploadError import com.flipcash.services.models.GetContactsError +import com.flipcash.services.user.UserManager import com.getcode.opencode.model.accounts.AccountCluster import com.getcode.opencode.providers.SessionListener import com.getcode.solana.keys.Checksum @@ -26,6 +38,7 @@ import com.getcode.solana.keys.PublicKey import com.getcode.utils.TraceType import com.getcode.utils.network.NetworkConnectivityListener import com.getcode.utils.trace +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -37,29 +50,46 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton + @Singleton class ContactCoordinator @Inject constructor( + @ApplicationContext private val context: Context, private val contactListController: ContactListController, + private val contactVerificationController: ContactVerificationController, private val resolverController: ResolverController, private val networkObserver: NetworkConnectivityListener, private val contactReader: ScopeAwareContactReader, private val phoneUtils: PhoneUtils, private val contactDataSource: ContactDataSource, + private val userManager: UserManager, + private val featureFlagController: FeatureFlagController, ) : SessionListener, DefaultLifecycleObserver { companion object { private const val TAG = "ContactCoordinator" + private val KEY_LINKED_FOR_PAYMENT = booleanPreferencesKey("linked_for_payment") } + private val contactPrefs = PreferenceDataStoreFactory.create( + corruptionHandler = ReplaceFileCorruptionHandler( + produceNewData = { emptyPreferences() } + ), + migrations = listOf(), + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + produceFile = { context.preferencesDataStoreFile("app-settings") } + ) + data class ContactState( val contacts: Map = emptyMap(), val flipcashE164s: Set = emptySet(), @@ -147,12 +177,49 @@ class ContactCoordinator @Inject constructor( return resolverController.resolve(ContactMethod.Phone(e164)) } + /** + * Ensures the user's verified phone number is linked for payment, calling the + * server RPC at most once per account lifetime (persisted via DataStore). + * + * Called from [com.flipcash.app.session.internal.RealSessionController.onAppInForeground]. + * + * | Scenario | What happens | + * |-----------------------------------------|--------------------------------------------------------------------------------| + * | No verified phone | Returns immediately (no RPC) | + * | Both flags off | Returns immediately (no RPC) | + * | Feature flag on, server flag off | Enabled — proceeds to DataStore check | + * | Feature flag off, server flag on | Enabled — proceeds to DataStore check | + * | Both flags on | Enabled — proceeds to DataStore check | + * | DataStore `linked_for_payment = true` | Returns immediately (no RPC) | + * | First time, RPC succeeds | Persists `true`, never fires again for this account | + * | First time, RPC fails | Flag stays `false`, retries on next foreground if conditions met | + * | 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 | + */ + fun linkForPaymentIfNeeded() { + val phone = userManager.profile?.verifiedPhoneNumber ?: return + val enabled = featureFlagController.observe(FeatureFlag.PhoneNumberSend).value || + userManager.state.value.flags?.enablePhoneNumberSend == true + if (!enabled) return + scope.launch { + val alreadyLinked = contactPrefs.data + .map { it[KEY_LINKED_FOR_PAYMENT] ?: false } + .first() + if (alreadyLinked) return@launch + contactVerificationController.linkForPayment(ContactMethod.Phone(phone)) + .onSuccess { + contactPrefs.edit { it[KEY_LINKED_FOR_PAYMENT] = true } + } + } + } + suspend fun reset() { syncJob?.cancel() _state.value = ContactState() cluster.value = null contactReader.reset() contactDataSource.clear() + contactPrefs.edit { it.remove(KEY_LINKED_FOR_PAYMENT) } trace(tag = TAG, message = "reset complete", type = TraceType.Process) } diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt index a0ce8ca12..155c2fa95 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt @@ -237,6 +237,7 @@ class RealSessionController @Inject constructor( startPolling() swapUsdcIfNeeded() updateUserFlags() + linkForPaymentIfNeeded() updateSettings() checkPendingItemsInFeed() bringActivityFeedCurrent() @@ -312,6 +313,12 @@ class RealSessionController @Inject constructor( } } + private fun linkForPaymentIfNeeded() { + if (userManager.authState.canAccessAuthenticatedApis) { + contactCoordinator.linkForPaymentIfNeeded() + } + } + private fun updateSettings() { if (userManager.authState.canAccessAuthenticatedApis) { scope.launch { diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ContactVerificationController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ContactVerificationController.kt index aad2d3cb5..f900d458b 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ContactVerificationController.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ContactVerificationController.kt @@ -4,7 +4,9 @@ import com.flipcash.services.models.ContactMethod import com.flipcash.services.repository.ContactVerificationRepository import com.flipcash.services.user.UserManager import javax.inject.Inject +import javax.inject.Singleton +@Singleton class ContactVerificationController @Inject constructor( private val repository: ContactVerificationRepository, private val userManager: UserManager,