Explorar o código

Add custom signals support in Remote Config. (#13976)

Co-authored-by: Nick Cooke <nickcooke@google.com>
Tushar Khandelwal hai 1 ano
pai
achega
15179e4b8d

+ 1 - 0
FirebaseRemoteConfig/CHANGELOG.md

@@ -1,5 +1,6 @@
 # Unreleased
 - [fixed] Mark ConfigUpdateListenerRegistration Sendable. (#14215)
+- [feature] Added support for custom signal targeting in Remote Config. Use `setCustomSignals` API for setting custom signals and use them to build custom targeting conditions in Remote Config. (#13976)
 
 # 11.5.0
 - [fixed] Mark two internal properties as `atomic` to prevent concurrency

+ 106 - 0
FirebaseRemoteConfig/Sources/FIRRemoteConfig.m

@@ -34,6 +34,9 @@
 /// Remote Config Error Domain.
 /// TODO: Rename according to obj-c style for constants.
 NSString *const FIRRemoteConfigErrorDomain = @"com.google.remoteconfig.ErrorDomain";
+// Remote Config Custom Signals Error Domain
+NSString *const FIRRemoteConfigCustomSignalsErrorDomain =
+    @"com.google.remoteconfig.customsignals.ErrorDomain";
 // Remote Config Realtime Error Domain
 NSString *const FIRRemoteConfigUpdateErrorDomain = @"com.google.remoteconfig.update.ErrorDomain";
 /// Remote Config Error Info End Time Seconds;
@@ -47,6 +50,12 @@ const NSNotificationName FIRRemoteConfigActivateNotification =
     @"FIRRemoteConfigActivateNotification";
 static NSNotificationName FIRRolloutsStateDidChangeNotificationName =
     @"FIRRolloutsStateDidChangeNotification";
+/// Maximum allowed length for a custom signal key (in characters).
+static const NSUInteger FIRRemoteConfigCustomSignalsMaxKeyLength = 250;
+/// Maximum allowed length for a string value in custom signals (in characters).
+static const NSUInteger FIRRemoteConfigCustomSignalsMaxStringValueLength = 500;
+/// Maximum number of custom signals allowed.
+static const NSUInteger FIRRemoteConfigCustomSignalsMaxCount = 100;
 
 /// Listener for the get methods.
 typedef void (^FIRRemoteConfigListener)(NSString *_Nonnull, NSDictionary *_Nonnull);
@@ -237,6 +246,103 @@ static NSMutableDictionary<NSString *, NSMutableDictionary<NSString *, FIRRemote
   }
 }
 
+- (void)setCustomSignals:(nonnull NSDictionary<NSString *, NSObject *> *)customSignals
+          withCompletion:(void (^_Nullable)(NSError *_Nullable error))completionHandler {
+  void (^setCustomSignalsBlock)(void) = ^{
+    // Validate value type, and key and value length
+    for (NSString *key in customSignals) {
+      NSObject *value = customSignals[key];
+      if (![value isKindOfClass:[NSNull class]] && ![value isKindOfClass:[NSString class]] &&
+          ![value isKindOfClass:[NSNumber class]]) {
+        if (completionHandler) {
+          dispatch_async(dispatch_get_main_queue(), ^{
+            NSError *error =
+                [NSError errorWithDomain:FIRRemoteConfigCustomSignalsErrorDomain
+                                    code:FIRRemoteConfigCustomSignalsErrorInvalidValueType
+                                userInfo:@{
+                                  NSLocalizedDescriptionKey :
+                                      @"Invalid value type. Must be NSString, NSNumber or NSNull"
+                                }];
+            completionHandler(error);
+          });
+        }
+        return;
+      }
+
+      if (key.length > FIRRemoteConfigCustomSignalsMaxKeyLength ||
+          ([value isKindOfClass:[NSString class]] &&
+           [(NSString *)value length] > FIRRemoteConfigCustomSignalsMaxStringValueLength)) {
+        if (completionHandler) {
+          dispatch_async(dispatch_get_main_queue(), ^{
+            NSError *error = [NSError
+                errorWithDomain:FIRRemoteConfigCustomSignalsErrorDomain
+                           code:FIRRemoteConfigCustomSignalsErrorLimitExceeded
+                       userInfo:@{
+                         NSLocalizedDescriptionKey : [NSString
+                             stringWithFormat:@"Custom signal keys and string values must be "
+                                              @"%lu and %lu characters or less respectively.",
+                                              FIRRemoteConfigCustomSignalsMaxKeyLength,
+                                              FIRRemoteConfigCustomSignalsMaxStringValueLength]
+                       }];
+            completionHandler(error);
+          });
+        }
+        return;
+      }
+    }
+
+    // Merge new signals with existing ones, overwriting existing keys.
+    // Also, remove entries where the new value is null.
+    NSMutableDictionary<NSString *, NSString *> *newCustomSignals =
+        [[NSMutableDictionary alloc] initWithDictionary:self->_settings.customSignals];
+
+    for (NSString *key in customSignals) {
+      NSObject *value = customSignals[key];
+      if (![value isKindOfClass:[NSNull class]]) {
+        NSString *stringValue = [value isKindOfClass:[NSNumber class]]
+                                    ? [(NSNumber *)value stringValue]
+                                    : (NSString *)value;
+        [newCustomSignals setObject:stringValue forKey:key];
+      } else {
+        [newCustomSignals removeObjectForKey:key];
+      }
+    }
+
+    // Check the size limit.
+    if (newCustomSignals.count > FIRRemoteConfigCustomSignalsMaxCount) {
+      if (completionHandler) {
+        dispatch_async(dispatch_get_main_queue(), ^{
+          NSError *error = [NSError
+              errorWithDomain:FIRRemoteConfigCustomSignalsErrorDomain
+                         code:FIRRemoteConfigCustomSignalsErrorLimitExceeded
+                     userInfo:@{
+                       NSLocalizedDescriptionKey : [NSString
+                           stringWithFormat:@"Custom signals count exceeds the limit of %lu.",
+                                            FIRRemoteConfigCustomSignalsMaxCount]
+                     }];
+          completionHandler(error);
+        });
+      }
+      return;
+    }
+
+    // Update only if there are changes.
+    if (![newCustomSignals isEqualToDictionary:self->_settings.customSignals]) {
+      self->_settings.customSignals = newCustomSignals;
+    }
+    // Log the keys of the updated custom signals.
+    FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000078", @"Keys of updated custom signals: %@",
+                [newCustomSignals allKeys]);
+
+    if (completionHandler) {
+      dispatch_async(dispatch_get_main_queue(), ^{
+        completionHandler(nil);
+      });
+    }
+  };
+  dispatch_async(_queue, setCustomSignalsBlock);
+}
+
 #pragma mark - fetch
 
 - (void)fetchWithCompletionHandler:(FIRRemoteConfigFetchCompletion)completionHandler {

+ 5 - 0
FirebaseRemoteConfig/Sources/Private/RCNConfigSettings.h

@@ -81,6 +81,11 @@
 /// Last active template version.
 @property(nonatomic, readwrite, assign) NSString *lastActiveTemplateVersion;
 
+#pragma mark - Custom Signals
+
+/// A dictionary to hold custom signals that are set by the developer.
+@property(nonatomic, readwrite, strong) NSDictionary<NSString *, NSString *> *customSignals;
+
 #pragma mark Throttling properties
 
 /// Throttling intervals are based on https://cloud.google.com/storage/docs/exponential-backoff

+ 17 - 0
FirebaseRemoteConfig/Sources/Public/FirebaseRemoteConfig/FIRRemoteConfig.h

@@ -97,6 +97,19 @@ typedef NS_ERROR_ENUM(FIRRemoteConfigUpdateErrorDomain, FIRRemoteConfigUpdateErr
     FIRRemoteConfigUpdateErrorUnavailable = 8004,
 } NS_SWIFT_NAME(RemoteConfigUpdateError);
 
+/// Error domain for custom signals errors.
+extern NSString *const _Nonnull FIRRemoteConfigCustomSignalsErrorDomain NS_SWIFT_NAME(RemoteConfigCustomSignalsErrorDomain);
+
+/// Firebase Remote Config custom signals error.
+typedef NS_ERROR_ENUM(FIRRemoteConfigCustomSignalsErrorDomain, FIRRemoteConfigCustomSignalsError){
+    /// Unknown error.
+    FIRRemoteConfigCustomSignalsErrorUnknown = 8101,
+    /// Invalid value type in the custom signals dictionary.
+    FIRRemoteConfigCustomSignalsErrorInvalidValueType = 8102,
+    /// Limit exceeded for key length, value length, or number of signals.
+    FIRRemoteConfigCustomSignalsErrorLimitExceeded = 8103,
+} NS_SWIFT_NAME(RemoteConfigCustomSignalsError);
+
 /// 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.
@@ -358,4 +371,8 @@ typedef void (^FIRRemoteConfigUpdateCompletion)(FIRRemoteConfigUpdate *_Nullable
     (FIRRemoteConfigUpdateCompletion _Nonnull)listener
     NS_SWIFT_NAME(addOnConfigUpdateListener(remoteConfigUpdateCompletion:));
 
+- (void)setCustomSignals:(nonnull NSDictionary<NSString *, NSObject *> *)customSignals
+          withCompletion:(void (^_Nullable)(NSError *_Nullable error))completionHandler
+    NS_REFINED_FOR_SWIFT;
+
 @end

+ 27 - 0
FirebaseRemoteConfig/Sources/RCNConfigSettings.m

@@ -404,6 +404,25 @@ static const int kRCNExponentialBackoffMaximumInterval = 60 * 60 * 4;  // 4 hour
       }
     }
   }
