@@ -10,17 +10,138 @@ import com.fasterxml.jackson.module.kotlin.readValue
1010import 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