Skip to content

Commit af1e036

Browse files
authored
fix: cache UserProfile and UserFlags in DataStore to survive cold starts (#1011)
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 <git@bmcreations.dev>
1 parent 6d444f6 commit af1e036

10 files changed

Lines changed: 333 additions & 6 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ dependencies {
1717
implementation(project(":apps:flipcash:shared:push"))
1818
implementation(project(":apps:flipcash:shared:featureflags"))
1919
implementation(project(":apps:flipcash:shared:tokens"))
20+
implementation(project(":apps:flipcash:shared:profile"))
2021
implementation(project(":apps:flipcash:shared:userflags"))
2122
implementation(project(":services:flipcash"))
2223

apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,14 @@ import com.flipcash.app.persistence.PersistenceProvider
1010
import com.flipcash.app.push.PushTokenProvider
1111
import com.flipcash.app.tokens.TokenCoordinator
1212
import com.flipcash.app.userflags.UserFlagsCoordinator
13+
import com.flipcash.shared.profile.ProfileCoordinator
1314
import com.flipcash.services.controllers.AccountController
1415
import com.flipcash.services.controllers.ProfileController
1516
import com.flipcash.services.controllers.PushController
1617
import com.flipcash.services.user.AuthState
1718
import com.flipcash.services.user.UserManager
1819
import com.flipcash.shared.authentication.BuildConfig
1920
import com.getcode.crypt.MnemonicPhrase
20-
import com.getcode.opencode.controllers.TokenController
2121
import com.getcode.opencode.model.core.ID
2222
import com.getcode.utils.TraceManager
2323
import com.getcode.utils.TraceType
@@ -46,9 +46,10 @@ class AuthManager @Inject constructor(
4646
private val pushTokenProvider: PushTokenProvider,
4747
private val tokenCoordinator: TokenCoordinator,
4848
private val persistence: PersistenceProvider,
49-
private val featureFlagController: FeatureFlagController,
49+
private val featureFlags: FeatureFlagController,
5050
private val appSettings: AppSettingsCoordinator,
5151
private val userFlags: UserFlagsCoordinator,
52+
private val profileCoordinator: ProfileCoordinator,
5253
private val contactCoordinator: ContactCoordinator,
5354
private val networkObserver: NetworkConnectivityListener,
5455
private val dispatchers: DispatcherProvider,
@@ -194,6 +195,9 @@ class AuthManager @Inject constructor(
194195

195196
coroutineScope {
196197
launch {
198+
profileCoordinator.restore()
199+
userFlags.restoreFlags()
200+
197201
val flags = if (!isSoftLogin || networkObserver.isConnected) {
198202
retryable(maxRetries = 3) {
199203
accountController.getUserFlags().getOrNull()
@@ -292,9 +296,11 @@ class AuthManager @Inject constructor(
292296
userManager.clear()
293297
tokenCoordinator.reset()
294298
persistence.close()
295-
featureFlagController.reset()
299+
featureFlags.reset()
296300
appSettings.reset()
297301
userFlags.clearAll()
302+
profileCoordinator.reset()
303+
userFlags.resetCache()
298304

299305
if (!BuildConfig.DEBUG) TraceManager.userId = null
300306
}

apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/AuthManagerTest.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import com.flipcash.app.persistence.PersistenceProvider
1010
import com.flipcash.app.push.PushTokenProvider
1111
import com.flipcash.app.tokens.TokenCoordinator
1212
import com.flipcash.app.userflags.UserFlagsCoordinator
13+
import com.flipcash.shared.profile.ProfileCoordinator
1314
import com.flipcash.services.controllers.AccountController
1415
import com.flipcash.services.controllers.ProfileController
1516
import com.flipcash.services.controllers.PushController
@@ -59,6 +60,7 @@ class AuthManagerTest {
5960
private val featureFlagController: FeatureFlagController = mockk(relaxed = true)
6061
private val appSettings: AppSettingsCoordinator = mockk(relaxed = true)
6162
private val userFlags: UserFlagsCoordinator = mockk(relaxed = true)
63+
private val profileCoordinator: ProfileCoordinator = mockk(relaxed = true)
6264
private val contactCoordinator: ContactCoordinator = mockk(relaxed = true)
6365
private val userManagerState = MutableStateFlow(UserManager.State())
6466

@@ -92,9 +94,10 @@ class AuthManagerTest {
9294
pushTokenProvider = pushTokenProvider,
9395
tokenCoordinator = tokenCoordinator,
9496
persistence = persistence,
95-
featureFlagController = featureFlagController,
97+
featureFlags = featureFlagController,
9698
appSettings = appSettings,
9799
userFlags = userFlags,
100+
profileCoordinator = profileCoordinator,
98101
contactCoordinator = contactCoordinator,
99102
dispatchers = dispatchers,
100103
networkObserver = networkConnectivityListener,
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/build
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
plugins {
2+
alias(libs.plugins.flipcash.android.library)
3+
alias(libs.plugins.kotlin.serialization)
4+
}
5+
6+
android {
7+
namespace = "${Gradle.flipcashNamespace}.shared.profile"
8+
}
9+
10+
dependencies {
11+
implementation(libs.bundles.hilt)
12+
implementation(libs.androidx.datastore)
13+
implementation(libs.bundles.kotlinx.serialization)
14+
15+
implementation(project(":libs:coroutines"))
16+
implementation(project(":services:flipcash"))
17+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package com.flipcash.shared.profile
2+
3+
import android.content.Context
4+
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
5+
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
6+
import androidx.datastore.preferences.core.edit
7+
import androidx.datastore.preferences.core.emptyPreferences
8+
import androidx.datastore.preferences.core.stringPreferencesKey
9+
import androidx.datastore.preferences.preferencesDataStoreFile
10+
import com.flipcash.libs.coroutines.DispatcherProvider
11+
import com.flipcash.services.models.SocialAccount
12+
import com.flipcash.services.models.UserProfile
13+
import com.flipcash.services.user.UserManager
14+
import dagger.hilt.android.qualifiers.ApplicationContext
15+
import kotlinx.coroutines.CoroutineScope
16+
import kotlinx.coroutines.SupervisorJob
17+
import kotlinx.coroutines.flow.distinctUntilChanged
18+
import kotlinx.coroutines.flow.filterNotNull
19+
import kotlinx.coroutines.flow.first
20+
import kotlinx.coroutines.flow.map
21+
import kotlinx.coroutines.launch
22+
import kotlinx.serialization.SerialName
23+
import kotlinx.serialization.Serializable
24+
import kotlinx.serialization.json.Json
25+
import javax.inject.Inject
26+
import javax.inject.Singleton
27+
28+
@Singleton
29+
class ProfileCoordinator @Inject constructor(
30+
@param:ApplicationContext private val context: Context,
31+
private val userManager: UserManager,
32+
dispatchers: DispatcherProvider,
33+
) {
34+
private val scope = CoroutineScope(SupervisorJob() + dispatchers.IO)
35+
36+
private val dataStore = PreferenceDataStoreFactory.create(
37+
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
38+
scope = scope,
39+
produceFile = { context.preferencesDataStoreFile("user-profile") }
40+
)
41+
42+
private val json = Json { ignoreUnknownKeys = true }
43+
44+
init {
45+
scope.launch {
46+
userManager.state
47+
.map { it.userProfile }
48+
.filterNotNull()
49+
.distinctUntilChanged()
50+
.collect { profile -> persist(profile) }
51+
}
52+
}
53+
54+
suspend fun restore() {
55+
val prefs = dataStore.data.first()
56+
val raw = prefs[KEY_PROFILE] ?: return
57+
val cached = runCatching { json.decodeFromString<CachedProfile>(raw) }.getOrNull() ?: return
58+
userManager.set(cached.toDomain())
59+
}
60+
61+
suspend fun reset() {
62+
dataStore.edit { it.remove(KEY_PROFILE) }
63+
}
64+
65+
private suspend fun persist(profile: UserProfile) {
66+
val cached = CachedProfile.fromDomain(profile)
67+
val raw = json.encodeToString(cached)
68+
dataStore.edit { it[KEY_PROFILE] = raw }
69+
}
70+
71+
companion object {
72+
private val KEY_PROFILE = stringPreferencesKey("cached_user_profile")
73+
}
74+
}
75+
76+
@Serializable
77+
private data class CachedProfile(
78+
val displayName: String? = null,
79+
val socialAccounts: List<CachedSocialAccount> = emptyList(),
80+
val verifiedPhoneNumber: String? = null,
81+
val verifiedEmailAddress: String? = null,
82+
) {
83+
fun toDomain(): UserProfile = UserProfile(
84+
displayName = displayName,
85+
socialAccounts = socialAccounts.mapNotNull { it.toDomain() },
86+
verifiedPhoneNumber = verifiedPhoneNumber,
87+
verifiedEmailAddress = verifiedEmailAddress,
88+
)
89+
90+
companion object {
91+
fun fromDomain(profile: UserProfile): CachedProfile = CachedProfile(
92+
displayName = profile.displayName,
93+
socialAccounts = profile.socialAccounts.map { CachedSocialAccount.fromDomain(it) },
94+
verifiedPhoneNumber = profile.verifiedPhoneNumber,
95+
verifiedEmailAddress = profile.verifiedEmailAddress,
96+
)
97+
}
98+
}
99+
100+
@Serializable
101+
private sealed interface CachedSocialAccount {
102+
@Serializable
103+
@SerialName("twitter_x")
104+
data class TwitterX(
105+
val id: String,
106+
val username: String,
107+
val name: String,
108+
val description: String,
109+
val profilePicUrl: String,
110+
val verifiedType: String? = null,
111+
val followerCount: Int,
112+
) : CachedSocialAccount
113+
114+
fun toDomain(): SocialAccount? = when (this) {
115+
is TwitterX -> SocialAccount.TwitterX(
116+
id = id,
117+
username = username,
118+
name = name,
119+
description = description,
120+
profilePicUrl = profilePicUrl,
121+
verifiedType = verifiedType?.let {
122+
runCatching { SocialAccount.TwitterX.VerifiedType.valueOf(it) }.getOrNull()
123+
},
124+
followerCount = followerCount,
125+
)
126+
}
127+
128+
companion object {
129+
fun fromDomain(account: SocialAccount): CachedSocialAccount = when (account) {
130+
is SocialAccount.TwitterX -> TwitterX(
131+
id = account.id,
132+
username = account.username,
133+
name = account.name,
134+
description = account.description,
135+
profilePicUrl = account.profilePicUrl,
136+
verifiedType = account.verifiedType?.name,
137+
followerCount = account.followerCount,
138+
)
139+
}
140+
}
141+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
plugins {
22
alias(libs.plugins.flipcash.android.library.compose)
3+
alias(libs.plugins.kotlin.serialization)
34
}
45

56
android {
@@ -11,6 +12,7 @@ dependencies {
1112
implementation(libs.androidx.datastore)
1213
implementation(libs.compose.foundation)
1314
implementation(libs.compose.ui.text)
15+
implementation(libs.kotlinx.serialization.json)
1416

1517
implementation(project(":libs:coroutines"))
1618
implementation(project(":libs:datetime"))

0 commit comments

Comments
 (0)