+
+  NSDictionary<NSString *, NSString *> *customSignals = [self customSignals];
+  if (customSignals.count > 0) {
+    NSError *error;
+    NSData *jsonData = [NSJSONSerialization dataWithJSONObject:customSignals
+                                                       options:0
+                                                         error:&error];
+    if (!error) {
+      ret = [ret
+          stringByAppendingString:[NSString
+                                      stringWithFormat:@", custom_signals:%@",
+                                                       [[NSString alloc]
+                                                           initWithData:jsonData
+                                                               encoding:NSUTF8StringEncoding]]];
+      // Log the keys of the custom signals sent during fetch.
+      FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000078",
+                  @"Keys of custom signals during fetch: %@", [customSignals allKeys]);
+    }
+  }
   ret = [ret stringByAppendingString:@"}"];
   return ret;
 }
@@ -473,6 +492,14 @@ static const int kRCNExponentialBackoffMaximumInterval = 60 * 60 * 4;  // 4 hour
                      completionHandler:nil];
 }
 
+- (NSDictionary<NSString *, NSString *> *)customSignals {
+  return [_userDefaultsManager customSignals];
+}
+
+- (void)setCustomSignals:(NSDictionary<NSString *, NSString *> *)customSignals {
+  [_userDefaultsManager setCustomSignals:customSignals];
+}
+
 #pragma mark Throttling
 
 - (BOOL)hasMinimumFetchIntervalElapsed:(NSTimeInterval)minimumFetchInterval {

+ 2 - 0
FirebaseRemoteConfig/Sources/RCNUserDefaultsManager.h

@@ -47,6 +47,8 @@ NS_ASSUME_NONNULL_BEGIN
 @property(nonatomic, assign) NSString *lastFetchedTemplateVersion;
 /// Last active template version.
 @property(nonatomic, assign) NSString *lastActiveTemplateVersion;
+/// A dictionary to hold the latest custom signals set by the developer.
+@property(nonatomic, readwrite, strong) NSDictionary<NSString *, NSString *> *customSignals;
 
 /// Designated initializer.
 - (instancetype)initWithAppName:(NSString *)appName

+ 16 - 0
FirebaseRemoteConfig/Sources/RCNUserDefaultsManager.m

@@ -34,6 +34,7 @@ static NSString *const kRCNUserDefaultsKeyNameRealtimeThrottleEndTime = @"thrott
 static NSString *const kRCNUserDefaultsKeyNameCurrentRealtimeThrottlingRetryInterval =
     @"currentRealtimeThrottlingRetryInterval";
 static NSString *const kRCNUserDefaultsKeyNameRealtimeRetryCount = @"realtimeRetryCount";
+static NSString *const kRCNUserDefaultsKeyCustomSignals = @"customSignals";
 
 @interface RCNUserDefaultsManager () {
   /// User Defaults instance for this bundleID. NSUserDefaults is guaranteed to be thread-safe.
@@ -141,6 +142,21 @@ static NSString *const kRCNUserDefaultsKeyNameRealtimeRetryCount = @"realtimeRet
   }
 }
 
