Skip to content

Commit 15179e4

Browse files
Add custom signals support in Remote Config. (#13976)
Co-authored-by: Nick Cooke <nickcooke@google.com>
1 parent 0a8db04 commit 15179e4

13 files changed

+487
-0
lines changed

FirebaseRemoteConfig/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Unreleased
22
- [fixed] Mark ConfigUpdateListenerRegistration Sendable. (#14215)
3+
- [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)
34

45
# 11.5.0
56
- [fixed] Mark two internal properties as `atomic` to prevent concurrency

FirebaseRemoteConfig/Sources/FIRRemoteConfig.m

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@
3434
/// Remote Config Error Domain.
3535
/// TODO: Rename according to obj-c style for constants.
3636
NSString *const FIRRemoteConfigErrorDomain = @"com.google.remoteconfig.ErrorDomain";
37+
// Remote Config Custom Signals Error Domain
38+
NSString *const FIRRemoteConfigCustomSignalsErrorDomain =
39+
@"com.google.remoteconfig.customsignals.ErrorDomain";
3740
// Remote Config Realtime Error Domain
3841
NSString *const FIRRemoteConfigUpdateErrorDomain = @"com.google.remoteconfig.update.ErrorDomain";
3942
/// Remote Config Error Info End Time Seconds;
@@ -47,6 +50,12 @@
4750
@"FIRRemoteConfigActivateNotification";
4851
static NSNotificationName FIRRolloutsStateDidChangeNotificationName =
4952
@"FIRRolloutsStateDidChangeNotification";
53+
/// Maximum allowed length for a custom signal key (in characters).
54+
static const NSUInteger FIRRemoteConfigCustomSignalsMaxKeyLength = 250;
55+
/// Maximum allowed length for a string value in custom signals (in characters).
56+
static const NSUInteger FIRRemoteConfigCustomSignalsMaxStringValueLength = 500;
57+
/// Maximum number of custom signals allowed.
58+
static const NSUInteger FIRRemoteConfigCustomSignalsMaxCount = 100;
5059

5160
/// Listener for the get methods.
5261
typedef void (^FIRRemoteConfigListener)(NSString *_Nonnull, NSDictionary *_Nonnull);
@@ -237,6 +246,103 @@ - (void)callListeners:(NSString *)key config:(NSDictionary *)config {
237246
}
238247
}
239248

249+
- (void)setCustomSignals:(nonnull NSDictionary<NSString *, NSObject *> *)customSignals
250+
withCompletion:(void (^_Nullable)(NSError *_Nullable error))completionHandler {
251+
void (^setCustomSignalsBlock)(void) = ^{
252+
// Validate value type, and key and value length
253+
for (NSString *key in customSignals) {
254+
NSObject *value = customSignals[key];
255+
if (![value isKindOfClass:[NSNull class]] && ![value isKindOfClass:[NSString class]] &&
256+
![value isKindOfClass:[NSNumber class]]) {
257+
if (completionHandler) {
258+
dispatch_async(dispatch_get_main_queue(), ^{
259+
NSError *error =
260+
[NSError errorWithDomain:FIRRemoteConfigCustomSignalsErrorDomain
261+
code:FIRRemoteConfigCustomSignalsErrorInvalidValueType
262+
userInfo:@{
263+
NSLocalizedDescriptionKey :
264+
@"Invalid value type. Must be NSString, NSNumber or NSNull"
265+
}];
266+
completionHandler(error);
267+
});
268+
}
269+
return;
270+
}
271+
272+
if (key.length > FIRRemoteConfigCustomSignalsMaxKeyLength ||
273+
([value isKindOfClass:[NSString class]] &&
274+
[(NSString *)value length] > FIRRemoteConfigCustomSignalsMaxStringValueLength)) {
275+
if (completionHandler) {
276+
dispatch_async(dispatch_get_main_queue(), ^{
277+
NSError *error = [NSError
278+
errorWithDomain:FIRRemoteConfigCustomSignalsErrorDomain
279+
code:FIRRemoteConfigCustomSignalsErrorLimitExceeded
280+
userInfo:@{
281+
NSLocalizedDescriptionKey : [NSString
282+
stringWithFormat:@"Custom signal keys and string values must be "
283+
@"%lu and %lu characters or less respectively.",
284+
FIRRemoteConfigCustomSignalsMaxKeyLength,
285+
FIRRemoteConfigCustomSignalsMaxStringValueLength]
286+
}];
287+
completionHandler(error);
288+
});
289+
}
290+
return;
291+
}
292+
}
293+
294+
// Merge new signals with existing ones, overwriting existing keys.
295+
// Also, remove entries where the new value is null.
296+
NSMutableDictionary<NSString *, NSString *> *newCustomSignals =
297+
[[NSMutableDictionary alloc] initWithDictionary:self->_settings.customSignals];
298+
299+
for (NSString *key in customSignals) {
300+
NSObject *value = customSignals[key];
301+
if (![value isKindOfClass:[NSNull class]]) {
302+
NSString *stringValue = [value isKindOfClass:[NSNumber class]]
303+
? [(NSNumber *)value stringValue]
304+
: (NSString *)value;
305+
[newCustomSignals setObject:stringValue forKey:key];
306+
} else {
307+
[newCustomSignals removeObjectForKey:key];
308+
}
309+
}
310+
311+
// Check the size limit.
312+
if (newCustomSignals.count > FIRRemoteConfigCustomSignalsMaxCount) {
313+
if (completionHandler) {
314+
dispatch_async(dispatch_get_main_queue(), ^{
315+
NSError *error = [NSError
316+
errorWithDomain:FIRRemoteConfigCustomSignalsErrorDomain
317+
code:FIRRemoteConfigCustomSignalsErrorLimitExceeded
318+
userInfo:@{
319+
NSLocalizedDescriptionKey : [NSString
320+
stringWithFormat:@"Custom signals count exceeds the limit of %lu.",
321+
FIRRemoteConfigCustomSignalsMaxCount]
322+
}];
323+
completionHandler(error);
324+
});
325+
}
326+
return;
327+
}
328+
329+
// Update only if there are changes.
330+
if (![newCustomSignals isEqualToDictionary:self->_settings.customSignals]) {
331+
self->_settings.customSignals = newCustomSignals;
332+
}
333+
// Log the keys of the updated custom signals.
334+
FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000078", @"Keys of updated custom signals: %@",
335+
[newCustomSignals allKeys]);
336+
337+
if (completionHandler) {
338+
dispatch_async(dispatch_get_main_queue(), ^{
339+
completionHandler(nil);
340+
});
341+
}
342+
};
343+
dispatch_async(_queue, setCustomSignalsBlock);
344+
}
345+
240346
#pragma mark - fetch
241347

