Skip to content

Commit d2608a4

Browse files
CopilotjonathannorrisJamieSinn
authored
feat: Update Cached Config Logic to Support User-specific Caching and 30-day TTL (#226)
* Initial plan for issue * Update cached config logic to support user-specific caching Co-authored-by: jonathannorris <[email protected]> * feat: update DVCSharedPrefs logic, add DVCSharedPrefsTests * fix: add missing @synchronized * feat: add MigrationCompletedKey * fix: improve legacy data cleanup to remove partial data Co-authored-by: JamieSinn <[email protected]> * feat: set expirary date on keys * feat: move configCacheTTL to constructor * feat: cleanup expired keys on init * fix: migration logic --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: jonathannorris <[email protected]> Co-authored-by: Jonathan Norris <[email protected]> Co-authored-by: JamieSinn <[email protected]>
1 parent 995611f commit d2608a4

File tree

3 files changed

+636
-39
lines changed

3 files changed

+636
-39
lines changed

android-client-sdk/src/main/java/com/devcycle/sdk/android/api/DevCycleClient.kt

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -51,23 +51,24 @@ class DevCycleClient private constructor(
5151
private var backgroundEventSource: BackgroundEventSource? = null
5252
private val defaultIntervalInMs: Long = 10000
5353
private val flushInMs: Long = options?.flushEventsIntervalMs ?: defaultIntervalInMs
54-
private val dvcSharedPrefs: DVCSharedPrefs = DVCSharedPrefs(context)
55-
private val request: Request = Request(sdkKey, apiUrl, eventsUrl, context)
56-
private val observable: BucketedUserConfigListener = BucketedUserConfigListener()
57-
private val enableEdgeDB: Boolean = options?.enableEdgeDB ?: false
58-
private val isInitialized = AtomicBoolean(false)
59-
private val isExecuting = AtomicBoolean(false)
60-
private val isConfigCached = AtomicBoolean(false)
61-
private val initializeJob: Deferred<Any>
62-
54+
6355
private val configRequestQueue = ConcurrentLinkedQueue<UserAndCallback>()
6456
private val configRequestMutex = Mutex()
65-
private val defaultCacheTTL = 7 * 24 * 3600000L // 7 days
57+
private val defaultCacheTTL = 30 * 24 * 3600000L // 30 days
6658
private val configCacheTTL = options?.configCacheTTL ?: defaultCacheTTL
6759
private val disableConfigCache = options?.disableConfigCache ?: false
6860
private val disableRealtimeUpdates = options?.disableRealtimeUpdates ?: false
6961
private val disableAutomaticEventLogging = options?.disableAutomaticEventLogging ?: false
7062
private val disableCustomEventLogging = options?.disableCustomEventLogging ?: false
63+
64+
private val dvcSharedPrefs: DVCSharedPrefs = DVCSharedPrefs(context, configCacheTTL)
65+
private val request: Request = Request(sdkKey, apiUrl, eventsUrl, context)
66+
private val observable: BucketedUserConfigListener = BucketedUserConfigListener()
67+
private val enableEdgeDB: Boolean = options?.enableEdgeDB ?: false
68+
private val isInitialized = AtomicBoolean(false)
69+
private val isExecuting = AtomicBoolean(false)
70+
private val isConfigCached = AtomicBoolean(false)
71+
private val initializeJob: Deferred<Any>
7172

7273
private val eventQueue: EventQueue = EventQueue(request, ::user, CoroutineScope(coroutineContext), flushInMs)
7374

@@ -77,7 +78,7 @@ class DevCycleClient private constructor(
7778
private val variableInstanceMap: MutableMap<String, MutableMap<Any, WeakReference<Variable<*>>>> = mutableMapOf()
7879

7980
init {
80-
val cachedConfig = if (disableConfigCache) null else dvcSharedPrefs.getConfig(user, configCacheTTL)
81+
val cachedConfig = if (disableConfigCache) null else dvcSharedPrefs.getConfig(user)
8182
if (cachedConfig != null) {
8283
config = cachedConfig
8384
isConfigCached.set(true)
@@ -631,7 +632,9 @@ class DevCycleClient private constructor(
631632
DevCycleLogger.start(logger)
632633
}
633634

634-
dvcSharedPrefs = DVCSharedPrefs(context!!);
635+
val defaultCacheTTL = 30 * 24 * 3600000L // 30 days
636+
val configCacheTTL = options?.configCacheTTL ?: defaultCacheTTL
637+
dvcSharedPrefs = DVCSharedPrefs(context!!, configCacheTTL);
635638

636639
val anonId: String? = dvcSharedPrefs!!.getString(DVCSharedPrefs.AnonUserIdKey)
637640

android-client-sdk/src/main/java/com/devcycle/sdk/android/util/DVCSharedPrefs.kt

Lines changed: 150 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,138 @@ import com.fasterxml.jackson.module.kotlin.readValue
1010
import java.util.*
1111

1212
// TODO: access disk on background thread
13-
internal class DVCSharedPrefs(context: Context) {
13+
internal class DVCSharedPrefs(context: Context, private val configCacheTTL: Long) {
1414
private var preferences: SharedPreferences = context.getSharedPreferences(
1515
context.getString(R.string.cached_data),
1616
Context.MODE_PRIVATE
1717
)
1818

19+
init {
20+
migrateLegacyConfigs()
21+
cleanupExpiredConfigs()
22+
}
23+
1924
companion object {
2025
const val UserKey = "USER"
2126
const val AnonUserIdKey = "ANONYMOUS_USER_ID"
2227
const val IdentifiedConfigKey = "IDENTIFIED_CONFIG"
2328
const val AnonymousConfigKey = "ANONYMOUS_CONFIG"
29+
const val ExpiryDateSuffix = "EXPIRY_DATE"
30+
const val MigrationCompletedKey = "MIGRATION_COMPLETED"
31+
}
32+
33+
private fun generateUserConfigKey(userId: String, isAnonymous: Boolean): String {
34+
val prefix = if (isAnonymous) AnonymousConfigKey else IdentifiedConfigKey
35+
return "$prefix.$userId"
36+
}
37+
38+
private fun generateUserExpiryDateKey(userId: String, isAnonymous: Boolean): String {
39+
return "${generateUserConfigKey(userId, isAnonymous)}.$ExpiryDateSuffix"
40+
}
41+
42+
@Synchronized
43+
private fun migrateLegacyConfigs() {
44+
// Check if migration has already been completed
45+
if (preferences.getBoolean(MigrationCompletedKey, false)) {
46+
return
47+
}
48+
49+
try {
50+
val legacyKeys = listOf(IdentifiedConfigKey, AnonymousConfigKey)
51+
val editor = preferences.edit()
52+
var migrationOccurred = false
53+
54+
for (legacyKey in legacyKeys) {
55+
val legacyUserIdKey = "$legacyKey.USER_ID"
56+
val legacyFetchDateKey = "$legacyKey.FETCH_DATE"
57+
58+
val userId = preferences.getString(legacyUserIdKey, null)
59+
val fetchDateMs = preferences.getLong(legacyFetchDateKey, 0)
60+
val configString = preferences.getString(legacyKey, null)
61+
62+
// Attempt migration if we have complete data
63+
if (userId != null && configString != null && fetchDateMs > 0) {
64+
val isAnonymous = legacyKey == AnonymousConfigKey
65+
val userKey = generateUserConfigKey(userId, isAnonymous)
66+
val userExpiryDateKey = generateUserExpiryDateKey(userId, isAnonymous)
67+
68+
// Only migrate if new format doesn't already exist
69+
if (!preferences.contains(userKey)) {
70+
editor.putString(userKey, configString)
71+
editor.putLong(userExpiryDateKey, Calendar.getInstance().timeInMillis + configCacheTTL)
72+
DevCycleLogger.d("Migrated legacy config for user ID $userId from key $legacyKey")
73+
}
74+
}
75+
76+
// Always clean up legacy keys if they exist, regardless of migration success
77+
var keysRemoved = false
78+
if (preferences.contains(legacyKey)) {
79+
editor.remove(legacyKey)
80+
keysRemoved = true
81+
}
82+
if (preferences.contains(legacyUserIdKey)) {
83+
editor.remove(legacyUserIdKey)
84+
keysRemoved = true
85+
}
86+
if (preferences.contains(legacyFetchDateKey)) {
87+
editor.remove(legacyFetchDateKey)
88+
keysRemoved = true
89+
}
90+
91+
if (keysRemoved) {
92+
migrationOccurred = true
93+
}
94+
}
95+
96+
// Mark migration as completed, regardless of whether data was migrated
97+
editor.putBoolean(MigrationCompletedKey, true)
98+
99+
if (migrationOccurred) {
100+
editor.apply()
101+
DevCycleLogger.d("Legacy config migration completed")
102+
} else {
103+
editor.apply()
104+
DevCycleLogger.d("Migration check completed - no legacy data found")
105+
}
106+
} catch (e: Exception) {
107+
DevCycleLogger.e(e, "Error during legacy config migration: ${e.message}")
108+
}
109+
}
110+
111+
@Synchronized
112+
private fun cleanupExpiredConfigs() {
113+
try {
114+
val allPrefs = preferences.all
115+
val currentTimeMs = Calendar.getInstance().timeInMillis
116+
val editor = preferences.edit()
117+
var cleanupOccurred = false
118+
119+
// Find all config keys (both identified and anonymous)
120+
val configKeys = allPrefs.keys.filter { key ->
121+
(key.startsWith("$IdentifiedConfigKey.") || key.startsWith("$AnonymousConfigKey.")) &&
122+
!key.endsWith(".$ExpiryDateSuffix")
123+
}
124+
125+
for (configKey in configKeys) {
126+
val expiryDateKey = "$configKey.$ExpiryDateSuffix"
127+
val expiryDateMs = preferences.getLong(expiryDateKey, 0)
128+
129+
// If expiry date exists and is in the past, remove both config and expiry date
130+
if (expiryDateMs > 0 && expiryDateMs <= currentTimeMs) {
131+
editor.remove(configKey)
132+
editor.remove(expiryDateKey)
133+
cleanupOccurred = true
134+
DevCycleLogger.d("Cleaned up expired config: $configKey")
135+
}
136+
}
137+
138+
if (cleanupOccurred) {
139+
editor.apply()
140+
DevCycleLogger.d("Expired config cleanup completed")
141+
}
142+
} catch (e: Exception) {
143+
DevCycleLogger.e(e, "Error during expired config cleanup: ${e.message}")
144+
}
24145
}
25146

26147
@Synchronized
@@ -65,43 +186,45 @@ internal class DVCSharedPrefs(context: Context) {
65186
@Synchronized
66187
fun saveConfig(configToSave: BucketedUserConfig, user: PopulatedUser) {
67188
try {
68-
val key = if (user.isAnonymous) AnonymousConfigKey else IdentifiedConfigKey
189+
val userKey = generateUserConfigKey(user.userId, user.isAnonymous)
190+
val userExpiryDateKey = generateUserExpiryDateKey(user.userId, user.isAnonymous)
191+
69192
val editor = preferences.edit()
70193
val jsonString = JSONMapper.mapper.writeValueAsString(configToSave)
71-
editor.putString(key, jsonString)
72-
editor.putString("$key.USER_ID", user.userId)
73-
editor.putLong("$key.FETCH_DATE", Calendar.getInstance().timeInMillis)
194+
editor.putString(userKey, jsonString)
195+
editor.putLong(userExpiryDateKey, Calendar.getInstance().timeInMillis + configCacheTTL)
74196
editor.apply()
75197
} catch (e: JsonProcessingException) {
76198
DevCycleLogger.e(e, e.message)
77199
}
78200
}
79201

80202
@Synchronized
81-
fun getConfig(user: PopulatedUser, ttlMs: Long): BucketedUserConfig? {
203+
fun getConfig(user: PopulatedUser): BucketedUserConfig? {
82204
try {
83-
val key = if (user.isAnonymous) AnonymousConfigKey else IdentifiedConfigKey
84-
val userId = preferences.getString("$key.USER_ID", null)
85-
val fetchDateMs = preferences.getLong("$key.FETCH_DATE", 0)
86-
87-
if (userId != user.userId) {
88-
DevCycleLogger.d("Skipping cached config: no config for user ID ${user.userId}")
89-
return null
90-
}
91-
92-
val oldestValidDateMs = Calendar.getInstance().timeInMillis - ttlMs
93-
if (fetchDateMs < oldestValidDateMs) {
94-
DevCycleLogger.d("Skipping cached config: last fetched date is too old")
95-
return null
205+
val userKey = generateUserConfigKey(user.userId, user.isAnonymous)
206+
val userConfigString = preferences.getString(userKey, null)
207+
val userExpiryDateKey = generateUserExpiryDateKey(user.userId, user.isAnonymous)
208+
val userExpiryDateMs = preferences.getLong(userExpiryDateKey, 0)
209+
210+
val currentTimeMs = Calendar.getInstance().timeInMillis
211+
212+
if (userConfigString != null) {
213+
if (userExpiryDateMs > currentTimeMs) {
214+
DevCycleLogger.d("Loaded config from cache for user ID ${user.userId}")
215+
return JSONMapper.mapper.readValue(userConfigString)
216+
} else {
217+
// Config exists but is expired, remove it
218+
val editor = preferences.edit()
219+
editor.remove(userKey)
220+
editor.remove(userExpiryDateKey)
221+
editor.apply()
222+
DevCycleLogger.d("Removed expired config for user ID ${user.userId}")
223+
}
96224
}
97-
98-
val configString = preferences.getString(key, null)
99-
if (configString == null) {
100-
DevCycleLogger.d("Skipping cached config: no config found")
101-
return null
102-
}
103-
104-
return JSONMapper.mapper.readValue(configString)
225+
226+
DevCycleLogger.d("No valid config found for user ID ${user.userId}")
227+
return null
105228
} catch (e: JsonProcessingException) {
106229
DevCycleLogger.e(e, e.message)
107230
return null

0 commit comments

Comments
 (0)