Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add custom signals support in Remote Config. #13976

Open
wants to merge 20 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2455ff8
[Config] Add custom signal setter API
ncooke3 Oct 7, 2024
372538c
Make API test clearer
ncooke3 Oct 7, 2024
2d840c4
Rename to CustomSignal to CustomSignalValue
ncooke3 Oct 7, 2024
4a25d7b
Work for interpolated strings cc: @andrewheard
ncooke3 Oct 7, 2024
4c2bc89
Add support for deleting key
ncooke3 Oct 8, 2024
a8e3617
Complete implementation for setting/updating custom signals with tests
tusharkhandelwal8 Oct 24, 2024
b047554
Fix lint errors and code formatting.
tusharkhandelwal8 Oct 27, 2024
8d0f566
Set minimum iOS version to 13 for setCustomSignals
tusharkhandelwal8 Oct 30, 2024
aebbcf5
Add limits, input validation, and unit tests for custom signals
tusharkhandelwal8 Nov 7, 2024
b2de614
Move last completionHandler block outside if condition
tusharkhandelwal8 Nov 8, 2024
548eefe
Add limits as constants and dispatch all completion blocks to global …
tusharkhandelwal8 Nov 15, 2024
6df9e32
Store Custom Signals value as String instead of Object in iOS SDK
tusharkhandelwal8 Nov 18, 2024
de73b8f
Extend CustomSignalValue to include Double type
tusharkhandelwal8 Nov 21, 2024
d024203
Add debug logs to print custom signals during updates and fetches.
tusharkhandelwal8 Nov 28, 2024
cff4d27
Fix Lint
tusharkhandelwal8 Nov 28, 2024
7fa9ed0
Dispatch completion blocks to main queue and update enum values.
tusharkhandelwal8 Dec 4, 2024
9f25a01
Refactor API to enforce non-null dictionary and improve naming
tusharkhandelwal8 Dec 5, 2024
a7ab377
Add Swift tests and immutable signal map
tusharkhandelwal8 Dec 9, 2024
1d99846
Use descriptionWithLocale instead of description to convert NSNumber …
tusharkhandelwal8 Dec 9, 2024
ec58080
Use stringValue to convert NSNumber to NSString
tusharkhandelwal8 Dec 9, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 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,110 @@ - (void)callListeners:(NSString *)key config:(NSDictionary *)config {
}
}

- (void)setCustomSignals:(nullable NSDictionary<NSString *, NSObject *> *)customSignals
tusharkhandelwal8 marked this conversation as resolved.
Show resolved Hide resolved
tusharkhandelwal8 marked this conversation as resolved.
Show resolved Hide resolved
tusharkhandelwal8 marked this conversation as resolved.
Show resolved Hide resolved
WithCompletion:(void (^_Nullable)(NSError *_Nullable error))completionHandler {
tusharkhandelwal8 marked this conversation as resolved.
Show resolved Hide resolved
void (^setCustomSignalsBlock)(void) = ^{
if (!customSignals) {
if (completionHandler) {
dispatch_async(dispatch_get_main_queue(), ^{
tusharkhandelwal8 marked this conversation as resolved.
Show resolved Hide resolved
completionHandler(nil);
});
}
return;
}

// 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(), ^{
tusharkhandelwal8 marked this conversation as resolved.
Show resolved Hide resolved
NSError *error =
[NSError errorWithDomain:FIRRemoteConfigCustomSignalsErrorDomain
code:FIRRemoteConfigCustomSignalsErrorInvalidValueType
userInfo:@{
NSLocalizedDescriptionKey :
@"Invalid value type. Must be NSString, NSNumber or NSNull"
}];
completionHandler(error);
tusharkhandelwal8 marked this conversation as resolved.
Show resolved Hide resolved
});
}
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]]) {
tusharkhandelwal8 marked this conversation as resolved.
Show resolved Hide resolved
NSString *stringValue = [value description];
tusharkhandelwal8 marked this conversation as resolved.
Show resolved Hide resolved
[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 final updated custom signals.
FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000078", @"Updated custom signals: %@",
newCustomSignals);

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 @@ -84,6 +84,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) NSMutableDictionary<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 @@ -96,6 +96,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 @@ -357,4 +370,8 @@ typedef void (^FIRRemoteConfigUpdateCompletion)(FIRRemoteConfigUpdate *_Nullable
(FIRRemoteConfigUpdateCompletion _Nonnull)listener
NS_SWIFT_NAME(addOnConfigUpdateListener(remoteConfigUpdateCompletion:));

- (void)setCustomSignals:(nullable NSDictionary<NSString *, NSObject *> *)customSignals
WithCompletion:(void (^_Nullable)(NSError *_Nullable error))completionHandler
NS_REFINED_FOR_SWIFT;

@end
24 changes: 24 additions & 0 deletions FirebaseRemoteConfig/Sources/RCNConfigSettings.m
tusharkhandelwal8 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ - (instancetype)initWithDatabaseManager:(RCNConfigDBManager *)manager
[_userDefaultsManager resetUserDefaults];
}

