2020-07-16 11:13:04 -04:00
|
|
|
// Copyright 2020 Google LLC
|
|
|
|
|
//
|
|
|
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
|
// you may not use this file except in compliance with the License.
|
|
|
|
|
// You may obtain a copy of the License at
|
|
|
|
|
//
|
|
|
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
|
//
|
|
|
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
|
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
|
// See the License for the specific language governing permissions and
|
|
|
|
|
// limitations under the License.
|
|
|
|
|
|
2021-10-12 13:39:45 -07:00
|
|
|
import FirebaseCore
|
|
|
|
|
|
2020-07-16 11:13:04 -04:00
|
|
|
class RemoteConfigConsole {
|
|
|
|
|
private let projectID: String
|
|
|
|
|
private var latestConfig: [String: Any]!
|
|
|
|
|
|
|
|
|
|
public var requestTimeout: TimeInterval = 10
|
|
|
|
|
|
|
|
|
|
private var consoleURL: URL {
|
|
|
|
|
let api = "https://firebaseremoteconfig.googleapis.com"
|
|
|
|
|
let endpoint = "/v1/projects/\(projectID)/remoteConfig"
|
|
|
|
|
return URL(string: api + endpoint)!
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private lazy var accessToken: String = {
|
2021-10-12 13:39:45 -07:00
|
|
|
guard let fileURL = Bundle.main.url(forResource: "AccessToken", withExtension: "json") else {
|
2020-07-16 11:13:04 -04:00
|
|
|
fatalError("Could not find AccessToken.json in bundle.")
|
|
|
|
|
}
|
2021-10-12 13:39:45 -07:00
|
|
|
|
2020-07-16 11:13:04 -04:00
|
|
|
guard let data = try? Data(contentsOf: fileURL),
|
2022-01-21 13:03:15 -08:00
|
|
|
let json = try? JSONSerialization.jsonObject(with: data, options: .allowFragments),
|
|
|
|
|
let jsonDict = json as? [String: Any],
|
|
|
|
|
let accessToken = jsonDict["access_token"] as? String else {
|
2020-07-16 11:13:04 -04:00
|
|
|
fatalError("Could not retrieve access token.")
|
|
|
|
|
}
|
|
|
|
|
return accessToken
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
/// Synchronously fetches and returns currently active Remote Config, if it exists.
|
|
|
|
|
public var activeRemoteConfig: [String: Any]? {
|
|
|
|
|
var config: [String: Any]?
|
|
|
|
|
perform(configRequest: .get) { latestConfigJSON in
|
|
|
|
|
config = latestConfigJSON
|
|
|
|
|
}
|
2024-05-03 17:50:20 +02:00
|
|
|
if let config {
|
2020-07-16 11:13:04 -04:00
|
|
|
saveConfig(config)
|
|
|
|
|
}
|
|
|
|
|
return config
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Exposing this initializer allows us to create`RemoteConfigConsole` instances without
|
|
|
|
|
/// depending on a `GoogleService-Info.plist`.
|
|
|
|
|
init(projectID: String) {
|
|
|
|
|
self.projectID = projectID
|
|
|
|
|
syncWithConsole()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// This initializer will attempt to read from a `GoogleService-Info.plist` to set `projectID`.
|
|
|
|
|
convenience init() {
|
2021-10-12 13:39:45 -07:00
|
|
|
let projectID = FirebaseApp.app()?.options.projectID
|
|
|
|
|
self.init(projectID: projectID!)
|
2020-07-16 11:13:04 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Public API
|
|
|
|
|
|
|
|
|
|
/// Update Remote Config with multiple String key value pairs.
|
|
|
|
|
/// - Parameter parameters: Dictionary representation of config key value pairs.
|
|
|
|
|
public func updateRemoteConfig(with parameters: [String: CustomStringConvertible]) {
|
|
|
|
|
var updatedConfig: [String: Any] = latestConfig
|
|
|
|
|
|
|
|
|
|
let latestParameters = latestConfig["parameters"] as? [String: Any]
|
|
|
|
|
var updatedParameters = latestParameters ?? [String: Any]()
|
|
|
|
|
for (key, value) in parameters {
|
|
|
|
|
updatedParameters.updateValue(["defaultValue": ["value": value.description]], forKey: key)
|
|
|
|
|
}
|
|
|
|
|
updatedConfig.updateValue(updatedParameters, forKey: "parameters")
|
|
|
|
|
|
|
|
|
|
publish(config: updatedConfig)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Updates a Remote Config value for a given key.
|
|
|
|
|
/// - Parameters:
|
|
|
|
|
/// - value: Use strings, numbers, and booleans to represent Remote Config values.
|
|
|
|
|
/// - key: The corresponding string key that maps to the given value.
|
|
|
|
|
public func updateRemoteConfigValue(_ value: CustomStringConvertible, forKey key: String) {
|
|
|
|
|
var updatedConfig: [String: Any] = latestConfig
|
|
|
|
|
|
|
|
|
|
let latestParameters = latestConfig["parameters"] as? [String: Any]
|
|
|
|
|
if var parameters = latestParameters {
|
|
|
|
|
parameters.updateValue(["defaultValue": ["value": value.description]], forKey: key)
|
|
|
|
|
updatedConfig.updateValue(parameters, forKey: "parameters")
|
|
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
updatedConfig.updateValue(
|
|
|
|
|
[key: ["defaultValue": ["value": value.description]]],
|
|
|
|
|
forKey: "parameters"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
publish(config: updatedConfig)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func removeRemoteConfigValue(forKey key: String) {
|
|
|
|
|
var updatedConfig: [String: Any] = latestConfig
|
|
|
|
|
|
|
|
|
|
let latestParameters = latestConfig["parameters"] as? [String: Any]
|
|
|
|
|
if var parameters = latestParameters {
|
|
|
|
|
parameters.removeValue(forKey: key)
|
|
|
|
|
updatedConfig.updateValue(parameters, forKey: "parameters")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
publish(config: updatedConfig)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func clearRemoteConfig() {
|
|
|
|
|
var updatedConfig: [String: Any]! = latestConfig
|
|
|
|
|
updatedConfig.removeValue(forKey: "parameters")
|
|
|
|
|
publish(config: updatedConfig)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Networking
|
|
|
|
|
|
|
|
|
|
private enum ConfigRequest {
|
|
|
|
|
case get, put(_ data: Data)
|
|
|
|
|
|
|
|
|
|
var httpMethod: String {
|
|
|
|
|
switch self {
|
|
|
|
|
case .get: return "GET"
|
|
|
|
|
case .put(data: _): return "PUT"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var httpBody: Data? {
|
|
|
|
|
switch self {
|
|
|
|
|
case .get: return nil
|
|
|
|
|
case let .put(data: data): return data
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var httpHeaderFields: [String: String]? {
|
|
|
|
|
switch self {
|
|
|
|
|
case .get: return nil
|
|
|
|
|
case .put(data: _):
|
|
|
|
|
return ["Content-Type": "application/json; UTF8", "If-Match": "*"]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func secureRequest(url: URL, with token: String, _ timeout: TimeInterval = 10) -> URLRequest {
|
|
|
|
|
var request = URLRequest(url: url, timeoutInterval: timeout)
|
2020-07-23 15:44:55 -07:00
|
|
|
request.httpMethod = httpMethod
|
|
|
|
|
request.allHTTPHeaderFields = httpHeaderFields
|
2020-07-16 11:13:04 -04:00
|
|
|
request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
2020-07-23 15:44:55 -07:00
|
|
|
request.httpBody = httpBody
|
2020-07-16 11:13:04 -04:00
|
|
|
return request
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Performs a given `ConfigRequest` synchronously.
|
|
|
|
|
private func perform(configRequest: ConfigRequest,
|
|
|
|
|
_ completion: (([String: Any]?) -> Void)? = nil) {
|
|
|
|
|
let request = configRequest.secureRequest(url: consoleURL, with: accessToken, requestTimeout)
|
|
|
|
|
|
|
|
|
|
let semaphore = DispatchSemaphore(value: 0)
|
|
|
|
|
|
|
|
|
|
let task = URLSession.shared.dataTask(with: request) { data, response, error in
|
|
|
|
|
// Signal the semaphore when this scope is escaped.
|
|
|
|
|
defer { semaphore.signal() }
|
|
|
|
|
|
|
|
|
|
guard let data = data else {
|
|
|
|
|
print(String(describing: error))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let json = try? JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed)
|
|
|
|
|
|
|
|
|
|
if let response = response as? HTTPURLResponse,
|
2022-01-21 13:03:15 -08:00
|
|
|
let json = json as? [String: Any] {
|
2020-07-16 11:13:04 -04:00
|
|
|
if response.statusCode >= 400 {
|
|
|
|
|
print("RemoteConfigConsole Error: \(String(describing: json["error"]!))")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
completion?(json as? [String: Any])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
task.resume()
|
|
|
|
|
semaphore.wait()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Publishes a config object to the live console and updates `latestConfig`.
|
|
|
|
|
private func publish(config: [String: Any]) {
|
|
|
|
|
let configData = data(withConfig: config)
|
|
|
|
|
perform(configRequest: .put(configData))
|
|
|
|
|
saveConfig(config)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Private Helpers
|
|
|
|
|
|
|
|
|
|
/// Creates an optional Data object given a config object.
|
|
|
|
|
/// Used for serializing config objects before posting them to live console.
|
|
|
|
|
private func data(withConfig config: [String: Any]) -> Data {
|
|
|
|
|
let dictionary = NSDictionary(dictionary: config, copyItems: true)
|
|
|
|
|
let data = try! JSONSerialization.data(withJSONObject: dictionary, options: .fragmentsAllowed)
|
|
|
|
|
return data
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Perform a synchronous sync with remote config console.
|
|
|
|
|
private func syncWithConsole() {
|
2024-05-03 17:50:20 +02:00
|
|
|
if let activeRemoteConfig {
|
|
|
|
|
latestConfig = activeRemoteConfig
|
2020-07-16 11:13:04 -04:00
|
|
|
} else {
|
|
|
|
|
fatalError("Could not sync with console.")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A more intuitively named setter for `latestConfig`.
|
|
|
|
|
private func saveConfig(_ config: [String: Any]) {
|
|
|
|
|
latestConfig = config
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Extensions
|
|
|
|
|
|
|
|
|
|
extension Bundle {
|
|
|
|
|
func plistValue(forKey key: String, fromPlist plist: String) -> Any? {
|
|
|
|
|
guard let plistURL = url(forResource: plist, withExtension: "") else {
|
|
|
|
|
print("Could not find plist file \(plist) in bundle.")
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
let plistDictionary = NSDictionary(contentsOf: plistURL)
|
|
|
|
|
return plistDictionary?.object(forKey: key)
|
|
|
|
|
}
|
|
|
|
|
}
|