浏览代码

Merge Realtime into Remote Config (#10060)

* Realtime rc dev (#9807)

* Remove commits from non-CLA account. Move TypeDef to private RCNRealtimeConfig file.

* Remove completion handler typedef

* Format file

* Update listener registration description

* Realtime rc dev (#9835)

* Remove commits from non-CLA account. Move TypeDef to private RCNRealtimeConfig file.

* Remove completion handler typedef

* Format file

* Update listener registration description

* Add http streaming and auto fetch. Also add template version number to cache for autofetch.

* Format file.

* Add unit tests

* Format files

* fix test

* update test and header file

* Change templateVersionNumber to ivar

* Fix async tests and update NSDictionary changes

* Format file

* Update fetch time

* remove define header

* Wrap auto fetch in async queue

* Add auto fetch test

* Change template version extraction check.

* Change realtime error code to start at 8001

* Realtime rc dev (#9914)

* Remove commits from non-CLA account. Move TypeDef to private RCNRealtimeConfig file.

* Remove completion handler typedef

* Format file

* Update listener registration description

* Add http streaming and auto fetch. Also add template version number to cache for autofetch.

* Format file.

* Add unit tests

* Format files

* fix test

* update test and header file

* Change templateVersionNumber to ivar

* Fix async tests and update NSDictionary changes

* Format file

* Update fetch time

* remove define header

* Add http stream retry and background change listener.

* Wrap auto fetch in async queue

* Expose realtime method in public

* Update comments

* Add auto fetch test

* Change template version extraction check.

* Change realtime error code to start at 8001

* Change from NSTimer to dispatch. Add flag to guard against retrying.

* Format and add comments.

* Update tests

* Qdpham13 patch 1 (#9913)

* Delete .SecondApp-GoogleService-Info.plist

* Delete SecondApp-GoogleService-Info.plist

* Delete GoogleService-Info.plist

* Delete SecondApp-GoogleService-Info.plist

* Update ViewController.m

* Delete clang-format directory

* format

* Change to NSExtensionHostWillEnterForegroundNotification

* Change to UIApplicationWillEnterForegroundNotification

* add import

* Remove import, use string literal instead of UIKit enum

* add test for new public method

* Update template version number retrieval function and update delay

* Fix test

* Fix test

* Update retry seconds and fetch interval seconds

* Update ddos to DDoS

* Update variable according to style guide.

* Replace NSLog with kFIRLoggerRemoteConfig (#10062)

* Replace NSLog with kFIRLoggerRemoteConfig

* Remove logs and change others from info to debug.

* update log messages.

* Format files

* Realtime rc failsafe (#10067)

* Add failsafe logic to request, response and application logic.

* Update flag name and conditions.

* Format file

* Add check for template version number

* Format files

* Changes based on PR comments

* move response parsing logic to separate function and add test for failsafe flag

* Update based on PR comments and add method to propagate error to listeners in queue.

* Realtime rc merge patch v1 (#10089)

* Fix foreground reconnection naming, add background check and flag, add check to see if fetch is currently in progress.

* Add check so fetches come only when the template version is greater than the current one.

* Format file changes

* Add debug log

* Make changes based on PR comments.

* Resolve failsafe changes

* Updating instance var names

* change template version check for auto fetch from >= to >

* Cherry-pick #10123 into Firebase 9.5.0

* [RC] Fix retry logic to respect max retry count (#10143)

* Fix retry logic to respect max retry count

* Rename to isRequestInProgress

* Save template version in settings (#10162)

* Save template version in settings

* Format files

* set last template version to 0

* Store template version in userDefaults instead of deviceContents

* return nil for unsuccessful fetches

* fix typo

* Realtime rc no change (#10188)

* Save template version in settings

* Format files

* set last template version to 0

* Store template version in userDefaults instead of deviceContents

* return nil for unsuccessful fetches

* fix typo

* put addEtagToHeader flag to fetch and propagate it to surrounding functions

* Add excludeEtagHeaderForRealtime flag to RCNConfigFetch

* Update mocks for Swift test

* Fix flag

* Add commentting

* reconcile master branch merge conflicts

* Revert "Realtime rc no change (#10188)" (#10346)

This reverts commit abe7939602ae743468ec0b06384f49eb7e302e17.
In part of implementing a different solution.

* Add Http status check to Realtime stream (#10406)

* Check status code before retrying

* Change time out to 5 and a half minutes

* Throw exception if unable to establish connection due to non-retryable status code

* Format file

* Replace old realtime exceptions with new exceptions (#10482)

* Replace old realtime exceptions with new exceptions

* Fix word type

* Realtime RC Fetch ID (#10499)

* Add Realtime specific fetch method and fetch type header changes

* Add test

* Add comments and format

* Fix tests

* Update fetch header name

* Update tests and edit realtime fetch name and params

* refactor remainingAttempts var

* Realtime rc backoff (#10483)

* Change Realtime back off to use same system as Fetch and persist backoff state

* format and fix merge conflict

* Save retry count along with other throttling metadata

* Update var name

* Fix naming

* Return ConfigUpdate to realtime listeners (#10578)

* diff fetched and active config on realtime fetch

* Some refactoring and added tests

* Revert some unrelated changes to fetch

* Fix formatting

* Update branch and fix swift tests

* Rename updatedParams to updatedKeys (#10683)

* Update Realtime RC service URL. (#10713)

* Compare p13n metadata values as dictionaries. (#10756)

* Realtime RC 1P Bug Fix: Add checks for retryHttpConnection method (#10770)

* Add checks to retry method

* Move background check to beginning of retry

* Capitalize FetchType header (#10774)

* Remove Fetch Duplicate error and fix API misuse error (#10814)

* Remove fetch duplicate error

* Move completionHandler to bottom of didReceiveData

* Remove unused var

* Return Server produced error if Http status code is 403  (#10844)

* Add API message to enable Realtime

* Update error message parsing to return error message from backend

* Format file

* Add comment and remove unnecessary string

* Format and update error checks

* Change matcher string

* Update matcher string

* Address PR comments (#10845)

* Address PR comments

* Add Realtime Swift mocks

* Remove unnecessary mocks

* Format files

* Format tests

* Add Realtime mock

* Add missing quote

* Fix bugs

* Remove import

* Update import

* Change import

* Don't make connection call for Realtime mock

* Move around import of RCNConfigSettings

* Fix andDo

* Update test

* Add mockedRealtime flags

* trigger integration test

* Add integration and unit test

* change constant value

* Add new RC key

* Update update

* Remove deleted item

* Add registration removal

* Fix and format files

* remove semicolon

* Update error check

* remove space

* Update capitalization

* Add space

* Remove spaces

* remove more spaces

* Remove comment and add swift name

* format

* Add tests and sampleApp changes for updatedKeys (#10879)

* Update tests and sample app

* change key name

* remove unused var

* Add comment

* update sampleApp comments

* Update comments for public methods

* Update backoff commenting

* Add changelog entry for real-time updates. (#10942)

* Add changelog entry for RC real time updates.

* Hyphenation.

* Change doc references to hyphenate real-time (#10948)

* Change doc references to hyphenate real-time

* Run formatter.

* Re-word real-time paragraph to flow better.

---------

Co-authored-by: Andrew Heard <andrew@wizheard.com>
Co-authored-by: karenyz <58443706+karenyz@users.noreply.github.com>
Co-authored-by: Karen Zeng <karenzeng@google.com>
Co-authored-by: Dana Silver <dsilver1221@gmail.com>
Co-authored-by: Dana Silver <danasilver@google.com>
Quan Pham 3 年之前
父节点
当前提交
4e5b83f2d9
共有 27 个文件被更改,包括 1973 次插入67 次删除
  1. 3 0
      FirebaseRemoteConfig/CHANGELOG.md
  2. 16 0
      FirebaseRemoteConfig/Sources/FIRRemoteConfig.m
  3. 33 0
      FirebaseRemoteConfig/Sources/FIRRemoteConfigUpdate.m
  4. 9 0
      FirebaseRemoteConfig/Sources/Private/FIRRemoteConfig_Private.h
  5. 15 0
      FirebaseRemoteConfig/Sources/Private/RCNConfigFetch.h
  6. 20 1
      FirebaseRemoteConfig/Sources/Private/RCNConfigSettings.h
  7. 72 0
      FirebaseRemoteConfig/Sources/Public/FirebaseRemoteConfig/FIRRemoteConfig.h
  8. 2 0
      FirebaseRemoteConfig/Sources/RCNConfigConstants.h
  9. 5 0
      FirebaseRemoteConfig/Sources/RCNConfigContent.h
  10. 51 0
      FirebaseRemoteConfig/Sources/RCNConfigContent.m
  11. 178 49
      FirebaseRemoteConfig/Sources/RCNConfigFetch.m
  12. 40 0
      FirebaseRemoteConfig/Sources/RCNConfigRealtime.h
  13. 715 0
      FirebaseRemoteConfig/Sources/RCNConfigRealtime.m
  14. 51 1
      FirebaseRemoteConfig/Sources/RCNConfigSettings.m
  15. 9 0
      FirebaseRemoteConfig/Sources/RCNUserDefaultsManager.h
  16. 65 0
      FirebaseRemoteConfig/Sources/RCNUserDefaultsManager.m
  17. 35 1
      FirebaseRemoteConfig/Tests/Sample/RemoteConfigSampleApp/ViewController.m
  18. 175 0
      FirebaseRemoteConfig/Tests/Unit/RCNConfigContentTest.m
  19. 9 2
      FirebaseRemoteConfig/Tests/Unit/RCNPersonalizationTest.m
  20. 290 8
      FirebaseRemoteConfig/Tests/Unit/RCNRemoteConfigTest.m
  21. 61 0
      FirebaseRemoteConfig/Tests/Unit/RCNUserDefaultsManagerTests.m
  22. 1 0
      FirebaseRemoteConfigSwift/Tests/ObjC/Bridging-Header.h
  23. 10 5
      FirebaseRemoteConfigSwift/Tests/ObjC/FetchMocks.m
  24. 24 0
      FirebaseRemoteConfigSwift/Tests/ObjC/RealtimeMocks.h
  25. 42 0
      FirebaseRemoteConfigSwift/Tests/ObjC/RealtimeMocks.m
  26. 6 0
      FirebaseRemoteConfigSwift/Tests/SwiftAPI/APITestBase.swift
  27. 36 0
      FirebaseRemoteConfigSwift/Tests/SwiftAPI/APITests.swift

+ 3 - 0
FirebaseRemoteConfig/CHANGELOG.md

@@ -1,3 +1,6 @@
+# 10.7.0
+- [feature] Added support for real-time config updates. To learn more, see [Get started with Firebase Remote Config](https://firebase.google.com/docs/remote-config/get-started?platform=ios).
+
 # 9.3.0
 - [changed] Arrays and Dictionaries are now supported when initializing defaults from a
   plist. (#8306)

+ 16 - 0
FirebaseRemoteConfig/Sources/FIRRemoteConfig.m

@@ -26,6 +26,7 @@
 #import "FirebaseRemoteConfig/Sources/RCNConfigContent.h"
 #import "FirebaseRemoteConfig/Sources/RCNConfigDBManager.h"
 #import "FirebaseRemoteConfig/Sources/RCNConfigExperiment.h"
+#import "FirebaseRemoteConfig/Sources/RCNConfigRealtime.h"
 #import "FirebaseRemoteConfig/Sources/RCNConfigValue_Internal.h"
 #import "FirebaseRemoteConfig/Sources/RCNDevice.h"
 #import "FirebaseRemoteConfig/Sources/RCNPersonalization.h"
@@ -33,6 +34,8 @@
 /// Remote Config Error Domain.
 /// TODO: Rename according to obj-c style for constants.
 NSString *const FIRRemoteConfigErrorDomain = @"com.google.remoteconfig.ErrorDomain";
+// Remote Config Realtime Error Domain
+NSString *const FIRRemoteConfigUpdateErrorDomain = @"com.google.remoteconfig.update.ErrorDomain";
 /// Remote Config Error Info End Time Seconds;
 NSString *const FIRRemoteConfigThrottledEndTimeInSecondsKey = @"error_throttled_end_time_seconds";
 /// Minimum required time interval between fetch requests made to the backend.
@@ -66,6 +69,7 @@ typedef void (^FIRRemoteConfigListener)(NSString *_Nonnull, NSDictionary *_Nonnu
   RCNConfigSettings *_settings;
   RCNConfigFetch *_configFetch;
   RCNConfigExperiment *_configExperiment;
+  RCNConfigRealtime *_configRealtime;
   dispatch_queue_t _queue;
   NSString *_appName;
   NSMutableArray *_listeners;
@@ -165,6 +169,11 @@ static NSMutableDictionary<NSString *, NSMutableDictionary<NSString *, FIRRemote
                                                  namespace:_FIRNamespace
                                                    options:options];
 
+    _configRealtime = [[RCNConfigRealtime alloc] init:_configFetch
+                                             settings:_settings
+                                            namespace:_FIRNamespace
+                                              options:options];
+
     [_settings loadConfigFromMetadataTable];
 
     if (analytics) {
@@ -597,4 +606,11 @@ typedef void (^FIRRemoteConfigActivateChangeCompletion)(BOOL changed, NSError *_
   dispatch_async(_queue, setConfigSettingsBlock);
 }
 
+#pragma mark - Realtime
+
+- (FIRConfigUpdateListenerRegistration *)addOnConfigUpdateListener:
+    (void (^_Nonnull)(FIRRemoteConfigUpdate *update, NSError *_Nullable error))listener {
+  return [self->_configRealtime addConfigUpdateListener:listener];
+}
+
 @end

+ 33 - 0
FirebaseRemoteConfig/Sources/FIRRemoteConfigUpdate.m

@@ -0,0 +1,33 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#import <Foundation/Foundation.h>
+
+#import "FirebaseRemoteConfig/Sources/Public/FirebaseRemoteConfig/FIRRemoteConfig.h"
+
+@implementation FIRRemoteConfigUpdate {
+  NSSet<NSString *> *_updatedKeys;
+}
+
+- (instancetype)initWithUpdatedKeys:(NSSet<NSString *> *)updatedKeys {
+  self = [super init];
+  if (self) {
+    _updatedKeys = [updatedKeys copy];
+  }
+  return self;
+}
+
+@end

+ 9 - 0
FirebaseRemoteConfig/Sources/Private/FIRRemoteConfig_Private.h

@@ -21,12 +21,19 @@
 @class RCNConfigContent;
 @class RCNConfigDBManager;
 @class RCNConfigFetch;
+@class RCNConfigRealtime;
 @protocol FIRAnalyticsInterop;
 
 NS_ASSUME_NONNULL_BEGIN
 
 @class RCNConfigSettings;
 
+@interface FIRRemoteConfigUpdate ()
+
+/// Designated initializer.
+- (instancetype)initWithUpdatedKeys:(NSSet<NSString *> *)updatedKeys;
+@end
+
 @interface FIRRemoteConfig () {
   NSString *_FIRNamespace;
 }
@@ -37,6 +44,8 @@ NS_ASSUME_NONNULL_BEGIN
 /// Config settings are custom settings.
 @property(nonatomic, readwrite, strong, nonnull) RCNConfigFetch *configFetch;
 
+@property(nonatomic, readwrite, strong, nonnull) RCNConfigRealtime *configRealtime;
+
 /// Returns the FIRRemoteConfig instance for your namespace and for the default Firebase App.
 /// This singleton object contains the complete set of Remote Config parameter values available to
 /// the app, including the Active Config and Default Config.. This object also caches values fetched

+ 15 - 0
FirebaseRemoteConfig/Sources/Private/RCNConfigFetch.h

@@ -30,6 +30,11 @@ NS_ASSUME_NONNULL_BEGIN
 /// Completion handler invoked by NSSessionFetcher.
 typedef void (^RCNConfigFetcherCompletion)(NSData *data, NSURLResponse *response, NSError *error);
 
+/// Completion handler invoked after a fetch that contains the updated keys
+typedef void (^RCNConfigFetchCompletion)(FIRRemoteConfigFetchStatus status,
+                                         FIRRemoteConfigUpdate *update,
+                                         NSError *error);
+
 @interface RCNConfigFetch : NSObject
 
 - (instancetype)init NS_UNAVAILABLE;
@@ -50,12 +55,22 @@ typedef void (^RCNConfigFetcherCompletion)(NSData *data, NSURLResponse *response
 - (void)fetchConfigWithExpirationDuration:(NSTimeInterval)expirationDuration
                         completionHandler:(FIRRemoteConfigFetchCompletion)completionHandler;
 
+/// Fetches config data immediately, keyed by namespace. Completion block will be called on the main
+/// queue.
+/// @param fetchAttemptNumber The number of the fetch attempt.
+/// @param completionHandler   Callback handler.
+- (void)realtimeFetchConfigWithNoExpirationDuration:(NSInteger)fetchAttemptNumber
+                                  completionHandler:(RCNConfigFetchCompletion)completionHandler;
+
 /// Add the ability to update NSURLSession's timeout after a session has already been created.
 - (void)recreateNetworkSession;
 
 /// Provide fetchSession for tests to override.
 @property(nonatomic, readwrite, strong, nonnull) NSURLSession *fetchSession;
 
+/// Provide config template version number for Realtime config client.
+@property(nonatomic, copy, nonnull) NSString *templateVersionNumber;
+
 NS_ASSUME_NONNULL_END
 
 @end

+ 20 - 1
FirebaseRemoteConfig/Sources/Private/RCNConfigSettings.h

@@ -79,6 +79,8 @@
 @property(nonatomic, readwrite, assign) NSString *lastETag;
 /// The timestamp of the last eTag update.
 @property(nonatomic, readwrite, assign) NSTimeInterval lastETagUpdateTime;
+// Last fetched template version.
+@property(nonatomic, readwrite, assign) NSString *lastTemplateVersion;
 
 #pragma mark Throttling properties
 
@@ -90,6 +92,14 @@
 @property(nonatomic, readwrite, assign) double exponentialBackoffRetryInterval;
 /// Returns the time in seconds until the next request is allowed while in exponential backoff mode.
 @property(nonatomic, readonly, assign) NSTimeInterval exponentialBackoffThrottleEndTime;
+/// Returns the current retry interval in seconds set for exponential backoff for the Realtime
+/// service.
+@property(nonatomic, readwrite, assign) double realtimeExponentialBackoffRetryInterval;
+/// Returns the time in seconds until the next request is allowed while in exponential backoff mode
+/// for the Realtime service.
+@property(nonatomic, readonly, assign) NSTimeInterval realtimeExponentialBackoffThrottleEndTime;
+/// Realtime connection attempts.
+@property(nonatomic, readwrite, assign) int realtimeRetryCount;
 
 #pragma mark Throttling Methods
 
@@ -113,12 +123,21 @@
 
 /// Updates the metadata table with the current fetch status.
 /// @param fetchSuccess True if fetch was successful.
-- (void)updateMetadataWithFetchSuccessStatus:(BOOL)fetchSuccess;
+- (void)updateMetadataWithFetchSuccessStatus:(BOOL)fetchSuccess
+                             templateVersion:(NSString *)templateVersion;
 
 /// Increases the throttling time. Should only be called if the fetch error indicates a server
 /// issue.
 - (void)updateExponentialBackoffTime;
 
+/// Increases the throttling time for Realtime. Should only be called if the Realtime error
+/// indicates a server issue.
+- (void)updateRealtimeExponentialBackoffTime;
+
+/// Returns the difference between the Realtime backoff end time and the current time in a
+/// NSTimeInterval format.
+- (NSTimeInterval)getRealtimeBackoffInterval;
+
 /// Returns true if we are in exponential backoff mode and it is not yet the next request time.
 - (BOOL)shouldThrottle;
 

+ 72 - 0
FirebaseRemoteConfig/Sources/Public/FirebaseRemoteConfig/FIRRemoteConfig.h

@@ -30,6 +30,23 @@ extern NSString *const _Nonnull FIRNamespaceGoogleMobilePlatform NS_SWIFT_NAME(
 extern NSString *const _Nonnull FIRRemoteConfigThrottledEndTimeInSecondsKey NS_SWIFT_NAME(
     RemoteConfigThrottledEndTimeInSecondsKey);
 
+/**
+ * Listener registration returned by `addOnConfigUpdateListener`. Calling its method `remove` stops
+ * the listener from receiving config updates and unregisters itself.
+ *
+ * If remove is called and no other listener registrations remain, the connection to the real-time
+ * RC backend is closed. Subsequently calling `addOnConfigUpdateListener` will re-open the
+ * connection.
+ */
+NS_SWIFT_NAME(ConfigUpdateListenerRegistration)
+@interface FIRConfigUpdateListenerRegistration : NSObject
+/**
+ * Removes the listener being tracked by this `ConfigUpdateListenerRegistration`. After the initial
+ * call, subsequent calls have no effect.
+ */
+- (void)remove;
+@end
+
 /// Indicates whether updated data was successfully fetched.
 typedef NS_ENUM(NSInteger, FIRRemoteConfigFetchStatus) {
   /// Config has never been fetched.
@@ -65,6 +82,20 @@ typedef NS_ERROR_ENUM(FIRRemoteConfigErrorDomain, FIRRemoteConfigError){
     FIRRemoteConfigErrorInternalError = 8003,
 } NS_SWIFT_NAME(RemoteConfigError);
 
+/// Remote Config error domain that handles errors for the real-time service.
+extern NSString *const _Nonnull FIRRemoteConfigUpdateErrorDomain NS_SWIFT_NAME(RemoteConfigUpdateErrorDomain);
+/// Firebase Remote Config real-time service error.
+typedef NS_ERROR_ENUM(FIRRemoteConfigUpdateErrorDomain, FIRRemoteConfigUpdateError){
+    /// Unable to make a connection to the backend.
+    FIRRemoteConfigUpdateErrorStreamError = 8001,
+    /// Unable to fetch the latest config.
+    FIRRemoteConfigUpdateErrorNotFetched = 8002,
+    /// The ConfigUpdate message was unparsable.
+    FIRRemoteConfigUpdateErrorMessageInvalid = 8003,
+    /// The real-time Remote Config service is unavailable.
+    FIRRemoteConfigUpdateErrorUnavailable = 8004,
+} NS_SWIFT_NAME(RemoteConfigUpdateError);
+
 /// Enumerated value that indicates the source of Remote Config data. Data can come from
 /// the Remote Config service, the DefaultConfig that is available when the app is first installed,
 /// or a static initialized value if data is not available from the service or DefaultConfig.
@@ -139,6 +170,17 @@ NS_SWIFT_NAME(RemoteConfigSettings)
 @property(nonatomic, assign) NSTimeInterval fetchTimeout;
 @end
 
+#pragma mark - FIRRemoteConfigUpdate
+/// Firebase Remote Config update
+NS_SWIFT_NAME(RemoteConfigUpdate)
+@interface FIRRemoteConfigUpdate : NSObject
+
+/// Parameter keys whose values have been updated from the currently activated values. Includes
+/// keys that are added, deleted, and whose value, value source, or metadata has changed.
+@property(nonatomic, readonly, nonnull) NSSet<NSString *> *updatedKeys;
+
+@end
+
 #pragma mark - FIRRemoteConfig
 /// Firebase Remote Config class. The class method `remoteConfig()` can be used
 /// to fetch, activate and read config results and set default config results on the default
@@ -283,4 +325,34 @@ NS_SWIFT_NAME(RemoteConfig)
 ///                         nil if the key doesn't exist in the default config.
 - (nullable FIRRemoteConfigValue *)defaultValueForKey:(nullable NSString *)key;
 
+#pragma mark - Realtime
+
+/// Completion handler invoked by `addOnConfigUpdateListener` when there is an update to
+/// the config from the backend.
+///
+/// @param configUpdate Information on which key's values have changed
+/// @param error  Error message on failure.
+typedef void (^FIRRemoteConfigUpdateCompletion)(FIRRemoteConfigUpdate *_Nullable configUpdate,
+                                                NSError *_Nullable error)
+    NS_SWIFT_UNAVAILABLE("Use Swift's closure syntax instead.");
+
+/// Start listening for real-time config updates from the Remote Config backend and automatically
+/// fetch updates when they're available.
+///
+/// If a connection to the Remote Config backend is not already open, calling this method will
+/// open it. Multiple listeners can be added by calling this method again, but subsequent calls
+/// re-use the same connection to the backend.
+///
+/// Note: Real-time Remote Config requires the Firebase Remote Config Realtime API. See Get started
+/// with Firebase Remote Config at https://firebase.google.com/docs/remote-config/get-started for
+/// more information.
+///
+/// @param listener              The configured listener that is called for every config update.
+/// @return              Returns a registration representing the listener. The registration contains
+/// a remove method, which can be used to stop receiving for updates for this particular
+/// registration.
+- (FIRConfigUpdateListenerRegistration *_Nonnull)addOnConfigUpdateListener:
+    (FIRRemoteConfigUpdateCompletion _Nonnull)listener
+    NS_SWIFT_NAME(addOnConfigUpdateListener(remoteConfigUpdateCompletion:));
+
 @end

+ 2 - 0
FirebaseRemoteConfig/Sources/RCNConfigConstants.h

@@ -58,3 +58,5 @@ static NSString *const RCNFetchResponseKeyStateNoTemplate = @"NO_TEMPLATE";
 static NSString *const RCNFetchResponseKeyStateNoChange = @"NO_CHANGE";
 /// Template found, but evaluates to empty (e.g. all keys omitted).
 static NSString *const RCNFetchResponseKeyStateEmptyConfig = @"EMPTY_CONFIG";
+/// Template Version key
+static NSString *const RCNFetchResponseKeyTemplateVersion = @"templateVersion";

+ 5 - 0
FirebaseRemoteConfig/Sources/RCNConfigContent.h

@@ -16,6 +16,8 @@
 
 #import <Foundation/Foundation.h>
 
+#import "FirebaseRemoteConfig/Sources/Public/FirebaseRemoteConfig/FIRRemoteConfig.h"
+
 typedef NS_ENUM(NSInteger, RCNDBSource) {
   RCNDBSourceActive,
   RCNDBSourceDefault,
@@ -63,4 +65,7 @@ typedef NS_ENUM(NSInteger, RCNDBSource) {
 /// Gets the active config and Personalization metadata.
 - (NSDictionary *)getConfigAndMetadataForNamespace:(NSString *)FIRNamespace;
 
+/// Returns the updated parameters between fetched and active config.
+- (FIRRemoteConfigUpdate *)getConfigUpdateForNamespace:(NSString *)FIRNamespace;
+
 @end

+ 51 - 0
FirebaseRemoteConfig/Sources/RCNConfigContent.m

@@ -16,6 +16,7 @@
 
 #import "FirebaseRemoteConfig/Sources/RCNConfigContent.h"
 
+#import "FirebaseRemoteConfig/Sources/Private/FIRRemoteConfig_Private.h"
 #import "FirebaseRemoteConfig/Sources/Public/FirebaseRemoteConfig/FIRRemoteConfig.h"
 #import "FirebaseRemoteConfig/Sources/RCNConfigConstants.h"
 #import "FirebaseRemoteConfig/Sources/RCNConfigDBManager.h"
@@ -363,6 +364,11 @@ const NSTimeInterval kDatabaseLoadTimeoutSecs = 30.0;
   return _defaultConfig;
 }
 
+- (NSDictionary *)activePersonalization {
+  [self checkAndWaitForInitialDatabaseLoad];
+  return _activePersonalization;
+}
+
 - (NSDictionary *)getConfigAndMetadataForNamespace:(NSString *)FIRNamespace {
   /// If this is the first time reading the active metadata, we might still be reading it from the
   /// database.
@@ -392,4 +398,49 @@ const NSTimeInterval kDatabaseLoadTimeoutSecs = 30.0;
   return true;
 }
 
+// Compare fetched config with active config and output what has changed
+- (FIRRemoteConfigUpdate *)getConfigUpdateForNamespace:(NSString *)FIRNamespace {
+  // TODO: handle diff in experiment metadata
+
+  FIRRemoteConfigUpdate *configUpdate;
+  NSMutableSet<NSString *> *updatedKeys = [[NSMutableSet alloc] init];
+
+  NSDictionary *fetchedConfig =
+      _fetchedConfig[FIRNamespace] ? _fetchedConfig[FIRNamespace] : [[NSDictionary alloc] init];
+  NSDictionary *activeConfig =
+      _activeConfig[FIRNamespace] ? _activeConfig[FIRNamespace] : [[NSDictionary alloc] init];
+  NSDictionary *fetchedP13n = _fetchedPersonalization;
+  NSDictionary *activeP13n = _activePersonalization;
+
+  // add new/updated params
+  for (NSString *key in [fetchedConfig allKeys]) {
+    if (activeConfig[key] == nil ||
+        ![[activeConfig[key] stringValue] isEqualToString:[fetchedConfig[key] stringValue]]) {
+      [updatedKeys addObject:key];
+    }
+  }
+  // add deleted params
+  for (NSString *key in [activeConfig allKeys]) {
+    if (fetchedConfig[key] == nil) {
+      [updatedKeys addObject:key];
+    }
+  }
+
+  // add params with new/updated p13n metadata
+  for (NSString *key in [fetchedP13n allKeys]) {
+    if (activeP13n[key] == nil || ![activeP13n[key] isEqualToDictionary:fetchedP13n[key]]) {
+      [updatedKeys addObject:key];
+    }
+  }
+  // add params with deleted p13n metadata
+  for (NSString *key in [activeP13n allKeys]) {
+    if (fetchedP13n[key] == nil) {
+      [updatedKeys addObject:key];
+    }
+  }
+
+  configUpdate = [[FIRRemoteConfigUpdate alloc] initWithUpdatedKeys:updatedKeys];
+  return configUpdate;
+}
+
 @end

+ 178 - 49
FirebaseRemoteConfig/Sources/RCNConfigFetch.m

@@ -15,6 +15,7 @@
  */
 
 #import "FirebaseRemoteConfig/Sources/Private/RCNConfigFetch.h"
+#import "FirebaseRemoteConfig/Sources/Private/FIRRemoteConfig_Private.h"
 
 #import <GoogleUtilities/GULNSData+zlib.h>
 #import "FirebaseCore/Extension/FirebaseCoreInternal.h"
@@ -51,6 +52,11 @@ static NSString *const kInstallationsAuthTokenHeaderName = @"x-goog-firebase-ins
 static NSString *const kiOSBundleIdentifierHeaderName =
     @"X-Ios-Bundle-Identifier";  ///< HTTP Header Field Name
 
+static NSString *const kFetchTypeHeaderName =
+    @"X-Firebase-RC-Fetch-Type";  ///< Custom Http header key to identify the fetch type
+static NSString *const kBaseFetchType = @"BASE";          ///< Fetch identifier for Base Fetch
+static NSString *const kRealtimeFetchType = @"REALTIME";  ///< Fetch identifier for Realtime Fetch
+
 /// Config HTTP request content type proto buffer
 static NSString *const kContentTypeValueJSON = @"application/json";
 
@@ -61,9 +67,6 @@ static NSInteger const kRCNFetchResponseHTTPStatusCodeInternalError = 500;
 static NSInteger const kRCNFetchResponseHTTPStatusCodeServiceUnavailable = 503;
 static NSInteger const kRCNFetchResponseHTTPStatusCodeGatewayTimeout = 504;
 
-// Deprecated error code previously from FirebaseCore
-static const NSInteger sFIRErrorCodeConfigFailed = -114;
-
 #pragma mark - RCNConfig
 
 @implementation RCNConfigFetch {
@@ -75,6 +78,7 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114;
   NSURLSession *_fetchSession;  /// Managed internally by the fetch instance.
   NSString *_FIRNamespace;
   FIROptions *_options;
+  NSString *_templateVersionNumber;
 }
 
 - (instancetype)init {
@@ -101,6 +105,7 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114;
     _content = content;
     _fetchSession = [self newFetchSession];
     _options = options;
+    _templateVersionNumber = [self->_settings lastTemplateVersion];
   }
   return self;
 }
@@ -158,16 +163,9 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114;
       } else {
         FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000053",
                     @"A fetch is already in progress. Ignoring duplicate request.");
-        NSError *error =
-            [NSError errorWithDomain:FIRRemoteConfigErrorDomain
-                                code:sFIRErrorCodeConfigFailed
-                            userInfo:@{
-                              NSLocalizedDescriptionKey :
-                                  @"FetchError: Duplicate request while the previous one is pending"
-                            }];
         return [strongSelf reportCompletionOnHandler:completionHandler
                                           withStatus:FIRRemoteConfigFetchStatusFailure
-                                           withError:error];
+                                           withError:nil];
       }
     }
 
@@ -189,19 +187,89 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114;
                                          withError:error];
     }
     strongSelf->_settings.isFetchInProgress = YES;
-    [strongSelf refreshInstallationsTokenWithCompletionHandler:completionHandler];
+    NSString *fetchTypeHeader = [NSString stringWithFormat:@"%@/1", kBaseFetchType];
+    [strongSelf refreshInstallationsTokenWithFetchHeader:fetchTypeHeader
+                                       completionHandler:completionHandler
+                                 updateCompletionHandler:nil];
   });
 }
 
 #pragma mark - Fetch helpers
 
+- (void)realtimeFetchConfigWithNoExpirationDuration:(NSInteger)fetchAttemptNumber
+                                  completionHandler:(RCNConfigFetchCompletion)completionHandler {
+  // Note: We expect the googleAppID to always be available.
+  BOOL hasDeviceContextChanged =
+      FIRRemoteConfigHasDeviceContextChanged(_settings.deviceContext, _options.googleAppID);
+
+  __weak RCNConfigFetch *weakSelf = self;
+  dispatch_async(_lockQueue, ^{
+    RCNConfigFetch *strongSelf = weakSelf;
+    if (strongSelf == nil) {
+      return;
+    }
+
+    // Check if a fetch is already in progress.
+    // TODO: for this case should we still return a SUCCESS status?
+    if (strongSelf->_settings.isFetchInProgress) {
+      // Check if we have some fetched data.
+      if (strongSelf->_settings.lastFetchTimeInterval > 0) {
+        FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000052",
+                    @"A fetch is already in progress. Using previous fetch results.");
+        FIRRemoteConfigUpdate *update =
+            [self->_content getConfigUpdateForNamespace:self->_FIRNamespace];
+        return [strongSelf reportCompletionWithStatus:strongSelf->_settings.lastFetchStatus
+                                           withUpdate:update
+                                            withError:nil
+                                    completionHandler:nil
+                              updateCompletionHandler:completionHandler];
+      } else {
+        FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000053",
+                    @"A fetch is already in progress. Ignoring duplicate request.");
+        return [strongSelf reportCompletionWithStatus:FIRRemoteConfigFetchStatusFailure
+                                           withUpdate:nil
+                                            withError:nil
+                                    completionHandler:nil
+                              updateCompletionHandler:completionHandler];
+      }
+    }
+    // Check whether cache data is within throttle limit.
+    if ([strongSelf->_settings shouldThrottle] && !hasDeviceContextChanged) {
+      // Must set lastFetchStatus before FailReason.
+      strongSelf->_settings.lastFetchStatus = FIRRemoteConfigFetchStatusThrottled;
+      strongSelf->_settings.lastFetchError = FIRRemoteConfigErrorThrottled;
+      NSTimeInterval throttledEndTime = strongSelf->_settings.exponentialBackoffThrottleEndTime;
+
+      NSError *error =
+          [NSError errorWithDomain:FIRRemoteConfigErrorDomain
+                              code:FIRRemoteConfigErrorThrottled
+                          userInfo:@{
+                            FIRRemoteConfigThrottledEndTimeInSecondsKey : @(throttledEndTime)
+                          }];
+      return [strongSelf reportCompletionWithStatus:FIRRemoteConfigFetchStatusFailure
+                                         withUpdate:nil
+                                          withError:error
+                                  completionHandler:nil
+                            updateCompletionHandler:completionHandler];
+    }
+    strongSelf->_settings.isFetchInProgress = YES;
+
+    NSString *fetchTypeHeader =
+        [NSString stringWithFormat:@"%@/%ld", kRealtimeFetchType, (long)fetchAttemptNumber];
+    [strongSelf refreshInstallationsTokenWithFetchHeader:fetchTypeHeader
+                                       completionHandler:nil
+                                 updateCompletionHandler:completionHandler];
+  });
+}
+
 - (NSString *)FIRAppNameFromFullyQualifiedNamespace {
   return [[_FIRNamespace componentsSeparatedByString:@":"] lastObject];
 }
 /// Refresh installation ID token before fetching config. installation ID is now mandatory for fetch
 /// requests to work.(b/14751422).
-- (void)refreshInstallationsTokenWithCompletionHandler:
-    (FIRRemoteConfigFetchCompletion)completionHandler {
+- (void)refreshInstallationsTokenWithFetchHeader:(NSString *)fetchTypeHeader
+                               completionHandler:(FIRRemoteConfigFetchCompletion)completionHandler
+                         updateCompletionHandler:(RCNConfigFetchCompletion)updateCompletionHandler {
   FIRInstallations *installations = [FIRInstallations
       installationsWithApp:[FIRApp appNamed:[self FIRAppNameFromFullyQualifiedNamespace]]];
   if (!installations || !_options.GCMSenderID) {
@@ -286,7 +354,9 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114;
 
         FIRLogInfo(kFIRLoggerRemoteConfig, @"I-RCN000022", @"Success to get iid : %@.",
                    strongSelfQueue->_settings.configInstallationsIdentifier);
-        [strongSelf doFetchCall:completionHandler];
+        [strongSelf doFetchCall:fetchTypeHeader
+                  completionHandler:completionHandler
+            updateCompletionHandler:updateCompletionHandler];
       });
     }];
   };
@@ -295,10 +365,15 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114;
   [installations authTokenWithCompletion:installationsTokenHandler];
 }
 
-- (void)doFetchCall:(FIRRemoteConfigFetchCompletion)completionHandler {
+- (void)doFetchCall:(NSString *)fetchTypeHeader
+          completionHandler:(FIRRemoteConfigFetchCompletion)completionHandler
+    updateCompletionHandler:(RCNConfigFetchCompletion)updateCompletionHandler {
   [self getAnalyticsUserPropertiesWithCompletionHandler:^(NSDictionary *userProperties) {
     dispatch_async(self->_lockQueue, ^{
-      [self fetchWithUserProperties:userProperties completionHandler:completionHandler];
+      [self fetchWithUserProperties:userProperties
+                    fetchTypeHeader:fetchTypeHeader
+                  completionHandler:completionHandler
+            updateCompletionHandler:updateCompletionHandler];
     });
   }];
 }
@@ -317,15 +392,35 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114;
 - (void)reportCompletionOnHandler:(FIRRemoteConfigFetchCompletion)completionHandler
                        withStatus:(FIRRemoteConfigFetchStatus)status
                         withError:(NSError *)error {
+  [self reportCompletionWithStatus:status
+                        withUpdate:nil
+                         withError:error
+                 completionHandler:completionHandler
+           updateCompletionHandler:nil];
+}
+
+- (void)reportCompletionWithStatus:(FIRRemoteConfigFetchStatus)status
+                        withUpdate:(FIRRemoteConfigUpdate *)update
+                         withError:(NSError *)error
+                 completionHandler:(FIRRemoteConfigFetchCompletion)completionHandler
+           updateCompletionHandler:(RCNConfigFetchCompletion)updateCompletionHandler {
   if (completionHandler) {
     dispatch_async(dispatch_get_main_queue(), ^{
       completionHandler(status, error);
     });
   }
+  // if completion handler expects a config update response
+  if (updateCompletionHandler) {
+    dispatch_async(dispatch_get_main_queue(), ^{
+      updateCompletionHandler(status, update, error);
+    });
+  }
 }
 
 - (void)fetchWithUserProperties:(NSDictionary *)userProperties
-              completionHandler:(FIRRemoteConfigFetchCompletion)completionHandler {
+                fetchTypeHeader:(NSString *)fetchTypeHeader
+              completionHandler:(FIRRemoteConfigFetchCompletion)completionHandler
+        updateCompletionHandler:(RCNConfigFetchCompletion)updateCompletionHandler {
   FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000061", @"Fetch with user properties initiated.");
 
   NSString *postRequestString = [_settings nextRequestWithUserProperties:userProperties];
@@ -337,15 +432,16 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114;
   if (compressionError) {
     NSString *errString = [NSString stringWithFormat:@"Failed to compress the config request."];
     FIRLogWarning(kFIRLoggerRemoteConfig, @"I-RCN000033", @"%@", errString);
+    NSError *error = [NSError errorWithDomain:FIRRemoteConfigErrorDomain
+                                         code:FIRRemoteConfigErrorInternalError
+                                     userInfo:@{NSLocalizedDescriptionKey : errString}];
 
     self->_settings.isFetchInProgress = NO;
-    return [self
-        reportCompletionOnHandler:completionHandler
-                       withStatus:FIRRemoteConfigFetchStatusFailure
-                        withError:[NSError
-                                      errorWithDomain:FIRRemoteConfigErrorDomain
-                                                 code:FIRRemoteConfigErrorInternalError
-                                             userInfo:@{NSLocalizedDescriptionKey : errString}]];
+    return [self reportCompletionWithStatus:FIRRemoteConfigFetchStatusFailure
+                                 withUpdate:nil
+                                  withError:error
+                          completionHandler:completionHandler
+                    updateCompletionHandler:updateCompletionHandler];
   }
 
   FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000040", @"Start config fetch.");
@@ -374,7 +470,7 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114;
 
       if (error || (statusCode != kRCNFetchResponseHTTPStatusCodeOK)) {
         // Update metadata about fetch failure.
-        [strongSelf->_settings updateMetadataWithFetchSuccessStatus:NO];
+        [strongSelf->_settings updateMetadataWithFetchSuccessStatus:NO templateVersion:nil];
         if (error) {
           if (strongSelf->_settings.lastFetchStatus == FIRRemoteConfigFetchStatusSuccess) {
             FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000025",
@@ -388,6 +484,7 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114;
           FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000026",
                       @"RCN Fetch failure. Response http error code: %ld", (long)statusCode);
           // Response error code 429, 500, 503 will trigger exponential backoff mode.
+          // TODO: check error code in helper
           if (statusCode == kRCNFetchResponseHTTPStatusTooManyRequests ||
               statusCode == kRCNFetchResponseHTTPStatusCodeInternalError ||
               statusCode == kRCNFetchResponseHTTPStatusCodeServiceUnavailable ||
@@ -406,9 +503,11 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114;
                          userInfo:@{
                            FIRRemoteConfigThrottledEndTimeInSecondsKey : @(throttledEndTime)
                          }];
-              return [strongSelf reportCompletionOnHandler:completionHandler
-                                                withStatus:strongSelf->_settings.lastFetchStatus
-                                                 withError:error];
+              return [strongSelf reportCompletionWithStatus:strongSelf->_settings.lastFetchStatus
+                                                 withUpdate:nil
+                                                  withError:error
+                                          completionHandler:completionHandler
+                                    updateCompletionHandler:updateCompletionHandler];
             }
           }
         }
@@ -423,20 +522,27 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114;
                           stringWithFormat:@"Internal Error. Status code: %ld", (long)statusCode])
         };
         return [strongSelf
-            reportCompletionOnHandler:completionHandler
-                           withStatus:FIRRemoteConfigFetchStatusFailure
-                            withError:[NSError errorWithDomain:FIRRemoteConfigErrorDomain
-                                                          code:FIRRemoteConfigErrorInternalError
-                                                      userInfo:userInfo]];
+            reportCompletionWithStatus:FIRRemoteConfigFetchStatusFailure
+                            withUpdate:nil
+                             withError:[NSError errorWithDomain:FIRRemoteConfigErrorDomain
+                                                           code:FIRRemoteConfigErrorInternalError
+                                                       userInfo:userInfo]
+                     completionHandler:completionHandler
+               updateCompletionHandler:updateCompletionHandler];
       }
 
       // Fetch was successful. Check if we have data.
       NSError *retError;
       if (!data) {
         FIRLogInfo(kFIRLoggerRemoteConfig, @"I-RCN000043", @"RCN Fetch: No data in fetch response");
-        return [strongSelf reportCompletionOnHandler:completionHandler
-                                          withStatus:FIRRemoteConfigFetchStatusSuccess
-                                           withError:nil];
+        // There may still be a difference between fetched and active config
+        FIRRemoteConfigUpdate *update =
+            [strongSelf->_content getConfigUpdateForNamespace:strongSelf->_FIRNamespace];
+        return [strongSelf reportCompletionWithStatus:FIRRemoteConfigFetchStatusSuccess
+                                           withUpdate:update
+                                            withError:nil
+                                    completionHandler:completionHandler
+                              updateCompletionHandler:updateCompletionHandler];
       }
 
       // Config fetch succeeded.
@@ -472,13 +578,14 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114;
                                                      errDict[RCNFetchResponseKeyErrorMessage]]];
         }
         FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000044", @"%@.", errStr);
-        return [strongSelf
-            reportCompletionOnHandler:completionHandler
-                           withStatus:FIRRemoteConfigFetchStatusFailure
-                            withError:[NSError
-                                          errorWithDomain:FIRRemoteConfigErrorDomain
-                                                     code:FIRRemoteConfigErrorInternalError
-                                                 userInfo:@{NSLocalizedDescriptionKey : errStr}]];
+        NSError *error = [NSError errorWithDomain:FIRRemoteConfigErrorDomain
+                                             code:FIRRemoteConfigErrorInternalError
+                                         userInfo:@{NSLocalizedDescriptionKey : errStr}];
+        return [strongSelf reportCompletionWithStatus:FIRRemoteConfigFetchStatusFailure
+                                           withUpdate:nil
+                                            withError:error
+                                    completionHandler:completionHandler
+                              updateCompletionHandler:updateCompletionHandler];
       }
 
       // Add the fetched config to the database.
@@ -493,6 +600,8 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114;
           [strongSelf->_experiment updateExperimentsWithResponse:
                                        fetchedConfig[RCNFetchResponseKeyExperimentDescriptions]];
         }
+
+        strongSelf->_templateVersionNumber = [strongSelf getTemplateVersionNumber:fetchedConfig];
       } else {
         FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000063",
                     @"Empty response with no fetched config.");
@@ -504,17 +613,25 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114;
           !([strongSelf->_settings.lastETag isEqualToString:latestETag])) {
         strongSelf->_settings.lastETag = latestETag;
       }
-
-      [strongSelf->_settings updateMetadataWithFetchSuccessStatus:YES];
-      return [strongSelf reportCompletionOnHandler:completionHandler
-                                        withStatus:FIRRemoteConfigFetchStatusSuccess
-                                         withError:nil];
+      // Compute config update after successful fetch
+      FIRRemoteConfigUpdate *update =
+          [strongSelf->_content getConfigUpdateForNamespace:strongSelf->_FIRNamespace];
+
+      [strongSelf->_settings
+          updateMetadataWithFetchSuccessStatus:YES
+                               templateVersion:strongSelf->_templateVersionNumber];
+      return [strongSelf reportCompletionWithStatus:FIRRemoteConfigFetchStatusSuccess
+                                         withUpdate:update
+                                          withError:nil
+                                  completionHandler:completionHandler
+                            updateCompletionHandler:updateCompletionHandler];
     });
   };
 
   FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000061", @"Making remote config fetch.");
 
   NSURLSessionDataTask *dataTask = [self URLSessionDataTaskWithContent:compressedContent
+                                                       fetchTypeHeader:fetchTypeHeader
                                                      completionHandler:fetcherCompletion];
   [dataTask resume];
 }
@@ -554,6 +671,7 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114;
 }
 
 - (NSURLSessionDataTask *)URLSessionDataTaskWithContent:(NSData *)content
+                                        fetchTypeHeader:(NSString *)fetchTypeHeader
                                       completionHandler:
                                           (RCNConfigFetcherCompletion)fetcherCompletion {
   NSURL *URL = [NSURL URLWithString:[self constructServerURL]];
@@ -573,6 +691,7 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114;
       forHTTPHeaderField:kiOSBundleIdentifierHeaderName];
   [URLRequest setValue:@"gzip" forHTTPHeaderField:kContentEncodingHeaderName];
   [URLRequest setValue:@"gzip" forHTTPHeaderField:kAcceptEncodingHeaderName];
+  [URLRequest setValue:fetchTypeHeader forHTTPHeaderField:kFetchTypeHeaderName];
   // Set the eTag from the last successful fetch, if available.
   if (_settings.lastETag) {
     [URLRequest setValue:_settings.lastETag forHTTPHeaderField:kIfNoneMatchETagHeaderName];
@@ -582,4 +701,14 @@ static const NSInteger sFIRErrorCodeConfigFailed = -114;
   return [_fetchSession dataTaskWithRequest:URLRequest completionHandler:fetcherCompletion];
 }
 
+- (NSString *)getTemplateVersionNumber:(NSDictionary *)fetchedConfig {
+  if (fetchedConfig != nil && [fetchedConfig objectForKey:RCNFetchResponseKeyTemplateVersion] &&
+      [[fetchedConfig objectForKey:RCNFetchResponseKeyTemplateVersion]
+          isKindOfClass:[NSString class]]) {
+    return (NSString *)[fetchedConfig objectForKey:RCNFetchResponseKeyTemplateVersion];
+  }
+
+  return @"0";
+}
+
 @end

+ 40 - 0
FirebaseRemoteConfig/Sources/RCNConfigRealtime.h

@@ -0,0 +1,40 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#import <Foundation/Foundation.h>
+#import "FirebaseRemoteConfig/Sources/Private/RCNConfigFetch.h"
+#import "FirebaseRemoteConfig/Sources/Public/FirebaseRemoteConfig/FIRRemoteConfig.h"
+
+@class RCNConfigSettings;
+
+@interface RCNConfigRealtime : NSObject <NSURLSessionDataDelegate>
+
+/// 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

+ 715 - 0
FirebaseRemoteConfig/Sources/RCNConfigRealtime.m

@@ -0,0 +1,715 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#import "FirebaseRemoteConfig/Sources/RCNConfigRealtime.h"
+#import <Foundation/Foundation.h>
+#import <GoogleUtilities/GULNSData+zlib.h>
+#import "FirebaseCore/Extension/FirebaseCoreInternal.h"
+#import "FirebaseInstallations/Source/Library/Private/FirebaseInstallationsInternal.h"
+#import "FirebaseRemoteConfig/Sources/Private/RCNConfigFetch.h"
+#import "FirebaseRemoteConfig/Sources/Private/RCNConfigSettings.h"
+#import "FirebaseRemoteConfig/Sources/RCNConfigConstants.h"
+#import "FirebaseRemoteConfig/Sources/RCNDevice.h"
+
+/// URL params
+static NSString *const kServerURLDomain = @"https://firebaseremoteconfigrealtime.googleapis.com";
+static NSString *const kServerURLVersion = @"/v1";
+static NSString *const kServerURLProjects = @"/projects/";
+static NSString *const kServerURLNamespaces = @"/namespaces/";
+static NSString *const kServerURLQuery = @":streamFetchInvalidations?";
+static NSString *const kServerURLKey = @"key=";
+
+/// Realtime API enablement
+static NSString *const kServerForbiddenStatusCode = @"\"code\": 403";
+
+/// Header names
+static NSString *const kHTTPMethodPost = @"POST";  ///< HTTP request method config fetch using
+static NSString *const kContentTypeHeaderName = @"Content-Type";  ///< HTTP Header Field Name
+static NSString *const kContentEncodingHeaderName =
+    @"Content-Encoding";                                               ///< HTTP Header Field Name
+static NSString *const kAcceptEncodingHeaderName = @"Accept";          ///< HTTP Header Field Name
+static NSString *const kETagHeaderName = @"etag";                      ///< HTTP Header Field Name
+static NSString *const kIfNoneMatchETagHeaderName = @"if-none-match";  ///< HTTP Header Field Name
+static NSString *const kInstallationsAuthTokenHeaderName = @"x-goog-firebase-installations-auth";
+// Sends the bundle ID. Refer to b/130301479 for details.
+static NSString *const kiOSBundleIdentifierHeaderName =
+    @"X-Ios-Bundle-Identifier";  ///< HTTP Header Field Name
+
+/// Retryable HTTP status code.
+static NSInteger const kRCNFetchResponseHTTPStatusOk = 200;
+static NSInteger const kRCNFetchResponseHTTPStatusClientTimeout = 429;
+static NSInteger const kRCNFetchResponseHTTPStatusTooManyRequests = 429;
+static NSInteger const kRCNFetchResponseHTTPStatusCodeBadGateway = 502;
+static NSInteger const kRCNFetchResponseHTTPStatusCodeServiceUnavailable = 503;
+static NSInteger const kRCNFetchResponseHTTPStatusCodeGatewayTimeout = 504;
+
+/// Invalidation message field names.
+static NSString *const kTemplateVersionNumberKey = @"latestTemplateVersionNumber";
+static NSString *const kIsFeatureDisabled = @"featureDisabled";
+
+static NSTimeInterval gTimeoutSeconds = 330;
+static NSInteger const gFetchAttempts = 3;
+
+// Retry parameters
+static NSInteger const gMaxRetries = 7;
+
+@interface FIRConfigUpdateListenerRegistration ()
+@property(strong, atomic, nonnull) RCNConfigUpdateCompletion completionHandler;
+@end
+
+@implementation FIRConfigUpdateListenerRegistration {
+  RCNConfigRealtime *_realtimeClient;
+}
+
+- (instancetype)initWithClient:(RCNConfigRealtime *)realtimeClient
+             completionHandler:(RCNConfigUpdateCompletion)completionHandler {
+  self = [super init];
+  if (self) {
+    _realtimeClient = realtimeClient;
+    _completionHandler = completionHandler;
+  }
+  return self;
+}
+
+- (void)remove {
+  [self->_realtimeClient removeConfigUpdateListener:_completionHandler];
+}
+
+@end
+
+@interface RCNConfigRealtime ()
+
+@property(strong, atomic, nonnull) NSMutableSet<RCNConfigUpdateCompletion> *listeners;
+@property(strong, atomic, nonnull) dispatch_queue_t realtimeLockQueue;
+@property(strong, atomic, nonnull) NSNotificationCenter *notificationCenter;
+
+@property(strong, atomic) NSURLSession *session;
+@property(strong, atomic) NSURLSessionDataTask *dataTask;
+@property(strong, atomic) NSMutableURLRequest *request;
+
+@end
+
+@implementation RCNConfigRealtime {
+  RCNConfigFetch *_configFetch;
+  RCNConfigSettings *_settings;
+  FIROptions *_options;
+  NSString *_namespace;
+  NSInteger _remainingRetryCount;
+  bool _isRequestInProgress;
+  bool _isInBackground;
+  bool _isRealtimeDisabled;
+}
+
+- (instancetype)init:(RCNConfigFetch *)configFetch
+            settings:(RCNConfigSettings *)settings
+           namespace:(NSString *)namespace
+             options:(FIROptions *)options {
+  self = [super init];
+  if (self) {
+    _listeners = [[NSMutableSet alloc] init];
+    _realtimeLockQueue = [RCNConfigRealtime realtimeRemoteConfigSerialQueue];
+    _notificationCenter = [NSNotificationCenter defaultCenter];
+
+    _configFetch = configFetch;
+    _settings = settings;
+    _options = options;
+    _namespace = namespace;
+
+    _remainingRetryCount = MAX(gMaxRetries - [_settings realtimeRetryCount], 1);
+    _isRequestInProgress = false;
+    _isRealtimeDisabled = false;
+    _isInBackground = false;
+
+    [self setUpHttpRequest];
+    [self setUpHttpSession];
+    [self backgroundChangeListener];
+  }
+
+  return self;
+}
+
+/// Singleton instance of serial queue for queuing all incoming RC calls.
++ (dispatch_queue_t)realtimeRemoteConfigSerialQueue {
+  static dispatch_once_t onceToken;
+  static dispatch_queue_t realtimeRemoteConfigQueue;
+  dispatch_once(&onceToken, ^{
+    realtimeRemoteConfigQueue =
+        dispatch_queue_create(RCNRemoteConfigQueueLabel, DISPATCH_QUEUE_SERIAL);
+  });
+  return realtimeRemoteConfigQueue;
+}
+
+- (void)propogateErrors:(NSError *)error {
+  __weak RCNConfigRealtime *weakSelf = self;
+  dispatch_async(_realtimeLockQueue, ^{
+    __strong RCNConfigRealtime *strongSelf = weakSelf;
+    for (RCNConfigUpdateCompletion listener in strongSelf->_listeners) {
+      listener(nil, error);
+    }
+  });
+}
+
+#pragma mark - Test Only Helpers
+
+// TESTING ONLY
+- (void)triggerListenerForTesting:(void (^_Nonnull)(FIRRemoteConfigUpdate *configUpdate,
+                                                    NSError *_Nullable error))listener {
+  listener([[FIRRemoteConfigUpdate alloc] init], nil);
+}
+
+#pragma mark - Http Helpers
+
+- (NSString *)constructServerURL {
+  NSString *serverURLStr = [[NSString alloc] initWithString:kServerURLDomain];
+  serverURLStr = [serverURLStr stringByAppendingString:kServerURLVersion];
+  serverURLStr = [serverURLStr stringByAppendingString:kServerURLProjects];
+  serverURLStr = [serverURLStr stringByAppendingString:_options.GCMSenderID];
+  serverURLStr = [serverURLStr stringByAppendingString:kServerURLNamespaces];
+
+  /// Get the namespace from the fully qualified namespace string of "namespace:FIRAppName".
+  NSString *namespace = [_namespace substringToIndex:[_namespace rangeOfString:@":"].location];
+  serverURLStr = [serverURLStr stringByAppendingString:namespace];
+  serverURLStr = [serverURLStr stringByAppendingString:kServerURLQuery];
+  if (_options.APIKey) {
+    serverURLStr = [serverURLStr stringByAppendingString:kServerURLKey];
+    serverURLStr = [serverURLStr stringByAppendingString:_options.APIKey];
+  } else {
+    FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000071",
+                @"Missing `APIKey` from `FirebaseOptions`, please ensure the configured "
+                @"`FirebaseApp` is configured with `FirebaseOptions` that contains an `APIKey`.");
+  }
+
+  return serverURLStr;
+}
+
+- (NSString *)FIRAppNameFromFullyQualifiedNamespace {
+  return [[_namespace componentsSeparatedByString:@":"] lastObject];
+}
+
+- (void)reportCompletionOnHandler:(FIRRemoteConfigFetchCompletion)completionHandler
+                       withStatus:(FIRRemoteConfigFetchStatus)status
+                        withError:(NSError *)error {
+  if (completionHandler) {
+    dispatch_async(_realtimeLockQueue, ^{
+      completionHandler(status, error);
+    });
+  }
+}
+
+/// Refresh installation ID token before fetching config. installation ID is now mandatory for fetch
+/// requests to work.(b/14751422).
+- (void)refreshInstallationsTokenWithCompletionHandler:
+    (FIRRemoteConfigFetchCompletion)completionHandler {
+  FIRInstallations *installations = [FIRInstallations
+      installationsWithApp:[FIRApp appNamed:[self FIRAppNameFromFullyQualifiedNamespace]]];
+  if (!installations || !_options.GCMSenderID) {
+    NSString *errorDescription = @"Failed to get GCMSenderID";
+    FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000074", @"%@",
+                [NSString stringWithFormat:@"%@", errorDescription]);
+    return [self
+        reportCompletionOnHandler:completionHandler
+                       withStatus:FIRRemoteConfigFetchStatusFailure
+                        withError:[NSError errorWithDomain:FIRRemoteConfigErrorDomain
+                                                      code:FIRRemoteConfigErrorInternalError
+                                                  userInfo:@{
+                                                    NSLocalizedDescriptionKey : errorDescription
+                                                  }]];
+  }
+
+  __weak RCNConfigRealtime *weakSelf = self;
+  FIRInstallationsTokenHandler installationsTokenHandler = ^(
+      FIRInstallationsAuthTokenResult *tokenResult, NSError *error) {
+    RCNConfigRealtime *strongSelf = weakSelf;
+    if (strongSelf == nil) {
+      return;
+    }
+
+    if (!tokenResult || !tokenResult.authToken || error) {
+      NSString *errorDescription =
+          [NSString stringWithFormat:@"Failed to get installations token. Error : %@.", error];
+      FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000073", @"%@",
+                  [NSString stringWithFormat:@"%@", errorDescription]);
+      return [strongSelf
+          reportCompletionOnHandler:completionHandler
+                         withStatus:FIRRemoteConfigFetchStatusFailure
+                          withError:[NSError errorWithDomain:FIRRemoteConfigErrorDomain
+                                                        code:FIRRemoteConfigErrorInternalError
+                                                    userInfo:@{
+                                                      NSLocalizedDescriptionKey : errorDescription
+                                                    }]];
+    }
+
+    /// We have a valid token. Get the backing installationID.
+    [installations installationIDWithCompletion:^(NSString *_Nullable identifier,
+                                                  NSError *_Nullable error) {
+      RCNConfigRealtime *strongSelf = weakSelf;
+      if (strongSelf == nil) {
+        return;
+      }
+
+      // Dispatch to the RC serial queue to update settings on the queue.
+      dispatch_async(strongSelf->_realtimeLockQueue, ^{
+        RCNConfigRealtime *strongSelfQueue = weakSelf;
+        if (strongSelfQueue == nil) {
+          return;
+        }
+
+        /// Update config settings with the IID and token.
+        strongSelfQueue->_settings.configInstallationsToken = tokenResult.authToken;
+        strongSelfQueue->_settings.configInstallationsIdentifier = identifier;
+
+        if (!identifier || error) {
+          NSString *errorDescription =
+              [NSString stringWithFormat:@"Error getting iid : %@.", error];
+          FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000055", @"%@",
+                      [NSString stringWithFormat:@"%@", errorDescription]);
+          strongSelfQueue->_settings.isFetchInProgress = NO;
+          return [strongSelfQueue
+              reportCompletionOnHandler:completionHandler
+                             withStatus:FIRRemoteConfigFetchStatusFailure
+                              withError:[NSError
+                                            errorWithDomain:FIRRemoteConfigErrorDomain
+                                                       code:FIRRemoteConfigErrorInternalError
+                                                   userInfo:@{
+                                                     NSLocalizedDescriptionKey : errorDescription
+                                                   }]];
+        }
+
+        FIRLogInfo(kFIRLoggerRemoteConfig, @"I-RCN000022", @"Success to get iid : %@.",
+                   strongSelfQueue->_settings.configInstallationsIdentifier);
+      });
+    }];
+  };
+
+  FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000039", @"Starting requesting token.");
+  [installations authTokenWithCompletion:installationsTokenHandler];
+}
+
+- (void)setRequestBody {
+  [self refreshInstallationsTokenWithCompletionHandler:^(FIRRemoteConfigFetchStatus status,
+                                                         NSError *_Nullable error) {
+    if (status != FIRRemoteConfigFetchStatusSuccess) {
+      FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000013", @"Installation token retrival failed.");
+    }
+  }];
+
+  [_request setValue:_settings.configInstallationsToken
+      forHTTPHeaderField:kInstallationsAuthTokenHeaderName];
+  if (_settings.lastETag) {
+    [_request setValue:_settings.lastETag forHTTPHeaderField:kIfNoneMatchETagHeaderName];
+  }
+
+  NSString *namespace = [_namespace substringToIndex:[_namespace rangeOfString:@":"].location];
+  NSString *postBody = [NSString
+      stringWithFormat:@"{project:'%@', namespace:'%@', lastKnownVersionNumber:'%@', appId:'%@', "
+                       @"sdkVersion:'%@'}",
+                       [self->_options GCMSenderID], namespace, _configFetch.templateVersionNumber,
+                       _options.googleAppID, FIRRemoteConfigPodVersion()];
+  NSData *postData = [postBody dataUsingEncoding:NSUTF8StringEncoding];
+  NSError *compressionError;
+  NSData *compressedContent = [NSData gul_dataByGzippingData:postData error:&compressionError];
+
+  [_request setHTTPBody:compressedContent];
+}
+
+/// Creates request.
+- (void)setUpHttpRequest {
+  NSString *address = [self constructServerURL];
+  _request = [[NSMutableURLRequest alloc] initWithURL:[NSURL URLWithString:address]
+                                          cachePolicy:NSURLRequestReloadIgnoringLocalCacheData
+                                      timeoutInterval:gTimeoutSeconds];
+  [_request setHTTPMethod:kHTTPMethodPost];
+  [_request setValue:@"application/json" forHTTPHeaderField:kContentTypeHeaderName];
+  [_request setValue:@"application/json" forHTTPHeaderField:kAcceptEncodingHeaderName];
+  [_request setValue:@"gzip" forHTTPHeaderField:kContentEncodingHeaderName];
+  [_request setValue:@"true" forHTTPHeaderField:@"X-Google-GFE-Can-Retry"];
+  [_request setValue:[_options APIKey] forHTTPHeaderField:@"X-Goog-Api-Key"];
+  [_request setValue:[[NSBundle mainBundle] bundleIdentifier]
+      forHTTPHeaderField:kiOSBundleIdentifierHeaderName];
+}
+
+/// Makes call to create session.
+- (void)setUpHttpSession {
+  NSURLSessionConfiguration *sessionConfig =
+      [[NSURLSessionConfiguration defaultSessionConfiguration] copy];
+  [sessionConfig setTimeoutIntervalForResource:gTimeoutSeconds];
+  [sessionConfig setTimeoutIntervalForRequest:gTimeoutSeconds];
+  _session = [NSURLSession sessionWithConfiguration:sessionConfig
+                                           delegate:self
+                                      delegateQueue:[NSOperationQueue mainQueue]];
+}
+
+#pragma mark - Retry Helpers
+
+// Retry mechanism for HTTP connections
+- (void)retryHTTPConnection {
+  __weak RCNConfigRealtime *weakSelf = self;
+  dispatch_async(_realtimeLockQueue, ^{
+    __strong RCNConfigRealtime *strongSelf = weakSelf;
+    if (strongSelf->_isInBackground) {
+      return;
+    }
+
+    bool noRunningConnection =
+        strongSelf->_dataTask == nil || strongSelf->_dataTask.state != NSURLSessionTaskStateRunning;
+    bool canMakeConnection = noRunningConnection && [strongSelf->_listeners count] > 0 &&
+                             !strongSelf->_isRealtimeDisabled;
+    if (canMakeConnection && strongSelf->_remainingRetryCount > 0) {
+      NSTimeInterval backOffInterval = self->_settings.getRealtimeBackoffInterval;
+
+      strongSelf->_remainingRetryCount--;
+      [strongSelf->_settings setRealtimeRetryCount:[strongSelf->_settings realtimeRetryCount] + 1];
+      dispatch_time_t executionDelay =
+          dispatch_time(DISPATCH_TIME_NOW, (backOffInterval * NSEC_PER_SEC));
+      dispatch_after(executionDelay, strongSelf->_realtimeLockQueue, ^{
+        [strongSelf beginRealtimeStream];
+      });
+    } else {
+      NSError *error = [NSError
+          errorWithDomain:FIRRemoteConfigUpdateErrorDomain
+                     code:FIRRemoteConfigUpdateErrorStreamError
+                 userInfo:@{
+                   NSLocalizedDescriptionKey :
+                       @"Unable to connect to the server. Check your connection and try again."
+                 }];
+      FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000014", @"Cannot establish connection. Error: %@",
+                  error);
+      [self propogateErrors:error];
+    }
+  });
+}
+
+- (void)backgroundChangeListener {
+  [_notificationCenter addObserver:self
+                          selector:@selector(isInForeground)
+                              name:@"UIApplicationWillEnterForegroundNotification"
+                            object:nil];
+
+  [_notificationCenter addObserver:self
+                          selector:@selector(isInBackground)
+                              name:@"UIApplicationDidEnterBackgroundNotification"
+                            object:nil];
+}
+
+- (void)isInForeground {
+  __weak RCNConfigRealtime *weakSelf = self;
+  dispatch_async(_realtimeLockQueue, ^{
+    __strong RCNConfigRealtime *strongSelf = weakSelf;
+    strongSelf->_isInBackground = false;
+    [strongSelf beginRealtimeStream];
+  });
+}
+
+- (void)isInBackground {
+  __weak RCNConfigRealtime *weakSelf = self;
+  dispatch_async(_realtimeLockQueue, ^{
+    __strong RCNConfigRealtime *strongSelf = weakSelf;
+    [strongSelf pauseRealtimeStream];
+    strongSelf->_isInBackground = true;
+  });
+}
+
+#pragma mark - Autofetch Helpers
+
+- (void)fetchLatestConfig:(NSInteger)remainingAttempts targetVersion:(NSInteger)targetVersion {
+  __weak RCNConfigRealtime *weakSelf = self;
+  dispatch_async(_realtimeLockQueue, ^{
+    __strong RCNConfigRealtime *strongSelf = weakSelf;
+    NSInteger attempts = remainingAttempts - 1;
+
+    [strongSelf->_configFetch
+        realtimeFetchConfigWithNoExpirationDuration:gFetchAttempts - attempts
+                                  completionHandler:^(FIRRemoteConfigFetchStatus status,
+                                                      FIRRemoteConfigUpdate *update,
+                                                      NSError *error) {
+                                    if (error != nil) {
+                                      FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000010",
+                                                  @"Failed to retrive config due to fetch error. "
+                                                  @"Error: %@",
+                                                  error);
+                                      return [self propogateErrors:error];
+                                    }
+                                    if (status == FIRRemoteConfigFetchStatusSuccess) {
+                                      if ([strongSelf->_configFetch.templateVersionNumber
+                                                  integerValue] >= targetVersion) {
+                                        // only notify listeners if there is a change
+                                        if ([update updatedKeys].count > 0) {
+                                          for (RCNConfigUpdateCompletion listener in strongSelf
+                                                   ->_listeners) {
+                                            listener(update, nil);
+                                          }
+                                        }
+                                      } else {
+                                        FIRLogDebug(
+                                            kFIRLoggerRemoteConfig, @"I-RCN000016",
+                                            @"Fetched config's template version is outdated, "
+                                            @"re-fetching");
+                                        [strongSelf autoFetch:attempts targetVersion:targetVersion];
+                                      }
+                                    } else {
+                                      FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000016",
+                                                  @"Fetched config's template version is "
+                                                  @"outdated, re-fetching");
+                                      [strongSelf autoFetch:attempts targetVersion:targetVersion];
+                                    }
+                                  }];
+  });
+}
+
+- (void)scheduleFetch:(NSInteger)remainingAttempts targetVersion:(NSInteger)targetVersion {
+  /// Needs fetch to occur between 0 - 3 seconds. Randomize to not cause DDoS alerts in backend
+  dispatch_time_t executionDelay =
+      dispatch_time(DISPATCH_TIME_NOW, arc4random_uniform(4) * NSEC_PER_SEC);
+  dispatch_after(executionDelay, _realtimeLockQueue, ^{
+    [self fetchLatestConfig:remainingAttempts targetVersion:targetVersion];
+  });
+}
+
+/// Perform fetch and handle developers callbacks
+- (void)autoFetch:(NSInteger)remainingAttempts targetVersion:(NSInteger)targetVersion {
+  __weak RCNConfigRealtime *weakSelf = self;
+  dispatch_async(_realtimeLockQueue, ^{
+    __strong RCNConfigRealtime *strongSelf = weakSelf;
+    if (remainingAttempts == 0) {
+      NSError *error = [NSError errorWithDomain:FIRRemoteConfigUpdateErrorDomain
+                                           code:FIRRemoteConfigUpdateErrorNotFetched
+                                       userInfo:@{
+                                         NSLocalizedDescriptionKey :
+                                             @"Unable to fetch the latest version of the template.."
+                                       }];
+      FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000011",
+                  @"Ran out of fetch attempts, cannot find target config version.");
+      [self propogateErrors:error];
+      return;
+    }
+
+    [strongSelf scheduleFetch:remainingAttempts targetVersion:targetVersion];
+  });
+}
+
+#pragma mark - NSURLSession Delegates
+
+- (void)evaluateStreamResponse:(NSDictionary *)response error:(NSError *)dataError {
+  NSInteger updateTemplateVersion = 1;
+  if (dataError == nil) {
+    if ([response objectForKey:kTemplateVersionNumberKey]) {
+      updateTemplateVersion = [[response objectForKey:kTemplateVersionNumberKey] integerValue];
+    }
+    if ([response objectForKey:kIsFeatureDisabled]) {
+      self->_isRealtimeDisabled = [response objectForKey:kIsFeatureDisabled];
+    }
+
+    if (self->_isRealtimeDisabled) {
+      [self pauseRealtimeStream];
+      NSError *error = [NSError
+          errorWithDomain:FIRRemoteConfigUpdateErrorDomain
+                     code:FIRRemoteConfigUpdateErrorUnavailable
+                 userInfo:@{
+                   NSLocalizedDescriptionKey :
+                       @"The server is temporarily unavailable. Try again in a few minutes."
+                 }];
+      [self propogateErrors:error];
+    } else {
+      NSInteger clientTemplateVersion = [_configFetch.templateVersionNumber integerValue];
+      if (updateTemplateVersion > clientTemplateVersion) {
+        [self autoFetch:gFetchAttempts targetVersion:updateTemplateVersion];
+      }
+    }
+  } else {
+    NSError *error =
+        [NSError errorWithDomain:FIRRemoteConfigUpdateErrorDomain
+                            code:FIRRemoteConfigUpdateErrorMessageInvalid
+                        userInfo:@{NSLocalizedDescriptionKey : @"Unable to parse ConfigUpdate."}];
+    [self propogateErrors:error];
+  }
+}
+
+/// Delegate to asynchronously handle every new notification that comes over the wire. Auto-fetches
+/// and runs callback for each new notification
+- (void)URLSession:(NSURLSession *)session
+          dataTask:(NSURLSessionDataTask *)dataTask
+    didReceiveData:(NSData *)data {
+  NSError *dataError;
+  NSString *strData = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
+
+  /// If response data contains the API enablement link, return the entire message to the user in
+  /// the form of a error.
+  if ([strData containsString:kServerForbiddenStatusCode]) {
+    NSError *error = [NSError errorWithDomain:FIRRemoteConfigUpdateErrorDomain
+                                         code:FIRRemoteConfigUpdateErrorStreamError
+                                     userInfo:@{NSLocalizedDescriptionKey : strData}];
+    FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000021", @"Cannot establish connection. %@", error);
+    [self propogateErrors:error];
+    return;
+  }
+
+  NSRange endRange = [strData rangeOfString:@"}"];
+  NSRange beginRange = [strData rangeOfString:@"{"];
+  if (beginRange.location != NSNotFound && endRange.location != NSNotFound) {
+    FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000015",
+                @"Received config update message on stream.");
+    NSRange msgRange =
+        NSMakeRange(beginRange.location, endRange.location - beginRange.location + 1);
+    strData = [strData substringWithRange:msgRange];
+    data = [strData dataUsingEncoding:NSUTF8StringEncoding];
+    NSDictionary *response = [NSJSONSerialization JSONObjectWithData:data
+                                                             options:NSJSONReadingMutableContainers
+                                                               error:&dataError];
+
+    [self evaluateStreamResponse:response error:dataError];
+  }
+}
+
+/// Check if response code is retryable
+- (bool)isStatusCodeRetryable:(NSInteger)statusCode {
+  return statusCode == kRCNFetchResponseHTTPStatusClientTimeout ||
+         statusCode == kRCNFetchResponseHTTPStatusTooManyRequests ||
+         statusCode == kRCNFetchResponseHTTPStatusCodeServiceUnavailable ||
+         statusCode == kRCNFetchResponseHTTPStatusCodeBadGateway ||
+         statusCode == kRCNFetchResponseHTTPStatusCodeGatewayTimeout;
+}
+
+/// Delegate to handle initial reply from the server
+- (void)URLSession:(NSURLSession *)session
+              dataTask:(NSURLSessionDataTask *)dataTask
+    didReceiveResponse:(NSURLResponse *)response
+     completionHandler:(void (^)(NSURLSessionResponseDisposition disposition))completionHandler {
+  _isRequestInProgress = false;
+  NSHTTPURLResponse *_httpURLResponse = (NSHTTPURLResponse *)response;
+  NSInteger statusCode = [_httpURLResponse statusCode];
+
+  if (statusCode == 403) {
+    completionHandler(NSURLSessionResponseAllow);
+    return;
+  }
+
+  if (statusCode != kRCNFetchResponseHTTPStatusOk) {
+    [self->_settings updateRealtimeExponentialBackoffTime];
+    [self pauseRealtimeStream];
+
+    if ([self isStatusCodeRetryable:statusCode]) {
+      [self retryHTTPConnection];
+    } else {
+      NSError *error = [NSError
+          errorWithDomain:FIRRemoteConfigUpdateErrorDomain
+                     code:FIRRemoteConfigUpdateErrorStreamError
+                 userInfo:@{
+                   NSLocalizedDescriptionKey :
+                       [NSString stringWithFormat:@"Unable to connect to the server. Try again in "
+                                                  @"a few minutes. Http Status code: %@",
+                                                  [@(statusCode) stringValue]]
+                 }];
+      FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000021", @"Cannot establish connection. Error: %@",
+                  error);
+    }
+  } else {
+    /// on success reset retry parameters
+    _remainingRetryCount = gMaxRetries;
+    [self->_settings setRealtimeRetryCount:0];
+  }
+
+  completionHandler(NSURLSessionResponseAllow);
+}
+
+/// Delegate to handle data task completion
+- (void)URLSession:(NSURLSession *)session
+                    task:(NSURLSessionTask *)task
+    didCompleteWithError:(NSError *)error {
+  _isRequestInProgress = false;
+  if (error != nil && [error code] != NSURLErrorCancelled) {
+    [self->_settings updateRealtimeExponentialBackoffTime];
+  }
+  [self pauseRealtimeStream];
+  [self retryHTTPConnection];
+}
+
+/// Delegate to handle session invalidation
+- (void)URLSession:(NSURLSession *)session didBecomeInvalidWithError:(NSError *)error {
+  if (!_isRequestInProgress) {
+    if (error != nil) {
+      [self->_settings updateRealtimeExponentialBackoffTime];
+    }
+    [self pauseRealtimeStream];
+    [self retryHTTPConnection];
+  }
+}
+
+#pragma mark - Top level methods
+
+- (void)beginRealtimeStream {
+  __weak RCNConfigRealtime *weakSelf = self;
+  dispatch_async(_realtimeLockQueue, ^{
+    __strong RCNConfigRealtime *strongSelf = weakSelf;
+    bool noRunningConnection =
+        strongSelf->_dataTask == nil || strongSelf->_dataTask.state != NSURLSessionTaskStateRunning;
+    bool canMakeConnection = noRunningConnection && [strongSelf->_listeners count] > 0 &&
+                             !strongSelf->_isInBackground && !strongSelf->_isRealtimeDisabled;
+
+    if (self->_settings.getRealtimeBackoffInterval > 0) {
+      [self retryHTTPConnection];
+      return;
+    }
+
+    if (canMakeConnection) {
+      strongSelf->_isRequestInProgress = true;
+      [strongSelf setRequestBody];
+      strongSelf->_dataTask = [strongSelf->_session dataTaskWithRequest:strongSelf->_request];
+      [strongSelf->_dataTask resume];
+    }
+  });
+}
+
+- (void)pauseRealtimeStream {
+  __weak RCNConfigRealtime *weakSelf = self;
+  dispatch_async(_realtimeLockQueue, ^{
+    __strong RCNConfigRealtime *strongSelf = weakSelf;
+    if (strongSelf->_dataTask != nil) {
+      [strongSelf->_dataTask cancel];
+      strongSelf->_dataTask = nil;
+    }
+  });
+}
+
+- (FIRConfigUpdateListenerRegistration *)addConfigUpdateListener:
+    (void (^_Nonnull)(FIRRemoteConfigUpdate *configUpdate, NSError *_Nullable error))listener {
+  if (listener == nil) {
+    return nil;
+  }
+
+  __weak RCNConfigRealtime *weakSelf = self;
+  dispatch_async(_realtimeLockQueue, ^{
+    __strong RCNConfigRealtime *strongSelf = weakSelf;
+    [strongSelf->_listeners addObject:listener];
+    [strongSelf beginRealtimeStream];
+  });
+
+  return [[FIRConfigUpdateListenerRegistration alloc] initWithClient:self
+                                                   completionHandler:listener];
+}
+
+- (void)removeConfigUpdateListener:(void (^_Nonnull)(FIRRemoteConfigUpdate *configUpdate,
+                                                     NSError *_Nullable error))listener {
+  __weak RCNConfigRealtime *weakSelf = self;
+  dispatch_async(_realtimeLockQueue, ^{
+    __strong RCNConfigRealtime *strongSelf = weakSelf;
+    [strongSelf->_listeners removeObject:listener];
+    if (strongSelf->_listeners.count == 0) {
+      [strongSelf pauseRealtimeStream];
+    }
+  });
+}
+
+@end

