| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111 |
- // Copyright 2025 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.
- import FirebaseABTesting
- // TODO: interop
- // import FirebaseAnalyticsInterop
- import FirebaseCore
- import FirebaseCoreExtension
- import FirebaseInstallations
- import FirebaseRemoteConfigInterop
- import Foundation
- @_implementationOnly import GoogleUtilities
- public let namespaceGoogleMobilePlatform = "firebase"
- public let remoteConfigThrottledEndTimeInSecondsKey = "error_throttled_end_time_seconds"
- public let remoteConfigActivateNotification =
- Notification.Name("FIRRemoteConfigActivateNotification")
- /// Listener for the get methods.
- public typealias RemoteConfigListener = (String, [String: RemoteConfigValue]) -> Void
- @objc(FIRRemoteConfigSettings)
- public class RemoteConfigSettings: NSObject, NSCopying {
- /// Indicates the default value in seconds to set for the minimum interval that needs to elapse
- /// before a fetch request can again be made to the Remote Config backend. After a fetch request
- /// to
- /// the backend has succeeded, no additional fetch requests to the backend will be allowed until
- /// the
- /// minimum fetch interval expires. Note that you can override this default on a per-fetch request
- /// basis using `RemoteConfig.fetch(withExpirationDuration:)`. For example, setting
- /// the expiration duration to 0 in the fetch request will override the `minimumFetchInterval` and
- /// allow the request to proceed.
- @objc public var minimumFetchInterval: TimeInterval =
- .init(ConfigConstants.defaultMinimumFetchInterval)
- /// Indicates the default value in seconds to abandon a pending fetch request made to the backend.
- /// This value is set for outgoing requests as the `timeoutIntervalForRequest` as well as the
- /// `timeoutIntervalForResource` on the `NSURLSession`'s configuration.
- @objc public var fetchTimeout: TimeInterval =
- .init(ConfigConstants.httpDefaultConnectionTimeout)
- // Default init removed to allow for simpler initialization.
- @objc public func copy(with zone: NSZone? = nil) -> Any {
- let copy = RemoteConfigSettings()
- copy.minimumFetchInterval = minimumFetchInterval
- copy.fetchTimeout = fetchTimeout
- return copy
- }
- }
- /// Indicates whether updated data was successfully fetched.
- @objc(FIRRemoteConfigFetchStatus)
- public enum RemoteConfigFetchStatus: Int, Sendable {
- /// Config has never been fetched.
- case noFetchYet
- /// Config fetch succeeded.
- case success
- /// Config fetch failed.
- case failure
- /// Config fetch was throttled.
- case throttled
- }
- /// Indicates whether updated data was successfully fetched and activated.
- @objc(FIRRemoteConfigFetchAndActivateStatus)
- public enum RemoteConfigFetchAndActivateStatus: Int {
- /// The remote fetch succeeded and fetched data was activated.
- case successFetchedFromRemote
- /// The fetch and activate succeeded from already fetched but yet unexpired config data. You can
- /// control this using minimumFetchInterval property in FIRRemoteConfigSettings.
- case successUsingPreFetchedData
- /// The fetch and activate failed.
- case error
- }
- @objc(FIRRemoteConfigError)
- public enum RemoteConfigError: Int, LocalizedError, CustomNSError {
- /// Unknown or no error.
- case unknown = 8001
- /// Frequency of fetch requests exceeds throttled limit.
- case throttled = 8002
- /// Internal error that covers all internal HTTP errors.
- case internalError = 8003
- public var errorDescription: String? {
- switch self {
- case .unknown:
- return "Unknown error."
- case .throttled:
- return "Frequency of fetch requests exceeds throttled limit."
- case .internalError:
- return "Internal error."
- }
- }
- }
- @objc(FIRRemoteConfigUpdateError)
- public enum RemoteConfigUpdateError: Int, LocalizedError, CustomNSError {
- /// Unable to make a connection to the Remote Config backend.
- case streamError = 8001
- /// Unable to fetch the latest version of the config.
- case notFetched = 8002
- /// The ConfigUpdate message was unparsable.
- case messageInvalid = 8003
- /// The Remote Config real-time config update service is unavailable.
- case unavailable = 8004
- public var errorDescription: String? {
- switch self {
- case .streamError:
- return "Unable to make a connection to the Remote Config backend."
- case .notFetched:
- return "Unable to fetch the latest version of the config."
- case .messageInvalid:
- return "The ConfigUpdate message was unparsable."
- case .unavailable:
- return "The Remote Config real-time config update service is unavailable."
- }
- }
- }
- /// Firebase Remote Config custom signals error.
- @objc(FIRRemoteConfigCustomSignalsError)
- public enum RemoteConfigCustomSignalsError: Int, CustomNSError {
- /// Unknown error.
- case unknown = 8101
- /// Invalid value type in the custom signals dictionary.
- case invalidValueType = 8102
- /// Limit exceeded for key length, value length, or number of signals.
- case limitExceeded = 8103
- }
- /// Enumerated value that indicates the source of Remote Config data. Data can come from
- /// the Remote Config service, the DefaultConfig that is available when the app is first
- /// installed, or a static initialized value if data is not available from the service or
- /// DefaultConfig.
- @objc(FIRRemoteConfigSource)
- public enum RemoteConfigSource: Int {
- /// The data source is the Remote Config service.
- case remote
- /// The data source is the DefaultConfig defined for this app.
- case `default`
- /// The data doesn't exist, return a static initialized value.
- case `static`
- }
- // MARK: - RemoteConfig
- /// Firebase Remote Config class. The class method `remoteConfig()` can be used
- /// to fetch, activate and read config results and set default config results on the default
- /// Remote Config instance.
- @objc(FIRRemoteConfig)
- open class RemoteConfig: NSObject, NSFastEnumeration {
- /// All the config content.
- private let configContent: ConfigContent
- private let dbManager: ConfigDBManager
- @objc public var settings: ConfigSettings
- let configFetch: ConfigFetch
- private let configExperiment: ConfigExperiment
- private let configRealtime: ConfigRealtime
- private let queue: DispatchQueue
- // TODO: remove objc public/
- @objc public let appName: String
- private var listeners = [RemoteConfigListener]()
- let FIRNamespace: String
- // MARK: - Public Initializers and Accessors
- /// Returns the `RemoteConfig` instance for your (non-default) Firebase appID. Note that Firebase
- /// analytics does not work for non-default app instances. This singleton object contains the
- /// complete set of Remote Config parameter values available to the app, including the Active
- /// Config
- /// and Default Config. This object also caches values fetched from the Remote Config Server until
- /// they are copied to the Active Config by calling `activate())`. When you fetch values
- /// from the Remote Config Server using the non-default Firebase app, you should use this
- /// class method to create and reuse shared instance of `RemoteConfig`.
- @objc(remoteConfigWithApp:) public static func remoteConfig(app: FirebaseApp) -> RemoteConfig {
- return remoteConfig(withFIRNamespace: RemoteConfigConstants.NamespaceGoogleMobilePlatform,
- app: app)
- }
- /// Returns the `RemoteConfig` instance configured for the default Firebase app. This singleton
- /// object contains the complete set of Remote Config parameter values available to the app,
- /// including the Active Config and Default Config. This object also caches values fetched from
- /// the
- /// Remote Config server until they are copied to the Active Config by calling `activate()`. When
- /// you fetch values from the Remote Config server using the default Firebase app, you should use
- /// this class method to create and reuse a shared instance of `RemoteConfig`.
- @objc public static func remoteConfig() -> RemoteConfig {
- guard let app = FirebaseApp.app() else {
- fatalError("The default FirebaseApp instance must be configured before the " +
- "default Remote Config instance can be initialized. One way to ensure " +
- "this is to call `FirebaseApp.configure()` in the App Delegate's " +
- "`application(_:didFinishLaunchingWithOptions:)` or the `@main` struct's " +
- "initializer in SwiftUI.")
- }
- return remoteConfig(withFIRNamespace: RemoteConfigConstants.NamespaceGoogleMobilePlatform,
- app: app)
- }
- /// API for internal use only.
- @objc(remoteConfigWithFIRNamespace:)
- public static func remoteConfig(withFIRNamespace firebaseNamespace: String) -> RemoteConfig {
- guard let app = FirebaseApp.app() else {
- fatalError("The default FirebaseApp instance must be configured before the " +
- "default Remote Config instance can be initialized. One way to ensure " +
- "this is to call `FirebaseApp.configure()` in the App Delegate's " +
- "`application(_:didFinishLaunchingWithOptions:)` or the `@main` struct's " +
- "initializer in SwiftUI.")
- }
- return remoteConfig(withFIRNamespace: firebaseNamespace, app: app)
- }
- /// API for internal use only.
- /// Use the provider to generate and return instances of FIRRemoteConfig for this specific app and
- /// namespace. This will ensure the app is configured before Remote Config can return an instance.
- @objc(remoteConfigWithFIRNamespace:app:)
- public static func remoteConfig(withFIRNamespace firebaseNamespace: String = RemoteConfigConstants
- .NamespaceGoogleMobilePlatform,
- app: FirebaseApp) -> RemoteConfig {
- let provider = ComponentType<RemoteConfigInterop>
- .instance(
- for: RemoteConfigInterop.self,
- in: app.container
- ) as! any RemoteConfigProvider as RemoteConfigProvider
- return provider.remoteConfig(forNamespace: firebaseNamespace)!
- }
- /// Last successful fetch completion time.
- @objc public var lastFetchTime: Date {
- queue.sync {
- let lastFetchTimeInterval = self.settings.lastFetchTimeInterval
- return Date(timeIntervalSince1970: lastFetchTimeInterval)
- }
- }
- /// Last fetch status. The status can be any enumerated value from `RemoteConfigFetchStatus`.
- @objc public var lastFetchStatus: RemoteConfigFetchStatus {
- queue.sync {
- self.configFetch.settings.lastFetchStatus
- }
- }
- /// Config settings are custom settings.
- @objc public var configSettings: RemoteConfigSettings {
- get {
- // These properties *must* be accessed and returned on the lock queue
- // to ensure thread safety.
- let (minimumFetchInterval, fetchTimeout) = queue.sync {
- (self.settings.minimumFetchInterval, self.settings.fetchTimeout)
- }
- RCLog.debug("I-RCN000066",
- "Successfully read configSettings. Minimum Fetch Interval: " +
- "\(minimumFetchInterval), Fetch timeout: \(fetchTimeout)")
- let settings = RemoteConfigSettings()
- settings.minimumFetchInterval = minimumFetchInterval
- settings.fetchTimeout = fetchTimeout
- /// The NSURLSession needs to be recreated whenever the fetch timeout may be updated.
- configFetch.recreateNetworkSession()
- RCLog.debug("I-RCN987366",
- "Successfully read configSettings. Minimum Fetch Interval: " +
- "\(minimumFetchInterval), Fetch timeout: \(fetchTimeout)")
- return settings
- }
- set {
- queue.async {
- self.settings.minimumFetchInterval = newValue.minimumFetchInterval
- self.settings.fetchTimeout = newValue.fetchTimeout
- /// The NSURLSession needs to be recreated whenever the fetch timeout may be updated.
- self.configFetch.recreateNetworkSession()
- RCLog.debug("I-RCN000067",
- "Successfully set configSettings. Minimum Fetch Interval: " +
- "\(newValue.minimumFetchInterval), " +
- "Fetch timeout: \(newValue.fetchTimeout)")
- }
- }
- }
- @objc public subscript(key: String) -> RemoteConfigValue {
- return configValue(forKey: key)
- }
- /// Singleton instance of serial queue for queuing all incoming RC calls.
- public static let sharedRemoteConfigSerialQueue =
- DispatchQueue(label: "com.google.remoteconfig.serialQueue")
- // TODO: Designated initializer - Consolidate with next when objc tests are gone.
- @objc(initWithAppName:FIROptions:namespace:DBManager:configContent:analytics:)
- public
- convenience init(appName: String,
- options: FirebaseOptions,
- namespace: String,
- dbManager: ConfigDBManager,
- configContent: ConfigContent,
- analytics: FIRAnalyticsInterop?) {
- self.init(
- appName: appName,
- options: options,
- namespace: namespace,
- dbManager: dbManager,
- configContent: configContent,
- userDefaults: nil,
- analytics: analytics,
- configFetch: nil,
- configRealtime: nil
- )
- }
- /// Designated initializer
- @objc(
- initWithAppName:FIROptions:namespace:DBManager:configContent:userDefaults:analytics:configFetch:configRealtime:settings:
- )
- public
- init(appName: String,
- options: FirebaseOptions,
- namespace: String,
- dbManager: ConfigDBManager,
- configContent: ConfigContent,
- userDefaults: UserDefaults?,
- analytics: FIRAnalyticsInterop?,
- configFetch: ConfigFetch? = nil,
- configRealtime: ConfigRealtime? = nil,
- settings: ConfigSettings? = nil) {
- self.appName = appName
- self.dbManager = dbManager
- // Initialize RCConfigContent if not already.
- self.configContent = configContent
- // The fully qualified Firebase namespace is namespace:firappname.
- FIRNamespace = "\(namespace):\(appName)"
- queue = RemoteConfig.sharedRemoteConfigSerialQueue
- self.settings = settings ?? ConfigSettings(
- databaseManager: dbManager,
- namespace: FIRNamespace,
- firebaseAppName: appName,
- googleAppID: options.googleAppID,
- userDefaults: userDefaults
- )
- let experimentController = ExperimentController.sharedInstance()
- configExperiment = ConfigExperiment(
- dbManager: dbManager,
- experimentController: experimentController
- )
- // Initialize with default config settings.
- self.configFetch = configFetch ?? ConfigFetch(
- content: configContent,
- DBManager: dbManager,
- settings: self.settings,
- analytics: analytics,
- experiment: configExperiment,
- queue: queue,
- namespace: FIRNamespace,
- options: options
- )
- self.configRealtime = configRealtime ?? ConfigRealtime(
- configFetch: self.configFetch,
- settings: self.settings,
- namespace: FIRNamespace,
- options: options
- )
- super.init()
- self.settings.loadConfigFromMetadataTable()
- if let analytics = analytics {
- let personalization = Personalization(analytics: analytics)
- addListener { key, config in
- personalization.logArmActive(rcParameter: key, config: config)
- }
- }
- }
- /// Ensures initialization is complete and clients can begin querying for Remote Config values.
- @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
- public func ensureInitialized() async throws {
- return try await withCheckedThrowingContinuation { continuation in
- self.ensureInitialized { error in
- if let error {
- continuation.resume(throwing: error)
- } else {
- continuation.resume()
- }
- }
- }
- }
- /// Ensures initialization is complete and clients can begin querying for Remote Config values.
- /// - Parameter completionHandler: Initialization complete callback with error parameter.
- @objc public func ensureInitialized(completionHandler: @Sendable @escaping (Error?) -> Void) {
- DispatchQueue.global(qos: .utility).async { [weak self] in
- guard let self else { return }
- let initializationSuccess = self.configContent.initializationSuccessful()
- let error = initializationSuccess ? nil :
- NSError(
- domain: ConfigConstants.remoteConfigErrorDomain,
- code: RemoteConfigError.internalError.rawValue,
- userInfo: [NSLocalizedDescriptionKey: "Timed out waiting for database load."]
- )
- completionHandler(error)
- }
- }
- /// Adds a listener that will be called whenever one of the get methods is called.
- /// - Parameter listener: Function that takes in the parameter key and the config.
- @objc public func addListener(_ listener: @escaping RemoteConfigListener) {
- queue.async {
- self.listeners.append(listener)
- }
- }
- private func callListeners(key: String, config: [String: RemoteConfigValue]) {
- queue.async { [weak self] in
- guard let self else { return }
- for listener in self.listeners {
- listener(key, config)
- }
- }
- }
- // MARK: - Fetch
- /// Fetches Remote Config data with a callback. Call `activate()` to make fetched data
- /// available to your app.
- ///
- /// Note: This method uses a Firebase Installations token to identify the app instance, and once
- /// it's called, it periodically sends data to the Firebase backend. (see
- /// `Installations.authToken(completion:)`).
- /// To stop the periodic sync, call `Installations.delete(completion:)`
- /// and avoid calling this method again.
- @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
- public func fetch() async throws -> RemoteConfigFetchStatus {
- return try await withUnsafeThrowingContinuation { continuation in
- self.fetch { status, error in
- if let error {
- continuation.resume(throwing: error)
- } else {
- continuation.resume(returning: status)
- }
- }
- }
- }
- /// Fetches Remote Config data with a callback. Call `activate()` to make fetched data
- /// available to your app.
- ///
- /// Note: This method uses a Firebase Installations token to identify the app instance, and once
- /// it's called, it periodically sends data to the Firebase backend. (see
- /// `Installations.authToken(completion:)`).
- /// To stop the periodic sync, call `Installations.delete(completion:)`
- /// and avoid calling this method again.
- ///
- /// - Parameter completionHandler: Fetch operation callback with status and error parameters.
- @objc public func fetch(completionHandler: (
- @Sendable (RemoteConfigFetchStatus, Error?) -> Void
- )? =
- nil) {
- queue.async {
- self.fetch(withExpirationDuration: self.settings.minimumFetchInterval,
- completionHandler: completionHandler)
- }
- }
- /// Fetches Remote Config data and sets a duration that specifies how long config data lasts.
- /// Call `activateWithCompletion:` to make fetched data available to your app.
- ///
- /// - Parameter expirationDuration: Override the (default or optionally set `minimumFetchInterval`
- /// property in RemoteConfigSettings) `minimumFetchInterval` for only the current request, in
- /// seconds. Setting a value of 0 seconds will force a fetch to the backend.
- ///
- /// Note: This method uses a Firebase Installations token to identify the app instance, and once
- /// it's called, it periodically sends data to the Firebase backend. (see
- /// `Installations.authToken(completion:)`).
- /// To stop the periodic sync, call `Installations.delete(completion:)`
- /// and avoid calling this method again.
- @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
- public func fetch(withExpirationDuration expirationDuration: TimeInterval) async throws
- -> RemoteConfigFetchStatus {
- return try await withUnsafeThrowingContinuation { continuation in
- configFetch.fetchConfig(withExpirationDuration: expirationDuration) { status, error in
- if let error {
- continuation.resume(throwing: error)
- } else {
- continuation.resume(returning: status)
- }
- }
- }
- }
- /// Fetches Remote Config data and sets a duration that specifies how long config data lasts.
- /// Call `activateWithCompletion:` to make fetched data available to your app.
- ///
- /// - Parameter expirationDuration: Override the (default or optionally set `minimumFetchInterval`
- /// property in RemoteConfigSettings) `minimumFetchInterval` for only the current request, in
- /// seconds. Setting a value of 0 seconds will force a fetch to the backend.
- /// - Parameter completionHandler: Fetch operation callback with status and error parameters.
- ///
- /// Note: This method uses a Firebase Installations token to identify the app instance, and once
- /// it's called, it periodically sends data to the Firebase backend. (see
- /// `Installations.authToken(completion:)`).
- /// To stop the periodic sync, call `Installations.delete(completion:)`
- /// and avoid calling this method again.
- @objc public func fetch(withExpirationDuration expirationDuration: TimeInterval,
- completionHandler: (
- @Sendable (RemoteConfigFetchStatus, Error?) -> Void
- )? =
- nil) {
- configFetch.fetchConfig(withExpirationDuration: expirationDuration,
- completionHandler: completionHandler)
- }
- // MARK: - FetchAndActivate
- /// Fetches Remote Config data and if successful, activates fetched data. Optional completion
- /// handler callback is invoked after the attempted activation of data, if the fetch call
- /// succeeded.
- ///
- /// Note: This method uses a Firebase Installations token to identify the app instance, and once
- /// it's called, it periodically sends data to the Firebase backend. (see
- /// `Installations.authToken(completion:)`).
- /// To stop the periodic sync, call `Installations.delete(completion:)`
- /// and avoid calling this method again.
- @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
- public func fetchAndActivate() async throws -> RemoteConfigFetchAndActivateStatus {
- return try await withUnsafeThrowingContinuation { continuation in
- fetchAndActivate { status, error in
- if let error {
- continuation.resume(throwing: error)
- } else {
- continuation.resume(returning: status)
- }
- }
- }
- }
- /// Fetches Remote Config data and if successful, activates fetched data. Optional completion
- /// handler callback is invoked after the attempted activation of data, if the fetch call
- /// succeeded.
- ///
- /// Note: This method uses a Firebase Installations token to identify the app instance, and once
- /// it's called, it periodically sends data to the Firebase backend. (see
- /// `Installations.authToken(completion:)`).
- /// To stop the periodic sync, call `Installations.delete(completion:)`
- /// and avoid calling this method again.
- ///
- /// - Parameter completionHandler: Fetch operation callback with status and error parameters.
- @objc public func fetchAndActivate(completionHandler:
- (@Sendable (RemoteConfigFetchAndActivateStatus, Error?) -> Void)? = nil) {
- fetch { [weak self] fetchStatus, error in
- guard let self else { return }
- // Fetch completed. We are being called on the main queue.
- // If fetch is successful, try to activate the fetched config
- if fetchStatus == .success, error == nil {
- self.activate { changed, error in
- if let completionHandler {
- DispatchQueue.main.async {
- let status: RemoteConfigFetchAndActivateStatus = error == nil ?
- .successFetchedFromRemote : .successUsingPreFetchedData
- completionHandler(status, nil)
- }
- }
- }
- } else if let completionHandler {
- DispatchQueue.main.async {
- let status: RemoteConfigFetchAndActivateStatus = fetchStatus == .success ?
- .successFetchedFromRemote : .error
- completionHandler(status, error)
- }
- }
- }
- }
- // MARK: - Activate
- /// Applies Fetched Config data to the Active Config, causing updates to the behavior and
- /// appearance of the app to take effect (depending on how config data is used in the app).
- /// - Returns A Bool indicating whether or not a change occurred.
- @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
- @discardableResult
- public func activate() async throws -> Bool {
- return try await withUnsafeThrowingContinuation { continuation in
- self.activate { updated, error in
- if let error {
- continuation.resume(throwing: error)
- } else {
- continuation.resume(returning: updated)
- }
- }
- }
- }
- /// Applies Fetched Config data to the Active Config, causing updates to the behavior and
- /// appearance of the app to take effect (depending on how config data is used in the app).
- /// - Parameter completion: Activate operation callback with changed and error parameters.
- @objc public func activate(completion: (@Sendable (Bool, Error?) -> Void)? = nil) {
- queue.async { [weak self] in
- guard let self else {
- let error = NSError(
- domain: ConfigConstants.remoteConfigErrorDomain,
- code: RemoteConfigError.internalError.rawValue,
- userInfo: ["ActivationFailureReason": "Internal Error."]
- )
- if let completion {
- DispatchQueue.main.async {
- completion(false, error)
- }
- }
- RCLog.error("I-RCN000068", "Internal error activating config.")
- return
- }
- // Check if the last fetched config has already been activated. Fetches with no data change
- // are ignored.
- if self.settings.lastETagUpdateTime == 0 ||
- self.settings.lastETagUpdateTime <= self.settings.lastApplyTimeInterval {
- RCLog.debug("I-RCN000069", "Most recently fetched config is already activated.")
- if let completion {
- DispatchQueue.main.async {
- completion(false, nil)
- }
- }
- return
- }
- self.configContent.copy(fromDictionary: self.configContent.fetchedConfig(),
- toSource: .active, forNamespace: self.FIRNamespace)
- self.settings.lastApplyTimeInterval = Date().timeIntervalSince1970
- // New config has been activated at this point
- RCLog.debug("I-RCN000069", "Config activated.")
- self.configContent.activatePersonalization()
- // Update last active template version number in setting and userDefaults.
- self.settings.updateLastActiveTemplateVersion()
- // Update activeRolloutMetadata
- self.configContent.activateRolloutMetadata { success in
- if success {
- self.notifyRolloutsStateChange(self.configContent.activeRolloutMetadata(),
- versionNumber: self.settings.lastActiveTemplateVersion)
- }
- }
- // Update experiments only for 3p namespace
- let namespace = self.FIRNamespace.split(separator: ":").first.map(String.init)
- if namespace == NamespaceGoogleMobilePlatform {
- DispatchQueue.main.async {
- self.notifyConfigHasActivated()
- }
- self.configExperiment.updateExperiments { _ in
- DispatchQueue.main.async {
- completion?(true, nil)
- }
- }
- } else {
- DispatchQueue.main.async {
- completion?(true, nil)
- }
- }
- }
- }
- private func notifyConfigHasActivated() {
- guard !appName.isEmpty else { return }
- // The Remote Config Swift SDK will be listening for this notification so it can tell SwiftUI
- // to update the UI.
- NotificationCenter.default.post(
- name: remoteConfigActivateNotification, object: self,
- userInfo: ["FIRAppNameKey": appName]
- )
- }
- // MARK: - Helpers
- private func fullyQualifiedNamespace(_ namespace: String) -> String {
- if namespace.contains(":") { return namespace } // Already fully qualified
- return "\(namespace):\(appName)"
- }
- private func defaultValue(forFullyQualifiedNamespace namespace: String, key: String)
- -> RemoteConfigValue {
- if let value = configContent.defaultConfig()[namespace]?[key] {
- return value
- }
- return RemoteConfigValue(data: Data(), source: .static)
- }
- // MARK: Get Config Result
- /// Gets the config value.
- /// - Parameter key: Config key.
- @objc public func configValue(forKey key: String) -> RemoteConfigValue {
- guard !key.isEmpty else {
- return RemoteConfigValue(data: Data(), source: .static)
- }
- let fullyQualifiedNamespace = fullyQualifiedNamespace(FIRNamespace)
- return queue.sync {
- guard let value = configContent.activeConfig()[fullyQualifiedNamespace]?[key] else {
- return defaultValue(forFullyQualifiedNamespace: fullyQualifiedNamespace, key: key)
- }
- if value.source != .remote {
- RCLog.error("I-RCN000001",
- "Key \(key) should come from source: \(RemoteConfigSource.remote.rawValue)" +
- "instead coming from source: \(value.source.rawValue)")
- }
- if let config = configContent.getConfigAndMetadata(forNamespace: fullyQualifiedNamespace)
- as? [String: RemoteConfigValue] {
- callListeners(key: key, config: config)
- }
- return value
- }
- }
- /// Gets the config value of a given source from the default namespace.
- /// - Parameter key: Config key.
- /// - Parameter source: Config value source.
- @objc public func configValue(forKey key: String, source: RemoteConfigSource) ->
- RemoteConfigValue {
- guard !key.isEmpty else {
- return RemoteConfigValue(data: Data(), source: .static)
- }
- let fullyQualifiedNamespace = self.fullyQualifiedNamespace(FIRNamespace)
- return queue.sync {
- let remoteConfigValue = switch source {
- case .remote:
- configContent.activeConfig()[fullyQualifiedNamespace]?[key]
- case .default:
- configContent.defaultConfig()[fullyQualifiedNamespace]?[key]
- case .static:
- RemoteConfigValue(data: Data(), source: .static)
- }
- return remoteConfigValue ?? RemoteConfigValue(data: Data(), source: source)
- }
- }
- @objc(allKeysFromSource:)
- public func allKeys(from source: RemoteConfigSource) -> [String] {
- queue.sync {
- let fullyQualifiedNamespace = self.fullyQualifiedNamespace(FIRNamespace)
- switch source {
- case .default:
- if let values = configContent.defaultConfig()[fullyQualifiedNamespace] {
- return Array(values.keys)
- }
- case .remote:
- if let values = configContent.activeConfig()[fullyQualifiedNamespace] {
- return Array(values.keys)
- }
- case .static: break
- }
- return []
- }
- }
- @objc public func keys(withPrefix prefix: String?) -> Set<String> {
- queue.sync {
- let fullyQualifiedNamespace = self.fullyQualifiedNamespace(FIRNamespace)
- if let config = configContent.activeConfig()[fullyQualifiedNamespace] {
- if let prefix = prefix, !prefix.isEmpty {
- return Set(config.keys.filter { $0.hasPrefix(prefix) })
- } else {
- return Set(config.keys)
- }
- }
- return Set<String>()
- }
- }
- public func countByEnumerating(with state: UnsafeMutablePointer<NSFastEnumerationState>,
- objects buffer: AutoreleasingUnsafeMutablePointer<AnyObject?>,
- count len: Int) -> Int {
- queue.sync {
- let fullyQualifiedNamespace = self.fullyQualifiedNamespace(FIRNamespace)
- if let config = configContent.activeConfig()[fullyQualifiedNamespace] as? NSDictionary {
- return config.countByEnumerating(with: state, objects: buffer, count: len)
- }
- return 0
- }
- }
- // MARK: - Defaults
- /// Sets config defaults for parameter keys and values in the default namespace config.
- /// - Parameter defaults: A dictionary mapping a NSString * key to a NSObject * value.
- @objc public func setDefaults(_ defaults: [String: Any]?) {
- let defaults = defaults ?? [String: Any]()
- let fullyQualifiedNamespace = self.fullyQualifiedNamespace(FIRNamespace)
- queue.async { [weak self] in
- guard let self else { return }
- self.configContent.copy(fromDictionary: [fullyQualifiedNamespace: defaults],
- toSource: .default,
- forNamespace: fullyQualifiedNamespace)
- self.settings.lastSetDefaultsTimeInterval = Date().timeIntervalSince1970
- }
- }
- /// Sets default configs from plist for default namespace.
- ///
- /// - Parameter fileName: The plist file name, with no file name extension. For example, if the
- /// plist file is named `defaultSamples.plist`:
- /// `RemoteConfig.remoteConfig().setDefaults(fromPlist: "defaultSamples")`
- @objc(setDefaultsFromPlistFileName:)
- public func setDefaults(fromPlist fileName: String?) {
- guard let fileName, !fileName.isEmpty else {
- RCLog.warning("I-RCN000037",
- "The plist file name cannot be nil or empty.")
- return
- }
- for bundle in [Bundle.main, Bundle(for: type(of: self))] {
- if let path = bundle.path(forResource: fileName, ofType: "plist"),
- let config = NSDictionary(contentsOfFile: path) as? [String: Any] {
- setDefaults(config)
- return
- }
- }
- RCLog.warning("I-RCN000037",
- "The plist file '\(fileName)' could not be found by Remote Config.")
- }
- /// Returns the default value of a given key from the default config.
- ///
- /// - Parameter key: The parameter key of default config.
- /// - Returns The default value of the specified key if the key exists; otherwise, nil.
- @objc public func defaultValue(forKey key: String) -> RemoteConfigValue? {
- queue.sync {
- let fullyQualifiedNamespace = self.fullyQualifiedNamespace(FIRNamespace)
- var value: RemoteConfigValue?
- if let config = configContent.defaultConfig()[fullyQualifiedNamespace] {
- value = config[key]
- if let value, value.source != .default {
- RCLog.error("I-RCN000002",
- "Key \(key) should come from source: \(RemoteConfigSource.default.rawValue)" +
- "instead coming from source: \(value.source.rawValue)")
- }
- }
- return value
- }
- }
- // MARK: Realtime
- /// Start listening for real-time config updates from the Remote Config backend and
- /// automatically fetch updates when they're available.
- ///
- /// If a connection to the Remote Config backend is not already open, calling this method will
- /// open it. Multiple listeners can be added by calling this method again, but subsequent calls
- /// re-use the same connection to the backend.
- ///
- /// Note: Real-time Remote Config requires the Firebase Remote Config Realtime API. See Get
- /// started with Firebase Remote Config at
- /// https://firebase.google.com/docs/remote-config/get-started
- /// for more information.
- ///
- /// - Parameter listener: The configured listener that is called for every config
- /// update.
- /// - Returns A registration representing the listener. The registration
- /// contains a remove method, which can be used to stop receiving updates for the provided
- /// listener.
- @discardableResult
- @objc(addOnConfigUpdateListener:)
- public func addOnConfigUpdateListener(remoteConfigUpdateCompletion listener: @Sendable @escaping (RemoteConfigUpdate?,
- Error?)
- -> Void)
- -> ConfigUpdateListenerRegistration {
- return configRealtime.addConfigUpdateListener(listener)
- }
- // MARK: Rollout
- @objc public func addRemoteConfigInteropSubscriber(_ subscriber: RolloutsStateSubscriber) {
- NotificationCenter.default.addObserver(
- forName: .rolloutsStateDidChange, object: self, queue: nil
- ) { notification in
- if let rolloutsState =
- notification.userInfo?[Notification.Name.rolloutsStateDidChange.rawValue]
- as? RolloutsState {
- subscriber.rolloutsStateDidChange(rolloutsState)
- }
- }
- // Send active rollout metadata stored in persistence while app launched if there is
- // an activeConfig
- let fullyQualifiedNamespace = fullyQualifiedNamespace(FIRNamespace)
- if let activeConfig = configContent.activeConfig()[fullyQualifiedNamespace],
- activeConfig.isEmpty == false {
- notifyRolloutsStateChange(configContent.activeRolloutMetadata(),
- versionNumber: settings.lastActiveTemplateVersion)
- }
- }
- private func notifyRolloutsStateChange(_ rolloutMetadata: [[String: Any]],
- versionNumber: String) {
- let rolloutsAssignments =
- rolloutsAssignments(with: rolloutMetadata, versionNumber: versionNumber)
- let rolloutsState = RolloutsState(assignmentList: rolloutsAssignments)
- RCLog.debug("I-RCN000069",
- "Send rollouts state notification with name " +
- "\(Notification.Name.rolloutsStateDidChange.rawValue) to RemoteConfigInterop.")
- NotificationCenter.default.post(
- name: .rolloutsStateDidChange,
- object: self,
- userInfo: [Notification.Name.rolloutsStateDidChange.rawValue: rolloutsState]
- )
- }
- private func rolloutsAssignments(with rolloutMetadata: [[String: Any]], versionNumber: String)
- -> [RolloutAssignment] {
- var rolloutsAssignments = [RolloutAssignment]()
- let fullyQualifiedNamespace = fullyQualifiedNamespace(FIRNamespace)
- for metadata in rolloutMetadata {
- if let rolloutID = metadata[ConfigConstants.fetchResponseKeyRolloutID] as? String,
- let variantID = metadata[ConfigConstants.fetchResponseKeyVariantID] as? String,
- let affectedParameterKeys =
- metadata[ConfigConstants.fetchResponseKeyAffectedParameterKeys] as? [String] {
- for key in affectedParameterKeys {
- let value = configContent.activeConfig()[fullyQualifiedNamespace]?[key] ??
- defaultValue(forFullyQualifiedNamespace: fullyQualifiedNamespace, key: key)
- let assignment = RolloutAssignment(
- rolloutId: rolloutID,
- variantId: variantID,
- templateVersion: Int64(versionNumber) ?? 0,
- parameterKey: key,
- parameterValue: value.stringValue
- )
- rolloutsAssignments.append(assignment)
- }
- }
- }
- return rolloutsAssignments
- }
- let customSignalsMaxKeyLength = 250
- let customSignalsMaxStringValueLength = 500
- let customSignalsMaxCount = 100
- // MARK: - Custom Signals
- /// Sets custom signals for this Remote Config instance.
- /// - Parameter customSignals: A dictionary mapping string keys to custom
- /// signals to be set for the app instance.
- ///
- /// When a new key is provided, a new key-value pair is added to the custom signals.
- /// If an existing key is provided with a new value, the corresponding signal is updated.
- /// If the value for a key is `nil`, the signal associated with that key is removed.
- @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
- public
- func setCustomSignals(_ customSignals: [String: CustomSignalValue?]) async throws {
- return try await withUnsafeThrowingContinuation { continuation in
- let customSignals = customSignals.mapValues { $0?.toNSObject() ?? NSNull() }
- self.setCustomSignalsImpl(customSignals) { error in
- if let error {
- continuation.resume(throwing: error)
- } else {
- continuation.resume()
- }
- }
- }
- }
- @available(swift 1000.0) // Objective-C only API
- @objc(setCustomSignals:withCompletion:) public func __setCustomSignals(_ customSignals: [
- String: Any
- ]?,
- withCompletion completionHandler: (
- @Sendable (Error?) -> Void
- )?) {
- setCustomSignalsImpl(customSignals, withCompletion: completionHandler)
- }
- private func setCustomSignalsImpl(_ customSignals: [String: Any]?,
- withCompletion completionHandler: (
- @Sendable (Error?) -> Void
- )?) {
- queue.async { [weak self] in
- guard let self else { return }
- guard let customSignals = customSignals else {
- if let completionHandler {
- DispatchQueue.main.async {
- completionHandler(nil)
- }
- }
- return
- }
- // Validate value type, and key and value length
- for (key, value) in customSignals {
- if !(value is NSNull || value is NSString || value is NSNumber) {
- let error = NSError(
- domain: ConfigConstants.remoteConfigCustomSignalsErrorDomain,
- code: RemoteConfigCustomSignalsError.invalidValueType.rawValue,
- userInfo: [
- NSLocalizedDescriptionKey: "Invalid value type. Must be NSString, NSNumber, or NSNull.",
- ]
- )
- if let completionHandler {
- DispatchQueue.main.async {
- completionHandler(error)
- }
- }
- return
- }
- if key.count > customSignalsMaxKeyLength ||
- (value is NSString && (value as! NSString).length > customSignalsMaxStringValueLength) {
- if let completionHandler {
- let error = NSError(
- domain: ConfigConstants.remoteConfigCustomSignalsErrorDomain,
- code: RemoteConfigCustomSignalsError.limitExceeded.rawValue,
- userInfo: [
- NSLocalizedDescriptionKey:
- "Custom signal keys and string values must be " +
- "\(customSignalsMaxKeyLength) and " +
- "\(customSignalsMaxStringValueLength) " +
- "characters or less respectively.",
- ]
- )
- DispatchQueue.main.async {
- completionHandler(error)
- }
- }
- return
- }
- }
- // Merge new signals with existing ones, overwriting existing keys.
- // Also, remove entries where the new value is null.
- var newCustomSignals = self.settings.customSignals
- for (key, value) in customSignals {
- if !(value is NSNull) {
- let stringValue = value is NSNumber ? (value as! NSNumber).stringValue : value as! String
- newCustomSignals[key] = stringValue
- } else {
- newCustomSignals.removeValue(forKey: key)
- }
- }
- // Check the size limit.
- if newCustomSignals.count > customSignalsMaxCount {
- if let completionHandler {
- let error = NSError(
- domain: ConfigConstants.remoteConfigCustomSignalsErrorDomain,
- code: RemoteConfigCustomSignalsError.limitExceeded.rawValue,
- userInfo: [
- NSLocalizedDescriptionKey:
- "Custom signals count exceeds the limit of \(customSignalsMaxCount).",
- ]
- )
- DispatchQueue.main.async {
- completionHandler(error)
- }
- }
- return
- }
- // Update only if there are changes.
- if newCustomSignals != self.settings.customSignals {
- self.settings.customSignals = newCustomSignals
- }
- // Log the keys of the updated custom signals using RCLog.debug
- RCLog.debug("I-RCN000078",
- "Keys of updated custom signals: \(newCustomSignals.keys.sorted())")
- DispatchQueue.main.async {
- completionHandler?(nil)
- }
- }
- }
- }
- // MARK: - Rollout Notification
- extension Notification.Name {
- static let rolloutsStateDidChange = Notification.Name(rawValue:
- "FIRRolloutsStateDidChangeNotification")
- }
|