Skip to content

Commit 8a0bb35

Browse files
Settings downloader (#10530)
* initial code for downloader * WIP fetchAndCacheSettings * fill out request headers * onComplete * working fetch * WIP tests * working tests * merge conflicts * comments * fix warning in pod-lib-lint * logger when cache does not expire * comments * move os info to ApplicationInfo and fix URL's platform * if cache does not exist, return empty dictionary instead of nil * unshadow variable in guard-let statement * add comment describing task.resume() * addressing reviews * attempt fetch every session instead of every app-start * address last review
1 parent 04c8e67 commit 8a0bb35

12 files changed

+425
-124
lines changed

FirebaseSessions/Sources/ApplicationInfo.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ protocol ApplicationInfoProtocol {
5050
var appBuildVersion: String { get }
5151

5252
var appDisplayVersion: String { get }
53+
54+
var osBuildVersion: String { get }
55+
56+
var osDisplayVersion: String { get }
5357
}
5458

5559
class ApplicationInfo: ApplicationInfoProtocol {
@@ -100,4 +104,12 @@ class ApplicationInfo: ApplicationInfoProtocol {
100104
var appDisplayVersion: String {
101105
return Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? ""
102106
}
107+
108+
var osBuildVersion: String {
109+
return FIRSESGetSysctlEntry("kern.osversion") ?? ""
110+
}
111+
112+
var osDisplayVersion: String {
113+
return GULAppEnvironmentUtil.systemVersion()
114+
}
103115
}

FirebaseSessions/Sources/FirebaseSessions.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,14 @@ protocol SessionsProvider {
6363
)
6464
let initiator = SessionInitiator()
6565
let appInfo = ApplicationInfo(appID: appID)
66-
let settings = Settings(appInfo: appInfo)
66+
let settings = Settings(
67+
appInfo: appInfo,
68+
downloader: SettingsDownloader(
69+
appInfo: appInfo,
70+
identifiers: identifiers,
71+
installations: installations
72+
)
73+
)
6774

6875
self.init(appID: appID,
6976
identifiers: identifiers,
@@ -87,6 +94,8 @@ protocol SessionsProvider {
8794
super.init()
8895

8996
self.initiator.beginListening {
97+
// On each session start, first update Settings if expired
98+
self.settings.fetchAndCacheSettings()
9099
self.identifiers.generateNewSessionID()
91100
let event = SessionStartEvent(identifiers: self.identifiers, appInfo: self.appInfo)
92101
DispatchQueue.global().async {

FirebaseSessions/Sources/FirebaseSessionsError.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,6 @@ enum FirebaseSessionsError: Error {
2020
case SessionSamplingError
2121
/// Firebase Installation ID related error
2222
case SessionInstallationsError
23+
/// Settings related error
24+
case SettingsError(String)
2325
}

FirebaseSessions/Sources/NanoPB/FIRSESNanoPBHelpers.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ BOOL FIRSESIsPBDataEqual(pb_bytes_array_t* _Nullable pbArray, NSData* _Nullable
8282
/// Swift does not support c-style macros.
8383
pb_size_t FIRSESGetAppleApplicationInfoTag(void);
8484

85+
/// Returns sysctl entry, useful for obtaining OS build version from the kernel. Copied from a
86+
/// private method in GULAppEnvironmentUtil.
87+
NSString* _Nullable FIRSESGetSysctlEntry(const char* sysctlKey);
88+
8589
/// Returns the validated MccMnc if it is available, or nil if the device does not support telephone
8690
NSString* _Nullable FIRSESValidateMccMnc(NSString* _Nullable mcc, NSString* _Nullable mnc);
8791

FirebaseSessions/Sources/NanoPB/FIRSESNanoPBHelpers.m

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
#import <nanopb/pb.h>
2525
#import <nanopb/pb_decode.h>
2626
#import <nanopb/pb_encode.h>
27+
#import <sys/sysctl.h>
2728

2829
NS_ASSUME_NONNULL_BEGIN
2930

@@ -157,6 +158,22 @@ pb_size_t FIRSESGetAppleApplicationInfoTag(void) {
157158
return firebase_appquality_sessions_ApplicationInfo_apple_app_info_tag;
158159
}
159160

161+
/// Copied from a private method in GULAppEnvironmentUtil.
162+
NSString *_Nullable FIRSESGetSysctlEntry(const char *sysctlKey) {
163+
static NSString *entryValue;
164+
size_t size;
165+
sysctlbyname(sysctlKey, NULL, &size, NULL, 0);
166+
if (size > 0) {
167+
char *entryValueCStr = malloc(size);
168+
sysctlbyname(sysctlKey, entryValueCStr, &size, NULL, 0);
169+
entryValue = [NSString stringWithCString:entryValueCStr encoding:NSUTF8StringEncoding];
170+
free(entryValueCStr);
171+
return entryValue;
172+
} else {
173+
return nil;
174+
}
175+
}
176+
160177
NSString *_Nullable FIRSESValidateMccMnc(NSString *_Nullable mcc, NSString *_Nullable mnc) {
161178
// These are both nil if the target does not support mobile connectivity
162179
if (mcc == nil && mnc == nil) {

FirebaseSessions/Sources/Settings.swift

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,16 @@ extension ApplicationInfoProtocol {
2020
var synthesizedVersion: String { return "\(appDisplayVersion) (\(appBuildVersion))" }
2121
}
2222

23+
extension SettingsProtocol {
24+
func fetchAndCacheSettings() {
25+
return fetchAndCacheSettings(currentTime: Date())
26+
}
27+
}
28+
2329
/// Provides the APIs to access Settings and their configuration values
2430
protocol SettingsProtocol {
25-
func isCacheExpired(currentTime: Date) -> Bool
31+
/// Attempts to fetch settings only if the current cache is expired
32+
func fetchAndCacheSettings(currentTime: Date)
2633
var sessionsEnabled: Bool { get }
2734
var samplingRate: Double { get }
2835
var sessionTimeout: TimeInterval { get }
@@ -32,47 +39,79 @@ class Settings: SettingsProtocol {
3239
private static let cacheDurationSecondsDefault: TimeInterval = 60 * 60
3340
private static let flagSessionsEnabled = "sessions_enabled"
3441
private static let flagSamplingRate = "sampling_rate"
35-
private static let flagSessionTimeout = "session_timeout"
42+
private static let flagSessionTimeout = "session_timeout_seconds"
3643
private static let flagCacheDuration = "cache_duration"
37-
private let cache: SettingsCacheClient
44+
private static let flagSessionsCache = "app_quality"
3845
private let appInfo: ApplicationInfoProtocol
46+
private let downloader: SettingsDownloadClient
47+
private var cache: SettingsCacheClient
3948

4049
var sessionsEnabled: Bool {
41-
guard let enabled = cache.cacheContent?[Settings.flagSessionsEnabled] as? Bool else {
50+
guard let enabled = sessionsCache[Settings.flagSessionsEnabled] as? Bool else {
4251
return true
4352
}
4453
return enabled
4554
}
4655

4756
var samplingRate: Double {
48-
guard let rate = cache.cacheContent?[Settings.flagSamplingRate] as? Double else {
57+
guard let rate = sessionsCache[Settings.flagSamplingRate] as? Double else {
4958
return 1.0
5059
}
5160
return rate
5261
}
5362

5463
var sessionTimeout: TimeInterval {
55-
guard let timeout = cache.cacheContent?[Settings.flagSessionTimeout] as? Double else {
64+
guard let timeout = sessionsCache[Settings.flagSessionTimeout] as? Double else {
5665
return 30 * 60
5766
}
5867
return timeout
5968
}
6069

6170
private var cacheDurationSeconds: TimeInterval {
62-
guard let duration = cache.cacheContent?[Settings.flagCacheDuration] as? Double else {
71+
guard let duration = cache.cacheContent[Settings.flagCacheDuration] as? Double else {
6372
return Settings.cacheDurationSecondsDefault
6473
}
6574
return duration
6675
}
6776

68-
init(cache: SettingsCacheClient = SettingsCache(),
69-
appInfo: ApplicationInfoProtocol) {
70-
self.cache = cache
77+
private var sessionsCache: [String: Any] {
78+
return cache.cacheContent[Settings.flagSessionsCache] as? [String: Any] ?? [:]
79+
}
80+
81+
init(appInfo: ApplicationInfoProtocol,
82+
downloader: SettingsDownloadClient,
83+
cache: SettingsCacheClient = SettingsCache()) {
7184
self.appInfo = appInfo
85+
self.cache = cache
86+
self.downloader = downloader
87+
}
88+
89+
func fetchAndCacheSettings(currentTime: Date) {
90+
// Only fetch if cache is expired, otherwise do nothing
91+
guard isCacheExpired(time: currentTime) else {
92+
Logger.logDebug("[Settings] Cache is not expired, no fetch will be made.")
93+
return
94+
}
95+
96+
downloader.fetch { result in
97+
switch result {
98+
case let .success(dictionary):
99+
// Saves all newly fetched Settings to cache
100+
self.cache.cacheContent = dictionary
101+
// Saves a "cache-key" which carries TTL metadata about current cache
102+
self.cache.cacheKey = CacheKey(
103+
createdAt: currentTime,
104+
googleAppID: self.appInfo.appID,
105+
appVersion: self.appInfo.synthesizedVersion
106+
)
107+
case let .failure(error):
108+
Logger.logError("[Settings] Fetching newest settings failed with error: \(error)")
109+
}
110+
}
72111
}
73112

74-
func isCacheExpired(currentTime: Date) -> Bool {
75-
guard cache.cacheContent != nil else {
113+
private func isCacheExpired(time: Date) -> Bool {
114+
guard !cache.cacheContent.isEmpty else {
76115
cache.removeCache()
77116
return true
78117
}
@@ -87,7 +126,7 @@ class Settings: SettingsProtocol {
87126
cache.removeCache()
88127
return true
89128
}
90-
if currentTime.timeIntervalSince(cacheKey.createdAt) > cacheDurationSeconds {
129+
if time.timeIntervalSince(cacheKey.createdAt) > cacheDurationSeconds {
91130
Logger.logDebug("[Settings] Cache TTL expired")
92131
return true
93132
}

FirebaseSessions/Sources/SettingsCacheClient.swift

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,42 +24,59 @@ struct CacheKey: Codable {
2424

2525
/// SettingsCacheClient is responsible for accessing the cache that Settings are stored in.
2626
protocol SettingsCacheClient {
27-
/// Returns in-memory cache content in O(1) time
28-
var cacheContent: [String: Any]? { get }
27+
/// Returns in-memory cache content in O(1) time. Returns empty dictionary if it does not exist in cache.
28+
var cacheContent: [String: Any] { get set }
2929
/// Returns in-memory cache-key, no performance guarantee because type-casting depends on size of CacheKey
30-
var cacheKey: CacheKey? { get }
30+
var cacheKey: CacheKey? { get set }
3131
/// Removes all cache content and cache-key
3232
func removeCache()
3333
}
3434

3535
/// 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.
3636
class SettingsCache: SettingsCacheClient {
3737
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"
38+
private enum UserDefaultsKeys {
39+
static let forContent = "firebase-sessions-settings"
40+
static let forCacheKey = "firebase-sessions-cache-key"
41+
}
42+
4043
/// UserDefaults holds values in memory, making access O(1) and synchronous within the app, while abstracting away async disk IO.
4144
private let cache: UserDefaults = .standard
4245

4346
/// Converting to dictionary is O(1) because object conversion is O(1)
44-
var cacheContent: [String: Any]? {
45-
return cache.dictionary(forKey: SettingsCache.content)
47+
var cacheContent: [String: Any] {
48+
get {
49+
return cache.dictionary(forKey: UserDefaultsKeys.forContent) ?? [:]
50+
}
51+
set {
52+
cache.set(newValue, forKey: UserDefaultsKeys.forContent)
53+
}
4654
}
4755

4856
/// Casting to Codable from Data is O(n)
4957
var cacheKey: CacheKey? {
50-
if let data = cache.data(forKey: SettingsCache.key) {
58+
get {
59+
if let data = cache.data(forKey: UserDefaultsKeys.forCacheKey) {
60+
do {
61+
return try JSONDecoder().decode(CacheKey.self, from: data)
62+
} catch {
63+
Logger.logError("[Settings] Decoding CacheKey failed with error: \(error)")
64+
}
65+
}
66+
return nil
67+
}
68+
set {
5169
do {
52-
return try JSONDecoder().decode(CacheKey.self, from: data)
70+
cache.set(try JSONEncoder().encode(newValue), forKey: UserDefaultsKeys.forCacheKey)
5371
} catch {
54-
Logger.logError("[Settings] Decoding CacheKey failed with error: \(error)")
72+
Logger.logError("[Settings] Encoding CacheKey failed with error: \(error)")
5573
}
5674
}
57-
return nil
5875
}
5976

6077
/// Removes stored cache
6178
func removeCache() {
62-
cache.set(nil, forKey: SettingsCache.content)
63-
cache.set(nil, forKey: SettingsCache.key)
79+
cache.set(nil, forKey: UserDefaultsKeys.forContent)
80+
cache.set(nil, forKey: UserDefaultsKeys.forCacheKey)
6481
}
6582
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
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+
@_implementationOnly import GoogleUtilities
19+
20+
protocol SettingsDownloadClient {
21+
func fetch(completion: @escaping (Result<[String: Any], Error>) -> Void)
22+
}
23+
24+
class SettingsDownloader: SettingsDownloadClient {
25+
private let appInfo: ApplicationInfoProtocol
26+
private let identifiers: IdentifierProvider
27+
private let installations: InstallationsProtocol
28+
29+
init(appInfo: ApplicationInfoProtocol, identifiers: IdentifierProvider,
30+
installations: InstallationsProtocol) {
31+
self.appInfo = appInfo
32+
self.identifiers = identifiers
33+
self.installations = installations
34+
}
35+
36+
func fetch(completion: @escaping (Result<[String: Any], Error>) -> Void) {
37+
guard let validURL = url else {
38+
completion(.failure(FirebaseSessionsError.SettingsError("Invalid URL")))
39+
return
40+
}
41+
42+
installations.installationID { result in
43+
switch result {
44+
case let .success(fiid):
45+
let request = self.buildRequest(url: validURL, fiid: fiid)
46+
let task = URLSession.shared.dataTask(with: request) { data, response, error in
47+
if let data = data {
48+
if let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
49+
completion(.success(dict))
50+
} else {
51+
completion(.failure(FirebaseSessionsError
52+
.SettingsError("Failed to parse JSON to dictionary")))
53+
}
54+
} else if let error = error {
55+
completion(.failure(FirebaseSessionsError.SettingsError(error.localizedDescription)))
56+
}
57+
}
58+
// Start the task that sends the network request
59+
task.resume()
60+
case let .failure(error):
61+
completion(.failure(FirebaseSessionsError.SettingsError(error.localizedDescription)))
62+
}
63+
}
64+
}
65+
66+
private var url: URL? {
67+
var components = URLComponents()
68+
components.scheme = "https"
69+
components.host = "firebase-settings.crashlytics.com"
70+
components.path = "/spi/v2/platforms/\(appInfo.osName)/gmp/\(appInfo.appID)/settings"
71+
components.queryItems = [
72+
URLQueryItem(name: "build_version", value: appInfo.appBuildVersion),
73+
URLQueryItem(name: "display_version", value: appInfo.appDisplayVersion),
74+
]
75+
return components.url
76+
}
77+
78+
private func buildRequest(url: URL, fiid: String) -> URLRequest {
79+
var request = URLRequest(url: url)
80+
request.setValue("application/json", forHTTPHeaderField: "Accept")
81+
request.setValue(fiid, forHTTPHeaderField: "X-Crashlytics-Installation-ID")
82+
request.setValue(appInfo.deviceModel, forHTTPHeaderField: "X-Crashlytics-Device-Model")
83+
request.setValue(
84+
appInfo.osBuildVersion,
85+
forHTTPHeaderField: "X-Crashlytics-OS-Build-Version"
86+
)
87+
request.setValue(
88+
appInfo.osDisplayVersion,
89+
forHTTPHeaderField: "X-Crashlytics-OS-Display-Version"
90+
)
91+
request.setValue(appInfo.sdkVersion, forHTTPHeaderField: "X-Crashlytics-API-Client-Version")
92+
return request
93+
}
94+
}

0 commit comments

Comments
 (0)