+ 51 - 1
FirebaseRemoteConfig/Sources/RCNConfigSettings.m

@@ -110,6 +110,11 @@ static const int kRCNExponentialBackoffMaximumInterval = 60 * 60 * 4;  // 4 hour
     }
 
     _isFetchInProgress = NO;
+    _lastTemplateVersion = [_userDefaultsManager lastTemplateVersion];
+    _realtimeExponentialBackoffRetryInterval =
+        [_userDefaultsManager currentRealtimeThrottlingRetryIntervalSeconds];
+    _realtimeExponentialBackoffThrottleEndTime = [_userDefaultsManager realtimeThrottleEndTime];
+    _realtimeRetryCount = [_userDefaultsManager realtimeRetryCount];
   }
   return self;
 }
@@ -233,7 +238,51 @@ static const int kRCNExponentialBackoffMaximumInterval = 60 * 60 * 4;  // 4 hour
       [[NSDate date] timeIntervalSince1970] + randomizedRetryInterval;
 }
 
-- (void)updateMetadataWithFetchSuccessStatus:(BOOL)fetchSuccess {
+/// If the last Realtime stream attempt was not successful, update the (exponential backoff) period
+/// that we wait until trying again. Any subsequent Realtime requests will be checked and allowed
+/// only if past this throttle end time.
+- (void)updateRealtimeExponentialBackoffTime {
+  // If there was only one stream attempt before, reset the retry interval.
+  if (_realtimeRetryCount == 0) {
+    FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000058",
+                @"Throttling: Entering exponential Realtime backoff mode.");
+    _realtimeExponentialBackoffRetryInterval = kRCNExponentialBackoffMinimumInterval;
+  } else {
+    FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000058",
+                @"Throttling: Updating Realtime throttling interval.");
+    // Double the retry interval until we hit the truncated exponential backoff. More info here:
+    // https://cloud.google.com/storage/docs/exponential-backoff
+    _realtimeExponentialBackoffRetryInterval =
+        ((_realtimeExponentialBackoffRetryInterval * 2) < kRCNExponentialBackoffMaximumInterval)
+            ? _realtimeExponentialBackoffRetryInterval * 2
+            : _realtimeExponentialBackoffRetryInterval;
+  }
+
+  // Randomize the next retry interval.
+  int randomPlusMinusInterval = ((arc4random() % 2) == 0) ? -1 : 1;
+  NSTimeInterval randomizedRetryInterval =
+      _realtimeExponentialBackoffRetryInterval +
+      (0.5 * _realtimeExponentialBackoffRetryInterval * randomPlusMinusInterval);
+  _realtimeExponentialBackoffThrottleEndTime =
+      [[NSDate date] timeIntervalSince1970] + randomizedRetryInterval;
+
+  [_userDefaultsManager setRealtimeThrottleEndTime:_realtimeExponentialBackoffThrottleEndTime];
+  [_userDefaultsManager
+      setCurrentRealtimeThrottlingRetryIntervalSeconds:_realtimeExponentialBackoffRetryInterval];
+}
+
+- (void)setRealtimeRetryCount:(int)realtimeRetryCount {
+  _realtimeRetryCount = realtimeRetryCount;
+  [_userDefaultsManager setRealtimeRetryCount:_realtimeRetryCount];
+}
+
+- (NSTimeInterval)getRealtimeBackoffInterval {
+  NSTimeInterval now = [[NSDate date] timeIntervalSince1970];
+  return _realtimeExponentialBackoffThrottleEndTime - now;
+}
+
+- (void)updateMetadataWithFetchSuccessStatus:(BOOL)fetchSuccess
+                             templateVersion:(NSString *)templateVersion {
   FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000056", @"Updating metadata with fetch result.");
   [self updateFetchTimeWithSuccessFetch:fetchSuccess];
   _lastFetchStatus =
@@ -243,6 +292,7 @@ static const int kRCNExponentialBackoffMaximumInterval = 60 * 60 * 4;  // 4 hour
     [self updateLastFetchTimeInterval:[[NSDate date] timeIntervalSince1970]];
     // Note: We expect the googleAppID to always be available.
     _deviceContext = FIRRemoteConfigDeviceContextWithProjectIdentifier(_googleAppID);
+    [_userDefaultsManager setLastTemplateVersion:templateVersion];
   }
 
   [self updateMetadataTable];

