diff --git a/FirebaseRemoteConfig/Swift/CustomSignals.swift b/FirebaseRemoteConfig/Swift/CustomSignals.swift index 669d2afc775..9e0f4fe9eb7 100644 --- a/FirebaseRemoteConfig/Swift/CustomSignals.swift +++ b/FirebaseRemoteConfig/Swift/CustomSignals.swift @@ -53,7 +53,7 @@ public struct CustomSignalValue { Self(kind: .double(double)) } - fileprivate func toNSObject() -> NSObject { + func toNSObject() -> NSObject { switch kind { case let .string(string): return string as NSString @@ -82,26 +82,3 @@ extension CustomSignalValue: ExpressibleByFloatLiteral { self = .double(value) } } - -@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) -public extension RemoteConfig { - /// 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. - func setCustomSignals(_ customSignals: [String: CustomSignalValue?]) async throws { - return try await withCheckedThrowingContinuation { continuation in - let customSignals = customSignals.mapValues { $0?.toNSObject() ?? NSNull() } - self.__setCustomSignals(customSignals) { error in - if let error { - continuation.resume(throwing: error) - } else { - continuation.resume() - } - } - } - } -} diff --git a/FirebaseRemoteConfig/SwiftNew/ConfigConstants.swift b/FirebaseRemoteConfig/SwiftNew/ConfigConstants.swift index a9330da9f71..49e56426696 100644 --- a/FirebaseRemoteConfig/SwiftNew/ConfigConstants.swift +++ b/FirebaseRemoteConfig/SwiftNew/ConfigConstants.swift @@ -25,8 +25,11 @@ enum ConfigConstants { /// Remote Config Error Domain. static let remoteConfigErrorDomain = "com.google.remoteconfig.ErrorDomain" - // Remote Config Realtime Error Domain + /// Remote Config Realtime Error Domain static let remoteConfigUpdateErrorDomain = "com.google.remoteconfig.update.ErrorDomain" + /// Error domain for custom signals errors. + static let remoteConfigCustomSignalsErrorDomain = + "com.google.remoteconfig.customsignals.ErrorDomain" // MARK: - Fetch Response Keys diff --git a/FirebaseRemoteConfig/SwiftNew/ConfigSettings.swift b/FirebaseRemoteConfig/SwiftNew/ConfigSettings.swift index 4f2e5cc830b..ab1f9006ede 100644 --- a/FirebaseRemoteConfig/SwiftNew/ConfigSettings.swift +++ b/FirebaseRemoteConfig/SwiftNew/ConfigSettings.swift @@ -458,6 +458,13 @@ let RCNHTTPDefaultConnectionTimeout: TimeInterval = 60 // Ignore JSON serialization error. } } + if customSignals.count > 0, + let jsonData = try? JSONSerialization.data(withJSONObject: customSignals), + let jsonString = String(data: jsonData, encoding: .utf8) { + request += ", custom_signals:\(jsonString)" + // Log the keys of the custom signals sent during fetch. + RCLog.debug("I-RCN000078", "Keys of custom signals during fetch: \(customSignals.keys)") + } } request += "}" return request @@ -524,6 +531,14 @@ let RCNHTTPDefaultConnectionTimeout: TimeInterval = 60 } } + /// A dictionary to hold custom signals set by the developer. + @objc public var customSignals: [String: String] { + get { _userDefaultsManager.customSignals } + set { + _userDefaultsManager.customSignals = newValue + } + } + // MARK: - Throttling /// Returns true if the last fetch is outside the minimum fetch interval supplied. diff --git a/FirebaseRemoteConfig/SwiftNew/RemoteConfig.swift b/FirebaseRemoteConfig/SwiftNew/RemoteConfig.swift index df3c7e924cc..4c3f059af95 100644 --- a/FirebaseRemoteConfig/SwiftNew/RemoteConfig.swift +++ b/FirebaseRemoteConfig/SwiftNew/RemoteConfig.swift @@ -65,7 +65,7 @@ public class RemoteConfigSettings: NSObject, NSCopying { /// Indicates whether updated data was successfully fetched. @objc(FIRRemoteConfigFetchStatus) -public enum RemoteConfigFetchStatus: Int { +public enum RemoteConfigFetchStatus: Int, Sendable { /// Config has never been fetched. case noFetchYet /// Config fetch succeeded. @@ -134,6 +134,17 @@ public enum RemoteConfigUpdateError: Int, LocalizedError, CustomNSError { } } +/// 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 @@ -468,7 +479,10 @@ open class RemoteConfig: NSObject, NSFastEnumeration { /// and avoid calling this method again. /// /// - Parameter completionHandler Fetch operation callback with status and error parameters. - @objc public func fetch(completionHandler: ((RemoteConfigFetchStatus, Error?) -> Void)? = nil) { + @objc public func fetch(completionHandler: ( + @Sendable (RemoteConfigFetchStatus, Error?) -> Void + )? = + nil) { queue.async { self.fetch(withExpirationDuration: self.settings.minimumFetchInterval, completionHandler: completionHandler) @@ -515,7 +529,10 @@ open class RemoteConfig: NSObject, NSFastEnumeration { /// To stop the periodic sync, call `Installations.delete(completion:)` /// and avoid calling this method again. @objc public func fetch(withExpirationDuration expirationDuration: TimeInterval, - completionHandler: ((RemoteConfigFetchStatus, Error?) -> Void)? = nil) { + completionHandler: ( + @Sendable (RemoteConfigFetchStatus, Error?) -> Void + )? = + nil) { configFetch.fetchConfig(withExpirationDuration: expirationDuration, completionHandler: completionHandler) } @@ -554,8 +571,7 @@ open class RemoteConfig: NSObject, NSFastEnumeration { /// /// - Parameter completionHandler Fetch operation callback with status and error parameters. @objc public func fetchAndActivate(completionHandler: - ((RemoteConfigFetchAndActivateStatus, Error?) -> Void)? = - nil) { + (@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. @@ -602,7 +618,7 @@ open class RemoteConfig: NSObject, NSFastEnumeration { /// 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: ((Bool, Error?) -> Void)? = nil) { + @objc public func activate(completion: (@Sendable (Bool, Error?) -> Void)? = nil) { queue.async { [weak self] in guard let self else { let error = NSError( @@ -882,8 +898,9 @@ open class RemoteConfig: NSObject, NSFastEnumeration { /// 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?) + @objc(addOnConfigUpdateListener:) + public func addOnConfigUpdateListener(remoteConfigUpdateCompletion listener: @Sendable @escaping (RemoteConfigUpdate?, + Error?) -> Void) -> ConfigUpdateListenerRegistration { return configRealtime.addConfigUpdateListener(listener) @@ -951,6 +968,145 @@ open class RemoteConfig: NSObject, NSFastEnumeration { } 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 diff --git a/FirebaseRemoteConfig/SwiftNew/UserDefaultsManager.swift b/FirebaseRemoteConfig/SwiftNew/UserDefaultsManager.swift index 7bd1af720f3..ad2e5ddf133 100644 --- a/FirebaseRemoteConfig/SwiftNew/UserDefaultsManager.swift +++ b/FirebaseRemoteConfig/SwiftNew/UserDefaultsManager.swift @@ -49,6 +49,7 @@ public class UserDefaultsManager: NSObject { let kRCNUserDefaultsKeyNameCurrentRealtimeThrottlingRetryInterval = "currentRealtimeThrottlingRetryInterval" let kRCNUserDefaultsKeyNameRealtimeRetryCount = "realtimeRetryCount" + let kRCNUserDefaultsKeyCustomSignals = "customSignals" // Delete when ObjC tests are gone. @objc public convenience init(appName: String, bundleID: String, namespace: String) { @@ -111,6 +112,13 @@ public class UserDefaultsManager: NSObject { return "\(kRCNGroupPrefix).\(bundleIdentifier).\(kRCNGroupSuffix)" } + @objc public var customSignals: [String: String] { + get { instanceUserDefaults[kRCNUserDefaultsKeyCustomSignals] as? [String: String] ?? [:] } + set { + setInstanceUserDefaultsValue(newValue, forKey: kRCNUserDefaultsKeyCustomSignals) + } + } + /// The last ETag received from the server. @objc public var lastETag: String? { get { instanceUserDefaults[kRCNUserDefaultsKeyNamelastETag] as? String } diff --git a/FirebaseRemoteConfig/Tests/Unit/RCNRemoteConfigTest.m b/FirebaseRemoteConfig/Tests/Unit/RCNRemoteConfigTest.m index b490b1c8fb7..e4774d7f99e 100644 --- a/FirebaseRemoteConfig/Tests/Unit/RCNRemoteConfigTest.m +++ b/FirebaseRemoteConfig/Tests/Unit/RCNRemoteConfigTest.m @@ -1949,7 +1949,7 @@ - (void)testSetCustomSignalsMultipleTimes { [_configInstances[i] setCustomSignals:testSignals1 withCompletion:^(NSError *_Nullable error) { XCTAssertNil(error); - [_configInstances[i] + [self->_configInstances[i] setCustomSignals:testSignals2 withCompletion:^(NSError *_Nullable error) { XCTAssertNil(error);