Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ class DevCycleClient private constructor(

private val configRequestQueue = ConcurrentLinkedQueue<UserAndCallback>()
private val configRequestMutex = Mutex()
private val defaultCacheTTL = 7 * 24 * 3600000L // 7 days
private val defaultCacheTTL = 30 * 24 * 3600000L // 30 days
private val configCacheTTL = options?.configCacheTTL ?: defaultCacheTTL
private val disableConfigCache = options?.disableConfigCache ?: false
private val disableRealtimeUpdates = options?.disableRealtimeUpdates ?: false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,82 @@ internal class DVCSharedPrefs(context: Context) {
Context.MODE_PRIVATE
)

init {
migrateLegacyConfigs()
}

companion object {
const val UserKey = "USER"
const val AnonUserIdKey = "ANONYMOUS_USER_ID"
const val IdentifiedConfigKey = "IDENTIFIED_CONFIG"
const val AnonymousConfigKey = "ANONYMOUS_CONFIG"
const val FetchDateSuffix = "FETCH_DATE"
const val MigrationCompletedKey = "MIGRATION_COMPLETED"
}

private fun generateUserConfigKey(userId: String, isAnonymous: Boolean): String {
val prefix = if (isAnonymous) AnonymousConfigKey else IdentifiedConfigKey
return "$prefix.$userId"
}

private fun generateUserFetchDateKey(userId: String, isAnonymous: Boolean): String {
return "${generateUserConfigKey(userId, isAnonymous)}.$FetchDateSuffix"
}

@Synchronized
private fun migrateLegacyConfigs() {
// Check if migration has already been completed
if (preferences.getBoolean(MigrationCompletedKey, false)) {
return
}

try {
val legacyKeys = listOf(IdentifiedConfigKey, AnonymousConfigKey)
val editor = preferences.edit()
var migrationOccurred = false

for (legacyKey in legacyKeys) {
val legacyUserIdKey = "$legacyKey.USER_ID"
val legacyFetchDateKey = "$legacyKey.FETCH_DATE"

val userId = preferences.getString(legacyUserIdKey, null)
val fetchDateMs = preferences.getLong(legacyFetchDateKey, 0)
val configString = preferences.getString(legacyKey, null)

if (userId != null && configString != null && fetchDateMs > 0) {
val isAnonymous = legacyKey == AnonymousConfigKey
val userKey = generateUserConfigKey(userId, isAnonymous)
val userFetchDateKey = generateUserFetchDateKey(userId, isAnonymous)

// Only migrate if new format doesn't already exist
if (!preferences.contains(userKey)) {
editor.putString(userKey, configString)
editor.putLong(userFetchDateKey, fetchDateMs)
migrationOccurred = true
DevCycleLogger.d("Migrated legacy config for user ID $userId from key $legacyKey")
}

// Remove legacy data
editor.remove(legacyKey)
editor.remove(legacyUserIdKey)
editor.remove(legacyFetchDateKey)
migrationOccurred = true
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't you remove regardless if a subset of the data exists?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point! I've updated the migration logic to clean up legacy keys regardless of whether we can migrate them completely. Now we check for each legacy key individually and remove it if it exists, preventing orphaned keys from staying in SharedPreferences. Added test coverage for this scenario as well. See commit e0a2b6c.

}
}

// Mark migration as completed, regardless of whether data was migrated
editor.putBoolean(MigrationCompletedKey, true)

if (migrationOccurred) {
editor.apply()
DevCycleLogger.d("Legacy config migration completed")
} else {
editor.apply()
DevCycleLogger.d("Migration check completed - no legacy data found")
}
} catch (e: Exception) {
DevCycleLogger.e(e, "Error during legacy config migration: ${e.message}")
}
}

@Synchronized
Expand Down Expand Up @@ -65,12 +136,13 @@ internal class DVCSharedPrefs(context: Context) {
@Synchronized
fun saveConfig(configToSave: BucketedUserConfig, user: PopulatedUser) {
try {
val key = if (user.isAnonymous) AnonymousConfigKey else IdentifiedConfigKey
val userKey = generateUserConfigKey(user.userId, user.isAnonymous)
val userFetchDateKey = generateUserFetchDateKey(user.userId, user.isAnonymous)

val editor = preferences.edit()
val jsonString = JSONMapper.mapper.writeValueAsString(configToSave)
editor.putString(key, jsonString)
editor.putString("$key.USER_ID", user.userId)
editor.putLong("$key.FETCH_DATE", Calendar.getInstance().timeInMillis)
editor.putString(userKey, jsonString)
editor.putLong(userFetchDateKey, Calendar.getInstance().timeInMillis)
editor.apply()
} catch (e: JsonProcessingException) {
DevCycleLogger.e(e, e.message)
Expand All @@ -80,28 +152,29 @@ internal class DVCSharedPrefs(context: Context) {
@Synchronized
fun getConfig(user: PopulatedUser, ttlMs: Long): BucketedUserConfig? {
try {
val key = if (user.isAnonymous) AnonymousConfigKey else IdentifiedConfigKey
val userId = preferences.getString("$key.USER_ID", null)
val fetchDateMs = preferences.getLong("$key.FETCH_DATE", 0)

if (userId != user.userId) {
DevCycleLogger.d("Skipping cached config: no config for user ID ${user.userId}")
return null
}

val userKey = generateUserConfigKey(user.userId, user.isAnonymous)
val userConfigString = preferences.getString(userKey, null)
val userFetchDateKey = generateUserFetchDateKey(user.userId, user.isAnonymous)
val userFetchDateMs = preferences.getLong(userFetchDateKey, 0)

val oldestValidDateMs = Calendar.getInstance().timeInMillis - ttlMs
if (fetchDateMs < oldestValidDateMs) {
DevCycleLogger.d("Skipping cached config: last fetched date is too old")
return null
}

val configString = preferences.getString(key, null)
if (configString == null) {
DevCycleLogger.d("Skipping cached config: no config found")
return null

if (userConfigString != null) {
if (userFetchDateMs >= oldestValidDateMs) {
DevCycleLogger.d("Loaded config from cache for user ID ${user.userId}")
return JSONMapper.mapper.readValue(userConfigString)
} else {
// Config exists but is expired, remove it
val editor = preferences.edit()
editor.remove(userKey)
editor.remove(userFetchDateKey)
editor.apply()
DevCycleLogger.d("Removed expired config for user ID ${user.userId}")
}
}

return JSONMapper.mapper.readValue(configString)

DevCycleLogger.d("No valid config found for user ID ${user.userId}")
return null
} catch (e: JsonProcessingException) {
DevCycleLogger.e(e, e.message)
return null
Expand Down
Loading
Loading