+- (NSDictionary<NSString *, NSString *> *)customSignals {
+  NSDictionary *userDefaults = [self instanceUserDefaults];
+  if ([userDefaults objectForKey:kRCNUserDefaultsKeyCustomSignals]) {
+    return [userDefaults objectForKey:kRCNUserDefaultsKeyCustomSignals];
+  }
+
+  return [[NSDictionary<NSString *, NSString *> alloc] init];
+}
+
+- (void)setCustomSignals:(NSDictionary<NSString *, NSString *> *)customSignals {
+  if (customSignals) {
+    [self setInstanceUserDefaultsValue:customSignals forKey:kRCNUserDefaultsKeyCustomSignals];
+  }
+}
+
 - (NSTimeInterval)lastETagUpdateTime {
   NSNumber *lastETagUpdateTime =
       [[self instanceUserDefaults] objectForKey:kRCNUserDefaultsKeyNamelastETagUpdateTime];

+ 107 - 0
FirebaseRemoteConfig/Swift/CustomSignals.swift

@@ -0,0 +1,107 @@
+// Copyright 2024 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 Foundation
+#if SWIFT_PACKAGE
+  @_exported import FirebaseRemoteConfigInternal
+#endif // SWIFT_PACKAGE
+
+/// Represents a value associated with a key in a custom signal, restricted to the allowed data
+/// types : String, Int, Double.
+public struct CustomSignalValue {
+  private enum Kind {
+    case string(String)
+    case integer(Int)
+    case double(Double)
+  }
+
+  private let kind: Kind
+
+  private init(kind: Kind) {
+    self.kind = kind
+  }
+
+  /// Returns a string backed custom signal.
+  /// - Parameter string: The given string to back the custom signal with.
+  /// - Returns: A string backed custom signal.
+  public static func string(_ string: String) -> Self {
+    Self(kind: .string(string))
+  }
+
+  /// Returns an integer backed custom signal.
+  /// - Parameter integer: The given integer to back the custom signal with.
+  /// - Returns: An integer backed custom signal.
+  public static func integer(_ integer: Int) -> Self {
+    Self(kind: .integer(integer))
+  }
+
+  /// Returns an floating-point backed custom signal.
+  /// - Parameter double: The given floating-point value to back the custom signal with.
+  /// - Returns: An floating-point backed custom signal
+  public static func double(_ double: Double) -> Self {
+    Self(kind: .double(double))
+  }
+
+  fileprivate func toNSObject() -> NSObject {
+    switch kind {
+    case let .string(string):
+      return string as NSString
+    case let .integer(int):
+      return int as NSNumber
+    case let .double(double):
+      return double as NSNumber
+    }
+  }
+}
+
+extension CustomSignalValue: ExpressibleByStringInterpolation {
+  public init(stringLiteral value: String) {
+    self = .string(value)
+  }
+}
+
+extension CustomSignalValue: ExpressibleByIntegerLiteral {
+  public init(integerLiteral value: Int) {
+    self = .integer(value)
+  }
+}
+
+extension CustomSignalValue: ExpressibleByFloatLiteral {
+  public init(floatLiteral value: Double) {
+    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()
+        }
+      }
+    }
+  }
+}

