From 446e526c11b715ab1762a3c0aaf7fa61f0aa6a60 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 26 Jun 2026 18:32:44 -0400 Subject: [PATCH] fix: cache UserProfile and UserFlags in DataStore to survive cold starts On cold start without network, userProfile and flags were null until the network fetch completed, causing the send flow to incorrectly show PhoneGate. Cache both in DataStore and restore early in login. Signed-off-by: Brandon McAnsh --- .../shared/authentication/build.gradle.kts | 1 + .../com/flipcash/app/auth/AuthManager.kt | 12 +- .../com/flipcash/app/auth/AuthManagerTest.kt | 5 +- apps/flipcash/shared/profile/.gitignore | 1 + apps/flipcash/shared/profile/build.gradle.kts | 17 ++ .../shared/profile/ProfileCoordinator.kt | 141 ++++++++++++++++ .../shared/userflags/build.gradle.kts | 2 + .../app/userflags/UserFlagsCoordinator.kt | 157 +++++++++++++++++- .../com/flipcash/services/user/UserManager.kt | 2 +- settings.gradle.kts | 1 + 10 files changed, 333 insertions(+), 6 deletions(-) create mode 100644 apps/flipcash/shared/profile/.gitignore create mode 100644 apps/flipcash/shared/profile/build.gradle.kts create mode 100644 apps/flipcash/shared/profile/src/main/kotlin/com/flipcash/shared/profile/ProfileCoordinator.kt diff --git a/apps/flipcash/shared/authentication/build.gradle.kts b/apps/flipcash/shared/authentication/build.gradle.kts index 72e3e9742..15c7b7cb7 100644 --- a/apps/flipcash/shared/authentication/build.gradle.kts +++ b/apps/flipcash/shared/authentication/build.gradle.kts @@ -17,6 +17,7 @@ dependencies { implementation(project(":apps:flipcash:shared:push")) implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:tokens")) + implementation(project(":apps:flipcash:shared:profile")) implementation(project(":apps:flipcash:shared:userflags")) implementation(project(":services:flipcash")) 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 a7ff230ab..e9af88d80 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 @@ -10,6 +10,7 @@ import com.flipcash.app.persistence.PersistenceProvider import com.flipcash.app.push.PushTokenProvider import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.userflags.UserFlagsCoordinator +import com.flipcash.shared.profile.ProfileCoordinator import com.flipcash.services.controllers.AccountController import com.flipcash.services.controllers.ProfileController import com.flipcash.services.controllers.PushController @@ -17,7 +18,6 @@ import com.flipcash.services.user.AuthState import com.flipcash.services.user.UserManager import com.flipcash.shared.authentication.BuildConfig import com.getcode.crypt.MnemonicPhrase -import com.getcode.opencode.controllers.TokenController import com.getcode.opencode.model.core.ID import com.getcode.utils.TraceManager import com.getcode.utils.TraceType @@ -46,9 +46,10 @@ class AuthManager @Inject constructor( private val pushTokenProvider: PushTokenProvider, private val tokenCoordinator: TokenCoordinator, private val persistence: PersistenceProvider, - private val featureFlagController: FeatureFlagController, + private val featureFlags: FeatureFlagController, private val appSettings: AppSettingsCoordinator, private val userFlags: UserFlagsCoordinator, + private val profileCoordinator: ProfileCoordinator, private val contactCoordinator: ContactCoordinator, private val networkObserver: NetworkConnectivityListener, private val dispatchers: DispatcherProvider, @@ -194,6 +195,9 @@ class AuthManager @Inject constructor( coroutineScope { launch { + profileCoordinator.restore() + userFlags.restoreFlags() + val flags = if (!isSoftLogin || networkObserver.isConnected) { retryable(maxRetries = 3) { accountController.getUserFlags().getOrNull() @@ -292,9 +296,11 @@ class AuthManager @Inject constructor( userManager.clear() tokenCoordinator.reset() persistence.close() - featureFlagController.reset() + featureFlags.reset() appSettings.reset() userFlags.clearAll() + profileCoordinator.reset() + userFlags.resetCache() if (!BuildConfig.DEBUG) TraceManager.userId = null } 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 169540947..d7411d366 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 @@ -10,6 +10,7 @@ import com.flipcash.app.persistence.PersistenceProvider import com.flipcash.app.push.PushTokenProvider import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.userflags.UserFlagsCoordinator +import com.flipcash.shared.profile.ProfileCoordinator import com.flipcash.services.controllers.AccountController import com.flipcash.services.controllers.ProfileController import com.flipcash.services.controllers.PushController @@ -59,6 +60,7 @@ class AuthManagerTest { private val featureFlagController: FeatureFlagController = mockk(relaxed = true) private val appSettings: AppSettingsCoordinator = mockk(relaxed = true) private val userFlags: UserFlagsCoordinator = mockk(relaxed = true) + private val profileCoordinator: ProfileCoordinator = mockk(relaxed = true) private val contactCoordinator: ContactCoordinator = mockk(relaxed = true) private val userManagerState = MutableStateFlow(UserManager.State()) @@ -92,9 +94,10 @@ class AuthManagerTest { pushTokenProvider = pushTokenProvider, tokenCoordinator = tokenCoordinator, persistence = persistence, - featureFlagController = featureFlagController, + featureFlags = featureFlagController, appSettings = appSettings, userFlags = userFlags, + profileCoordinator = profileCoordinator, contactCoordinator = contactCoordinator, dispatchers = dispatchers, networkObserver = networkConnectivityListener, diff --git a/apps/flipcash/shared/profile/.gitignore b/apps/flipcash/shared/profile/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/apps/flipcash/shared/profile/.gitignore @@ -0,0 +1 @@ +/build diff --git a/apps/flipcash/shared/profile/build.gradle.kts b/apps/flipcash/shared/profile/build.gradle.kts new file mode 100644 index 000000000..1c3cb9fca --- /dev/null +++ b/apps/flipcash/shared/profile/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + alias(libs.plugins.flipcash.android.library) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "${Gradle.flipcashNamespace}.shared.profile" +} + +dependencies { + implementation(libs.bundles.hilt) + implementation(libs.androidx.datastore) + implementation(libs.bundles.kotlinx.serialization) + + implementation(project(":libs:coroutines")) + implementation(project(":services:flipcash")) +} diff --git a/apps/flipcash/shared/profile/src/main/kotlin/com/flipcash/shared/profile/ProfileCoordinator.kt b/apps/flipcash/shared/profile/src/main/kotlin/com/flipcash/shared/profile/ProfileCoordinator.kt new file mode 100644 index 000000000..42593c21d --- /dev/null +++ b/apps/flipcash/shared/profile/src/main/kotlin/com/flipcash/shared/profile/ProfileCoordinator.kt @@ -0,0 +1,141 @@ +package com.flipcash.shared.profile + +import android.content.Context +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStoreFile +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.models.SocialAccount +import com.flipcash.services.models.UserProfile +import com.flipcash.services.user.UserManager +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ProfileCoordinator @Inject constructor( + @param:ApplicationContext private val context: Context, + private val userManager: UserManager, + dispatchers: DispatcherProvider, +) { + private val scope = CoroutineScope(SupervisorJob() + dispatchers.IO) + + private val dataStore = PreferenceDataStoreFactory.create( + corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() }, + scope = scope, + produceFile = { context.preferencesDataStoreFile("user-profile") } + ) + + private val json = Json { ignoreUnknownKeys = true } + + init { + scope.launch { + userManager.state + .map { it.userProfile } + .filterNotNull() + .distinctUntilChanged() + .collect { profile -> persist(profile) } + } + } + + suspend fun restore() { + val prefs = dataStore.data.first() + val raw = prefs[KEY_PROFILE] ?: return + val cached = runCatching { json.decodeFromString(raw) }.getOrNull() ?: return + userManager.set(cached.toDomain()) + } + + suspend fun reset() { + dataStore.edit { it.remove(KEY_PROFILE) } + } + + private suspend fun persist(profile: UserProfile) { + val cached = CachedProfile.fromDomain(profile) + val raw = json.encodeToString(cached) + dataStore.edit { it[KEY_PROFILE] = raw } + } + + companion object { + private val KEY_PROFILE = stringPreferencesKey("cached_user_profile") + } +} + +@Serializable +private data class CachedProfile( + val displayName: String? = null, + val socialAccounts: List = emptyList(), + val verifiedPhoneNumber: String? = null, + val verifiedEmailAddress: String? = null, +) { + fun toDomain(): UserProfile = UserProfile( + displayName = displayName, + socialAccounts = socialAccounts.mapNotNull { it.toDomain() }, + verifiedPhoneNumber = verifiedPhoneNumber, + verifiedEmailAddress = verifiedEmailAddress, + ) + + companion object { + fun fromDomain(profile: UserProfile): CachedProfile = CachedProfile( + displayName = profile.displayName, + socialAccounts = profile.socialAccounts.map { CachedSocialAccount.fromDomain(it) }, + verifiedPhoneNumber = profile.verifiedPhoneNumber, + verifiedEmailAddress = profile.verifiedEmailAddress, + ) + } +} + +@Serializable +private sealed interface CachedSocialAccount { + @Serializable + @SerialName("twitter_x") + data class TwitterX( + val id: String, + val username: String, + val name: String, + val description: String, + val profilePicUrl: String, + val verifiedType: String? = null, + val followerCount: Int, + ) : CachedSocialAccount + + fun toDomain(): SocialAccount? = when (this) { + is TwitterX -> SocialAccount.TwitterX( + id = id, + username = username, + name = name, + description = description, + profilePicUrl = profilePicUrl, + verifiedType = verifiedType?.let { + runCatching { SocialAccount.TwitterX.VerifiedType.valueOf(it) }.getOrNull() + }, + followerCount = followerCount, + ) + } + + companion object { + fun fromDomain(account: SocialAccount): CachedSocialAccount = when (account) { + is SocialAccount.TwitterX -> TwitterX( + id = account.id, + username = account.username, + name = account.name, + description = account.description, + profilePicUrl = account.profilePicUrl, + verifiedType = account.verifiedType?.name, + followerCount = account.followerCount, + ) + } + } +} diff --git a/apps/flipcash/shared/userflags/build.gradle.kts b/apps/flipcash/shared/userflags/build.gradle.kts index 6134b026e..38e90ecf0 100644 --- a/apps/flipcash/shared/userflags/build.gradle.kts +++ b/apps/flipcash/shared/userflags/build.gradle.kts @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.flipcash.android.library.compose) + alias(libs.plugins.kotlin.serialization) } android { @@ -11,6 +12,7 @@ dependencies { implementation(libs.androidx.datastore) implementation(libs.compose.foundation) implementation(libs.compose.ui.text) + implementation(libs.kotlinx.serialization.json) implementation(project(":libs:coroutines")) implementation(project(":libs:datetime")) diff --git a/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/UserFlagsCoordinator.kt b/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/UserFlagsCoordinator.kt index dbde9bcd0..6d75ac9d8 100644 --- a/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/UserFlagsCoordinator.kt +++ b/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/UserFlagsCoordinator.kt @@ -5,12 +5,15 @@ import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStoreFile import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.internal.model.thirdparty.OnRampProvider +import com.flipcash.services.internal.model.thirdparty.OnRampType import com.flipcash.services.internal.model.thirdparty.UsdcLiquidtyPool import com.flipcash.services.models.UserFlags import com.flipcash.services.user.UserManager +import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.Fiat import dagger.hilt.android.qualifiers.ApplicationContext import java.io.File @@ -20,17 +23,22 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds @Singleton class UserFlagsCoordinator @Inject constructor( @param:ApplicationContext private val context: Context, - userManager: UserManager, + private val userManager: UserManager, dispatchers: DispatcherProvider, ) { data class Overrides( @@ -62,6 +70,7 @@ class UserFlagsCoordinator @Inject constructor( } private val scope = CoroutineScope(SupervisorJob() + dispatchers.IO) + private val json = Json { ignoreUnknownKeys = true } init { // Delete the backing file before DataStore reads it to avoid a race @@ -71,6 +80,14 @@ class UserFlagsCoordinator @Inject constructor( context.preferencesDataStoreFile("user-flag-overrides").delete() marker.createNewFile() } + + scope.launch { + userManager.state + .map { it.flags } + .filterNotNull() + .distinctUntilChanged() + .collect { flags -> persistFlags(flags) } + } } private val dataStore = PreferenceDataStoreFactory.create( @@ -115,4 +132,142 @@ class UserFlagsCoordinator @Inject constructor( fun clearAll() { scope.launch { dataStore.edit { it.clear() } } } + + // --- Server flags cache --- + + private val cacheDataStore = PreferenceDataStoreFactory.create( + corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() }, + scope = scope, + produceFile = { context.preferencesDataStoreFile("cached-user-flags") } + ) + + suspend fun restoreFlags() { + val prefs = cacheDataStore.data.first() + val raw = prefs[KEY_CACHED_FLAGS] ?: return + val cached = runCatching { json.decodeFromString(raw) }.getOrNull() ?: return + userManager.set(cached.toDomain()) + } + + suspend fun resetCache() { + cacheDataStore.edit { it.remove(KEY_CACHED_FLAGS) } + } + + private suspend fun persistFlags(flags: UserFlags) { + val cached = CachedFlags.fromDomain(flags) + val raw = json.encodeToString(cached) + cacheDataStore.edit { it[KEY_CACHED_FLAGS] = raw } + } + + companion object { + private val KEY_CACHED_FLAGS = stringPreferencesKey("cached_flags_json") + } +} + +@Serializable +private data class CachedFlags( + val isStaff: Boolean, + val isRegistered: Boolean, + val requiresIapForRegistration: Boolean, + val preferredOnRampProvider: CachedOnRampProvider? = null, + val supportedOnRampProviders: List = emptyList(), + val minimumVersion: Int? = null, + val billExchangeDataTimeoutMillis: Long? = null, + val newCurrencyPurchaseAmount: CachedFiat, + val newCurrencyFeeAmount: CachedFiat, + val withdrawalFeeAmount: CachedFiat, + val preferredUsdcOnRampLiquidityPool: String, + val enablePhoneNumberSend: Boolean, + val minimumHolderValue: CachedFiat, + val requireCoinbaseEmailVerification: Boolean, +) { + fun toDomain(): UserFlags = UserFlags( + isStaff = isStaff, + isRegistered = isRegistered, + requiresIapForRegistration = requiresIapForRegistration, + preferredOnRampProvider = preferredOnRampProvider?.toDomain(), + supportedOnRampProviders = supportedOnRampProviders.mapNotNull { it.toDomain() }, + minimumVersion = minimumVersion, + billExchangeDataTimeout = billExchangeDataTimeoutMillis?.milliseconds, + newCurrencyPurchaseAmount = newCurrencyPurchaseAmount.toDomain(), + newCurrencyFeeAmount = newCurrencyFeeAmount.toDomain(), + withdrawalFeeAmount = withdrawalFeeAmount.toDomain(), + preferredUsdcOnRampLiquidityPool = runCatching { + UsdcLiquidtyPool.valueOf(preferredUsdcOnRampLiquidityPool) + }.getOrDefault(UsdcLiquidtyPool.Unknown), + enablePhoneNumberSend = enablePhoneNumberSend, + minimumHolderValue = minimumHolderValue.toDomain(), + requireCoinbaseEmailVerification = requireCoinbaseEmailVerification, + ) + + companion object { + fun fromDomain(flags: UserFlags): CachedFlags = CachedFlags( + isStaff = flags.isStaff, + isRegistered = flags.isRegistered, + requiresIapForRegistration = flags.requiresIapForRegistration, + preferredOnRampProvider = flags.preferredOnRampProvider?.let { + CachedOnRampProvider.fromDomain(it) + }, + supportedOnRampProviders = flags.supportedOnRampProviders.map { + CachedOnRampProvider.fromDomain(it) + }, + minimumVersion = flags.minimumVersion, + billExchangeDataTimeoutMillis = flags.billExchangeDataTimeout?.inWholeMilliseconds, + newCurrencyPurchaseAmount = CachedFiat.fromDomain(flags.newCurrencyPurchaseAmount), + newCurrencyFeeAmount = CachedFiat.fromDomain(flags.newCurrencyFeeAmount), + withdrawalFeeAmount = CachedFiat.fromDomain(flags.withdrawalFeeAmount), + preferredUsdcOnRampLiquidityPool = flags.preferredUsdcOnRampLiquidityPool.name, + enablePhoneNumberSend = flags.enablePhoneNumberSend, + minimumHolderValue = CachedFiat.fromDomain(flags.minimumHolderValue), + requireCoinbaseEmailVerification = flags.requireCoinbaseEmailVerification, + ) + } +} + +@Serializable +private data class CachedOnRampProvider( + val tag: String, + val coinbaseType: String? = null, +) { + fun toDomain(): OnRampProvider? = when (tag) { + "manual_deposit" -> OnRampProvider.ManualDeposit + "phantom" -> OnRampProvider.Phantom + "coinbase" -> { + val type = coinbaseType?.let { + runCatching { OnRampType.valueOf(it) }.getOrNull() + } ?: OnRampType.Virtual + OnRampProvider.Coinbase(type) + } + else -> null + } + + companion object { + fun fromDomain(provider: OnRampProvider): CachedOnRampProvider = when (provider) { + is OnRampProvider.ManualDeposit -> CachedOnRampProvider(tag = "manual_deposit") + is OnRampProvider.Phantom -> CachedOnRampProvider(tag = "phantom") + is OnRampProvider.Coinbase -> CachedOnRampProvider( + tag = "coinbase", + coinbaseType = provider.type.name, + ) + is OnRampProvider.Unknown -> CachedOnRampProvider(tag = "unknown") + } + } +} + +@Serializable +private data class CachedFiat( + val quarks: Long, + val currencyCode: String = "USD", +) { + fun toDomain(): Fiat = Fiat( + quarks = quarks, + currencyCode = runCatching { CurrencyCode.valueOf(currencyCode) } + .getOrDefault(CurrencyCode.USD), + ) + + companion object { + fun fromDomain(fiat: Fiat): CachedFiat = CachedFiat( + quarks = fiat.quarks, + currencyCode = fiat.currencyCode.name, + ) + } } \ No newline at end of file diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/user/UserManager.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/user/UserManager.kt index b2115fcfb..6e13b3ac2 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/user/UserManager.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/user/UserManager.kt @@ -179,7 +179,7 @@ class UserManager @Inject constructor( _state.update { it.copy(pushToken = pushToken) } } - internal fun set(userProfile: UserProfile) { + fun set(userProfile: UserProfile) { _state.update { it.copy(userProfile = userProfile) } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 5e8b4e5dd..cc49fa420 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -81,6 +81,7 @@ include( ":apps:flipcash:shared:tokens", ":apps:flipcash:shared:tokens:core", ":apps:flipcash:shared:theme", + ":apps:flipcash:shared:profile", ":apps:flipcash:shared:userflags", ":apps:flipcash:shared:workers", ":apps:flipcash:shared:web",