| 
7 | 7 | import Foundation  | 
8 | 8 | 
 
  | 
9 | 9 | protocol CacheServiceProtocol {  | 
10 |  | -    func load() -> Cache  | 
11 |  | -    func save(user: DevCycleUser)  | 
 | 10 | +    func load(user: DevCycleUser) -> Cache  | 
12 | 11 |     func setAnonUserId(anonUserId: String)  | 
13 | 12 |     func getAnonUserId() -> String?  | 
14 | 13 |     func clearAnonUserId()  | 
15 |  | -    func saveConfig(user: DevCycleUser, fetchDate: Int, configToSave: Data?)  | 
16 |  | -    func getConfig(user: DevCycleUser, ttlMs: Int) -> UserConfig?  | 
 | 14 | +    func saveConfig(user: DevCycleUser, configToSave: Data?)  | 
 | 15 | +    func getConfig(user: DevCycleUser) -> UserConfig?  | 
17 | 16 |     func getOrCreateAnonUserId() -> String  | 
 | 17 | +    func migrateLegacyCache()  | 
18 | 18 | }  | 
19 | 19 | 
 
  | 
20 | 20 | struct Cache {  | 
21 | 21 |     var config: UserConfig?  | 
22 |  | -    var user: DevCycleUser?  | 
23 | 22 |     var anonUserId: String?  | 
24 | 23 | }  | 
25 | 24 | 
 
  | 