+ 9 - 0
FirebaseRemoteConfig/Sources/RCNUserDefaultsManager.h

@@ -36,6 +36,15 @@ NS_ASSUME_NONNULL_BEGIN
 /// The retry interval increases exponentially for cumulative fetch failures. Refer to
 /// go/rc-client-throttling for details.
 @property(nonatomic, assign) NSTimeInterval currentThrottlingRetryIntervalSeconds;
+/// Time when the next request can be made while being throttled.
+@property(nonatomic, assign) NSTimeInterval realtimeThrottleEndTime;
+/// The retry interval increases exponentially for cumulative Realtime failures. Refer to
+/// go/rc-client-throttling for details.
+@property(nonatomic, assign) NSTimeInterval currentRealtimeThrottlingRetryIntervalSeconds;
+/// Realtime retry count.
+@property(nonatomic, assign) int realtimeRetryCount;
+/// Last fetched template version.
+@property(nonatomic, assign) NSString *lastTemplateVersion;
 
 /// Designated initializer.
 - (instancetype)initWithAppName:(NSString *)appName

+ 65 - 0
FirebaseRemoteConfig/Sources/RCNUserDefaultsManager.m

@@ -17,6 +17,7 @@
 #import "FirebaseRemoteConfig/Sources/RCNUserDefaultsManager.h"
 #import "FirebaseCore/Extension/FirebaseCoreInternal.h"
 #import "FirebaseRemoteConfig/Sources/Public/FirebaseRemoteConfig/FIRRemoteConfig.h"
