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",