242348
- (void)fetchWithCompletionHandler:(FIRRemoteConfigFetchCompletion)completionHandler {

FirebaseRemoteConfig/Sources/Private/RCNConfigSettings.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@
8181
/// Last active template version.
8282
@property(nonatomic, readwrite, assign) NSString *lastActiveTemplateVersion;
8383

84+
#pragma mark - Custom Signals
85+
86+
/// A dictionary to hold custom signals that are set by the developer.
87+
@property(nonatomic, readwrite, strong) NSDictionary<NSString *, NSString *> *customSignals;
88+
8489
#pragma mark Throttling properties
8590

8691
/// Throttling intervals are based on https://cloud.google.com/storage/docs/exponential-backoff

FirebaseRemoteConfig/Sources/Public/FirebaseRemoteConfig/FIRRemoteConfig.h

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,19 @@ typedef NS_ERROR_ENUM(FIRRemoteConfigUpdateErrorDomain, FIRRemoteConfigUpdateErr
9797
FIRRemoteConfigUpdateErrorUnavailable = 8004,
9898
} NS_SWIFT_NAME(RemoteConfigUpdateError);
9999

100+
/// Error domain for custom signals errors.
101+
extern NSString *const _Nonnull FIRRemoteConfigCustomSignalsErrorDomain NS_SWIFT_NAME(RemoteConfigCustomSignalsErrorDomain);
102+
103+
/// Firebase Remote Config custom signals error.
104+
typedef NS_ERROR_ENUM(FIRRemoteConfigCustomSignalsErrorDomain, FIRRemoteConfigCustomSignalsError){
105+
/// Unknown error.
106+
FIRRemoteConfigCustomSignalsErrorUnknown = 8101,
107+
/// Invalid value type in the custom signals dictionary.
108+
FIRRemoteConfigCustomSignalsErrorInvalidValueType = 8102,
109+
/// Limit exceeded for key length, value length, or number of signals.
110+
FIRRemoteConfigCustomSignalsErrorLimitExceeded = 8103,
111+
} NS_SWIFT_NAME(RemoteConfigCustomSignalsError);
112+
100113
/// Enumerated value that indicates the source of Remote Config data. Data can come from
101114
/// the Remote Config service, the DefaultConfig that is available when the app is first installed,
102115
/// or a static initialized value if data is not available from the service or DefaultConfig.
@@ -358,4 +371,8 @@ typedef void (^FIRRemoteConfigUpdateCompletion)(FIRRemoteConfigUpdate *_Nullable
358371
(FIRRemoteConfigUpdateCompletion _Nonnull)listener
359372
NS_SWIFT_NAME(addOnConfigUpdateListener(remoteConfigUpdateCompletion:));
360373

374+
- (void)setCustomSignals:(nonnull NSDictionary<NSString *, NSObject *> *)customSignals
375+
withCompletion:(void (^_Nullable)(NSError *_Nullable error))completionHandler
376+
NS_REFINED_FOR_SWIFT;
377+
361378
@end

FirebaseRemoteConfig/Sources/RCNConfigSettings.m

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,25 @@ - (NSString *)nextRequestWithUserProperties:(NSDictionary *)userProperties {
404404
}
405405
}
406406
}
407+
408+
NSDictionary<NSString *, NSString *> *customSignals = [self customSignals];
409+
if (customSignals.count > 0) {
410+
NSError *error;
411+
NSData *jsonData = [NSJSONSerialization dataWithJSONObject:customSignals
412+
options:0
413+
error:&error];
414+
if (!error) {
415+
ret = [ret
416+
stringByAppendingString:[NSString
417+
stringWithFormat:@", custom_signals:%@",
418+
[[NSString alloc]
419+
initWithData:jsonData
420+
encoding:NSUTF8StringEncoding]]];
421+
// Log the keys of the custom signals sent during fetch.
422+
FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000078",
423+
@"Keys of custom signals during fetch: %@", [customSignals allKeys]);
424+
}
425+
}
407426
ret = [ret stringByAppendingString:@"}"];
408427
return ret;
409428
}
@@ -473,6 +492,14 @@ - (void)setLastSetDefaultsTimeInterval:(NSTimeInterval)lastSetDefaultsTimestamp
473492
completionHandler:nil];
474493
}
475494