+#import "FirebaseRemoteConfig/Sources/RCNConfigConstants.h"
 
 static NSString *const kRCNGroupPrefix = @"group";
 static NSString *const kRCNGroupSuffix = @"firebase";
@@ -29,6 +30,10 @@ static NSString *const kRCNUserDefaultsKeyNameIsClientThrottled =
 static NSString *const kRCNUserDefaultsKeyNameThrottleEndTime = @"throttleEndTime";
 static NSString *const kRCNUserDefaultsKeyNamecurrentThrottlingRetryInterval =
     @"currentThrottlingRetryInterval";
+static NSString *const kRCNUserDefaultsKeyNameRealtimeThrottleEndTime = @"throttleRealtimeEndTime";
+static NSString *const kRCNUserDefaultsKeyNameCurrentRealtimeThrottlingRetryInterval =
+    @"currentRealtimeThrottlingRetryInterval";
+static NSString *const kRCNUserDefaultsKeyNameRealtimeRetryCount = @"realtimeRetryCount";
 
 @interface RCNUserDefaultsManager () {
   /// User Defaults instance for this bundleID. NSUserDefaults is guaranteed to be thread-safe.
@@ -106,6 +111,21 @@ static NSString *const kRCNUserDefaultsKeyNamecurrentThrottlingRetryInterval =
   }
 }
 