_customSignals = [_userDefaultsManager customSignals];
_isFetchInProgress = NO;
_lastFetchedTemplateVersion = [_userDefaultsManager lastFetchedTemplateVersion];
_lastActiveTemplateVersion = [_userDefaultsManager lastActiveTemplateVersion];
Expand Down Expand Up @@ -444,6 +445,24 @@ - (NSString *)nextRequestWithUserProperties:(NSDictionary *)userProperties {
}
}
}

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 custom signals during fetch.
FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000078", @"Fetching with custom signals: %@",
_customSignals);
}
}
ret = [ret stringByAppendingString:@"}"];
return ret;
}
Expand Down Expand Up @@ -517,6 +536,11 @@ - (void)setLastSetDefaultsTimeInterval:(NSTimeInterval)lastSetDefaultsTimestamp
completionHandler:nil];
}

- (void)setCustomSignals:(NSMutableDictionary<NSString *, NSString *> *)customSignals {
_customSignals = 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) NSMutableDictionary<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 {
}
}

- (NSMutableDictionary<NSString *, NSString *> *)customSignals {
tusharkhandelwal8 marked this conversation as resolved.
Show resolved Hide resolved
NSDictionary *userDefaults = [self instanceUserDefaults];
if ([userDefaults objectForKey:kRCNUserDefaultsKeyCustomSignals]) {
return [userDefaults objectForKey:kRCNUserDefaultsKeyCustomSignals];
}

return [[NSMutableDictionary<NSString *, NSString *> alloc] init];
}

- (void)setCustomSignals:(NSMutableDictionary<NSString *, NSString *> *)customSignals {
if (customSignals) {
[self setInstanceUserDefaultsValue:customSignals forKey:kRCNUserDefaultsKeyCustomSignals];
}
}

- (NSTimeInterval)lastETagUpdateTime {
NSNumber *lastETagUpdateTime =
[[self instanceUserDefaults] objectForKey:kRCNUserDefaultsKeyNamelastETagUpdateTime];
Expand Down
103 changes: 103 additions & 0 deletions FirebaseRemoteConfig/Swift/CustomSignals.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// 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.
func setCustomSignals(_ customSignals: [String: CustomSignalValue?]) async throws {
tusharkhandelwal8 marked this conversation as resolved.
Show resolved Hide resolved
Comment on lines +88 to +91
Copy link
Member

@ncooke3 ncooke3 Dec 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taking another pass here. I would recommend including a discussion point to the doc comment on the behavior that clients should expect when adding vs. inserting vs. removing.

Suggested change
/// 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.
func setCustomSignals(_ customSignals: [String: CustomSignalValue?]) async throws {
/// 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.
///
/// <insert a few sentences on adding/updating signals and using nil to delete a signal>
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()
}
}
}
}
}
tusharkhandelwal8 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Loading
Loading