+ 1 - 0
FirebaseRemoteConfig/Tests/Swift/SwiftAPI/APITestBase.swift

@@ -57,6 +57,7 @@ class APITestBase: XCTestCase {
     let settings = RemoteConfigSettings()
     settings.minimumFetchInterval = 0
     config.configSettings = settings
+    config.settings.customSignals = [:]
 
     let jsonData = try JSONSerialization.data(
       withJSONObject: Constants.jsonValue

+ 50 - 0
FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncAwaitTests.swift

@@ -129,4 +129,54 @@ class AsyncAwaitTests: APITestBase {
     XCTAssertTrue(config.configValue(forKey: Constants.jedi).dataValue.isEmpty,
                   "Remote config should have been deleted.")
   }
+
+  func testSetCustomSignals() async throws {
+    let testSignals: [String: CustomSignalValue?] = [
+      "signal_1": .integer(5),
+      "signal_2": .string("basic"),
+      "signal_3": .double(3.14159),
+    ]
+
+    let expectedSignals: [String: String] = [
+      "signal_1": "5",
+      "signal_2": "basic",
+      "signal_3": "3.14159",
+    ]
+
+    _ = try await config.setCustomSignals(testSignals)
+    XCTAssertEqual(config.settings.customSignals, expectedSignals)
+  }
+
+  func testSetCustomSignalsMultipleTimes() async throws {
+    let testSignals: [String: CustomSignalValue?] = [
+      "signal_1": 6,
+      "signal_2": "basic",
+      "signal_3": 3.14,
+    ]
+
+    let expectedSignals: [String: String] = [
+      "signal_1": "6",
+      "signal_2": "basic",
+      "signal_3": "3.14",
+    ]
+
+    _ = try await config.setCustomSignals(testSignals)
+    XCTAssertEqual(config.settings.customSignals, expectedSignals)
+
+    let testSignals2: [String: CustomSignalValue?] = [
+      "signal_4": .integer(100),
+      "signal_3": nil,
+      "signal_5": .double(3.1234),
+    ]
+
+    let expectedSignals2: [String: String] = [
+      "signal_1": "6",
+      "signal_2": "basic",
+      "signal_4": "100",
+      "signal_5": "3.1234",
+    ]
+
+    _ = try await config.setCustomSignals(testSignals2)
+    XCTAssertEqual(config.settings.customSignals, expectedSignals2)
+  }
 }

+ 14 - 0
FirebaseRemoteConfig/Tests/Swift/SwiftAPI/FirebaseRemoteConfigSwift_APIBuildTests.swift

@@ -223,5 +223,19 @@ final class FirebaseRemoteConfig_APIBuildTests: XCTestCase {
 
     struct MyEncodableValue: Encodable {}
     let _: Void = try config.setDefaults(from: MyEncodableValue())
+
+    Task {
+      let signals: [String: CustomSignalValue?] = [
+        "signal_1": .integer(5),
+        "signal_2": .string("enable_feature"),
+        "signal_3": 5,
+        "signal_4": "enable_feature",
+        "signal_5": "enable_feature_\("secret")",
+        "signal_6": .double(3.14),
+        "signal_7": 3.14159,
+        "signal_8": nil, // Used to delete the custom signal for a given key.
+      ]
+      try await config.setCustomSignals(signals)
+    }
   }
 }