+- (NSString *)lastTemplateVersion {
+  NSDictionary *userDefaults = [self instanceUserDefaults];
+  if ([userDefaults objectForKey:RCNFetchResponseKeyTemplateVersion]) {
+    return [userDefaults objectForKey:RCNFetchResponseKeyTemplateVersion];
+  }
+
+  return @"0";
+}
+
+- (void)setLastTemplateVersion:(NSString *)templateVersion {
+  if (templateVersion) {
+    [self setInstanceUserDefaultsValue:templateVersion forKey:RCNFetchResponseKeyTemplateVersion];
+  }
+}
+
 - (NSTimeInterval)lastETagUpdateTime {
   NSNumber *lastETagUpdateTime =
       [[self instanceUserDefaults] objectForKey:kRCNUserDefaultsKeyNamelastETagUpdateTime];
@@ -174,6 +194,51 @@ static NSString *const kRCNUserDefaultsKeyNamecurrentThrottlingRetryInterval =
                               forKey:kRCNUserDefaultsKeyNamecurrentThrottlingRetryInterval];
 }
 
+- (int)realtimeRetryCount {
+  int realtimeRetryCount = 0;
+  if ([[self instanceUserDefaults] objectForKey:kRCNUserDefaultsKeyNameRealtimeRetryCount]) {
+    realtimeRetryCount = [[[self instanceUserDefaults]
+        objectForKey:kRCNUserDefaultsKeyNameRealtimeRetryCount] intValue];
+  }
+
+  return realtimeRetryCount;
+}
+
+- (void)setRealtimeRetryCount:(int)realtimeRetryCount {
+  [self setInstanceUserDefaultsValue:[NSNumber numberWithInt:realtimeRetryCount]
+                              forKey:kRCNUserDefaultsKeyNameRealtimeRetryCount];
+}
+
+- (NSTimeInterval)realtimeThrottleEndTime {
+  NSNumber *realtimeThrottleEndTime = 0;
+  if ([[self instanceUserDefaults] objectForKey:kRCNUserDefaultsKeyNameRealtimeThrottleEndTime]) {
+    realtimeThrottleEndTime =
+        [[self instanceUserDefaults] objectForKey:kRCNUserDefaultsKeyNameRealtimeThrottleEndTime];
+  }
+  return realtimeThrottleEndTime.doubleValue;
+}
+
+- (void)setRealtimeThrottleEndTime:(NSTimeInterval)throttleEndTime {
+  [self setInstanceUserDefaultsValue:@(throttleEndTime)
+                              forKey:kRCNUserDefaultsKeyNameRealtimeThrottleEndTime];
+}
+
+- (NSTimeInterval)currentRealtimeThrottlingRetryIntervalSeconds {
+  NSNumber *realtimeThrottleEndTime = 0;
+  if ([[self instanceUserDefaults]
+          objectForKey:kRCNUserDefaultsKeyNameCurrentRealtimeThrottlingRetryInterval]) {
+    realtimeThrottleEndTime = [[self instanceUserDefaults]
+        objectForKey:kRCNUserDefaultsKeyNameCurrentRealtimeThrottlingRetryInterval];
+  }
+  return realtimeThrottleEndTime.doubleValue;
+}
+
+- (void)setCurrentRealtimeThrottlingRetryIntervalSeconds:
+    (NSTimeInterval)throttlingRetryIntervalSeconds {
+  [self setInstanceUserDefaultsValue:@(throttlingRetryIntervalSeconds)
+                              forKey:kRCNUserDefaultsKeyNameCurrentRealtimeThrottlingRetryInterval];
+}
+
 #pragma mark Public methods.
 - (void)resetUserDefaults {
   [self resetInstanceUserDefaults];

+ 35 - 1
FirebaseRemoteConfig/Tests/Sample/RemoteConfigSampleApp/ViewController.m

@@ -103,10 +103,44 @@ static NSString *const FIRSecondFIRAppName = @"secondFIRApp";
       }
       FIRRemoteConfigSettings *settings = [[FIRRemoteConfigSettings alloc] init];
       settings.fetchTimeout = 300;
