Skip to content

Commit

Permalink
Add custom signals support in Remote Config. (#13976)
Browse files Browse the repository at this point in the history
Co-authored-by: Nick Cooke <nickcooke@google.com>
  • Loading branch information
tusharkhandelwal8 and ncooke3 authored Jan 7, 2025
1 parent 0a8db04 commit 15179e4
Show file tree
Hide file tree
Showing 13 changed files with 487 additions and 0 deletions.
1 change: 1 addition & 0 deletions FirebaseRemoteConfig/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
106 changes: 106 additions & 0 deletions FirebaseRemoteConfig/Sources/FIRRemoteConfig.m
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -47,6 +50,12 @@
@"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);
Expand Down Expand Up @@ -237,6 +246,103 @@ - (void)callListeners:(NSString *)key config:(NSDictionary *)config {
}
}

- (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 {
Expand Down
5 changes: 5 additions & 0 deletions FirebaseRemoteConfig/Sources/Private/RCNConfigSettings.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 changes: 27 additions & 0 deletions FirebaseRemoteConfig/Sources/RCNConfigSettings.m
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,25 @@ - (NSString *)nextRequestWithUserProperties:(NSDictionary *)userProperties {
}
}
}

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;
}
Expand Down Expand Up @@ -473,6 +492,14 @@ - (void)setLastSetDefaultsTimeInterval:(NSTimeInterval)lastSetDefaultsTimestamp
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 {
Expand Down
2 changes: 2 additions & 0 deletions FirebaseRemoteConfig/Sources/RCNUserDefaultsManager.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions FirebaseRemoteConfig/Sources/RCNUserDefaultsManager.m
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
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.
Expand Down Expand Up @@ -141,6 +142,21 @@ - (void)setLastActiveTemplateVersion:(NSString *)templateVersion {
}
}

- (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];
Expand Down
107 changes: 107 additions & 0 deletions FirebaseRemoteConfig/Swift/CustomSignals.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit 15179e4

Please sign in to comment.