Skip to content

Commit 17cc972

Browse files
Settings cache reader (#10442)
* loadCache * SettingsFileManager * code compiles and test successful * ApplicationInfo.synthesizedVersion * SettingsProtocol * properties * broken test * fix test and add lots of tests * conflict * style * remove unnecessary explicit type * synchronize settings for thread-safety * fix macOS and Catalyst url.appending compatibility * fix compatibility attempt 2 * exclude macOS and Catalyst * attempt 4 * PR review * migrate to NSUserDefaults * cleanup * review * add comments * constants
1 parent d5e3888 commit 17cc972

File tree

6 files changed

+388
-2
lines changed

6 files changed

+388
-2
lines changed

FirebaseSessions/Sources/ApplicationInfo.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ protocol ApplicationInfoProtocol {
4646

4747
/// Development environment on which the application is running.
4848
var environment: DevEnvironment { get }
49+
50+
var appBuildVersion: String { get }
51+
52+
var appDisplayVersion: String { get }
4953
}
5054

5155
class ApplicationInfo: ApplicationInfoProtocol {
@@ -92,4 +96,12 @@ class ApplicationInfo: ApplicationInfoProtocol {
9296
}
9397
return DevEnvironment.prod
9498
}
99+
100+
var appBuildVersion: String {
101+
return Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
102+
}
103+
104+
var appDisplayVersion: String {
105+
return Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? ""
106+
}
95107
}

FirebaseSessions/Sources/FirebaseSessions.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ protocol SessionsProvider {
4040
private let initiator: SessionInitiator
4141
private let identifiers: Identifiers
4242
private let appInfo: ApplicationInfo
43+
private let settings: SettingsProtocol
4344

4445
// MARK: - Initializers
4546

@@ -57,23 +58,26 @@ protocol SessionsProvider {
5758
let coordinator = SessionCoordinator(identifiers: identifiers, fireLogger: fireLogger)
5859
let initiator = SessionInitiator()
5960
let appInfo = ApplicationInfo(appID: appID)
61+
let settings = Settings(appInfo: appInfo)
6062

6163
self.init(appID: appID,
6264
identifiers: identifiers,
6365
coordinator: coordinator,
6466
initiator: initiator,
65-
appInfo: appInfo)
67+
appInfo: appInfo,
68+
settings: settings)
6669
}
6770