-      settings.minimumFetchInterval = 0;
+      settings.minimumFetchInterval = 300;
       ((FIRRemoteConfig *)(self.RCInstances[namespaceString][appString])).configSettings = settings;
     }
   }
+
+  /// UI popup for Realtime that shows if realtime_test_key was included in update.
+  UIAlertController *alert = [UIAlertController
+      alertControllerWithTitle:@"Alert"
+                       message:@"The value for realtime_test_key has been updated!"
+                preferredStyle:UIAlertControllerStyleAlert];
+  UIAlertAction *defaultAction = [UIAlertAction actionWithTitle:@"OK"
+                                                          style:UIAlertActionStyleDefault
+                                                        handler:^(UIAlertAction *action){
+                                                        }];
+  [alert addAction:defaultAction];
+
+  // Add realtime listener for firebase namespace
+  [self.RCInstances[FIRNamespaceGoogleMobilePlatform][FIRDefaultFIRAppName]
+      addOnConfigUpdateListener:^(FIRRemoteConfigUpdate *_Nullable update,
+                                  NSError *_Nullable error) {
+        if (error != nil) {
+          [[FRCLog sharedInstance]
+              logToConsole:[NSString
+                               stringWithFormat:@"Realtime Error: %@", error.localizedDescription]];
+        } else {
+          [[FRCLog sharedInstance] logToConsole:[NSString stringWithFormat:@"Config updated!"]];
+          if (update != nil) {
+            /// UI popup that lets user know that fetch included realtime_test_key in updatedKeys.
+            if ([[update updatedKeys] containsObject:@"realtime_test_key"]) {
+              [self presentViewController:alert animated:YES completion:nil];
+            }
+            NSString *updatedParams = [update updatedKeys];
+            [[FRCLog sharedInstance]
+                logToConsole:[NSString stringWithFormat:[updatedParams description]]];
+            [self apply];
+          }
+        }
+      }];
   [[FRCLog sharedInstance] logToConsole:@"RC instances inited"];
 
   self.namespacePicker.dataSource = self;

+ 175 - 0
FirebaseRemoteConfig/Tests/Unit/RCNConfigContentTest.m

@@ -19,6 +19,7 @@
 
 #import "FirebaseRemoteConfig/Sources/Private/RCNConfigSettings.h"
 #import "FirebaseRemoteConfig/Sources/Public/FirebaseRemoteConfig/FIRRemoteConfig.h"
+#import "FirebaseRemoteConfig/Sources/RCNConfigConstants.h"
 #import "FirebaseRemoteConfig/Sources/RCNConfigContent.h"
 #import "FirebaseRemoteConfig/Sources/RCNConfigDBManager.h"
 #import "FirebaseRemoteConfig/Sources/RCNConfigValue_Internal.h"
@@ -326,4 +327,178 @@ extern const NSTimeInterval kDatabaseLoadTimeoutSecs;
   [self waitForExpectationsWithTimeout:0.5 * kDatabaseLoadTimeoutSecs handler:nil];
 }
 
+- (void)testConfigUpdate_noChange_emptyResponse {
+  NSString *namespace = @"test_namespace";
+
+  // populate fetched config
+  NSMutableDictionary *fetchResponse =
+      [self createFetchResponseWithConfigEntries:@{@"key1" : @"value1"} p13nMetadata:nil];
+  [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace];
+
+  // active config is the same as fetched config
+  FIRRemoteConfigValue *value =
+      [[FIRRemoteConfigValue alloc] initWithData:[@"value1" dataUsingEncoding:NSUTF8StringEncoding]
+                                          source:FIRRemoteConfigSourceRemote];
+  NSDictionary *namespaceToConfig = @{namespace : @{@"key1" : value}};
+  [_configContent copyFromDictionary:namespaceToConfig
+                            toSource:RCNDBSourceActive
+                        forNamespace:namespace];
+
+  FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace];
+
+  XCTAssertTrue([update updatedKeys].count == 0);
+}
+
+- (void)testConfigUpdate_paramAdded_returnsNewKey {
+  NSString *namespace = @"test_namespace";
+  NSString *newParam = @"key2";
+
+  // populate active config
+  FIRRemoteConfigValue *value =
+      [[FIRRemoteConfigValue alloc] initWithData:[@"value1" dataUsingEncoding:NSUTF8StringEncoding]
+                                          source:FIRRemoteConfigSourceRemote];
+  NSDictionary *namespaceToConfig = @{namespace : @{@"key1" : value}};
+  [_configContent copyFromDictionary:namespaceToConfig
+                            toSource:RCNDBSourceActive
+                        forNamespace:namespace];
+
+  // fetch response has new param
+  NSMutableDictionary *fetchResponse =
+      [self createFetchResponseWithConfigEntries:@{@"key1" : @"value1", newParam : @"value2"}
+                                    p13nMetadata:nil];
+  [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace];
+
+  FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace];
+
+  XCTAssertTrue([update updatedKeys].count == 1);
+  XCTAssertTrue([[update updatedKeys] containsObject:newParam]);
+}
+
+- (void)testConfigUpdate_paramValueChanged_returnsUpdatedKey {
+  NSString *namespace = @"test_namespace";
+  NSString *existingParam = @"key1";
+  NSString *oldValue = @"value1";
+  NSString *updatedValue = @"value2";
+
+  // active config contains old value
+  FIRRemoteConfigValue *value =
+      [[FIRRemoteConfigValue alloc] initWithData:[oldValue dataUsingEncoding:NSUTF8StringEncoding]
+                                          source:FIRRemoteConfigSourceRemote];
+  NSDictionary *namespaceToConfig = @{namespace : @{existingParam : value}};
+  [_configContent copyFromDictionary:namespaceToConfig
+                            toSource:RCNDBSourceActive
+                        forNamespace:namespace];
+
+  // fetch response contains updated value
+  NSMutableDictionary *fetchResponse =
+      [self createFetchResponseWithConfigEntries:@{existingParam : updatedValue} p13nMetadata:nil];
+  [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace];
+
+  FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace];
+
+  XCTAssertTrue([update updatedKeys].count == 1);
+  XCTAssertTrue([[update updatedKeys] containsObject:existingParam]);
+}
+
+- (void)testConfigUpdate_paramDeleted_returnsDeletedKey {
+  NSString *namespace = @"test_namespace";
+  NSString *existingParam = @"key1";
+  NSString *newParam = @"key2";
+  NSString *value1 = @"value1";
+
+  // populate active config
+  FIRRemoteConfigValue *value =
+      [[FIRRemoteConfigValue alloc] initWithData:[value1 dataUsingEncoding:NSUTF8StringEncoding]
+                                          source:FIRRemoteConfigSourceRemote];
+  NSDictionary *namespaceToConfig = @{namespace : @{existingParam : value}};
+  [_configContent copyFromDictionary:namespaceToConfig
+                            toSource:RCNDBSourceActive
+                        forNamespace:namespace];
+
+  // fetch response does not contain existing param
+  NSMutableDictionary *fetchResponse =
+      [self createFetchResponseWithConfigEntries:@{newParam : value1} p13nMetadata:nil];
+  [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace];
+
+  FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace];
+
+  XCTAssertTrue([update updatedKeys].count == 2);
+  XCTAssertTrue([[update updatedKeys] containsObject:existingParam]);  // deleted
+  XCTAssertTrue([[update updatedKeys] containsObject:newParam]);       // added
+}
+
+- (void)testConfigUpdate_p13nMetadataUpdated_returnsKey {
+  NSString *namespace = @"test_namespace";
+  NSString *existingParam = @"key1";
+  NSString *value1 = @"value1";
+  NSDictionary *oldMetadata = @{@"arm_index" : @"1"};
+  NSDictionary *updatedMetadata = @{@"arm_index" : @"2"};
+
+  // popuate fetched config
+  NSMutableDictionary *fetchResponse =
+      [self createFetchResponseWithConfigEntries:@{existingParam : value1}
+                                    p13nMetadata:@{existingParam : oldMetadata}];
+  [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace];
+
+  // populate active config with the same content
+  [_configContent activatePersonalization];
+  FIRRemoteConfigValue *value =
+      [[FIRRemoteConfigValue alloc] initWithData:[value1 dataUsingEncoding:NSUTF8StringEncoding]
+                                          source:FIRRemoteConfigSourceRemote];
+  NSDictionary *namespaceToConfig = @{namespace : @{existingParam : value}};
+  [_configContent copyFromDictionary:namespaceToConfig
+                            toSource:RCNDBSourceActive
+                        forNamespace:namespace];
+
+  // fetched response has updated p13n metadata
+  [fetchResponse setValue:@{existingParam : updatedMetadata}
+                   forKey:RCNFetchResponseKeyPersonalizationMetadata];
+  [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace];
+
+  FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace];
+
+  XCTAssertTrue([update updatedKeys].count == 1);
+  XCTAssertTrue([[update updatedKeys] containsObject:existingParam]);
+}
+
+- (void)testConfigUpdate_valueSourceChanged_returnsKey {
+  NSString *namespace = @"test_namespace";
+  NSString *existingParam = @"key1";
+  NSString *value1 = @"value1";
+
+  // set default config
+  FIRRemoteConfigValue *value =
+      [[FIRRemoteConfigValue alloc] initWithData:[value1 dataUsingEncoding:NSUTF8StringEncoding]
+                                          source:FIRRemoteConfigSourceDefault];
+  NSDictionary *namespaceToConfig = @{namespace : @{existingParam : value}};
+  [_configContent copyFromDictionary:namespaceToConfig
+                            toSource:RCNDBSourceDefault
+                        forNamespace:namespace];
+
+  // fetch response contains same key->value
+  NSMutableDictionary *fetchResponse =
+      [self createFetchResponseWithConfigEntries:@{existingParam : value1} p13nMetadata:nil];
+  [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace];
+
+  FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace];
+
+  XCTAssertTrue([update updatedKeys].count == 1);
+  XCTAssertTrue([[update updatedKeys] containsObject:existingParam]);
+}
+
+#pragma mark - Test Helpers
+
+- (NSMutableDictionary *)createFetchResponseWithConfigEntries:(NSDictionary *)config
+                                                 p13nMetadata:(NSDictionary *)metadata {
+  NSMutableDictionary *fetchResponse = [[NSMutableDictionary alloc]
+      initWithObjectsAndKeys:RCNFetchResponseKeyStateUpdate, RCNFetchResponseKeyState, nil];
+  if (config) {
+    [fetchResponse setValue:config forKey:RCNFetchResponseKeyEntries];
+  }
+  if (metadata) {
+    [fetchResponse setValue:metadata forKey:RCNFetchResponseKeyPersonalizationMetadata];
+  }
+  return fetchResponse;
+}
+
 @end

+ 9 - 2
FirebaseRemoteConfig/Tests/Unit/RCNPersonalizationTest.m

@@ -29,11 +29,14 @@
 
 @interface RCNConfigFetch (ForTest)
 - (NSURLSessionDataTask *)URLSessionDataTaskWithContent:(NSData *)content
+                                        fetchTypeHeader:(NSString *)fetchTypeHeader
                                       completionHandler:
                                           (RCNConfigFetcherCompletion)fetcherCompletion;
 
 - (void)fetchWithUserProperties:(NSDictionary *)userProperties
