| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674 |
- // 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 FirebaseCore
- import FirebaseInstallations
- import Foundation
- @_implementationOnly import GoogleUtilities
- #if canImport(UIKit) // iOS/tvOS/watchOS
- import UIKit
- #endif
- #if canImport(AppKit) // macOS
- import AppKit
- #endif
- // URL params
- private let serverURLDomain = "firebaseremoteconfigrealtime.googleapis.com"
- // Realtime API enablement
- private let serverForbiddenStatusCode = "\"code\": 403"
- // Header names
- private let httpMethodPost = "POST"
- private let contentTypeHeaderName = "Content-Type"
- private let contentEncodingHeaderName = "Content-Encoding"
- private let acceptEncodingHeaderName = "Accept"
- private let etagHeaderName = "etag"
- private let ifNoneMatchETagHeaderName = "if-none-match"
- private let installationsAuthTokenHeaderName = "x-goog-firebase-installations-auth"
- // Sends the bundle ID. Refer to b/130301479 for details.
- private let iOSBundleIdentifierHeaderName = "X-Ios-Bundle-Identifier"
- // Retryable HTTP status code.
- private let fetchResponseHTTPStatusOK = 200
- private let fetchResponseHTTPStatusTooManyRequests = 429
- private let fetchResponseHTTPStatusCodeBadGateway = 502
- private let fetchResponseHTTPStatusCodeServiceUnavailable = 503
- private let fetchResponseHTTPStatusCodeGatewayTimeout = 504
- // Invalidation message field names.
- private let templateVersionNumberKey = "latestTemplateVersionNumber"
- private let featureDisabledKey = "featureDisabled"
- private let timeoutSeconds: TimeInterval = 330
- private let fetchAttempts = 3
- private let applicationJSON = "application/json"
- private let gzip = "gzip"
- private let canRetry = "X-Google-GFE-Can-Retry"
- // Retry parameters
- private let maxRetries = 7
- /// Listener registration returned by `addOnConfigUpdateListener`. Calling its method `remove` stops
- /// the associated listener from receiving config updates and unregisters itself.
- ///
- /// If `remove` is called and no other listener registrations remain, the connection to the
- /// real-time connection.
- @objc(FIRConfigUpdateListenerRegistration) public
- final class ConfigUpdateListenerRegistration: NSObject, Sendable {
- let completionHandler: @Sendable (RemoteConfigUpdate?, Error?) -> Void
- private let realtimeClient: ConfigRealtime?
- @objc public
- init(client: ConfigRealtime,
- completionHandler: @escaping @Sendable (RemoteConfigUpdate?, Error?) -> Void) {
- realtimeClient = client
- self.completionHandler = completionHandler
- }
- @objc public
- func remove() {
- realtimeClient?.removeConfigUpdateListener(completionHandler)
- }
- }
- @objc(RCNConfigRealtime) public
- class ConfigRealtime: NSObject, URLSessionDataDelegate {
- private var listeners = NSOrderedSet()
- private let realtimeLockQueue = DispatchQueue(label: "com.google.firebase.remoteconfig.realtime")
- private let notificationCenter = NotificationCenter.default
- private let request: URLRequest
- private var session: URLSession?
- private var dataTask: URLSessionDataTask?
- private let configFetch: ConfigFetch
- private let settings: ConfigSettings
- private let options: FirebaseOptions
- private let namespace: String
- var remainingRetryCount: Int
- private var isRequestInProgress: Bool
- var isInBackground: Bool
- var isRealtimeDisabled: Bool
- public var installations: (any InstallationsProtocol)?
- @objc public
- init(configFetch: ConfigFetch,
- settings: ConfigSettings,
- namespace: String,
- options: FirebaseOptions,
- installations: InstallationsProtocol? = nil) {
- self.configFetch = configFetch
- self.settings = settings
- self.options = options
- self.namespace = namespace
- remainingRetryCount = max(maxRetries - settings.realtimeRetryCount, 1)
- isRequestInProgress = false
- isRealtimeDisabled = false
- isInBackground = false
- self.installations = if let installations {
- installations
- } else if
- let appName = namespace.components(separatedBy: ":").last,
- let app = FirebaseApp.app(name: appName) {
- Installations.installations(app: app)
- } else {
- nil as InstallationsProtocol?
- }
- request = ConfigRealtime.setupHTTPRequest(options, namespace)
- super.init()
- session = setupSession()
- backgroundChangeListener()
- }
- deinit {
- dataTask?.cancel() // Ensure the task is cancelled when the object is deallocated
- session?.invalidateAndCancel()
- }
- private static func setupHTTPRequest(_ options: FirebaseOptions,
- _ namespace: String) -> URLRequest {
- let url = Utils.constructServerURL(
- domain: serverURLDomain,
- apiKey: options.apiKey,
- optionsID: options.gcmSenderID,
- namespace: namespace
- )
- var request = URLRequest(url: url,
- cachePolicy: .reloadIgnoringLocalCacheData,
- timeoutInterval: timeoutSeconds)
- request.httpMethod = httpMethodPost
- request.setValue(applicationJSON, forHTTPHeaderField: contentTypeHeaderName)
- request.setValue(applicationJSON, forHTTPHeaderField: acceptEncodingHeaderName)
- request.setValue(gzip, forHTTPHeaderField: contentEncodingHeaderName)
- request.setValue("true", forHTTPHeaderField: canRetry)
- request.setValue(options.apiKey, forHTTPHeaderField: "X-Goog-Api-Key")
- request.setValue(
- Bundle.main.bundleIdentifier,
- forHTTPHeaderField: iOSBundleIdentifierHeaderName
- )
- return request
- }
- private func setupSession() -> URLSession {
- let config = URLSessionConfiguration.default
- config.timeoutIntervalForResource = timeoutSeconds
- config.timeoutIntervalForRequest = timeoutSeconds
- return URLSession(configuration: config, delegate: self, delegateQueue: .main)
- }
- private func propagateErrors(_ error: Error) {
- realtimeLockQueue.async { [weak self] in
- guard let self else { return }
- for listener in self.listeners {
- if let listener = listener as? (RemoteConfigUpdate?, Error?) -> Void {
- listener(nil, error)
- }
- }
- }
- }
- // TESTING ONLY
- @objc func triggerListenerForTesting(listener: @escaping (RemoteConfigUpdate?, Error?) -> Void) {
- DispatchQueue.main.async {
- listener(RemoteConfigUpdate(), nil)
- }
- }
- // MARK: - HTTP Helpers
- private func appName(fromFullyQualifiedNamespace fullyQualifiedNamespace: String) -> String {
- return String(fullyQualifiedNamespace.split(separator: ":").last ?? "")
- }
- private func reportCompletion(onHandler completionHandler: (
- (RemoteConfigFetchStatus, Error?) -> Void
- )?,
- withStatus status: RemoteConfigFetchStatus,
- withError error: Error?) {
- guard let completionHandler = completionHandler else { return }
- realtimeLockQueue.async {
- completionHandler(status, error)
- }
- }
- private func refreshInstallationsToken(completionHandler: (
- (RemoteConfigFetchStatus, Error?) -> Void
- )?) {
- guard let installations, !options.gcmSenderID.isEmpty else {
- let errorDescription = "Failed to get GCMSenderID"
- RCLog.error("I-RCN000074", errorDescription)
- settings.isFetchInProgress = false
- reportCompletion(
- onHandler: completionHandler,
- withStatus: .failure,
- withError: NSError(
- domain: ConfigConstants.remoteConfigErrorDomain,
- code: RemoteConfigError.internalError.rawValue,
- userInfo: [NSLocalizedDescriptionKey: errorDescription]
- )
- )
- return
- }
- RCLog.debug("I-RCN000039", "Starting requesting token.")
- installations.authToken { [weak self] result, error in
- guard let self else { return }
- if let error = error {
- let errorDescription = "Failed to get installations token. Error : \(error)."
- RCLog.error("I-RCN000073", errorDescription)
- self.isRequestInProgress = false
- var userInfo = [String: Any]()
- userInfo[NSLocalizedDescriptionKey] = errorDescription
- userInfo[NSUnderlyingErrorKey] = error
- self.reportCompletion(
- onHandler: completionHandler,
- withStatus: .failure,
- withError: NSError(domain: ConfigConstants.remoteConfigErrorDomain,
- code: RemoteConfigError.internalError.rawValue,
- userInfo: userInfo)
- )
- return
- }
- guard let tokenResult = result else {
- let errorDescription = "Failed to get installations token"
- RCLog.error("I-RCN000073", errorDescription)
- self.isRequestInProgress = false
- reportCompletion(onHandler: completionHandler,
- withStatus: .failure,
- withError: NSError(domain: ConfigConstants.remoteConfigErrorDomain,
- code: RemoteConfigError.internalError.rawValue,
- userInfo: [
- NSLocalizedDescriptionKey: errorDescription,
- ]))
- return
- }
- /// We have a valid token. Get the backing installationID.
- installations.installationID { [weak self] identifier, error in
- guard let self else { return }
- // Dispatch to the RC serial queue to update settings on the queue.
- self.realtimeLockQueue.async {
- /// Update config settings with the IID and token.
- self.settings.configInstallationsToken = tokenResult.authToken
- self.settings.configInstallationsIdentifier = identifier ?? ""
- if let error = error {
- let errorDescription = "Error getting iid : \(error)."
- RCLog.error("I-RCN000055", errorDescription)
- self.isRequestInProgress = false
- var userInfo = [String: Any]()
- userInfo[NSLocalizedDescriptionKey] = errorDescription
- userInfo[NSUnderlyingErrorKey] = error
- self.reportCompletion(
- onHandler: completionHandler,
- withStatus: .failure,
- withError: NSError(domain: ConfigConstants.remoteConfigErrorDomain,
- code: RemoteConfigError.internalError.rawValue,
- userInfo: userInfo)
- )
- } else if let identifier = identifier {
- RCLog.info("I-RCN000022", "Success to get iid : \(identifier)")
- self.reportCompletion(onHandler: completionHandler,
- withStatus: .noFetchYet,
- withError: nil)
- }
- }
- }
- }
- }
- @objc public
- func createRequestBody(completion: @escaping (Data) -> Void) {
- refreshInstallationsToken { status, error in
- if self.settings.configInstallationsIdentifier.isEmpty {
- RCLog.debug(
- "I-RCN000013",
- "Installation token retrieval failed. Realtime connection will not include " +
- "valid installations token."
- )
- }
- var request = self.request
- request.setValue(self.settings.configInstallationsToken,
- forHTTPHeaderField: installationsAuthTokenHeaderName)
- if let etag = self.settings.lastETag {
- request.setValue(etag, forHTTPHeaderField: ifNoneMatchETagHeaderName)
- }
- let postBody = """
- {
- project:'\(self.options.gcmSenderID)',
- namespace:'\(Utils.namespaceOnly(self.namespace))',
- lastKnownVersionNumber:'\(self.configFetch.templateVersionNumber)',
- appId:'\(self.options.googleAppID)',
- sdkVersion:'\(Device.remoteConfigPodVersion())',
- appInstanceId:'\(self.settings.configInstallationsIdentifier)'
- }
- """
- do {
- if let postData = postBody.data(using: .utf8) {
- let compressedData = try NSData.gul_data(byGzippingData: postData)
- completion(compressedData)
- } else {
- RCLog.error("I-RCN000090", "Error creating fetch body for realtime")
- completion(Data())
- }
- } catch {
- RCLog.error("I-RCN000091", "Error compressing fetch body for realtime \(error)")
- completion(Data())
- }
- }
- }
- // MARK: - Retry Helpers
- func canMakeConnection() -> Bool {
- let noRunningConnection = dataTask == nil || dataTask?.state != .running
- return noRunningConnection && listeners.count > 0 && !isInBackground && !isRealtimeDisabled
- }
- func retryHTTPConnection() {
- realtimeLockQueue.async { [weak self] in
- guard let self, !self.isInBackground else { return }
- guard self.remainingRetryCount > 0 else {
- let error = NSError(domain: ConfigConstants.remoteConfigUpdateErrorDomain,
- code: RemoteConfigUpdateError.streamError.rawValue,
- userInfo: [
- NSLocalizedDescriptionKey: "Unable to connect to the server. Check your connection and try again.",
- ])
- RCLog.error("I-RCN000014", "Cannot establish connection. Error: \(error)")
- self.propagateErrors(error)
- return
- }
- if self.canMakeConnection() {
- self.remainingRetryCount -= 1
- self.settings.realtimeRetryCount += 1
- let backoffInterval = self.settings.realtimeBackoffInterval()
- self.realtimeLockQueue.asyncAfter(deadline: .now() + backoffInterval) {
- self.beginRealtimeStream()
- }
- }
- }
- }
- private func backgroundChangeListener() {
- #if canImport(UIKit)
- NotificationCenter.default.addObserver(self,
- selector: #selector(willEnterForeground),
- name: UIApplication
- .willEnterForegroundNotification,
- object: nil)
- NotificationCenter.default.addObserver(self,
- selector: #selector(didEnterBackground),
- name: UIApplication.didEnterBackgroundNotification,
- object: nil)
- #elseif canImport(AppKit)
- NotificationCenter.default.addObserver(self,
- selector: #selector(willEnterForeground),
- name: NSApplication.willBecomeActiveNotification,
- object: nil)
- NotificationCenter.default.addObserver(self,
- selector: #selector(didEnterBackground),
- name: NSApplication.didResignActiveNotification,
- object: nil)
- #endif
- }
- @objc private func willEnterForeground() {
- realtimeLockQueue.async { [weak self] in
- guard let self else { return }
- self.isInBackground = false
- self.beginRealtimeStream()
- }
- }
- @objc private func didEnterBackground() {
- realtimeLockQueue.async { [weak self] in
- guard let self else { return }
- self.pauseRealtimeStream()
- self.isInBackground = true
- }
- }
- // MARK: - Autofetch Helpers
- @objc(fetchLatestConfig:targetVersion:) public
- func fetchLatestConfig(remainingAttempts: Int, targetVersion: Int) {
- realtimeLockQueue.async { [weak self] in
- guard let self else { return }
- let attempts = remainingAttempts - 1
- self.configFetch.realtimeFetchConfig(fetchAttemptNumber: fetchAttempts - attempts) {
- status, update, error in
- if let error = error {
- RCLog.error("I-RCN000010",
- "Failed to retrieve config due to fetch error. Error: \(error)")
- self.propagateErrors(error)
- return
- }
- if status == .success {
- if Int(self.configFetch.templateVersionNumber) ?? 0 >= targetVersion {
- // Only notify listeners if there is a change.
- if let update = update, !update.updatedKeys.isEmpty {
- self.realtimeLockQueue.async { [weak self] in
- guard let self else { return }
- for listener in self.listeners {
- if let l = listener as? (RemoteConfigUpdate?, Error?) -> Void {
- l(update, nil)
- }
- }
- }
- }
- } else {
- RCLog.debug("I-RCN000016",
- "Fetched config's template version is outdated, re-fetching")
- self.autoFetch(attempts: attempts, targetVersion: targetVersion)
- }
- } else {
- RCLog.debug("I-RCN000016",
- "Fetched config's template version is outdated, re-fetching")
- self.autoFetch(attempts: attempts, targetVersion: targetVersion)
- }
- }
- }
- }
- @objc(scheduleFetch:targetVersion:) public
- func scheduleFetch(remainingAttempts: Int, targetVersion: Int) {
- // Needs fetch to occur between 0 - 3 seconds. Randomize to not cause DDoS
- // alerts in backend.
- let delay = TimeInterval.random(in: 0 ... 3) // Random delay between 0 and 3 seconds
- realtimeLockQueue.asyncAfter(deadline: .now() + delay) {
- self.fetchLatestConfig(remainingAttempts: remainingAttempts, targetVersion: targetVersion)
- }
- }
- /// Perform fetch and handle developers callbacks.
- @objc(autoFetch:targetVersion:) public
- func autoFetch(attempts: Int, targetVersion: Int) {
- realtimeLockQueue.async { [weak self] in
- guard let self else { return }
- guard attempts > 0 else {
- let error = NSError(domain: ConfigConstants.remoteConfigUpdateErrorDomain,
- code: RemoteConfigUpdateError.notFetched.rawValue,
- userInfo: [
- NSLocalizedDescriptionKey: "Unable to fetch the latest version of the template.",
- ])
- RCLog.error("I-RCN000011", "Ran out of fetch attempts, cannot find target config version.")
- self.propagateErrors(error)
- return
- }
- self.scheduleFetch(remainingAttempts: attempts, targetVersion: targetVersion)
- }
- }
- // MARK: - URLSessionDataDelegate
- /// Delegate to asynchronously handle every new notification that comes over
- /// the wire. Auto-fetches and runs callback for each new notification.
- public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask,
- didReceive data: Data) {
- let strData = String(data: data, encoding: .utf8) ?? ""
- // If response data contains the API enablement link, return the entire
- // message to the user in the form of a error.
- if strData.contains(serverForbiddenStatusCode) {
- let error = NSError(domain: ConfigConstants.remoteConfigUpdateErrorDomain,
- code: RemoteConfigUpdateError.streamError.rawValue,
- userInfo: [NSLocalizedDescriptionKey: strData])
- RCLog.error("I-RCN000021", "Cannot establish connection. \(error)")
- propagateErrors(error)
- return
- }
- if let beginRange = strData.range(of: "{"),
- let endRange = strData.range(of: "}") {
- RCLog.debug("I-RCN000015", "Received config update message on stream.")
- let msgRange = Range(uncheckedBounds: (lower: beginRange.lowerBound,
- upper: strData.index(after: endRange.upperBound)))
- let jsonData = String(strData[msgRange]).data(using: .utf8)!
- do {
- if let response = try JSONSerialization.jsonObject(with: jsonData,
- options: []) as? [String: Any] {
- evaluateStreamResponse(response)
- }
- } catch {
- let wrappedError =
- NSError(domain: ConfigConstants.remoteConfigUpdateErrorDomain,
- code: RemoteConfigUpdateError.messageInvalid.rawValue,
- userInfo: [
- NSLocalizedDescriptionKey: "Unable to parse ConfigUpdate. \(strData)",
- NSUnderlyingErrorKey: error,
- ])
- propagateErrors(wrappedError)
- return
- }
- }
- }
- @objc public
- func evaluateStreamResponse(_ response: [String: Any]) {
- var updateTemplateVersion = 1
- if let version = response[templateVersionNumberKey] as? Int {
- updateTemplateVersion = version
- }
- if let isDisabled = response[featureDisabledKey] as? Bool {
- isRealtimeDisabled = isDisabled
- }
- if isRealtimeDisabled {
- pauseRealtimeStream()
- let error =
- NSError(domain: ConfigConstants.remoteConfigUpdateErrorDomain,
- code: RemoteConfigUpdateError.unavailable.rawValue,
- userInfo: [
- NSLocalizedDescriptionKey: "The server is temporarily unavailable. Try again in a few minutes.",
- ])
- propagateErrors(error)
- } else {
- let clientTemplateVersion = Int(configFetch.templateVersionNumber) ?? 0
- if updateTemplateVersion > clientTemplateVersion {
- autoFetch(attempts: fetchAttempts, targetVersion: updateTemplateVersion)
- }
- }
- }
- func isStatusCodeRetryable(_ statusCode: Int) -> Bool {
- return statusCode == fetchResponseHTTPStatusTooManyRequests ||
- statusCode == fetchResponseHTTPStatusCodeServiceUnavailable ||
- statusCode == fetchResponseHTTPStatusCodeBadGateway ||
- statusCode == fetchResponseHTTPStatusCodeGatewayTimeout
- }
- /// Delegate to handle initial reply from the server.
- public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask,
- didReceive response: URLResponse,
- completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
- isRequestInProgress = false
- if let httpResponse = response as? HTTPURLResponse {
- let statusCode = httpResponse.statusCode
- if statusCode == 403 {
- completionHandler(.allow)
- return
- }
- if statusCode != fetchResponseHTTPStatusOK {
- settings.updateRealtimeExponentialBackoffTime()
- pauseRealtimeStream()
- if isStatusCodeRetryable(statusCode) {
- retryHTTPConnection()
- } else {
- let error = NSError(
- domain: ConfigConstants.remoteConfigUpdateErrorDomain,
- code: RemoteConfigUpdateError.streamError.rawValue,
- userInfo: [
- NSLocalizedDescriptionKey:
- "Unable to connect to the server. Try again in a few minutes. HTTP Status code: \(statusCode)",
- ]
- )
- RCLog.error("I-RCN000021", "Cannot establish connection. Error: \(error)")
- propagateErrors(error)
- }
- } else {
- // On success, reset retry parameters.
- remainingRetryCount = maxRetries
- settings.realtimeRetryCount = 0
- }
- completionHandler(.allow)
- }
- }
- /// Delegate to handle data task completion.
- public func urlSession(_ session: URLSession, task: URLSessionTask,
- didCompleteWithError error: Error?) {
- if !session.isEqual(self.session) {
- return
- }
- isRequestInProgress = false
- if let error = error, error._code != NSURLErrorCancelled {
- settings.updateRealtimeExponentialBackoffTime()
- }
- pauseRealtimeStream()
- retryHTTPConnection()
- }
- /// Delegate to handle session invalidation.
- public func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) {
- if !isRequestInProgress {
- if let _ = error {
- settings.updateRealtimeExponentialBackoffTime()
- }
- pauseRealtimeStream()
- retryHTTPConnection()
- }
- }
- // MARK: - Top Level Methods
- @objc public
- func beginRealtimeStream() {
- realtimeLockQueue.async { [weak self] in
- guard let self else { return }
- guard self.settings.realtimeBackoffInterval() <= 0.0 else {
- self.retryHTTPConnection()
- return
- }
- if self.canMakeConnection() {
- self.createRequestBody { [weak self] requestBody in
- guard let self else { return }
- var request = self.request
- request.httpBody = requestBody
- self.isRequestInProgress = true
- self.dataTask = self.session?.dataTask(with: request)
- self.dataTask?.resume()
- }
- }
- }
- }
- @objc public
- func pauseRealtimeStream() {
- realtimeLockQueue.async { [weak self] in
- guard let self else { return }
- if let task = self.dataTask {
- task.cancel()
- self.dataTask = nil
- }
- }
- }
- @discardableResult
- @objc public func addConfigUpdateListener(_ listener: @Sendable @escaping (RemoteConfigUpdate?,
- Error?) -> Void)
- -> ConfigUpdateListenerRegistration {
- realtimeLockQueue.async { [weak self] in
- guard let self else { return }
- let temp = self.listeners.mutableCopy() as! NSMutableOrderedSet
- temp.add(listener)
- self.listeners = temp
- self.beginRealtimeStream()
- }
- return ConfigUpdateListenerRegistration(client: self, completionHandler: listener)
- }
- @objc public func removeConfigUpdateListener(_ listener: @escaping (RemoteConfigUpdate?, Error?)
- -> Void) {
- realtimeLockQueue.async { [weak self] in
- guard let self else { return }
- let temp: NSMutableOrderedSet = self.listeners.mutableCopy() as! NSMutableOrderedSet
- temp.remove(listener)
- self.listeners = temp
- if self.listeners.count == 0 {
- self.pauseRealtimeStream()
- }
- }
- }
- }
|