6871
// Initializes the SDK and begines the process of listening for lifecycle events and logging events
6972
init(appID: String, identifiers: Identifiers, coordinator: SessionCoordinator,
70-
initiator: SessionInitiator, appInfo: ApplicationInfo) {
73+
initiator: SessionInitiator, appInfo: ApplicationInfo, settings: SettingsProtocol) {
7174
self.appID = appID
7275

7376
self.identifiers = identifiers
7477
self.coordinator = coordinator
7578
self.initiator = initiator
7679
self.appInfo = appInfo
80+
self.settings = settings
7781

7882
super.init()
7983

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
//
2+
// Copyright 2022 Google LLC
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
import Foundation
17+
18+
/// Extends ApplicationInfoProtocol to string-format a combined appDisplayVersion and appBuildVersion
19+
extension ApplicationInfoProtocol {
20+
var synthesizedVersion: String { return "\(appDisplayVersion) (\(appBuildVersion))" }
21+
}
22+
23+
/// Provides the APIs to access Settings and their configuration values
24+
protocol SettingsProtocol {
25+
func isCacheExpired(currentTime: Date) -> Bool
26+
var sessionsEnabled: Bool { get }
27+
var samplingRate: Double { get }
28+
var sessionTimeout: TimeInterval { get }
29+
}
30+
31+
class Settings: SettingsProtocol {
32+
private static let cacheDurationSecondsDefault: TimeInterval = 60 * 60
33+
private static let flagSessionsEnabled = "sessions_enabled"
34+
private static let flagSamplingRate = "sampling_rate"
35+
private static let flagSessionTimeout = "session_timeout"
36+
private static let flagCacheDuration = "cache_duration"
37+
private let cache: SettingsCacheClient
38+
private let appInfo: ApplicationInfoProtocol
39+
40+
var sessionsEnabled: Bool {
41+
guard let enabled = cache.cacheContent?[Settings.flagSessionsEnabled] as? Bool else {
42+
return true
43+
}
44+
return enabled
45+
}
46+
47+
var samplingRate: Double {
48+
guard let rate = cache.cacheContent?[Settings.flagSamplingRate] as? Double else {
49+
return 1.0
50+
}
51+
return rate
52+
}
53+
54+
var sessionTimeout: TimeInterval {
55+
guard let timeout = cache.cacheContent?[Settings.flagSessionTimeout] as? Double else {
56+
return 30 * 60
57+
}
58+
return timeout
59+
}
60+
61+
private var cacheDurationSeconds: TimeInterval {
62+
guard let duration = cache.cacheContent?[Settings.flagCacheDuration] as? Double else {
63+
return Settings.cacheDurationSecondsDefault
64+
}
65+
return duration
66+
}
67+
68+
init(cache: SettingsCacheClient = SettingsCache(),
69+
appInfo: ApplicationInfoProtocol) {
70+
self.cache = cache
71+
self.appInfo = appInfo
72+
}
73+
74+
func isCacheExpired(currentTime: Date) -> Bool {
75+
guard cache.cacheContent != nil else {
76+
cache.removeCache()
77+
return true
78+
}
79+
guard let cacheKey = cache.cacheKey else {
80+
Logger.logError("[Settings] Could not load settings cache key")
81+
cache.removeCache()
82+
return true
83+
}
84+
guard cacheKey.googleAppID == appInfo.appID else {
85+
Logger
86+
.logDebug("[Settings] Cache expired because Google App ID changed")
87+
cache.removeCache()
88+
return true
89+
}
90+
if currentTime.timeIntervalSince(cacheKey.createdAt) > cacheDurationSeconds {
91+
Logger.logDebug("[Settings] Cache TTL expired")
92+
return true
93+
}
94+
if appInfo.synthesizedVersion != cacheKey.appVersion {
95+
Logger.logDebug("[Settings] Cache expired because app version changed")
96+
return true
97+
}
98+
return false
99+
}
100+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
//
2+
// Copyright 2022 Google LLC
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
import Foundation
17+
18+
/// CacheKey is like a "key" to a "safe". It provides necessary metadata about the current cache to know if it should be expired.
19+
struct CacheKey: Codable {
20+
var createdAt: Date
21+
var googleAppID: String
22+
var appVersion: String
23+
}
24+
25+
/// SettingsCacheClient is responsible for accessing the cache that Settings are stored in.
26+
protocol SettingsCacheClient {
27+
/// Returns in-memory cache content in O(1) time
28+
var cacheContent: [String: Any]? { get }
29+
/// Returns in-memory cache-key, no performance guarantee because type-casting depends on size of CacheKey
30+
var cacheKey: CacheKey? { get }
31+
/// Removes all cache content and cache-key
32+
func removeCache()
33+
}
34+
35+
/// SettingsCache uses UserDefaults to store Settings on-disk, but also directly query UserDefaults when accessing Settings values during run-time. This is because UserDefaults encapsulates both in-memory and persisted-on-disk storage, allowing fast synchronous access in-app while hiding away the complexity of managing persistence asynchronously.
36+
class SettingsCache: SettingsCacheClient {
37+
private static let settingsVersion: Int = 1
38+
private static let content: String = "firebase-sessions-settings"
39+
private static let key: String = "firebase-sessions-cache-key"
40+
/// UserDefaults holds values in memory, making access O(1) and synchronous within the app, while abstracting away async disk IO.
41+
private let cache: UserDefaults = .standard
42+
43+
/// Converting to dictionary is O(1) because object conversion is O(1)
44+
var cacheContent: [String: Any]? {
45+
return cache.dictionary(forKey: SettingsCache.content)
46+
}
47+
48+
/// Casting to Codable from Data is O(n)
49+
var cacheKey: CacheKey? {
50+
if let data = cache.data(forKey: SettingsCache.key) {
51+
do {
52+
return try JSONDecoder().decode(CacheKey.self, from: data)
53+
} catch {
54+
Logger.logError("[Settings] Decoding CacheKey failed with error: \(error)")
55+
}
56+
}
57+
return nil
58+
}
59+
60+
/// Removes stored cache
61+
func removeCache() {
62+
cache.set(nil, forKey: SettingsCache.content)
63+
cache.set(nil, forKey: SettingsCache.key)
64+
}
65+
}

FirebaseSessions/Tests/Unit/Mocks/MockApplicationInfo.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,19 @@ class MockApplicationInfo: ApplicationInfoProtocol {
3232

3333
var environment: DevEnvironment = .prod
3434

35+
var appBuildVersion: String = ""
36+
37+
var appDisplayVersion: String = ""
38+
3539
static let testAppID = "testAppID"
3640
static let testBundleID = "testBundleID"
3741
static let testSDKVersion = "testSDKVersion"
3842
static let testOSName = "ios"
3943
static let testMCCMNC = "testMCCMNC"
4044
static let testDeviceModel = "testDeviceModel"
4145
static let testEnvironment: DevEnvironment = .prod
46+
static let testAppBuildVersion = "testAppBuildVersion"
47+
static let testAppDisplayVersion = "testAppDisplayVersion"
4248

4349
func mockAllInfo() {
4450
appID = MockApplicationInfo.testAppID
@@ -48,5 +54,7 @@ class MockApplicationInfo: ApplicationInfoProtocol {
4854
mccMNC = MockApplicationInfo.testMCCMNC
4955
deviceModel = MockApplicationInfo.testDeviceModel
5056
environment = MockApplicationInfo.testEnvironment
57+
appBuildVersion = MockApplicationInfo.testAppBuildVersion
58+
appDisplayVersion = MockApplicationInfo.testAppDisplayVersion
5159
}
5260
}

0 commit comments

Comments
 (0)