+ 114 - 0
FirebaseRemoteConfig/Tests/Unit/RCNRemoteConfigTest.m

@@ -1834,6 +1834,120 @@ static NSString *UTCToLocal(NSString *utcTime) {
   [self waitForExpectations:@[ notificationExpectation ] timeout:_expectationTimeout];
 }
 
+- (void)testSetCustomSignals {
+  NSMutableArray<XCTestExpectation *> *expectations =
+      [[NSMutableArray alloc] initWithCapacity:RCNTestRCNumTotalInstances];
+
+  for (int i = 0; i < RCNTestRCNumTotalInstances; i++) {
+    expectations[i] = [self
+        expectationWithDescription:[NSString
+                                       stringWithFormat:@"Set custom signals - instance %d", i]];
+
+    NSDictionary<NSString *, NSObject *> *testSignals = @{
+      @"signal1" : @"stringValue",
+      @"signal2" : @"stringValue2",
+    };
+
+    [_configInstances[i] setCustomSignals:testSignals
+                           withCompletion:^(NSError *_Nullable error) {
+                             XCTAssertNil(error);
+                             NSDictionary<NSString *, NSString *> *retrievedSignals =
+                                 self->_configInstances[i].settings.customSignals;
+                             XCTAssertEqualObjects(retrievedSignals, testSignals);
+                             [expectations[i] fulfill];
+                           }];
+  }
+  [self waitForExpectationsWithTimeout:_expectationTimeout handler:nil];
+}
+
+- (void)testSetCustomSignalsMultipleTimes {
+  NSMutableArray<XCTestExpectation *> *expectations =
+      [[NSMutableArray alloc] initWithCapacity:RCNTestRCNumTotalInstances];
+
+  for (int i = 0; i < RCNTestRCNumTotalInstances; i++) {
+    expectations[i] = [self
+        expectationWithDescription:
+            [NSString stringWithFormat:@"Set custom signals multiple times - instance %d", i]];
+
+    // First set of signals
+    NSDictionary<NSString *, NSObject *> *testSignals1 = @{
+      @"signal1" : @"stringValue1",
+      @"signal2" : @"stringValue2",
+    };
+
+    // Second set of signals (overwrites, remove and adds new)
+    NSDictionary<NSString *, NSObject *> *testSignals2 = @{
+      @"signal1" : @"updatedValue1",
+      @"signal2" : [NSNull null],
+      @"signal3" : @5,
+    };
+
+    // Expected final set of signals
+    NSDictionary<NSString *, NSString *> *expectedSignals = @{
+      @"signal1" : @"updatedValue1",
+      @"signal3" : @"5",
+    };
+
+    [_configInstances[i] setCustomSignals:testSignals1
+                           withCompletion:^(NSError *_Nullable error) {
+                             XCTAssertNil(error);
+                             [_configInstances[i]
+                                 setCustomSignals:testSignals2
+                                   withCompletion:^(NSError *_Nullable error) {
+                                     XCTAssertNil(error);
+                                     NSDictionary<NSString *, NSString *> *retrievedSignals =
+                                         self->_configInstances[i].settings.customSignals;
+                                     XCTAssertEqualObjects(retrievedSignals, expectedSignals);
+                                     [expectations[i] fulfill];
+                                   }];
+                           }];
+  }
+  [self waitForExpectationsWithTimeout:_expectationTimeout handler:nil];
+}
+
+- (void)testSetCustomSignals_invalidInput_throwsException {
+  NSMutableArray<XCTestExpectation *> *expectations =
+      [[NSMutableArray alloc] initWithCapacity:RCNTestRCNumTotalInstances];
+
+  for (int i = 0; i < RCNTestRCNumTotalInstances; i++) {
+    expectations[i] =
+        [self expectationWithDescription:
+                  [NSString stringWithFormat:@"Set custom signals expects error - instance %d", i]];
+
+    // Invalid value type.
+    NSDictionary<NSString *, NSObject *> *invalidSignals1 = @{@"name" : [NSDate date]};
+
+    // Key length exceeds limit.
+    NSDictionary<NSString *, NSObject *> *invalidSignals2 =
+        @{[@"a" stringByPaddingToLength:251 withString:@"a" startingAtIndex:0] : @"value"};
+
+    // Value length exceeds limit.
+    NSDictionary<NSString *, NSObject *> *invalidSignals3 =
+        @{@"key" : [@"a" stringByPaddingToLength:501 withString:@"a" startingAtIndex:0]};
+
+    [_configInstances[i]
+        setCustomSignals:invalidSignals1
+          withCompletion:^(NSError *_Nullable error) {
+            XCTAssertNotNil(error);
+            XCTAssertEqual(error.code, FIRRemoteConfigCustomSignalsErrorInvalidValueType);
+          }];
+    [_configInstances[i]
+        setCustomSignals:invalidSignals2
+          withCompletion:^(NSError *_Nullable error) {
+            XCTAssertNotNil(error);
+            XCTAssertEqual(error.code, FIRRemoteConfigCustomSignalsErrorLimitExceeded);
+          }];
+    [_configInstances[i]
+        setCustomSignals:invalidSignals3
+          withCompletion:^(NSError *_Nullable error) {
+            XCTAssertNotNil(error);
+            XCTAssertEqual(error.code, FIRRemoteConfigCustomSignalsErrorLimitExceeded);
+            [expectations[i] fulfill];
+          }];
+  }
+  [self waitForExpectationsWithTimeout:_expectationTimeout handler:nil];
+}
+
 #pragma mark - Test Helpers
 
 - (FIROptions *)firstAppOptions {

+ 27 - 0
FirebaseRemoteConfig/Tests/Unit/RCNUserDefaultsManagerTests.m

@@ -23,6 +23,8 @@ static NSTimeInterval RCNUserDefaultsSampleTimeStamp = 0;
 static NSString* const AppName = @"testApp";
 static NSString* const FQNamespace1 = @"testNamespace1:testApp";
 static NSString* const FQNamespace2 = @"testNamespace2:testApp";
+static NSMutableDictionary<NSString*, NSString*>* customSignals1 = nil;
+static NSMutableDictionary<NSString*, NSString*>* customSignals2 = nil;
 
 @interface RCNUserDefaultsManagerTests : XCTestCase
 
@@ -36,6 +38,13 @@ static NSString* const FQNamespace2 = @"testNamespace2:testApp";
   [[NSUserDefaults standardUserDefaults]
       removePersistentDomainForName:[NSBundle mainBundle].bundleIdentifier];
   RCNUserDefaultsSampleTimeStamp = [[NSDate date] timeIntervalSince1970];
+
+  customSignals1 = [[NSMutableDictionary alloc] initWithDictionary:@{
+    @"signal1" : @"stringValue",
+  }];
+  customSignals2 = [[NSMutableDictionary alloc] initWithDictionary:@{
+    @"signal2" : @"stringValue2",
+  }];
 }
 
 - (void)testUserDefaultsEtagWriteAndRead {
@@ -168,6 +177,18 @@ static NSString* const FQNamespace2 = @"testNamespace2:testApp";
                  RCNUserDefaultsSampleTimeStamp - 2.0);
 }
 