26 | 25 | class CacheService: CacheServiceProtocol {  | 
27 | 26 |     struct CacheKeys {  | 
28 |  | -        static let user = "user"  | 
29 |  | -        static let config = "config"  | 
30 | 27 |         static let anonUserId = "ANONYMOUS_USER_ID"  | 
31 | 28 |         static let identifiedConfigKey = "IDENTIFIED_CONFIG"  | 
32 | 29 |         static let anonymousConfigKey = "ANONYMOUS_CONFIG"  | 
 | 30 | +        static let userIdSuffix = ".USER_ID"  | 
 | 31 | +        static let expiryDateSuffix = ".EXPIRY_DATE"  | 
 | 32 | + | 
 | 33 | +        // Legacy keys for cleanup  | 
 | 34 | +        static let legacyUser = "user"  | 
 | 35 | +        static let legacyConfig = "config"  | 
 | 36 | +        static let legacyFetchDateSuffix = ".FETCH_DATE"  | 
33 | 37 |     }  | 
34 | 38 | 
 
  | 
35 | 39 |     private let defaults: UserDefaults = UserDefaults.standard  | 
 | 40 | +    private let configCacheTTL: Int  | 
36 | 41 | 
 
  | 
37 |  | -    func load() -> Cache {  | 
38 |  | -        var userConfig: UserConfig?  | 
39 |  | -        var dvcUser: DevCycleUser?  | 
40 |  | -        if let data = defaults.object(forKey: CacheKeys.config) as? Data,  | 
41 |  | -            let dictionary = try? JSONSerialization.jsonObject(  | 
42 |  | -                with: data, options: .fragmentsAllowed) as? [String: Any],  | 
43 |  | -            let config = try? UserConfig(from: dictionary)  | 
44 |  | -        {  | 
45 |  | -            userConfig = config  | 
46 |  | -        }  | 
47 |  | -        if let data = defaults.object(forKey: CacheKeys.user) as? Data {  | 
48 |  | -            dvcUser = try? JSONDecoder().decode(DevCycleUser.self, from: data)  | 
49 |  | -        }  | 
50 |  | -        let anonUserId = self.getAnonUserId()  | 
51 |  | - | 
52 |  | -        return Cache(config: userConfig, user: dvcUser, anonUserId: anonUserId)  | 
 | 42 | +    init(configCacheTTL: Int = DEFAULT_CONFIG_CACHE_TTL) {  | 
 | 43 | +        self.configCacheTTL = configCacheTTL  | 
53 | 44 |     }  | 
54 | 45 | 
 
  | 
55 |  | -    func save(user: DevCycleUser) {  | 
56 |  | -        if let data = try? JSONEncoder().encode(user) {  | 
57 |  | -            defaults.set(data, forKey: CacheKeys.user)  | 
58 |  | -        }  | 
 | 46 | +    func load(user: DevCycleUser) -> Cache {  | 
 | 47 | +        migrateLegacyCache()  | 
 | 48 | + | 
 | 49 | +        return Cache(config: getConfig(user: user), anonUserId: getAnonUserId())  | 
59 | 50 |     }  | 
60 | 51 | 
 
  | 
61 | 52 |     func setAnonUserId(anonUserId: String) {  | 
62 |  | -        self.setString(key: CacheKeys.anonUserId, value: anonUserId)  | 
 | 53 | +        defaults.set(anonUserId, forKey: CacheKeys.anonUserId)  | 
63 | 54 |     }  | 
64 | 55 | 
 
  | 
65 | 56 |     func getAnonUserId() -> String? {  | 
66 |  | -        return self.getString(key: CacheKeys.anonUserId)  | 
 | 57 | +        return defaults.string(forKey: CacheKeys.anonUserId)  | 
67 | 58 |     }  | 
68 | 59 | 
 
  | 
69 | 60 |     func clearAnonUserId() {  | 
70 |  | -        self.remove(key: CacheKeys.anonUserId)  | 
 | 61 | +        defaults.removeObject(forKey: CacheKeys.anonUserId)  | 
71 | 62 |     }  | 
72 | 63 | 
 
  | 
73 |  | -    func saveConfig(user: DevCycleUser, fetchDate: Int, configToSave: Data?) {  | 
 | 64 | +    func saveConfig(user: DevCycleUser, configToSave: Data?) {  | 
74 | 65 |         let key = getConfigKeyPrefix(user: user)  | 
75 | 66 |         defaults.set(configToSave, forKey: key)  | 
76 |  | -        if let data = user.userId {  | 
77 |  | -            self.setString(key: "\(key).USER_ID", value: data)  | 
78 |  | -        }  | 
79 |  | -        self.setInt(key: "\(key).FETCH_DATE", value: fetchDate)  | 
 | 67 | + | 
 | 68 | +        let expiryDate = currentTimeMs() + configCacheTTL  | 
 | 69 | +        defaults.set(expiryDate, forKey: "\(key)\(CacheKeys.expiryDateSuffix)")  | 
80 | 70 |     }  | 
81 | 71 | 
 
  | 
82 |  | -    func getConfig(user: DevCycleUser, ttlMs: Int) -> UserConfig? {  | 
 | 72 | +    func getConfig(user: DevCycleUser) -> UserConfig? {  | 
83 | 73 |         let key = getConfigKeyPrefix(user: user)  | 
84 |  | -        var config: UserConfig?  | 
85 | 74 | 
 
  | 
86 |  | -        let savedUserId = self.getString(key: "\(key).USER_ID")  | 
87 |  | -        let savedFetchDate = self.getInt(key: "\(key).FETCH_DATE")  | 
88 |  | - | 
89 |  | -        if let userId = user.userId, userId != savedUserId {  | 
90 |  | -            Log.debug("Skipping cached config: user ID does not match")  | 
91 |  | -            return nil  | 
92 |  | -        }  | 
93 |  | - | 
94 |  | -        let oldestValidDateMs = Int(Date().timeIntervalSince1970) - ttlMs  | 
95 |  | -        if let savedFetchDate = savedFetchDate, savedFetchDate < oldestValidDateMs {  | 
96 |  | -            Log.debug("Skipping cached config: last fetched date is too old")  | 
 | 75 | +        // Check if cache has expired  | 
 | 76 | +        if let savedExpiryDate = getIntValue(forKey: "\(key)\(CacheKeys.expiryDateSuffix)"),  | 
 | 77 | +            currentTimeMs() > savedExpiryDate  | 
 | 78 | +        {  | 
 | 79 | +            Log.debug("Skipping cached config: config has expired")  | 
 | 80 | +            cleanupCacheEntry(key: key)  | 
97 | 81 |             return nil  | 
98 | 82 |         }  | 
99 | 83 | 
 
  | 
100 |  | -        if let data = defaults.object(forKey: key) as? Data,  | 
 | 84 | +        // Try to load and parse cached config  | 
 | 85 | +        guard let data = defaults.object(forKey: key) as? Data,  | 
101 | 86 |             let dictionary = try? JSONSerialization.jsonObject(  | 
102 |  | -                with: data, options: .fragmentsAllowed) as? [String: Any]  | 
103 |  | -        {  | 
104 |  | -            config = try? UserConfig(from: dictionary)  | 
105 |  | -        } else {  | 
 | 87 | +                with: data, options: .fragmentsAllowed) as? [String: Any],  | 
 | 88 | +            let config = try? UserConfig(from: dictionary)  | 
 | 89 | +        else {  | 
106 | 90 |             Log.debug("Skipping cached config: no config found")  | 
 | 91 | +            return nil  | 
107 | 92 |         }  | 
108 | 93 | 
 
  | 
109 | 94 |         return config  | 
110 | 95 |     }  | 
111 | 96 | 
 
  | 
112 | 97 |     func getOrCreateAnonUserId() -> String {  | 
113 |  | -        if let anonId = getAnonUserId() {  | 
114 |  | -            return anonId  | 
 | 98 | +        if let existingId = getAnonUserId() {  | 
 | 99 | +            return existingId  | 
115 | 100 |         }  | 
116 |  | -        let newAnonId = UUID().uuidString  | 
117 |  | -        setAnonUserId(anonUserId: newAnonId)  | 
118 |  | -        return newAnonId  | 
119 |  | -    }  | 
120 | 101 | 
 
  | 
121 |  | -    private func setString(key: String, value: String) {  | 
122 |  | -        defaults.set(value, forKey: key)  | 
 | 102 | +        let newId = UUID().uuidString  | 
 | 103 | +        setAnonUserId(anonUserId: newId)  | 
 | 104 | +        return newId  | 
123 | 105 |     }  | 
124 | 106 | 
 
  | 
125 |  | -    private func getString(key: String) -> String? {  | 
126 |  | -        return defaults.string(forKey: key)  | 
 | 107 | +    // MARK: - Private Helper Methods  | 
 | 108 | + | 
 | 109 | +    private func currentTimeMs() -> Int {  | 
 | 110 | +        return Int(Date().timeIntervalSince1970 * 1000)  | 
127 | 111 |     }  | 
128 | 112 | 
 
  | 
129 |  | -    private func setInt(key: String, value: Int) {  | 
130 |  | -        defaults.set(value, forKey: key)  | 
 | 113 | +    private func getIntValue(forKey key: String) -> Int? {  | 
 | 114 | +        return defaults.object(forKey: key) != nil ? defaults.integer(forKey: key) : nil  | 
131 | 115 |     }  | 
132 | 116 | 
 
  | 
133 |  | -    private func getInt(key: String) -> Int? {  | 
134 |  | -        return defaults.integer(forKey: key)  | 
 | 117 | +    private func cleanupCacheEntry(key: String) {  | 
 | 118 | +        defaults.removeObject(forKey: key)  | 
 | 119 | +        defaults.removeObject(forKey: "\(key)\(CacheKeys.expiryDateSuffix)")  | 
135 | 120 |     }  | 
136 | 121 | 
 
  | 
137 |  | -    private func remove(key: String) {  | 
 | 122 | +    private func cleanupLegacyCacheEntry(key: String) {  | 
138 | 123 |         defaults.removeObject(forKey: key)  | 
 | 124 | +        defaults.removeObject(forKey: "\(key)\(CacheKeys.userIdSuffix)")  | 
 | 125 | +        defaults.removeObject(forKey: "\(key)\(CacheKeys.legacyFetchDateSuffix)")  | 
139 | 126 |     }  | 
140 | 127 | 
 
  | 
141 | 128 |     private func getConfigKeyPrefix(user: DevCycleUser) -> String {  | 
142 |  | -        return (user.isAnonymous ?? false)  | 
 | 129 | +        let baseKey =  | 
 | 130 | +            (user.isAnonymous ?? false)  | 
143 | 131 |             ? CacheKeys.anonymousConfigKey : CacheKeys.identifiedConfigKey  | 
 | 132 | + | 
 | 133 | +        if let userId = user.userId {  | 
 | 134 | +            return "\(baseKey)_\(userId)"  | 
 | 135 | +        }  | 
 | 136 | + | 
 | 137 | +        return baseKey  | 
 | 138 | +    }  | 
 | 139 | + | 
 | 140 | +    // MARK: - Legacy Cache Migration  | 
 | 141 | + | 
 | 142 | +    func migrateLegacyCache() {  | 
 | 143 | +        // Migrate config cache  | 
 | 144 | +        migrateConfigIfNeeded(oldKey: CacheKeys.identifiedConfigKey, isIdentified: true)  | 
 | 145 | +        migrateConfigIfNeeded(oldKey: CacheKeys.anonymousConfigKey, isIdentified: false)  | 
 | 146 | + | 
 | 147 | +        // Clean up legacy user cache  | 
 | 148 | +        cleanupLegacyUserCache()  | 
 | 149 | + | 
 | 150 | +        // Clean up legacy config cache  | 
 | 151 | +        cleanupLegacyConfigCache()  | 
 | 152 | +    }  | 
 | 153 | + | 
 | 154 | +    private func cleanupLegacyUserCache() {  | 
 | 155 | +        if defaults.object(forKey: CacheKeys.legacyUser) != nil {  | 
 | 156 | +            defaults.removeObject(forKey: CacheKeys.legacyUser)  | 
 | 157 | +            Log.debug("Cleaned up legacy user cache")  | 
 | 158 | +        }  | 
 | 159 | +    }  | 
 | 160 | + | 
 | 161 | +    private func cleanupLegacyConfigCache() {  | 
 | 162 | +        if defaults.object(forKey: CacheKeys.legacyConfig) != nil {  | 
 | 163 | +            defaults.removeObject(forKey: CacheKeys.legacyConfig)  | 
 | 164 | +            Log.debug("Cleaned up legacy config cache")  | 
 | 165 | +        }  | 
 | 166 | +    }  | 
 | 167 | + | 
 | 168 | +    private func migrateConfigIfNeeded(oldKey: String, isIdentified: Bool) {  | 
 | 169 | +        guard let oldConfigData = defaults.object(forKey: oldKey) as? Data,  | 
 | 170 | +            let oldUserId = defaults.string(forKey: "\(oldKey)\(CacheKeys.userIdSuffix)")  | 
 | 171 | +        else {  | 
 | 172 | +            return  | 
 | 173 | +        }  | 
 | 174 | + | 
 | 175 | +        let newKey =  | 
 | 176 | +            isIdentified  | 
 | 177 | +            ? "\(CacheKeys.identifiedConfigKey)_\(oldUserId)"  | 
 | 178 | +            : "\(CacheKeys.anonymousConfigKey)_\(oldUserId)"  | 
 | 179 | + | 
 | 180 | +        // If new cache already exists, just cleanup legacy cache  | 
 | 181 | +        if defaults.object(forKey: newKey) != nil {  | 
 | 182 | +            Log.debug("New cache key \(newKey) already exists, cleaning up legacy cache \(oldKey)")  | 
 | 183 | +            cleanupLegacyCacheEntry(key: oldKey)  | 
 | 184 | +            return  | 
 | 185 | +        }  | 
 | 186 | + | 
 | 187 | +        // Migrate data to new format  | 
 | 188 | +        defaults.set(oldConfigData, forKey: newKey)  | 
 | 189 | + | 
 | 190 | +        let expiryDate = currentTimeMs() + configCacheTTL  | 
 | 191 | +        defaults.set(expiryDate, forKey: "\(newKey)\(CacheKeys.expiryDateSuffix)")  | 
 | 192 | + | 
 | 193 | +        // Cleanup old cache  | 
 | 194 | +        cleanupLegacyCacheEntry(key: oldKey)  | 
 | 195 | + | 
 | 196 | +        Log.debug("Migrated config + user cache from \(oldKey) to \(newKey)")  | 
144 | 197 |     }  | 
145 | 198 | }  | 
0 commit comments