-              completionHandler:(FIRRemoteConfigFetchCompletion)completionHandler;
+                fetchTypeHeader:(NSString *)fetchTypeHeader
+              completionHandler:(FIRRemoteConfigFetchCompletion)completionHandler
+        updateCompletionHandler:(RCNConfigFetchCompletion)updateCompletionHandler;
 @end
 
 @interface RCNPersonalizationTest : XCTestCase {
@@ -243,10 +246,14 @@
       .andDo(^(NSInvocation *invocation) {
         __unsafe_unretained FIRRemoteConfigFetchCompletion handler;
         [invocation getArgument:&handler atIndex:3];
-        [configFetch fetchWithUserProperties:[[NSDictionary alloc] init] completionHandler:handler];
+        [configFetch fetchWithUserProperties:[[NSDictionary alloc] init]
+                             fetchTypeHeader:@"Base/1"
+                           completionHandler:handler
+                     updateCompletionHandler:nil];
       });
   OCMExpect([configFetch
                 URLSessionDataTaskWithContent:[OCMArg any]
+                              fetchTypeHeader:@"Base/1"
                             completionHandler:[RCNPersonalizationTest mockResponseHandler]])
       .andReturn(nil);
   return configFetch;

+ 290 - 8
FirebaseRemoteConfig/Tests/Unit/RCNRemoteConfigTest.m

@@ -24,6 +24,7 @@
 #import "FirebaseRemoteConfig/Sources/RCNConfigConstants.h"
 #import "FirebaseRemoteConfig/Sources/RCNConfigDBManager.h"
 #import "FirebaseRemoteConfig/Sources/RCNConfigExperiment.h"
+#import "FirebaseRemoteConfig/Sources/RCNConfigRealtime.h"
 #import "FirebaseRemoteConfig/Sources/RCNUserDefaultsManager.h"
 
 #import "FirebaseRemoteConfig/Tests/Unit/RCNTestUtilities.h"
@@ -41,22 +42,51 @@
                             app:firebaseApp;
 /// Skip fetching user properties from analytics because we cannot mock the action here. Instead
 /// overriding the method to skip.
-- (void)fetchWithUserPropertiesCompletionHandler:(FIRAInteropUserPropertiesCallback)block;
+- (void)fetchWithUserPropertiesCompletionHandler:(NSString *)fetchTypeHeader
+                               completionHandler:(FIRAInteropUserPropertiesCallback)block;
 - (NSURLSessionDataTask *)URLSessionDataTaskWithContent:(NSData *)content
+                                        fetchTypeHeader:(NSString *)fetchTypeHeader
                                       completionHandler:
                                           (RCNConfigFetcherCompletion)fetcherCompletion;
-
+- (void)fetchConfigWithExpirationDuration:(NSTimeInterval)expirationDuration
+                        completionHandler:(FIRRemoteConfigFetchCompletion)completionHandler;
+- (void)realtimeFetchConfigWithNoExpirationDuration:(NSInteger)fetchAttemptNumber
+                                  completionHandler:(RCNConfigFetchCompletion)completionHandler;
 - (void)fetchWithUserProperties:(NSDictionary *)userProperties
-              completionHandler:(FIRRemoteConfigFetchCompletion)completionHandler;
+                fetchTypeHeader:(NSString *)fetchTypeHeader
+              completionHandler:(FIRRemoteConfigFetchCompletion)completionHandler
+        updateCompletionHandler:(RCNConfigFetchCompletion)updateCompletionHandler;
 - (NSString *)constructServerURL;
 - (NSURLSession *)currentNetworkSession;
 @end
 
+@interface RCNConfigRealtime (ForTest)
+
+- (instancetype _Nonnull)init:(RCNConfigFetch *_Nonnull)configFetch
+                     settings:(RCNConfigSettings *_Nonnull)settings
+                    namespace:(NSString *_Nonnull)namespace
+                      options:(FIROptions *_Nonnull)options;
+
+- (void)fetchLatestConfig:(NSInteger)remainingAttempts targetVersion:(NSInteger)targetVersion;
+- (void)scheduleFetch:(NSInteger)remainingAttempts targetVersion:(NSInteger)targetVersion;
+- (void)autoFetch:(NSInteger)remainingAttempts targetVersion:(NSInteger)targetVersion;
+- (void)beginRealtimeStream;
+- (void)pauseRealtimeStream;
+
+- (FIRConfigUpdateListenerRegistration *_Nonnull)addConfigUpdateListener:
+    (RCNConfigUpdateCompletion _Nonnull)listener;
+- (void)removeConfigUpdateListener:(RCNConfigUpdateCompletion _Nonnull)listener;
+- (void)evaluateStreamResponse:(NSDictionary *)response error:(NSError *)dataError;
+
+@end
+
 @interface FIRRemoteConfig (ForTest)
 - (void)updateWithNewInstancesForConfigFetch:(RCNConfigFetch *)configFetch
                                configContent:(RCNConfigContent *)configContent
                               configSettings:(RCNConfigSettings *)configSettings
                             configExperiment:(RCNConfigExperiment *)configExperiment;
+
+- (void)updateWithNewInstancesForConfigRealtime:(RCNConfigRealtime *)configRealtime;
 @end
 
 @implementation FIRRemoteConfig (ForTest)
@@ -69,6 +99,10 @@
   [self setValue:configSettings forKey:@"_settings"];
   [self setValue:configExperiment forKey:@"_configExperiment"];
 }
+
+- (void)updateWithNewInstancesForConfigRealtime:(RCNConfigRealtime *)configRealtime {
+  [self setValue:configRealtime forKey:@"_configRealtime"];
+}
 @end
 
 @interface RCNConfigDBManager (Test)
@@ -99,6 +133,7 @@ typedef NS_ENUM(NSInteger, RCNTestRCInstance) {
   NSMutableArray<NSData *> *_responseData;
   NSMutableArray<NSURLResponse *> *_URLResponse;
   NSMutableArray<id> *_configFetch;
+  NSMutableArray<id> *_configRealtime;
   RCNConfigDBManager *_DBManager;
   NSUserDefaults *_userDefaults;
   NSString *_userDefaultsSuiteName;
@@ -143,6 +178,7 @@ typedef NS_ENUM(NSInteger, RCNTestRCInstance) {
   _responseData = [[NSMutableArray alloc] initWithCapacity:3];
   _URLResponse = [[NSMutableArray alloc] initWithCapacity:3];
   _configFetch = [[NSMutableArray alloc] initWithCapacity:3];
+  _configRealtime = [[NSMutableArray alloc] initWithCapacity:3];
 
   // Populate the default, second app, second namespace instances.
   for (int i = 0; i < RCNTestRCNumTotalInstances; i++) {
@@ -203,6 +239,10 @@ typedef NS_ENUM(NSInteger, RCNTestRCInstance) {
                                                          queue:_queue
                                                      namespace:_fullyQualifiedNamespace
                                                        options:currentOptions]);
+    _configRealtime[i] = OCMPartialMock([[RCNConfigRealtime alloc] init:_configFetch[i]
+                                                               settings:_settings
+                                                              namespace:_fullyQualifiedNamespace
+                                                                options:currentOptions]);
 
     OCMStubRecorder *mock = OCMStub([_configFetch[i] fetchConfigWithExpirationDuration:0
                                                                      completionHandler:OCMOCK_ANY]);
@@ -212,7 +252,9 @@ typedef NS_ENUM(NSInteger, RCNTestRCInstance) {
                                           NSError *_Nullable error) = nil;
       [invocation getArgument:&handler atIndex:3];
       [self->_configFetch[i] fetchWithUserProperties:[[NSDictionary alloc] init]
-                                   completionHandler:handler];
+                                     fetchTypeHeader:@"Base/1"
+                                   completionHandler:handler
+                             updateCompletionHandler:nil];
     });
 
     _response[i] = @{@"state" : @"UPDATE", @"entries" : _entries[i]};
@@ -229,12 +271,14 @@ typedef NS_ENUM(NSInteger, RCNTestRCInstance) {
         [OCMArg invokeBlockWithArgs:_responseData[i], _URLResponse[i], [NSNull null], nil];
 
     OCMStub([_configFetch[i] URLSessionDataTaskWithContent:[OCMArg any]
+                                           fetchTypeHeader:[OCMArg any]
                                          completionHandler:completionBlock])
         .andReturn(nil);
     [_configInstances[i] updateWithNewInstancesForConfigFetch:_configFetch[i]
                                                 configContent:configContent
                                                configSettings:_settings
                                              configExperiment:_experimentMock];
+    [_configInstances[i] updateWithNewInstancesForConfigRealtime:_configRealtime[i]];
   }
 }
 
@@ -251,6 +295,7 @@ typedef NS_ENUM(NSInteger, RCNTestRCInstance) {
   }
   [_configInstances removeAllObjects];
   [_configFetch removeAllObjects];
+  [_configRealtime removeAllObjects];
   _configInstances = nil;
   _configFetch = nil;
   [super tearDown];
@@ -581,13 +626,20 @@ typedef NS_ENUM(NSInteger, RCNTestRCInstance) {
                                                      namespace:_fullyQualifiedNamespace
                                                        options:currentOptions]);
 
+    _configRealtime[i] = OCMPartialMock([[RCNConfigRealtime alloc] init:_configFetch[i]
+                                                               settings:_settings
+                                                              namespace:_fullyQualifiedNamespace
+                                                                options:currentOptions]);
+
     OCMStub([_configFetch[i] fetchConfigWithExpirationDuration:43200 completionHandler:OCMOCK_ANY])
         .andDo(^(NSInvocation *invocation) {
           __unsafe_unretained void (^handler)(FIRRemoteConfigFetchStatus status,
                                               NSError *_Nullable error) = nil;
           [invocation getArgument:&handler atIndex:3];
           [self->_configFetch[i] fetchWithUserProperties:[[NSDictionary alloc] init]
-                                       completionHandler:handler];
+                                         fetchTypeHeader:@"Base/1"
+                                       completionHandler:handler
+                                 updateCompletionHandler:nil];
         });
 
     _response[i] = @{};
@@ -696,13 +748,20 @@ typedef NS_ENUM(NSInteger, RCNTestRCInstance) {
                                                                    namespace:fullyQualifiedNamespace
                                                                      options:currentOptions]);
 
+    _configRealtime[i] = OCMPartialMock([[RCNConfigRealtime alloc] init:_configFetch[i]
+                                                               settings:settings
+                                                              namespace:fullyQualifiedNamespace
+                                                                options:currentOptions]);
+
     OCMStub([_configFetch[i] fetchConfigWithExpirationDuration:43200 completionHandler:OCMOCK_ANY])
         .andDo(^(NSInvocation *invocation) {
           __unsafe_unretained void (^handler)(FIRRemoteConfigFetchStatus status,
                                               NSError *_Nullable error) = nil;
           [invocation getArgument:&handler atIndex:3];
           [self->_configFetch[i] fetchWithUserProperties:[[NSDictionary alloc] init]
-                                       completionHandler:handler];
+                                         fetchTypeHeader:@"Base/1"
+                                       completionHandler:handler
+                                 updateCompletionHandler:nil];
         });
 
     _response[i] = @{};
@@ -795,7 +854,10 @@ typedef NS_ENUM(NSInteger, RCNTestRCInstance) {
         __unsafe_unretained void (^handler)(FIRRemoteConfigFetchStatus status,
                                             NSError *_Nullable error) = nil;
         [invocation getArgument:&handler atIndex:3];
-        [configFetch fetchWithUserProperties:[[NSDictionary alloc] init] completionHandler:handler];
+        [configFetch fetchWithUserProperties:[[NSDictionary alloc] init]
+                             fetchTypeHeader:@"Base/1"
+                           completionHandler:handler
+                     updateCompletionHandler:nil];
       });
   _responseData[0] = [NSJSONSerialization dataWithJSONObject:@{} options:0 error:nil];
 
@@ -895,6 +957,10 @@ typedef NS_ENUM(NSInteger, RCNTestRCInstance) {
                                                                        queue:queue
                                                                    namespace:fullyQualifiedNamespace
                                                                      options:currentOptions]);
+    _configRealtime[i] = OCMPartialMock([[RCNConfigRealtime alloc] init:_configFetch[i]
+                                                               settings:settings
+                                                              namespace:fullyQualifiedNamespace
+                                                                options:currentOptions]);
 
     OCMStub([_configFetch[i] fetchConfigWithExpirationDuration:43200 completionHandler:OCMOCK_ANY])
         .andDo(^(NSInvocation *invocation) {
@@ -903,7 +969,9 @@ typedef NS_ENUM(NSInteger, RCNTestRCInstance) {
 
           [invocation getArgument:&handler atIndex:3];
           [self->_configFetch[i] fetchWithUserProperties:[[NSDictionary alloc] init]
-                                       completionHandler:handler];
+                                         fetchTypeHeader:@"Base/1"
+                                       completionHandler:handler
+                                 updateCompletionHandler:nil];
         });
 
     _response[i] = @{@"state" : @"NO_CHANGE"};
