diff --git a/FirebaseRemoteConfig/CHANGELOG.md b/FirebaseRemoteConfig/CHANGELOG.md index c62c74fcea..cde2a9c79e 100644 --- a/FirebaseRemoteConfig/CHANGELOG.md +++ b/FirebaseRemoteConfig/CHANGELOG.md @@ -1,3 +1,6 @@ +# 10.7.0 +- [feature] Added support for real-time config updates. To learn more, see [Get started with Firebase Remote Config](https://firebase.google.com/docs/remote-config/get-started?platform=ios). + # 9.3.0 - [changed] Arrays and Dictionaries are now supported when initializing defaults from a plist. (#8306) diff --git a/FirebaseRemoteConfig/Sources/FIRRemoteConfig.m b/FirebaseRemoteConfig/Sources/FIRRemoteConfig.m index e402d1d6e1..4035d55870 100644 --- a/FirebaseRemoteConfig/Sources/FIRRemoteConfig.m +++ b/FirebaseRemoteConfig/Sources/FIRRemoteConfig.m @@ -26,6 +26,7 @@ #import "FirebaseRemoteConfig/Sources/RCNConfigContent.h" #import "FirebaseRemoteConfig/Sources/RCNConfigDBManager.h" #import "FirebaseRemoteConfig/Sources/RCNConfigExperiment.h" +#import "FirebaseRemoteConfig/Sources/RCNConfigRealtime.h" #import "FirebaseRemoteConfig/Sources/RCNConfigValue_Internal.h" #import "FirebaseRemoteConfig/Sources/RCNDevice.h" #import "FirebaseRemoteConfig/Sources/RCNPersonalization.h" @@ -33,6 +34,8 @@ /// Remote Config Error Domain. /// TODO: Rename according to obj-c style for constants. NSString *const FIRRemoteConfigErrorDomain = @"com.google.remoteconfig.ErrorDomain"; +// Remote Config Realtime Error Domain +NSString *const FIRRemoteConfigUpdateErrorDomain = @"com.google.remoteconfig.update.ErrorDomain"; /// Remote Config Error Info End Time Seconds; NSString *const FIRRemoteConfigThrottledEndTimeInSecondsKey = @"error_throttled_end_time_seconds"; /// Minimum required time interval between fetch requests made to the backend. @@ -66,6 +69,7 @@ typedef void (^FIRRemoteConfigListener)(NSString *_Nonnull, NSDictionary *_Nonnu RCNConfigSettings *_settings; RCNConfigFetch *_configFetch; RCNConfigExperiment *_configExperiment; + RCNConfigRealtime *_configRealtime; dispatch_queue_t _queue; NSString *_appName; NSMutableArray *_listeners; @@ -165,6 +169,11 @@ static NSMutableDictionary_configRealtime addConfigUpdateListener:listener]; +} + @end diff --git a/FirebaseRemoteConfig/Sources/FIRRemoteConfigUpdate.m b/FirebaseRemoteConfig/Sources/FIRRemoteConfigUpdate.m new file mode 100644 index 0000000000..47a8c89214 --- /dev/null +++ b/FirebaseRemoteConfig/Sources/FIRRemoteConfigUpdate.m @@ -0,0 +1,33 @@ +/* + * Copyright 2022 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 + +#import "FirebaseRemoteConfig/Sources/Public/FirebaseRemoteConfig/FIRRemoteConfig.h" + +@implementation FIRRemoteConfigUpdate { + NSSet *_updatedKeys; +} + +- (instancetype)initWithUpdatedKeys:(NSSet *)updatedKeys { + self = [super init]; + if (self) { + _updatedKeys = [updatedKeys copy]; + } + return self; +} + +@end diff --git a/FirebaseRemoteConfig/Sources/Private/FIRRemoteConfig_Private.h b/FirebaseRemoteConfig/Sources/Private/FIRRemoteConfig_Private.h index a3ff346bf5..ef7def6fd9 100644 --- a/FirebaseRemoteConfig/Sources/Private/FIRRemoteConfig_Private.h +++ b/FirebaseRemoteConfig/Sources/Private/FIRRemoteConfig_Private.h @@ -21,12 +21,19 @@ @class RCNConfigContent; @class RCNConfigDBManager; @class RCNConfigFetch; +@class RCNConfigRealtime; @protocol FIRAnalyticsInterop; NS_ASSUME_NONNULL_BEGIN @class RCNConfigSettings; +@interface FIRRemoteConfigUpdate () + +/// Designated initializer. +- (instancetype)initWithUpdatedKeys:(NSSet *)updatedKeys; +@end + @interface FIRRemoteConfig () { NSString *_FIRNamespace; } @@ -37,6 +44,8 @@ NS_ASSUME_NONNULL_BEGIN /// Config settings are custom settings. @property(nonatomic, readwrite, strong, nonnull) RCNConfigFetch *configFetch; +@property(nonatomic, readwrite, strong, nonnull) RCNConfigRealtime *configRealtime; + /// Returns the FIRRemoteConfig instance for your namespace and for the default Firebase App. /// This singleton object contains the complete set of Remote Config parameter values available to /// the app, including the Active Config and Default Config.. This object also caches values fetched diff --git a/FirebaseRemoteConfig/Sources/Private/RCNConfigFetch.h b/FirebaseRemoteConfig/Sources/Private/RCNConfigFetch.h index 6a49f0150f..dbef87d206 100644 --- a/FirebaseRemoteConfig/Sources/Private/RCNConfigFetch.h +++ b/FirebaseRemoteConfig/Sources/Private/RCNConfigFetch.h @@ -30,6 +30,11 @@ NS_ASSUME_NONNULL_BEGIN /// Completion handler invoked by NSSessionFetcher. typedef void (^RCNConfigFetcherCompletion)(NSData *data, NSURLResponse *response, NSError *error); +/// Completion handler invoked after a fetch that contains the updated keys +typedef void (^RCNConfigFetchCompletion)(FIRRemoteConfigFetchStatus status, + FIRRemoteConfigUpdate *update, + NSError *error); + @interface RCNConfigFetch : NSObject - (instancetype)init NS_UNAVAILABLE; @@ -50,12 +55,22 @@ typedef void (^RCNConfigFetcherCompletion)(NSData *data, NSURLResponse *response - (void)fetchConfigWithExpirationDuration:(NSTimeInterval)expirationDuration completionHandler:(FIRRemoteConfigFetchCompletion)completionHandler; +/// Fetches config data immediately, keyed by namespace. Completion block will be called on the main +/// queue. +/// @param fetchAttemptNumber The number of the fetch attempt. +/// @param completionHandler Callback handler. +- (void)realtimeFetchConfigWithNoExpirationDuration:(NSInteger)fetchAttemptNumber + completionHandler:(RCNConfigFetchCompletion)completionHandler; + /// Add the ability to update NSURLSession's timeout after a session has already been created. - (void)recreateNetworkSession; /// Provide fetchSession for tests to override. @property(nonatomic, readwrite, strong, nonnull) NSURLSession *fetchSession; +/// Provide config template version number for Realtime config client. +@property(nonatomic, copy, nonnull) NSString *templateVersionNumber; + NS_ASSUME_NONNULL_END @end diff --git a/FirebaseRemoteConfig/Sources/Private/RCNConfigSettings.h b/FirebaseRemoteConfig/Sources/Private/RCNConfigSettings.h index 43852a1319..987f3a9822 100644 --- a/FirebaseRemoteConfig/Sources/Private/RCNConfigSettings.h +++ b/FirebaseRemoteConfig/Sources/Private/RCNConfigSettings.h @@ -79,6 +79,8 @@ @property(nonatomic, readwrite, assign) NSString *lastETag; /// The timestamp of the last eTag update. @property(nonatomic, readwrite, assign) NSTimeInterval lastETagUpdateTime; +// Last fetched template version. +@property(nonatomic, readwrite, assign) NSString *lastTemplateVersion; #pragma mark Throttling properties @@ -90,6 +92,14 @@ @property(nonatomic, readwrite, assign) double exponentialBackoffRetryInterval; /// Returns the time in seconds until the next request is allowed while in exponential backoff mode. @property(nonatomic, readonly, assign) NSTimeInterval exponentialBackoffThrottleEndTime; +/// Returns the current retry interval in seconds set for exponential backoff for the Realtime +/// service. +@property(nonatomic, readwrite, assign) double realtimeExponentialBackoffRetryInterval; +/// Returns the time in seconds until the next request is allowed while in exponential backoff mode +/// for the Realtime service. +@property(nonatomic, readonly, assign) NSTimeInterval realtimeExponentialBackoffThrottleEndTime; +/// Realtime connection attempts. +@property(nonatomic, readwrite, assign) int realtimeRetryCount; #pragma mark Throttling Methods @@ -113,12 +123,21 @@ /// Updates the metadata table with the current fetch status. /// @param fetchSuccess True if fetch was successful. -- (void)updateMetadataWithFetchSuccessStatus:(BOOL)fetchSuccess; +- (void)updateMetadataWithFetchSuccessStatus:(BOOL)fetchSuccess + templateVersion:(NSString *)templateVersion; /// Increases the throttling time. Should only be called if the fetch error indicates a server /// issue. - (void)updateExponentialBackoffTime; +/// Increases the throttling time for Realtime. Should only be called if the Realtime error +/// indicates a server issue. +- (void)updateRealtimeExponentialBackoffTime; + +/// Returns the difference between the Realtime backoff end time and the current time in a +/// NSTimeInterval format. +- (NSTimeInterval)getRealtimeBackoffInterval; + /// Returns true if we are in exponential backoff mode and it is not yet the next request time. - (BOOL)shouldThrottle; diff --git a/FirebaseRemoteConfig/Sources/Public/FirebaseRemoteConfig/FIRRemoteConfig.h b/FirebaseRemoteConfig/Sources/Public/FirebaseRemoteConfig/FIRRemoteConfig.h index d72534878a..8e63626c0d 100644 --- a/FirebaseRemoteConfig/Sources/Public/FirebaseRemoteConfig/FIRRemoteConfig.h +++ b/FirebaseRemoteConfig/Sources/Public/FirebaseRemoteConfig/FIRRemoteConfig.h @@ -30,6 +30,23 @@ extern NSString *const _Nonnull FIRNamespaceGoogleMobilePlatform NS_SWIFT_NAME( extern NSString *const _Nonnull FIRRemoteConfigThrottledEndTimeInSecondsKey NS_SWIFT_NAME( RemoteConfigThrottledEndTimeInSecondsKey); +/** + * Listener registration returned by `addOnConfigUpdateListener`. Calling its method `remove` stops + * the listener from receiving config updates and unregisters itself. + * + * If remove is called and no other listener registrations remain, the connection to the real-time + * RC backend is closed. Subsequently calling `addOnConfigUpdateListener` will re-open the + * connection. + */ +NS_SWIFT_NAME(ConfigUpdateListenerRegistration) +@interface FIRConfigUpdateListenerRegistration : NSObject +/** + * Removes the listener being tracked by this `ConfigUpdateListenerRegistration`. After the initial + * call, subsequent calls have no effect. + */ +- (void)remove; +@end + /// Indicates whether updated data was successfully fetched. typedef NS_ENUM(NSInteger, FIRRemoteConfigFetchStatus) { /// Config has never been fetched. @@ -65,6 +82,20 @@ typedef NS_ERROR_ENUM(FIRRemoteConfigErrorDomain, FIRRemoteConfigError){ FIRRemoteConfigErrorInternalError = 8003, } NS_SWIFT_NAME(RemoteConfigError); +/// Remote Config error domain that handles errors for the real-time service. +extern NSString *const _Nonnull FIRRemoteConfigUpdateErrorDomain NS_SWIFT_NAME(RemoteConfigUpdateErrorDomain); +/// Firebase Remote Config real-time service error. +typedef NS_ERROR_ENUM(FIRRemoteConfigUpdateErrorDomain, FIRRemoteConfigUpdateError){ + /// Unable to make a connection to the backend. + FIRRemoteConfigUpdateErrorStreamError = 8001, + /// Unable to fetch the latest config. + FIRRemoteConfigUpdateErrorNotFetched = 8002, + /// The ConfigUpdate message was unparsable. + FIRRemoteConfigUpdateErrorMessageInvalid = 8003, + /// The real-time Remote Config service is unavailable. + FIRRemoteConfigUpdateErrorUnavailable = 8004, +} NS_SWIFT_NAME(RemoteConfigUpdateError); + /// 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. @@ -139,6 +170,17 @@ NS_SWIFT_NAME(RemoteConfigSettings) @property(nonatomic, assign) NSTimeInterval fetchTimeout; @end +#pragma mark - FIRRemoteConfigUpdate +/// Firebase Remote Config update +NS_SWIFT_NAME(RemoteConfigUpdate) +@interface FIRRemoteConfigUpdate : NSObject + +/// Parameter keys whose values have been updated from the currently activated values. Includes +/// keys that are added, deleted, and whose value, value source, or metadata has changed. +@property(nonatomic, readonly, nonnull) NSSet *updatedKeys; + +@end + #pragma mark - FIRRemoteConfig /// Firebase Remote Config class. The class method `remoteConfig()` can be used /// to fetch, activate and read config results and set default config results on the default @@ -283,4 +325,34 @@ NS_SWIFT_NAME(RemoteConfig) /// nil if the key doesn't exist in the default config. - (nullable FIRRemoteConfigValue *)defaultValueForKey:(nullable NSString *)key; +#pragma mark - Realtime + +/// Completion handler invoked by `addOnConfigUpdateListener` when there is an update to +/// the config from the backend. +/// +/// @param configUpdate Information on which key's values have changed +/// @param error Error message on failure. +typedef void (^FIRRemoteConfigUpdateCompletion)(FIRRemoteConfigUpdate *_Nullable configUpdate, + NSError *_Nullable error) + NS_SWIFT_UNAVAILABLE("Use Swift's closure syntax instead."); + +/// Start listening for real-time config updates from the Remote Config backend and automatically +/// fetch updates when they're available. +/// +/// If a connection to the Remote Config backend is not already open, calling this method will +/// open it. Multiple listeners can be added by calling this method again, but subsequent calls +/// re-use the same connection to the backend. +/// +/// Note: Real-time Remote Config requires the Firebase Remote Config Realtime API. See Get started +/// with Firebase Remote Config at https://firebase.google.com/docs/remote-config/get-started for +/// more information. +/// +/// @param listener The configured listener that is called for every config update. +/// @return Returns a registration representing the listener. The registration contains +/// a remove method, which can be used to stop receiving for updates for this particular +/// registration. +- (FIRConfigUpdateListenerRegistration *_Nonnull)addOnConfigUpdateListener: + (FIRRemoteConfigUpdateCompletion _Nonnull)listener + NS_SWIFT_NAME(addOnConfigUpdateListener(remoteConfigUpdateCompletion:)); + @end diff --git a/FirebaseRemoteConfig/Sources/RCNConfigConstants.h b/FirebaseRemoteConfig/Sources/RCNConfigConstants.h index cd062b59e7..db0e0213ae 100644 --- a/FirebaseRemoteConfig/Sources/RCNConfigConstants.h +++ b/FirebaseRemoteConfig/Sources/RCNConfigConstants.h @@ -58,3 +58,5 @@ static NSString *const RCNFetchResponseKeyStateNoTemplate = @"NO_TEMPLATE"; static NSString *const RCNFetchResponseKeyStateNoChange = @"NO_CHANGE"; /// Template found, but evaluates to empty (e.g. all keys omitted). static NSString *const RCNFetchResponseKeyStateEmptyConfig = @"EMPTY_CONFIG"; +/// Template Version key +static NSString *const RCNFetchResponseKeyTemplateVersion = @"templateVersion"; diff --git a/FirebaseRemoteConfig/Sources/RCNConfigContent.h b/FirebaseRemoteConfig/Sources/RCNConfigContent.h index 946e044b35..34d0895243 100644 --- a/FirebaseRemoteConfig/Sources/RCNConfigContent.h +++ b/FirebaseRemoteConfig/Sources/RCNConfigContent.h @@ -16,6 +16,8 @@ #import +#import "FirebaseRemoteConfig/Sources/Public/FirebaseRemoteConfig/FIRRemoteConfig.h" + typedef NS_ENUM(NSInteger, RCNDBSource) { RCNDBSourceActive, RCNDBSourceDefault, @@ -63,4 +65,7 @@ typedef NS_ENUM(NSInteger, RCNDBSource) { /// Gets the active config and Personalization metadata. - (NSDictionary *)getConfigAndMetadataForNamespace:(NSString *)FIRNamespace; +/// Returns the updated parameters between fetched and active config. +- (FIRRemoteConfigUpdate *)getConfigUpdateForNamespace:(NSString *)FIRNamespace; + @end diff --git a/FirebaseRemoteConfig/Sources/RCNConfigContent.m b/FirebaseRemoteConfig/Sources/RCNConfigContent.m index faf00a8eda..4f55a2e927 100644 --- a/FirebaseRemoteConfig/Sources/RCNConfigContent.m +++ b/FirebaseRemoteConfig/Sources/RCNConfigContent.m @@ -16,6 +16,7 @@ #import "FirebaseRemoteConfig/Sources/RCNConfigContent.h" +#import "FirebaseRemoteConfig/Sources/Private/FIRRemoteConfig_Private.h" #import "FirebaseRemoteConfig/Sources/Public/FirebaseRemoteConfig/FIRRemoteConfig.h" #import "FirebaseRemoteConfig/Sources/RCNConfigConstants.h" #import "FirebaseRemoteConfig/Sources/RCNConfigDBManager.h" @@ -363,6 +364,11 @@ const NSTimeInterval kDatabaseLoadTimeoutSecs = 30.0; return _defaultConfig; } +- (NSDictionary *)activePersonalization { + [self checkAndWaitForInitialDatabaseLoad]; + return _activePersonalization; +} + - (NSDictionary *)getConfigAndMetadataForNamespace:(NSString *)FIRNamespace { /// If this is the first time reading the active metadata, we might still be reading it from the /// database. @@ -392,4 +398,49 @@ const NSTimeInterval kDatabaseLoadTimeoutSecs = 30.0; return true; } +// Compare fetched config with active config and output what has changed +- (FIRRemoteConfigUpdate *)getConfigUpdateForNamespace:(NSString *)FIRNamespace { + // TODO: handle diff in experiment metadata + + FIRRemoteConfigUpdate *configUpdate; + NSMutableSet *updatedKeys = [[NSMutableSet alloc] init]; + + NSDictionary *fetchedConfig = + _fetchedConfig[FIRNamespace] ? _fetchedConfig[FIRNamespace] : [[NSDictionary alloc] init]; + NSDictionary *activeConfig = + _activeConfig[FIRNamespace] ? _activeConfig[FIRNamespace] : [[NSDictionary alloc] init]; + NSDictionary *fetchedP13n = _fetchedPersonalization; + NSDictionary *activeP13n = _activePersonalization; + + // add new/updated params + for (NSString *key in [fetchedConfig allKeys]) { + if (activeConfig[key] == nil || + ![[activeConfig[key] stringValue] isEqualToString:[fetchedConfig[key] stringValue]]) { + [updatedKeys addObject:key]; + } + } + // add deleted params + for (NSString *key in [activeConfig allKeys]) { + if (fetchedConfig[key] == nil) { + [updatedKeys addObject:key]; + } + } + + // add params with new/updated p13n metadata + for (NSString *key in [fetchedP13n allKeys]) { + if (activeP13n[key] == nil || ![activeP13n[key] isEqualToDictionary:fetchedP13n[key]]) { + [updatedKeys addObject:key]; + } + } + // add params with deleted p13n metadata + for (NSString *key in [activeP13n allKeys]) { + if (fetchedP13n[key] == nil) { + [updatedKeys addObject:key]; + } + } + + configUpdate = [[FIRRemoteConfigUpdate alloc] initWithUpdatedKeys:updatedKeys]; + return configUpdate; +} + @end diff --git a/FirebaseRemoteConfig/Sources/RCNConfigFetch.m b/FirebaseRemoteConfig/Sources/RCNConfigFetch.m index a9a62a4922..aab76a8bed 100644 --- a/FirebaseRemoteConfig/Sources/RCNConfigFetch.m +++ b/FirebaseRemoteConfig/Sources/RCNConfigFetch.m @@ -15,6 +15,7 @@ */ #import "FirebaseRemoteConfig/Sources/Private/RCNConfigFetch.h" +#import "FirebaseRemoteConfig/Sources/Private/FIRRemoteConfig_Private.h" #import #import "FirebaseCore/Extension/FirebaseCoreInternal.h" @@ -51,6 +52,11 @@ static NSString *const kInstallationsAuthTokenHeaderName = @"x-goog-firebase-ins static NSString *const kiOSBundleIdentifierHeaderName = @"X-Ios-Bundle-Identifier"; ///< HTTP Header Field Name +static NSString *const kFetchTypeHeaderName = + @"X-Firebase-RC-Fetch-Type"; ///< Custom Http header key to identify the fetch type +static NSString *const kBaseFetchType = @"BASE"; ///< Fetch identifier for Base Fetch +static NSString *const kRealtimeFetchType = @"REALTIME"; ///< Fetch identifier for Realtime Fetch + /// Config HTTP request content type proto buffer static NSString *const kContentTypeValueJSON = @"application/json"; @@ -61,9 +67,6 @@ static NSInteger const kRCNFetchResponseHTTPStatusCodeInternalError = 500; static NSInteger const kRCNFetchResponseHTTPStatusCodeServiceUnavailable = 503; static NSInteger const kRCNFetchResponseHTTPStatusCodeGatewayTimeout = 504; -// Deprecated error code previously from FirebaseCore -static const NSInteger sFIRErrorCodeConfigFailed = -114; - #pragma mark - RCNConfig @implementation RCNConfigFetch { @@ -75,6 +78,7 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114; NSURLSession *_fetchSession; /// Managed internally by the fetch instance. NSString *_FIRNamespace; FIROptions *_options; + NSString *_templateVersionNumber; } - (instancetype)init { @@ -101,6 +105,7 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114; _content = content; _fetchSession = [self newFetchSession]; _options = options; + _templateVersionNumber = [self->_settings lastTemplateVersion]; } return self; } @@ -158,16 +163,9 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114; } else { FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000053", @"A fetch is already in progress. Ignoring duplicate request."); - NSError *error = - [NSError errorWithDomain:FIRRemoteConfigErrorDomain - code:sFIRErrorCodeConfigFailed - userInfo:@{ - NSLocalizedDescriptionKey : - @"FetchError: Duplicate request while the previous one is pending" - }]; return [strongSelf reportCompletionOnHandler:completionHandler withStatus:FIRRemoteConfigFetchStatusFailure - withError:error]; + withError:nil]; } } @@ -189,19 +187,89 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114; withError:error]; } strongSelf->_settings.isFetchInProgress = YES; - [strongSelf refreshInstallationsTokenWithCompletionHandler:completionHandler]; + NSString *fetchTypeHeader = [NSString stringWithFormat:@"%@/1", kBaseFetchType]; + [strongSelf refreshInstallationsTokenWithFetchHeader:fetchTypeHeader + completionHandler:completionHandler + updateCompletionHandler:nil]; }); } #pragma mark - Fetch helpers +- (void)realtimeFetchConfigWithNoExpirationDuration:(NSInteger)fetchAttemptNumber + completionHandler:(RCNConfigFetchCompletion)completionHandler { + // Note: We expect the googleAppID to always be available. + BOOL hasDeviceContextChanged = + FIRRemoteConfigHasDeviceContextChanged(_settings.deviceContext, _options.googleAppID); + + __weak RCNConfigFetch *weakSelf = self; + dispatch_async(_lockQueue, ^{ + RCNConfigFetch *strongSelf = weakSelf; + if (strongSelf == nil) { + return; + } + + // Check if a fetch is already in progress. + // TODO: for this case should we still return a SUCCESS status? + if (strongSelf->_settings.isFetchInProgress) { + // Check if we have some fetched data. + if (strongSelf->_settings.lastFetchTimeInterval > 0) { + FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000052", + @"A fetch is already in progress. Using previous fetch results."); + FIRRemoteConfigUpdate *update = + [self->_content getConfigUpdateForNamespace:self->_FIRNamespace]; + return [strongSelf reportCompletionWithStatus:strongSelf->_settings.lastFetchStatus + withUpdate:update + withError:nil + completionHandler:nil + updateCompletionHandler:completionHandler]; + } else { + FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000053", + @"A fetch is already in progress. Ignoring duplicate request."); + return [strongSelf reportCompletionWithStatus:FIRRemoteConfigFetchStatusFailure + withUpdate:nil + withError:nil + completionHandler:nil + updateCompletionHandler:completionHandler]; + } + } + // Check whether cache data is within throttle limit. + if ([strongSelf->_settings shouldThrottle] && !hasDeviceContextChanged) { + // Must set lastFetchStatus before FailReason. + strongSelf->_settings.lastFetchStatus = FIRRemoteConfigFetchStatusThrottled; + strongSelf->_settings.lastFetchError = FIRRemoteConfigErrorThrottled; + NSTimeInterval throttledEndTime = strongSelf->_settings.exponentialBackoffThrottleEndTime; + + NSError *error = + [NSError errorWithDomain:FIRRemoteConfigErrorDomain + code:FIRRemoteConfigErrorThrottled + userInfo:@{ + FIRRemoteConfigThrottledEndTimeInSecondsKey : @(throttledEndTime) + }]; + return [strongSelf reportCompletionWithStatus:FIRRemoteConfigFetchStatusFailure + withUpdate:nil + withError:error + completionHandler:nil + updateCompletionHandler:completionHandler]; + } + strongSelf->_settings.isFetchInProgress = YES; + + NSString *fetchTypeHeader = + [NSString stringWithFormat:@"%@/%ld", kRealtimeFetchType, (long)fetchAttemptNumber]; + [strongSelf refreshInstallationsTokenWithFetchHeader:fetchTypeHeader + completionHandler:nil + updateCompletionHandler:completionHandler]; + }); +} + - (NSString *)FIRAppNameFromFullyQualifiedNamespace { return [[_FIRNamespace componentsSeparatedByString:@":"] lastObject]; } /// Refresh installation ID token before fetching config. installation ID is now mandatory for fetch /// requests to work.(b/14751422). -- (void)refreshInstallationsTokenWithCompletionHandler: - (FIRRemoteConfigFetchCompletion)completionHandler { +- (void)refreshInstallationsTokenWithFetchHeader:(NSString *)fetchTypeHeader + completionHandler:(FIRRemoteConfigFetchCompletion)completionHandler + updateCompletionHandler:(RCNConfigFetchCompletion)updateCompletionHandler { FIRInstallations *installations = [FIRInstallations installationsWithApp:[FIRApp appNamed:[self FIRAppNameFromFullyQualifiedNamespace]]]; if (!installations || !_options.GCMSenderID) { @@ -286,7 +354,9 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114; FIRLogInfo(kFIRLoggerRemoteConfig, @"I-RCN000022", @"Success to get iid : %@.", strongSelfQueue->_settings.configInstallationsIdentifier); - [strongSelf doFetchCall:completionHandler]; + [strongSelf doFetchCall:fetchTypeHeader + completionHandler:completionHandler + updateCompletionHandler:updateCompletionHandler]; }); }]; }; @@ -295,10 +365,15 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114; [installations authTokenWithCompletion:installationsTokenHandler]; } -- (void)doFetchCall:(FIRRemoteConfigFetchCompletion)completionHandler { +- (void)doFetchCall:(NSString *)fetchTypeHeader + completionHandler:(FIRRemoteConfigFetchCompletion)completionHandler + updateCompletionHandler:(RCNConfigFetchCompletion)updateCompletionHandler { [self getAnalyticsUserPropertiesWithCompletionHandler:^(NSDictionary *userProperties) { dispatch_async(self->_lockQueue, ^{ - [self fetchWithUserProperties:userProperties completionHandler:completionHandler]; + [self fetchWithUserProperties:userProperties + fetchTypeHeader:fetchTypeHeader + completionHandler:completionHandler + updateCompletionHandler:updateCompletionHandler]; }); }]; } @@ -317,15 +392,35 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114; - (void)reportCompletionOnHandler:(FIRRemoteConfigFetchCompletion)completionHandler withStatus:(FIRRemoteConfigFetchStatus)status withError:(NSError *)error { + [self reportCompletionWithStatus:status + withUpdate:nil + withError:error + completionHandler:completionHandler + updateCompletionHandler:nil]; +} + +- (void)reportCompletionWithStatus:(FIRRemoteConfigFetchStatus)status + withUpdate:(FIRRemoteConfigUpdate *)update + withError:(NSError *)error + completionHandler:(FIRRemoteConfigFetchCompletion)completionHandler + updateCompletionHandler:(RCNConfigFetchCompletion)updateCompletionHandler { if (completionHandler) { dispatch_async(dispatch_get_main_queue(), ^{ completionHandler(status, error); }); } + // if completion handler expects a config update response + if (updateCompletionHandler) { + dispatch_async(dispatch_get_main_queue(), ^{ + updateCompletionHandler(status, update, error); + }); + } } - (void)fetchWithUserProperties:(NSDictionary *)userProperties - completionHandler:(FIRRemoteConfigFetchCompletion)completionHandler { + fetchTypeHeader:(NSString *)fetchTypeHeader + completionHandler:(FIRRemoteConfigFetchCompletion)completionHandler + updateCompletionHandler:(RCNConfigFetchCompletion)updateCompletionHandler { FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000061", @"Fetch with user properties initiated."); NSString *postRequestString = [_settings nextRequestWithUserProperties:userProperties]; @@ -337,15 +432,16 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114; if (compressionError) { NSString *errString = [NSString stringWithFormat:@"Failed to compress the config request."]; FIRLogWarning(kFIRLoggerRemoteConfig, @"I-RCN000033", @"%@", errString); + NSError *error = [NSError errorWithDomain:FIRRemoteConfigErrorDomain + code:FIRRemoteConfigErrorInternalError + userInfo:@{NSLocalizedDescriptionKey : errString}]; self->_settings.isFetchInProgress = NO; - return [self - reportCompletionOnHandler:completionHandler - withStatus:FIRRemoteConfigFetchStatusFailure - withError:[NSError - errorWithDomain:FIRRemoteConfigErrorDomain - code:FIRRemoteConfigErrorInternalError - userInfo:@{NSLocalizedDescriptionKey : errString}]]; + return [self reportCompletionWithStatus:FIRRemoteConfigFetchStatusFailure + withUpdate:nil + withError:error + completionHandler:completionHandler + updateCompletionHandler:updateCompletionHandler]; } FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000040", @"Start config fetch."); @@ -374,7 +470,7 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114; if (error || (statusCode != kRCNFetchResponseHTTPStatusCodeOK)) { // Update metadata about fetch failure. - [strongSelf->_settings updateMetadataWithFetchSuccessStatus:NO]; + [strongSelf->_settings updateMetadataWithFetchSuccessStatus:NO templateVersion:nil]; if (error) { if (strongSelf->_settings.lastFetchStatus == FIRRemoteConfigFetchStatusSuccess) { FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000025", @@ -388,6 +484,7 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114; FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000026", @"RCN Fetch failure. Response http error code: %ld", (long)statusCode); // Response error code 429, 500, 503 will trigger exponential backoff mode. + // TODO: check error code in helper if (statusCode == kRCNFetchResponseHTTPStatusTooManyRequests || statusCode == kRCNFetchResponseHTTPStatusCodeInternalError || statusCode == kRCNFetchResponseHTTPStatusCodeServiceUnavailable || @@ -406,9 +503,11 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114; userInfo:@{ FIRRemoteConfigThrottledEndTimeInSecondsKey : @(throttledEndTime) }]; - return [strongSelf reportCompletionOnHandler:completionHandler - withStatus:strongSelf->_settings.lastFetchStatus - withError:error]; + return [strongSelf reportCompletionWithStatus:strongSelf->_settings.lastFetchStatus + withUpdate:nil + withError:error + completionHandler:completionHandler + updateCompletionHandler:updateCompletionHandler]; } } } @@ -423,20 +522,27 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114; stringWithFormat:@"Internal Error. Status code: %ld", (long)statusCode]) }; return [strongSelf - reportCompletionOnHandler:completionHandler - withStatus:FIRRemoteConfigFetchStatusFailure - withError:[NSError errorWithDomain:FIRRemoteConfigErrorDomain - code:FIRRemoteConfigErrorInternalError - userInfo:userInfo]]; + reportCompletionWithStatus:FIRRemoteConfigFetchStatusFailure + withUpdate:nil + withError:[NSError errorWithDomain:FIRRemoteConfigErrorDomain + code:FIRRemoteConfigErrorInternalError + userInfo:userInfo] + completionHandler:completionHandler + updateCompletionHandler:updateCompletionHandler]; } // Fetch was successful. Check if we have data. NSError *retError; if (!data) { FIRLogInfo(kFIRLoggerRemoteConfig, @"I-RCN000043", @"RCN Fetch: No data in fetch response"); - return [strongSelf reportCompletionOnHandler:completionHandler - withStatus:FIRRemoteConfigFetchStatusSuccess - withError:nil]; + // There may still be a difference between fetched and active config + FIRRemoteConfigUpdate *update = + [strongSelf->_content getConfigUpdateForNamespace:strongSelf->_FIRNamespace]; + return [strongSelf reportCompletionWithStatus:FIRRemoteConfigFetchStatusSuccess + withUpdate:update + withError:nil + completionHandler:completionHandler + updateCompletionHandler:updateCompletionHandler]; } // Config fetch succeeded. @@ -472,13 +578,14 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114; errDict[RCNFetchResponseKeyErrorMessage]]]; } FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000044", @"%@.", errStr); - return [strongSelf - reportCompletionOnHandler:completionHandler - withStatus:FIRRemoteConfigFetchStatusFailure - withError:[NSError - errorWithDomain:FIRRemoteConfigErrorDomain - code:FIRRemoteConfigErrorInternalError - userInfo:@{NSLocalizedDescriptionKey : errStr}]]; + NSError *error = [NSError errorWithDomain:FIRRemoteConfigErrorDomain + code:FIRRemoteConfigErrorInternalError + userInfo:@{NSLocalizedDescriptionKey : errStr}]; + return [strongSelf reportCompletionWithStatus:FIRRemoteConfigFetchStatusFailure + withUpdate:nil + withError:error + completionHandler:completionHandler + updateCompletionHandler:updateCompletionHandler]; } // Add the fetched config to the database. @@ -493,6 +600,8 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114; [strongSelf->_experiment updateExperimentsWithResponse: fetchedConfig[RCNFetchResponseKeyExperimentDescriptions]]; } + + strongSelf->_templateVersionNumber = [strongSelf getTemplateVersionNumber:fetchedConfig]; } else { FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000063", @"Empty response with no fetched config."); @@ -504,17 +613,25 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114; !([strongSelf->_settings.lastETag isEqualToString:latestETag])) { strongSelf->_settings.lastETag = latestETag; } + // Compute config update after successful fetch + FIRRemoteConfigUpdate *update = + [strongSelf->_content getConfigUpdateForNamespace:strongSelf->_FIRNamespace]; - [strongSelf->_settings updateMetadataWithFetchSuccessStatus:YES]; - return [strongSelf reportCompletionOnHandler:completionHandler - withStatus:FIRRemoteConfigFetchStatusSuccess - withError:nil]; + [strongSelf->_settings + updateMetadataWithFetchSuccessStatus:YES + templateVersion:strongSelf->_templateVersionNumber]; + return [strongSelf reportCompletionWithStatus:FIRRemoteConfigFetchStatusSuccess + withUpdate:update + withError:nil + completionHandler:completionHandler + updateCompletionHandler:updateCompletionHandler]; }); }; FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000061", @"Making remote config fetch."); NSURLSessionDataTask *dataTask = [self URLSessionDataTaskWithContent:compressedContent + fetchTypeHeader:fetchTypeHeader completionHandler:fetcherCompletion]; [dataTask resume]; } @@ -554,6 +671,7 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114; } - (NSURLSessionDataTask *)URLSessionDataTaskWithContent:(NSData *)content + fetchTypeHeader:(NSString *)fetchTypeHeader completionHandler: (RCNConfigFetcherCompletion)fetcherCompletion { NSURL *URL = [NSURL URLWithString:[self constructServerURL]]; @@ -573,6 +691,7 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114; forHTTPHeaderField:kiOSBundleIdentifierHeaderName]; [URLRequest setValue:@"gzip" forHTTPHeaderField:kContentEncodingHeaderName]; [URLRequest setValue:@"gzip" forHTTPHeaderField:kAcceptEncodingHeaderName]; + [URLRequest setValue:fetchTypeHeader forHTTPHeaderField:kFetchTypeHeaderName]; // Set the eTag from the last successful fetch, if available. if (_settings.lastETag) { [URLRequest setValue:_settings.lastETag forHTTPHeaderField:kIfNoneMatchETagHeaderName]; @@ -582,4 +701,14 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114; return [_fetchSession dataTaskWithRequest:URLRequest completionHandler:fetcherCompletion]; } +- (NSString *)getTemplateVersionNumber:(NSDictionary *)fetchedConfig { + if (fetchedConfig != nil && [fetchedConfig objectForKey:RCNFetchResponseKeyTemplateVersion] && + [[fetchedConfig objectForKey:RCNFetchResponseKeyTemplateVersion] + isKindOfClass:[NSString class]]) { + return (NSString *)[fetchedConfig objectForKey:RCNFetchResponseKeyTemplateVersion]; + } + + return @"0"; +} + @end diff --git a/FirebaseRemoteConfig/Sources/RCNConfigRealtime.h b/FirebaseRemoteConfig/Sources/RCNConfigRealtime.h new file mode 100644 index 0000000000..56b6ad1b08 --- /dev/null +++ b/FirebaseRemoteConfig/Sources/RCNConfigRealtime.h @@ -0,0 +1,40 @@ +/* + * Copyright 2022 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 +#import "FirebaseRemoteConfig/Sources/Private/RCNConfigFetch.h" +#import "FirebaseRemoteConfig/Sources/Public/FirebaseRemoteConfig/FIRRemoteConfig.h" + +@class RCNConfigSettings; + +@interface RCNConfigRealtime : NSObject + +/// Completion handler invoked by config update methods when they get a response from the server. +/// +/// @param error Error message on failure. +typedef void (^RCNConfigUpdateCompletion)(FIRRemoteConfigUpdate *_Nullable configUpdate, + NSError *_Nullable error); + +- (instancetype _Nonnull)init:(RCNConfigFetch *_Nonnull)configFetch + settings:(RCNConfigSettings *_Nonnull)settings + namespace:(NSString *_Nonnull)namespace + options:(FIROptions *_Nonnull)options; + +- (FIRConfigUpdateListenerRegistration *_Nonnull)addConfigUpdateListener: + (RCNConfigUpdateCompletion _Nonnull)listener; +- (void)removeConfigUpdateListener:(RCNConfigUpdateCompletion _Nonnull)listener; + +@end diff --git a/FirebaseRemoteConfig/Sources/RCNConfigRealtime.m b/FirebaseRemoteConfig/Sources/RCNConfigRealtime.m new file mode 100644 index 0000000000..23db8c77e5 --- /dev/null +++ b/FirebaseRemoteConfig/Sources/RCNConfigRealtime.m @@ -0,0 +1,715 @@ +/* + * Copyright 2022 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 "FirebaseRemoteConfig/Sources/RCNConfigRealtime.h" +#import +#import +#import "FirebaseCore/Extension/FirebaseCoreInternal.h" +#import "FirebaseInstallations/Source/Library/Private/FirebaseInstallationsInternal.h" +#import "FirebaseRemoteConfig/Sources/Private/RCNConfigFetch.h" +#import "FirebaseRemoteConfig/Sources/Private/RCNConfigSettings.h" +#import "FirebaseRemoteConfig/Sources/RCNConfigConstants.h" +#import "FirebaseRemoteConfig/Sources/RCNDevice.h" + +/// URL params +static NSString *const kServerURLDomain = @"https://firebaseremoteconfigrealtime.googleapis.com"; +static NSString *const kServerURLVersion = @"/v1"; +static NSString *const kServerURLProjects = @"/projects/"; +static NSString *const kServerURLNamespaces = @"/namespaces/"; +static NSString *const kServerURLQuery = @":streamFetchInvalidations?"; +static NSString *const kServerURLKey = @"key="; + +/// Realtime API enablement +static NSString *const kServerForbiddenStatusCode = @"\"code\": 403"; + +/// Header names +static NSString *const kHTTPMethodPost = @"POST"; ///< HTTP request method config fetch using +static NSString *const kContentTypeHeaderName = @"Content-Type"; ///< HTTP Header Field Name +static NSString *const kContentEncodingHeaderName = + @"Content-Encoding"; ///< HTTP Header Field Name +static NSString *const kAcceptEncodingHeaderName = @"Accept"; ///< HTTP Header Field Name +static NSString *const kETagHeaderName = @"etag"; ///< HTTP Header Field Name +static NSString *const kIfNoneMatchETagHeaderName = @"if-none-match"; ///< HTTP Header Field Name +static NSString *const kInstallationsAuthTokenHeaderName = @"x-goog-firebase-installations-auth"; +// Sends the bundle ID. Refer to b/130301479 for details. +static NSString *const kiOSBundleIdentifierHeaderName = + @"X-Ios-Bundle-Identifier"; ///< HTTP Header Field Name + +/// Retryable HTTP status code. +static NSInteger const kRCNFetchResponseHTTPStatusOk = 200; +static NSInteger const kRCNFetchResponseHTTPStatusClientTimeout = 429; +static NSInteger const kRCNFetchResponseHTTPStatusTooManyRequests = 429; +static NSInteger const kRCNFetchResponseHTTPStatusCodeBadGateway = 502; +static NSInteger const kRCNFetchResponseHTTPStatusCodeServiceUnavailable = 503; +static NSInteger const kRCNFetchResponseHTTPStatusCodeGatewayTimeout = 504; + +/// Invalidation message field names. +static NSString *const kTemplateVersionNumberKey = @"latestTemplateVersionNumber"; +static NSString *const kIsFeatureDisabled = @"featureDisabled"; + +static NSTimeInterval gTimeoutSeconds = 330; +static NSInteger const gFetchAttempts = 3; + +// Retry parameters +static NSInteger const gMaxRetries = 7; + +@interface FIRConfigUpdateListenerRegistration () +@property(strong, atomic, nonnull) RCNConfigUpdateCompletion completionHandler; +@end + +@implementation FIRConfigUpdateListenerRegistration { + RCNConfigRealtime *_realtimeClient; +} + +- (instancetype)initWithClient:(RCNConfigRealtime *)realtimeClient + completionHandler:(RCNConfigUpdateCompletion)completionHandler { + self = [super init]; + if (self) { + _realtimeClient = realtimeClient; + _completionHandler = completionHandler; + } + return self; +} + +- (void)remove { + [self->_realtimeClient removeConfigUpdateListener:_completionHandler]; +} + +@end + +@interface RCNConfigRealtime () + +@property(strong, atomic, nonnull) NSMutableSet *listeners; +@property(strong, atomic, nonnull) dispatch_queue_t realtimeLockQueue; +@property(strong, atomic, nonnull) NSNotificationCenter *notificationCenter; + +@property(strong, atomic) NSURLSession *session; +@property(strong, atomic) NSURLSessionDataTask *dataTask; +@property(strong, atomic) NSMutableURLRequest *request; + +@end + +@implementation RCNConfigRealtime { + RCNConfigFetch *_configFetch; + RCNConfigSettings *_settings; + FIROptions *_options; + NSString *_namespace; + NSInteger _remainingRetryCount; + bool _isRequestInProgress; + bool _isInBackground; + bool _isRealtimeDisabled; +} + +- (instancetype)init:(RCNConfigFetch *)configFetch + settings:(RCNConfigSettings *)settings + namespace:(NSString *)namespace + options:(FIROptions *)options { + self = [super init]; + if (self) { + _listeners = [[NSMutableSet alloc] init]; + _realtimeLockQueue = [RCNConfigRealtime realtimeRemoteConfigSerialQueue]; + _notificationCenter = [NSNotificationCenter defaultCenter]; + + _configFetch = configFetch; + _settings = settings; + _options = options; + _namespace = namespace; + + _remainingRetryCount = MAX(gMaxRetries - [_settings realtimeRetryCount], 1); + _isRequestInProgress = false; + _isRealtimeDisabled = false; + _isInBackground = false; + + [self setUpHttpRequest]; + [self setUpHttpSession]; + [self backgroundChangeListener]; + } + + return self; +} + +/// Singleton instance of serial queue for queuing all incoming RC calls. ++ (dispatch_queue_t)realtimeRemoteConfigSerialQueue { + static dispatch_once_t onceToken; + static dispatch_queue_t realtimeRemoteConfigQueue; + dispatch_once(&onceToken, ^{ + realtimeRemoteConfigQueue = + dispatch_queue_create(RCNRemoteConfigQueueLabel, DISPATCH_QUEUE_SERIAL); + }); + return realtimeRemoteConfigQueue; +} + +- (void)propogateErrors:(NSError *)error { + __weak RCNConfigRealtime *weakSelf = self; + dispatch_async(_realtimeLockQueue, ^{ + __strong RCNConfigRealtime *strongSelf = weakSelf; + for (RCNConfigUpdateCompletion listener in strongSelf->_listeners) { + listener(nil, error); + } + }); +} + +#pragma mark - Test Only Helpers + +// TESTING ONLY +- (void)triggerListenerForTesting:(void (^_Nonnull)(FIRRemoteConfigUpdate *configUpdate, + NSError *_Nullable error))listener { + listener([[FIRRemoteConfigUpdate alloc] init], nil); +} + +#pragma mark - Http Helpers + +- (NSString *)constructServerURL { + NSString *serverURLStr = [[NSString alloc] initWithString:kServerURLDomain]; + serverURLStr = [serverURLStr stringByAppendingString:kServerURLVersion]; + serverURLStr = [serverURLStr stringByAppendingString:kServerURLProjects]; + serverURLStr = [serverURLStr stringByAppendingString:_options.GCMSenderID]; + serverURLStr = [serverURLStr stringByAppendingString:kServerURLNamespaces]; + + /// Get the namespace from the fully qualified namespace string of "namespace:FIRAppName". + NSString *namespace = [_namespace substringToIndex:[_namespace rangeOfString:@":"].location]; + serverURLStr = [serverURLStr stringByAppendingString:namespace]; + serverURLStr = [serverURLStr stringByAppendingString:kServerURLQuery]; + if (_options.APIKey) { + serverURLStr = [serverURLStr stringByAppendingString:kServerURLKey]; + serverURLStr = [serverURLStr stringByAppendingString:_options.APIKey]; + } else { + FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000071", + @"Missing `APIKey` from `FirebaseOptions`, please ensure the configured " + @"`FirebaseApp` is configured with `FirebaseOptions` that contains an `APIKey`."); + } + + return serverURLStr; +} + +- (NSString *)FIRAppNameFromFullyQualifiedNamespace { + return [[_namespace componentsSeparatedByString:@":"] lastObject]; +} + +- (void)reportCompletionOnHandler:(FIRRemoteConfigFetchCompletion)completionHandler + withStatus:(FIRRemoteConfigFetchStatus)status + withError:(NSError *)error { + if (completionHandler) { + dispatch_async(_realtimeLockQueue, ^{ + completionHandler(status, error); + }); + } +} + +/// Refresh installation ID token before fetching config. installation ID is now mandatory for fetch +/// requests to work.(b/14751422). +- (void)refreshInstallationsTokenWithCompletionHandler: + (FIRRemoteConfigFetchCompletion)completionHandler { + FIRInstallations *installations = [FIRInstallations + installationsWithApp:[FIRApp appNamed:[self FIRAppNameFromFullyQualifiedNamespace]]]; + if (!installations || !_options.GCMSenderID) { + NSString *errorDescription = @"Failed to get GCMSenderID"; + FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000074", @"%@", + [NSString stringWithFormat:@"%@", errorDescription]); + return [self + reportCompletionOnHandler:completionHandler + withStatus:FIRRemoteConfigFetchStatusFailure + withError:[NSError errorWithDomain:FIRRemoteConfigErrorDomain + code:FIRRemoteConfigErrorInternalError + userInfo:@{ + NSLocalizedDescriptionKey : errorDescription + }]]; + } + + __weak RCNConfigRealtime *weakSelf = self; + FIRInstallationsTokenHandler installationsTokenHandler = ^( + FIRInstallationsAuthTokenResult *tokenResult, NSError *error) { + RCNConfigRealtime *strongSelf = weakSelf; + if (strongSelf == nil) { + return; + } + + if (!tokenResult || !tokenResult.authToken || error) { + NSString *errorDescription = + [NSString stringWithFormat:@"Failed to get installations token. Error : %@.", error]; + FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000073", @"%@", + [NSString stringWithFormat:@"%@", errorDescription]); + return [strongSelf + reportCompletionOnHandler:completionHandler + withStatus:FIRRemoteConfigFetchStatusFailure + withError:[NSError errorWithDomain:FIRRemoteConfigErrorDomain + code:FIRRemoteConfigErrorInternalError + userInfo:@{ + NSLocalizedDescriptionKey : errorDescription + }]]; + } + + /// We have a valid token. Get the backing installationID. + [installations installationIDWithCompletion:^(NSString *_Nullable identifier, + NSError *_Nullable error) { + RCNConfigRealtime *strongSelf = weakSelf; + if (strongSelf == nil) { + return; + } + + // Dispatch to the RC serial queue to update settings on the queue. + dispatch_async(strongSelf->_realtimeLockQueue, ^{ + RCNConfigRealtime *strongSelfQueue = weakSelf; + if (strongSelfQueue == nil) { + return; + } + + /// Update config settings with the IID and token. + strongSelfQueue->_settings.configInstallationsToken = tokenResult.authToken; + strongSelfQueue->_settings.configInstallationsIdentifier = identifier; + + if (!identifier || error) { + NSString *errorDescription = + [NSString stringWithFormat:@"Error getting iid : %@.", error]; + FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000055", @"%@", + [NSString stringWithFormat:@"%@", errorDescription]); + strongSelfQueue->_settings.isFetchInProgress = NO; + return [strongSelfQueue + reportCompletionOnHandler:completionHandler + withStatus:FIRRemoteConfigFetchStatusFailure + withError:[NSError + errorWithDomain:FIRRemoteConfigErrorDomain + code:FIRRemoteConfigErrorInternalError + userInfo:@{ + NSLocalizedDescriptionKey : errorDescription + }]]; + } + + FIRLogInfo(kFIRLoggerRemoteConfig, @"I-RCN000022", @"Success to get iid : %@.", + strongSelfQueue->_settings.configInstallationsIdentifier); + }); + }]; + }; + + FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000039", @"Starting requesting token."); + [installations authTokenWithCompletion:installationsTokenHandler]; +} + +- (void)setRequestBody { + [self refreshInstallationsTokenWithCompletionHandler:^(FIRRemoteConfigFetchStatus status, + NSError *_Nullable error) { + if (status != FIRRemoteConfigFetchStatusSuccess) { + FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000013", @"Installation token retrival failed."); + } + }]; + + [_request setValue:_settings.configInstallationsToken + forHTTPHeaderField:kInstallationsAuthTokenHeaderName]; + if (_settings.lastETag) { + [_request setValue:_settings.lastETag forHTTPHeaderField:kIfNoneMatchETagHeaderName]; + } + + NSString *namespace = [_namespace substringToIndex:[_namespace rangeOfString:@":"].location]; + NSString *postBody = [NSString + stringWithFormat:@"{project:'%@', namespace:'%@', lastKnownVersionNumber:'%@', appId:'%@', " + @"sdkVersion:'%@'}", + [self->_options GCMSenderID], namespace, _configFetch.templateVersionNumber, + _options.googleAppID, FIRRemoteConfigPodVersion()]; + NSData *postData = [postBody dataUsingEncoding:NSUTF8StringEncoding]; + NSError *compressionError; + NSData *compressedContent = [NSData gul_dataByGzippingData:postData error:&compressionError]; + + [_request setHTTPBody:compressedContent]; +} + +/// Creates request. +- (void)setUpHttpRequest { + NSString *address = [self constructServerURL]; + _request = [[NSMutableURLRequest alloc] initWithURL:[NSURL URLWithString:address] + cachePolicy:NSURLRequestReloadIgnoringLocalCacheData + timeoutInterval:gTimeoutSeconds]; + [_request setHTTPMethod:kHTTPMethodPost]; + [_request setValue:@"application/json" forHTTPHeaderField:kContentTypeHeaderName]; + [_request setValue:@"application/json" forHTTPHeaderField:kAcceptEncodingHeaderName]; + [_request setValue:@"gzip" forHTTPHeaderField:kContentEncodingHeaderName]; + [_request setValue:@"true" forHTTPHeaderField:@"X-Google-GFE-Can-Retry"]; + [_request setValue:[_options APIKey] forHTTPHeaderField:@"X-Goog-Api-Key"]; + [_request setValue:[[NSBundle mainBundle] bundleIdentifier] + forHTTPHeaderField:kiOSBundleIdentifierHeaderName]; +} + +/// Makes call to create session. +- (void)setUpHttpSession { + NSURLSessionConfiguration *sessionConfig = + [[NSURLSessionConfiguration defaultSessionConfiguration] copy]; + [sessionConfig setTimeoutIntervalForResource:gTimeoutSeconds]; + [sessionConfig setTimeoutIntervalForRequest:gTimeoutSeconds]; + _session = [NSURLSession sessionWithConfiguration:sessionConfig + delegate:self + delegateQueue:[NSOperationQueue mainQueue]]; +} + +#pragma mark - Retry Helpers + +// Retry mechanism for HTTP connections +- (void)retryHTTPConnection { + __weak RCNConfigRealtime *weakSelf = self; + dispatch_async(_realtimeLockQueue, ^{ + __strong RCNConfigRealtime *strongSelf = weakSelf; + if (strongSelf->_isInBackground) { + return; + } + + bool noRunningConnection = + strongSelf->_dataTask == nil || strongSelf->_dataTask.state != NSURLSessionTaskStateRunning; + bool canMakeConnection = noRunningConnection && [strongSelf->_listeners count] > 0 && + !strongSelf->_isRealtimeDisabled; + if (canMakeConnection && strongSelf->_remainingRetryCount > 0) { + NSTimeInterval backOffInterval = self->_settings.getRealtimeBackoffInterval; + + strongSelf->_remainingRetryCount--; + [strongSelf->_settings setRealtimeRetryCount:[strongSelf->_settings realtimeRetryCount] + 1]; + dispatch_time_t executionDelay = + dispatch_time(DISPATCH_TIME_NOW, (backOffInterval * NSEC_PER_SEC)); + dispatch_after(executionDelay, strongSelf->_realtimeLockQueue, ^{ + [strongSelf beginRealtimeStream]; + }); + } else { + NSError *error = [NSError + errorWithDomain:FIRRemoteConfigUpdateErrorDomain + code:FIRRemoteConfigUpdateErrorStreamError + userInfo:@{ + NSLocalizedDescriptionKey : + @"Unable to connect to the server. Check your connection and try again." + }]; + FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000014", @"Cannot establish connection. Error: %@", + error); + [self propogateErrors:error]; + } + }); +} + +- (void)backgroundChangeListener { + [_notificationCenter addObserver:self + selector:@selector(isInForeground) + name:@"UIApplicationWillEnterForegroundNotification" + object:nil]; + + [_notificationCenter addObserver:self + selector:@selector(isInBackground) + name:@"UIApplicationDidEnterBackgroundNotification" + object:nil]; +} + +- (void)isInForeground { + __weak RCNConfigRealtime *weakSelf = self; + dispatch_async(_realtimeLockQueue, ^{ + __strong RCNConfigRealtime *strongSelf = weakSelf; + strongSelf->_isInBackground = false; + [strongSelf beginRealtimeStream]; + }); +} + +- (void)isInBackground { + __weak RCNConfigRealtime *weakSelf = self; + dispatch_async(_realtimeLockQueue, ^{ + __strong RCNConfigRealtime *strongSelf = weakSelf; + [strongSelf pauseRealtimeStream]; + strongSelf->_isInBackground = true; + }); +} + +#pragma mark - Autofetch Helpers + +- (void)fetchLatestConfig:(NSInteger)remainingAttempts targetVersion:(NSInteger)targetVersion { + __weak RCNConfigRealtime *weakSelf = self; + dispatch_async(_realtimeLockQueue, ^{ + __strong RCNConfigRealtime *strongSelf = weakSelf; + NSInteger attempts = remainingAttempts - 1; + + [strongSelf->_configFetch + realtimeFetchConfigWithNoExpirationDuration:gFetchAttempts - attempts + completionHandler:^(FIRRemoteConfigFetchStatus status, + FIRRemoteConfigUpdate *update, + NSError *error) { + if (error != nil) { + FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000010", + @"Failed to retrive config due to fetch error. " + @"Error: %@", + error); + return [self propogateErrors:error]; + } + if (status == FIRRemoteConfigFetchStatusSuccess) { + if ([strongSelf->_configFetch.templateVersionNumber + integerValue] >= targetVersion) { + // only notify listeners if there is a change + if ([update updatedKeys].count > 0) { + for (RCNConfigUpdateCompletion listener in strongSelf + ->_listeners) { + listener(update, nil); + } + } + } else { + FIRLogDebug( + kFIRLoggerRemoteConfig, @"I-RCN000016", + @"Fetched config's template version is outdated, " + @"re-fetching"); + [strongSelf autoFetch:attempts targetVersion:targetVersion]; + } + } else { + FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000016", + @"Fetched config's template version is " + @"outdated, re-fetching"); + [strongSelf autoFetch:attempts targetVersion:targetVersion]; + } + }]; + }); +} + +- (void)scheduleFetch:(NSInteger)remainingAttempts targetVersion:(NSInteger)targetVersion { + /// Needs fetch to occur between 0 - 3 seconds. Randomize to not cause DDoS alerts in backend + dispatch_time_t executionDelay = + dispatch_time(DISPATCH_TIME_NOW, arc4random_uniform(4) * NSEC_PER_SEC); + dispatch_after(executionDelay, _realtimeLockQueue, ^{ + [self fetchLatestConfig:remainingAttempts targetVersion:targetVersion]; + }); +} + +/// Perform fetch and handle developers callbacks +- (void)autoFetch:(NSInteger)remainingAttempts targetVersion:(NSInteger)targetVersion { + __weak RCNConfigRealtime *weakSelf = self; + dispatch_async(_realtimeLockQueue, ^{ + __strong RCNConfigRealtime *strongSelf = weakSelf; + if (remainingAttempts == 0) { + NSError *error = [NSError errorWithDomain:FIRRemoteConfigUpdateErrorDomain + code:FIRRemoteConfigUpdateErrorNotFetched + userInfo:@{ + NSLocalizedDescriptionKey : + @"Unable to fetch the latest version of the template.." + }]; + FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000011", + @"Ran out of fetch attempts, cannot find target config version."); + [self propogateErrors:error]; + return; + } + + [strongSelf scheduleFetch:remainingAttempts targetVersion:targetVersion]; + }); +} + +#pragma mark - NSURLSession Delegates + +- (void)evaluateStreamResponse:(NSDictionary *)response error:(NSError *)dataError { + NSInteger updateTemplateVersion = 1; + if (dataError == nil) { + if ([response objectForKey:kTemplateVersionNumberKey]) { + updateTemplateVersion = [[response objectForKey:kTemplateVersionNumberKey] integerValue]; + } + if ([response objectForKey:kIsFeatureDisabled]) { + self->_isRealtimeDisabled = [response objectForKey:kIsFeatureDisabled]; + } + + if (self->_isRealtimeDisabled) { + [self pauseRealtimeStream]; + NSError *error = [NSError + errorWithDomain:FIRRemoteConfigUpdateErrorDomain + code:FIRRemoteConfigUpdateErrorUnavailable + userInfo:@{ + NSLocalizedDescriptionKey : + @"The server is temporarily unavailable. Try again in a few minutes." + }]; + [self propogateErrors:error]; + } else { + NSInteger clientTemplateVersion = [_configFetch.templateVersionNumber integerValue]; + if (updateTemplateVersion > clientTemplateVersion) { + [self autoFetch:gFetchAttempts targetVersion:updateTemplateVersion]; + } + } + } else { + NSError *error = + [NSError errorWithDomain:FIRRemoteConfigUpdateErrorDomain + code:FIRRemoteConfigUpdateErrorMessageInvalid + userInfo:@{NSLocalizedDescriptionKey : @"Unable to parse ConfigUpdate."}]; + [self propogateErrors:error]; + } +} + +/// Delegate to asynchronously handle every new notification that comes over the wire. Auto-fetches +/// and runs callback for each new notification +- (void)URLSession:(NSURLSession *)session + dataTask:(NSURLSessionDataTask *)dataTask + didReceiveData:(NSData *)data { + NSError *dataError; + NSString *strData = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + + /// If response data contains the API enablement link, return the entire message to the user in + /// the form of a error. + if ([strData containsString:kServerForbiddenStatusCode]) { + NSError *error = [NSError errorWithDomain:FIRRemoteConfigUpdateErrorDomain + code:FIRRemoteConfigUpdateErrorStreamError + userInfo:@{NSLocalizedDescriptionKey : strData}]; + FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000021", @"Cannot establish connection. %@", error); + [self propogateErrors:error]; + return; + } + + NSRange endRange = [strData rangeOfString:@"}"]; + NSRange beginRange = [strData rangeOfString:@"{"]; + if (beginRange.location != NSNotFound && endRange.location != NSNotFound) { + FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000015", + @"Received config update message on stream."); + NSRange msgRange = + NSMakeRange(beginRange.location, endRange.location - beginRange.location + 1); + strData = [strData substringWithRange:msgRange]; + data = [strData dataUsingEncoding:NSUTF8StringEncoding]; + NSDictionary *response = [NSJSONSerialization JSONObjectWithData:data + options:NSJSONReadingMutableContainers + error:&dataError]; + + [self evaluateStreamResponse:response error:dataError]; + } +} + +/// Check if response code is retryable +- (bool)isStatusCodeRetryable:(NSInteger)statusCode { + return statusCode == kRCNFetchResponseHTTPStatusClientTimeout || + statusCode == kRCNFetchResponseHTTPStatusTooManyRequests || + statusCode == kRCNFetchResponseHTTPStatusCodeServiceUnavailable || + statusCode == kRCNFetchResponseHTTPStatusCodeBadGateway || + statusCode == kRCNFetchResponseHTTPStatusCodeGatewayTimeout; +} + +/// Delegate to handle initial reply from the server +- (void)URLSession:(NSURLSession *)session + dataTask:(NSURLSessionDataTask *)dataTask + didReceiveResponse:(NSURLResponse *)response + completionHandler:(void (^)(NSURLSessionResponseDisposition disposition))completionHandler { + _isRequestInProgress = false; + NSHTTPURLResponse *_httpURLResponse = (NSHTTPURLResponse *)response; + NSInteger statusCode = [_httpURLResponse statusCode]; + + if (statusCode == 403) { + completionHandler(NSURLSessionResponseAllow); + return; + } + + if (statusCode != kRCNFetchResponseHTTPStatusOk) { + [self->_settings updateRealtimeExponentialBackoffTime]; + [self pauseRealtimeStream]; + + if ([self isStatusCodeRetryable:statusCode]) { + [self retryHTTPConnection]; + } else { + NSError *error = [NSError + errorWithDomain:FIRRemoteConfigUpdateErrorDomain + code:FIRRemoteConfigUpdateErrorStreamError + userInfo:@{ + NSLocalizedDescriptionKey : + [NSString stringWithFormat:@"Unable to connect to the server. Try again in " + @"a few minutes. Http Status code: %@", + [@(statusCode) stringValue]] + }]; + FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000021", @"Cannot establish connection. Error: %@", + error); + } + } else { + /// on success reset retry parameters + _remainingRetryCount = gMaxRetries; + [self->_settings setRealtimeRetryCount:0]; + } + + completionHandler(NSURLSessionResponseAllow); +} + +/// Delegate to handle data task completion +- (void)URLSession:(NSURLSession *)session + task:(NSURLSessionTask *)task + didCompleteWithError:(NSError *)error { + _isRequestInProgress = false; + if (error != nil && [error code] != NSURLErrorCancelled) { + [self->_settings updateRealtimeExponentialBackoffTime]; + } + [self pauseRealtimeStream]; + [self retryHTTPConnection]; +} + +/// Delegate to handle session invalidation +- (void)URLSession:(NSURLSession *)session didBecomeInvalidWithError:(NSError *)error { + if (!_isRequestInProgress) { + if (error != nil) { + [self->_settings updateRealtimeExponentialBackoffTime]; + } + [self pauseRealtimeStream]; + [self retryHTTPConnection]; + } +} + +#pragma mark - Top level methods + +- (void)beginRealtimeStream { + __weak RCNConfigRealtime *weakSelf = self; + dispatch_async(_realtimeLockQueue, ^{ + __strong RCNConfigRealtime *strongSelf = weakSelf; + bool noRunningConnection = + strongSelf->_dataTask == nil || strongSelf->_dataTask.state != NSURLSessionTaskStateRunning; + bool canMakeConnection = noRunningConnection && [strongSelf->_listeners count] > 0 && + !strongSelf->_isInBackground && !strongSelf->_isRealtimeDisabled; + + if (self->_settings.getRealtimeBackoffInterval > 0) { + [self retryHTTPConnection]; + return; + } + + if (canMakeConnection) { + strongSelf->_isRequestInProgress = true; + [strongSelf setRequestBody]; + strongSelf->_dataTask = [strongSelf->_session dataTaskWithRequest:strongSelf->_request]; + [strongSelf->_dataTask resume]; + } + }); +} + +- (void)pauseRealtimeStream { + __weak RCNConfigRealtime *weakSelf = self; + dispatch_async(_realtimeLockQueue, ^{ + __strong RCNConfigRealtime *strongSelf = weakSelf; + if (strongSelf->_dataTask != nil) { + [strongSelf->_dataTask cancel]; + strongSelf->_dataTask = nil; + } + }); +} + +- (FIRConfigUpdateListenerRegistration *)addConfigUpdateListener: + (void (^_Nonnull)(FIRRemoteConfigUpdate *configUpdate, NSError *_Nullable error))listener { + if (listener == nil) { + return nil; + } + + __weak RCNConfigRealtime *weakSelf = self; + dispatch_async(_realtimeLockQueue, ^{ + __strong RCNConfigRealtime *strongSelf = weakSelf; + [strongSelf->_listeners addObject:listener]; + [strongSelf beginRealtimeStream]; + }); + + return [[FIRConfigUpdateListenerRegistration alloc] initWithClient:self + completionHandler:listener]; +} + +- (void)removeConfigUpdateListener:(void (^_Nonnull)(FIRRemoteConfigUpdate *configUpdate, + NSError *_Nullable error))listener { + __weak RCNConfigRealtime *weakSelf = self; + dispatch_async(_realtimeLockQueue, ^{ + __strong RCNConfigRealtime *strongSelf = weakSelf; + [strongSelf->_listeners removeObject:listener]; + if (strongSelf->_listeners.count == 0) { + [strongSelf pauseRealtimeStream]; + } + }); +} + +@end diff --git a/FirebaseRemoteConfig/Sources/RCNConfigSettings.m b/FirebaseRemoteConfig/Sources/RCNConfigSettings.m index 45b672ae4c..0b3e3ad116 100644 --- a/FirebaseRemoteConfig/Sources/RCNConfigSettings.m +++ b/FirebaseRemoteConfig/Sources/RCNConfigSettings.m @@ -110,6 +110,11 @@ static const int kRCNExponentialBackoffMaximumInterval = 60 * 60 * 4; // 4 hour } _isFetchInProgress = NO; + _lastTemplateVersion = [_userDefaultsManager lastTemplateVersion]; + _realtimeExponentialBackoffRetryInterval = + [_userDefaultsManager currentRealtimeThrottlingRetryIntervalSeconds]; + _realtimeExponentialBackoffThrottleEndTime = [_userDefaultsManager realtimeThrottleEndTime]; + _realtimeRetryCount = [_userDefaultsManager realtimeRetryCount]; } return self; } @@ -233,7 +238,51 @@ static const int kRCNExponentialBackoffMaximumInterval = 60 * 60 * 4; // 4 hour [[NSDate date] timeIntervalSince1970] + randomizedRetryInterval; } -- (void)updateMetadataWithFetchSuccessStatus:(BOOL)fetchSuccess { +/// If the last Realtime stream attempt was not successful, update the (exponential backoff) period +/// that we wait until trying again. Any subsequent Realtime requests will be checked and allowed +/// only if past this throttle end time. +- (void)updateRealtimeExponentialBackoffTime { + // If there was only one stream attempt before, reset the retry interval. + if (_realtimeRetryCount == 0) { + FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000058", + @"Throttling: Entering exponential Realtime backoff mode."); + _realtimeExponentialBackoffRetryInterval = kRCNExponentialBackoffMinimumInterval; + } else { + FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000058", + @"Throttling: Updating Realtime throttling interval."); + // Double the retry interval until we hit the truncated exponential backoff. More info here: + // https://cloud.google.com/storage/docs/exponential-backoff + _realtimeExponentialBackoffRetryInterval = + ((_realtimeExponentialBackoffRetryInterval * 2) < kRCNExponentialBackoffMaximumInterval) + ? _realtimeExponentialBackoffRetryInterval * 2 + : _realtimeExponentialBackoffRetryInterval; + } + + // Randomize the next retry interval. + int randomPlusMinusInterval = ((arc4random() % 2) == 0) ? -1 : 1; + NSTimeInterval randomizedRetryInterval = + _realtimeExponentialBackoffRetryInterval + + (0.5 * _realtimeExponentialBackoffRetryInterval * randomPlusMinusInterval); + _realtimeExponentialBackoffThrottleEndTime = + [[NSDate date] timeIntervalSince1970] + randomizedRetryInterval; + + [_userDefaultsManager setRealtimeThrottleEndTime:_realtimeExponentialBackoffThrottleEndTime]; + [_userDefaultsManager + setCurrentRealtimeThrottlingRetryIntervalSeconds:_realtimeExponentialBackoffRetryInterval]; +} + +- (void)setRealtimeRetryCount:(int)realtimeRetryCount { + _realtimeRetryCount = realtimeRetryCount; + [_userDefaultsManager setRealtimeRetryCount:_realtimeRetryCount]; +} + +- (NSTimeInterval)getRealtimeBackoffInterval { + NSTimeInterval now = [[NSDate date] timeIntervalSince1970]; + return _realtimeExponentialBackoffThrottleEndTime - now; +} + +- (void)updateMetadataWithFetchSuccessStatus:(BOOL)fetchSuccess + templateVersion:(NSString *)templateVersion { FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000056", @"Updating metadata with fetch result."); [self updateFetchTimeWithSuccessFetch:fetchSuccess]; _lastFetchStatus = @@ -243,6 +292,7 @@ static const int kRCNExponentialBackoffMaximumInterval = 60 * 60 * 4; // 4 hour [self updateLastFetchTimeInterval:[[NSDate date] timeIntervalSince1970]]; // Note: We expect the googleAppID to always be available. _deviceContext = FIRRemoteConfigDeviceContextWithProjectIdentifier(_googleAppID); + [_userDefaultsManager setLastTemplateVersion:templateVersion]; } [self updateMetadataTable]; diff --git a/FirebaseRemoteConfig/Sources/RCNUserDefaultsManager.h b/FirebaseRemoteConfig/Sources/RCNUserDefaultsManager.h index 020d651ebc..acbcd5842f 100644 --- a/FirebaseRemoteConfig/Sources/RCNUserDefaultsManager.h +++ b/FirebaseRemoteConfig/Sources/RCNUserDefaultsManager.h @@ -36,6 +36,15 @@ NS_ASSUME_NONNULL_BEGIN /// The retry interval increases exponentially for cumulative fetch failures. Refer to /// go/rc-client-throttling for details. @property(nonatomic, assign) NSTimeInterval currentThrottlingRetryIntervalSeconds; +/// Time when the next request can be made while being throttled. +@property(nonatomic, assign) NSTimeInterval realtimeThrottleEndTime; +/// The retry interval increases exponentially for cumulative Realtime failures. Refer to +/// go/rc-client-throttling for details. +@property(nonatomic, assign) NSTimeInterval currentRealtimeThrottlingRetryIntervalSeconds; +/// Realtime retry count. +@property(nonatomic, assign) int realtimeRetryCount; +/// Last fetched template version. +@property(nonatomic, assign) NSString *lastTemplateVersion; /// Designated initializer. - (instancetype)initWithAppName:(NSString *)appName diff --git a/FirebaseRemoteConfig/Sources/RCNUserDefaultsManager.m b/FirebaseRemoteConfig/Sources/RCNUserDefaultsManager.m index c3ea8f1bcd..29ec2e87a0 100644 --- a/FirebaseRemoteConfig/Sources/RCNUserDefaultsManager.m +++ b/FirebaseRemoteConfig/Sources/RCNUserDefaultsManager.m @@ -17,6 +17,7 @@ #import "FirebaseRemoteConfig/Sources/RCNUserDefaultsManager.h" #import "FirebaseCore/Extension/FirebaseCoreInternal.h" #import "FirebaseRemoteConfig/Sources/Public/FirebaseRemoteConfig/FIRRemoteConfig.h" +#import "FirebaseRemoteConfig/Sources/RCNConfigConstants.h" static NSString *const kRCNGroupPrefix = @"group"; static NSString *const kRCNGroupSuffix = @"firebase"; @@ -29,6 +30,10 @@ static NSString *const kRCNUserDefaultsKeyNameIsClientThrottled = static NSString *const kRCNUserDefaultsKeyNameThrottleEndTime = @"throttleEndTime"; static NSString *const kRCNUserDefaultsKeyNamecurrentThrottlingRetryInterval = @"currentThrottlingRetryInterval"; +static NSString *const kRCNUserDefaultsKeyNameRealtimeThrottleEndTime = @"throttleRealtimeEndTime"; +static NSString *const kRCNUserDefaultsKeyNameCurrentRealtimeThrottlingRetryInterval = + @"currentRealtimeThrottlingRetryInterval"; +static NSString *const kRCNUserDefaultsKeyNameRealtimeRetryCount = @"realtimeRetryCount"; @interface RCNUserDefaultsManager () { /// User Defaults instance for this bundleID. NSUserDefaults is guaranteed to be thread-safe. @@ -106,6 +111,21 @@ static NSString *const kRCNUserDefaultsKeyNamecurrentThrottlingRetryInterval = } } +- (NSString *)lastTemplateVersion { + NSDictionary *userDefaults = [self instanceUserDefaults]; + if ([userDefaults objectForKey:RCNFetchResponseKeyTemplateVersion]) { + return [userDefaults objectForKey:RCNFetchResponseKeyTemplateVersion]; + } + + return @"0"; +} + +- (void)setLastTemplateVersion:(NSString *)templateVersion { + if (templateVersion) { + [self setInstanceUserDefaultsValue:templateVersion forKey:RCNFetchResponseKeyTemplateVersion]; + } +} + - (NSTimeInterval)lastETagUpdateTime { NSNumber *lastETagUpdateTime = [[self instanceUserDefaults] objectForKey:kRCNUserDefaultsKeyNamelastETagUpdateTime]; @@ -174,6 +194,51 @@ static NSString *const kRCNUserDefaultsKeyNamecurrentThrottlingRetryInterval = forKey:kRCNUserDefaultsKeyNamecurrentThrottlingRetryInterval]; } +- (int)realtimeRetryCount { + int realtimeRetryCount = 0; + if ([[self instanceUserDefaults] objectForKey:kRCNUserDefaultsKeyNameRealtimeRetryCount]) { + realtimeRetryCount = [[[self instanceUserDefaults] + objectForKey:kRCNUserDefaultsKeyNameRealtimeRetryCount] intValue]; + } + + return realtimeRetryCount; +} + +- (void)setRealtimeRetryCount:(int)realtimeRetryCount { + [self setInstanceUserDefaultsValue:[NSNumber numberWithInt:realtimeRetryCount] + forKey:kRCNUserDefaultsKeyNameRealtimeRetryCount]; +} + +- (NSTimeInterval)realtimeThrottleEndTime { + NSNumber *realtimeThrottleEndTime = 0; + if ([[self instanceUserDefaults] objectForKey:kRCNUserDefaultsKeyNameRealtimeThrottleEndTime]) { + realtimeThrottleEndTime = + [[self instanceUserDefaults] objectForKey:kRCNUserDefaultsKeyNameRealtimeThrottleEndTime]; + } + return realtimeThrottleEndTime.doubleValue; +} + +- (void)setRealtimeThrottleEndTime:(NSTimeInterval)throttleEndTime { + [self setInstanceUserDefaultsValue:@(throttleEndTime) + forKey:kRCNUserDefaultsKeyNameRealtimeThrottleEndTime]; +} + +- (NSTimeInterval)currentRealtimeThrottlingRetryIntervalSeconds { + NSNumber *realtimeThrottleEndTime = 0; + if ([[self instanceUserDefaults] + objectForKey:kRCNUserDefaultsKeyNameCurrentRealtimeThrottlingRetryInterval]) { + realtimeThrottleEndTime = [[self instanceUserDefaults] + objectForKey:kRCNUserDefaultsKeyNameCurrentRealtimeThrottlingRetryInterval]; + } + return realtimeThrottleEndTime.doubleValue; +} + +- (void)setCurrentRealtimeThrottlingRetryIntervalSeconds: + (NSTimeInterval)throttlingRetryIntervalSeconds { + [self setInstanceUserDefaultsValue:@(throttlingRetryIntervalSeconds) + forKey:kRCNUserDefaultsKeyNameCurrentRealtimeThrottlingRetryInterval]; +} + #pragma mark Public methods. - (void)resetUserDefaults { [self resetInstanceUserDefaults]; diff --git a/FirebaseRemoteConfig/Tests/Sample/RemoteConfigSampleApp/ViewController.m b/FirebaseRemoteConfig/Tests/Sample/RemoteConfigSampleApp/ViewController.m index beee9160b3..57c766a903 100644 --- a/FirebaseRemoteConfig/Tests/Sample/RemoteConfigSampleApp/ViewController.m +++ b/FirebaseRemoteConfig/Tests/Sample/RemoteConfigSampleApp/ViewController.m @@ -103,10 +103,44 @@ static NSString *const FIRSecondFIRAppName = @"secondFIRApp"; } FIRRemoteConfigSettings *settings = [[FIRRemoteConfigSettings alloc] init]; settings.fetchTimeout = 300; - settings.minimumFetchInterval = 0; + settings.minimumFetchInterval = 300; ((FIRRemoteConfig *)(self.RCInstances[namespaceString][appString])).configSettings = settings; } } + + /// UI popup for Realtime that shows if realtime_test_key was included in update. + UIAlertController *alert = [UIAlertController + alertControllerWithTitle:@"Alert" + message:@"The value for realtime_test_key has been updated!" + preferredStyle:UIAlertControllerStyleAlert]; + UIAlertAction *defaultAction = [UIAlertAction actionWithTitle:@"OK" + style:UIAlertActionStyleDefault + handler:^(UIAlertAction *action){ + }]; + [alert addAction:defaultAction]; + + // Add realtime listener for firebase namespace + [self.RCInstances[FIRNamespaceGoogleMobilePlatform][FIRDefaultFIRAppName] + addOnConfigUpdateListener:^(FIRRemoteConfigUpdate *_Nullable update, + NSError *_Nullable error) { + if (error != nil) { + [[FRCLog sharedInstance] + logToConsole:[NSString + stringWithFormat:@"Realtime Error: %@", error.localizedDescription]]; + } else { + [[FRCLog sharedInstance] logToConsole:[NSString stringWithFormat:@"Config updated!"]]; + if (update != nil) { + /// UI popup that lets user know that fetch included realtime_test_key in updatedKeys. + if ([[update updatedKeys] containsObject:@"realtime_test_key"]) { + [self presentViewController:alert animated:YES completion:nil]; + } + NSString *updatedParams = [update updatedKeys]; + [[FRCLog sharedInstance] + logToConsole:[NSString stringWithFormat:[updatedParams description]]]; + [self apply]; + } + } + }]; [[FRCLog sharedInstance] logToConsole:@"RC instances inited"]; self.namespacePicker.dataSource = self; diff --git a/FirebaseRemoteConfig/Tests/Unit/RCNConfigContentTest.m b/FirebaseRemoteConfig/Tests/Unit/RCNConfigContentTest.m index 46fddf6212..d4f33bf0f7 100644 --- a/FirebaseRemoteConfig/Tests/Unit/RCNConfigContentTest.m +++ b/FirebaseRemoteConfig/Tests/Unit/RCNConfigContentTest.m @@ -19,6 +19,7 @@ #import "FirebaseRemoteConfig/Sources/Private/RCNConfigSettings.h" #import "FirebaseRemoteConfig/Sources/Public/FirebaseRemoteConfig/FIRRemoteConfig.h" +#import "FirebaseRemoteConfig/Sources/RCNConfigConstants.h" #import "FirebaseRemoteConfig/Sources/RCNConfigContent.h" #import "FirebaseRemoteConfig/Sources/RCNConfigDBManager.h" #import "FirebaseRemoteConfig/Sources/RCNConfigValue_Internal.h" @@ -326,4 +327,178 @@ extern const NSTimeInterval kDatabaseLoadTimeoutSecs; [self waitForExpectationsWithTimeout:0.5 * kDatabaseLoadTimeoutSecs handler:nil]; } +- (void)testConfigUpdate_noChange_emptyResponse { + NSString *namespace = @"test_namespace"; + + // populate fetched config + NSMutableDictionary *fetchResponse = + [self createFetchResponseWithConfigEntries:@{@"key1" : @"value1"} p13nMetadata:nil]; + [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace]; + + // active config is the same as fetched config + FIRRemoteConfigValue *value = + [[FIRRemoteConfigValue alloc] initWithData:[@"value1" dataUsingEncoding:NSUTF8StringEncoding] + source:FIRRemoteConfigSourceRemote]; + NSDictionary *namespaceToConfig = @{namespace : @{@"key1" : value}}; + [_configContent copyFromDictionary:namespaceToConfig + toSource:RCNDBSourceActive + forNamespace:namespace]; + + FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace]; + + XCTAssertTrue([update updatedKeys].count == 0); +} + +- (void)testConfigUpdate_paramAdded_returnsNewKey { + NSString *namespace = @"test_namespace"; + NSString *newParam = @"key2"; + + // populate active config + FIRRemoteConfigValue *value = + [[FIRRemoteConfigValue alloc] initWithData:[@"value1" dataUsingEncoding:NSUTF8StringEncoding] + source:FIRRemoteConfigSourceRemote]; + NSDictionary *namespaceToConfig = @{namespace : @{@"key1" : value}}; + [_configContent copyFromDictionary:namespaceToConfig + toSource:RCNDBSourceActive + forNamespace:namespace]; + + // fetch response has new param + NSMutableDictionary *fetchResponse = + [self createFetchResponseWithConfigEntries:@{@"key1" : @"value1", newParam : @"value2"} + p13nMetadata:nil]; + [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace]; + + FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace]; + + XCTAssertTrue([update updatedKeys].count == 1); + XCTAssertTrue([[update updatedKeys] containsObject:newParam]); +} + +- (void)testConfigUpdate_paramValueChanged_returnsUpdatedKey { + NSString *namespace = @"test_namespace"; + NSString *existingParam = @"key1"; + NSString *oldValue = @"value1"; + NSString *updatedValue = @"value2"; + + // active config contains old value + FIRRemoteConfigValue *value = + [[FIRRemoteConfigValue alloc] initWithData:[oldValue dataUsingEncoding:NSUTF8StringEncoding] + source:FIRRemoteConfigSourceRemote]; + NSDictionary *namespaceToConfig = @{namespace : @{existingParam : value}}; + [_configContent copyFromDictionary:namespaceToConfig + toSource:RCNDBSourceActive + forNamespace:namespace]; + + // fetch response contains updated value + NSMutableDictionary *fetchResponse = + [self createFetchResponseWithConfigEntries:@{existingParam : updatedValue} p13nMetadata:nil]; + [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace]; + + FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace]; + + XCTAssertTrue([update updatedKeys].count == 1); + XCTAssertTrue([[update updatedKeys] containsObject:existingParam]); +} + +- (void)testConfigUpdate_paramDeleted_returnsDeletedKey { + NSString *namespace = @"test_namespace"; + NSString *existingParam = @"key1"; + NSString *newParam = @"key2"; + NSString *value1 = @"value1"; + + // populate active config + FIRRemoteConfigValue *value = + [[FIRRemoteConfigValue alloc] initWithData:[value1 dataUsingEncoding:NSUTF8StringEncoding] + source:FIRRemoteConfigSourceRemote]; + NSDictionary *namespaceToConfig = @{namespace : @{existingParam : value}}; + [_configContent copyFromDictionary:namespaceToConfig + toSource:RCNDBSourceActive + forNamespace:namespace]; + + // fetch response does not contain existing param + NSMutableDictionary *fetchResponse = + [self createFetchResponseWithConfigEntries:@{newParam : value1} p13nMetadata:nil]; + [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace]; + + FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace]; + + XCTAssertTrue([update updatedKeys].count == 2); + XCTAssertTrue([[update updatedKeys] containsObject:existingParam]); // deleted + XCTAssertTrue([[update updatedKeys] containsObject:newParam]); // added +} + +- (void)testConfigUpdate_p13nMetadataUpdated_returnsKey { + NSString *namespace = @"test_namespace"; + NSString *existingParam = @"key1"; + NSString *value1 = @"value1"; + NSDictionary *oldMetadata = @{@"arm_index" : @"1"}; + NSDictionary *updatedMetadata = @{@"arm_index" : @"2"}; + + // popuate fetched config + NSMutableDictionary *fetchResponse = + [self createFetchResponseWithConfigEntries:@{existingParam : value1} + p13nMetadata:@{existingParam : oldMetadata}]; + [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace]; + + // populate active config with the same content + [_configContent activatePersonalization]; + FIRRemoteConfigValue *value = + [[FIRRemoteConfigValue alloc] initWithData:[value1 dataUsingEncoding:NSUTF8StringEncoding] + source:FIRRemoteConfigSourceRemote]; + NSDictionary *namespaceToConfig = @{namespace : @{existingParam : value}}; + [_configContent copyFromDictionary:namespaceToConfig + toSource:RCNDBSourceActive + forNamespace:namespace]; + + // fetched response has updated p13n metadata + [fetchResponse setValue:@{existingParam : updatedMetadata} + forKey:RCNFetchResponseKeyPersonalizationMetadata]; + [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace]; + + FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace]; + + XCTAssertTrue([update updatedKeys].count == 1); + XCTAssertTrue([[update updatedKeys] containsObject:existingParam]); +} + +- (void)testConfigUpdate_valueSourceChanged_returnsKey { + NSString *namespace = @"test_namespace"; + NSString *existingParam = @"key1"; + NSString *value1 = @"value1"; + + // set default config + FIRRemoteConfigValue *value = + [[FIRRemoteConfigValue alloc] initWithData:[value1 dataUsingEncoding:NSUTF8StringEncoding] + source:FIRRemoteConfigSourceDefault]; + NSDictionary *namespaceToConfig = @{namespace : @{existingParam : value}}; + [_configContent copyFromDictionary:namespaceToConfig + toSource:RCNDBSourceDefault + forNamespace:namespace]; + + // fetch response contains same key->value + NSMutableDictionary *fetchResponse = + [self createFetchResponseWithConfigEntries:@{existingParam : value1} p13nMetadata:nil]; + [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace]; + + FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace]; + + XCTAssertTrue([update updatedKeys].count == 1); + XCTAssertTrue([[update updatedKeys] containsObject:existingParam]); +} + +#pragma mark - Test Helpers + +- (NSMutableDictionary *)createFetchResponseWithConfigEntries:(NSDictionary *)config + p13nMetadata:(NSDictionary *)metadata { + NSMutableDictionary *fetchResponse = [[NSMutableDictionary alloc] + initWithObjectsAndKeys:RCNFetchResponseKeyStateUpdate, RCNFetchResponseKeyState, nil]; + if (config) { + [fetchResponse setValue:config forKey:RCNFetchResponseKeyEntries]; + } + if (metadata) { + [fetchResponse setValue:metadata forKey:RCNFetchResponseKeyPersonalizationMetadata]; + } + return fetchResponse; +} + @end diff --git a/FirebaseRemoteConfig/Tests/Unit/RCNPersonalizationTest.m b/FirebaseRemoteConfig/Tests/Unit/RCNPersonalizationTest.m index e8d3c13cd4..bdd8ab0b8b 100644 --- a/FirebaseRemoteConfig/Tests/Unit/RCNPersonalizationTest.m +++ b/FirebaseRemoteConfig/Tests/Unit/RCNPersonalizationTest.m @@ -29,11 +29,14 @@ @interface RCNConfigFetch (ForTest) - (NSURLSessionDataTask *)URLSessionDataTaskWithContent:(NSData *)content + fetchTypeHeader:(NSString *)fetchTypeHeader completionHandler: (RCNConfigFetcherCompletion)fetcherCompletion; - (void)fetchWithUserProperties:(NSDictionary *)userProperties - completionHandler:(FIRRemoteConfigFetchCompletion)completionHandler; + fetchTypeHeader:(NSString *)fetchTypeHeader + completionHandler:(FIRRemoteConfigFetchCompletion)completionHandler + updateCompletionHandler:(RCNConfigFetchCompletion)updateCompletionHandler; @end @interface RCNPersonalizationTest : XCTestCase { @@ -243,10 +246,14 @@ .andDo(^(NSInvocation *invocation) { __unsafe_unretained FIRRemoteConfigFetchCompletion handler; [invocation getArgument:&handler atIndex:3]; - [configFetch fetchWithUserProperties:[[NSDictionary alloc] init] completionHandler:handler]; + [configFetch fetchWithUserProperties:[[NSDictionary alloc] init] + fetchTypeHeader:@"Base/1" + completionHandler:handler + updateCompletionHandler:nil]; }); OCMExpect([configFetch URLSessionDataTaskWithContent:[OCMArg any] + fetchTypeHeader:@"Base/1" completionHandler:[RCNPersonalizationTest mockResponseHandler]]) .andReturn(nil); return configFetch; diff --git a/FirebaseRemoteConfig/Tests/Unit/RCNRemoteConfigTest.m b/FirebaseRemoteConfig/Tests/Unit/RCNRemoteConfigTest.m index 909edc662d..4fc6eb80d2 100644 --- a/FirebaseRemoteConfig/Tests/Unit/RCNRemoteConfigTest.m +++ b/FirebaseRemoteConfig/Tests/Unit/RCNRemoteConfigTest.m @@ -24,6 +24,7 @@ #import "FirebaseRemoteConfig/Sources/RCNConfigConstants.h" #import "FirebaseRemoteConfig/Sources/RCNConfigDBManager.h" #import "FirebaseRemoteConfig/Sources/RCNConfigExperiment.h" +#import "FirebaseRemoteConfig/Sources/RCNConfigRealtime.h" #import "FirebaseRemoteConfig/Sources/RCNUserDefaultsManager.h" #import "FirebaseRemoteConfig/Tests/Unit/RCNTestUtilities.h" @@ -41,22 +42,51 @@ app:firebaseApp; /// Skip fetching user properties from analytics because we cannot mock the action here. Instead /// overriding the method to skip. -- (void)fetchWithUserPropertiesCompletionHandler:(FIRAInteropUserPropertiesCallback)block; +- (void)fetchWithUserPropertiesCompletionHandler:(NSString *)fetchTypeHeader + completionHandler:(FIRAInteropUserPropertiesCallback)block; - (NSURLSessionDataTask *)URLSessionDataTaskWithContent:(NSData *)content + fetchTypeHeader:(NSString *)fetchTypeHeader completionHandler: (RCNConfigFetcherCompletion)fetcherCompletion; - +- (void)fetchConfigWithExpirationDuration:(NSTimeInterval)expirationDuration + completionHandler:(FIRRemoteConfigFetchCompletion)completionHandler; +- (void)realtimeFetchConfigWithNoExpirationDuration:(NSInteger)fetchAttemptNumber + completionHandler:(RCNConfigFetchCompletion)completionHandler; - (void)fetchWithUserProperties:(NSDictionary *)userProperties - completionHandler:(FIRRemoteConfigFetchCompletion)completionHandler; + fetchTypeHeader:(NSString *)fetchTypeHeader + completionHandler:(FIRRemoteConfigFetchCompletion)completionHandler + updateCompletionHandler:(RCNConfigFetchCompletion)updateCompletionHandler; - (NSString *)constructServerURL; - (NSURLSession *)currentNetworkSession; @end +@interface RCNConfigRealtime (ForTest) + +- (instancetype _Nonnull)init:(RCNConfigFetch *_Nonnull)configFetch + settings:(RCNConfigSettings *_Nonnull)settings + namespace:(NSString *_Nonnull)namespace + options:(FIROptions *_Nonnull)options; + +- (void)fetchLatestConfig:(NSInteger)remainingAttempts targetVersion:(NSInteger)targetVersion; +- (void)scheduleFetch:(NSInteger)remainingAttempts targetVersion:(NSInteger)targetVersion; +- (void)autoFetch:(NSInteger)remainingAttempts targetVersion:(NSInteger)targetVersion; +- (void)beginRealtimeStream; +- (void)pauseRealtimeStream; + +- (FIRConfigUpdateListenerRegistration *_Nonnull)addConfigUpdateListener: + (RCNConfigUpdateCompletion _Nonnull)listener; +- (void)removeConfigUpdateListener:(RCNConfigUpdateCompletion _Nonnull)listener; +- (void)evaluateStreamResponse:(NSDictionary *)response error:(NSError *)dataError; + +@end + @interface FIRRemoteConfig (ForTest) - (void)updateWithNewInstancesForConfigFetch:(RCNConfigFetch *)configFetch configContent:(RCNConfigContent *)configContent configSettings:(RCNConfigSettings *)configSettings configExperiment:(RCNConfigExperiment *)configExperiment; + +- (void)updateWithNewInstancesForConfigRealtime:(RCNConfigRealtime *)configRealtime; @end @implementation FIRRemoteConfig (ForTest) @@ -69,6 +99,10 @@ [self setValue:configSettings forKey:@"_settings"]; [self setValue:configExperiment forKey:@"_configExperiment"]; } + +- (void)updateWithNewInstancesForConfigRealtime:(RCNConfigRealtime *)configRealtime { + [self setValue:configRealtime forKey:@"_configRealtime"]; +} @end @interface RCNConfigDBManager (Test) @@ -99,6 +133,7 @@ typedef NS_ENUM(NSInteger, RCNTestRCInstance) { NSMutableArray *_responseData; NSMutableArray *_URLResponse; NSMutableArray *_configFetch; + NSMutableArray *_configRealtime; RCNConfigDBManager *_DBManager; NSUserDefaults *_userDefaults; NSString *_userDefaultsSuiteName; @@ -143,6 +178,7 @@ typedef NS_ENUM(NSInteger, RCNTestRCInstance) { _responseData = [[NSMutableArray alloc] initWithCapacity:3]; _URLResponse = [[NSMutableArray alloc] initWithCapacity:3]; _configFetch = [[NSMutableArray alloc] initWithCapacity:3]; + _configRealtime = [[NSMutableArray alloc] initWithCapacity:3]; // Populate the default, second app, second namespace instances. for (int i = 0; i < RCNTestRCNumTotalInstances; i++) { @@ -203,6 +239,10 @@ typedef NS_ENUM(NSInteger, RCNTestRCInstance) { queue:_queue namespace:_fullyQualifiedNamespace options:currentOptions]); + _configRealtime[i] = OCMPartialMock([[RCNConfigRealtime alloc] init:_configFetch[i] + settings:_settings + namespace:_fullyQualifiedNamespace + options:currentOptions]); OCMStubRecorder *mock = OCMStub([_configFetch[i] fetchConfigWithExpirationDuration:0 completionHandler:OCMOCK_ANY]); @@ -212,7 +252,9 @@ typedef NS_ENUM(NSInteger, RCNTestRCInstance) { NSError *_Nullable error) = nil; [invocation getArgument:&handler atIndex:3]; [self->_configFetch[i] fetchWithUserProperties:[[NSDictionary alloc] init] - completionHandler:handler]; + fetchTypeHeader:@"Base/1" + completionHandler:handler + updateCompletionHandler:nil]; }); _response[i] = @{@"state" : @"UPDATE", @"entries" : _entries[i]}; @@ -229,12 +271,14 @@ typedef NS_ENUM(NSInteger, RCNTestRCInstance) { [OCMArg invokeBlockWithArgs:_responseData[i], _URLResponse[i], [NSNull null], nil]; OCMStub([_configFetch[i] URLSessionDataTaskWithContent:[OCMArg any] + fetchTypeHeader:[OCMArg any] completionHandler:completionBlock]) .andReturn(nil); [_configInstances[i] updateWithNewInstancesForConfigFetch:_configFetch[i] configContent:configContent configSettings:_settings configExperiment:_experimentMock]; + [_configInstances[i] updateWithNewInstancesForConfigRealtime:_configRealtime[i]]; } } @@ -251,6 +295,7 @@ typedef NS_ENUM(NSInteger, RCNTestRCInstance) { } [_configInstances removeAllObjects]; [_configFetch removeAllObjects]; + [_configRealtime removeAllObjects]; _configInstances = nil; _configFetch = nil; [super tearDown]; @@ -581,13 +626,20 @@ typedef NS_ENUM(NSInteger, RCNTestRCInstance) { namespace:_fullyQualifiedNamespace options:currentOptions]); + _configRealtime[i] = OCMPartialMock([[RCNConfigRealtime alloc] init:_configFetch[i] + settings:_settings + namespace:_fullyQualifiedNamespace + options:currentOptions]); + OCMStub([_configFetch[i] fetchConfigWithExpirationDuration:43200 completionHandler:OCMOCK_ANY]) .andDo(^(NSInvocation *invocation) { __unsafe_unretained void (^handler)(FIRRemoteConfigFetchStatus status, NSError *_Nullable error) = nil; [invocation getArgument:&handler atIndex:3]; [self->_configFetch[i] fetchWithUserProperties:[[NSDictionary alloc] init] - completionHandler:handler]; + fetchTypeHeader:@"Base/1" + completionHandler:handler + updateCompletionHandler:nil]; }); _response[i] = @{}; @@ -696,13 +748,20 @@ typedef NS_ENUM(NSInteger, RCNTestRCInstance) { namespace:fullyQualifiedNamespace options:currentOptions]); + _configRealtime[i] = OCMPartialMock([[RCNConfigRealtime alloc] init:_configFetch[i] + settings:settings + namespace:fullyQualifiedNamespace + options:currentOptions]); + OCMStub([_configFetch[i] fetchConfigWithExpirationDuration:43200 completionHandler:OCMOCK_ANY]) .andDo(^(NSInvocation *invocation) { __unsafe_unretained void (^handler)(FIRRemoteConfigFetchStatus status, NSError *_Nullable error) = nil; [invocation getArgument:&handler atIndex:3]; [self->_configFetch[i] fetchWithUserProperties:[[NSDictionary alloc] init] - completionHandler:handler]; + fetchTypeHeader:@"Base/1" + completionHandler:handler + updateCompletionHandler:nil]; }); _response[i] = @{}; @@ -795,7 +854,10 @@ typedef NS_ENUM(NSInteger, RCNTestRCInstance) { __unsafe_unretained void (^handler)(FIRRemoteConfigFetchStatus status, NSError *_Nullable error) = nil; [invocation getArgument:&handler atIndex:3]; - [configFetch fetchWithUserProperties:[[NSDictionary alloc] init] completionHandler:handler]; + [configFetch fetchWithUserProperties:[[NSDictionary alloc] init] + fetchTypeHeader:@"Base/1" + completionHandler:handler + updateCompletionHandler:nil]; }); _responseData[0] = [NSJSONSerialization dataWithJSONObject:@{} options:0 error:nil]; @@ -895,6 +957,10 @@ typedef NS_ENUM(NSInteger, RCNTestRCInstance) { queue:queue namespace:fullyQualifiedNamespace options:currentOptions]); + _configRealtime[i] = OCMPartialMock([[RCNConfigRealtime alloc] init:_configFetch[i] + settings:settings + namespace:fullyQualifiedNamespace + options:currentOptions]); OCMStub([_configFetch[i] fetchConfigWithExpirationDuration:43200 completionHandler:OCMOCK_ANY]) .andDo(^(NSInvocation *invocation) { @@ -903,7 +969,9 @@ typedef NS_ENUM(NSInteger, RCNTestRCInstance) { [invocation getArgument:&handler atIndex:3]; [self->_configFetch[i] fetchWithUserProperties:[[NSDictionary alloc] init] - completionHandler:handler]; + fetchTypeHeader:@"Base/1" + completionHandler:handler + updateCompletionHandler:nil]; }); _response[i] = @{@"state" : @"NO_CHANGE"}; @@ -920,6 +988,7 @@ typedef NS_ENUM(NSInteger, RCNTestRCInstance) { [OCMArg invokeBlockWithArgs:_responseData[i], _URLResponse[i], [NSNull null], nil]; OCMStub([_configFetch[i] URLSessionDataTaskWithContent:[OCMArg any] + fetchTypeHeader:@"Base/1" completionHandler:completionBlock]) .andReturn(nil); @@ -1483,6 +1552,219 @@ static NSString *UTCToLocal(NSString *utcTime) { XCTAssertEqual([config valueForKey:@"_appName"], kFIRDefaultAppName); } +#pragma mark - Realtime tests + +- (void)testRealtimeAddConfigUpdateListenerWithValidListener { + NSMutableArray *expectations = + [[NSMutableArray alloc] initWithCapacity:RCNTestRCNumTotalInstances]; + for (int i = 0; i < RCNTestRCNumTotalInstances; i++) { + expectations[i] = [self + expectationWithDescription: + [NSString + stringWithFormat:@"Test Realtime add listener successfully - instance %d", i]]; + + OCMStub([_configRealtime[i] beginRealtimeStream]).andDo(nil); + id completion = ^void(FIRRemoteConfigUpdate *_Nullable configUpdate, NSError *_Nullable error) { + if (error != nil) { + NSLog(@"Callback"); + } + }; + + [_configRealtime[i] addConfigUpdateListener:completion]; + + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(_checkCompletionTimeout * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + OCMVerify([self->_configRealtime[i] beginRealtimeStream]); + OCMVerify([self->_configRealtime[i] addConfigUpdateListener:completion]); + [expectations[i] fulfill]; + }); + + [self waitForExpectationsWithTimeout:_expectationTimeout handler:nil]; + } +} + +- (void)testRealtimeAddConfigUpdateListenerWithInvalidListener { + NSMutableArray *expectations = + [[NSMutableArray alloc] initWithCapacity:RCNTestRCNumTotalInstances]; + for (int i = 0; i < RCNTestRCNumTotalInstances; i++) { + expectations[i] = [self + expectationWithDescription: + [NSString + stringWithFormat:@"Test Realtime add listener unsuccessfully - instance %d", i]]; + + id completion = nil; + [_configRealtime[i] addConfigUpdateListener:completion]; + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(_checkCompletionTimeout * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + OCMVerify(never(), [self->_configRealtime[i] beginRealtimeStream]); + [expectations[i] fulfill]; + }); + + [self waitForExpectationsWithTimeout:_expectationTimeout handler:nil]; + } +} + +- (void)testRemoveRealtimeListener { + NSMutableArray *expectations = + [[NSMutableArray alloc] initWithCapacity:RCNTestRCNumTotalInstances]; + for (int i = 0; i < RCNTestRCNumTotalInstances; i++) { + expectations[i] = [self + expectationWithDescription: + [NSString + stringWithFormat:@"Test Realtime remove listeners successfully - instance %d", i]]; + + id completion = ^void(FIRRemoteConfigUpdate *_Nullable configUpdate, NSError *_Nullable error) { + if (error != nil) { + NSLog(@"Callback"); + } + }; + OCMStub([_configRealtime[i] beginRealtimeStream]).andDo(nil); + + FIRConfigUpdateListenerRegistration *registration = + [_configRealtime[i] addConfigUpdateListener:completion]; + [registration remove]; + + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(_checkCompletionTimeout * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + OCMVerify([self->_configRealtime[i] addConfigUpdateListener:completion]); + OCMVerify([self->_configRealtime[i] removeConfigUpdateListener:completion]); + OCMVerify([self->_configRealtime[i] pauseRealtimeStream]); + [expectations[i] fulfill]; + }); + + [self waitForExpectationsWithTimeout:_expectationTimeout handler:nil]; + } +} + +- (void)testRealtimeFetch { + NSMutableArray *expectations = + [[NSMutableArray alloc] initWithCapacity:RCNTestRCNumTotalInstances]; + for (int i = 0; i < RCNTestRCNumTotalInstances; i++) { + expectations[i] = [self + expectationWithDescription: + [NSString stringWithFormat:@"Test Realtime Autofetch successfully - instance %d", i]]; + + OCMStub([_configFetch[i] realtimeFetchConfigWithNoExpirationDuration:1 + completionHandler:OCMOCK_ANY]) + .andDo(nil); + OCMStub([_configRealtime[i] scheduleFetch:1 targetVersion:1]).andDo(nil); + + [_configRealtime[i] fetchLatestConfig:3 targetVersion:1]; + + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(_checkCompletionTimeout * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + OCMVerify([self->_configFetch[i] realtimeFetchConfigWithNoExpirationDuration:1 + completionHandler:OCMOCK_ANY]); + [expectations[i] fulfill]; + }); + + [self waitForExpectationsWithTimeout:_expectationTimeout handler:nil]; + } +} + +- (void)testAutofetch { + NSMutableArray *expectations = + [[NSMutableArray alloc] initWithCapacity:RCNTestRCNumTotalInstances]; + for (int i = 0; i < RCNTestRCNumTotalInstances; i++) { + expectations[i] = [self + expectationWithDescription: + [NSString stringWithFormat:@"Test Realtime Autofetch successfully - instance %d", i]]; + + OCMStub([_configRealtime[i] scheduleFetch:1 targetVersion:1]).andDo(nil); + + [_configRealtime[i] autoFetch:1 targetVersion:1]; + + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(_checkCompletionTimeout * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + OCMVerify([self->_configRealtime[i] scheduleFetch:1 targetVersion:1]); + [expectations[i] fulfill]; + }); + + [self waitForExpectationsWithTimeout:_expectationTimeout handler:nil]; + } +} + +- (void)testAddOnConfigUpdateMethodSuccess { + NSMutableArray *expectations = + [[NSMutableArray alloc] initWithCapacity:RCNTestRCNumTotalInstances]; + for (int i = 0; i < RCNTestRCNumTotalInstances; i++) { + expectations[i] = [self + expectationWithDescription: + [NSString + stringWithFormat:@"Test public realtime method successfully - instance %d", i]]; + + OCMStub([_configRealtime[i] beginRealtimeStream]).andDo(nil); + + id completion = ^void(FIRRemoteConfigUpdate *_Nullable configUpdate, NSError *_Nullable error) { + if (error != nil) { + NSLog(@"Callback"); + } + }; + [_configInstances[i] addOnConfigUpdateListener:completion]; + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(_checkCompletionTimeout * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + OCMVerify([self->_configRealtime[i] addConfigUpdateListener:completion]); + [expectations[i] fulfill]; + }); + + [self waitForExpectationsWithTimeout:_expectationTimeout handler:nil]; + } +} + +- (void)testAddOnConfigUpdateMethodFail { + NSMutableArray *expectations = + [[NSMutableArray alloc] initWithCapacity:RCNTestRCNumTotalInstances]; + for (int i = 0; i < RCNTestRCNumTotalInstances; i++) { + expectations[i] = [self + expectationWithDescription: + [NSString stringWithFormat:@"Test public realtime method and fails - instance %d", i]]; + + id completion = nil; + [_configInstances[i] addOnConfigUpdateListener:completion]; + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(_checkCompletionTimeout * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + OCMVerify(never(), [self->_configRealtime[i] beginRealtimeStream]); + [expectations[i] fulfill]; + }); + + [self waitForExpectationsWithTimeout:_expectationTimeout handler:nil]; + } +} + +- (void)testRealtimeDisabled { + NSMutableArray *expectations = + [[NSMutableArray alloc] initWithCapacity:RCNTestRCNumTotalInstances]; + for (int i = 0; i < RCNTestRCNumTotalInstances; i++) { + expectations[i] = [self + expectationWithDescription: + [NSString + stringWithFormat:@"Test isRealtimeDisabled flag and makes it true - instance %d", + i]]; + OCMStub([_configRealtime[i] pauseRealtimeStream]).andDo(nil); + NSMutableDictionary *dictionary = [[NSMutableDictionary alloc] init]; + [dictionary setValue:@"true" forKey:@"featureDisabled"]; + [dictionary setValue:@"1" forKey:@"latestTemplateVersionNumber"]; + + [_configRealtime[i] evaluateStreamResponse:dictionary error:nil]; + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(_checkCompletionTimeout * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + OCMVerify([self->_configRealtime[i] pauseRealtimeStream]); + OCMVerify(never(), [self->_configRealtime[i] autoFetch:5 targetVersion:1]); + [expectations[i] fulfill]; + }); + + [self waitForExpectationsWithTimeout:_expectationTimeout handler:nil]; + } +} + #pragma mark - Test Helpers - (FIROptions *)firstAppOptions { diff --git a/FirebaseRemoteConfig/Tests/Unit/RCNUserDefaultsManagerTests.m b/FirebaseRemoteConfig/Tests/Unit/RCNUserDefaultsManagerTests.m index de2eec2b70..0c3135e2ed 100644 --- a/FirebaseRemoteConfig/Tests/Unit/RCNUserDefaultsManagerTests.m +++ b/FirebaseRemoteConfig/Tests/Unit/RCNUserDefaultsManagerTests.m @@ -124,6 +124,41 @@ static NSString* const FQNamespace2 = @"testNamespace2:testApp"; RCNUserDefaultsSampleTimeStamp - 2.0); } +- (void)testUserDefaultsTemplateVersionWriteAndRead { + RCNUserDefaultsManager* manager = + [[RCNUserDefaultsManager alloc] initWithAppName:AppName + bundleID:[NSBundle mainBundle].bundleIdentifier + namespace:FQNamespace1]; + [manager setLastTemplateVersion:@"1"]; + XCTAssertEqual([manager lastTemplateVersion], @"1"); +} + +- (void)testUserDefaultsRealtimeThrottleEndTimeWriteAndRead { + RCNUserDefaultsManager* manager = + [[RCNUserDefaultsManager alloc] initWithAppName:AppName + bundleID:[NSBundle mainBundle].bundleIdentifier + namespace:FQNamespace1]; + [manager setRealtimeThrottleEndTime:RCNUserDefaultsSampleTimeStamp - 7.0]; + XCTAssertEqual([manager realtimeThrottleEndTime], RCNUserDefaultsSampleTimeStamp - 7.0); + + [manager setRealtimeThrottleEndTime:RCNUserDefaultsSampleTimeStamp - 8.0]; + XCTAssertEqual([manager realtimeThrottleEndTime], RCNUserDefaultsSampleTimeStamp - 8.0); +} + +- (void)testUserDefaultsCurrentRealtimeThrottlingRetryIntervalWriteAndRead { + RCNUserDefaultsManager* manager = + [[RCNUserDefaultsManager alloc] initWithAppName:AppName + bundleID:[NSBundle mainBundle].bundleIdentifier + namespace:FQNamespace1]; + [manager setCurrentRealtimeThrottlingRetryIntervalSeconds:RCNUserDefaultsSampleTimeStamp - 1.0]; + XCTAssertEqual([manager currentRealtimeThrottlingRetryIntervalSeconds], + RCNUserDefaultsSampleTimeStamp - 1.0); + + [manager setCurrentRealtimeThrottlingRetryIntervalSeconds:RCNUserDefaultsSampleTimeStamp - 2.0]; + XCTAssertEqual([manager currentRealtimeThrottlingRetryIntervalSeconds], + RCNUserDefaultsSampleTimeStamp - 2.0); +} + - (void)testUserDefaultsForMultipleNamespaces { RCNUserDefaultsManager* manager1 = [[RCNUserDefaultsManager alloc] initWithAppName:AppName @@ -172,6 +207,32 @@ static NSString* const FQNamespace2 = @"testNamespace2:testApp"; RCNUserDefaultsSampleTimeStamp - 1.0); XCTAssertEqual([manager2 currentThrottlingRetryIntervalSeconds], RCNUserDefaultsSampleTimeStamp - 2.0); + + /// Realtime throttle end time. + [manager1 setRealtimeThrottleEndTime:RCNUserDefaultsSampleTimeStamp - 7.0]; + [manager2 setRealtimeThrottleEndTime:RCNUserDefaultsSampleTimeStamp - 8.0]; + XCTAssertEqual([manager1 realtimeThrottleEndTime], RCNUserDefaultsSampleTimeStamp - 7.0); + XCTAssertEqual([manager2 realtimeThrottleEndTime], RCNUserDefaultsSampleTimeStamp - 8.0); + + /// Realtime throttling retry interval. + [manager1 setCurrentRealtimeThrottlingRetryIntervalSeconds:RCNUserDefaultsSampleTimeStamp - 1.0]; + [manager2 setCurrentRealtimeThrottlingRetryIntervalSeconds:RCNUserDefaultsSampleTimeStamp - 2.0]; + XCTAssertEqual([manager1 currentRealtimeThrottlingRetryIntervalSeconds], + RCNUserDefaultsSampleTimeStamp - 1.0); + XCTAssertEqual([manager2 currentRealtimeThrottlingRetryIntervalSeconds], + RCNUserDefaultsSampleTimeStamp - 2.0); + + /// Realtime retry count; + [manager1 setRealtimeRetryCount:1]; + [manager2 setRealtimeRetryCount:2]; + XCTAssertEqual([manager1 realtimeRetryCount], 1); + XCTAssertEqual([manager2 realtimeRetryCount], 2); + + /// Fetch template version. + [manager1 setLastTemplateVersion:@"1"]; + [manager2 setLastTemplateVersion:@"2"]; + XCTAssertEqualObjects([manager1 lastTemplateVersion], @"1"); + XCTAssertEqualObjects([manager2 lastTemplateVersion], @"2"); } - (void)testUserDefaultsReset { diff --git a/FirebaseRemoteConfigSwift/Tests/ObjC/Bridging-Header.h b/FirebaseRemoteConfigSwift/Tests/ObjC/Bridging-Header.h index 0522aff9de..96c3121621 100644 --- a/FirebaseRemoteConfigSwift/Tests/ObjC/Bridging-Header.h +++ b/FirebaseRemoteConfigSwift/Tests/ObjC/Bridging-Header.h @@ -15,3 +15,4 @@ #import "FirebaseRemoteConfig/Sources/Private/FIRRemoteConfig_Private.h" #import "FirebaseRemoteConfig/Sources/RCNConfigConstants.h" #import "FirebaseRemoteConfigSwift/Tests/ObjC/FetchMocks.h" +#import "FirebaseRemoteConfigSwift/Tests/ObjC/RealtimeMocks.h" diff --git a/FirebaseRemoteConfigSwift/Tests/ObjC/FetchMocks.m b/FirebaseRemoteConfigSwift/Tests/ObjC/FetchMocks.m index 01c111cde1..3cb0bed5cc 100644 --- a/FirebaseRemoteConfigSwift/Tests/ObjC/FetchMocks.m +++ b/FirebaseRemoteConfigSwift/Tests/ObjC/FetchMocks.m @@ -18,9 +18,12 @@ #import "FirebaseRemoteConfigSwift/Tests/ObjC/FetchMocks.h" @interface RCNConfigFetch (ExposedForTest) -- (void)refreshInstallationsTokenWithCompletionHandler: - (FIRRemoteConfigFetchCompletion)completionHandler; -- (void)doFetchCall:(FIRRemoteConfigFetchCompletion)completionHandler; +- (void)refreshInstallationsTokenWithFetchHeader:(NSString *)fetchTypeHeader + completionHandler:(FIRRemoteConfigFetchCompletion)completionHandler + updateCompletionHandler:(RCNConfigFetchCompletion)updateCompletionHandler; +- (void)doFetchCall:(NSString *)fetchTypeHeader + completionHandler:(FIRRemoteConfigFetchCompletion)completionHandler + updateCompletionHandler:(RCNConfigFetchCompletion)updateCompletionHandler; @end @implementation FetchMocks @@ -28,8 +31,10 @@ + (RCNConfigFetch *)mockFetch:(RCNConfigFetch *)fetch { RCNConfigFetch *mock = OCMPartialMock(fetch); OCMStub([mock recreateNetworkSession]).andDo(nil); - OCMStub([mock refreshInstallationsTokenWithCompletionHandler:[OCMArg any]]) - .andCall(mock, @selector(doFetchCall:)); + OCMStub([mock refreshInstallationsTokenWithFetchHeader:[OCMArg any] + completionHandler:[OCMArg any] + updateCompletionHandler:[OCMArg any]]) + .andCall(mock, @selector(doFetchCall:completionHandler:updateCompletionHandler:)); return mock; } diff --git a/FirebaseRemoteConfigSwift/Tests/ObjC/RealtimeMocks.h b/FirebaseRemoteConfigSwift/Tests/ObjC/RealtimeMocks.h new file mode 100644 index 0000000000..0e14fc5223 --- /dev/null +++ b/FirebaseRemoteConfigSwift/Tests/ObjC/RealtimeMocks.h @@ -0,0 +1,24 @@ +// Copyright 2023 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 +#import "FirebaseRemoteConfig/Sources/RCNConfigRealtime.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface RealtimeMocks : NSObject ++ (RCNConfigRealtime *)mockRealtime:(RCNConfigRealtime *)realtime; +@end + +NS_ASSUME_NONNULL_END diff --git a/FirebaseRemoteConfigSwift/Tests/ObjC/RealtimeMocks.m b/FirebaseRemoteConfigSwift/Tests/ObjC/RealtimeMocks.m new file mode 100644 index 0000000000..c9aa9e5029 --- /dev/null +++ b/FirebaseRemoteConfigSwift/Tests/ObjC/RealtimeMocks.m @@ -0,0 +1,42 @@ +// Copyright 2023 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 + +#import "FirebaseRemoteConfig/Sources/RCNConfigRealtime.h" +#import "FirebaseRemoteConfigSwift/Tests/ObjC/RealtimeMocks.h" + +@interface RCNConfigRealtime (ExposedForTest) + +- (FIRConfigUpdateListenerRegistration *)addConfigUpdateListener: + (void (^_Nonnull)(FIRRemoteConfigUpdate *configUpdate, NSError *_Nullable error))listener; + +- (void)triggerListenerForTesting:(void (^_Nonnull)(FIRRemoteConfigUpdate *configUpdate, + NSError *_Nullable error))listener; + +- (void)beginRealtimeStream; + +@end + +@implementation RealtimeMocks + ++ (RCNConfigRealtime *)mockRealtime:(RCNConfigRealtime *)realtime { + RCNConfigRealtime *realtimeMock = OCMPartialMock(realtime); + OCMStub([realtimeMock beginRealtimeStream]).andDo(nil); + OCMStub([realtimeMock addConfigUpdateListener:[OCMArg any]]) + .andCall(realtimeMock, @selector(triggerListenerForTesting:)); + return realtimeMock; +} + +@end diff --git a/FirebaseRemoteConfigSwift/Tests/SwiftAPI/APITestBase.swift b/FirebaseRemoteConfigSwift/Tests/SwiftAPI/APITestBase.swift index 10ce7a1951..f028874401 100644 --- a/FirebaseRemoteConfigSwift/Tests/SwiftAPI/APITestBase.swift +++ b/FirebaseRemoteConfigSwift/Tests/SwiftAPI/APITestBase.swift @@ -24,6 +24,7 @@ import XCTest class APITestBase: XCTestCase { static var useFakeConfig: Bool! static var mockedFetch: Bool! + static var mockedRealtime: Bool! var app: FirebaseApp! var config: RemoteConfig! var console: RemoteConfigConsole! @@ -42,6 +43,7 @@ class APITestBase: XCTestCase { options.projectID = "Fake Project" FirebaseApp.configure(options: options) APITests.mockedFetch = false + APITests.mockedRealtime = false #endif } } @@ -78,6 +80,10 @@ class APITestBase: XCTestCase { APITests.mockedFetch = true config.configFetch = FetchMocks.mockFetch(config.configFetch) } + if !APITests.mockedRealtime { + APITests.mockedRealtime = true + config.configRealtime = RealtimeMocks.mockRealtime(config.configRealtime) + } fakeConsole = FakeConsole() config.configFetch.fetchSession = URLSessionMock(with: fakeConsole) diff --git a/FirebaseRemoteConfigSwift/Tests/SwiftAPI/APITests.swift b/FirebaseRemoteConfigSwift/Tests/SwiftAPI/APITests.swift index 14fd2f731e..d1b0b4a048 100644 --- a/FirebaseRemoteConfigSwift/Tests/SwiftAPI/APITests.swift +++ b/FirebaseRemoteConfigSwift/Tests/SwiftAPI/APITests.swift @@ -143,6 +143,42 @@ class APITests: APITestBase { waitForExpectations() } + // MARK: - RemoteConfigRealtime Tests + + func testRealtimeRemoteConfigFakeConsole() { + guard APITests.useFakeConfig == true else { return } + + let expectation = self.expectation(description: #function) + + let registration = config.addOnConfigUpdateListener { RemoteConfigUpdate, Error in + XCTAssertNil(Error, "Realtime error \(Error!)") + XCTAssertNotNil(RemoteConfigUpdate) + + expectation.fulfill() + } + + waitForExpectations() + registration.remove() + } + + func testRealtimeRemoteConfigRealConsole() { + guard APITests.useFakeConfig == false else { return } + + let expectation = self.expectation(description: #function) + + let registration = config.addOnConfigUpdateListener { RemoteConfigUpdate, Error in + XCTAssertNil(Error, "Realtime error \(Error!)") + XCTAssertNotNil(RemoteConfigUpdate) + XCTAssertNotNil(RemoteConfigUpdate?.updatedKeys.contains(Constants.jedi)) + expectation.fulfill() + } + + console.updateRemoteConfigValue(Constants.yoda, forKey: Constants.jedi) + + waitForExpectations() + registration.remove() + } + // MARK: - RemoteConfigConsole Tests func testFetchConfigThenUpdateConsoleThenFetchAgain() {