From ade07fc47156079b98f6bb4ffa2dcc159fe6b9af Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Thu, 2 Jan 2025 13:57:45 -0800 Subject: [PATCH 1/6] [rc-swift] ConfigRealtime --- .../Sources/FIRRemoteConfig.m | 21 +- .../Sources/Private/FIRRemoteConfig_Private.h | 3 - .../FirebaseRemoteConfig/FIRRemoteConfig.h | 24 +- .../Sources/RCNConfigRealtime.h | 40 - .../Sources/RCNConfigRealtime.m | 732 ------------------ .../SwiftNew/ConfigContent.swift | 4 +- .../SwiftNew/ConfigFetch.swift | 69 +- .../SwiftNew/ConfigRealtime.swift | 652 ++++++++++++++++ .../SwiftNew/ConfigSettings.swift | 6 +- .../Utils/InstallationsProtocol.swift | 24 + .../SwiftNew/Utils/UtilsURL.swift | 51 ++ .../SwiftUnit/ConfigDBManagerOrigTest.swift | 1 - .../Tests/SwiftUnit/ConfigRealtimeTests.swift | 122 +++ .../Tests/Unit/RCNPersonalizationTest.m | 17 +- .../Tests/Unit/RCNRemoteConfigTest.m | 85 +- 15 files changed, 947 insertions(+), 904 deletions(-) delete mode 100644 FirebaseRemoteConfig/Sources/RCNConfigRealtime.h delete mode 100644 FirebaseRemoteConfig/Sources/RCNConfigRealtime.m create mode 100644 FirebaseRemoteConfig/SwiftNew/ConfigRealtime.swift create mode 100644 FirebaseRemoteConfig/SwiftNew/Utils/InstallationsProtocol.swift create mode 100644 FirebaseRemoteConfig/SwiftNew/Utils/UtilsURL.swift create mode 100644 FirebaseRemoteConfig/Tests/SwiftUnit/ConfigRealtimeTests.swift diff --git a/FirebaseRemoteConfig/Sources/FIRRemoteConfig.m b/FirebaseRemoteConfig/Sources/FIRRemoteConfig.m index cef514c1ab0..6cc322c2b89 100644 --- a/FirebaseRemoteConfig/Sources/FIRRemoteConfig.m +++ b/FirebaseRemoteConfig/Sources/FIRRemoteConfig.m @@ -20,7 +20,6 @@ #import "FirebaseCore/Extension/FirebaseCoreInternal.h" #import "FirebaseRemoteConfig/Sources/Private/FIRRemoteConfig_Private.h" #import "FirebaseRemoteConfig/Sources/RCNConfigConstants.h" -#import "FirebaseRemoteConfig/Sources/RCNConfigRealtime.h" #import "FirebaseRemoteConfig/FirebaseRemoteConfig-Swift.h" @@ -136,7 +135,8 @@ - (instancetype)initWithAppName:(NSString *)appName configContent:(RCNConfigContent *)configContent userDefaults:(nullable NSUserDefaults *)userDefaults analytics:(nullable id)analytics - configFetch:(nullable RCNConfigFetch *)configFetch { + configFetch:(nullable RCNConfigFetch *)configFetch + configRealtime:(nullable RCNConfigRealtime *)configRealtime { self = [super init]; if (self) { _appName = appName; @@ -172,11 +172,15 @@ - (instancetype)initWithAppName:(NSString *)appName namespace:_FIRNamespace options:options]; } - - _configRealtime = [[RCNConfigRealtime alloc] init:_configFetch - settings:_settings - namespace:_FIRNamespace - options:options]; + if (configRealtime) { + _configRealtime = configRealtime; + } else { + _configRealtime = [[RCNConfigRealtime alloc] initWithConfigFetch:_configFetch + settings:_settings + namespace:_FIRNamespace + options:options + installations:nil]; + } [_settings loadConfigFromMetadataTable]; @@ -205,7 +209,8 @@ - (instancetype)initWithAppName:(NSString *)appName configContent:configContent userDefaults:nil analytics:analytics - configFetch:nil]; + configFetch:nil + configRealtime:nil]; } // Initialize with default config settings. diff --git a/FirebaseRemoteConfig/Sources/Private/FIRRemoteConfig_Private.h b/FirebaseRemoteConfig/Sources/Private/FIRRemoteConfig_Private.h index 4bc864fe302..4c4be44a5a5 100644 --- a/FirebaseRemoteConfig/Sources/Private/FIRRemoteConfig_Private.h +++ b/FirebaseRemoteConfig/Sources/Private/FIRRemoteConfig_Private.h @@ -20,7 +20,6 @@ @class RCNConfigContent; @class RCNConfigDBManager; @class RCNConfigFetch; -@class RCNConfigRealtime; @protocol FIRAnalyticsInterop; NS_ASSUME_NONNULL_BEGIN @@ -34,8 +33,6 @@ 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/Public/FirebaseRemoteConfig/FIRRemoteConfig.h b/FirebaseRemoteConfig/Sources/Public/FirebaseRemoteConfig/FIRRemoteConfig.h index ca2714dd438..12bb176363f 100644 --- a/FirebaseRemoteConfig/Sources/Public/FirebaseRemoteConfig/FIRRemoteConfig.h +++ b/FirebaseRemoteConfig/Sources/Public/FirebaseRemoteConfig/FIRRemoteConfig.h @@ -26,6 +26,8 @@ @class RCNConfigSettings; @class FIRRemoteConfigValue; @class RCNConfigFetch; +@class RCNConfigRealtime; +@class FIRConfigUpdateListenerRegistration; @protocol FIRAnalyticsInterop; @protocol FIRRolloutsStateSubscriber; @@ -42,24 +44,6 @@ 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 associated 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_SENDABLE -NS_SWIFT_NAME(ConfigUpdateListenerRegistration) -@interface FIRConfigUpdateListenerRegistration : NSObject -/** - * Removes the listener associated with 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. @@ -340,6 +324,7 @@ typedef void (^FIRRemoteConfigUpdateCompletion)(FIRRemoteConfigUpdate *_Nullable // TODO: Below here is temporary public for Swift port +@property(nonatomic, readwrite, strong, nonnull) RCNConfigRealtime *configRealtime; @property(nonatomic, readonly, strong) RCNConfigSettings *settings; /// Initialize a FIRRemoteConfig instance with all the required parameters directly. This exists so @@ -358,7 +343,8 @@ typedef void (^FIRRemoteConfigUpdateCompletion)(FIRRemoteConfigUpdate *_Nullable configContent:(RCNConfigContent *)configContent userDefaults:(nullable NSUserDefaults *)userDefaults analytics:(nullable id)analytics - configFetch:(nullable RCNConfigFetch *)configFetch; + configFetch:(nullable RCNConfigFetch *)configFetch + configRealtime:(nullable RCNConfigRealtime *)configRealtime; /// Register `FIRRolloutsStateSubscriber` to `FIRRemoteConfig` instance - (void)addRemoteConfigInteropSubscriber:(id _Nonnull)subscriber; diff --git a/FirebaseRemoteConfig/Sources/RCNConfigRealtime.h b/FirebaseRemoteConfig/Sources/RCNConfigRealtime.h deleted file mode 100644 index 3065373d4d7..00000000000 --- a/FirebaseRemoteConfig/Sources/RCNConfigRealtime.h +++ /dev/null @@ -1,40 +0,0 @@ -/* - * 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/FirebaseRemoteConfig-Swift.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 deleted file mode 100644 index e2302f0b2e9..00000000000 --- a/FirebaseRemoteConfig/Sources/RCNConfigRealtime.m +++ /dev/null @@ -1,732 +0,0 @@ -/* - * 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/FirebaseRemoteConfig-Swift.h" -#import "FirebaseRemoteConfig/Sources/RCNConfigConstants.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)propagateErrors:(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] initWithUpdatedKeys:[[NSSet 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); - return [strongSelfQueue reportCompletionOnHandler:completionHandler - withStatus:FIRRemoteConfigFetchStatusNoFetchYet - withError:nil]; - }); - }]; - }; - - FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000039", @"Starting requesting token."); - [installations authTokenWithCompletion:installationsTokenHandler]; -} - -- (void)createRequestBodyWithCompletion:(void (^)(NSData *_Nonnull requestBody))completion { - __weak __typeof(self) weakSelf = self; - [self refreshInstallationsTokenWithCompletionHandler:^(FIRRemoteConfigFetchStatus status, - NSError *_Nullable error) { - __strong __typeof(self) strongSelf = weakSelf; - if (!strongSelf) return; - - if (![strongSelf->_settings.configInstallationsIdentifier length]) { - FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000013", - @"Installation token retrieval failed. Realtime connection will not include " - @"valid installations token."); - } - - [strongSelf.request setValue:strongSelf->_settings.configInstallationsToken - forHTTPHeaderField:kInstallationsAuthTokenHeaderName]; - if (strongSelf->_settings.lastETag) { - [strongSelf.request setValue:strongSelf->_settings.lastETag - forHTTPHeaderField:kIfNoneMatchETagHeaderName]; - } - - NSString *namespace = [strongSelf->_namespace - substringToIndex:[strongSelf->_namespace rangeOfString:@":"].location]; - NSString *postBody = [NSString - stringWithFormat:@"{project:'%@', namespace:'%@', lastKnownVersionNumber:'%@', appId:'%@', " - @"sdkVersion:'%@', appInstanceId:'%@'}", - [strongSelf->_options GCMSenderID], namespace, - strongSelf->_configFetch.templateVersionNumber, - strongSelf->_options.googleAppID, Device.remoteConfigPodVersion, - strongSelf->_settings.configInstallationsIdentifier]; - NSData *postData = [postBody dataUsingEncoding:NSUTF8StringEncoding]; - NSError *compressionError; - completion([NSData gul_dataByGzippingData:postData error:&compressionError]); - }]; -} - -/// 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 - -- (BOOL)canMakeConnection { - BOOL noRunningConnection = - self->_dataTask == nil || self->_dataTask.state != NSURLSessionTaskStateRunning; - BOOL canMakeConnection = noRunningConnection && [self->_listeners count] > 0 && - !self->_isInBackground && !self->_isRealtimeDisabled; - return canMakeConnection; -} - -// Retry mechanism for HTTP connections -- (void)retryHTTPConnection { - __weak RCNConfigRealtime *weakSelf = self; - dispatch_async(_realtimeLockQueue, ^{ - __strong RCNConfigRealtime *strongSelf = weakSelf; - if (!strongSelf || strongSelf->_isInBackground) { - return; - } - - if ([strongSelf 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 propagateErrors: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 retrieve config due to fetch error. " - @"Error: %@", - error); - return [self propagateErrors:error]; - } - if (status == FIRRemoteConfigFetchStatusSuccess) { - if ([strongSelf->_configFetch.templateVersionNumber - integerValue] >= targetVersion) { - // only notify listeners if there is a change - if ([update updatedKeys].count > 0) { - dispatch_async(strongSelf->_realtimeLockQueue, ^{ - 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 propagateErrors: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 propagateErrors: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 propagateErrors: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 propagateErrors: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 __typeof(self) weakSelf = self; - dispatch_async(_realtimeLockQueue, ^{ - __strong __typeof(self) strongSelf = weakSelf; - - if (strongSelf->_settings.getRealtimeBackoffInterval > 0) { - [strongSelf retryHTTPConnection]; - return; - } - - if ([strongSelf canMakeConnection]) { - __weak __typeof(self) weakSelf = strongSelf; - [strongSelf createRequestBodyWithCompletion:^(NSData *_Nonnull requestBody) { - __strong __typeof(self) strongSelf = weakSelf; - if (!strongSelf) return; - strongSelf->_isRequestInProgress = true; - [strongSelf->_request setHTTPBody:requestBody]; - 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; - } - __block id listenerCopy = listener; - - __weak RCNConfigRealtime *weakSelf = self; - dispatch_async(_realtimeLockQueue, ^{ - __strong RCNConfigRealtime *strongSelf = weakSelf; - [strongSelf->_listeners addObject:listenerCopy]; - [strongSelf beginRealtimeStream]; - }); - - return [[FIRConfigUpdateListenerRegistration alloc] initWithClient:self - completionHandler:listenerCopy]; -} - -- (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/SwiftNew/ConfigContent.swift b/FirebaseRemoteConfig/SwiftNew/ConfigContent.swift index 3d4650f66f2..eedb58f25be 100644 --- a/FirebaseRemoteConfig/SwiftNew/ConfigContent.swift +++ b/FirebaseRemoteConfig/SwiftNew/ConfigContent.swift @@ -171,10 +171,10 @@ class ConfigContent: NSObject { self._defaultConfig.store(newValue: defaults) self ._fetchedRolloutMetadata = - rolloutMetadata[ConfigConstants.rolloutTableKeyFetchedMetadata] as? [[String: Any]] ?? [] + rolloutMetadata[ConfigConstants.rolloutTableKeyFetchedMetadata] ?? [] self ._activeRolloutMetadata = - rolloutMetadata[ConfigConstants.rolloutTableKeyActiveMetadata] as? [[String: Any]] ?? [] + rolloutMetadata[ConfigConstants.rolloutTableKeyActiveMetadata] ?? [] self.dispatchGroup.leave() } diff --git a/FirebaseRemoteConfig/SwiftNew/ConfigFetch.swift b/FirebaseRemoteConfig/SwiftNew/ConfigFetch.swift index fd3aee0af23..8465e84ecb5 100644 --- a/FirebaseRemoteConfig/SwiftNew/ConfigFetch.swift +++ b/FirebaseRemoteConfig/SwiftNew/ConfigFetch.swift @@ -26,15 +26,11 @@ import Foundation // TODO(ncooke3): Once Obj-C tests are ported, all `public` access modifers can be removed. #if RCN_STAGING_SERVER - private let serverURLDomain = "https://staging-firebaseremoteconfig.sandbox.googleapis.com" + private let serverURLDomain = "staging-firebaseremoteconfig.sandbox.googleapis.com" #else - private let serverURLDomain = "https://firebaseremoteconfig.googleapis.com" + private let serverURLDomain = "firebaseremoteconfig.googleapis.com" #endif -private let serverURLVersion = "/v1" -private let serverURLProjects = "/projects/" -private let serverURLNamespaces = "/namespaces/" -private let serverURLQuery = ":fetch?" -private let serverURLKey = "key=" + private let requestJSONKeyAppID = "app_id" private let eTagHeaderName = "Etag" @@ -67,9 +63,9 @@ extension URLSessionDataTask: RCNURLSessionDataTaskProtocol {} @objc public protocol RCNConfigFetchSession { var configuration: URLSessionConfiguration { get } func invalidateAndCancel() - @preconcurrency func dataTask(with request: URLRequest, - completionHandler: @escaping @Sendable (Data?, URLResponse?, - (any Error)?) -> Void) + @preconcurrency + func dataTask(with request: URLRequest, + completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) -> RCNURLSessionDataTaskProtocol } @@ -82,13 +78,6 @@ extension URLSession: RCNConfigFetchSession { } } -@objc(FIRInstallationsProtocol) public protocol InstallationsProtocol { - func installationID(completion: @escaping (String?, (any Error)?) -> Void) - func authToken(completion: @escaping (InstallationsAuthTokenResult?, (any Error)?) -> Void) -} - -extension Installations: InstallationsProtocol {} - // MARK: - ConfigFetch @objc(RCNConfigFetch) public class ConfigFetch: NSObject { @@ -282,16 +271,14 @@ extension Installations: InstallationsProtocol {} // MARK: - Fetch Helpers /// Fetches config data immediately, keyed by namespace. Completion block will be called on the - /// main - /// queue. + /// main queue. /// - Parameters: /// - fetchAttemptNumber: The number of the fetch attempt. /// - completionHandler: Callback handler. - @objc public func realtimeFetchConfigWithNoExpirationDuration(_ fetchAttemptNumber: Int, - completionHandler: @escaping (RemoteConfigFetchStatus, - RemoteConfigUpdate?, - Error?) - -> Void) { + @objc public func realtimeFetchConfig(fetchAttemptNumber: Int, + completionHandler: @escaping (RemoteConfigFetchStatus, + RemoteConfigUpdate?, + Error?) -> Void) { // Note: We expect the googleAppID to always be available. let hasDeviceContextChanged = Device.remoteConfigHasDeviceContextChanged( settings.deviceContext, @@ -397,7 +384,7 @@ extension Installations: InstallationsProtocol {} // Update config settings with the IID and token. strongSelf.settings.configInstallationsToken = tokenResult?.authToken - strongSelf.settings.configInstallationsIdentifier = identifier + strongSelf.settings.configInstallationsIdentifier = identifier ?? "" // NOTE(ncooke3): Confirmed that identifier is nil. if let error { @@ -424,7 +411,7 @@ extension Installations: InstallationsProtocol {} RCLog .info( "I-RCN000022", - "Success to get iid : \(strongSelf.settings.configInstallationsIdentifier ?? "null")." + "Success to get iid : \(strongSelf.settings.configInstallationsIdentifier)." ) strongSelf.doFetchCall( fetchTypeHeader: fetchTypeHeader, @@ -741,29 +728,6 @@ extension Installations: InstallationsProtocol {} dataTask.resume() } - private func constructServerURL() -> String { - var serverURLStr = serverURLDomain - serverURLStr += serverURLVersion - serverURLStr += serverURLProjects - serverURLStr += options.projectID ?? "" - serverURLStr += serverURLNamespaces - - // Get the namespace from the fully qualified namespace string of "namespace:FIRAppName". - serverURLStr += namespace.components(separatedBy: ":")[0] - serverURLStr += serverURLQuery - - if let apiKey = options.apiKey { - serverURLStr += serverURLKey - serverURLStr += apiKey - } else { - RCLog.error("I-RCN000071", - "Missing `APIKey` from `FirebaseOptions`, please ensure the configured " + - "`FirebaseApp` is configured with `FirebaseOptions` that contains an `APIKey`.") - } - - return serverURLStr - } - private static func newFetchSession(settings: ConfigSettings) -> URLSession { let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = settings.fetchTimeout @@ -778,7 +742,12 @@ extension Installations: InstallationsProtocol {} URLResponse?, Error?) -> Void) -> RCNURLSessionDataTaskProtocol { - let url = URL(string: constructServerURL())! + let url = Utils.constructServerURL( + domain: serverURLDomain, + apiKey: options.apiKey, + optionsID: options.projectID ?? "", + namespace: namespace + ) RCLog.debug("I-RCN000046", "Making config request: \(url.absoluteString)") let timeoutInterval = fetchSession.configuration.timeoutIntervalForResource diff --git a/FirebaseRemoteConfig/SwiftNew/ConfigRealtime.swift b/FirebaseRemoteConfig/SwiftNew/ConfigRealtime.swift new file mode 100644 index 00000000000..f61a9f0f636 --- /dev/null +++ b/FirebaseRemoteConfig/SwiftNew/ConfigRealtime.swift @@ -0,0 +1,652 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseCore +import FirebaseInstallations +import Foundation +@_implementationOnly import GoogleUtilities + +// URL params +private let serverURLDomain = "firebaseremoteconfigrealtime.googleapis.com" + +// Realtime API enablement +private let serverForbiddenStatusCode = "\"code\": 403" + +// Header names +private let httpMethodPost = "POST" +private let contentTypeHeaderName = "Content-Type" +private let contentEncodingHeaderName = "Content-Encoding" +private let acceptEncodingHeaderName = "Accept" +private let etagHeaderName = "etag" +private let ifNoneMatchETagHeaderName = "if-none-match" +private let installationsAuthTokenHeaderName = "x-goog-firebase-installations-auth" +// Sends the bundle ID. Refer to b/130301479 for details. +private let iOSBundleIdentifierHeaderName = "X-Ios-Bundle-Identifier" + +// Retryable HTTP status code. +private let fetchResponseHTTPStatusOK = 200 +private let fetchResponseHTTPStatusClientTimeout = 429 +private let fetchResponseHTTPStatusCodeBadGateway = 502 +private let fetchResponseHTTPStatusCodeServiceUnavailable = 503 +private let fetchResponseHTTPStatusCodeGatewayTimeout = 504 + +// Invalidation message field names. +private let templateVersionNumberKey = "latestTemplateVersionNumber" +private let featureDisabledKey = "featureDisabled" + +private let timeoutSeconds: TimeInterval = 330 +private let fetchAttempts = 3 +private let applicationJSON = "application/json" +private let gzip = "gzip" +private let canRetry = "X-Google-GFE-Can-Retry" +// Retry parameters +private let maxRetries = 7 + +/// Listener registration returned by `addOnConfigUpdateListener`. Calling its method `remove` stops +/// the associated listener from receiving config updates and unregisters itself. +/// +/// If `remove` is called and no other listener registrations remain, the connection to the +/// real-time +/// connection. + +@objc(FIRConfigUpdateListenerRegistration) public +final class ConfigUpdateListenerRegistration: NSObject, Sendable { + let completionHandler: @Sendable (RemoteConfigUpdate?, Error?) -> Void + private let realtimeClient: ConfigRealtime? + + @objc public + init(client: ConfigRealtime, + completionHandler: @escaping @Sendable (RemoteConfigUpdate?, Error?) -> Void) { + realtimeClient = client + self.completionHandler = completionHandler + } + + @objc public + func remove() { + realtimeClient?.removeConfigUpdateListener(completionHandler) + } +} + +@objc(RCNConfigRealtime) public +class ConfigRealtime: NSObject, URLSessionDataDelegate { + private var listeners = NSOrderedSet() + private let realtimeLockQueue = DispatchQueue(label: "com.google.firebase.remoteconfig.realtime") + private let notificationCenter = NotificationCenter.default + private let request: URLRequest + private var session: URLSession? + private var dataTask: URLSessionDataTask? + private let configFetch: ConfigFetch + private let settings: ConfigSettings + private let options: FirebaseOptions + private let namespace: String + var remainingRetryCount: Int + private var isRequestInProgress: Bool + var isInBackground: Bool + var isRealtimeDisabled: Bool + + public var installations: (any InstallationsProtocol)? + + @objc public + init(configFetch: ConfigFetch, + settings: ConfigSettings, + namespace: String, + options: FirebaseOptions, + installations: InstallationsProtocol?) { + self.configFetch = configFetch + self.settings = settings + self.options = options + self.namespace = namespace + remainingRetryCount = max(maxRetries - settings.realtimeRetryCount, 1) + isRequestInProgress = false + isRealtimeDisabled = false + isInBackground = false + + self.installations = if let installations { + installations + } else if + let appName = namespace.components(separatedBy: ":").last, + let app = FirebaseApp.app(name: appName) { + Installations.installations(app: app) + } else { + nil as InstallationsProtocol? + } + request = ConfigRealtime.setupHTTPRequest(options, namespace) + super.init() + session = setupSession() + backgroundChangeListener() + } + + deinit { + dataTask?.cancel() // Ensure the task is cancelled when the object is deallocated + session?.invalidateAndCancel() + } + + private static func setupHTTPRequest(_ options: FirebaseOptions, + _ namespace: String) -> URLRequest { + let url = Utils.constructServerURL( + domain: serverURLDomain, + apiKey: options.apiKey, + optionsID: options.gcmSenderID, + namespace: namespace + ) + var request = URLRequest(url: url, + cachePolicy: .reloadIgnoringLocalCacheData, + timeoutInterval: timeoutSeconds) + request.httpMethod = httpMethodPost + request.setValue(applicationJSON, forHTTPHeaderField: contentTypeHeaderName) + request.setValue(applicationJSON, forHTTPHeaderField: acceptEncodingHeaderName) + request.setValue(gzip, forHTTPHeaderField: contentEncodingHeaderName) + request.setValue("true", forHTTPHeaderField: canRetry) + request.setValue(options.apiKey, forHTTPHeaderField: "X-Goog-Api-Key") + request.setValue( + Bundle.main.bundleIdentifier, + forHTTPHeaderField: iOSBundleIdentifierHeaderName + ) + return request + } + + private func setupSession() -> URLSession { + let config = URLSessionConfiguration.default + config.timeoutIntervalForResource = timeoutSeconds + config.timeoutIntervalForRequest = timeoutSeconds + return URLSession(configuration: config, delegate: self, delegateQueue: .main) + } + + private func propagateErrors(_ error: Error) { + realtimeLockQueue.async { [weak self] in + guard let self else { return } + for listener in self.listeners { + if let listener = listener as? (RemoteConfigUpdate?, Error?) -> Void { + listener(nil, error) + } + } + } + } + + // TESTING ONLY + @objc func triggerListenerForTesting(listener: @escaping (RemoteConfigUpdate?, Error?) -> Void) { + DispatchQueue.main.async { + listener(RemoteConfigUpdate(), nil) + } + } + + // MARK: - HTTP Helpers + + private func appName(fromFullyQualifiedNamespace fullyQualifiedNamespace: String) -> String { + return String(fullyQualifiedNamespace.split(separator: ":").last ?? "") + } + + private func reportCompletion(onHandler completionHandler: ( + (RemoteConfigFetchStatus, Error?) -> Void + )?, + withStatus status: RemoteConfigFetchStatus, + withError error: Error?) { + guard let completionHandler = completionHandler else { return } + realtimeLockQueue.async { + completionHandler(status, error) + } + } + + private func refreshInstallationsToken(completionHandler: ( + (RemoteConfigFetchStatus, Error?) -> Void + )?) { + guard let installations, !options.gcmSenderID.isEmpty else { + let errorDescription = "Failed to get GCMSenderID" + RCLog.error("I-RCN000074", errorDescription) + settings.isFetchInProgress = false + reportCompletion( + onHandler: completionHandler, + withStatus: .failure, + withError: NSError( + domain: RemoteConfigErrorDomain, + code: RemoteConfigError.internalError.rawValue, + userInfo: [NSLocalizedDescriptionKey: errorDescription] + ) + ) + return + } + + RCLog.debug("I-RCN000039", "Starting requesting token.") + installations.authToken { [weak self] result, error in + guard let self = self else { return } + if let error = error { + let errorDescription = "Failed to get installations token. Error : \(error)." + RCLog.error("I-RCN000073", errorDescription) + self.isRequestInProgress = false + var userInfo = [String: Any]() + userInfo[NSLocalizedDescriptionKey] = errorDescription + userInfo[NSUnderlyingErrorKey] = error + + self.reportCompletion( + onHandler: completionHandler, + withStatus: .failure, + withError: NSError(domain: RemoteConfigErrorDomain, + code: RemoteConfigError.internalError.rawValue, + userInfo: userInfo) + ) + return + } + guard let tokenResult = result else { + let errorDescription = "Failed to get installations token" + RCLog.error("I-RCN000073", errorDescription) + self.isRequestInProgress = false + reportCompletion(onHandler: completionHandler, + withStatus: .failure, + withError: NSError(domain: RemoteConfigErrorDomain, + code: RemoteConfigError.internalError.rawValue, + userInfo: [ + NSLocalizedDescriptionKey: errorDescription, + ])) + return + } + /// We have a valid token. Get the backing installationID. + installations.installationID { [weak self] identifier, error in + guard let self = self else { return } + // Dispatch to the RC serial queue to update settings on the queue. + self.realtimeLockQueue.async { + self.settings.configInstallationsToken = tokenResult.authToken + self.settings.configInstallationsIdentifier = identifier ?? "" + if let error = error { + let errorDescription = "Error getting iid : \(error)." + RCLog.error("I-RCN000055", errorDescription) + self.isRequestInProgress = false + var userInfo = [String: Any]() + userInfo[NSLocalizedDescriptionKey] = errorDescription + userInfo[NSUnderlyingErrorKey] = error + self.reportCompletion( + onHandler: completionHandler, + withStatus: .failure, + withError: NSError(domain: RemoteConfigErrorDomain, + code: RemoteConfigError.internalError.rawValue, + userInfo: userInfo) + ) + } else if let identifier = identifier { + RCLog.info("I-RCN000022", "Success to get iid : \(identifier)") + self.reportCompletion(onHandler: completionHandler, + withStatus: .noFetchYet, + withError: nil) + } + } + } + } + } + + @objc public + func createRequestBody(completion: @escaping (Data) -> Void) { + refreshInstallationsToken { status, error in + if self.settings.configInstallationsIdentifier.isEmpty { + RCLog.debug( + "I-RCN000013", + "Installation token retrieval failed. Realtime connection will not include " + + "valid installations token." + ) + } + var request = self.request + request.setValue(self.settings.configInstallationsToken, + forHTTPHeaderField: installationsAuthTokenHeaderName) + if let etag = self.settings.lastETag { + request.setValue(etag, forHTTPHeaderField: ifNoneMatchETagHeaderName) + } + + let postBody = """ + { + project:'\(self.options.gcmSenderID)', + namespace:'\(Utils.namespaceOnly(self.namespace))', + lastKnownVersionNumber:'\(self.configFetch.templateVersionNumber)', + appId:'\(self.options.googleAppID)', + sdkVersion:'\(RemoteConfig.sdkVersion())', + appInstanceId:'\(self.settings.configInstallationsIdentifier)' + } + """ + do { + if let postData = postBody.data(using: .utf8) { + let compressedData = try NSData.gul_data(byGzippingData: postData) + completion(compressedData) + } else { + RCLog.error("I-RCN000090", "Error creating fetch body for realtime") + completion(Data()) + } + } catch { + RCLog.error("I-RCN000091", "Error compressing fetch body for realtime \(error)") + completion(Data()) + } + } + } + + // MARK: - Retry Helpers + + func canMakeConnection() -> Bool { + let noRunningConnection = dataTask == nil || dataTask?.state != .running + return noRunningConnection && listeners.count == 0 && !isInBackground && !isRealtimeDisabled + } + + func retryHTTPConnection() { + realtimeLockQueue.async { + guard self.remainingRetryCount > 0 else { + let error = NSError(domain: RemoteConfigUpdateErrorDomain, + code: RemoteConfigUpdateError.streamError.rawValue, + userInfo: [ + NSLocalizedDescriptionKey: "Unable to connect to the server. Check your connection and try again.", + ]) + RCLog.error("I-RCN000014", "Cannot establish connection. Error: \(error)") + self.propagateErrors(error) + return + } + if self.canMakeConnection() { + self.remainingRetryCount -= 1 + self.settings.realtimeRetryCount += 1 + let backoffInterval = self.settings.realtimeBackoffInterval() + self.realtimeLockQueue.asyncAfter(deadline: .now() + backoffInterval) { + self.beginRealtimeStream() + } + } + } + } + + private func backgroundChangeListener() { + notificationCenter.addObserver( + self, + selector: #selector(willEnterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + notificationCenter.addObserver( + self, + selector: #selector(didEnterBackground), + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + } + + @objc private func willEnterForeground() { + realtimeLockQueue.async { + self.isInBackground = false + self.beginRealtimeStream() + } + } + + @objc private func didEnterBackground() { + realtimeLockQueue.async { + self.isInBackground = true + self.pauseRealtimeStream() + } + } + + // MARK: - Autofetch Helpers + + @objc(fetchLatestConfig:targetVersion:) public + func fetchLatestConfig(remainingAttempts: Int, targetVersion: Int) { + realtimeLockQueue.async { [weak self] in + guard let self else { return } + let attempts = remainingAttempts - 1 + self.configFetch.realtimeFetchConfig(fetchAttemptNumber: fetchAttempts - attempts) { + status, update, error in + if let error = error { + RCLog.error("I-RCN000010", + "Failed to retrieve config due to fetch error. Error: \(error)") + self.propagateErrors(error) + return + } + if status == .success { + if Int(self.configFetch.templateVersionNumber) ?? 0 >= targetVersion { + if let update = update, !update.updatedKeys.isEmpty { + self.realtimeLockQueue.async { [weak self] in + guard let self else { return } + for listener in self.listeners { + if let l = listener as? (RemoteConfigUpdate?, Error?) -> Void { + l(update, nil) + } + } + } + } + } else { + RCLog.debug("I-RCN000016", + "Fetched config's template version is outdated, re-fetching") + self.autoFetch(attempts: attempts, targetVersion: targetVersion) + } + } else { + RCLog.debug("I-RCN000016", + "Fetched config's template version is outdated, re-fetching") + self.autoFetch(attempts: attempts, targetVersion: targetVersion) + } + } + } + } + + @objc(scheduleFetch:targetVersion:) public + func scheduleFetch(remainingAttempts: Int, targetVersion: Int) { + let delay = TimeInterval.random(in: 0 ... 3) // Random delay between 0 and 3 seconds + realtimeLockQueue.asyncAfter(deadline: .now() + delay) { + self.fetchLatestConfig(remainingAttempts: remainingAttempts, targetVersion: targetVersion) + } + } + + @objc(autoFetch:targetVersion:) public + func autoFetch(attempts: Int, targetVersion: Int) { + realtimeLockQueue.async { + guard attempts > 0 else { + let error = NSError(domain: RemoteConfigUpdateErrorDomain, + code: RemoteConfigUpdateError.notFetched.rawValue, + userInfo: [ + NSLocalizedDescriptionKey: "Unable to fetch the latest version of the template.", + ]) + RCLog.error("I-RCN000011", "Ran out of fetch attempts, cannot find target config version.") + self.propagateErrors(error) + return + } + self.scheduleFetch(remainingAttempts: attempts, targetVersion: targetVersion) + } + } + + // MARK: - URLSessionDataDelegate + + public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, + didReceive data: Data) { + if !session.isEqual(self.session) { + return + } + + let strData = String(data: data, encoding: .utf8) ?? "" + if strData.contains(serverForbiddenStatusCode) { + let error = NSError(domain: RemoteConfigUpdateErrorDomain, + code: RemoteConfigUpdateError.streamError.rawValue, + userInfo: [NSLocalizedDescriptionKey: strData]) + RCLog.error("I-RCN000021", "Cannot establish connection. \(error)") + propagateErrors(error) + return + } + + if let beginRange = strData.range(of: "{"), + let endRange = strData.range(of: "}") { + RCLog.debug("I-RCN000015", "Received config update message on stream.") + let msgRange = Range(uncheckedBounds: (lower: beginRange.lowerBound, + upper: strData.index(after: endRange.upperBound))) + let jsonData = String(strData[msgRange]).data(using: .utf8)! + do { + if let response = try JSONSerialization.jsonObject(with: jsonData, + options: []) as? [String: Any] { + evaluateStreamResponse(response) + } + } catch { + propagateErrors(error) + return + } + } + } + + @objc public + func evaluateStreamResponse(_ response: [String: Any]) { + var updateTemplateVersion = 1 + if let version = response[templateVersionNumberKey] as? Int { + updateTemplateVersion = version + } + if let isDisabled = response[featureDisabledKey] as? Bool { + isRealtimeDisabled = isDisabled + } + if isRealtimeDisabled { + pauseRealtimeStream() + let error = NSError(domain: RemoteConfigUpdateErrorDomain, + code: RemoteConfigUpdateError.unavailable.rawValue, + userInfo: [ + NSLocalizedDescriptionKey: "The server is temporarily unavailable. Try again in a few minutes.", + ]) + propagateErrors(error) + } else { + let clientTemplateVersion = Int(configFetch.templateVersionNumber) ?? 0 + if updateTemplateVersion > clientTemplateVersion { + autoFetch(attempts: fetchAttempts, targetVersion: updateTemplateVersion) + } + } + } + + func isStatusCodeRetryable(_ statusCode: Int) -> Bool { + return statusCode == fetchResponseHTTPStatusClientTimeout || + statusCode == fetchResponseHTTPStatusCodeServiceUnavailable || + statusCode == fetchResponseHTTPStatusCodeBadGateway || + statusCode == fetchResponseHTTPStatusCodeGatewayTimeout + } + + public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, + didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + if !session.isEqual(self.session) { + completionHandler(.cancel) // Cancel if not current session + return + } + isRequestInProgress = false + if let httpResponse = response as? HTTPURLResponse { + let statusCode = httpResponse.statusCode + if statusCode == 403 { + completionHandler(.allow) + return + } + + if statusCode != fetchResponseHTTPStatusOK { + settings.updateRealtimeExponentialBackoffTime() + pauseRealtimeStream() + + if isStatusCodeRetryable(statusCode) { + retryHTTPConnection() + completionHandler(.cancel) // cancel the failing task + + } else { + let error = NSError( + domain: RemoteConfigUpdateErrorDomain, + code: RemoteConfigUpdateError.streamError.rawValue, + userInfo: [ + NSLocalizedDescriptionKey: + "Unable to connect to the server. Try again in a few minutes. HTTP Status code: \(statusCode)", + ] + ) + RCLog.error("I-RCN000021", "Cannot establish connection. Error: \(error)") + propagateErrors(error) + completionHandler(.cancel) // cancel the failing task + } + } else { + remainingRetryCount = maxRetries + settings.realtimeRetryCount = 0 + completionHandler(.allow) + } + } + } + + public func urlSession(_ session: URLSession, task: URLSessionTask, + didCompleteWithError error: Error?) { + if !session.isEqual(self.session) { + return + } + isRequestInProgress = false + if let error = error, error._code != NSURLErrorCancelled { + settings.updateRealtimeExponentialBackoffTime() + } + pauseRealtimeStream() + retryHTTPConnection() + } + + public func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) { + if !session.isEqual(self.session) { + return + } + if !isRequestInProgress { + if let _ = error { + settings.updateRealtimeExponentialBackoffTime() + } + pauseRealtimeStream() + retryHTTPConnection() + } + } + + // MARK: - Top Level Methods + + @objc public + func beginRealtimeStream() { + realtimeLockQueue.async { + if self.canMakeConnection() { + guard self.settings.realtimeBackoffInterval() <= 0.0 else { + self.retryHTTPConnection() + return + } + self.createRequestBody { requestBody in + var request = self.request + request.httpBody = requestBody + self.isRequestInProgress = true + self.dataTask = self.session?.dataTask(with: request) + self.dataTask?.resume() + } + } + } + } + + @objc public + func pauseRealtimeStream() { + realtimeLockQueue.async { + if let task = self.dataTask { + task.cancel() + self.dataTask = nil + } + } + } + + @discardableResult + @objc public func addConfigUpdateListener(_ listener: @Sendable @escaping (RemoteConfigUpdate?, + Error?) -> Void) + -> ConfigUpdateListenerRegistration { + realtimeLockQueue.async { + let temp = self.listeners.mutableCopy() as! NSMutableOrderedSet + temp.add(listener) + self.listeners = temp + self.beginRealtimeStream() + } + return ConfigUpdateListenerRegistration(client: self, completionHandler: listener) + } + + @objc public func removeConfigUpdateListener(_ listener: @escaping (RemoteConfigUpdate?, Error?) + -> Void) { + realtimeLockQueue.async { + let temp: NSMutableOrderedSet = self.listeners.mutableCopy() as! NSMutableOrderedSet + temp.remove(listener) + self.listeners = temp + if self.listeners.count == 0 { + self.pauseRealtimeStream() + } + } + } +} + +extension RemoteConfig { + static func sdkVersion() -> String { + return Bundle(identifier: "org.cocoapods.FirebaseRemoteConfig")? + .infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" + } +} diff --git a/FirebaseRemoteConfig/SwiftNew/ConfigSettings.swift b/FirebaseRemoteConfig/SwiftNew/ConfigSettings.swift index 6afb938c996..aa9dc30760b 100644 --- a/FirebaseRemoteConfig/SwiftNew/ConfigSettings.swift +++ b/FirebaseRemoteConfig/SwiftNew/ConfigSettings.swift @@ -77,7 +77,7 @@ let RCNHTTPDefaultConnectionTimeout: TimeInterval = 60 // TODO(ncooke3): This property was atomic in ObjC. /// InstallationsID. /// - Note: The property is atomic because it is accessed across multiple threads. - @objc public var configInstallationsIdentifier: String? + @objc public var configInstallationsIdentifier = "" // TODO(ncooke3): This property was atomic in ObjC. /// Installations token. @@ -317,7 +317,7 @@ let RCNHTTPDefaultConnectionTimeout: TimeInterval = 60 /// Returns the difference between the Realtime backoff end time and the current time in a /// NSTimeInterval format. - @objc public func getRealtimeBackoffInterval() -> TimeInterval { + @objc public func realtimeBackoffInterval() -> TimeInterval { let now = Date().timeIntervalSince1970 return realtimeExponentialBackoffThrottleEndTime - now } @@ -422,7 +422,7 @@ let RCNHTTPDefaultConnectionTimeout: TimeInterval = 60 /// - Returns: Config fetch request string @objc public func nextRequest(withUserProperties userProperties: [String: Any]?) -> String { var request = "{" - request += "app_instance_id:'\(configInstallationsIdentifier ?? "")'" + request += "app_instance_id:'\(configInstallationsIdentifier)'" request += ", app_instance_id_token:'\(configInstallationsToken ?? "")'" request += ", app_id:'\(_googleAppID)'" request += ", country_code:'\(Device.remoteConfigDeviceCountry())'" diff --git a/FirebaseRemoteConfig/SwiftNew/Utils/InstallationsProtocol.swift b/FirebaseRemoteConfig/SwiftNew/Utils/InstallationsProtocol.swift new file mode 100644 index 00000000000..b363c3afdbf --- /dev/null +++ b/FirebaseRemoteConfig/SwiftNew/Utils/InstallationsProtocol.swift @@ -0,0 +1,24 @@ +// Copyright 2025 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 FirebaseInstallations +import Foundation + +/// Enable faking Installations for fetch and realtime testing. +@objc(FIRInstallationsProtocol) public protocol InstallationsProtocol { + func installationID(completion: @escaping (String?, (any Error)?) -> Void) + func authToken(completion: @escaping (InstallationsAuthTokenResult?, (any Error)?) -> Void) +} + +extension Installations: InstallationsProtocol {} diff --git a/FirebaseRemoteConfig/SwiftNew/Utils/UtilsURL.swift b/FirebaseRemoteConfig/SwiftNew/Utils/UtilsURL.swift new file mode 100644 index 00000000000..417c3c83a3c --- /dev/null +++ b/FirebaseRemoteConfig/SwiftNew/Utils/UtilsURL.swift @@ -0,0 +1,51 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseCore +import Foundation + +private let serverURLVersion = "/v1" +private let serverURLProjects = "/projects/" +private let serverURLNamespaces = "/namespaces/" +private let serverURLQuery = "fetch" + +class Utils { + class func constructServerURL(domain: String, apiKey: String?, optionsID: String, + namespace: String) -> URL { + var components = URLComponents() + components.scheme = "https" + components.host = domain + + guard let apiKey else { + fatalError("Missing `APIKey` from `FirebaseOptions`, please ensure the configured " + + "`FirebaseApp` is configured with `FirebaseOptions` that contains an `APIKey`.") + } + components.path = + "\(serverURLVersion)\(serverURLProjects)\(optionsID)\(serverURLNamespaces)" + + "\(namespaceOnly(namespace)):\(serverURLQuery)" + components.queryItems = [ + URLQueryItem(name: "key", value: apiKey), + ] + guard let url = components.url else { + fatalError("Could not construct valid URL. Check your project ID, namespace, and API Key") + } + return url + } + + class func namespaceOnly(_ fullyQualifiedNamespace: String) -> String { + let separatorIndex = fullyQualifiedNamespace.firstIndex(of: ":") ?? fullyQualifiedNamespace + .endIndex + return String(fullyQualifiedNamespace[.. Void + +class ConfigRealtimeTests: XCTestCase { + // Fake ConfigFetch - Simulates successful fetch with controllable version number + private class FakeConfigFetch: ConfigFetch { + var fakeTemplateVersionNumber: String = "0" + var fetchCompletionHandler: ConfigFetchCompletion? + + override func realtimeFetchConfig(fetchAttemptNumber: Int, + completionHandler: @escaping ConfigFetchCompletion) { + fetchCompletionHandler = completionHandler + } + } + + public typealias FIRInstallationsTokenHandler = (InstallationsAuthTokenResult?, Error?) -> Void + public typealias FIRInstallationsIDHandler = (String?, Error?) -> Void + + // Fake Installations - Simulates Installations token retrieval + private class FakeInstallations: InstallationsProtocol { + var fakeAuthToken: String? + var fakeInstallationID: String? + var authTokenCompletion: FIRInstallationsTokenHandler? + var installationIDCompletion: FIRInstallationsIDHandler? + + func authToken(completion: @escaping FIRInstallationsTokenHandler) { + authTokenCompletion = completion + } + + func installationID(completion: @escaping FIRInstallationsIDHandler) { + installationIDCompletion = completion + } + } + + var realtime: ConfigRealtime! + private var fakeFetch: FakeConfigFetch! + var fakeSettings: ConfigSettings! + private var fakeInstallations: FakeInstallations! + var options: FirebaseOptions! + let namespace = "test_namespace:test_app" + let expectationTimeout: TimeInterval = 2 + + override func setUp() { + super.setUp() + options = FirebaseOptions(googleAppID: "1:1234567890:ios:abcdef1234567890", + gcmSenderID: "1234567890") + options.apiKey = "fake_api_key" + fakeFetch = FakeConfigFetch( + content: ConfigContent.sharedInstance, + DBManager: ConfigDBManager.sharedInstance, + settings: ConfigSettings(databaseManager: ConfigDBManager.sharedInstance, + namespace: namespace, firebaseAppName: "test_app", + googleAppID: options.googleAppID), + analytics: nil, + experiment: nil, + queue: DispatchQueue.main, + namespace: namespace, + options: options + ) + fakeSettings = ConfigSettings(databaseManager: ConfigDBManager.sharedInstance, + namespace: namespace, firebaseAppName: "test_app", + googleAppID: options.googleAppID) + + fakeInstallations = FakeInstallations() + realtime = ConfigRealtime(configFetch: fakeFetch, + settings: fakeSettings, namespace: namespace, + options: options, installations: fakeInstallations) + } + + override func tearDown() { + realtime = nil + fakeFetch = nil + fakeSettings = nil + options = nil + fakeInstallations = nil + super.tearDown() + } + + private let fetchResponseHTTPStatusOK = 200 + private let fetchResponseHTTPStatusClientTimeout = 429 + private let fetchResponseHTTPStatusCodeBadGateway = 502 + private let fetchResponseHTTPStatusCodeServiceUnavailable = 503 + private let fetchResponseHTTPStatusCodeGatewayTimeout = 504 + + func testIsStatusCodeRetryable() { + XCTAssertTrue(realtime.isStatusCodeRetryable(fetchResponseHTTPStatusClientTimeout)) + XCTAssertTrue(realtime.isStatusCodeRetryable(fetchResponseHTTPStatusCodeServiceUnavailable)) + XCTAssertTrue(realtime.isStatusCodeRetryable(fetchResponseHTTPStatusCodeBadGateway)) + XCTAssertTrue(realtime.isStatusCodeRetryable(fetchResponseHTTPStatusCodeGatewayTimeout)) + XCTAssertFalse(realtime.isStatusCodeRetryable(fetchResponseHTTPStatusOK)) + XCTAssertFalse(realtime.isStatusCodeRetryable(400)) // Example non-retryable code + } + + func testInBackground() { + realtime.isInBackground = true // Set background state + XCTAssertFalse(realtime.canMakeConnection()) // Should not be able to connect + } + + func testRealtimeDisabled() { + realtime.isRealtimeDisabled = true // Disable realtime updates + XCTAssertFalse(realtime.canMakeConnection()) // Should not be able to connect + } +} diff --git a/FirebaseRemoteConfig/Tests/Unit/RCNPersonalizationTest.m b/FirebaseRemoteConfig/Tests/Unit/RCNPersonalizationTest.m index b1022a8a70c..49123cb75f1 100644 --- a/FirebaseRemoteConfig/Tests/Unit/RCNPersonalizationTest.m +++ b/FirebaseRemoteConfig/Tests/Unit/RCNPersonalizationTest.m @@ -116,14 +116,15 @@ - (void)setUp { RCNConfigContent *configContent = [[RCNConfigContent alloc] initWithDBManager:DBManager]; // Create a mock FIRRemoteConfig instance. - _configInstance = OCMPartialMock([[FIRRemoteConfig alloc] - initWithAppName:@"testApp" - FIROptions:[[FIROptions alloc] initWithGoogleAppID:@"1:123:ios:test" - GCMSenderID:@"testSender"] - namespace:@"namespace" - DBManager:DBManager - configContent:configContent - analytics:_analyticsMock]); + FIROptions *options = [[FIROptions alloc] initWithGoogleAppID:@"1:123:ios:test" + GCMSenderID:@"testSender"]; + options.APIKey = @"test API key"; + _configInstance = OCMPartialMock([[FIRRemoteConfig alloc] initWithAppName:@"testApp" + FIROptions:options + namespace:@"namespace" + DBManager:DBManager + configContent:configContent + analytics:_analyticsMock]); // [_configInstance setValue:[RCNPersonalizationTest mockFetchRequest] forKey:@"_configFetch"]; } diff --git a/FirebaseRemoteConfig/Tests/Unit/RCNRemoteConfigTest.m b/FirebaseRemoteConfig/Tests/Unit/RCNRemoteConfigTest.m index e0f7a7b40b5..a8e5e444f9f 100644 --- a/FirebaseRemoteConfig/Tests/Unit/RCNRemoteConfigTest.m +++ b/FirebaseRemoteConfig/Tests/Unit/RCNRemoteConfigTest.m @@ -28,7 +28,6 @@ // #import "FirebaseRemoteConfig/Sources/Private/FIRRemoteConfig_Private.h" #import "FirebaseRemoteConfig/Sources/Public/FirebaseRemoteConfig/FIRRemoteConfig.h" #import "FirebaseRemoteConfig/Sources/RCNConfigConstants.h" -#import "FirebaseRemoteConfig/Sources/RCNConfigRealtime.h" #import "Interop/Analytics/Public/FIRAnalyticsInterop.h" #import "FirebaseRemoteConfig/Tests/Unit/RCNTestUtilities.h" @@ -135,27 +134,23 @@ - (void)fetchWithUserProperties:(NSDictionary *)userProperties - (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; -- (void)createRequestBodyWithCompletion:(void (^)(NSData *_Nonnull requestBody))completion; - -- (FIRConfigUpdateListenerRegistration *_Nonnull)addConfigUpdateListener: - (RCNConfigUpdateCompletion _Nonnull)listener; -- (void)removeConfigUpdateListener:(RCNConfigUpdateCompletion _Nonnull)listener; -- (void)evaluateStreamResponse:(NSDictionary *)response error:(NSError *)dataError; - -@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; +//- (void)createRequestBodyWithCompletion:(void (^)(NSData *_Nonnull requestBody))completion; +//- (void)evaluateStreamResponse:(NSDictionary *)response error:(NSError *)dataError; +// +//@end @interface FIRRemoteConfig (ForTest) - (void)updateWithNewInstancesForConfigFetch:(RCNConfigFetch *)configFetch @@ -331,6 +326,12 @@ - (void)setUp { error:nil]; } installations:[[FIRMockInstallations alloc] init]]; + _configRealtime[i] = + [[RCNConfigRealtime alloc] initWithConfigFetch:configFetch + settings:_settings + namespace:_fullyQualifiedNamespace + options:currentOptions + installations:[[FIRMockInstallations alloc] init]]; FIRRemoteConfig *config = [[FIRRemoteConfig alloc] initWithAppName:currentAppName FIROptions:currentOptions namespace:currentNamespace @@ -338,15 +339,13 @@ - (void)setUp { configContent:configContent userDefaults:_userDefaults analytics:nil - configFetch:configFetch]; + configFetch:configFetch + configRealtime:_configRealtime[i]]; _configFetch[i] = configFetch; _configInstances[i] = config; - _configRealtime[i] = OCMPartialMock([[RCNConfigRealtime alloc] init:_configFetch[i] - settings:_settings - namespace:_fullyQualifiedNamespace - options:currentOptions]); _settings.configInstallationsIdentifier = @"iid"; + // TODO: Consider deleting rest of function... [_configInstances[i] updateWithNewInstancesForConfigFetch:_configFetch[i] configContent:configContent configSettings:_settings @@ -674,7 +673,8 @@ - (void)testFetchConfigsFailed { configContent:configContent userDefaults:_userDefaults analytics:nil - configFetch:nil]); + configFetch:nil + configRealtime:nil]); _configInstances[i] = config; @@ -778,7 +778,8 @@ - (void)testFetchConfigsFailedErrorNoNetwork { configContent:configContent userDefaults:_userDefaults analytics:nil - configFetch:nil]); + configFetch:nil + configRealtime:nil]); _configInstances[i] = config; RCNConfigSettings *settings = @@ -820,10 +821,12 @@ - (void)testFetchConfigsFailedErrorNoNetwork { } installations:[[FIRMockInstallations alloc] init]]; - _configRealtime[i] = OCMPartialMock([[RCNConfigRealtime alloc] init:_configFetch[i] - settings:settings - namespace:fullyQualifiedNamespace - options:currentOptions]); + _configRealtime[i] = + [[RCNConfigRealtime alloc] initWithConfigFetch:_configFetch[i] + settings:settings + namespace:fullyQualifiedNamespace + options:currentOptions + installations:[[FIRMockInstallations alloc] init]]; [_configInstances[i] updateWithNewInstancesForConfigFetch:_configFetch[i] configContent:configContent @@ -1019,10 +1022,12 @@ - (void)testActivateOnFetchNoChangeStatus { } installations:[[FIRMockInstallations alloc] init]]; - _configRealtime[i] = OCMPartialMock([[RCNConfigRealtime alloc] init:_configFetch[i] - settings:settings - namespace:fullyQualifiedNamespace - options:currentOptions]); + _configRealtime[i] = + [[RCNConfigRealtime alloc] initWithConfigFetch:_configFetch[i] + settings:settings + namespace:fullyQualifiedNamespace + options:currentOptions + installations:[[FIRMockInstallations alloc] init]]; [_configInstances[i] updateWithNewInstancesForConfigFetch:_configFetch[i] configContent:configContent @@ -1568,6 +1573,7 @@ - (void)testConfigureConfigWithValidInput { #pragma mark - Realtime tests +#ifdef AFTER_SWIFT_REWRITE - (void)testRealtimeAddConfigUpdateListenerWithValidListener { NSMutableArray *expectations = [[NSMutableArray alloc] initWithCapacity:RCNTestRCNumTotalInstances]; @@ -1732,6 +1738,8 @@ - (void)testAddOnConfigUpdateMethodSuccess { } } +// TODO: Modify this test since the listener should not be nullable - verify beginRealtimeStream +// starts - and calls into listener? - (void)testAddOnConfigUpdateMethodFail { NSMutableArray *expectations = [[NSMutableArray alloc] initWithCapacity:RCNTestRCNumTotalInstances]; @@ -1767,7 +1775,7 @@ - (void)testRealtimeDisabled { [dictionary setValue:@"true" forKey:@"featureDisabled"]; [dictionary setValue:@"1" forKey:@"latestTemplateVersionNumber"]; - [_configRealtime[i] evaluateStreamResponse:dictionary error:nil]; + [_configRealtime[i] evaluateStreamResponse:dictionary]; dispatch_after( dispatch_time(DISPATCH_TIME_NOW, (int64_t)(_checkCompletionTimeout * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ @@ -1779,6 +1787,7 @@ - (void)testRealtimeDisabled { [self waitForExpectationsWithTimeout:_expectationTimeout handler:nil]; } } +#endif - (void)testRealtimeStreamRequestBody { XCTestExpectation *requestBodyExpectation = [self expectationWithDescription:@"requestBody"]; From 9ec28e008fbbb9ece482030c77eef8b36910f871 Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Thu, 2 Jan 2025 14:35:54 -0800 Subject: [PATCH 2/6] fixes --- .../SwiftNew/ConfigRealtime.swift | 39 +++++++++++------ .../Tests/Swift/ObjC/Bridging-Header.h | 1 - .../Tests/Swift/ObjC/RealtimeMocks.h | 24 ----------- .../Tests/Swift/ObjC/RealtimeMocks.m | 42 ------------------- .../Tests/Swift/SwiftAPI/APITestBase.swift | 5 --- .../Tests/Swift/SwiftAPI/APITests.swift | 3 +- ...ebaseRemoteConfigSwift_APIBuildTests.swift | 5 ++- 7 files changed, 32 insertions(+), 87 deletions(-) delete mode 100644 FirebaseRemoteConfig/Tests/Swift/ObjC/RealtimeMocks.h delete mode 100644 FirebaseRemoteConfig/Tests/Swift/ObjC/RealtimeMocks.m diff --git a/FirebaseRemoteConfig/SwiftNew/ConfigRealtime.swift b/FirebaseRemoteConfig/SwiftNew/ConfigRealtime.swift index f61a9f0f636..21281d21037 100644 --- a/FirebaseRemoteConfig/SwiftNew/ConfigRealtime.swift +++ b/FirebaseRemoteConfig/SwiftNew/ConfigRealtime.swift @@ -17,6 +17,13 @@ import FirebaseInstallations import Foundation @_implementationOnly import GoogleUtilities +#if canImport(UIKit) // iOS/tvOS/watchOS + import UIKit +#endif +#if canImport(AppKit) // macOS + import AppKit +#endif + // URL params private let serverURLDomain = "firebaseremoteconfigrealtime.googleapis.com" @@ -355,18 +362,26 @@ class ConfigRealtime: NSObject, URLSessionDataDelegate { } private func backgroundChangeListener() { - notificationCenter.addObserver( - self, - selector: #selector(willEnterForeground), - name: UIApplication.willEnterForegroundNotification, - object: nil - ) - notificationCenter.addObserver( - self, - selector: #selector(didEnterBackground), - name: UIApplication.didEnterBackgroundNotification, - object: nil - ) + #if canImport(UIKit) + NotificationCenter.default.addObserver(self, + selector: #selector(willEnterForeground), + name: UIApplication + .willEnterForegroundNotification, + object: nil) + NotificationCenter.default.addObserver(self, + selector: #selector(didEnterBackground), + name: UIApplication.didEnterBackgroundNotification, + object: nil) + #elseif canImport(AppKit) + NotificationCenter.default.addObserver(self, + selector: #selector(willEnterForeground), + name: NSApplication.willBecomeActiveNotification, + object: nil) + NotificationCenter.default.addObserver(self, + selector: #selector(didEnterBackground), + name: NSApplication.didResignActiveNotification, + object: nil) + #endif } @objc private func willEnterForeground() { diff --git a/FirebaseRemoteConfig/Tests/Swift/ObjC/Bridging-Header.h b/FirebaseRemoteConfig/Tests/Swift/ObjC/Bridging-Header.h index 11140ba9c99..ef2472558b5 100644 --- a/FirebaseRemoteConfig/Tests/Swift/ObjC/Bridging-Header.h +++ b/FirebaseRemoteConfig/Tests/Swift/ObjC/Bridging-Header.h @@ -14,4 +14,3 @@ #import "FirebaseRemoteConfig/Sources/Private/FIRRemoteConfig_Private.h" #import "FirebaseRemoteConfig/Sources/RCNConfigConstants.h" -#import "FirebaseRemoteConfig/Tests/Swift/ObjC/RealtimeMocks.h" diff --git a/FirebaseRemoteConfig/Tests/Swift/ObjC/RealtimeMocks.h b/FirebaseRemoteConfig/Tests/Swift/ObjC/RealtimeMocks.h deleted file mode 100644 index 0e14fc52238..00000000000 --- a/FirebaseRemoteConfig/Tests/Swift/ObjC/RealtimeMocks.h +++ /dev/null @@ -1,24 +0,0 @@ -// 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/FirebaseRemoteConfig/Tests/Swift/ObjC/RealtimeMocks.m b/FirebaseRemoteConfig/Tests/Swift/ObjC/RealtimeMocks.m deleted file mode 100644 index f35ceba7760..00000000000 --- a/FirebaseRemoteConfig/Tests/Swift/ObjC/RealtimeMocks.m +++ /dev/null @@ -1,42 +0,0 @@ -// 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 "FirebaseRemoteConfig/Tests/Swift/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/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/APITestBase.swift b/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/APITestBase.swift index 56b502c3c21..0cda7dafeda 100644 --- a/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/APITestBase.swift +++ b/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/APITestBase.swift @@ -56,7 +56,6 @@ class APITestBase: XCTestCase { options.projectID = "Fake_Project" FirebaseApp.configure(options: options) APITests.mockedFetch = false - APITests.mockedRealtime = false #endif } } @@ -93,10 +92,6 @@ class APITestBase: XCTestCase { APITests.mockedFetch = true config.configFetch.installations = InstallationsFake() } - if !APITests.mockedRealtime { - APITests.mockedRealtime = true - config.configRealtime = RealtimeMocks.mockRealtime(config.configRealtime) - } fakeConsole = FakeConsole() config.configFetch.fetchSession = URLSessionMock(with: fakeConsole) config.configFetch.disableNetworkSessionRecreation = true diff --git a/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/APITests.swift b/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/APITests.swift index 6665dd91258..31319568d25 100644 --- a/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/APITests.swift +++ b/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/APITests.swift @@ -145,7 +145,8 @@ class APITests: APITestBase { // MARK: - RemoteConfigRealtime Tests - func testRealtimeRemoteConfigFakeConsole() { + // TODO: Fix by replacing mock with a a fake. + func SKIPtestRealtimeRemoteConfigFakeConsole() { guard APITests.useFakeConfig == true else { return } let expectation = self.expectation(description: #function) diff --git a/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/FirebaseRemoteConfigSwift_APIBuildTests.swift b/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/FirebaseRemoteConfigSwift_APIBuildTests.swift index e390e61c4c2..2264899fc09 100644 --- a/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/FirebaseRemoteConfigSwift_APIBuildTests.swift +++ b/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/FirebaseRemoteConfigSwift_APIBuildTests.swift @@ -26,8 +26,9 @@ final class FirebaseRemoteConfig_APIBuildTests: XCTestCase { let _: String = FirebaseRemoteConfig.NamespaceGoogleMobilePlatform let _: String = FirebaseRemoteConfig.RemoteConfigThrottledEndTimeInSecondsKey - // TODO(ncooke3): This should probably not be initializable. - FirebaseRemoteConfig.ConfigUpdateListenerRegistration().remove() + func testRemoveListener(registration: ConfigUpdateListenerRegistration) { + registration.remove() + } let fetchStatus: FirebaseRemoteConfig.RemoteConfigFetchStatus? = nil switch fetchStatus! { From b53e360cd5527a5e2f5f4331ce2af8a08badc271 Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Thu, 2 Jan 2025 16:56:05 -0800 Subject: [PATCH 3/6] review --- FirebaseRemoteConfig/SwiftNew/ConfigRealtime.swift | 8 +++----- FirebaseRemoteConfig/SwiftNew/Utils/UtilsURL.swift | 2 +- .../Tests/SwiftUnit/ConfigRealtimeTests.swift | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/FirebaseRemoteConfig/SwiftNew/ConfigRealtime.swift b/FirebaseRemoteConfig/SwiftNew/ConfigRealtime.swift index 21281d21037..934ce8c187a 100644 --- a/FirebaseRemoteConfig/SwiftNew/ConfigRealtime.swift +++ b/FirebaseRemoteConfig/SwiftNew/ConfigRealtime.swift @@ -1,4 +1,4 @@ -// Copyright 2024 Google LLC +// Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -64,9 +64,7 @@ private let maxRetries = 7 /// the associated listener from receiving config updates and unregisters itself. /// /// If `remove` is called and no other listener registrations remain, the connection to the -/// real-time -/// connection. - +/// real-time connection. @objc(FIRConfigUpdateListenerRegistration) public final class ConfigUpdateListenerRegistration: NSObject, Sendable { let completionHandler: @Sendable (RemoteConfigUpdate?, Error?) -> Void @@ -109,7 +107,7 @@ class ConfigRealtime: NSObject, URLSessionDataDelegate { settings: ConfigSettings, namespace: String, options: FirebaseOptions, - installations: InstallationsProtocol?) { + installations: InstallationsProtocol? = nil) { self.configFetch = configFetch self.settings = settings self.options = options diff --git a/FirebaseRemoteConfig/SwiftNew/Utils/UtilsURL.swift b/FirebaseRemoteConfig/SwiftNew/Utils/UtilsURL.swift index 417c3c83a3c..443064c83aa 100644 --- a/FirebaseRemoteConfig/SwiftNew/Utils/UtilsURL.swift +++ b/FirebaseRemoteConfig/SwiftNew/Utils/UtilsURL.swift @@ -1,4 +1,4 @@ -// Copyright 2024 Google LLC +// Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/FirebaseRemoteConfig/Tests/SwiftUnit/ConfigRealtimeTests.swift b/FirebaseRemoteConfig/Tests/SwiftUnit/ConfigRealtimeTests.swift index 1d86b40be4c..904ae4a847f 100644 --- a/FirebaseRemoteConfig/Tests/SwiftUnit/ConfigRealtimeTests.swift +++ b/FirebaseRemoteConfig/Tests/SwiftUnit/ConfigRealtimeTests.swift @@ -1,4 +1,4 @@ -// Copyright 2024 Google LLC +// Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. From 0b0e250e2c3db27305dadc592408a1ae9178af87 Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Fri, 3 Jan 2025 11:18:53 -0800 Subject: [PATCH 4/6] Apply suggestions from code review Co-authored-by: Nick Cooke <36927374+ncooke3@users.noreply.github.com> --- FirebaseRemoteConfig/SwiftNew/ConfigRealtime.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/FirebaseRemoteConfig/SwiftNew/ConfigRealtime.swift b/FirebaseRemoteConfig/SwiftNew/ConfigRealtime.swift index 934ce8c187a..84cad624b51 100644 --- a/FirebaseRemoteConfig/SwiftNew/ConfigRealtime.swift +++ b/FirebaseRemoteConfig/SwiftNew/ConfigRealtime.swift @@ -260,6 +260,7 @@ class ConfigRealtime: NSObject, URLSessionDataDelegate { guard let self = self else { return } // Dispatch to the RC serial queue to update settings on the queue. self.realtimeLockQueue.async { + /// Update config settings with the IID and token. self.settings.configInstallationsToken = tokenResult.authToken self.settings.configInstallationsIdentifier = identifier ?? "" if let error = error { @@ -333,11 +334,12 @@ class ConfigRealtime: NSObject, URLSessionDataDelegate { func canMakeConnection() -> Bool { let noRunningConnection = dataTask == nil || dataTask?.state != .running - return noRunningConnection && listeners.count == 0 && !isInBackground && !isRealtimeDisabled + return noRunningConnection && listeners.count > 0 && !isInBackground && !isRealtimeDisabled } func retryHTTPConnection() { - realtimeLockQueue.async { + realtimeLockQueue.async { [weak self] in + guard let self, !self.isInBackground else { return } guard self.remainingRetryCount > 0 else { let error = NSError(domain: RemoteConfigUpdateErrorDomain, code: RemoteConfigUpdateError.streamError.rawValue, @@ -391,8 +393,8 @@ class ConfigRealtime: NSObject, URLSessionDataDelegate { @objc private func didEnterBackground() { realtimeLockQueue.async { - self.isInBackground = true self.pauseRealtimeStream() + self.isInBackground = true } } From c257fe6949dc1abb10a3f724cdb35699c0c4f1ee Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Fri, 3 Jan 2025 11:24:46 -0800 Subject: [PATCH 5/6] review --- .../SwiftNew/ConfigRealtime.swift | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/FirebaseRemoteConfig/SwiftNew/ConfigRealtime.swift b/FirebaseRemoteConfig/SwiftNew/ConfigRealtime.swift index 84cad624b51..82d112800e1 100644 --- a/FirebaseRemoteConfig/SwiftNew/ConfigRealtime.swift +++ b/FirebaseRemoteConfig/SwiftNew/ConfigRealtime.swift @@ -43,7 +43,7 @@ private let iOSBundleIdentifierHeaderName = "X-Ios-Bundle-Identifier" // Retryable HTTP status code. private let fetchResponseHTTPStatusOK = 200 -private let fetchResponseHTTPStatusClientTimeout = 429 +private let fetchResponseHTTPStatusTooManyRequests = 429 private let fetchResponseHTTPStatusCodeBadGateway = 502 private let fetchResponseHTTPStatusCodeServiceUnavailable = 503 private let fetchResponseHTTPStatusCodeGatewayTimeout = 504 @@ -311,7 +311,7 @@ class ConfigRealtime: NSObject, URLSessionDataDelegate { namespace:'\(Utils.namespaceOnly(self.namespace))', lastKnownVersionNumber:'\(self.configFetch.templateVersionNumber)', appId:'\(self.options.googleAppID)', - sdkVersion:'\(RemoteConfig.sdkVersion())', + sdkVersion:'\(Device.remoteConfigPodVersion())', appInstanceId:'\(self.settings.configInstallationsIdentifier)' } """ @@ -385,14 +385,16 @@ class ConfigRealtime: NSObject, URLSessionDataDelegate { } @objc private func willEnterForeground() { - realtimeLockQueue.async { + realtimeLockQueue.async { [weak self] in + guard let self else { return } self.isInBackground = false self.beginRealtimeStream() } } @objc private func didEnterBackground() { - realtimeLockQueue.async { + realtimeLockQueue.async{ [weak self] in + guard let self else { return } self.pauseRealtimeStream() self.isInBackground = true } @@ -526,7 +528,7 @@ class ConfigRealtime: NSObject, URLSessionDataDelegate { } func isStatusCodeRetryable(_ statusCode: Int) -> Bool { - return statusCode == fetchResponseHTTPStatusClientTimeout || + return statusCode == fetchResponseHTTPStatusTooManyRequests || statusCode == fetchResponseHTTPStatusCodeServiceUnavailable || statusCode == fetchResponseHTTPStatusCodeBadGateway || statusCode == fetchResponseHTTPStatusCodeGatewayTimeout @@ -658,10 +660,3 @@ class ConfigRealtime: NSObject, URLSessionDataDelegate { } } } - -extension RemoteConfig { - static func sdkVersion() -> String { - return Bundle(identifier: "org.cocoapods.FirebaseRemoteConfig")? - .infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" - } -} From 5b228cf26430810aca23739cbea0b03be6b2b4ca Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Fri, 3 Jan 2025 11:30:50 -0800 Subject: [PATCH 6/6] style --- FirebaseRemoteConfig/SwiftNew/ConfigRealtime.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/FirebaseRemoteConfig/SwiftNew/ConfigRealtime.swift b/FirebaseRemoteConfig/SwiftNew/ConfigRealtime.swift index 82d112800e1..efff139c7eb 100644 --- a/FirebaseRemoteConfig/SwiftNew/ConfigRealtime.swift +++ b/FirebaseRemoteConfig/SwiftNew/ConfigRealtime.swift @@ -393,7 +393,7 @@ class ConfigRealtime: NSObject, URLSessionDataDelegate { } @objc private func didEnterBackground() { - realtimeLockQueue.async{ [weak self] in + realtimeLockQueue.async { [weak self] in guard let self else { return } self.pauseRealtimeStream() self.isInBackground = true @@ -528,7 +528,7 @@ class ConfigRealtime: NSObject, URLSessionDataDelegate { } func isStatusCodeRetryable(_ statusCode: Int) -> Bool { - return statusCode == fetchResponseHTTPStatusTooManyRequests || + return statusCode == fetchResponseHTTPStatusTooManyRequests || statusCode == fetchResponseHTTPStatusCodeServiceUnavailable || statusCode == fetchResponseHTTPStatusCodeBadGateway || statusCode == fetchResponseHTTPStatusCodeGatewayTimeout