@@ -920,6 +988,7 @@ typedef NS_ENUM(NSInteger, RCNTestRCInstance) {
         [OCMArg invokeBlockWithArgs:_responseData[i], _URLResponse[i], [NSNull null], nil];
 
     OCMStub([_configFetch[i] URLSessionDataTaskWithContent:[OCMArg any]
+                                           fetchTypeHeader:@"Base/1"
                                          completionHandler:completionBlock])
         .andReturn(nil);
 
@@ -1483,6 +1552,219 @@ static NSString *UTCToLocal(NSString *utcTime) {
   XCTAssertEqual([config valueForKey:@"_appName"], kFIRDefaultAppName);
 }
 
+#pragma mark - Realtime tests
+
+- (void)testRealtimeAddConfigUpdateListenerWithValidListener {
+  NSMutableArray<XCTestExpectation *> *expectations =
+      [[NSMutableArray alloc] initWithCapacity:RCNTestRCNumTotalInstances];
+  for (int i = 0; i < RCNTestRCNumTotalInstances; i++) {
+    expectations[i] = [self
+        expectationWithDescription:
+            [NSString
+                stringWithFormat:@"Test Realtime add listener successfully - instance %d", i]];
+
+    OCMStub([_configRealtime[i] beginRealtimeStream]).andDo(nil);
+    id completion = ^void(FIRRemoteConfigUpdate *_Nullable configUpdate, NSError *_Nullable error) {
+      if (error != nil) {
+        NSLog(@"Callback");
+      }
+    };
+
+    [_configRealtime[i] addConfigUpdateListener:completion];
+
+    dispatch_after(
+        dispatch_time(DISPATCH_TIME_NOW, (int64_t)(_checkCompletionTimeout * NSEC_PER_SEC)),
+        dispatch_get_main_queue(), ^{
+          OCMVerify([self->_configRealtime[i] beginRealtimeStream]);
+          OCMVerify([self->_configRealtime[i] addConfigUpdateListener:completion]);
+          [expectations[i] fulfill];
+        });
+
+    [self waitForExpectationsWithTimeout:_expectationTimeout handler:nil];
+  }
+}
+
+- (void)testRealtimeAddConfigUpdateListenerWithInvalidListener {
+  NSMutableArray<XCTestExpectation *> *expectations =
+      [[NSMutableArray alloc] initWithCapacity:RCNTestRCNumTotalInstances];
+  for (int i = 0; i < RCNTestRCNumTotalInstances; i++) {
+    expectations[i] = [self
+        expectationWithDescription:
+            [NSString
+                stringWithFormat:@"Test Realtime add listener unsuccessfully - instance %d", i]];
+
+    id completion = nil;
+    [_configRealtime[i] addConfigUpdateListener:completion];
+    dispatch_after(
+        dispatch_time(DISPATCH_TIME_NOW, (int64_t)(_checkCompletionTimeout * NSEC_PER_SEC)),
+        dispatch_get_main_queue(), ^{
+          OCMVerify(never(), [self->_configRealtime[i] beginRealtimeStream]);
+          [expectations[i] fulfill];
+        });
+
+    [self waitForExpectationsWithTimeout:_expectationTimeout handler:nil];
+  }
+}
+
+- (void)testRemoveRealtimeListener {
+  NSMutableArray<XCTestExpectation *> *expectations =
+      [[NSMutableArray alloc] initWithCapacity:RCNTestRCNumTotalInstances];
+  for (int i = 0; i < RCNTestRCNumTotalInstances; i++) {
+    expectations[i] = [self
+        expectationWithDescription:
+            [NSString
+                stringWithFormat:@"Test Realtime remove listeners successfully - instance %d", i]];
+
+    id completion = ^void(FIRRemoteConfigUpdate *_Nullable configUpdate, NSError *_Nullable error) {
+      if (error != nil) {
+        NSLog(@"Callback");
+      }
+    };
+    OCMStub([_configRealtime[i] beginRealtimeStream]).andDo(nil);
+
+    FIRConfigUpdateListenerRegistration *registration =
+        [_configRealtime[i] addConfigUpdateListener:completion];
+    [registration remove];
+
+    dispatch_after(
+        dispatch_time(DISPATCH_TIME_NOW, (int64_t)(_checkCompletionTimeout * NSEC_PER_SEC)),
+        dispatch_get_main_queue(), ^{
+          OCMVerify([self->_configRealtime[i] addConfigUpdateListener:completion]);
+          OCMVerify([self->_configRealtime[i] removeConfigUpdateListener:completion]);
+          OCMVerify([self->_configRealtime[i] pauseRealtimeStream]);
+          [expectations[i] fulfill];
+        });
+
+    [self waitForExpectationsWithTimeout:_expectationTimeout handler:nil];
+  }
+}
+
+- (void)testRealtimeFetch {
+  NSMutableArray<XCTestExpectation *> *expectations =
+      [[NSMutableArray alloc] initWithCapacity:RCNTestRCNumTotalInstances];
+  for (int i = 0; i < RCNTestRCNumTotalInstances; i++) {
+    expectations[i] = [self
+        expectationWithDescription:
+            [NSString stringWithFormat:@"Test Realtime Autofetch successfully - instance %d", i]];
+
+    OCMStub([_configFetch[i] realtimeFetchConfigWithNoExpirationDuration:1
+                                                       completionHandler:OCMOCK_ANY])
+        .andDo(nil);
+    OCMStub([_configRealtime[i] scheduleFetch:1 targetVersion:1]).andDo(nil);
+
+    [_configRealtime[i] fetchLatestConfig:3 targetVersion:1];
+
+    dispatch_after(
+        dispatch_time(DISPATCH_TIME_NOW, (int64_t)(_checkCompletionTimeout * NSEC_PER_SEC)),
+        dispatch_get_main_queue(), ^{
+          OCMVerify([self->_configFetch[i] realtimeFetchConfigWithNoExpirationDuration:1
+                                                                     completionHandler:OCMOCK_ANY]);
+          [expectations[i] fulfill];
+        });
+
+    [self waitForExpectationsWithTimeout:_expectationTimeout handler:nil];
+  }
+}
+
+- (void)testAutofetch {
+  NSMutableArray<XCTestExpectation *> *expectations =
+      [[NSMutableArray alloc] initWithCapacity:RCNTestRCNumTotalInstances];
+  for (int i = 0; i < RCNTestRCNumTotalInstances; i++) {
+    expectations[i] = [self
+        expectationWithDescription:
+            [NSString stringWithFormat:@"Test Realtime Autofetch successfully - instance %d", i]];
+
+    OCMStub([_configRealtime[i] scheduleFetch:1 targetVersion:1]).andDo(nil);
+
+    [_configRealtime[i] autoFetch:1 targetVersion:1];
+
+    dispatch_after(
+        dispatch_time(DISPATCH_TIME_NOW, (int64_t)(_checkCompletionTimeout * NSEC_PER_SEC)),
+        dispatch_get_main_queue(), ^{
+          OCMVerify([self->_configRealtime[i] scheduleFetch:1 targetVersion:1]);
+          [expectations[i] fulfill];
+        });
+
+    [self waitForExpectationsWithTimeout:_expectationTimeout handler:nil];
+  }
+}
+
+- (void)testAddOnConfigUpdateMethodSuccess {
+  NSMutableArray<XCTestExpectation *> *expectations =
+      [[NSMutableArray alloc] initWithCapacity:RCNTestRCNumTotalInstances];
+  for (int i = 0; i < RCNTestRCNumTotalInstances; i++) {
+    expectations[i] = [self
+        expectationWithDescription:
+            [NSString
+                stringWithFormat:@"Test public realtime method successfully - instance %d", i]];
+
+    OCMStub([_configRealtime[i] beginRealtimeStream]).andDo(nil);
+
+    id completion = ^void(FIRRemoteConfigUpdate *_Nullable configUpdate, NSError *_Nullable error) {
+      if (error != nil) {
+        NSLog(@"Callback");
+      }
+    };
+    [_configInstances[i] addOnConfigUpdateListener:completion];
+    dispatch_after(
+        dispatch_time(DISPATCH_TIME_NOW, (int64_t)(_checkCompletionTimeout * NSEC_PER_SEC)),
+        dispatch_get_main_queue(), ^{
+          OCMVerify([self->_configRealtime[i] addConfigUpdateListener:completion]);
+          [expectations[i] fulfill];
+        });
+
+    [self waitForExpectationsWithTimeout:_expectationTimeout handler:nil];
+  }
+}
+
+- (void)testAddOnConfigUpdateMethodFail {
+  NSMutableArray<XCTestExpectation *> *expectations =
+      [[NSMutableArray alloc] initWithCapacity:RCNTestRCNumTotalInstances];
+  for (int i = 0; i < RCNTestRCNumTotalInstances; i++) {
+    expectations[i] = [self
+        expectationWithDescription:
+            [NSString stringWithFormat:@"Test public realtime method and fails - instance %d", i]];
+
+    id completion = nil;
+    [_configInstances[i] addOnConfigUpdateListener:completion];
+    dispatch_after(
+        dispatch_time(DISPATCH_TIME_NOW, (int64_t)(_checkCompletionTimeout * NSEC_PER_SEC)),
+        dispatch_get_main_queue(), ^{
+          OCMVerify(never(), [self->_configRealtime[i] beginRealtimeStream]);
+          [expectations[i] fulfill];
+        });
+
+    [self waitForExpectationsWithTimeout:_expectationTimeout handler:nil];
+  }
+}
+
+- (void)testRealtimeDisabled {
+  NSMutableArray<XCTestExpectation *> *expectations =
+      [[NSMutableArray alloc] initWithCapacity:RCNTestRCNumTotalInstances];
+  for (int i = 0; i < RCNTestRCNumTotalInstances; i++) {
+    expectations[i] = [self
+        expectationWithDescription:
+            [NSString
+                stringWithFormat:@"Test isRealtimeDisabled flag and makes it true - instance %d",
+                                 i]];
+    OCMStub([_configRealtime[i] pauseRealtimeStream]).andDo(nil);
+    NSMutableDictionary *dictionary = [[NSMutableDictionary alloc] init];
+    [dictionary setValue:@"true" forKey:@"featureDisabled"];
+    [dictionary setValue:@"1" forKey:@"latestTemplateVersionNumber"];
+
+    [_configRealtime[i] evaluateStreamResponse:dictionary error:nil];
+    dispatch_after(
+        dispatch_time(DISPATCH_TIME_NOW, (int64_t)(_checkCompletionTimeout * NSEC_PER_SEC)),
+        dispatch_get_main_queue(), ^{
+          OCMVerify([self->_configRealtime[i] pauseRealtimeStream]);
+          OCMVerify(never(), [self->_configRealtime[i] autoFetch:5 targetVersion:1]);
+          [expectations[i] fulfill];
+        });
+
+    [self waitForExpectationsWithTimeout:_expectationTimeout handler:nil];
+  }
+}
+
 #pragma mark - Test Helpers
 
 - (FIROptions *)firstAppOptions {

+ 61 - 0
FirebaseRemoteConfig/Tests/Unit/RCNUserDefaultsManagerTests.m

@@ -124,6 +124,41 @@ static NSString* const FQNamespace2 = @"testNamespace2:testApp";
                  RCNUserDefaultsSampleTimeStamp - 2.0);
 }
 
+- (void)testUserDefaultsTemplateVersionWriteAndRead {
+  RCNUserDefaultsManager* manager =
+      [[RCNUserDefaultsManager alloc] initWithAppName:AppName
+                                             bundleID:[NSBundle mainBundle].bundleIdentifier
+                                            namespace:FQNamespace1];
+  [manager setLastTemplateVersion:@"1"];
+  XCTAssertEqual([manager lastTemplateVersion], @"1");
+}
+
+- (void)testUserDefaultsRealtimeThrottleEndTimeWriteAndRead {
+  RCNUserDefaultsManager* manager =
+      [[RCNUserDefaultsManager alloc] initWithAppName:AppName
+                                             bundleID:[NSBundle mainBundle].bundleIdentifier
+                                            namespace:FQNamespace1];
+  [manager setRealtimeThrottleEndTime:RCNUserDefaultsSampleTimeStamp - 7.0];
+  XCTAssertEqual([manager realtimeThrottleEndTime], RCNUserDefaultsSampleTimeStamp - 7.0);
+
+  [manager setRealtimeThrottleEndTime:RCNUserDefaultsSampleTimeStamp - 8.0];
+  XCTAssertEqual([manager realtimeThrottleEndTime], RCNUserDefaultsSampleTimeStamp - 8.0);
+}
+
+- (void)testUserDefaultsCurrentRealtimeThrottlingRetryIntervalWriteAndRead {
+  RCNUserDefaultsManager* manager =
+      [[RCNUserDefaultsManager alloc] initWithAppName:AppName
+                                             bundleID:[NSBundle mainBundle].bundleIdentifier
+                                            namespace:FQNamespace1];
+  [manager setCurrentRealtimeThrottlingRetryIntervalSeconds:RCNUserDefaultsSampleTimeStamp - 1.0];
+  XCTAssertEqual([manager currentRealtimeThrottlingRetryIntervalSeconds],
+                 RCNUserDefaultsSampleTimeStamp - 1.0);
+
+  [manager setCurrentRealtimeThrottlingRetryIntervalSeconds:RCNUserDefaultsSampleTimeStamp - 2.0];
+  XCTAssertEqual([manager currentRealtimeThrottlingRetryIntervalSeconds],
+                 RCNUserDefaultsSampleTimeStamp - 2.0);
+}
+
 - (void)testUserDefaultsForMultipleNamespaces {
   RCNUserDefaultsManager* manager1 =
       [[RCNUserDefaultsManager alloc] initWithAppName:AppName
@@ -172,6 +207,32 @@ static NSString* const FQNamespace2 = @"testNamespace2:testApp";
                  RCNUserDefaultsSampleTimeStamp - 1.0);
   XCTAssertEqual([manager2 currentThrottlingRetryIntervalSeconds],
                  RCNUserDefaultsSampleTimeStamp - 2.0);
+
+  /// Realtime throttle end time.
+  [manager1 setRealtimeThrottleEndTime:RCNUserDefaultsSampleTimeStamp - 7.0];
+  [manager2 setRealtimeThrottleEndTime:RCNUserDefaultsSampleTimeStamp - 8.0];
+  XCTAssertEqual([manager1 realtimeThrottleEndTime], RCNUserDefaultsSampleTimeStamp - 7.0);
+  XCTAssertEqual([manager2 realtimeThrottleEndTime], RCNUserDefaultsSampleTimeStamp - 8.0);
+
+  /// Realtime throttling retry interval.
+  [manager1 setCurrentRealtimeThrottlingRetryIntervalSeconds:RCNUserDefaultsSampleTimeStamp - 1.0];
+  [manager2 setCurrentRealtimeThrottlingRetryIntervalSeconds:RCNUserDefaultsSampleTimeStamp - 2.0];
+  XCTAssertEqual([manager1 currentRealtimeThrottlingRetryIntervalSeconds],
+                 RCNUserDefaultsSampleTimeStamp - 1.0);
+  XCTAssertEqual([manager2 currentRealtimeThrottlingRetryIntervalSeconds],
+                 RCNUserDefaultsSampleTimeStamp - 2.0);
+
+  /// Realtime retry count;
+  [manager1 setRealtimeRetryCount:1];
+  [manager2 setRealtimeRetryCount:2];
+  XCTAssertEqual([manager1 realtimeRetryCount], 1);
+  XCTAssertEqual([manager2 realtimeRetryCount], 2);
+
+  /// Fetch template version.
+  [manager1 setLastTemplateVersion:@"1"];
+  [manager2 setLastTemplateVersion:@"2"];
+  XCTAssertEqualObjects([manager1 lastTemplateVersion], @"1");
+  XCTAssertEqualObjects([manager2 lastTemplateVersion], @"2");
 }
 
 - (void)testUserDefaultsReset {

+ 1 - 0
FirebaseRemoteConfigSwift/Tests/ObjC/Bridging-Header.h

@@ -15,3 +15,4 @@
 #import "FirebaseRemoteConfig/Sources/Private/FIRRemoteConfig_Private.h"
 #import "FirebaseRemoteConfig/Sources/RCNConfigConstants.h"
 #import "FirebaseRemoteConfigSwift/Tests/ObjC/FetchMocks.h"
+#import "FirebaseRemoteConfigSwift/Tests/ObjC/RealtimeMocks.h"

+ 10 - 5
FirebaseRemoteConfigSwift/Tests/ObjC/FetchMocks.m

@@ -18,9 +18,12 @@
 #import "FirebaseRemoteConfigSwift/Tests/ObjC/FetchMocks.h"
 
 @interface RCNConfigFetch (ExposedForTest)
-- (void)refreshInstallationsTokenWithCompletionHandler:
-    (FIRRemoteConfigFetchCompletion)completionHandler;
-- (void)doFetchCall:(FIRRemoteConfigFetchCompletion)completionHandler;
+- (void)refreshInstallationsTokenWithFetchHeader:(NSString *)fetchTypeHeader
+                               completionHandler:(FIRRemoteConfigFetchCompletion)completionHandler
+                         updateCompletionHandler:(RCNConfigFetchCompletion)updateCompletionHandler;
+- (void)doFetchCall:(NSString *)fetchTypeHeader
+          completionHandler:(FIRRemoteConfigFetchCompletion)completionHandler
+    updateCompletionHandler:(RCNConfigFetchCompletion)updateCompletionHandler;
 @end
 
 @implementation FetchMocks
@@ -28,8 +31,10 @@
 + (RCNConfigFetch *)mockFetch:(RCNConfigFetch *)fetch {
   RCNConfigFetch *mock = OCMPartialMock(fetch);
   OCMStub([mock recreateNetworkSession]).andDo(nil);
-  OCMStub([mock refreshInstallationsTokenWithCompletionHandler:[OCMArg any]])
-      .andCall(mock, @selector(doFetchCall:));
+  OCMStub([mock refreshInstallationsTokenWithFetchHeader:[OCMArg any]
+                                       completionHandler:[OCMArg any]
+                                 updateCompletionHandler:[OCMArg any]])
+      .andCall(mock, @selector(doFetchCall:completionHandler:updateCompletionHandler:));
   return mock;
 }
 

+ 24 - 0
FirebaseRemoteConfigSwift/Tests/ObjC/RealtimeMocks.h

@@ -0,0 +1,24 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#import <Foundation/Foundation.h>
+#import "FirebaseRemoteConfig/Sources/RCNConfigRealtime.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface RealtimeMocks : NSObject
++ (RCNConfigRealtime *)mockRealtime:(RCNConfigRealtime *)realtime;
+@end
+
+NS_ASSUME_NONNULL_END

+ 42 - 0
FirebaseRemoteConfigSwift/Tests/ObjC/RealtimeMocks.m

@@ -0,0 +1,42 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#import <OCMock/OCMock.h>
+
+#import "FirebaseRemoteConfig/Sources/RCNConfigRealtime.h"
+#import "FirebaseRemoteConfigSwift/Tests/ObjC/RealtimeMocks.h"
+
+@interface RCNConfigRealtime (ExposedForTest)
+
+- (FIRConfigUpdateListenerRegistration *)addConfigUpdateListener:
+    (void (^_Nonnull)(FIRRemoteConfigUpdate *configUpdate, NSError *_Nullable error))listener;
+
+- (void)triggerListenerForTesting:(void (^_Nonnull)(FIRRemoteConfigUpdate *configUpdate,
+                                                    NSError *_Nullable error))listener;
+
+- (void)beginRealtimeStream;
+
+@end
+
+@implementation RealtimeMocks
+
++ (RCNConfigRealtime *)mockRealtime:(RCNConfigRealtime *)realtime {
+  RCNConfigRealtime *realtimeMock = OCMPartialMock(realtime);
+  OCMStub([realtimeMock beginRealtimeStream]).andDo(nil);
+  OCMStub([realtimeMock addConfigUpdateListener:[OCMArg any]])
+      .andCall(realtimeMock, @selector(triggerListenerForTesting:));
+  return realtimeMock;
+}
+
+@end

+ 6 - 0
FirebaseRemoteConfigSwift/Tests/SwiftAPI/APITestBase.swift

@@ -24,6 +24,7 @@ import XCTest
 class APITestBase: XCTestCase {
   static var useFakeConfig: Bool!
   static var mockedFetch: Bool!
+  static var mockedRealtime: Bool!
   var app: FirebaseApp!
   var config: RemoteConfig!
   var console: RemoteConfigConsole!
@@ -42,6 +43,7 @@ class APITestBase: XCTestCase {
         options.projectID = "Fake Project"
         FirebaseApp.configure(options: options)
         APITests.mockedFetch = false
+        APITests.mockedRealtime = false
       #endif
     }
   }
@@ -78,6 +80,10 @@ class APITestBase: XCTestCase {
         APITests.mockedFetch = true
         config.configFetch = FetchMocks.mockFetch(config.configFetch)
       }
+      if !APITests.mockedRealtime {
+        APITests.mockedRealtime = true
+        config.configRealtime = RealtimeMocks.mockRealtime(config.configRealtime)
+      }
       fakeConsole = FakeConsole()
       config.configFetch.fetchSession = URLSessionMock(with: fakeConsole)
 

+ 36 - 0
FirebaseRemoteConfigSwift/Tests/SwiftAPI/APITests.swift

@@ -143,6 +143,42 @@ class APITests: APITestBase {
     waitForExpectations()
   }
 
+  // MARK: - RemoteConfigRealtime Tests
+
+  func testRealtimeRemoteConfigFakeConsole() {
+    guard APITests.useFakeConfig == true else { return }
+
+    let expectation = self.expectation(description: #function)
+
+    let registration = config.addOnConfigUpdateListener { RemoteConfigUpdate, Error in
+      XCTAssertNil(Error, "Realtime error \(Error!)")
+      XCTAssertNotNil(RemoteConfigUpdate)
+
+      expectation.fulfill()
+    }
+
+    waitForExpectations()
+    registration.remove()
+  }
+
+  func testRealtimeRemoteConfigRealConsole() {
+    guard APITests.useFakeConfig == false else { return }
+
+    let expectation = self.expectation(description: #function)
+
+    let registration = config.addOnConfigUpdateListener { RemoteConfigUpdate, Error in
+      XCTAssertNil(Error, "Realtime error \(Error!)")
+      XCTAssertNotNil(RemoteConfigUpdate)
+      XCTAssertNotNil(RemoteConfigUpdate?.updatedKeys.contains(Constants.jedi))
+      expectation.fulfill()
+    }
+
+    console.updateRemoteConfigValue(Constants.yoda, forKey: Constants.jedi)
+
+    waitForExpectations()
+    registration.remove()
+  }
+
   // MARK: - RemoteConfigConsole Tests
 
   func testFetchConfigThenUpdateConsoleThenFetchAgain() {