+- (void)testUserDefaultsCustomSignalsWriteAndRead {
+  RCNUserDefaultsManager* manager =
+      [[RCNUserDefaultsManager alloc] initWithAppName:AppName
+                                             bundleID:[NSBundle mainBundle].bundleIdentifier
+                                            namespace:FQNamespace1];
+  [manager setCustomSignals:customSignals1];
+  XCTAssertEqualObjects([manager customSignals], customSignals1);
+
+  [manager setCustomSignals:customSignals2];
+  XCTAssertEqualObjects([manager customSignals], customSignals2);
+}
+
 - (void)testUserDefaultsForMultipleNamespaces {
   RCNUserDefaultsManager* manager1 =
       [[RCNUserDefaultsManager alloc] initWithAppName:AppName
@@ -248,6 +269,12 @@ static NSString* const FQNamespace2 = @"testNamespace2:testApp";
   [manager2 setLastActiveTemplateVersion:@"2"];
   XCTAssertEqualObjects([manager1 lastActiveTemplateVersion], @"1");
   XCTAssertEqualObjects([manager2 lastActiveTemplateVersion], @"2");
+
+  /// Custom Signals
+  [manager1 setCustomSignals:customSignals1];
+  [manager2 setCustomSignals:customSignals2];
+  XCTAssertEqualObjects([manager1 customSignals], customSignals1);
+  XCTAssertEqualObjects([manager2 customSignals], customSignals2);
 }
 
 - (void)testUserDefaultsReset {