495+
- (NSDictionary<NSString *, NSString *> *)customSignals {
496+
return [_userDefaultsManager customSignals];
497+
}
498+
499+
- (void)setCustomSignals:(NSDictionary<NSString *, NSString *> *)customSignals {
500+
[_userDefaultsManager setCustomSignals:customSignals];
501+
}
502+
476503
#pragma mark Throttling
477504

478505
- (BOOL)hasMinimumFetchIntervalElapsed:(NSTimeInterval)minimumFetchInterval {

FirebaseRemoteConfig/Sources/RCNUserDefaultsManager.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ NS_ASSUME_NONNULL_BEGIN
4747
@property(nonatomic, assign) NSString *lastFetchedTemplateVersion;
4848
/// Last active template version.
4949
@property(nonatomic, assign) NSString *lastActiveTemplateVersion;
50+
/// A dictionary to hold the latest custom signals set by the developer.
51+
@property(nonatomic, readwrite, strong) NSDictionary<NSString *, NSString *> *customSignals;
5052

5153
/// Designated initializer.
5254
- (instancetype)initWithAppName:(NSString *)appName

FirebaseRemoteConfig/Sources/RCNUserDefaultsManager.m

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
static NSString *const kRCNUserDefaultsKeyNameCurrentRealtimeThrottlingRetryInterval =
3535
@"currentRealtimeThrottlingRetryInterval";
3636
static NSString *const kRCNUserDefaultsKeyNameRealtimeRetryCount = @"realtimeRetryCount";
37+
static NSString *const kRCNUserDefaultsKeyCustomSignals = @"customSignals";
3738

3839
@interface RCNUserDefaultsManager () {
3940
/// User Defaults instance for this bundleID. NSUserDefaults is guaranteed to be thread-safe.
@@ -141,6 +142,21 @@ - (void)setLastActiveTemplateVersion:(NSString *)templateVersion {
141142
}
142143
}
143144

145+
- (NSDictionary<NSString *, NSString *> *)customSignals {
146+
NSDictionary *userDefaults = [self instanceUserDefaults];
147+
if ([userDefaults objectForKey:kRCNUserDefaultsKeyCustomSignals]) {
148+
return [userDefaults objectForKey:kRCNUserDefaultsKeyCustomSignals];
149+
}
150+
151+
return [[NSDictionary<NSString *, NSString *> alloc] init];
152+
}
153+
154+
- (void)setCustomSignals:(NSDictionary<NSString *, NSString *> *)customSignals {
155+
if (customSignals) {
156+
[self setInstanceUserDefaultsValue:customSignals forKey:kRCNUserDefaultsKeyCustomSignals];
157+
}
158+
}
159+
144160
- (NSTimeInterval)lastETagUpdateTime {
145161
NSNumber *lastETagUpdateTime =
146162
[[self instanceUserDefaults] objectForKey:kRCNUserDefaultsKeyNamelastETagUpdateTime];
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// Copyright 2024 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import Foundation
16+
#if SWIFT_PACKAGE
17+
@_exported import FirebaseRemoteConfigInternal
18+
#endif // SWIFT_PACKAGE
19+
20+
/// Represents a value associated with a key in a custom signal, restricted to the allowed data
21+
/// types : String, Int, Double.
22+
public struct CustomSignalValue {
23+
private enum Kind {
24+
case string(String)
25+
case integer(Int)
26+
case double(Double)
27+
}
28+
29+
private let kind: Kind
30+
31+
private init(kind: Kind) {
32+
self.kind = kind
33+
}
34+
35+
/// Returns a string backed custom signal.
36+
/// - Parameter string: The given string to back the custom signal with.
37+
/// - Returns: A string backed custom signal.
38+
public static func string(_ string: String) -> Self {
39+
Self(kind: .string(string))
40+
}
41+
42+
/// Returns an integer backed custom signal.
43+
/// - Parameter integer: The given integer to back the custom signal with.
44+
/// - Returns: An integer backed custom signal.
45+
public static func integer(_ integer: Int) -> Self {
46+
Self(kind: .integer(integer))
47+
}
48+
49+
/// Returns an floating-point backed custom signal.
50+
/// - Parameter double: The given floating-point value to back the custom signal with.
51+
/// - Returns: An floating-point backed custom signal
52+
public static func double(_ double: Double) -> Self {
53+
Self(kind: .double(double))
54+
}
55+
56+
fileprivate func toNSObject() -> NSObject {
57+
switch kind {
58+
case let .string(string):
59+
return string as NSString
60+
case let .integer(int):
61+
return int as NSNumber
62+
case let .double(double):
63+
return double as NSNumber
64+
}
65+
}
66+
}
67+
68+
extension CustomSignalValue: ExpressibleByStringInterpolation {
69+
public init(stringLiteral value: String) {
70+
self = .string(value)
71+
}
72+
}
73+
74+
extension CustomSignalValue: ExpressibleByIntegerLiteral {
75+
public init(integerLiteral value: Int) {
76+
self = .integer(value)
77+
}
78+
}
79+
80+
extension CustomSignalValue: ExpressibleByFloatLiteral {
81+
public init(floatLiteral value: Double) {
82+
self = .double(value)
83+
}
84+
}
85+
86+
@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
87+
public extension RemoteConfig {
88+
/// Sets custom signals for this Remote Config instance.
89+
/// - Parameter customSignals: A dictionary mapping string keys to custom
90+
/// signals to be set for the app instance.
91+
///
92+
/// When a new key is provided, a new key-value pair is added to the custom signals.
93+
/// If an existing key is provided with a new value, the corresponding signal is updated.
94+
/// If the value for a key is `nil`, the signal associated with that key is removed.
95+
func setCustomSignals(_ customSignals: [String: CustomSignalValue?]) async throws {
96+
return try await withCheckedThrowingContinuation { continuation in
97+
let customSignals = customSignals.mapValues { $0?.toNSObject() ?? NSNull() }
98+
self.__setCustomSignals(customSignals) { error in
99+
if let error {
100+
continuation.resume(throwing: error)
101+
} else {
102+
continuation.resume()
103+
}
104+
}
105+
}
106+
}
107+
}

FirebaseRemoteConfig/Tests/Swift/SwiftAPI/APITestBase.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ class APITestBase: XCTestCase {
5757
let settings = RemoteConfigSettings()
5858
settings.minimumFetchInterval = 0
5959
config.configSettings = settings
60+
config.settings.customSignals = [:]
6061

6162
let jsonData = try JSONSerialization.data(
6263
withJSONObject: Constants.jsonValue

0 commit comments

Comments
 (0)