Selaa lähdekoodia

[Rollouts] Feature rollouts merge to main (#12410)

themiswang 2 vuotta sitten
vanhempi
sitoutus
90c33e404f
73 muutettua tiedostoa jossa 3281 lisäystä ja 177 poistoa
  1. 1 0
      CoreOnly/Tests/FirebasePodTest/Podfile
  2. 1 0
      Crashlytics/CHANGELOG.md
  3. 3 1
      Crashlytics/Crashlytics/Components/FIRCLSUserLogging.h
  4. 13 3
      Crashlytics/Crashlytics/Components/FIRCLSUserLogging.m
  5. 30 0
      Crashlytics/Crashlytics/Controllers/FIRCLSRolloutsPersistenceManager.h
  6. 67 0
      Crashlytics/Crashlytics/Controllers/FIRCLSRolloutsPersistenceManager.m
  7. 45 6
      Crashlytics/Crashlytics/FIRCrashlytics.m
  8. 3 2
      Crashlytics/Crashlytics/Handlers/FIRCLSException.h
  9. 26 15
      Crashlytics/Crashlytics/Handlers/FIRCLSException.mm
  10. 1 0
      Crashlytics/Crashlytics/Models/FIRCLSInternalReport.h
  11. 1 0
      Crashlytics/Crashlytics/Models/FIRCLSInternalReport.m
  12. 141 0
      Crashlytics/Crashlytics/Rollouts/CrashlyticsRemoteConfigManager.swift
  13. 44 0
      Crashlytics/Crashlytics/Rollouts/EncodedRolloutAssignment.swift
  14. 38 0
      Crashlytics/Crashlytics/Rollouts/StringToHexConverter.swift
  15. 31 0
      Crashlytics/UnitTests/FIRCLSFileTests.m
  16. 5 5
      Crashlytics/UnitTests/FIRCLSLoggingTests.m
  17. 70 0
      Crashlytics/UnitTests/FIRCLSRolloutsPersistenceManagerTests.m
  18. 1 1
      Crashlytics/UnitTests/FIRRecordExceptionModelTests.m
  19. 136 0
      Crashlytics/UnitTestsSwift/CrashlyticsRemoteConfigManagerTests.swift
  20. 1 0
      Example/watchOSSample/Podfile
  21. 4 2
      FirebaseCrashlytics.podspec
  22. 3 1
      FirebaseRemoteConfig.podspec
  23. 3 0
      FirebaseRemoteConfig/CHANGELOG.md
  24. 21 0
      FirebaseRemoteConfig/Interop/RemoteConfigConstants.swift
  25. 21 0
      FirebaseRemoteConfig/Interop/RemoteConfigInterop.swift
  26. 47 0
      FirebaseRemoteConfig/Interop/RolloutAssignment.swift
  27. 20 0
      FirebaseRemoteConfig/Interop/RolloutsStateSubscriber.swift
  28. 94 12
      FirebaseRemoteConfig/Sources/FIRRemoteConfig.m
  29. 7 1
      FirebaseRemoteConfig/Sources/FIRRemoteConfigComponent.h
  30. 47 2
      FirebaseRemoteConfig/Sources/FIRRemoteConfigComponent.m
  31. 4 0
      FirebaseRemoteConfig/Sources/Private/FIRRemoteConfig_Private.h
  32. 7 2
      FirebaseRemoteConfig/Sources/Private/RCNConfigSettings.h
  33. 11 1
      FirebaseRemoteConfig/Sources/RCNConfigConstants.h
  34. 5 0
      FirebaseRemoteConfig/Sources/RCNConfigContent.h
  35. 96 16
      FirebaseRemoteConfig/Sources/RCNConfigContent.m
  36. 12 2
      FirebaseRemoteConfig/Sources/RCNConfigDBManager.h
  37. 111 11
      FirebaseRemoteConfig/Sources/RCNConfigDBManager.m
  38. 2 0
      FirebaseRemoteConfig/Sources/RCNConfigDefines.h
  39. 3 2
      FirebaseRemoteConfig/Sources/RCNConfigFetch.m
  40. 9 2
      FirebaseRemoteConfig/Sources/RCNConfigSettings.m
  41. 1 0
      FirebaseRemoteConfig/Sources/RCNConstants3P.m
  42. 3 1
      FirebaseRemoteConfig/Sources/RCNUserDefaultsManager.h
  43. 17 2
      FirebaseRemoteConfig/Sources/RCNUserDefaultsManager.m
  44. 1059 0
      FirebaseRemoteConfig/Tests/FeatureRolloutsTestApp/FeatureRolloutsTestApp.xcodeproj/project.pbxproj
  45. 29 0
      FirebaseRemoteConfig/Tests/FeatureRolloutsTestApp/FeatureRolloutsTestApp/ContentView.swift
  46. 10 0
      FirebaseRemoteConfig/Tests/FeatureRolloutsTestApp/FeatureRolloutsTestApp/FeatureRolloutsTestApp.entitlements
  47. 27 0
      FirebaseRemoteConfig/Tests/FeatureRolloutsTestApp/FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS/ContentView.swift
  48. 26 0
      FirebaseRemoteConfig/Tests/FeatureRolloutsTestApp/FeatureRolloutsTestApp_Crashlytics_iOS/ContentView.swift
  49. 25 0
      FirebaseRemoteConfig/Tests/FeatureRolloutsTestApp/FeatureRolloutsTestApp_RemoteConfig_iOS/ContentView.swift
  50. 52 0
      FirebaseRemoteConfig/Tests/FeatureRolloutsTestApp/Podfile
  51. 62 0
      FirebaseRemoteConfig/Tests/FeatureRolloutsTestApp/Shared/CrashButtonView.swift
  52. 31 0
      FirebaseRemoteConfig/Tests/FeatureRolloutsTestApp/Shared/FeatureRolloutsTestAppApp.swift
  53. 51 0
      FirebaseRemoteConfig/Tests/FeatureRolloutsTestApp/Shared/RemoteConfigButtonView.swift
  54. 1 0
      FirebaseRemoteConfig/Tests/Sample/Podfile
  55. 6 3
      FirebaseRemoteConfig/Tests/Sample/RemoteConfigSampleApp/ViewController.m
  56. 65 0
      FirebaseRemoteConfig/Tests/SwiftUnit/RemoteConfigInteropTests.swift
  57. 42 1
      FirebaseRemoteConfig/Tests/Unit/FIRRemoteConfigComponentTest.m
  58. 184 25
      FirebaseRemoteConfig/Tests/Unit/RCNConfigContentTest.m
  59. 149 16
      FirebaseRemoteConfig/Tests/Unit/RCNConfigDBManagerTest.m
  60. 18 15
      FirebaseRemoteConfig/Tests/Unit/RCNConfigTest.m
  61. 3 1
      FirebaseRemoteConfig/Tests/Unit/RCNInstanceIDTest.m
  62. 60 5
      FirebaseRemoteConfig/Tests/Unit/RCNRemoteConfigTest.m
  63. 13 10
      FirebaseRemoteConfig/Tests/Unit/RCNThrottlingTests.m
  64. 21 6
      FirebaseRemoteConfig/Tests/Unit/RCNUserDefaultsManagerTests.m
  65. 58 0
      FirebaseRemoteConfig/generate_featureRolloutsTestApp.sh
  66. 34 0
      FirebaseRemoteConfigInterop.podspec
  67. 1 0
      FirebaseRemoteConfigSwift/Tests/SwiftAPI/FirebaseRemoteConfigSwift_APIBuildTests.swift
  68. 1 0
      FirebaseSessions/Tests/TestApp/Podfile
  69. 1 0
      IntegrationTesting/ClientApp/Podfile
  70. 1 0
      IntegrationTesting/CocoapodsIntegrationTest/TestEnvironments/Cocoapods_multiprojects_frameworks/Podfile
  71. 43 5
      Package.swift
  72. 1 0
      ReleaseTooling/Sources/FirebaseManifest/FirebaseManifest.swift
  73. 1 0
      scripts/localize_podfile.swift

+ 1 - 0
CoreOnly/Tests/FirebasePodTest/Podfile

@@ -33,6 +33,7 @@ target 'FirebasePodTest' do
   pod 'FirebaseAppCheckInterop', :path => '../../../'
   pod 'FirebaseAppCheckInterop', :path => '../../../'
   pod 'FirebaseAuthInterop', :path => '../../../'
   pod 'FirebaseAuthInterop', :path => '../../../'
   pod 'FirebaseMessagingInterop', :path => '../../../'
   pod 'FirebaseMessagingInterop', :path => '../../../'
+  pod 'FirebaseRemoteConfigInterop', :path => '../../../'
   pod 'FirebaseCoreInternal', :path => '../../../'
   pod 'FirebaseCoreInternal', :path => '../../../'
   pod 'FirebaseCoreExtension', :path => '../../../'
   pod 'FirebaseCoreExtension', :path => '../../../'
   pod 'FirebaseSessions', :path => '../../../'
   pod 'FirebaseSessions', :path => '../../../'

+ 1 - 0
Crashlytics/CHANGELOG.md

@@ -1,5 +1,6 @@
 # Unreleased
 # Unreleased
 - [added] Updated upload-symbols to 13.7 with VisionPro build phase support. (#12306)
 - [added] Updated upload-symbols to 13.7 with VisionPro build phase support. (#12306)
+- [changed] Added support for Crashlytics to report metadata about Remote Config keys and values.
 
 
 # 10.22.0
 # 10.22.0
 - [fixed] Force validation or rotation of FIDs for FirebaseSessions.
 - [fixed] Force validation or rotation of FIDs for FirebaseSessions.

+ 3 - 1
Crashlytics/Crashlytics/Components/FIRCLSUserLogging.h

@@ -81,7 +81,9 @@ void FIRCLSUserLoggingRecordUserKeysAndValues(NSDictionary* keysAndValues);
 void FIRCLSUserLoggingRecordInternalKeyValue(NSString* key, id value);
 void FIRCLSUserLoggingRecordInternalKeyValue(NSString* key, id value);
 void FIRCLSUserLoggingWriteInternalKeyValue(NSString* key, NSString* value);
 void FIRCLSUserLoggingWriteInternalKeyValue(NSString* key, NSString* value);
 
 
-void FIRCLSUserLoggingRecordError(NSError* error, NSDictionary<NSString*, id>* additionalUserInfo);
+void FIRCLSUserLoggingRecordError(NSError* error,
+                                  NSDictionary<NSString*, id>* additionalUserInfo,
+                                  NSString* rolloutsInfoJSON);
 
 
 NSDictionary* FIRCLSUserLoggingGetCompactedKVEntries(FIRCLSUserLoggingKVStorage* storage,
 NSDictionary* FIRCLSUserLoggingGetCompactedKVEntries(FIRCLSUserLoggingKVStorage* storage,
                                                      bool decodeHex);
                                                      bool decodeHex);

+ 13 - 3
Crashlytics/Crashlytics/Components/FIRCLSUserLogging.m

@@ -355,7 +355,8 @@ static void FIRCLSUserLoggingWriteError(FIRCLSFile *file,
                                         NSError *error,
                                         NSError *error,
                                         NSDictionary<NSString *, id> *additionalUserInfo,
                                         NSDictionary<NSString *, id> *additionalUserInfo,
                                         NSArray *addresses,
                                         NSArray *addresses,
-                                        uint64_t timestamp) {
+                                        uint64_t timestamp,
+                                        NSString *rolloutsInfoJSON) {
   FIRCLSFileWriteSectionStart(file, "error");
   FIRCLSFileWriteSectionStart(file, "error");
   FIRCLSFileWriteHashStart(file);
   FIRCLSFileWriteHashStart(file);
   FIRCLSFileWriteHashEntryHexEncodedString(file, "domain", [[error domain] UTF8String]);
   FIRCLSFileWriteHashEntryHexEncodedString(file, "domain", [[error domain] UTF8String]);
@@ -374,12 +375,20 @@ static void FIRCLSUserLoggingWriteError(FIRCLSFile *file,
   FIRCLSUserLoggingRecordErrorUserInfo(file, "info", [error userInfo]);
   FIRCLSUserLoggingRecordErrorUserInfo(file, "info", [error userInfo]);
   FIRCLSUserLoggingRecordErrorUserInfo(file, "extra_info", additionalUserInfo);
   FIRCLSUserLoggingRecordErrorUserInfo(file, "extra_info", additionalUserInfo);
 
 
+  // rollouts
+  if (rolloutsInfoJSON) {
+    FIRCLSFileWriteHashKey(file, "rollouts");
+    FIRCLSFileWriteStringUnquoted(file, [rolloutsInfoJSON UTF8String]);
+    FIRCLSFileWriteHashEnd(file);
+  }
+
   FIRCLSFileWriteHashEnd(file);
   FIRCLSFileWriteHashEnd(file);
   FIRCLSFileWriteSectionEnd(file);
   FIRCLSFileWriteSectionEnd(file);
 }
 }
 
 
 void FIRCLSUserLoggingRecordError(NSError *error,
 void FIRCLSUserLoggingRecordError(NSError *error,
-                                  NSDictionary<NSString *, id> *additionalUserInfo) {
+                                  NSDictionary<NSString *, id> *additionalUserInfo,
+                                  NSString *rolloutsInfoJSON) {
   if (!error) {
   if (!error) {
     return;
     return;
   }
   }
@@ -396,7 +405,8 @@ void FIRCLSUserLoggingRecordError(NSError *error,
   FIRCLSUserLoggingWriteAndCheckABFiles(
   FIRCLSUserLoggingWriteAndCheckABFiles(
       &_firclsContext.readonly->logging.errorStorage,
       &_firclsContext.readonly->logging.errorStorage,
       &_firclsContext.writable->logging.activeErrorLogPath, ^(FIRCLSFile *file) {
       &_firclsContext.writable->logging.activeErrorLogPath, ^(FIRCLSFile *file) {
-        FIRCLSUserLoggingWriteError(file, error, additionalUserInfo, addresses, timestamp);
+        FIRCLSUserLoggingWriteError(file, error, additionalUserInfo, addresses, timestamp,
+                                    rolloutsInfoJSON);
       });
       });
 }
 }
 
 

+ 30 - 0
Crashlytics/Crashlytics/Controllers/FIRCLSRolloutsPersistenceManager.h

@@ -0,0 +1,30 @@
+// Copyright 2024 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#if SWIFT_PACKAGE
+@import FirebaseCrashlyticsSwift;
+#else  // Swift Package Manager
+#import <FirebaseCrashlytics/FirebaseCrashlytics-Swift.h>
+#endif  // CocoaPods
+
+@interface FIRCLSRolloutsPersistenceManager : NSObject <FIRCLSPersistenceLog>
+
+- (instancetype _Nullable)initWithFileManager:(FIRCLSFileManager *_Nonnull)fileManager;
+- (instancetype _Nonnull)init NS_UNAVAILABLE;
++ (instancetype _Nonnull)new NS_UNAVAILABLE;
+
+- (void)updateRolloutsStateToPersistenceWithRollouts:(NSData *_Nonnull)rollouts
+                                            reportID:(NSString *_Nonnull)reportID;
+- (void)debugLogWithMessage:(NSString *_Nonnull)message;
+@end

+ 67 - 0
Crashlytics/Crashlytics/Controllers/FIRCLSRolloutsPersistenceManager.m

@@ -0,0 +1,67 @@
+// Copyright 2024 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#import <Foundation/Foundation.h>
+#include "Crashlytics/Crashlytics/Components/FIRCLSGlobals.h"
+#include "Crashlytics/Crashlytics/Components/FIRCLSUserLogging.h"
+#import "Crashlytics/Crashlytics/Helpers/FIRCLSLogger.h"
+#import "Crashlytics/Crashlytics/Models/FIRCLSFileManager.h"
+#import "Crashlytics/Crashlytics/Models/FIRCLSInternalReport.h"
+
+#if SWIFT_PACKAGE
+@import FirebaseCrashlyticsSwift;
+#else  // Swift Package Manager
+#import <FirebaseCrashlytics/FirebaseCrashlytics-Swift.h>
+#endif  // CocoaPods
+
+@interface FIRCLSRolloutsPersistenceManager : NSObject <FIRCLSPersistenceLog>
+@property(nonatomic, readonly) FIRCLSFileManager *fileManager;
+@end
+
+@implementation FIRCLSRolloutsPersistenceManager
+- (instancetype)initWithFileManager:(FIRCLSFileManager *)fileManager {
+  self = [super init];
+  if (!self) {
+    return nil;
+  }
+  _fileManager = fileManager;
+  return self;
+}
+
+- (void)updateRolloutsStateToPersistenceWithRollouts:(NSData *_Nonnull)rollouts
+                                            reportID:(NSString *_Nonnull)reportID {
+  NSString *rolloutsPath = [[[_fileManager activePath] stringByAppendingPathComponent:reportID]
+      stringByAppendingPathComponent:FIRCLSReportRolloutsFile];
+  if (![_fileManager fileExistsAtPath:rolloutsPath]) {
+    if (![_fileManager createFileAtPath:rolloutsPath contents:nil attributes:nil]) {
+      FIRCLSDebugLog(@"Could not create rollouts.clsrecord file. Error was code: %d - message: %s",
+                     errno, strerror(errno));
+    }
+  }
+
+  NSFileHandle *rolloutsFile = [NSFileHandle fileHandleForUpdatingAtPath:rolloutsPath];
+
+  dispatch_sync(FIRCLSGetLoggingQueue(), ^{
+    [rolloutsFile seekToEndOfFile];
+    [rolloutsFile writeData:rollouts];
+    NSData *newLineData = [@"\n" dataUsingEncoding:NSUTF8StringEncoding];
+    [rolloutsFile writeData:newLineData];
+  });
+}
+
+- (void)debugLogWithMessage:(NSString *_Nonnull)message {
+  FIRCLSDebugLog(message);
+}
+
+@end

+ 45 - 6
Crashlytics/Crashlytics/FIRCrashlytics.m

@@ -31,6 +31,7 @@
 #import "Crashlytics/Crashlytics/Helpers/FIRCLSDefines.h"
 #import "Crashlytics/Crashlytics/Helpers/FIRCLSDefines.h"
 #include "Crashlytics/Crashlytics/Helpers/FIRCLSProfiling.h"
 #include "Crashlytics/Crashlytics/Helpers/FIRCLSProfiling.h"
 #include "Crashlytics/Crashlytics/Helpers/FIRCLSUtility.h"
 #include "Crashlytics/Crashlytics/Helpers/FIRCLSUtility.h"
+#import "Crashlytics/Crashlytics/Models/FIRCLSExecutionIdentifierModel.h"
 #import "Crashlytics/Crashlytics/Models/FIRCLSFileManager.h"
 #import "Crashlytics/Crashlytics/Models/FIRCLSFileManager.h"
 #import "Crashlytics/Crashlytics/Models/FIRCLSSettings.h"
 #import "Crashlytics/Crashlytics/Models/FIRCLSSettings.h"
 #import "Crashlytics/Crashlytics/Settings/Models/FIRCLSApplicationIdentifierModel.h"
 #import "Crashlytics/Crashlytics/Settings/Models/FIRCLSApplicationIdentifierModel.h"
@@ -47,6 +48,7 @@
 #import "Crashlytics/Crashlytics/Controllers/FIRCLSNotificationManager.h"
 #import "Crashlytics/Crashlytics/Controllers/FIRCLSNotificationManager.h"
 #import "Crashlytics/Crashlytics/Controllers/FIRCLSReportManager.h"
 #import "Crashlytics/Crashlytics/Controllers/FIRCLSReportManager.h"
 #import "Crashlytics/Crashlytics/Controllers/FIRCLSReportUploader.h"
 #import "Crashlytics/Crashlytics/Controllers/FIRCLSReportUploader.h"
+#import "Crashlytics/Crashlytics/Controllers/FIRCLSRolloutsPersistenceManager.h"
 #import "Crashlytics/Crashlytics/Private/FIRCLSExistingReportManager_Private.h"
 #import "Crashlytics/Crashlytics/Private/FIRCLSExistingReportManager_Private.h"
 #import "Crashlytics/Crashlytics/Private/FIRCLSOnDemandModel_Private.h"
 #import "Crashlytics/Crashlytics/Private/FIRCLSOnDemandModel_Private.h"
 #import "Crashlytics/Crashlytics/Private/FIRExceptionModel_Private.h"
 #import "Crashlytics/Crashlytics/Private/FIRExceptionModel_Private.h"
@@ -58,6 +60,12 @@
 #import <GoogleDataTransport/GoogleDataTransport.h>
 #import <GoogleDataTransport/GoogleDataTransport.h>
 
 
 @import FirebaseSessions;
 @import FirebaseSessions;
+@import FirebaseRemoteConfigInterop;
+#if SWIFT_PACKAGE
+@import FirebaseCrashlyticsSwift;
+#else  // Swift Package Manager
+#import <FirebaseCrashlytics/FirebaseCrashlytics-Swift.h>
+#endif  // CocoaPods
 
 
 #if TARGET_OS_IPHONE
 #if TARGET_OS_IPHONE
 #import <UIKit/UIKit.h>
 #import <UIKit/UIKit.h>
@@ -76,7 +84,10 @@ NSString *const FIRCLSGoogleTransportMappingID = @"1206";
 @protocol FIRCrashlyticsInstanceProvider <NSObject>
 @protocol FIRCrashlyticsInstanceProvider <NSObject>
 @end
 @end
 
 
-@interface FIRCrashlytics () <FIRLibrary, FIRCrashlyticsInstanceProvider, FIRSessionsSubscriber>
+@interface FIRCrashlytics () <FIRLibrary,
+                              FIRCrashlyticsInstanceProvider,
+                              FIRSessionsSubscriber,
+                              FIRRolloutsStateSubscriber>
 
 
 @property(nonatomic) BOOL didPreviouslyCrash;
 @property(nonatomic) BOOL didPreviouslyCrash;
 @property(nonatomic, copy) NSString *googleAppID;
 @property(nonatomic, copy) NSString *googleAppID;
@@ -91,6 +102,8 @@ NSString *const FIRCLSGoogleTransportMappingID = @"1206";
 
 
 @property(nonatomic, strong) FIRCLSAnalyticsManager *analyticsManager;
 @property(nonatomic, strong) FIRCLSAnalyticsManager *analyticsManager;
 
 
+@property(nonatomic, strong) FIRCLSRemoteConfigManager *remoteConfigManager;
+
 // Dependencies common to each of the Controllers
 // Dependencies common to each of the Controllers
 @property(nonatomic, strong) FIRCLSManagerData *managerData;
 @property(nonatomic, strong) FIRCLSManagerData *managerData;
 
 
@@ -104,7 +117,8 @@ NSString *const FIRCLSGoogleTransportMappingID = @"1206";
                     appInfo:(NSDictionary *)appInfo
                     appInfo:(NSDictionary *)appInfo
               installations:(FIRInstallations *)installations
               installations:(FIRInstallations *)installations
                   analytics:(id<FIRAnalyticsInterop>)analytics
                   analytics:(id<FIRAnalyticsInterop>)analytics
-                   sessions:(id<FIRSessionsProvider>)sessions {
+                   sessions:(id<FIRSessionsProvider>)sessions
+               remoteConfig:(id<FIRRemoteConfigInterop>)remoteConfig {
   self = [super init];
   self = [super init];
 
 
   if (self) {
   if (self) {
@@ -189,8 +203,19 @@ NSString *const FIRCLSGoogleTransportMappingID = @"1206";
     }] catch:^void(NSError *error) {
     }] catch:^void(NSError *error) {
       FIRCLSErrorLog(@"Crash reporting failed to initialize with error: %@", error);
       FIRCLSErrorLog(@"Crash reporting failed to initialize with error: %@", error);
     }];
     }];
-  }
 
 
+    // RemoteConfig subscription should be made after session report directory created.
+    if (remoteConfig) {
+      FIRCLSDebugLog(@"Registering RemoteConfig SDK subscription for rollouts data");
+
+      FIRCLSRolloutsPersistenceManager *persistenceManager =
+          [[FIRCLSRolloutsPersistenceManager alloc] initWithFileManager:_fileManager];
+      _remoteConfigManager =
+          [[FIRCLSRemoteConfigManager alloc] initWithRemoteConfig:remoteConfig
+                                              persistenceDelegate:persistenceManager];
+          [remoteConfig registerRolloutsStateSubscriber:self for:FIRRemoteConfigConstants.FIRNamespaceGoogleMobilePlatform];
+    }
+  }
   return self;
   return self;
 }
 }
 
 
@@ -215,6 +240,7 @@ NSString *const FIRCLSGoogleTransportMappingID = @"1206";
 
 
     id<FIRAnalyticsInterop> analytics = FIR_COMPONENT(FIRAnalyticsInterop, container);
     id<FIRAnalyticsInterop> analytics = FIR_COMPONENT(FIRAnalyticsInterop, container);
     id<FIRSessionsProvider> sessions = FIR_COMPONENT(FIRSessionsProvider, container);
     id<FIRSessionsProvider> sessions = FIR_COMPONENT(FIRSessionsProvider, container);
+    id<FIRRemoteConfigInterop> remoteConfig = FIR_COMPONENT(FIRRemoteConfigInterop, container);
 
 
     FIRInstallations *installations = [FIRInstallations installationsWithApp:container.app];
     FIRInstallations *installations = [FIRInstallations installationsWithApp:container.app];
 
 
@@ -224,7 +250,8 @@ NSString *const FIRCLSGoogleTransportMappingID = @"1206";
                                        appInfo:NSBundle.mainBundle.infoDictionary
                                        appInfo:NSBundle.mainBundle.infoDictionary
                                  installations:installations
                                  installations:installations
                                      analytics:analytics
                                      analytics:analytics
-                                      sessions:sessions];
+                                      sessions:sessions
+                                  remoteConfig:remoteConfig];
   };
   };
 
 
   FIRComponent *component =
   FIRComponent *component =
@@ -377,11 +404,13 @@ NSString *const FIRCLSGoogleTransportMappingID = @"1206";
 }
 }
 
 
 - (void)recordError:(NSError *)error userInfo:(NSDictionary<NSString *, id> *)userInfo {
 - (void)recordError:(NSError *)error userInfo:(NSDictionary<NSString *, id> *)userInfo {
-  FIRCLSUserLoggingRecordError(error, userInfo);
+  NSString *rolloutsInfoJSON = [_remoteConfigManager getRolloutAssignmentsEncodedJsonString];
+  FIRCLSUserLoggingRecordError(error, userInfo, rolloutsInfoJSON);
 }
 }
 
 
 - (void)recordExceptionModel:(FIRExceptionModel *)exceptionModel {
 - (void)recordExceptionModel:(FIRExceptionModel *)exceptionModel {
-  FIRCLSExceptionRecordModel(exceptionModel);
+  NSString *rolloutsInfoJSON = [_remoteConfigManager getRolloutAssignmentsEncodedJsonString];
+  FIRCLSExceptionRecordModel(exceptionModel, rolloutsInfoJSON);
 }
 }
 
 
 - (void)recordOnDemandExceptionModel:(FIRExceptionModel *)exceptionModel {
 - (void)recordOnDemandExceptionModel:(FIRExceptionModel *)exceptionModel {
@@ -407,4 +436,14 @@ NSString *const FIRCLSGoogleTransportMappingID = @"1206";
   return FIRSessionsSubscriberNameCrashlytics;
   return FIRSessionsSubscriberNameCrashlytics;
 }
 }
 
 
+#pragma mark - FIRRolloutsStateSubscriber
+- (void)rolloutsStateDidChange:(FIRRolloutsState *_Nonnull)rolloutsState {
+  if (!_remoteConfigManager) {
+    FIRCLSDebugLog(@"rolloutsStateDidChange gets called without init the rc manager.");
+    return;
+  }
+  NSString *currentReportID = _managerData.executionIDModel.executionID;
+  [_remoteConfigManager updateRolloutsStateWithRolloutsState:rolloutsState
+                                                    reportID:currentReportID];
+}
 @end
 @end

+ 3 - 2
Crashlytics/Crashlytics/Handlers/FIRCLSException.h

@@ -60,7 +60,7 @@ void FIRCLSExceptionRaiseTestObjCException(void) __attribute((noreturn));
 void FIRCLSExceptionRaiseTestCppException(void) __attribute((noreturn));
 void FIRCLSExceptionRaiseTestCppException(void) __attribute((noreturn));
 
 
 #ifdef __OBJC__
 #ifdef __OBJC__
-void FIRCLSExceptionRecordModel(FIRExceptionModel* exceptionModel);
+void FIRCLSExceptionRecordModel(FIRExceptionModel* exceptionModel, NSString* rolloutsInfoJSON);
 NSString* FIRCLSExceptionRecordOnDemandModel(FIRExceptionModel* exceptionModel,
 NSString* FIRCLSExceptionRecordOnDemandModel(FIRExceptionModel* exceptionModel,
                                              int previousRecordedOnDemandExceptions,
                                              int previousRecordedOnDemandExceptions,
                                              int previousDroppedOnDemandExceptions);
                                              int previousDroppedOnDemandExceptions);
@@ -68,7 +68,8 @@ void FIRCLSExceptionRecordNSException(NSException* exception);
 void FIRCLSExceptionRecord(FIRCLSExceptionType type,
 void FIRCLSExceptionRecord(FIRCLSExceptionType type,
                            const char* name,
                            const char* name,
                            const char* reason,
                            const char* reason,
-                           NSArray<FIRStackFrame*>* frames);
+                           NSArray<FIRStackFrame*>* frames,
+                           NSString* rolloutsInfoJSON);
 NSString* FIRCLSExceptionRecordOnDemand(FIRCLSExceptionType type,
 NSString* FIRCLSExceptionRecordOnDemand(FIRCLSExceptionType type,
                                         const char* name,
                                         const char* name,
                                         const char* reason,
                                         const char* reason,

+ 26 - 15
Crashlytics/Crashlytics/Handlers/FIRCLSException.mm

@@ -82,11 +82,11 @@ void FIRCLSExceptionInitialize(FIRCLSExceptionReadOnlyContext *roContext,
   rwContext->customExceptionCount = 0;
   rwContext->customExceptionCount = 0;
 }
 }
 
 
-void FIRCLSExceptionRecordModel(FIRExceptionModel *exceptionModel) {
+void FIRCLSExceptionRecordModel(FIRExceptionModel *exceptionModel, NSString *rolloutsInfoJSON) {
   const char *name = [[exceptionModel.name copy] UTF8String];
   const char *name = [[exceptionModel.name copy] UTF8String];
   const char *reason = [[exceptionModel.reason copy] UTF8String] ?: "";
   const char *reason = [[exceptionModel.reason copy] UTF8String] ?: "";
-
-  FIRCLSExceptionRecord(FIRCLSExceptionTypeCustom, name, reason, [exceptionModel.stackTrace copy]);
+  FIRCLSExceptionRecord(FIRCLSExceptionTypeCustom, name, reason, [exceptionModel.stackTrace copy],
+                        rolloutsInfoJSON);
 }
 }
 
 
 NSString *FIRCLSExceptionRecordOnDemandModel(FIRExceptionModel *exceptionModel,
 NSString *FIRCLSExceptionRecordOnDemandModel(FIRExceptionModel *exceptionModel,
@@ -122,7 +122,7 @@ void FIRCLSExceptionRecordNSException(NSException *exception) {
   }
   }
 
 
   FIRCLSExceptionRecord(FIRCLSExceptionTypeObjectiveC, [name UTF8String], [reason UTF8String],
   FIRCLSExceptionRecord(FIRCLSExceptionTypeObjectiveC, [name UTF8String], [reason UTF8String],
-                        frames);
+                        frames, nil);
 }
 }
 
 
 static void FIRCLSExceptionRecordFrame(FIRCLSFile *file, FIRStackFrame *frame) {
 static void FIRCLSExceptionRecordFrame(FIRCLSFile *file, FIRStackFrame *frame) {
@@ -175,7 +175,8 @@ void FIRCLSExceptionWrite(FIRCLSFile *file,
                           FIRCLSExceptionType type,
                           FIRCLSExceptionType type,
                           const char *name,
                           const char *name,
                           const char *reason,
                           const char *reason,
-                          NSArray<FIRStackFrame *> *frames) {
+                          NSArray<FIRStackFrame *> *frames,
+                          NSString *rolloutsInfoJSON) {
   FIRCLSFileWriteSectionStart(file, "exception");
   FIRCLSFileWriteSectionStart(file, "exception");
 
 
   FIRCLSFileWriteHashStart(file);
   FIRCLSFileWriteHashStart(file);
@@ -196,6 +197,12 @@ void FIRCLSExceptionWrite(FIRCLSFile *file,
     FIRCLSFileWriteArrayEnd(file);
     FIRCLSFileWriteArrayEnd(file);
   }
   }
 
 
+  if (rolloutsInfoJSON) {
+    FIRCLSFileWriteHashKey(file, "rollouts");
+    FIRCLSFileWriteStringUnquoted(file, [rolloutsInfoJSON UTF8String]);
+    FIRCLSFileWriteHashEnd(file);
+  }
+
   FIRCLSFileWriteHashEnd(file);
   FIRCLSFileWriteHashEnd(file);
 
 
   FIRCLSFileWriteSectionEnd(file);
   FIRCLSFileWriteSectionEnd(file);
@@ -204,7 +211,8 @@ void FIRCLSExceptionWrite(FIRCLSFile *file,
 void FIRCLSExceptionRecord(FIRCLSExceptionType type,
 void FIRCLSExceptionRecord(FIRCLSExceptionType type,
                            const char *name,
                            const char *name,
                            const char *reason,
                            const char *reason,
-                           NSArray<FIRStackFrame *> *frames) {
+                           NSArray<FIRStackFrame *> *frames,
+                           NSString *rolloutsInfoJSON) {
   if (!FIRCLSContextIsInitialized()) {
   if (!FIRCLSContextIsInitialized()) {
     return;
     return;
   }
   }
@@ -224,7 +232,7 @@ void FIRCLSExceptionRecord(FIRCLSExceptionType type,
         return;
         return;
       }
       }
 
 
-      FIRCLSExceptionWrite(&file, type, name, reason, frames);
+      FIRCLSExceptionWrite(&file, type, name, reason, frames, nil);
 
 
       // We only want to do this work if we have the expectation that we'll actually crash
       // We only want to do this work if we have the expectation that we'll actually crash
       FIRCLSHandler(&file, mach_thread_self(), NULL);
       FIRCLSHandler(&file, mach_thread_self(), NULL);
@@ -235,7 +243,7 @@ void FIRCLSExceptionRecord(FIRCLSExceptionType type,
     FIRCLSUserLoggingWriteAndCheckABFiles(
     FIRCLSUserLoggingWriteAndCheckABFiles(
         &_firclsContext.readonly->logging.customExceptionStorage,
         &_firclsContext.readonly->logging.customExceptionStorage,
         &_firclsContext.writable->logging.activeCustomExceptionPath, ^(FIRCLSFile *file) {
         &_firclsContext.writable->logging.activeCustomExceptionPath, ^(FIRCLSFile *file) {
-          FIRCLSExceptionWrite(file, type, name, reason, frames);
+          FIRCLSExceptionWrite(file, type, name, reason, frames, rolloutsInfoJSON);
         });
         });
   }
   }
 
 
@@ -271,6 +279,7 @@ NSString *FIRCLSExceptionRecordOnDemand(FIRCLSExceptionType type,
 
 
   // Create new report and copy into it the current state of custom keys and log and the sdk.log,
   // Create new report and copy into it the current state of custom keys and log and the sdk.log,
   // binary_images.clsrecord, and metadata.clsrecord files.
   // binary_images.clsrecord, and metadata.clsrecord files.
+  // Also copy rollouts.clsrecord if applicable.
   NSError *error = nil;
   NSError *error = nil;
   BOOL copied = [fileManager.underlyingFileManager copyItemAtPath:currentReportPath
   BOOL copied = [fileManager.underlyingFileManager copyItemAtPath:currentReportPath
                                                            toPath:newReportPath
                                                            toPath:newReportPath
@@ -343,7 +352,7 @@ NSString *FIRCLSExceptionRecordOnDemand(FIRCLSExceptionType type,
     FIRCLSSDKLog("Unable to open log file for on demand custom exception\n");
     FIRCLSSDKLog("Unable to open log file for on demand custom exception\n");
     return nil;
     return nil;
   }
   }
-  FIRCLSExceptionWrite(&file, type, name, reason, frames);
+  FIRCLSExceptionWrite(&file, type, name, reason, frames, nil);
   FIRCLSHandler(&file, mach_thread_self(), NULL);
   FIRCLSHandler(&file, mach_thread_self(), NULL);
   FIRCLSFileClose(&file);
   FIRCLSFileClose(&file);
 
 
@@ -397,19 +406,21 @@ static void FIRCLSCatchAndRecordActiveException(std::type_info *typeInfo) {
 #endif
 #endif
     }
     }
   } catch (const char *exc) {
   } catch (const char *exc) {
-    FIRCLSExceptionRecord(FIRCLSExceptionTypeCpp, "const char *", exc, nil);
+    FIRCLSExceptionRecord(FIRCLSExceptionTypeCpp, "const char *", exc, nil, nil);
   } catch (const std::string &exc) {
   } catch (const std::string &exc) {
-    FIRCLSExceptionRecord(FIRCLSExceptionTypeCpp, "std::string", exc.c_str(), nil);
+    FIRCLSExceptionRecord(FIRCLSExceptionTypeCpp, "std::string", exc.c_str(), nil, nil);
   } catch (const std::exception &exc) {
   } catch (const std::exception &exc) {
-    FIRCLSExceptionRecord(FIRCLSExceptionTypeCpp, FIRCLSExceptionDemangle(name), exc.what(), nil);
+    FIRCLSExceptionRecord(FIRCLSExceptionTypeCpp, FIRCLSExceptionDemangle(name), exc.what(), nil,
+                          nil);
   } catch (const std::exception *exc) {
   } catch (const std::exception *exc) {
-    FIRCLSExceptionRecord(FIRCLSExceptionTypeCpp, FIRCLSExceptionDemangle(name), exc->what(), nil);
+    FIRCLSExceptionRecord(FIRCLSExceptionTypeCpp, FIRCLSExceptionDemangle(name), exc->what(), nil,
+                          nil);
   } catch (const std::bad_alloc &exc) {
   } catch (const std::bad_alloc &exc) {
     // it is especially important to avoid demangling in this case, because the expetation at this
     // it is especially important to avoid demangling in this case, because the expetation at this
     // point is that all allocations could fail
     // point is that all allocations could fail
-    FIRCLSExceptionRecord(FIRCLSExceptionTypeCpp, "std::bad_alloc", exc.what(), nil);
+    FIRCLSExceptionRecord(FIRCLSExceptionTypeCpp, "std::bad_alloc", exc.what(), nil, nil);
   } catch (...) {
   } catch (...) {
-    FIRCLSExceptionRecord(FIRCLSExceptionTypeCpp, FIRCLSExceptionDemangle(name), "", nil);
+    FIRCLSExceptionRecord(FIRCLSExceptionTypeCpp, FIRCLSExceptionDemangle(name), "", nil, nil);
   }
   }
 }
 }
 
 

+ 1 - 0
Crashlytics/Crashlytics/Models/FIRCLSInternalReport.h

@@ -36,6 +36,7 @@ extern NSString *const FIRCLSReportInternalIncrementalKVFile;
 extern NSString *const FIRCLSReportInternalCompactedKVFile;
 extern NSString *const FIRCLSReportInternalCompactedKVFile;
 extern NSString *const FIRCLSReportUserIncrementalKVFile;
 extern NSString *const FIRCLSReportUserIncrementalKVFile;
 extern NSString *const FIRCLSReportUserCompactedKVFile;
 extern NSString *const FIRCLSReportUserCompactedKVFile;
+extern NSString *const FIRCLSReportRolloutsFile;
 
 
 @class FIRCLSFileManager;
 @class FIRCLSFileManager;
 
 

+ 1 - 0
Crashlytics/Crashlytics/Models/FIRCLSInternalReport.m

@@ -41,6 +41,7 @@ NSString *const FIRCLSReportInternalIncrementalKVFile = @"internal_incremental_k
 NSString *const FIRCLSReportInternalCompactedKVFile = @"internal_compacted_kv.clsrecord";
 NSString *const FIRCLSReportInternalCompactedKVFile = @"internal_compacted_kv.clsrecord";
 NSString *const FIRCLSReportUserIncrementalKVFile = @"user_incremental_kv.clsrecord";
 NSString *const FIRCLSReportUserIncrementalKVFile = @"user_incremental_kv.clsrecord";
 NSString *const FIRCLSReportUserCompactedKVFile = @"user_compacted_kv.clsrecord";
 NSString *const FIRCLSReportUserCompactedKVFile = @"user_compacted_kv.clsrecord";
+NSString *const FIRCLSReportRolloutsFile = @"rollouts.clsrecord";
 
 
 @interface FIRCLSInternalReport () {
 @interface FIRCLSInternalReport () {
   NSString *_identifier;
   NSString *_identifier;

+ 141 - 0
Crashlytics/Crashlytics/Rollouts/CrashlyticsRemoteConfigManager.swift

@@ -0,0 +1,141 @@
+// 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 FirebaseRemoteConfigInterop
+import Foundation
+
+@objc(FIRCLSPersistenceLog)
+public protocol CrashlyticsPersistenceLog {
+  func updateRolloutsStateToPersistence(rollouts: Data, reportID: String)
+  func debugLog(message: String)
+}
+
+@objc(FIRCLSRemoteConfigManager)
+public class CrashlyticsRemoteConfigManager: NSObject {
+  public static let maxRolloutAssignments = 128
+  public static let maxParameterValueLength = 256
+
+  private let lock = NSLock()
+  private var _rolloutAssignment: [RolloutAssignment] = []
+
+  var remoteConfig: RemoteConfigInterop
+  var persistenceDelegate: CrashlyticsPersistenceLog
+
+  @objc public var rolloutAssignment: [RolloutAssignment] {
+    lock.lock()
+    defer { lock.unlock() }
+    let copy = _rolloutAssignment
+    return copy
+  }
+
+  @objc public init(remoteConfig: RemoteConfigInterop,
+                    persistenceDelegate: CrashlyticsPersistenceLog) {
+    self.remoteConfig = remoteConfig
+    self.persistenceDelegate = persistenceDelegate
+  }
+
+  @objc public func updateRolloutsState(rolloutsState: RolloutsState, reportID: String) {
+    lock.lock()
+    _rolloutAssignment = normalizeRolloutAssignment(assignments: Array(rolloutsState.assignments))
+    lock.unlock()
+
+    // Writring to persistence
+    if let rolloutsData =
+      getRolloutsStateEncodedJsonData() {
+      persistenceDelegate.updateRolloutsStateToPersistence(
+        rollouts: rolloutsData,
+        reportID: reportID
+      )
+    }
+  }
+
+  /// Return string format: [{RolloutAssignment1}, {RolloutAssignment2}, {RolloutAssignment3}...]
+  /// This will get inserted into each clsrcord for non-fatal events.
+  /// Return a string type because later `FIRCLSFileWriteStringUnquoted` takes string as input
+  @objc public func getRolloutAssignmentsEncodedJsonString() -> String? {
+    let encodeData = getRolloutAssignmentsEncodedJsonData()
+    if let data = encodeData {
+      return String(data: data, encoding: .utf8)
+    }
+
+    let debugInfo = encodeData?.debugDescription ?? "nil"
+    persistenceDelegate.debugLog(message: String(
+      format: "Failed to serialize rollouts: %@",
+      arguments: [debugInfo]
+    ))
+
+    return nil
+  }
+}
+
+private extension CrashlyticsRemoteConfigManager {
+  func normalizeRolloutAssignment(assignments: [RolloutAssignment]) -> [RolloutAssignment] {
+    var validatedAssignments = assignments
+    if assignments.count > CrashlyticsRemoteConfigManager.maxRolloutAssignments {
+      persistenceDelegate
+        .debugLog(
+          message: "Rollouts excess the maximum number of assignments can pass to Crashlytics"
+        )
+      validatedAssignments =
+        Array(assignments[..<CrashlyticsRemoteConfigManager.maxRolloutAssignments])
+    }
+
+    _ = validatedAssignments.map { assignment in
+      if assignment.parameterValue.count > CrashlyticsRemoteConfigManager.maxParameterValueLength {
+        debugPrint(
+          "Rollouts excess the maximum length of parameter value can pass to Crashlytics",
+          assignment.parameterValue
+        )
+        let upperBound = String.Index(
+          utf16Offset: CrashlyticsRemoteConfigManager.maxParameterValueLength,
+          in: assignment.parameterValue
+        )
+        let slicedParameterValue = assignment.parameterValue[..<upperBound]
+        assignment.parameterValue = String(slicedParameterValue)
+      }
+    }
+
+    return validatedAssignments
+  }
+
+  // Helper for later convert Data to String. Because `FIRCLSFileWriteStringUnquoted` takes string
+  // as input
+  func getRolloutAssignmentsEncodedJsonData() -> Data? {
+    let contentEncodedRolloutAssignments = rolloutAssignment.map { assignment in
+      EncodedRolloutAssignment(assignment: assignment)
+    }
+
+    let encoder = JSONEncoder()
+    encoder.keyEncodingStrategy = .convertToSnakeCase
+    encoder.outputFormatting = .sortedKeys
+    let encodeData = try? encoder.encode(contentEncodedRolloutAssignments)
+    return encodeData
+  }
+
+  /// Return string format: {"rollouts": [{RolloutAssignment1}, {RolloutAssignment2},
+  /// {RolloutAssignment3}...]}
+  /// This will get stored in the separate rollouts.clsrecord
+  /// Return a data  type because later `[NSFileHandler writeData:]` takes data as input
+  func getRolloutsStateEncodedJsonData() -> Data? {
+    let contentEncodedRolloutAssignments = rolloutAssignment.map { assignment in
+      EncodedRolloutAssignment(assignment: assignment)
+    }
+
+    let state = EncodedRolloutsState(assignments: contentEncodedRolloutAssignments)
+    let encoder = JSONEncoder()
+    encoder.keyEncodingStrategy = .convertToSnakeCase
+    let encodeData = try? encoder.encode(state)
+    return encodeData
+  }
+}

+ 44 - 0
Crashlytics/Crashlytics/Rollouts/EncodedRolloutAssignment.swift

@@ -0,0 +1,44 @@
+// Copyright 2024 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import FirebaseRemoteConfigInterop
+import Foundation
+
+@objc(FIRCLSEncodedRolloutsState)
+class EncodedRolloutsState: NSObject, Codable {
+  @objc public private(set) var rollouts: [EncodedRolloutAssignment]
+
+  @objc public init(assignments: [EncodedRolloutAssignment]) {
+    rollouts = assignments
+    super.init()
+  }
+}
+
+@objc(FIRCLSEncodedRolloutAssignment)
+class EncodedRolloutAssignment: NSObject, Codable {
+  @objc public private(set) var rolloutId: String
+  @objc public private(set) var variantId: String
+  @objc public private(set) var templateVersion: Int64
+  @objc public private(set) var parameterKey: String
+  @objc public private(set) var parameterValue: String
+
+  public init(assignment: RolloutAssignment) {
+    rolloutId = FileUtility.stringToHexConverter(for: assignment.rolloutId)
+    variantId = FileUtility.stringToHexConverter(for: assignment.variantId)
+    templateVersion = assignment.templateVersion
+    parameterKey = FileUtility.stringToHexConverter(for: assignment.parameterKey)
+    parameterValue = FileUtility.stringToHexConverter(for: assignment.parameterValue)
+    super.init()
+  }
+}

+ 38 - 0
Crashlytics/Crashlytics/Rollouts/StringToHexConverter.swift

@@ -0,0 +1,38 @@
+// Copyright 2024 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import Foundation
+
+// This is a swift rewrite for the logic in FIRCLSFile for the function FIRCLSFileHexEncodeString()
+@objc(FIRCLSwiftFileUtility)
+public class FileUtility: NSObject {
+  @objc public static func stringToHexConverter(for string: String) -> String {
+    let hexMap = "0123456789abcdef"
+
+    var processedString = ""
+    let utf8Array = string.utf8.map { UInt8($0) }
+    for c in utf8Array {
+      let index1 = String.Index(
+        utf16Offset: Int(c >> 4),
+        in: hexMap
+      )
+      let index2 = String.Index(
+        utf16Offset: Int(c & 0x0F),
+        in: hexMap
+      )
+      processedString = processedString + String(hexMap[index1]) + String(hexMap[index2])
+    }
+    return processedString
+  }
+}

+ 31 - 0
Crashlytics/UnitTests/FIRCLSFileTests.m

@@ -14,6 +14,12 @@
 
 
 #include "Crashlytics/Crashlytics/Helpers/FIRCLSFile.h"
 #include "Crashlytics/Crashlytics/Helpers/FIRCLSFile.h"
 
 
+#if SWIFT_PACKAGE
+@import FirebaseCrashlyticsSwift;
+#else  // Swift Package Manager
+#import <FirebaseCrashlytics/FirebaseCrashlytics-Swift.h>
+#endif  // CocoaPods
+
 #import <XCTest/XCTest.h>
 #import <XCTest/XCTest.h>
 
 
 @interface FIRCLSFileTests : XCTestCase
 @interface FIRCLSFileTests : XCTestCase
@@ -169,6 +175,31 @@
                         buffered ? @"" : @"un");
                         buffered ? @"" : @"un");
 }
 }
 
 
+// This is the test to compare FIRCLSwiftFileUtility.stringToHexConverter(for:) and
+// FIRCLSFileWriteHexEncodedString return the same hex encoding value
+- (void)testHexEncodingStringObjcAndSwiftResultsSame {
+  NSString *testedValueString = @"是themis的测试数据,输入中文";
+
+  FIRCLSFile *unbufferedFile = &_unbufferedFile;
+  FIRCLSFileWriteHashStart(unbufferedFile);
+  FIRCLSFileWriteHashEntryHexEncodedString(unbufferedFile, "hex", [testedValueString UTF8String]);
+  FIRCLSFileWriteHashEnd(unbufferedFile);
+  NSString *contentsFromObjcHexEncoding = [self contentsOfFileAtPath:self.unbufferedPath];
+
+  FIRCLSFile *bufferedFile = &_bufferedFile;
+  NSString *encodedValue = [FIRCLSwiftFileUtility stringToHexConverterFor:testedValueString];
+  FIRCLSFileWriteHashStart(bufferedFile);
+  FIRCLSFileWriteHashKey(bufferedFile, "hex");
+  FIRCLSFileWriteStringUnquoted(bufferedFile, "\"");
+  FIRCLSFileWriteStringUnquoted(bufferedFile, [encodedValue UTF8String]);
+  FIRCLSFileWriteStringUnquoted(bufferedFile, "\"");
+  FIRCLSFileWriteHashEnd(bufferedFile);
+  FIRCLSFileFlushWriteBuffer(bufferedFile);
+  NSString *contentsFromSwiftHexEncoding = [self contentsOfFileAtPath:self.bufferedPath];
+
+  XCTAssertTrue([contentsFromObjcHexEncoding isEqualToString:contentsFromSwiftHexEncoding]);
+}
+
 #pragma mark -
 #pragma mark -
 
 
 - (void)testHexEncodingLongString {
 - (void)testHexEncodingLongString {

+ 5 - 5
Crashlytics/UnitTests/FIRCLSLoggingTests.m

@@ -365,7 +365,7 @@
                                        code:-1
                                        code:-1
                                    userInfo:@{@"key1" : @"value", @"key2" : @"value2"}];
                                    userInfo:@{@"key1" : @"value", @"key2" : @"value2"}];
 
 
-  FIRCLSUserLoggingRecordError(error, @{@"additional" : @"key"});
+  FIRCLSUserLoggingRecordError(error, @{@"additional" : @"key"}, nil);
 
 
   NSArray* errors = [self errorAContents];
   NSArray* errors = [self errorAContents];
 
 
@@ -405,7 +405,7 @@
                                    userInfo:@{@"key1" : @"value", @"key2" : @"value2"}];
                                    userInfo:@{@"key1" : @"value", @"key2" : @"value2"}];
 
 
   for (size_t i = 0; i < _firclsContext.readonly->logging.errorStorage.maxEntries; ++i) {
   for (size_t i = 0; i < _firclsContext.readonly->logging.errorStorage.maxEntries; ++i) {
-    FIRCLSUserLoggingRecordError(error, nil);
+    FIRCLSUserLoggingRecordError(error, nil, nil);
   }
   }
 
 
   NSArray* errors = [self errorAContents];
   NSArray* errors = [self errorAContents];
@@ -414,7 +414,7 @@
 
 
   // at this point, if we log one more, we should expect a roll over to the next file
   // at this point, if we log one more, we should expect a roll over to the next file
 
 
-  FIRCLSUserLoggingRecordError(error, nil);
+  FIRCLSUserLoggingRecordError(error, nil, nil);
 
 
   XCTAssertEqual([[self errorAContents] count], 8, @"");
   XCTAssertEqual([[self errorAContents] count], 8, @"");
   XCTAssertEqual([[self errorBContents] count], 1, @"");
   XCTAssertEqual([[self errorBContents] count], 1, @"");
@@ -422,7 +422,7 @@
 
 
   // and our next entry should continue into the B file
   // and our next entry should continue into the B file
 
 
-  FIRCLSUserLoggingRecordError(error, nil);
+  FIRCLSUserLoggingRecordError(error, nil, nil);
 
 
   XCTAssertEqual([[self errorAContents] count], 8, @"");
   XCTAssertEqual([[self errorAContents] count], 8, @"");
   XCTAssertEqual([[self errorBContents] count], 2, @"");
   XCTAssertEqual([[self errorBContents] count], 2, @"");
@@ -432,7 +432,7 @@
 - (void)testLoggedErrorWithNullsInAdditionalInfo {
 - (void)testLoggedErrorWithNullsInAdditionalInfo {
   NSError* error = [NSError errorWithDomain:@"Domain" code:-1 userInfo:nil];
   NSError* error = [NSError errorWithDomain:@"Domain" code:-1 userInfo:nil];
 
 
-  FIRCLSUserLoggingRecordError(error, @{@"null-key" : [NSNull null]});
+  FIRCLSUserLoggingRecordError(error, @{@"null-key" : [NSNull null]}, nil);
 
 
   NSArray* errors = [self errorAContents];
   NSArray* errors = [self errorAContents];
 
 

+ 70 - 0
Crashlytics/UnitTests/FIRCLSRolloutsPersistenceManagerTests.m

@@ -0,0 +1,70 @@
+// Copyright 2024 Google
+//
+// 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 <XCTest/XCTest.h>
+
+#import "Crashlytics/Crashlytics/Components/FIRCLSContext.h"
+#import "Crashlytics/Crashlytics/Controllers/FIRCLSRolloutsPersistenceManager.h"
+#import "Crashlytics/Crashlytics/Models/FIRCLSInternalReport.h"
+#import "Crashlytics/UnitTests/Mocks/FIRCLSTempMockFileManager.h"
+#if SWIFT_PACKAGE
+@import FirebaseCrashlyticsSwift;
+#else  // Swift Package Manager
+#import <FirebaseCrashlytics/FirebaseCrashlytics-Swift.h>
+#endif  // CocoaPods
+
+NSString *reportId = @"1234567";
+
+@interface FIRCLSRolloutsPersistenceManagerTests : XCTestCase
+@property(nonatomic, strong) FIRCLSTempMockFileManager *fileManager;
+@property(nonatomic, strong) FIRCLSRolloutsPersistenceManager *rolloutsPersistenceManager;
+@end
+
+@implementation FIRCLSRolloutsPersistenceManagerTests
+- (void)setUp {
+  [super setUp];
+  FIRCLSContextBaseInit();
+  self.fileManager = [[FIRCLSTempMockFileManager alloc] init];
+  [self.fileManager createReportDirectories];
+  [self.fileManager setupNewPathForExecutionIdentifier:reportId];
+
+  self.rolloutsPersistenceManager =
+      [[FIRCLSRolloutsPersistenceManager alloc] initWithFileManager:self.fileManager];
+}
+
+- (void)tearDown {
+  [self.fileManager removeItemAtPath:_fileManager.rootPath];
+  FIRCLSContextBaseDeinit();
+  [super tearDown];
+}
+
+- (void)testUpdateRolloutsStateToPersistenceWithRollouts {
+  NSString *encodedStateString =
+      @"{rollouts:[{\"parameter_key\":\"6d795f66656174757265\",\"parameter_value\":"
+      @"\"e8bf99e698af7468656d6973e79a84e6b58be8af95e695b0e68daeefbc8ce8be93e585a5e4b8ade69687\","
+      @"\"rollout_id\":\"726f6c6c6f75745f31\",\"template_version\":1,\"variant_id\":"
+      @"\"636f6e74726f6c\"}]}";
+
+  NSData *data = [encodedStateString dataUsingEncoding:NSUTF8StringEncoding];
+  NSString *rolloutsFilePath =
+      [[[self.fileManager activePath] stringByAppendingPathComponent:reportId]
+          stringByAppendingPathComponent:FIRCLSReportRolloutsFile];
+
+  [self.rolloutsPersistenceManager updateRolloutsStateToPersistenceWithRollouts:data
+                                                                       reportID:reportId];
+  XCTAssertTrue([[NSFileManager defaultManager] fileExistsAtPath:rolloutsFilePath]);
+}
+
+@end

+ 1 - 1
Crashlytics/UnitTests/FIRRecordExceptionModelTests.m

@@ -75,7 +75,7 @@
   FIRExceptionModel *exceptionModel = [FIRExceptionModel exceptionModelWithName:name reason:reason];
   FIRExceptionModel *exceptionModel = [FIRExceptionModel exceptionModelWithName:name reason:reason];
   exceptionModel.stackTrace = stackTrace;
   exceptionModel.stackTrace = stackTrace;
 
 
-  FIRCLSExceptionRecordModel(exceptionModel);
+  FIRCLSExceptionRecordModel(exceptionModel, nil);
 
 
   NSData *data = [NSData
   NSData *data = [NSData
       dataWithContentsOfFile:[self.reportPath
       dataWithContentsOfFile:[self.reportPath

+ 136 - 0
Crashlytics/UnitTestsSwift/CrashlyticsRemoteConfigManagerTests.swift

@@ -0,0 +1,136 @@
+// 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.
+#if SWIFT_PACKAGE
+  @testable import FirebaseCrashlyticsSwift
+#else
+  @testable import FirebaseCrashlytics
+#endif
+import FirebaseRemoteConfigInterop
+import XCTest
+
+class RemoteConfigConfigMock: RemoteConfigInterop {
+  func registerRolloutsStateSubscriber(_ subscriber: FirebaseRemoteConfigInterop
+    .RolloutsStateSubscriber,
+    for namespace: String) {}
+}
+
+class PersistanceManagerMock: CrashlyticsPersistenceLog {
+  func updateRolloutsStateToPersistence(rollouts: Data, reportID: String) {}
+  func debugLog(message: String) {}
+}
+
+final class CrashlyticsRemoteConfigManagerTests: XCTestCase {
+  let rollouts: RolloutsState = {
+    let assignment1 = RolloutAssignment(
+      rolloutId: "rollout_1",
+      variantId: "control",
+      templateVersion: 1,
+      parameterKey: "my_feature",
+      parameterValue: "false"
+    )
+    let assignment2 = RolloutAssignment(
+      rolloutId: "rollout_2",
+      variantId: "enabled",
+      templateVersion: 1,
+      parameterKey: "themis_big_feature",
+      parameterValue: "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111"
+    )
+    let rollouts = RolloutsState(assignmentList: [assignment1, assignment2])
+    return rollouts
+  }()
+
+  let singleRollout: RolloutsState = {
+    let assignment1 = RolloutAssignment(
+      rolloutId: "rollout_1",
+      variantId: "control",
+      templateVersion: 1,
+      parameterKey: "my_feature",
+      parameterValue: "这是themis的测试数据,输入中文" // check unicode
+    )
+    let rollouts = RolloutsState(assignmentList: [assignment1])
+    return rollouts
+  }()
+
+  let rcInterop = RemoteConfigConfigMock()
+
+  func testRemoteConfigManagerProperlyProcessRolloutsState() throws {
+    let rcManager = CrashlyticsRemoteConfigManager(
+      remoteConfig: rcInterop,
+      persistenceDelegate: PersistanceManagerMock()
+    )
+    rcManager.updateRolloutsState(rolloutsState: rollouts, reportID: "12R")
+    XCTAssertEqual(rcManager.rolloutAssignment.count, 2)
+
+    for assignment in rollouts.assignments {
+      if assignment.parameterKey == "themis_big_feature" {
+        XCTAssertEqual(
+          assignment.parameterValue.count,
+          CrashlyticsRemoteConfigManager.maxParameterValueLength
+        )
+      }
+    }
+  }
+
+  func testRemoteConfigManagerGenerateEncodedRolloutAssignmentsJson() throws {
+    let expectedString =
+      "[{\"parameter_key\":\"6d795f66656174757265\",\"parameter_value\":\"e8bf99e698af7468656d6973e79a84e6b58be8af95e695b0e68daeefbc8ce8be93e585a5e4b8ade69687\",\"rollout_id\":\"726f6c6c6f75745f31\",\"template_version\":1,\"variant_id\":\"636f6e74726f6c\"}]"
+
+    let rcManager = CrashlyticsRemoteConfigManager(
+      remoteConfig: rcInterop,
+      persistenceDelegate: PersistanceManagerMock()
+    )
+    rcManager.updateRolloutsState(rolloutsState: singleRollout, reportID: "456")
+
+    let string = rcManager.getRolloutAssignmentsEncodedJsonString()
+    XCTAssertEqual(string, expectedString)
+  }
+
+  func testMultiThreadsUpdateRolloutAssignments() throws {
+    let rcManager = CrashlyticsRemoteConfigManager(
+      remoteConfig: rcInterop,
+      persistenceDelegate: PersistanceManagerMock()
+    )
+    DispatchQueue.main.async { [weak self] in
+      if let singleRollout = self?.singleRollout {
+        rcManager.updateRolloutsState(rolloutsState: singleRollout, reportID: "456")
+        XCTAssertEqual(rcManager.rolloutAssignment.count, 1)
+      }
+    }
+
+    DispatchQueue.main.async { [weak self] in
+      if let rollouts = self?.rollouts {
+        rcManager.updateRolloutsState(rolloutsState: rollouts, reportID: "456")
+        XCTAssertEqual(rcManager.rolloutAssignment.count, 2)
+      }
+    }
+  }
+
+  func testMultiThreadsReadAndWriteRolloutAssignments() throws {
+    let rcManager = CrashlyticsRemoteConfigManager(
+      remoteConfig: rcInterop,
+      persistenceDelegate: PersistanceManagerMock()
+    )
+    rcManager.updateRolloutsState(rolloutsState: singleRollout, reportID: "456")
+
+    DispatchQueue.main.async { [weak self] in
+      if let rollouts = self?.rollouts {
+        let oldAssignments = rcManager.rolloutAssignment
+        rcManager.updateRolloutsState(rolloutsState: rollouts, reportID: "456")
+        XCTAssertEqual(rcManager.rolloutAssignment.count, 2)
+        XCTAssertEqual(oldAssignments.count, 1)
+      }
+    }
+    XCTAssertEqual(rcManager.rolloutAssignment.count, 1)
+  }
+}

+ 1 - 0
Example/watchOSSample/Podfile

@@ -19,6 +19,7 @@ target 'SampleWatchAppWatchKitExtension' do
   pod 'FirebaseDatabase', :path => '../../'
   pod 'FirebaseDatabase', :path => '../../'
   pod 'FirebaseAppCheckInterop', :path => '../../'
   pod 'FirebaseAppCheckInterop', :path => '../../'
   pod 'FirebaseAuthInterop', :path => '../../'
   pod 'FirebaseAuthInterop', :path => '../../'
+  pod 'FirebaseRemoteConfigInterop', :path => '../../'
 
 
   pod 'Firebase/Messaging', :path => '../../'
   pod 'Firebase/Messaging', :path => '../../'
   pod 'Firebase/Storage', :path => '../../'
   pod 'Firebase/Storage', :path => '../../'

+ 4 - 2
FirebaseCrashlytics.podspec

@@ -27,7 +27,7 @@ Pod::Spec.new do |s|
   s.prefix_header_file = false
   s.prefix_header_file = false
 
 
   s.source_files = [
   s.source_files = [
-    'Crashlytics/Crashlytics/**/*.{c,h,m,mm}',
+    'Crashlytics/Crashlytics/**/*.{c,h,m,mm,swift}',
     'Crashlytics/Protogen/**/*.{c,h,m,mm}',
     'Crashlytics/Protogen/**/*.{c,h,m,mm}',
     'Crashlytics/Shared/**/*.{c,h,m,mm}',
     'Crashlytics/Shared/**/*.{c,h,m,mm}',
     'Crashlytics/third_party/**/*.{c,h,m,mm}',
     'Crashlytics/third_party/**/*.{c,h,m,mm}',
@@ -62,6 +62,7 @@ Pod::Spec.new do |s|
   s.dependency 'FirebaseCore', '~> 10.5'
   s.dependency 'FirebaseCore', '~> 10.5'
   s.dependency 'FirebaseInstallations', '~> 10.0'
   s.dependency 'FirebaseInstallations', '~> 10.0'
   s.dependency 'FirebaseSessions', '~> 10.5'
   s.dependency 'FirebaseSessions', '~> 10.5'
+  s.dependency 'FirebaseRemoteConfigInterop', '~> 10.23'
   s.dependency 'PromisesObjC', '~> 2.1'
   s.dependency 'PromisesObjC', '~> 2.1'
   s.dependency 'GoogleDataTransport', '~> 9.2'
   s.dependency 'GoogleDataTransport', '~> 9.2'
   s.dependency 'GoogleUtilities/Environment', '~> 7.8'
   s.dependency 'GoogleUtilities/Environment', '~> 7.8'
@@ -119,7 +120,8 @@ Pod::Spec.new do |s|
       :tvos => tvos_deployment_target
       :tvos => tvos_deployment_target
     }
     }
     unit_tests.source_files = 'Crashlytics/UnitTests/*.[mh]',
     unit_tests.source_files = 'Crashlytics/UnitTests/*.[mh]',
-                              'Crashlytics/UnitTests/*/*.[mh]'
+                              'Crashlytics/UnitTests/*/*.[mh]',
+                              'Crashlytics/UnitTestsSwift/*.swift'
     unit_tests.resources = 'Crashlytics/UnitTests/Data/*',
     unit_tests.resources = 'Crashlytics/UnitTests/Data/*',
                            'Crashlytics/UnitTests/*.clsrecord',
                            'Crashlytics/UnitTests/*.clsrecord',
                            'Crashlytics/UnitTests/FIRCLSMachO/machO_data/*'
                            'Crashlytics/UnitTests/FIRCLSMachO/machO_data/*'

+ 3 - 1
FirebaseRemoteConfig.podspec

@@ -56,6 +56,7 @@ app update.
   s.dependency 'FirebaseInstallations', '~> 10.0'
   s.dependency 'FirebaseInstallations', '~> 10.0'
   s.dependency 'GoogleUtilities/Environment', '~> 7.8'
   s.dependency 'GoogleUtilities/Environment', '~> 7.8'
   s.dependency 'GoogleUtilities/NSData+zlib', '~> 7.8'
   s.dependency 'GoogleUtilities/NSData+zlib', '~> 7.8'
+  s.dependency 'FirebaseRemoteConfigInterop', '~> 10.23'
 
 
   s.test_spec 'unit' do |unit_tests|
   s.test_spec 'unit' do |unit_tests|
     unit_tests.scheme = { :code_coverage => true }
     unit_tests.scheme = { :code_coverage => true }
@@ -80,7 +81,8 @@ app update.
         'FirebaseRemoteConfig/Tests/Unit/RCNTestUtilities.m',
         'FirebaseRemoteConfig/Tests/Unit/RCNTestUtilities.m',
         'FirebaseRemoteConfig/Tests/Unit/RCNUserDefaultsManagerTests.m',
         'FirebaseRemoteConfig/Tests/Unit/RCNUserDefaultsManagerTests.m',
         'FirebaseRemoteConfig/Tests/Unit/RCNTestUtilities.h',
         'FirebaseRemoteConfig/Tests/Unit/RCNTestUtilities.h',
-        'FirebaseRemoteConfig/Tests/Unit/RCNInstanceIDTest.m'
+        'FirebaseRemoteConfig/Tests/Unit/RCNInstanceIDTest.m',
+        'FirebaseRemoteConfig/Tests/SwiftUnit/*.swift'
     # Supply plist custom plist testing.
     # Supply plist custom plist testing.
     unit_tests.resources =
     unit_tests.resources =
         'FirebaseRemoteConfig/Tests/Unit/Defaults-testInfo.plist',
         'FirebaseRemoteConfig/Tests/Unit/Defaults-testInfo.plist',

+ 3 - 0
FirebaseRemoteConfig/CHANGELOG.md

@@ -1,3 +1,6 @@
+# Unreleased
+- [changed] Add support for other Firebase products to integrate with Remote Config.
+
 # 10.17.0
 # 10.17.0
 - [feature] The `FirebaseRemoteConfig` module now contains Firebase Remote
 - [feature] The `FirebaseRemoteConfig` module now contains Firebase Remote
   Config's Swift-only APIs that were previously only available via the
   Config's Swift-only APIs that were previously only available via the

+ 21 - 0
FirebaseRemoteConfig/Interop/RemoteConfigConstants.swift

@@ -0,0 +1,21 @@
+// Copyright 2024 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import Foundation
+
+@objc(FIRRemoteConfigConstants)
+public final class RemoteConfigConstants: NSObject {
+  @objc(FIRNamespaceGoogleMobilePlatform) public static let NamespaceGoogleMobilePlatform =
+    "firebase"
+}

+ 21 - 0
FirebaseRemoteConfig/Interop/RemoteConfigInterop.swift

@@ -0,0 +1,21 @@
+// 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
+
+@objc(FIRRemoteConfigInterop)
+public protocol RemoteConfigInterop {
+  func registerRolloutsStateSubscriber(_ subscriber: RolloutsStateSubscriber,
+                                       for namespace: String)
+}

+ 47 - 0
FirebaseRemoteConfig/Interop/RolloutAssignment.swift

@@ -0,0 +1,47 @@
+// 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
+
+@objc(FIRRolloutAssignment)
+public class RolloutAssignment: NSObject {
+  @objc public var rolloutId: String
+  @objc public var variantId: String
+  @objc public var templateVersion: Int64
+  @objc public var parameterKey: String
+  @objc public var parameterValue: String
+
+  @objc public init(rolloutId: String, variantId: String, templateVersion: Int64,
+                    parameterKey: String,
+                    parameterValue: String) {
+    self.rolloutId = rolloutId
+    self.variantId = variantId
+    self.templateVersion = templateVersion
+    self.parameterKey = parameterKey
+    self.parameterValue = parameterValue
+    super.init()
+  }
+}
+
+@objc(FIRRolloutsState)
+public class RolloutsState: NSObject {
+  @objc public var assignments: Set<RolloutAssignment> = Set()
+
+  @objc public init(assignmentList: [RolloutAssignment]) {
+    for assignment in assignmentList {
+      assignments.insert(assignment)
+    }
+    super.init()
+  }
+}

+ 20 - 0
FirebaseRemoteConfig/Interop/RolloutsStateSubscriber.swift

@@ -0,0 +1,20 @@
+// 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
+
+@objc(FIRRolloutsStateSubscriber)
+public protocol RolloutsStateSubscriber {
+  func rolloutsStateDidChange(_ rolloutsState: RolloutsState)
+}

+ 94 - 12
FirebaseRemoteConfig/Sources/FIRRemoteConfig.m

@@ -45,6 +45,8 @@ static NSString *const kRemoteConfigFetchTimeoutKey = @"_rcn_fetch_timeout";
 /// Notification when config is successfully activated
 /// Notification when config is successfully activated
 const NSNotificationName FIRRemoteConfigActivateNotification =
 const NSNotificationName FIRRemoteConfigActivateNotification =
     @"FIRRemoteConfigActivateNotification";
     @"FIRRemoteConfigActivateNotification";
+static NSNotificationName FIRRolloutsStateDidChangeNotificationName =
+    @"FIRRolloutsStateDidChangeNotification";
 
 
 /// Listener for the get methods.
 /// Listener for the get methods.
 typedef void (^FIRRemoteConfigListener)(NSString *_Nonnull, NSDictionary *_Nonnull);
 typedef void (^FIRRemoteConfigListener)(NSString *_Nonnull, NSDictionary *_Nonnull);
@@ -79,8 +81,9 @@ static NSMutableDictionary<NSString *, NSMutableDictionary<NSString *, FIRRemote
     *RCInstances;
     *RCInstances;
 
 
 + (nonnull FIRRemoteConfig *)remoteConfigWithApp:(FIRApp *_Nonnull)firebaseApp {
 + (nonnull FIRRemoteConfig *)remoteConfigWithApp:(FIRApp *_Nonnull)firebaseApp {
-  return [FIRRemoteConfig remoteConfigWithFIRNamespace:FIRNamespaceGoogleMobilePlatform
-                                                   app:firebaseApp];
+  return [FIRRemoteConfig
+      remoteConfigWithFIRNamespace:FIRRemoteConfigConstants.FIRNamespaceGoogleMobilePlatform
+                               app:firebaseApp];
 }
 }
 
 
 + (nonnull FIRRemoteConfig *)remoteConfigWithFIRNamespace:(NSString *_Nonnull)firebaseNamespace {
 + (nonnull FIRRemoteConfig *)remoteConfigWithFIRNamespace:(NSString *_Nonnull)firebaseNamespace {
@@ -116,8 +119,9 @@ static NSMutableDictionary<NSString *, NSMutableDictionary<NSString *, FIRRemote
                        @"initializer in SwiftUI."];
                        @"initializer in SwiftUI."];
   }
   }
 
 
-  return [FIRRemoteConfig remoteConfigWithFIRNamespace:FIRNamespaceGoogleMobilePlatform
-                                                   app:[FIRApp defaultApp]];
+  return [FIRRemoteConfig
+      remoteConfigWithFIRNamespace:FIRRemoteConfigConstants.FIRNamespaceGoogleMobilePlatform
+                               app:[FIRApp defaultApp]];
 }
 }
 
 
 /// Singleton instance of serial queue for queuing all incoming RC calls.
 /// Singleton instance of serial queue for queuing all incoming RC calls.
@@ -329,10 +333,20 @@ typedef void (^FIRRemoteConfigActivateChangeCompletion)(BOOL changed, NSError *_
     // New config has been activated at this point
     // New config has been activated at this point
     FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000069", @"Config activated.");
     FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000069", @"Config activated.");
     [strongSelf->_configContent activatePersonalization];
     [strongSelf->_configContent activatePersonalization];
+    // Update last active template version number in setting and userDefaults.
+    [strongSelf->_settings updateLastActiveTemplateVersion];
+    // Update activeRolloutMetadata
+    [strongSelf->_configContent activateRolloutMetadata:^(BOOL success) {
+      if (success) {
+        [self notifyRolloutsStateChange:strongSelf->_configContent.activeRolloutMetadata
+                          versionNumber:strongSelf->_settings.lastActiveTemplateVersion];
+      }
+    }];
+
     // Update experiments only for 3p namespace
     // Update experiments only for 3p namespace
     NSString *namespace = [strongSelf->_FIRNamespace
     NSString *namespace = [strongSelf->_FIRNamespace
         substringToIndex:[strongSelf->_FIRNamespace rangeOfString:@":"].location];
         substringToIndex:[strongSelf->_FIRNamespace rangeOfString:@":"].location];
-    if ([namespace isEqualToString:FIRNamespaceGoogleMobilePlatform]) {
+    if ([namespace isEqualToString:FIRRemoteConfigConstants.FIRNamespaceGoogleMobilePlatform]) {
       dispatch_async(dispatch_get_main_queue(), ^{
       dispatch_async(dispatch_get_main_queue(), ^{
         [self notifyConfigHasActivated];
         [self notifyConfigHasActivated];
       });
       });
@@ -377,6 +391,17 @@ typedef void (^FIRRemoteConfigActivateChangeCompletion)(BOOL changed, NSError *_
   return fullyQualifiedNamespace;
   return fullyQualifiedNamespace;
 }
 }
 
 
+- (FIRRemoteConfigValue *)defaultValueForFullyQualifiedNamespace:(NSString *)namespace
+                                                             key:(NSString *)key {
+  FIRRemoteConfigValue *value = self->_configContent.defaultConfig[namespace][key];
+  if (!value) {
+    value = [[FIRRemoteConfigValue alloc]
+        initWithData:[NSData data]
+              source:(FIRRemoteConfigSource)FIRRemoteConfigSourceStatic];
+  }
+  return value;
+}
+
 #pragma mark - Get Config Result
 #pragma mark - Get Config Result
 
 
 - (FIRRemoteConfigValue *)objectForKeyedSubscript:(NSString *)key {
 - (FIRRemoteConfigValue *)objectForKeyedSubscript:(NSString *)key {
@@ -402,13 +427,7 @@ typedef void (^FIRRemoteConfigActivateChangeCompletion)(BOOL changed, NSError *_
                    config:[self->_configContent getConfigAndMetadataForNamespace:FQNamespace]];
                    config:[self->_configContent getConfigAndMetadataForNamespace:FQNamespace]];
       return;
       return;
     }
     }
-    value = self->_configContent.defaultConfig[FQNamespace][key];
-    if (value) {
-      return;
-    }
-
-    value = [[FIRRemoteConfigValue alloc] initWithData:[NSData data]
-                                                source:FIRRemoteConfigSourceStatic];
+    value = [self defaultValueForFullyQualifiedNamespace:FQNamespace key:key];
   });
   });
   return value;
   return value;
 }
 }
@@ -613,4 +632,67 @@ typedef void (^FIRRemoteConfigActivateChangeCompletion)(BOOL changed, NSError *_
   return [self->_configRealtime addConfigUpdateListener:listener];
   return [self->_configRealtime addConfigUpdateListener:listener];
 }
 }
 
 
+#pragma mark - Rollout
+
+- (void)addRemoteConfigInteropSubscriber:(id<FIRRolloutsStateSubscriber>)subscriber {
+  [[NSNotificationCenter defaultCenter]
+      addObserverForName:FIRRolloutsStateDidChangeNotificationName
+                  object:self
+                   queue:nil
+              usingBlock:^(NSNotification *_Nonnull notification) {
+                FIRRolloutsState *rolloutsState =
+                    notification.userInfo[FIRRolloutsStateDidChangeNotificationName];
+                [subscriber rolloutsStateDidChange:rolloutsState];
+              }];
+  // Send active rollout metadata stored in persistence while app launched if there is activeConfig
+  NSString *fullyQualifiedNamespace = [self fullyQualifiedNamespace:_FIRNamespace];
+  NSDictionary<NSString *, NSDictionary *> *activeConfig = self->_configContent.activeConfig;
+  if (activeConfig[fullyQualifiedNamespace] && activeConfig[fullyQualifiedNamespace].count > 0) {
+    [self notifyRolloutsStateChange:self->_configContent.activeRolloutMetadata
+                      versionNumber:self->_settings.lastActiveTemplateVersion];
+  }
+}
+
+- (void)notifyRolloutsStateChange:(NSArray<NSDictionary *> *)rolloutMetadata
+                    versionNumber:(NSString *)versionNumber {
+  NSArray<FIRRolloutAssignment *> *rolloutsAssignments =
+      [self rolloutsAssignmentsWith:rolloutMetadata versionNumber:versionNumber];
+  FIRRolloutsState *rolloutsState =
+      [[FIRRolloutsState alloc] initWithAssignmentList:rolloutsAssignments];
+  FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000069",
+              @"Send rollouts state notification with name %@ to RemoteConfigInterop.",
+              FIRRolloutsStateDidChangeNotificationName);
+  [[NSNotificationCenter defaultCenter]
+      postNotificationName:FIRRolloutsStateDidChangeNotificationName
+                    object:self
+                  userInfo:@{FIRRolloutsStateDidChangeNotificationName : rolloutsState}];
+}
+
+- (NSArray<FIRRolloutAssignment *> *)rolloutsAssignmentsWith:
+                                         (NSArray<NSDictionary *> *)rolloutMetadata
+                                               versionNumber:(NSString *)versionNumber {
+  NSMutableArray<FIRRolloutAssignment *> *rolloutsAssignments = [[NSMutableArray alloc] init];
+  NSString *FQNamespace = [self fullyQualifiedNamespace:_FIRNamespace];
+  for (NSDictionary *metadata in rolloutMetadata) {
+    NSString *rolloutId = metadata[RCNFetchResponseKeyRolloutID];
+    NSString *variantID = metadata[RCNFetchResponseKeyVariantID];
+    NSArray<NSString *> *affectedParameterKeys = metadata[RCNFetchResponseKeyAffectedParameterKeys];
+    if (rolloutId && variantID && affectedParameterKeys) {
+      for (NSString *key in affectedParameterKeys) {
+        FIRRemoteConfigValue *value = self->_configContent.activeConfig[FQNamespace][key];
+        if (!value) {
+          value = [self defaultValueForFullyQualifiedNamespace:FQNamespace key:key];
+        }
+        FIRRolloutAssignment *assignment =
+            [[FIRRolloutAssignment alloc] initWithRolloutId:rolloutId
+                                                  variantId:variantID
+                                            templateVersion:[versionNumber longLongValue]
+                                               parameterKey:key
+                                             parameterValue:value.stringValue];
+        [rolloutsAssignments addObject:assignment];
+      }
+    }
+  }
+  return rolloutsAssignments;
+}
 @end
 @end

+ 7 - 1
FirebaseRemoteConfig/Sources/FIRRemoteConfigComponent.h

@@ -17,6 +17,7 @@
 #import <Foundation/Foundation.h>
 #import <Foundation/Foundation.h>
 
 
 #import "FirebaseCore/Extension/FirebaseCoreInternal.h"
 #import "FirebaseCore/Extension/FirebaseCoreInternal.h"
+@import FirebaseRemoteConfigInterop;
 
 
 @class FIRApp;
 @class FIRApp;
 @class FIRRemoteConfig;
 @class FIRRemoteConfig;
@@ -37,7 +38,8 @@ NS_ASSUME_NONNULL_BEGIN
 
 
 /// A concrete implementation for FIRRemoteConfigInterop to create Remote Config instances and
 /// A concrete implementation for FIRRemoteConfigInterop to create Remote Config instances and
 /// register with Core's component system.
 /// register with Core's component system.
-@interface FIRRemoteConfigComponent : NSObject <FIRRemoteConfigProvider, FIRLibrary>
+@interface FIRRemoteConfigComponent
+    : NSObject <FIRRemoteConfigProvider, FIRLibrary, FIRRemoteConfigInterop>
 
 
 /// The FIRApp that instances will be set up with.
 /// The FIRApp that instances will be set up with.
 @property(nonatomic, weak, readonly) FIRApp *app;
 @property(nonatomic, weak, readonly) FIRApp *app;
@@ -45,6 +47,10 @@ NS_ASSUME_NONNULL_BEGIN
 /// Cached instances of Remote Config objects.
 /// Cached instances of Remote Config objects.
 @property(nonatomic, strong) NSMutableDictionary<NSString *, FIRRemoteConfig *> *instances;
 @property(nonatomic, strong) NSMutableDictionary<NSString *, FIRRemoteConfig *> *instances;
 
 
+/// Clear all the component instances from the singleton which created previously, this is for
+/// testing only
++ (void)clearAllComponentInstances;
+
 /// Default method for retrieving a Remote Config instance, or creating one if it doesn't exist.
 /// Default method for retrieving a Remote Config instance, or creating one if it doesn't exist.
 - (FIRRemoteConfig *)remoteConfigForNamespace:(NSString *)remoteConfigNamespace;
 - (FIRRemoteConfig *)remoteConfigForNamespace:(NSString *)remoteConfigNamespace;
 
 

+ 47 - 2
FirebaseRemoteConfig/Sources/FIRRemoteConfigComponent.m

@@ -24,6 +24,31 @@
 
 
 @implementation FIRRemoteConfigComponent
 @implementation FIRRemoteConfigComponent
 
 
+// Because Component now need to register two protocols (provider and interop), we need a way to
+// return the same component instance for both registered protocol, this singleton pattern allow us
+// to return the same component object for both registration callback.
+static NSMutableDictionary<NSString *, FIRRemoteConfigComponent *> *_componentInstances = nil;
+
++ (FIRRemoteConfigComponent *)getComponentForApp:(FIRApp *)app {
+  @synchronized(_componentInstances) {
+    // need to init the dictionary first
+    if (!_componentInstances) {
+      _componentInstances = [[NSMutableDictionary alloc] init];
+    }
+    if (![_componentInstances objectForKey:app.name]) {
+      _componentInstances[app.name] = [[self alloc] initWithApp:app];
+    }
+    return _componentInstances[app.name];
+  }
+  return nil;
+}
+
++ (void)clearAllComponentInstances {
+  @synchronized(_componentInstances) {
+    [_componentInstances removeAllObjects];
+  }
+}
+
 /// Default method for retrieving a Remote Config instance, or creating one if it doesn't exist.
 /// Default method for retrieving a Remote Config instance, or creating one if it doesn't exist.
 - (FIRRemoteConfig *)remoteConfigForNamespace:(NSString *)remoteConfigNamespace {
 - (FIRRemoteConfig *)remoteConfigForNamespace:(NSString *)remoteConfigNamespace {
   if (!remoteConfigNamespace) {
   if (!remoteConfigNamespace) {
@@ -102,9 +127,29 @@
               creationBlock:^id _Nullable(FIRComponentContainer *container, BOOL *isCacheable) {
               creationBlock:^id _Nullable(FIRComponentContainer *container, BOOL *isCacheable) {
                 // Cache the component so instances of Remote Config are cached.
                 // Cache the component so instances of Remote Config are cached.
                 *isCacheable = YES;
                 *isCacheable = YES;
-                return [[FIRRemoteConfigComponent alloc] initWithApp:container.app];
+                return [FIRRemoteConfigComponent getComponentForApp:container.app];
+              }];
+
+  // Unlike provider needs to setup a hard dependency on remote config, interop allows an optional
+  // dependency on RC
+  FIRComponent *rcInterop = [FIRComponent
+      componentWithProtocol:@protocol(FIRRemoteConfigInterop)
+        instantiationTiming:FIRInstantiationTimingAlwaysEager
+               dependencies:@[]
+              creationBlock:^id _Nullable(FIRComponentContainer *container, BOOL *isCacheable) {
+                // Cache the component so instances of Remote Config are cached.
+                *isCacheable = YES;
+                return [FIRRemoteConfigComponent getComponentForApp:container.app];
               }];
               }];
-  return @[ rcProvider ];
+  return @[ rcProvider, rcInterop ];
+}
+
+#pragma mark - Remote Config Interop Protocol
+
+- (void)registerRolloutsStateSubscriber:(id<FIRRolloutsStateSubscriber>)subscriber
+                                    for:(NSString * _Nonnull)namespace {
+  FIRRemoteConfig *instance = [self remoteConfigForNamespace:namespace];
+  [instance addRemoteConfigInteropSubscriber:subscriber];
 }
 }
 
 
 @end
 @end

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

@@ -23,6 +23,7 @@
 @class RCNConfigFetch;
 @class RCNConfigFetch;
 @class RCNConfigRealtime;
 @class RCNConfigRealtime;
 @protocol FIRAnalyticsInterop;
 @protocol FIRAnalyticsInterop;
+@protocol FIRRolloutsStateSubscriber;
 
 
 NS_ASSUME_NONNULL_BEGIN
 NS_ASSUME_NONNULL_BEGIN
 
 
@@ -78,6 +79,9 @@ NS_ASSUME_NONNULL_BEGIN
                   configContent:(RCNConfigContent *)configContent
                   configContent:(RCNConfigContent *)configContent
                       analytics:(nullable id<FIRAnalyticsInterop>)analytics;
                       analytics:(nullable id<FIRAnalyticsInterop>)analytics;
 
 
+/// Register RolloutsStateSubcriber to FIRRemoteConfig instance
+- (void)addRemoteConfigInteropSubscriber:(id<FIRRolloutsStateSubscriber> _Nonnull)subscriber;
+
 @end
 @end
 
 
 NS_ASSUME_NONNULL_END
 NS_ASSUME_NONNULL_END

+ 7 - 2
FirebaseRemoteConfig/Sources/Private/RCNConfigSettings.h

@@ -79,8 +79,10 @@
 @property(nonatomic, readwrite, assign) NSString *lastETag;
 @property(nonatomic, readwrite, assign) NSString *lastETag;
 /// The timestamp of the last eTag update.
 /// The timestamp of the last eTag update.
 @property(nonatomic, readwrite, assign) NSTimeInterval lastETagUpdateTime;
 @property(nonatomic, readwrite, assign) NSTimeInterval lastETagUpdateTime;
-// Last fetched template version.
-@property(nonatomic, readwrite, assign) NSString *lastTemplateVersion;
+/// Last fetched template version.
+@property(nonatomic, readwrite, assign) NSString *lastFetchedTemplateVersion;
+/// Last active template version.
+@property(nonatomic, readwrite, assign) NSString *lastActiveTemplateVersion;
 
 
 #pragma mark Throttling properties
 #pragma mark Throttling properties
 
 
@@ -134,6 +136,9 @@
 /// indicates a server issue.
 /// indicates a server issue.
 - (void)updateRealtimeExponentialBackoffTime;
 - (void)updateRealtimeExponentialBackoffTime;
 
 
+/// Update last active template version from last fetched template version.
+- (void)updateLastActiveTemplateVersion;
+
 /// Returns the difference between the Realtime backoff end time and the current time in a
 /// Returns the difference between the Realtime backoff end time and the current time in a
 /// NSTimeInterval format.
 /// NSTimeInterval format.
 - (NSTimeInterval)getRealtimeBackoffInterval;
 - (NSTimeInterval)getRealtimeBackoffInterval;

+ 11 - 1
FirebaseRemoteConfig/Sources/RCNConfigConstants.h

@@ -37,6 +37,14 @@ static NSString *const RCNFetchResponseKeyEntries = @"entries";
 static NSString *const RCNFetchResponseKeyExperimentDescriptions = @"experimentDescriptions";
 static NSString *const RCNFetchResponseKeyExperimentDescriptions = @"experimentDescriptions";
 /// Key that includes data for Personalization metadata.
 /// Key that includes data for Personalization metadata.
 static NSString *const RCNFetchResponseKeyPersonalizationMetadata = @"personalizationMetadata";
 static NSString *const RCNFetchResponseKeyPersonalizationMetadata = @"personalizationMetadata";
+/// Key that includes data for Rollout metadata.
+static NSString *const RCNFetchResponseKeyRolloutMetadata = @"rolloutMetadata";
+/// Key that indicates rollout id in Rollout metadata.
+static NSString *const RCNFetchResponseKeyRolloutID = @"rolloutId";
+/// Key that indicates variant id in Rollout metadata.
+static NSString *const RCNFetchResponseKeyVariantID = @"variantId";
+/// Key that indicates affected parameter keys in Rollout Metadata.
+static NSString *const RCNFetchResponseKeyAffectedParameterKeys = @"affectedParameterKeys";
 /// Error key.
 /// Error key.
 static NSString *const RCNFetchResponseKeyError = @"error";
 static NSString *const RCNFetchResponseKeyError = @"error";
 /// Error code.
 /// Error code.
@@ -58,5 +66,7 @@ static NSString *const RCNFetchResponseKeyStateNoTemplate = @"NO_TEMPLATE";
 static NSString *const RCNFetchResponseKeyStateNoChange = @"NO_CHANGE";
 static NSString *const RCNFetchResponseKeyStateNoChange = @"NO_CHANGE";
 /// Template found, but evaluates to empty (e.g. all keys omitted).
 /// Template found, but evaluates to empty (e.g. all keys omitted).
 static NSString *const RCNFetchResponseKeyStateEmptyConfig = @"EMPTY_CONFIG";
 static NSString *const RCNFetchResponseKeyStateEmptyConfig = @"EMPTY_CONFIG";
-/// Template Version key
+/// Fetched Template Version key
 static NSString *const RCNFetchResponseKeyTemplateVersion = @"templateVersion";
 static NSString *const RCNFetchResponseKeyTemplateVersion = @"templateVersion";
+/// Active Template Version key
+static NSString *const RCNActiveKeyTemplateVersion = @"activeTemplateVersion";

+ 5 - 0
FirebaseRemoteConfig/Sources/RCNConfigContent.h

@@ -39,6 +39,8 @@ typedef NS_ENUM(NSInteger, RCNDBSource) {
 @property(nonatomic, readonly, copy) NSDictionary *activeConfig;
 @property(nonatomic, readonly, copy) NSDictionary *activeConfig;
 /// Local default config that is provided by external users;
 /// Local default config that is provided by external users;
 @property(nonatomic, readonly, copy) NSDictionary *defaultConfig;
 @property(nonatomic, readonly, copy) NSDictionary *defaultConfig;
+/// Active Rollout metadata that is currently used.
+@property(nonatomic, readonly, copy) NSArray<NSDictionary *> *activeRolloutMetadata;
 
 
 - (instancetype)init NS_UNAVAILABLE;
 - (instancetype)init NS_UNAVAILABLE;
 
 
@@ -65,6 +67,9 @@ typedef NS_ENUM(NSInteger, RCNDBSource) {
 /// Gets the active config and Personalization metadata.
 /// Gets the active config and Personalization metadata.
 - (NSDictionary *)getConfigAndMetadataForNamespace:(NSString *)FIRNamespace;
 - (NSDictionary *)getConfigAndMetadataForNamespace:(NSString *)FIRNamespace;
 
 
+/// Sets the fetched rollout metadata to active with a success completion handler.
+- (void)activateRolloutMetadata:(void (^)(BOOL success))completionHandler;
+
 /// Returns the updated parameters between fetched and active config.
 /// Returns the updated parameters between fetched and active config.
 - (FIRRemoteConfigUpdate *)getConfigUpdateForNamespace:(NSString *)FIRNamespace;
 - (FIRRemoteConfigUpdate *)getConfigUpdateForNamespace:(NSString *)FIRNamespace;
 
 

+ 96 - 16
FirebaseRemoteConfig/Sources/RCNConfigContent.m

@@ -38,6 +38,10 @@
   /// Pending Personalization metadata that is latest data from server that might or might not be
   /// Pending Personalization metadata that is latest data from server that might or might not be
   /// applied.
   /// applied.
   NSDictionary *_fetchedPersonalization;
   NSDictionary *_fetchedPersonalization;
+  /// Active Rollout metadata that is currently used.
+  NSArray<NSDictionary *> *_activeRolloutMetadata;
+  /// Pending Rollout metadata that is latest data from server that might or might not be applied.
+  NSArray<NSDictionary *> *_fetchedRolloutMetadata;
   /// DBManager
   /// DBManager
   RCNConfigDBManager *_DBManager;
   RCNConfigDBManager *_DBManager;
   /// Current bundle identifier;
   /// Current bundle identifier;
@@ -80,6 +84,8 @@ const NSTimeInterval kDatabaseLoadTimeoutSecs = 30.0;
     _defaultConfig = [[NSMutableDictionary alloc] init];
     _defaultConfig = [[NSMutableDictionary alloc] init];
     _activePersonalization = [[NSDictionary alloc] init];
     _activePersonalization = [[NSDictionary alloc] init];
     _fetchedPersonalization = [[NSDictionary alloc] init];
     _fetchedPersonalization = [[NSDictionary alloc] init];
+    _activeRolloutMetadata = [[NSArray alloc] init];
+    _fetchedRolloutMetadata = [[NSArray alloc] init];
     _bundleIdentifier = [[NSBundle mainBundle] bundleIdentifier];
     _bundleIdentifier = [[NSBundle mainBundle] bundleIdentifier];
     if (!_bundleIdentifier) {
     if (!_bundleIdentifier) {
       FIRLogNotice(kFIRLoggerRemoteConfig, @"I-RCN000038",
       FIRLogNotice(kFIRLoggerRemoteConfig, @"I-RCN000038",
@@ -115,25 +121,30 @@ const NSTimeInterval kDatabaseLoadTimeoutSecs = 30.0;
   _isDatabaseLoadAlreadyInitiated = true;
   _isDatabaseLoadAlreadyInitiated = true;
 
 
   dispatch_group_enter(_dispatch_group);
   dispatch_group_enter(_dispatch_group);
-  [_DBManager
-      loadMainWithBundleIdentifier:_bundleIdentifier
-                 completionHandler:^(BOOL success, NSDictionary *fetchedConfig,
-                                     NSDictionary *activeConfig, NSDictionary *defaultConfig) {
-                   self->_fetchedConfig = [fetchedConfig mutableCopy];
-                   self->_activeConfig = [activeConfig mutableCopy];
-                   self->_defaultConfig = [defaultConfig mutableCopy];
-                   dispatch_group_leave(self->_dispatch_group);
-                 }];
+  [_DBManager loadMainWithBundleIdentifier:_bundleIdentifier
+                         completionHandler:^(
+                             BOOL success, NSDictionary *fetchedConfig, NSDictionary *activeConfig,
+                             NSDictionary *defaultConfig, NSDictionary *rolloutMetadata) {
+                           self->_fetchedConfig = [fetchedConfig mutableCopy];
+                           self->_activeConfig = [activeConfig mutableCopy];
+                           self->_defaultConfig = [defaultConfig mutableCopy];
+                           self->_fetchedRolloutMetadata =
+                               [rolloutMetadata[@RCNRolloutTableKeyFetchedMetadata] copy];
+                           self->_activeRolloutMetadata =
+                               [rolloutMetadata[@RCNRolloutTableKeyActiveMetadata] copy];
+                           dispatch_group_leave(self->_dispatch_group);
+                         }];
 
 
   // TODO(karenzeng): Refactor personalization to be returned in loadMainWithBundleIdentifier above
   // TODO(karenzeng): Refactor personalization to be returned in loadMainWithBundleIdentifier above
   dispatch_group_enter(_dispatch_group);
   dispatch_group_enter(_dispatch_group);
-  [_DBManager loadPersonalizationWithCompletionHandler:^(
-                  BOOL success, NSDictionary *fetchedPersonalization,
-                  NSDictionary *activePersonalization, NSDictionary *defaultConfig) {
-    self->_fetchedPersonalization = [fetchedPersonalization copy];
-    self->_activePersonalization = [activePersonalization copy];
-    dispatch_group_leave(self->_dispatch_group);
-  }];
+  [_DBManager
+      loadPersonalizationWithCompletionHandler:^(
+          BOOL success, NSDictionary *fetchedPersonalization, NSDictionary *activePersonalization,
+          NSDictionary *defaultConfig, NSDictionary *rolloutMetadata) {
+        self->_fetchedPersonalization = [fetchedPersonalization copy];
+        self->_activePersonalization = [activePersonalization copy];
+        dispatch_group_leave(self->_dispatch_group);
+      }];
 }
 }
 
 
 /// Update the current config result to main table.
 /// Update the current config result to main table.
@@ -269,6 +280,7 @@ const NSTimeInterval kDatabaseLoadTimeoutSecs = 30.0;
     [self handleUpdateStateForConfigNamespace:currentNamespace
     [self handleUpdateStateForConfigNamespace:currentNamespace
                                   withEntries:response[RCNFetchResponseKeyEntries]];
                                   withEntries:response[RCNFetchResponseKeyEntries]];
     [self handleUpdatePersonalization:response[RCNFetchResponseKeyPersonalizationMetadata]];
     [self handleUpdatePersonalization:response[RCNFetchResponseKeyPersonalizationMetadata]];
+    [self handleUpdateRolloutFetchedMetadata:response[RCNFetchResponseKeyRolloutMetadata]];
     return;
     return;
   }
   }
 }
 }
@@ -279,6 +291,15 @@ const NSTimeInterval kDatabaseLoadTimeoutSecs = 30.0;
                                        fromSource:RCNDBSourceActive];
                                        fromSource:RCNDBSourceActive];
 }
 }
 
 
+- (void)activateRolloutMetadata:(void (^)(BOOL success))completionHandler {
+  _activeRolloutMetadata = _fetchedRolloutMetadata;
+  [_DBManager insertOrUpdateRolloutTableWithKey:@RCNRolloutTableKeyActiveMetadata
+                                          value:_activeRolloutMetadata
+                              completionHandler:^(BOOL success, NSDictionary *result) {
+                                completionHandler(success);
+                              }];
+}
+
 #pragma mark State handling
 #pragma mark State handling
 - (void)handleNoChangeStateForConfigNamespace:(NSString *)currentNamespace {
 - (void)handleNoChangeStateForConfigNamespace:(NSString *)currentNamespace {
   if (!_fetchedConfig[currentNamespace]) {
   if (!_fetchedConfig[currentNamespace]) {
@@ -342,6 +363,16 @@ const NSTimeInterval kDatabaseLoadTimeoutSecs = 30.0;
   [_DBManager insertOrUpdatePersonalizationConfig:metadata fromSource:RCNDBSourceFetched];
   [_DBManager insertOrUpdatePersonalizationConfig:metadata fromSource:RCNDBSourceFetched];
 }
 }
 
 
+- (void)handleUpdateRolloutFetchedMetadata:(NSArray<NSDictionary *> *)metadata {
+  if (!metadata) {
+    metadata = [[NSArray alloc] init];
+  }
+  _fetchedRolloutMetadata = metadata;
+  [_DBManager insertOrUpdateRolloutTableWithKey:@RCNRolloutTableKeyFetchedMetadata
+                                          value:metadata
+                              completionHandler:nil];
+}
+
 #pragma mark - getter/setter
 #pragma mark - getter/setter
 - (NSDictionary *)fetchedConfig {
 - (NSDictionary *)fetchedConfig {
   /// If this is the first time reading the fetchedConfig, we might still be reading it from the
   /// If this is the first time reading the fetchedConfig, we might still be reading it from the
@@ -369,6 +400,11 @@ const NSTimeInterval kDatabaseLoadTimeoutSecs = 30.0;
   return _activePersonalization;
   return _activePersonalization;
 }
 }
 
 
+- (NSArray<NSDictionary *> *)activeRolloutMetadata {
+  [self checkAndWaitForInitialDatabaseLoad];
+  return _activeRolloutMetadata;
+}
+
 - (NSDictionary *)getConfigAndMetadataForNamespace:(NSString *)FIRNamespace {
 - (NSDictionary *)getConfigAndMetadataForNamespace:(NSString *)FIRNamespace {
   /// If this is the first time reading the active metadata, we might still be reading it from the
   /// If this is the first time reading the active metadata, we might still be reading it from the
   /// database.
   /// database.
@@ -411,6 +447,8 @@ const NSTimeInterval kDatabaseLoadTimeoutSecs = 30.0;
       _activeConfig[FIRNamespace] ? _activeConfig[FIRNamespace] : [[NSDictionary alloc] init];
       _activeConfig[FIRNamespace] ? _activeConfig[FIRNamespace] : [[NSDictionary alloc] init];
   NSDictionary *fetchedP13n = _fetchedPersonalization;
   NSDictionary *fetchedP13n = _fetchedPersonalization;
   NSDictionary *activeP13n = _activePersonalization;
   NSDictionary *activeP13n = _activePersonalization;
+  NSArray<NSDictionary *> *fetchedRolloutMetadata = _fetchedRolloutMetadata;
+  NSArray<NSDictionary *> *activeRolloutMetadata = _activeRolloutMetadata;
 
 
   // add new/updated params
   // add new/updated params
   for (NSString *key in [fetchedConfig allKeys]) {
   for (NSString *key in [fetchedConfig allKeys]) {
@@ -439,8 +477,50 @@ const NSTimeInterval kDatabaseLoadTimeoutSecs = 30.0;
     }
     }
   }
   }
 
 
+  NSDictionary<NSString *, NSDictionary *> *fetchedRollouts =
+      [self getParameterKeyToRolloutMetadata:fetchedRolloutMetadata];
+  NSDictionary<NSString *, NSDictionary *> *activeRollouts =
+      [self getParameterKeyToRolloutMetadata:activeRolloutMetadata];
+
+  // add params with new/updated rollout metadata
+  for (NSString *key in [fetchedRollouts allKeys]) {
+    if (activeRollouts[key] == nil ||
+        ![activeRollouts[key] isEqualToDictionary:fetchedRollouts[key]]) {
+      [updatedKeys addObject:key];
+    }
+  }
+  // add params with deleted rollout metadata
+  for (NSString *key in [activeRollouts allKeys]) {
+    if (fetchedRollouts[key] == nil) {
+      [updatedKeys addObject:key];
+    }
+  }
+
   configUpdate = [[FIRRemoteConfigUpdate alloc] initWithUpdatedKeys:updatedKeys];
   configUpdate = [[FIRRemoteConfigUpdate alloc] initWithUpdatedKeys:updatedKeys];
   return configUpdate;
   return configUpdate;
 }
 }
 
 
+- (NSDictionary<NSString *, NSDictionary *> *)getParameterKeyToRolloutMetadata:
+    (NSArray<NSDictionary *> *)rolloutMetadata {
+  NSMutableDictionary<NSString *, NSMutableDictionary *> *result =
+      [[NSMutableDictionary alloc] init];
+  for (NSDictionary *metadata in rolloutMetadata) {
+    NSString *rolloutId = metadata[RCNFetchResponseKeyRolloutID];
+    NSString *variantId = metadata[RCNFetchResponseKeyVariantID];
+    NSArray<NSString *> *affectedKeys = metadata[RCNFetchResponseKeyAffectedParameterKeys];
+    if (rolloutId && variantId && affectedKeys) {
+      for (NSString *key in affectedKeys) {
+        if (result[key]) {
+          NSMutableDictionary *rolloutIdToVariantId = result[key];
+          [rolloutIdToVariantId setValue:variantId forKey:rolloutId];
+        } else {
+          NSMutableDictionary *rolloutIdToVariantId = [@{rolloutId : variantId} mutableCopy];
+          [result setValue:rolloutIdToVariantId forKey:key];
+        }
+      }
+    }
+  }
+  return [result copy];
+}
+
 @end
 @end

+ 12 - 2
FirebaseRemoteConfig/Sources/RCNConfigDBManager.h

@@ -53,10 +53,12 @@ typedef void (^RCNDBCompletion)(BOOL success, NSDictionary *result);
 /// @param fetchedConfig  Return fetchedConfig loaded from DB
 /// @param fetchedConfig  Return fetchedConfig loaded from DB
 /// @param activeConfig  Return activeConfig loaded from DB
 /// @param activeConfig  Return activeConfig loaded from DB
 /// @param defaultConfig  Return defaultConfig loaded from DB
 /// @param defaultConfig  Return defaultConfig loaded from DB
+/// @param rolloutMetadata  Return fetched and active RolloutMetadata loaded from DB
 typedef void (^RCNDBLoadCompletion)(BOOL success,
 typedef void (^RCNDBLoadCompletion)(BOOL success,
                                     NSDictionary *fetchedConfig,
                                     NSDictionary *fetchedConfig,
                                     NSDictionary *activeConfig,
                                     NSDictionary *activeConfig,
-                                    NSDictionary *defaultConfig);
+                                    NSDictionary *defaultConfig,
+                                    NSDictionary *rolloutMetadata);
 
 
 /// Returns the current version of the Remote Config database.
 /// Returns the current version of the Remote Config database.
 + (NSString *)remoteConfigPathForDatabase;
 + (NSString *)remoteConfigPathForDatabase;
@@ -78,7 +80,6 @@ typedef void (^RCNDBLoadCompletion)(BOOL success,
 /// Load Personalization from table.
 /// Load Personalization from table.
 /// @param handler    The callback when reading from DB is complete.
 /// @param handler    The callback when reading from DB is complete.
 - (void)loadPersonalizationWithCompletionHandler:(RCNDBLoadCompletion)handler;
 - (void)loadPersonalizationWithCompletionHandler:(RCNDBLoadCompletion)handler;
-
 /// Insert a record in metadata table.
 /// Insert a record in metadata table.
 /// @param columnNameToValue The column name and its value to be inserted in metadata table.
 /// @param columnNameToValue The column name and its value to be inserted in metadata table.
 /// @param handler           The callback.
 /// @param handler           The callback.
@@ -110,6 +111,15 @@ typedef void (^RCNDBLoadCompletion)(BOOL success,
 /// Insert or update the data in Personalization config.
 /// Insert or update the data in Personalization config.
 - (BOOL)insertOrUpdatePersonalizationConfig:(NSDictionary *)metadata fromSource:(RCNDBSource)source;
 - (BOOL)insertOrUpdatePersonalizationConfig:(NSDictionary *)metadata fromSource:(RCNDBSource)source;
 
 
+/// Insert rollout metadata in rollout table.
+/// @param key        Key indicating whether rollout metadata is fetched or active and defined in
+/// RCNConfigDefines.h.
+/// @param metadataList      The metadata info for each rollout entry .
+/// @param handler    The callback.
+- (void)insertOrUpdateRolloutTableWithKey:(NSString *)key
+                                    value:(NSArray<NSDictionary *> *)metadataList
+                        completionHandler:(RCNDBCompletion)handler;
+
 /// Clear the record of given namespace and package name
 /// Clear the record of given namespace and package name
 /// before updating the table.
 /// before updating the table.
 - (void)deleteRecordFromMainTableWithNamespace:(NSString *)namespace_p
 - (void)deleteRecordFromMainTableWithNamespace:(NSString *)namespace_p

+ 111 - 11
FirebaseRemoteConfig/Sources/RCNConfigDBManager.m

@@ -31,6 +31,7 @@
 #define RCNTableNameInternalMetadata "internal_metadata"
 #define RCNTableNameInternalMetadata "internal_metadata"
 #define RCNTableNameExperiment "experiment"
 #define RCNTableNameExperiment "experiment"
 #define RCNTableNamePersonalization "personalization"
 #define RCNTableNamePersonalization "personalization"
+#define RCNTableNameRollout "rollout"
 
 
 static BOOL gIsNewDatabase;
 static BOOL gIsNewDatabase;
 /// SQLite file name in versions 0, 1 and 2.
 /// SQLite file name in versions 0, 1 and 2.
@@ -284,11 +285,14 @@ static NSArray *RemoteConfigMetadataTableColumnsInOrder(void) {
       "create TABLE IF NOT EXISTS " RCNTableNamePersonalization
       "create TABLE IF NOT EXISTS " RCNTableNamePersonalization
       " (_id INTEGER PRIMARY KEY, key INTEGER, value BLOB)";
       " (_id INTEGER PRIMARY KEY, key INTEGER, value BLOB)";
 
 
+  static const char *createTableRollout = "create TABLE IF NOT EXISTS " RCNTableNameRollout
+                                          " (_id INTEGER PRIMARY KEY, key TEXT, value BLOB)";
+
   return [self executeQuery:createTableMain] && [self executeQuery:createTableMainActive] &&
   return [self executeQuery:createTableMain] && [self executeQuery:createTableMainActive] &&
          [self executeQuery:createTableMainDefault] && [self executeQuery:createTableMetadata] &&
          [self executeQuery:createTableMainDefault] && [self executeQuery:createTableMetadata] &&
          [self executeQuery:createTableInternalMetadata] &&
          [self executeQuery:createTableInternalMetadata] &&
          [self executeQuery:createTableExperiment] &&
          [self executeQuery:createTableExperiment] &&
-         [self executeQuery:createTablePersonalization];
+         [self executeQuery:createTablePersonalization] && [self executeQuery:createTableRollout];
 }
 }
 
 
 - (void)removeDatabaseOnDatabaseQueueAtPath:(NSString *)path {
 - (void)removeDatabaseOnDatabaseQueueAtPath:(NSString *)path {
@@ -618,6 +622,52 @@ static NSArray *RemoteConfigMetadataTableColumnsInOrder(void) {
   return YES;
   return YES;
 }
 }
 
 
+- (void)insertOrUpdateRolloutTableWithKey:(NSString *)key
+                                    value:(NSArray<NSDictionary *> *)metadataList
+                        completionHandler:(RCNDBCompletion)handler {
+  dispatch_async(_databaseOperationQueue, ^{
+    BOOL success = [self insertOrUpdateRolloutTableWithKey:key value:metadataList];
+    if (handler) {
+      dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
+        handler(success, nil);
+      });
+    }
+  });
+}
+
+- (BOOL)insertOrUpdateRolloutTableWithKey:(NSString *)key
+                                    value:(NSArray<NSDictionary *> *)arrayValue {
+  RCN_MUST_NOT_BE_MAIN_THREAD();
+  NSError *error;
+  NSData *dataValue = [NSJSONSerialization dataWithJSONObject:arrayValue
+                                                      options:NSJSONWritingPrettyPrinted
+                                                        error:&error];
+  const char *SQL =
+      "INSERT OR REPLACE INTO " RCNTableNameRollout
+      " (_id, key, value) values ((SELECT _id from " RCNTableNameRollout " WHERE key = ?), ?, ?)";
+  sqlite3_stmt *statement = [self prepareSQL:SQL];
+  if (!statement) {
+    return NO;
+  }
+  if (![self bindStringToStatement:statement index:1 string:key]) {
+    return [self logErrorWithSQL:SQL finalizeStatement:statement returnValue:NO];
+  }
+
+  if (![self bindStringToStatement:statement index:2 string:key]) {
+    return [self logErrorWithSQL:SQL finalizeStatement:statement returnValue:NO];
+  }
+
+  if (sqlite3_bind_blob(statement, 3, dataValue.bytes, (int)dataValue.length, NULL) != SQLITE_OK) {
+    return [self logErrorWithSQL:SQL finalizeStatement:statement returnValue:NO];
+  }
+
+  if (sqlite3_step(statement) != SQLITE_DONE) {
+    return [self logErrorWithSQL:SQL finalizeStatement:statement returnValue:NO];
+  }
+  sqlite3_finalize(statement);
+  return YES;
+}
+
 #pragma mark - update
 #pragma mark - update
 
 
 - (void)updateMetadataWithOption:(RCNUpdateOption)option
 - (void)updateMetadataWithOption:(RCNUpdateOption)option
@@ -852,7 +902,6 @@ static NSArray *RemoteConfigMetadataTableColumnsInOrder(void) {
 - (NSMutableArray<NSData *> *)loadExperimentTableFromKey:(NSString *)key {
 - (NSMutableArray<NSData *> *)loadExperimentTableFromKey:(NSString *)key {
   RCN_MUST_NOT_BE_MAIN_THREAD();
   RCN_MUST_NOT_BE_MAIN_THREAD();
 
 
-  NSMutableArray *results = [[NSMutableArray alloc] init];
   const char *SQL = "SELECT value FROM " RCNTableNameExperiment " WHERE key = ?";
   const char *SQL = "SELECT value FROM " RCNTableNameExperiment " WHERE key = ?";
   sqlite3_stmt *statement = [self prepareSQL:SQL];
   sqlite3_stmt *statement = [self prepareSQL:SQL];
   if (!statement) {
   if (!statement) {
@@ -861,12 +910,49 @@ static NSArray *RemoteConfigMetadataTableColumnsInOrder(void) {
 
 
   NSArray *params = @[ key ];
   NSArray *params = @[ key ];
   [self bindStringsToStatement:statement stringArray:params];
   [self bindStringsToStatement:statement stringArray:params];
-  NSData *experimentData;
+  NSMutableArray *results = [self loadValuesFromStatement:statement];
+  return results;
+}
+
+- (NSArray<NSDictionary *> *)loadRolloutTableFromKey:(NSString *)key {
+  RCN_MUST_NOT_BE_MAIN_THREAD();
+  const char *SQL = "SELECT value FROM " RCNTableNameRollout " WHERE key = ?";
+  sqlite3_stmt *statement = [self prepareSQL:SQL];
+  if (!statement) {
+    return nil;
+  }
+  NSArray *params = @[ key ];
+  [self bindStringsToStatement:statement stringArray:params];
+  NSMutableArray *results = [self loadValuesFromStatement:statement];
+  // There should be only one entry in this table.
+  if (results.count != 1) {
+    return nil;
+  }
+  NSArray *rollout;
+  // Convert from NSData to NSArray
+  if (results[0]) {
+    NSError *error;
+    rollout = [NSJSONSerialization JSONObjectWithData:results[0] options:0 error:&error];
+    if (!rollout) {
+      FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000011",
+                  @"Failed to convert NSData to NSAarry for Rollout Metadata with error %@.",
+                  error);
+    }
+  }
+  if (!rollout) {
+    rollout = [[NSArray alloc] init];
+  }
+  return rollout;
+}
+
+- (NSMutableArray *)loadValuesFromStatement:(sqlite3_stmt *)statement {
+  NSMutableArray *results = [[NSMutableArray alloc] init];
+  NSData *value;
   while (sqlite3_step(statement) == SQLITE_ROW) {
   while (sqlite3_step(statement) == SQLITE_ROW) {
-    experimentData = [NSData dataWithBytes:(char *)sqlite3_column_blob(statement, 0)
-                                    length:sqlite3_column_bytes(statement, 0)];
-    if (experimentData) {
-      [results addObject:experimentData];
+    value = [NSData dataWithBytes:(char *)sqlite3_column_blob(statement, 0)
+                           length:sqlite3_column_bytes(statement, 0)];
+    if (value) {
+      [results addObject:value];
     }
     }
   }
   }
 
 
@@ -880,7 +966,7 @@ static NSArray *RemoteConfigMetadataTableColumnsInOrder(void) {
     RCNConfigDBManager *strongSelf = weakSelf;
     RCNConfigDBManager *strongSelf = weakSelf;
     if (!strongSelf) {
     if (!strongSelf) {
       dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
       dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
-        handler(NO, [NSMutableDictionary new], [NSMutableDictionary new], nil);
+        handler(NO, [NSMutableDictionary new], [NSMutableDictionary new], nil, nil);
       });
       });
       return;
       return;
     }
     }
@@ -913,7 +999,7 @@ static NSArray *RemoteConfigMetadataTableColumnsInOrder(void) {
 
 
     if (handler) {
     if (handler) {
       dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
       dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
-        handler(YES, fetchedPersonalization, activePersonalization, nil);
+        handler(YES, fetchedPersonalization, activePersonalization, nil, nil);
       });
       });
     }
     }
   });
   });
@@ -987,7 +1073,7 @@ static NSArray *RemoteConfigMetadataTableColumnsInOrder(void) {
     RCNConfigDBManager *strongSelf = weakSelf;
     RCNConfigDBManager *strongSelf = weakSelf;
     if (!strongSelf) {
     if (!strongSelf) {
       dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
       dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
-        handler(NO, [NSDictionary new], [NSDictionary new], [NSDictionary new]);
+        handler(NO, [NSDictionary new], [NSDictionary new], [NSDictionary new], [NSDictionary new]);
       });
       });
       return;
       return;
     }
     }
@@ -1000,12 +1086,26 @@ static NSArray *RemoteConfigMetadataTableColumnsInOrder(void) {
     __block NSDictionary *defaultConfig =
     __block NSDictionary *defaultConfig =
         [strongSelf loadMainTableWithBundleIdentifier:bundleIdentifier
         [strongSelf loadMainTableWithBundleIdentifier:bundleIdentifier
                                            fromSource:RCNDBSourceDefault];
                                            fromSource:RCNDBSourceDefault];
+
+    __block NSArray<NSDictionary *> *fetchedRolloutMetadata =
+        [strongSelf loadRolloutTableFromKey:@RCNRolloutTableKeyFetchedMetadata];
+    __block NSArray<NSDictionary *> *activeRolloutMetadata =
+        [strongSelf loadRolloutTableFromKey:@RCNRolloutTableKeyActiveMetadata];
+
     if (handler) {
     if (handler) {
       dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
       dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
         fetchedConfig = fetchedConfig ? fetchedConfig : [[NSDictionary alloc] init];
         fetchedConfig = fetchedConfig ? fetchedConfig : [[NSDictionary alloc] init];
         activeConfig = activeConfig ? activeConfig : [[NSDictionary alloc] init];
         activeConfig = activeConfig ? activeConfig : [[NSDictionary alloc] init];
         defaultConfig = defaultConfig ? defaultConfig : [[NSDictionary alloc] init];
         defaultConfig = defaultConfig ? defaultConfig : [[NSDictionary alloc] init];
-        handler(YES, fetchedConfig, activeConfig, defaultConfig);
+        fetchedRolloutMetadata =
+            fetchedRolloutMetadata ? fetchedRolloutMetadata : [[NSArray alloc] init];
+        activeRolloutMetadata =
+            activeRolloutMetadata ? activeRolloutMetadata : [[NSArray alloc] init];
+        NSDictionary *rolloutMetadata = @{
+          @RCNRolloutTableKeyActiveMetadata : [activeRolloutMetadata copy],
+          @RCNRolloutTableKeyFetchedMetadata : [fetchedRolloutMetadata copy]
+        };
+        handler(YES, fetchedConfig, activeConfig, defaultConfig, rolloutMetadata);
       });
       });
     }
     }
   });
   });

+ 2 - 0
FirebaseRemoteConfig/Sources/RCNConfigDefines.h

@@ -31,5 +31,7 @@
 #define RCNExperimentTableKeyPayload "experiment_payload"
 #define RCNExperimentTableKeyPayload "experiment_payload"
 #define RCNExperimentTableKeyMetadata "experiment_metadata"
 #define RCNExperimentTableKeyMetadata "experiment_metadata"
 #define RCNExperimentTableKeyActivePayload "experiment_active_payload"
 #define RCNExperimentTableKeyActivePayload "experiment_active_payload"
+#define RCNRolloutTableKeyActiveMetadata "active_rollout_metadata"
+#define RCNRolloutTableKeyFetchedMetadata "fetched_rollout_metadata"
 
 
 #endif
 #endif

+ 3 - 2
FirebaseRemoteConfig/Sources/RCNConfigFetch.m

@@ -25,6 +25,7 @@
 #import "FirebaseRemoteConfig/Sources/RCNConfigContent.h"
 #import "FirebaseRemoteConfig/Sources/RCNConfigContent.h"
 #import "FirebaseRemoteConfig/Sources/RCNConfigExperiment.h"
 #import "FirebaseRemoteConfig/Sources/RCNConfigExperiment.h"
 #import "FirebaseRemoteConfig/Sources/RCNDevice.h"
 #import "FirebaseRemoteConfig/Sources/RCNDevice.h"
+@import FirebaseRemoteConfigInterop;
 
 
 #ifdef RCN_STAGING_SERVER
 #ifdef RCN_STAGING_SERVER
 static NSString *const kServerURLDomain =
 static NSString *const kServerURLDomain =
@@ -105,7 +106,7 @@ static NSInteger const kRCNFetchResponseHTTPStatusCodeGatewayTimeout = 504;
     _content = content;
     _content = content;
     _fetchSession = [self newFetchSession];
     _fetchSession = [self newFetchSession];
     _options = options;
     _options = options;
-    _templateVersionNumber = [self->_settings lastTemplateVersion];
+    _templateVersionNumber = [self->_settings lastFetchedTemplateVersion];
   }
   }
   return self;
   return self;
 }
 }
@@ -572,7 +573,7 @@ static NSInteger const kRCNFetchResponseHTTPStatusCodeGatewayTimeout = 504;
         // Update experiments only for 3p namespace
         // Update experiments only for 3p namespace
         NSString *namespace = [strongSelf->_FIRNamespace
         NSString *namespace = [strongSelf->_FIRNamespace
             substringToIndex:[strongSelf->_FIRNamespace rangeOfString:@":"].location];
             substringToIndex:[strongSelf->_FIRNamespace rangeOfString:@":"].location];
-        if ([namespace isEqualToString:FIRNamespaceGoogleMobilePlatform]) {
+        if ([namespace isEqualToString:FIRRemoteConfigConstants.FIRNamespaceGoogleMobilePlatform]) {
           [strongSelf->_experiment updateExperimentsWithResponse:
           [strongSelf->_experiment updateExperimentsWithResponse:
                                        fetchedConfig[RCNFetchResponseKeyExperimentDescriptions]];
                                        fetchedConfig[RCNFetchResponseKeyExperimentDescriptions]];
         }
         }

+ 9 - 2
FirebaseRemoteConfig/Sources/RCNConfigSettings.m

@@ -110,7 +110,8 @@ static const int kRCNExponentialBackoffMaximumInterval = 60 * 60 * 4;  // 4 hour
     }
     }
 
 
     _isFetchInProgress = NO;
     _isFetchInProgress = NO;
-    _lastTemplateVersion = [_userDefaultsManager lastTemplateVersion];
+    _lastFetchedTemplateVersion = [_userDefaultsManager lastFetchedTemplateVersion];
+    _lastActiveTemplateVersion = [_userDefaultsManager lastActiveTemplateVersion];
     _realtimeExponentialBackoffRetryInterval =
     _realtimeExponentialBackoffRetryInterval =
         [_userDefaultsManager currentRealtimeThrottlingRetryIntervalSeconds];
         [_userDefaultsManager currentRealtimeThrottlingRetryIntervalSeconds];
     _realtimeExponentialBackoffThrottleEndTime = [_userDefaultsManager realtimeThrottleEndTime];
     _realtimeExponentialBackoffThrottleEndTime = [_userDefaultsManager realtimeThrottleEndTime];
@@ -292,7 +293,8 @@ static const int kRCNExponentialBackoffMaximumInterval = 60 * 60 * 4;  // 4 hour
     [self updateLastFetchTimeInterval:[[NSDate date] timeIntervalSince1970]];
     [self updateLastFetchTimeInterval:[[NSDate date] timeIntervalSince1970]];
     // Note: We expect the googleAppID to always be available.
     // Note: We expect the googleAppID to always be available.
     _deviceContext = FIRRemoteConfigDeviceContextWithProjectIdentifier(_googleAppID);
     _deviceContext = FIRRemoteConfigDeviceContextWithProjectIdentifier(_googleAppID);
-    [_userDefaultsManager setLastTemplateVersion:templateVersion];
+    _lastFetchedTemplateVersion = templateVersion;
+    [_userDefaultsManager setLastFetchedTemplateVersion:templateVersion];
   }
   }
 
 
   [self updateMetadataTable];
   [self updateMetadataTable];
@@ -377,6 +379,11 @@ static const int kRCNExponentialBackoffMaximumInterval = 60 * 60 * 4;  // 4 hour
   [_DBManager insertMetadataTableWithValues:columnNameToValue completionHandler:nil];
   [_DBManager insertMetadataTableWithValues:columnNameToValue completionHandler:nil];
 }
 }
 
 
+- (void)updateLastActiveTemplateVersion {
+  _lastActiveTemplateVersion = _lastFetchedTemplateVersion;
+  [_userDefaultsManager setLastActiveTemplateVersion:_lastActiveTemplateVersion];
+}
+
 #pragma mark - fetch request
 #pragma mark - fetch request
 
 
 /// Returns a fetch request with the latest device and config change.
 /// Returns a fetch request with the latest device and config change.

+ 1 - 0
FirebaseRemoteConfig/Sources/RCNConstants3P.m

@@ -17,4 +17,5 @@
 #import "FirebaseRemoteConfig/Sources/Public/FirebaseRemoteConfig/FIRRemoteConfig.h"
 #import "FirebaseRemoteConfig/Sources/Public/FirebaseRemoteConfig/FIRRemoteConfig.h"
 
 
 /// Firebase Remote Config service default namespace.
 /// Firebase Remote Config service default namespace.
+/// TODO(doudounan): Change to use this namespace defined in RemoteConfigInterop.
 NSString *const FIRNamespaceGoogleMobilePlatform = @"firebase";
 NSString *const FIRNamespaceGoogleMobilePlatform = @"firebase";

+ 3 - 1
FirebaseRemoteConfig/Sources/RCNUserDefaultsManager.h

@@ -44,7 +44,9 @@ NS_ASSUME_NONNULL_BEGIN
 /// Realtime retry count.
 /// Realtime retry count.
 @property(nonatomic, assign) int realtimeRetryCount;
 @property(nonatomic, assign) int realtimeRetryCount;
 /// Last fetched template version.
 /// Last fetched template version.
-@property(nonatomic, assign) NSString *lastTemplateVersion;
+@property(nonatomic, assign) NSString *lastFetchedTemplateVersion;
+/// Last active template version.
+@property(nonatomic, assign) NSString *lastActiveTemplateVersion;
 
 
 /// Designated initializer.
 /// Designated initializer.
 - (instancetype)initWithAppName:(NSString *)appName
 - (instancetype)initWithAppName:(NSString *)appName

+ 17 - 2
FirebaseRemoteConfig/Sources/RCNUserDefaultsManager.m

@@ -111,7 +111,7 @@ static NSString *const kRCNUserDefaultsKeyNameRealtimeRetryCount = @"realtimeRet
   }
   }
 }
 }
 
 
-- (NSString *)lastTemplateVersion {
+- (NSString *)lastFetchedTemplateVersion {
   NSDictionary *userDefaults = [self instanceUserDefaults];
   NSDictionary *userDefaults = [self instanceUserDefaults];
   if ([userDefaults objectForKey:RCNFetchResponseKeyTemplateVersion]) {
   if ([userDefaults objectForKey:RCNFetchResponseKeyTemplateVersion]) {
     return [userDefaults objectForKey:RCNFetchResponseKeyTemplateVersion];
     return [userDefaults objectForKey:RCNFetchResponseKeyTemplateVersion];
@@ -120,12 +120,27 @@ static NSString *const kRCNUserDefaultsKeyNameRealtimeRetryCount = @"realtimeRet
   return @"0";
   return @"0";
 }
 }
 
 
-- (void)setLastTemplateVersion:(NSString *)templateVersion {
+- (void)setLastFetchedTemplateVersion:(NSString *)templateVersion {
   if (templateVersion) {
   if (templateVersion) {
     [self setInstanceUserDefaultsValue:templateVersion forKey:RCNFetchResponseKeyTemplateVersion];
     [self setInstanceUserDefaultsValue:templateVersion forKey:RCNFetchResponseKeyTemplateVersion];
   }
   }
 }
 }
 
 
+- (NSString *)lastActiveTemplateVersion {
+  NSDictionary *userDefaults = [self instanceUserDefaults];
+  if ([userDefaults objectForKey:RCNActiveKeyTemplateVersion]) {
+    return [userDefaults objectForKey:RCNActiveKeyTemplateVersion];
+  }
+
+  return @"0";
+}
+
+- (void)setLastActiveTemplateVersion:(NSString *)templateVersion {
+  if (templateVersion) {
+    [self setInstanceUserDefaultsValue:templateVersion forKey:RCNActiveKeyTemplateVersion];
+  }
+}
+
 - (NSTimeInterval)lastETagUpdateTime {
 - (NSTimeInterval)lastETagUpdateTime {
   NSNumber *lastETagUpdateTime =
   NSNumber *lastETagUpdateTime =
       [[self instanceUserDefaults] objectForKey:kRCNUserDefaultsKeyNamelastETagUpdateTime];
       [[self instanceUserDefaults] objectForKey:kRCNUserDefaultsKeyNamelastETagUpdateTime];

+ 1059 - 0
FirebaseRemoteConfig/Tests/FeatureRolloutsTestApp/FeatureRolloutsTestApp.xcodeproj/project.pbxproj

@@ -0,0 +1,1059 @@
+// !$*UTF8*$!
+{
+	archiveVersion = 1;
+	classes = {
+	};
+	objectVersion = 56;
+	objects = {
+
+/* Begin PBXBuildFile section */
+		61A5654706089C41A7398CF3 /* Pods_FeatureRolloutsTestApp_iOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2FE5945D035CAAB3297D2CAC /* Pods_FeatureRolloutsTestApp_iOS.framework */; };
+		848D345C8969AF72BCC0E2E4 /* Pods_FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1729B0ED2CACB9C5A62A6F8C /* Pods_FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS.framework */; };
+		951D70152B71AD9B00BE7EED /* RemoteConfigButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 951D70142B71AD9A00BE7EED /* RemoteConfigButtonView.swift */; };
+		951D70162B71AD9B00BE7EED /* RemoteConfigButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 951D70142B71AD9A00BE7EED /* RemoteConfigButtonView.swift */; };
+		AD11C57C978D52894BFDC47F /* Pods_FeatureRolloutsTestApp_RemoteConfig_iOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4E60230146BDE14D306856CB /* Pods_FeatureRolloutsTestApp_RemoteConfig_iOS.framework */; };
+		C427C4A32B4603F60088A488 /* FeatureRolloutsTestAppApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C427C4A22B4603F60088A488 /* FeatureRolloutsTestAppApp.swift */; };
+		C427C4A52B4603F60088A488 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C427C4A42B4603F60088A488 /* ContentView.swift */; };
+		C49C486C2B4704D900BC1456 /* FeatureRolloutsTestAppApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C427C4A22B4603F60088A488 /* FeatureRolloutsTestAppApp.swift */; };
+		C49C48702B4704F300BC1456 /* FeatureRolloutsTestAppApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C427C4A22B4603F60088A488 /* FeatureRolloutsTestAppApp.swift */; };
+		C49C487A2B4704F500BC1456 /* FeatureRolloutsTestAppApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C427C4A22B4603F60088A488 /* FeatureRolloutsTestAppApp.swift */; };
+		C49C48832B47074400BC1456 /* FirebaseCrashlytics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C49C48822B47074400BC1456 /* FirebaseCrashlytics.framework */; };
+		C49C48872B47075600BC1456 /* FirebaseRemoteConfig.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C49C48862B47075600BC1456 /* FirebaseRemoteConfig.framework */; };
+		C49C488B2B47075C00BC1456 /* FirebaseCrashlytics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C49C488A2B47075C00BC1456 /* FirebaseCrashlytics.framework */; };
+		C49C488F2B47076200BC1456 /* FirebaseRemoteConfig.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C49C488E2B47076200BC1456 /* FirebaseRemoteConfig.framework */; };
+		C49C48952B47207200BC1456 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49C48942B47207200BC1456 /* ContentView.swift */; };
+		C49C48992B4720AE00BC1456 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49C48982B4720AE00BC1456 /* ContentView.swift */; };
+		C49C489C2B4720DD00BC1456 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49C489B2B4720DD00BC1456 /* ContentView.swift */; };
+		C49C489E2B4722C100BC1456 /* CrashButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49C489D2B4722C100BC1456 /* CrashButtonView.swift */; };
+		C49C489F2B47233000BC1456 /* CrashButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49C489D2B4722C100BC1456 /* CrashButtonView.swift */; };
+		C49C48A12B47261000BC1456 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = C49C48A02B47261000BC1456 /* GoogleService-Info.plist */; };
+		C49C48A22B47261000BC1456 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = C49C48A02B47261000BC1456 /* GoogleService-Info.plist */; };
+		C49C48A32B47261000BC1456 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = C49C48A02B47261000BC1456 /* GoogleService-Info.plist */; };
+		C49C48A42B47261000BC1456 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = C49C48A02B47261000BC1456 /* GoogleService-Info.plist */; };
+		F07A9478976524A8264259F0 /* Pods_FeatureRolloutsTestApp_Crashlytics_iOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5472955122D0CE0A8A3CE4D5 /* Pods_FeatureRolloutsTestApp_Crashlytics_iOS.framework */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+		C49C48852B47074400BC1456 /* Embed Frameworks */ = {
+			isa = PBXCopyFilesBuildPhase;
+			buildActionMask = 2147483647;
+			dstPath = "";
+			dstSubfolderSpec = 10;
+			files = (
+			);
+			name = "Embed Frameworks";
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+		C49C48892B47075600BC1456 /* Embed Frameworks */ = {
+			isa = PBXCopyFilesBuildPhase;
+			buildActionMask = 2147483647;
+			dstPath = "";
+			dstSubfolderSpec = 10;
+			files = (
+			);
+			name = "Embed Frameworks";
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+		C49C488D2B47075C00BC1456 /* Embed Frameworks */ = {
+			isa = PBXCopyFilesBuildPhase;
+			buildActionMask = 2147483647;
+			dstPath = "";
+			dstSubfolderSpec = 10;
+			files = (
+			);
+			name = "Embed Frameworks";
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXCopyFilesBuildPhase section */
+
+/* Begin PBXFileReference section */
+		025F972344BB0B489CC052D6 /* Pods-FeatureRolloutsTestApp_Crashlytics_iOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FeatureRolloutsTestApp_Crashlytics_iOS.debug.xcconfig"; path = "Target Support Files/Pods-FeatureRolloutsTestApp_Crashlytics_iOS/Pods-FeatureRolloutsTestApp_Crashlytics_iOS.debug.xcconfig"; sourceTree = "<group>"; };
+		10710CAF870FA7E8D1ABF94C /* Pods-FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS.release.xcconfig"; path = "Target Support Files/Pods-FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS/Pods-FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS.release.xcconfig"; sourceTree = "<group>"; };
+		1729B0ED2CACB9C5A62A6F8C /* Pods_FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+		2842D338F32EE531C752262E /* Pods-FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS.debug.xcconfig"; path = "Target Support Files/Pods-FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS/Pods-FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS.debug.xcconfig"; sourceTree = "<group>"; };
+		2D15DD53784CDDE94D00AB02 /* Pods-FeatureRolloutsTestApp_iOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FeatureRolloutsTestApp_iOS.release.xcconfig"; path = "Target Support Files/Pods-FeatureRolloutsTestApp_iOS/Pods-FeatureRolloutsTestApp_iOS.release.xcconfig"; sourceTree = "<group>"; };
+		2FE5945D035CAAB3297D2CAC /* Pods_FeatureRolloutsTestApp_iOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_FeatureRolloutsTestApp_iOS.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+		30BB126FCB8D5F53B5795500 /* Pods-FeatureRolloutsTestApp_RemoteConfig_iOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FeatureRolloutsTestApp_RemoteConfig_iOS.release.xcconfig"; path = "Target Support Files/Pods-FeatureRolloutsTestApp_RemoteConfig_iOS/Pods-FeatureRolloutsTestApp_RemoteConfig_iOS.release.xcconfig"; sourceTree = "<group>"; };
+		4E60230146BDE14D306856CB /* Pods_FeatureRolloutsTestApp_RemoteConfig_iOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_FeatureRolloutsTestApp_RemoteConfig_iOS.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+		5472955122D0CE0A8A3CE4D5 /* Pods_FeatureRolloutsTestApp_Crashlytics_iOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_FeatureRolloutsTestApp_Crashlytics_iOS.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+		6D30A6D1F2CE622B6D5D563F /* Pods-FeatureRolloutsTestApp_iOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FeatureRolloutsTestApp_iOS.debug.xcconfig"; path = "Target Support Files/Pods-FeatureRolloutsTestApp_iOS/Pods-FeatureRolloutsTestApp_iOS.debug.xcconfig"; sourceTree = "<group>"; };
+		8BA72854B19D7A9D9BE15E1D /* Pods-FeatureRolloutsTestApp_Crashlytics_iOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FeatureRolloutsTestApp_Crashlytics_iOS.release.xcconfig"; path = "Target Support Files/Pods-FeatureRolloutsTestApp_Crashlytics_iOS/Pods-FeatureRolloutsTestApp_Crashlytics_iOS.release.xcconfig"; sourceTree = "<group>"; };
+		951D70142B71AD9A00BE7EED /* RemoteConfigButtonView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteConfigButtonView.swift; sourceTree = "<group>"; };
+		AF260B513E38B2528E7B13CC /* Pods-FeatureRolloutsTestApp_RemoteConfig_iOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FeatureRolloutsTestApp_RemoteConfig_iOS.debug.xcconfig"; path = "Target Support Files/Pods-FeatureRolloutsTestApp_RemoteConfig_iOS/Pods-FeatureRolloutsTestApp_RemoteConfig_iOS.debug.xcconfig"; sourceTree = "<group>"; };
+		C427C49F2B4603F60088A488 /* FeatureRolloutsTestApp_iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FeatureRolloutsTestApp_iOS.app; sourceTree = BUILT_PRODUCTS_DIR; };
+		C427C4A22B4603F60088A488 /* FeatureRolloutsTestAppApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureRolloutsTestAppApp.swift; sourceTree = "<group>"; };
+		C427C4A42B4603F60088A488 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
+		C427C4A82B4603F80088A488 /* FeatureRolloutsTestApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FeatureRolloutsTestApp.entitlements; sourceTree = "<group>"; };
+		C49C48412B460FC600BC1456 /* FeatureRolloutsTestApp_Crashlytics_iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FeatureRolloutsTestApp_Crashlytics_iOS.app; sourceTree = BUILT_PRODUCTS_DIR; };
+		C49C48772B4704F300BC1456 /* FeatureRolloutsTestApp_RemoteConfig_iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FeatureRolloutsTestApp_RemoteConfig_iOS.app; sourceTree = BUILT_PRODUCTS_DIR; };
+		C49C48812B4704F500BC1456 /* FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS.app; sourceTree = BUILT_PRODUCTS_DIR; };
+		C49C48822B47074400BC1456 /* FirebaseCrashlytics.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = FirebaseCrashlytics.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+		C49C48862B47075600BC1456 /* FirebaseRemoteConfig.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = FirebaseRemoteConfig.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+		C49C488A2B47075C00BC1456 /* FirebaseCrashlytics.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = FirebaseCrashlytics.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+		C49C488E2B47076200BC1456 /* FirebaseRemoteConfig.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = FirebaseRemoteConfig.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+		C49C48942B47207200BC1456 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
+		C49C48982B4720AE00BC1456 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
+		C49C489B2B4720DD00BC1456 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
+		C49C489D2B4722C100BC1456 /* CrashButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashButtonView.swift; sourceTree = "<group>"; };
+		C49C48A02B47261000BC1456 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+		C427C49C2B4603F60088A488 /* Frameworks */ = {
+			isa = PBXFrameworksBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				61A5654706089C41A7398CF3 /* Pods_FeatureRolloutsTestApp_iOS.framework in Frameworks */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+		C49C483E2B460FC600BC1456 /* Frameworks */ = {
+			isa = PBXFrameworksBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				F07A9478976524A8264259F0 /* Pods_FeatureRolloutsTestApp_Crashlytics_iOS.framework in Frameworks */,
+				C49C48832B47074400BC1456 /* FirebaseCrashlytics.framework in Frameworks */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+		C49C48722B4704F300BC1456 /* Frameworks */ = {
+			isa = PBXFrameworksBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				AD11C57C978D52894BFDC47F /* Pods_FeatureRolloutsTestApp_RemoteConfig_iOS.framework in Frameworks */,
+				C49C48872B47075600BC1456 /* FirebaseRemoteConfig.framework in Frameworks */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+		C49C487C2B4704F500BC1456 /* Frameworks */ = {
+			isa = PBXFrameworksBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				C49C488F2B47076200BC1456 /* FirebaseRemoteConfig.framework in Frameworks */,
+				848D345C8969AF72BCC0E2E4 /* Pods_FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS.framework in Frameworks */,
+				C49C488B2B47075C00BC1456 /* FirebaseCrashlytics.framework in Frameworks */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+		29E7B4F9D5112B2AFBA1C6F8 /* Pods */ = {
+			isa = PBXGroup;
+			children = (
+				2842D338F32EE531C752262E /* Pods-FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS.debug.xcconfig */,
+				10710CAF870FA7E8D1ABF94C /* Pods-FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS.release.xcconfig */,
+				025F972344BB0B489CC052D6 /* Pods-FeatureRolloutsTestApp_Crashlytics_iOS.debug.xcconfig */,
+				8BA72854B19D7A9D9BE15E1D /* Pods-FeatureRolloutsTestApp_Crashlytics_iOS.release.xcconfig */,
+				AF260B513E38B2528E7B13CC /* Pods-FeatureRolloutsTestApp_RemoteConfig_iOS.debug.xcconfig */,
+				30BB126FCB8D5F53B5795500 /* Pods-FeatureRolloutsTestApp_RemoteConfig_iOS.release.xcconfig */,
+				6D30A6D1F2CE622B6D5D563F /* Pods-FeatureRolloutsTestApp_iOS.debug.xcconfig */,
+				2D15DD53784CDDE94D00AB02 /* Pods-FeatureRolloutsTestApp_iOS.release.xcconfig */,
+			);
+			path = Pods;
+			sourceTree = "<group>";
+		};
+		4D9F4C8E7175D4479AD28BAC /* Frameworks */ = {
+			isa = PBXGroup;
+			children = (
+				C49C488E2B47076200BC1456 /* FirebaseRemoteConfig.framework */,
+				C49C488A2B47075C00BC1456 /* FirebaseCrashlytics.framework */,
+				C49C48862B47075600BC1456 /* FirebaseRemoteConfig.framework */,
+				C49C48822B47074400BC1456 /* FirebaseCrashlytics.framework */,
+				1729B0ED2CACB9C5A62A6F8C /* Pods_FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS.framework */,
+				5472955122D0CE0A8A3CE4D5 /* Pods_FeatureRolloutsTestApp_Crashlytics_iOS.framework */,
+				4E60230146BDE14D306856CB /* Pods_FeatureRolloutsTestApp_RemoteConfig_iOS.framework */,
+				2FE5945D035CAAB3297D2CAC /* Pods_FeatureRolloutsTestApp_iOS.framework */,
+			);
+			name = Frameworks;
+			sourceTree = "<group>";
+		};
+		C427C4962B4603F60088A488 = {
+			isa = PBXGroup;
+			children = (
+				C49C489A2B4720C700BC1456 /* FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS */,
+				C49C48972B47208B00BC1456 /* FeatureRolloutsTestApp_RemoteConfig_iOS */,
+				C49C48932B47205400BC1456 /* FeatureRolloutsTestApp_Crashlytics_iOS */,
+				C427C4A12B4603F60088A488 /* FeatureRolloutsTestApp */,
+				C49C486B2B47048000BC1456 /* Shared */,
+				C427C4A02B4603F60088A488 /* Products */,
+				29E7B4F9D5112B2AFBA1C6F8 /* Pods */,
+				4D9F4C8E7175D4479AD28BAC /* Frameworks */,
+			);
+			sourceTree = "<group>";
+		};
+		C427C4A02B4603F60088A488 /* Products */ = {
+			isa = PBXGroup;
+			children = (
+				C427C49F2B4603F60088A488 /* FeatureRolloutsTestApp_iOS.app */,
+				C49C48412B460FC600BC1456 /* FeatureRolloutsTestApp_Crashlytics_iOS.app */,
+				C49C48772B4704F300BC1456 /* FeatureRolloutsTestApp_RemoteConfig_iOS.app */,
+				C49C48812B4704F500BC1456 /* FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS.app */,
+			);
+			name = Products;
+			sourceTree = "<group>";
+		};
+		C427C4A12B4603F60088A488 /* FeatureRolloutsTestApp */ = {
+			isa = PBXGroup;
+			children = (
+				C427C4A42B4603F60088A488 /* ContentView.swift */,
+				C427C4A82B4603F80088A488 /* FeatureRolloutsTestApp.entitlements */,
+			);
+			path = FeatureRolloutsTestApp;
+			sourceTree = "<group>";
+		};
+		C49C486B2B47048000BC1456 /* Shared */ = {
+			isa = PBXGroup;
+			children = (
+				C49C48A02B47261000BC1456 /* GoogleService-Info.plist */,
+				C427C4A22B4603F60088A488 /* FeatureRolloutsTestAppApp.swift */,
+				951D70142B71AD9A00BE7EED /* RemoteConfigButtonView.swift */,
+				C49C489D2B4722C100BC1456 /* CrashButtonView.swift */,
+			);
+			path = Shared;
+			sourceTree = "<group>";
+		};
+		C49C48932B47205400BC1456 /* FeatureRolloutsTestApp_Crashlytics_iOS */ = {
+			isa = PBXGroup;
+			children = (
+				C49C48942B47207200BC1456 /* ContentView.swift */,
+			);
+			path = FeatureRolloutsTestApp_Crashlytics_iOS;
+			sourceTree = "<group>";
+		};
+		C49C48972B47208B00BC1456 /* FeatureRolloutsTestApp_RemoteConfig_iOS */ = {
+			isa = PBXGroup;
+			children = (
+				C49C48982B4720AE00BC1456 /* ContentView.swift */,
+			);
+			path = FeatureRolloutsTestApp_RemoteConfig_iOS;
+			sourceTree = "<group>";
+		};
+		C49C489A2B4720C700BC1456 /* FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS */ = {
+			isa = PBXGroup;
+			children = (
+				C49C489B2B4720DD00BC1456 /* ContentView.swift */,
+			);
+			path = FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS;
+			sourceTree = "<group>";
+		};
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+		C427C49E2B4603F60088A488 /* FeatureRolloutsTestApp_iOS */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = C427C4C42B4603F80088A488 /* Build configuration list for PBXNativeTarget "FeatureRolloutsTestApp_iOS" */;
+			buildPhases = (
+				1C5C78EEA66C3693218AA186 /* [CP] Check Pods Manifest.lock */,
+				C427C49B2B4603F60088A488 /* Sources */,
+				C427C49C2B4603F60088A488 /* Frameworks */,
+				C427C49D2B4603F60088A488 /* Resources */,
+				3FCED6F36D70B363DD56F3FB /* [CP] Embed Pods Frameworks */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+			);
+			name = FeatureRolloutsTestApp_iOS;
+			productName = FeatureRolloutsTestApp;
+			productReference = C427C49F2B4603F60088A488 /* FeatureRolloutsTestApp_iOS.app */;
+			productType = "com.apple.product-type.application";
+		};
+		C49C48402B460FC600BC1456 /* FeatureRolloutsTestApp_Crashlytics_iOS */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = C49C48622B460FC800BC1456 /* Build configuration list for PBXNativeTarget "FeatureRolloutsTestApp_Crashlytics_iOS" */;
+			buildPhases = (
+				E894A1751ED4EBA12174B475 /* [CP] Check Pods Manifest.lock */,
+				C49C483D2B460FC600BC1456 /* Sources */,
+				C49C483E2B460FC600BC1456 /* Frameworks */,
+				C49C483F2B460FC600BC1456 /* Resources */,
+				8809A9DD2155751AF47F697B /* [CP] Embed Pods Frameworks */,
+				C49C48852B47074400BC1456 /* Embed Frameworks */,
+				C49C48A72B47285600BC1456 /* Crashlytics run script */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+			);
+			name = FeatureRolloutsTestApp_Crashlytics_iOS;
+			productName = FeatureRolloutsTestApp_Crashlytics_iOS;
+			productReference = C49C48412B460FC600BC1456 /* FeatureRolloutsTestApp_Crashlytics_iOS.app */;
+			productType = "com.apple.product-type.application";
+		};
+		C49C486E2B4704F300BC1456 /* FeatureRolloutsTestApp_RemoteConfig_iOS */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = C49C48742B4704F300BC1456 /* Build configuration list for PBXNativeTarget "FeatureRolloutsTestApp_RemoteConfig_iOS" */;
+			buildPhases = (
+				2E2005BA5C63DC9C5F6B0B5C /* [CP] Check Pods Manifest.lock */,
+				C49C486F2B4704F300BC1456 /* Sources */,
+				C49C48722B4704F300BC1456 /* Frameworks */,
+				C49C48732B4704F300BC1456 /* Resources */,
+				AE72CFC82C05D96F24F22349 /* [CP] Embed Pods Frameworks */,
+				C49C48892B47075600BC1456 /* Embed Frameworks */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+			);
+			name = FeatureRolloutsTestApp_RemoteConfig_iOS;
+			productName = FeatureRolloutsTestApp_Crashlytics_iOS;
+			productReference = C49C48772B4704F300BC1456 /* FeatureRolloutsTestApp_RemoteConfig_iOS.app */;
+			productType = "com.apple.product-type.application";
+		};
+		C49C48782B4704F500BC1456 /* FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = C49C487E2B4704F500BC1456 /* Build configuration list for PBXNativeTarget "FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS" */;
+			buildPhases = (
+				1D1A7736C1202CF5AE3E74DF /* [CP] Check Pods Manifest.lock */,
+				C49C48792B4704F500BC1456 /* Sources */,
+				C49C487C2B4704F500BC1456 /* Frameworks */,
+				C49C487D2B4704F500BC1456 /* Resources */,
+				1AFC789ACC0369540ADCC334 /* [CP] Embed Pods Frameworks */,
+				C49C488D2B47075C00BC1456 /* Embed Frameworks */,
+				C49C48A52B47279000BC1456 /* Crashlytics run script */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+			);
+			name = FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS;
+			productName = FeatureRolloutsTestApp_Crashlytics_iOS;
+			productReference = C49C48812B4704F500BC1456 /* FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS.app */;
+			productType = "com.apple.product-type.application";
+		};
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+		C427C4972B4603F60088A488 /* Project object */ = {
+			isa = PBXProject;
+			attributes = {
+				BuildIndependentTargetsInParallel = 1;
+				LastSwiftUpdateCheck = 1500;
+				LastUpgradeCheck = 1500;
+				TargetAttributes = {
+					C427C49E2B4603F60088A488 = {
+						CreatedOnToolsVersion = 15.0;
+					};
+					C49C48402B460FC600BC1456 = {
+						CreatedOnToolsVersion = 15.0;
+					};
+				};
+			};
+			buildConfigurationList = C427C49A2B4603F60088A488 /* Build configuration list for PBXProject "FeatureRolloutsTestApp" */;
+			compatibilityVersion = "Xcode 14.0";
+			developmentRegion = en;
+			hasScannedForEncodings = 0;
+			knownRegions = (
+				en,
+				Base,
+			);
+			mainGroup = C427C4962B4603F60088A488;
+			productRefGroup = C427C4A02B4603F60088A488 /* Products */;
+			projectDirPath = "";
+			projectRoot = "";
+			targets = (
+				C49C48782B4704F500BC1456 /* FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS */,
+				C49C48402B460FC600BC1456 /* FeatureRolloutsTestApp_Crashlytics_iOS */,
+				C49C486E2B4704F300BC1456 /* FeatureRolloutsTestApp_RemoteConfig_iOS */,
+				C427C49E2B4603F60088A488 /* FeatureRolloutsTestApp_iOS */,
+			);
+		};
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+		C427C49D2B4603F60088A488 /* Resources */ = {
+			isa = PBXResourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				C49C48A12B47261000BC1456 /* GoogleService-Info.plist in Resources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+		C49C483F2B460FC600BC1456 /* Resources */ = {
+			isa = PBXResourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				C49C48A22B47261000BC1456 /* GoogleService-Info.plist in Resources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+		C49C48732B4704F300BC1456 /* Resources */ = {
+			isa = PBXResourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				C49C48A32B47261000BC1456 /* GoogleService-Info.plist in Resources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+		C49C487D2B4704F500BC1456 /* Resources */ = {
+			isa = PBXResourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				C49C48A42B47261000BC1456 /* GoogleService-Info.plist in Resources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+		1AFC789ACC0369540ADCC334 /* [CP] Embed Pods Frameworks */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputFileListPaths = (
+				"${PODS_ROOT}/Target Support Files/Pods-FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS/Pods-FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS-frameworks-${CONFIGURATION}-input-files.xcfilelist",
+			);
+			name = "[CP] Embed Pods Frameworks";
+			outputFileListPaths = (
+				"${PODS_ROOT}/Target Support Files/Pods-FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS/Pods-FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS-frameworks-${CONFIGURATION}-output-files.xcfilelist",
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS/Pods-FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS-frameworks.sh\"\n";
+			showEnvVarsInLog = 0;
+		};
+		1C5C78EEA66C3693218AA186 /* [CP] Check Pods Manifest.lock */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputFileListPaths = (
+			);
+			inputPaths = (
+				"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+				"${PODS_ROOT}/Manifest.lock",
+			);
+			name = "[CP] Check Pods Manifest.lock";
+			outputFileListPaths = (
+			);
+			outputPaths = (
+				"$(DERIVED_FILE_DIR)/Pods-FeatureRolloutsTestApp_iOS-checkManifestLockResult.txt",
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n    # print error to STDERR\n    echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n    exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+			showEnvVarsInLog = 0;
+		};
+		1D1A7736C1202CF5AE3E74DF /* [CP] Check Pods Manifest.lock */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputFileListPaths = (
+			);
+			inputPaths = (
+				"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+				"${PODS_ROOT}/Manifest.lock",
+			);
+			name = "[CP] Check Pods Manifest.lock";
+			outputFileListPaths = (
+			);
+			outputPaths = (
+				"$(DERIVED_FILE_DIR)/Pods-FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS-checkManifestLockResult.txt",
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n    # print error to STDERR\n    echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n    exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+			showEnvVarsInLog = 0;
+		};
+		2E2005BA5C63DC9C5F6B0B5C /* [CP] Check Pods Manifest.lock */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputFileListPaths = (
+			);
+			inputPaths = (
+				"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+				"${PODS_ROOT}/Manifest.lock",
+			);
+			name = "[CP] Check Pods Manifest.lock";
+			outputFileListPaths = (
+			);
+			outputPaths = (
+				"$(DERIVED_FILE_DIR)/Pods-FeatureRolloutsTestApp_RemoteConfig_iOS-checkManifestLockResult.txt",
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n    # print error to STDERR\n    echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n    exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+			showEnvVarsInLog = 0;
+		};
+		3FCED6F36D70B363DD56F3FB /* [CP] Embed Pods Frameworks */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputFileListPaths = (
+				"${PODS_ROOT}/Target Support Files/Pods-FeatureRolloutsTestApp_iOS/Pods-FeatureRolloutsTestApp_iOS-frameworks-${CONFIGURATION}-input-files.xcfilelist",
+			);
+			name = "[CP] Embed Pods Frameworks";
+			outputFileListPaths = (
+				"${PODS_ROOT}/Target Support Files/Pods-FeatureRolloutsTestApp_iOS/Pods-FeatureRolloutsTestApp_iOS-frameworks-${CONFIGURATION}-output-files.xcfilelist",
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-FeatureRolloutsTestApp_iOS/Pods-FeatureRolloutsTestApp_iOS-frameworks.sh\"\n";
+			showEnvVarsInLog = 0;
+		};
+		8809A9DD2155751AF47F697B /* [CP] Embed Pods Frameworks */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputFileListPaths = (
+				"${PODS_ROOT}/Target Support Files/Pods-FeatureRolloutsTestApp_Crashlytics_iOS/Pods-FeatureRolloutsTestApp_Crashlytics_iOS-frameworks-${CONFIGURATION}-input-files.xcfilelist",
+			);
+			name = "[CP] Embed Pods Frameworks";
+			outputFileListPaths = (
+				"${PODS_ROOT}/Target Support Files/Pods-FeatureRolloutsTestApp_Crashlytics_iOS/Pods-FeatureRolloutsTestApp_Crashlytics_iOS-frameworks-${CONFIGURATION}-output-files.xcfilelist",
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-FeatureRolloutsTestApp_Crashlytics_iOS/Pods-FeatureRolloutsTestApp_Crashlytics_iOS-frameworks.sh\"\n";
+			showEnvVarsInLog = 0;
+		};
+		AE72CFC82C05D96F24F22349 /* [CP] Embed Pods Frameworks */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputFileListPaths = (
+				"${PODS_ROOT}/Target Support Files/Pods-FeatureRolloutsTestApp_RemoteConfig_iOS/Pods-FeatureRolloutsTestApp_RemoteConfig_iOS-frameworks-${CONFIGURATION}-input-files.xcfilelist",
+			);
+			name = "[CP] Embed Pods Frameworks";
+			outputFileListPaths = (
+				"${PODS_ROOT}/Target Support Files/Pods-FeatureRolloutsTestApp_RemoteConfig_iOS/Pods-FeatureRolloutsTestApp_RemoteConfig_iOS-frameworks-${CONFIGURATION}-output-files.xcfilelist",
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-FeatureRolloutsTestApp_RemoteConfig_iOS/Pods-FeatureRolloutsTestApp_RemoteConfig_iOS-frameworks.sh\"\n";
+			showEnvVarsInLog = 0;
+		};
+		C49C48A52B47279000BC1456 /* Crashlytics run script */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputFileListPaths = (
+			);
+			inputPaths = (
+				"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${BUILD_NAME}",
+				"$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)",
+			);
+			name = "Crashlytics run script";
+			outputFileListPaths = (
+			);
+			outputPaths = (
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n${PODS_ROOT}/../../../../Crashlytics/run\n";
+		};
+		C49C48A72B47285600BC1456 /* Crashlytics run script */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputFileListPaths = (
+			);
+			inputPaths = (
+				"${BUILD_DIR%Build/*}SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run",
+				"$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)",
+			);
+			name = "Crashlytics run script";
+			outputFileListPaths = (
+			);
+			outputPaths = (
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n${PODS_ROOT}/../../../../Crashlytics/run\n";
+		};
+		E894A1751ED4EBA12174B475 /* [CP] Check Pods Manifest.lock */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputFileListPaths = (
+			);
+			inputPaths = (
+				"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+				"${PODS_ROOT}/Manifest.lock",
+			);
+			name = "[CP] Check Pods Manifest.lock";
+			outputFileListPaths = (
+			);
+			outputPaths = (
+				"$(DERIVED_FILE_DIR)/Pods-FeatureRolloutsTestApp_Crashlytics_iOS-checkManifestLockResult.txt",
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n    # print error to STDERR\n    echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n    exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+			showEnvVarsInLog = 0;
+		};
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+		C427C49B2B4603F60088A488 /* Sources */ = {
+			isa = PBXSourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				C427C4A52B4603F60088A488 /* ContentView.swift in Sources */,
+				C427C4A32B4603F60088A488 /* FeatureRolloutsTestAppApp.swift in Sources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+		C49C483D2B460FC600BC1456 /* Sources */ = {
+			isa = PBXSourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				C49C489F2B47233000BC1456 /* CrashButtonView.swift in Sources */,
+				C49C486C2B4704D900BC1456 /* FeatureRolloutsTestAppApp.swift in Sources */,
+				C49C48952B47207200BC1456 /* ContentView.swift in Sources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+		C49C486F2B4704F300BC1456 /* Sources */ = {
+			isa = PBXSourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				C49C48702B4704F300BC1456 /* FeatureRolloutsTestAppApp.swift in Sources */,
+				951D70162B71AD9B00BE7EED /* RemoteConfigButtonView.swift in Sources */,
+				C49C48992B4720AE00BC1456 /* ContentView.swift in Sources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+		C49C48792B4704F500BC1456 /* Sources */ = {
+			isa = PBXSourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				951D70152B71AD9B00BE7EED /* RemoteConfigButtonView.swift in Sources */,
+				C49C487A2B4704F500BC1456 /* FeatureRolloutsTestAppApp.swift in Sources */,
+				C49C489E2B4722C100BC1456 /* CrashButtonView.swift in Sources */,
+				C49C489C2B4720DD00BC1456 /* ContentView.swift in Sources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+		C427C4C22B4603F80088A488 /* Debug */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				ALWAYS_SEARCH_USER_PATHS = NO;
+				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+				CLANG_ANALYZER_NONNULL = YES;
+				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+				CLANG_ENABLE_MODULES = YES;
+				CLANG_ENABLE_OBJC_ARC = YES;
+				CLANG_ENABLE_OBJC_WEAK = YES;
+				CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+				CLANG_WARN_BOOL_CONVERSION = YES;
+				CLANG_WARN_COMMA = YES;
+				CLANG_WARN_CONSTANT_CONVERSION = YES;
+				CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+				CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+				CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+				CLANG_WARN_EMPTY_BODY = YES;
+				CLANG_WARN_ENUM_CONVERSION = YES;
+				CLANG_WARN_INFINITE_RECURSION = YES;
+				CLANG_WARN_INT_CONVERSION = YES;
+				CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+				CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+				CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+				CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+				CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+				CLANG_WARN_STRICT_PROTOTYPES = YES;
+				CLANG_WARN_SUSPICIOUS_MOVE = YES;
+				CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+				CLANG_WARN_UNREACHABLE_CODE = YES;
+				CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+				COPY_PHASE_STRIP = NO;
+				DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+				ENABLE_STRICT_OBJC_MSGSEND = YES;
+				ENABLE_TESTABILITY = YES;
+				ENABLE_USER_SCRIPT_SANDBOXING = NO;
+				GCC_C_LANGUAGE_STANDARD = gnu17;
+				GCC_DYNAMIC_NO_PIC = NO;
+				GCC_NO_COMMON_BLOCKS = YES;
+				GCC_OPTIMIZATION_LEVEL = 0;
+				GCC_PREPROCESSOR_DEFINITIONS = (
+					"DEBUG=1",
+					"$(inherited)",
+				);
+				GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+				GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+				GCC_WARN_UNDECLARED_SELECTOR = YES;
+				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+				GCC_WARN_UNUSED_FUNCTION = YES;
+				GCC_WARN_UNUSED_VARIABLE = YES;
+				LD_RUNPATH_SEARCH_PATHS = "$(inherited)";
+				LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+				MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+				MTL_FAST_MATH = YES;
+				ONLY_ACTIVE_ARCH = YES;
+				SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+				SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+			};
+			name = Debug;
+		};
+		C427C4C32B4603F80088A488 /* Release */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				ALWAYS_SEARCH_USER_PATHS = NO;
+				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+				CLANG_ANALYZER_NONNULL = YES;
+				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+				CLANG_ENABLE_MODULES = YES;
+				CLANG_ENABLE_OBJC_ARC = YES;
+				CLANG_ENABLE_OBJC_WEAK = YES;
+				CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+				CLANG_WARN_BOOL_CONVERSION = YES;
+				CLANG_WARN_COMMA = YES;
+				CLANG_WARN_CONSTANT_CONVERSION = YES;
+				CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+				CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+				CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+				CLANG_WARN_EMPTY_BODY = YES;
+				CLANG_WARN_ENUM_CONVERSION = YES;
+				CLANG_WARN_INFINITE_RECURSION = YES;
+				CLANG_WARN_INT_CONVERSION = YES;
+				CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+				CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+				CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+				CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+				CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+				CLANG_WARN_STRICT_PROTOTYPES = YES;
+				CLANG_WARN_SUSPICIOUS_MOVE = YES;
+				CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+				CLANG_WARN_UNREACHABLE_CODE = YES;
+				CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+				COPY_PHASE_STRIP = NO;
+				DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+				ENABLE_NS_ASSERTIONS = NO;
+				ENABLE_STRICT_OBJC_MSGSEND = YES;
+				ENABLE_USER_SCRIPT_SANDBOXING = NO;
+				GCC_C_LANGUAGE_STANDARD = gnu17;
+				GCC_NO_COMMON_BLOCKS = YES;
+				GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+				GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+				GCC_WARN_UNDECLARED_SELECTOR = YES;
+				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+				GCC_WARN_UNUSED_FUNCTION = YES;
+				GCC_WARN_UNUSED_VARIABLE = YES;
+				LD_RUNPATH_SEARCH_PATHS = "$(inherited)";
+				LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+				MTL_ENABLE_DEBUG_INFO = NO;
+				MTL_FAST_MATH = YES;
+				SWIFT_COMPILATION_MODE = wholemodule;
+			};
+			name = Release;
+		};
+		C427C4C52B4603F80088A488 /* Debug */ = {
+			isa = XCBuildConfiguration;
+			baseConfigurationReference = 6D30A6D1F2CE622B6D5D563F /* Pods-FeatureRolloutsTestApp_iOS.debug.xcconfig */;
+			buildSettings = {
+				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+				CODE_SIGN_IDENTITY = "Apple Development";
+				CODE_SIGN_STYLE = Automatic;
+				CURRENT_PROJECT_VERSION = 1;
+				DEVELOPMENT_ASSET_PATHS = "";
+				DEVELOPMENT_TEAM = "";
+				ENABLE_PREVIEWS = YES;
+				GENERATE_INFOPLIST_FILE = YES;
+				"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
+				"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
+				"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
+				"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
+				"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
+				"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
+				"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
+				"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
+				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+				IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+				LD_RUNPATH_SEARCH_PATHS = "$(inherited)";
+				"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
+				MACOSX_DEPLOYMENT_TARGET = 14.0;
+				MARKETING_VERSION = 1.0;
+				PRODUCT_BUNDLE_IDENTIFIER = com.firebase.config.featureRolloutsTestApp;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SDKROOT = iphoneos;
+				SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+				SUPPORTS_MACCATALYST = NO;
+				SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
+				SWIFT_EMIT_LOC_STRINGS = YES;
+				SWIFT_VERSION = 5.0;
+				TARGETED_DEVICE_FAMILY = "1,2";
+			};
+			name = Debug;
+		};
+		C427C4C62B4603F80088A488 /* Release */ = {
+			isa = XCBuildConfiguration;
+			baseConfigurationReference = 2D15DD53784CDDE94D00AB02 /* Pods-FeatureRolloutsTestApp_iOS.release.xcconfig */;
+			buildSettings = {
+				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+				CODE_SIGN_IDENTITY = "Apple Development";
+				CODE_SIGN_STYLE = Automatic;
+				CURRENT_PROJECT_VERSION = 1;
+				DEVELOPMENT_ASSET_PATHS = "";
+				DEVELOPMENT_TEAM = "";
+				ENABLE_PREVIEWS = YES;
+				GENERATE_INFOPLIST_FILE = YES;
+				"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
+				"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
+				"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
+				"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
+				"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
+				"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
+				"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
+				"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
+				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+				IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+				LD_RUNPATH_SEARCH_PATHS = "$(inherited)";
+				"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
+				MACOSX_DEPLOYMENT_TARGET = 14.0;
+				MARKETING_VERSION = 1.0;
+				PRODUCT_BUNDLE_IDENTIFIER = com.firebase.config.featureRolloutsTestApp;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SDKROOT = iphoneos;
+				SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+				SUPPORTS_MACCATALYST = NO;
+				SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
+				SWIFT_EMIT_LOC_STRINGS = YES;
+				SWIFT_VERSION = 5.0;
+				TARGETED_DEVICE_FAMILY = "1,2";
+			};
+			name = Release;
+		};
+		C49C48632B460FC800BC1456 /* Debug */ = {
+			isa = XCBuildConfiguration;
+			baseConfigurationReference = 025F972344BB0B489CC052D6 /* Pods-FeatureRolloutsTestApp_Crashlytics_iOS.debug.xcconfig */;
+			buildSettings = {
+				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+				CODE_SIGN_STYLE = Automatic;
+				CURRENT_PROJECT_VERSION = 1;
+				DEVELOPMENT_ASSET_PATHS = "";
+				ENABLE_PREVIEWS = YES;
+				GENERATE_INFOPLIST_FILE = YES;
+				INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+				INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+				INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+				IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+				LD_RUNPATH_SEARCH_PATHS = "$(inherited)";
+				MARKETING_VERSION = 1.0;
+				PRODUCT_BUNDLE_IDENTIFIER = com.firebase.config.featureRolloutsTestApp;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SDKROOT = iphoneos;
+				SWIFT_EMIT_LOC_STRINGS = YES;
+				SWIFT_VERSION = 5.0;
+				TARGETED_DEVICE_FAMILY = "1,2";
+			};
+			name = Debug;
+		};
+		C49C48642B460FC800BC1456 /* Release */ = {
+			isa = XCBuildConfiguration;
+			baseConfigurationReference = 8BA72854B19D7A9D9BE15E1D /* Pods-FeatureRolloutsTestApp_Crashlytics_iOS.release.xcconfig */;
+			buildSettings = {
+				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+				CODE_SIGN_STYLE = Automatic;
+				CURRENT_PROJECT_VERSION = 1;
+				DEVELOPMENT_ASSET_PATHS = "";
+				ENABLE_PREVIEWS = YES;
+				GENERATE_INFOPLIST_FILE = YES;
+				INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+				INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+				INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+				IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+				LD_RUNPATH_SEARCH_PATHS = "$(inherited)";
+				MARKETING_VERSION = 1.0;
+				PRODUCT_BUNDLE_IDENTIFIER = com.firebase.config.featureRolloutsTestApp;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SDKROOT = iphoneos;
+				SWIFT_EMIT_LOC_STRINGS = YES;
+				SWIFT_VERSION = 5.0;
+				TARGETED_DEVICE_FAMILY = "1,2";
+				VALIDATE_PRODUCT = YES;
+			};
+			name = Release;
+		};
+		C49C48752B4704F300BC1456 /* Debug */ = {
+			isa = XCBuildConfiguration;
+			baseConfigurationReference = AF260B513E38B2528E7B13CC /* Pods-FeatureRolloutsTestApp_RemoteConfig_iOS.debug.xcconfig */;
+			buildSettings = {
+				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+				CODE_SIGN_STYLE = Automatic;
+				CURRENT_PROJECT_VERSION = 1;
+				DEVELOPMENT_ASSET_PATHS = "";
+				ENABLE_PREVIEWS = YES;
+				GENERATE_INFOPLIST_FILE = YES;
+				INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+				INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+				INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+				IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+				LD_RUNPATH_SEARCH_PATHS = "$(inherited)";
+				MARKETING_VERSION = 1.0;
+				PRODUCT_BUNDLE_IDENTIFIER = com.firebase.config.featureRolloutsTestApp;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SDKROOT = iphoneos;
+				SWIFT_EMIT_LOC_STRINGS = YES;
+				SWIFT_VERSION = 5.0;
+				TARGETED_DEVICE_FAMILY = "1,2";
+			};
+			name = Debug;
+		};
+		C49C48762B4704F300BC1456 /* Release */ = {
+			isa = XCBuildConfiguration;
+			baseConfigurationReference = 30BB126FCB8D5F53B5795500 /* Pods-FeatureRolloutsTestApp_RemoteConfig_iOS.release.xcconfig */;
+			buildSettings = {
+				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+				CODE_SIGN_STYLE = Automatic;
+				CURRENT_PROJECT_VERSION = 1;
+				DEVELOPMENT_ASSET_PATHS = "";
+				ENABLE_PREVIEWS = YES;
+				GENERATE_INFOPLIST_FILE = YES;
+				INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+				INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+				INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+				IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+				LD_RUNPATH_SEARCH_PATHS = "$(inherited)";
+				MARKETING_VERSION = 1.0;
+				PRODUCT_BUNDLE_IDENTIFIER = com.firebase.config.featureRolloutsTestApp;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SDKROOT = iphoneos;
+				SWIFT_EMIT_LOC_STRINGS = YES;
+				SWIFT_VERSION = 5.0;
+				TARGETED_DEVICE_FAMILY = "1,2";
+				VALIDATE_PRODUCT = YES;
+			};
+			name = Release;
+		};
+		C49C487F2B4704F500BC1456 /* Debug */ = {
+			isa = XCBuildConfiguration;
+			baseConfigurationReference = 2842D338F32EE531C752262E /* Pods-FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS.debug.xcconfig */;
+			buildSettings = {
+				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+				CODE_SIGN_STYLE = Automatic;
+				CURRENT_PROJECT_VERSION = 1;
+				DEVELOPMENT_ASSET_PATHS = "";
+				ENABLE_PREVIEWS = YES;
+				GENERATE_INFOPLIST_FILE = YES;
+				INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+				INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+				INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+				IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+				LD_RUNPATH_SEARCH_PATHS = "$(inherited)";
+				MARKETING_VERSION = 1.0;
+				PRODUCT_BUNDLE_IDENTIFIER = com.firebase.config.featureRolloutsTestApp;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SDKROOT = iphoneos;
+				SWIFT_EMIT_LOC_STRINGS = YES;
+				SWIFT_VERSION = 5.0;
+				TARGETED_DEVICE_FAMILY = "1,2";
+			};
+			name = Debug;
+		};
+		C49C48802B4704F500BC1456 /* Release */ = {
+			isa = XCBuildConfiguration;
+			baseConfigurationReference = 10710CAF870FA7E8D1ABF94C /* Pods-FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS.release.xcconfig */;
+			buildSettings = {
+				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+				CODE_SIGN_STYLE = Automatic;
+				CURRENT_PROJECT_VERSION = 1;
+				DEVELOPMENT_ASSET_PATHS = "";
+				ENABLE_PREVIEWS = YES;
+				GENERATE_INFOPLIST_FILE = YES;
+				INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+				INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+				INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+				IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+				LD_RUNPATH_SEARCH_PATHS = "$(inherited)";
+				MARKETING_VERSION = 1.0;
+				PRODUCT_BUNDLE_IDENTIFIER = com.firebase.config.featureRolloutsTestApp;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SDKROOT = iphoneos;
+				SWIFT_EMIT_LOC_STRINGS = YES;
+				SWIFT_VERSION = 5.0;
+				TARGETED_DEVICE_FAMILY = "1,2";
+				VALIDATE_PRODUCT = YES;
+			};
+			name = Release;
+		};
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+		C427C49A2B4603F60088A488 /* Build configuration list for PBXProject "FeatureRolloutsTestApp" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				C427C4C22B4603F80088A488 /* Debug */,
+				C427C4C32B4603F80088A488 /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Release;
+		};
+		C427C4C42B4603F80088A488 /* Build configuration list for PBXNativeTarget "FeatureRolloutsTestApp_iOS" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				C427C4C52B4603F80088A488 /* Debug */,
+				C427C4C62B4603F80088A488 /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Release;
+		};
+		C49C48622B460FC800BC1456 /* Build configuration list for PBXNativeTarget "FeatureRolloutsTestApp_Crashlytics_iOS" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				C49C48632B460FC800BC1456 /* Debug */,
+				C49C48642B460FC800BC1456 /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Release;
+		};
+		C49C48742B4704F300BC1456 /* Build configuration list for PBXNativeTarget "FeatureRolloutsTestApp_RemoteConfig_iOS" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				C49C48752B4704F300BC1456 /* Debug */,
+				C49C48762B4704F300BC1456 /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Release;
+		};
+		C49C487E2B4704F500BC1456 /* Build configuration list for PBXNativeTarget "FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				C49C487F2B4704F500BC1456 /* Debug */,
+				C49C48802B4704F500BC1456 /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Release;
+		};
+/* End XCConfigurationList section */
+	};
+	rootObject = C427C4972B4603F60088A488 /* Project object */;
+}

+ 29 - 0
FirebaseRemoteConfig/Tests/FeatureRolloutsTestApp/FeatureRolloutsTestApp/ContentView.swift

@@ -0,0 +1,29 @@
+//
+// Copyright 2024 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import SwiftUI
+
+struct ContentView: View {
+  var body: some View {
+    VStack {
+      Image(systemName: "globe")
+        .imageScale(.large)
+        .foregroundStyle(.tint)
+      Text("Hello, world!")
+    }
+    .padding()
+  }
+}

+ 10 - 0
FirebaseRemoteConfig/Tests/FeatureRolloutsTestApp/FeatureRolloutsTestApp/FeatureRolloutsTestApp.entitlements

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+    <key>com.apple.security.app-sandbox</key>
+    <true/>
+    <key>com.apple.security.files.user-selected.read-only</key>
+    <true/>
+</dict>
+</plist>

+ 27 - 0
FirebaseRemoteConfig/Tests/FeatureRolloutsTestApp/FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS/ContentView.swift

@@ -0,0 +1,27 @@
+//
+// Copyright 2024 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+import SwiftUI
+
+struct ContentView: View {
+  var body: some View {
+    CrashButtonView()
+      .padding()
+    RemoteConfigButtonView()
+      .padding()
+  }
+}

+ 26 - 0
FirebaseRemoteConfig/Tests/FeatureRolloutsTestApp/FeatureRolloutsTestApp_Crashlytics_iOS/ContentView.swift

@@ -0,0 +1,26 @@
+//
+// Copyright 2024 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import FirebaseCrashlytics
+import Foundation
+import SwiftUI
+
+struct ContentView: View {
+  var body: some View {
+    CrashButtonView()
+      .padding()
+  }
+}

+ 25 - 0
FirebaseRemoteConfig/Tests/FeatureRolloutsTestApp/FeatureRolloutsTestApp_RemoteConfig_iOS/ContentView.swift

@@ -0,0 +1,25 @@
+//
+// Copyright 2024 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+import SwiftUI
+
+struct ContentView: View {
+  var body: some View {
+    RemoteConfigButtonView()
+      .padding()
+  }
+}

+ 52 - 0
FirebaseRemoteConfig/Tests/FeatureRolloutsTestApp/Podfile

@@ -0,0 +1,52 @@
+# Uncomment the next line to define a global platform for your project
+# platform :ios, '9.0'
+
+def shared_pods
+  pod 'FirebaseCore', :path => '../../../'
+  pod 'FirebaseInstallations', :path => '../../../'
+  pod 'FirebaseCoreInternal', :path => '../../../'
+  pod 'FirebaseCoreExtension', :path => '../../../'
+  pod 'FirebaseRemoteConfigInterop', :path => '../../../'
+  pod 'FirebasePerformance', :path => '../../../'
+end
+
+target 'FeatureRolloutsTestApp_iOS' do
+  platform :ios, '11.0'
+
+  # Comment the next line if you don't want to use dynamic frameworks
+  use_frameworks!
+
+  shared_pods
+end
+
+target 'FeatureRolloutsTestApp_Crashlytics_iOS' do
+  platform :ios, '11.0'
+
+  # Comment the next line if you don't want to use dynamic frameworks
+  use_frameworks!
+
+  shared_pods
+  pod 'FirebaseCrashlytics', :path => '../../../'
+end
+
+target 'FeatureRolloutsTestApp_RemoteConfig_iOS' do
+  platform :ios, '11.0'
+
+  # Comment the next line if you don't want to use dynamic frameworks
+  use_frameworks!
+
+  shared_pods
+  pod 'FirebaseRemoteConfig', :path => '../../../'
+end
+
+target 'FeatureRolloutsTestApp_CrashlyticsRemoteConfig_iOS' do
+  platform :ios, '11.0'
+
+  # Comment the next line if you don't want to use dynamic frameworks
+  use_frameworks!
+
+  shared_pods
+  pod 'FirebaseCrashlytics', :path => '../../../'
+  pod 'FirebaseRemoteConfig', :path => '../../../'
+end
+

+ 62 - 0
FirebaseRemoteConfig/Tests/FeatureRolloutsTestApp/Shared/CrashButtonView.swift

@@ -0,0 +1,62 @@
+//
+// Copyright 2024 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import FirebaseCrashlytics
+import Foundation
+import SwiftUI
+
+struct CrashButtonView: View {
+  var body: some View {
+    var counter = 0
+
+    NavigationView {
+      VStack(
+        alignment: .leading,
+        spacing: 10
+      ) {
+        Button(action: {
+          Crashlytics.crashlytics().setUserID("ThisIsABot")
+        }) {
+          Text("Set User Id")
+        }
+
+        Button(action: {
+          assertionFailure("Throw a Crash")
+        }) {
+          Text("Crash")
+        }
+
+        Button(action: {
+          Crashlytics.crashlytics().record(error: NSError(
+            domain: "This is a test non-fatal",
+            code: 400
+          ))
+        }) {
+          Text("Record Non-fatal event")
+        }
+
+        Button(action: {
+          Crashlytics.crashlytics().setCustomValue(counter, forKey: "counter " + String(counter))
+          let i = counter
+          counter = i + 1
+        }) {
+          Text("Set custom key")
+        }
+      }
+      .navigationTitle("Crashlytics Example")
+    }
+  }
+}

+ 31 - 0
FirebaseRemoteConfig/Tests/FeatureRolloutsTestApp/Shared/FeatureRolloutsTestAppApp.swift

@@ -0,0 +1,31 @@
+//
+// Copyright 2024 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import FirebaseCore
+import SwiftUI
+
+@main
+struct FeatureRolloutsTestAppApp: App {
+  init() {
+    FirebaseApp.configure()
+  }
+
+  var body: some Scene {
+    WindowGroup {
+      ContentView()
+    }
+  }
+}

+ 51 - 0
FirebaseRemoteConfig/Tests/FeatureRolloutsTestApp/Shared/RemoteConfigButtonView.swift

@@ -0,0 +1,51 @@
+//
+// Copyright 2024 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import FirebaseRemoteConfig
+import Foundation
+import SwiftUI
+
+struct RemoteConfigButtonView: View {
+  @State private var turnOnRealTimeRC = false
+  let rc = RemoteConfig.remoteConfig()
+  @RemoteConfigProperty(key: "ios_rollouts", fallback: "unfetched") var iosRollouts: String
+
+  var body: some View {
+    NavigationView {
+      VStack(
+        alignment: .leading,
+        spacing: 10
+      ) {
+        Button(action: {
+          rc.fetch()
+        }) {
+          Text("Fetch")
+        }
+        Button(action: {
+          rc.activate()
+        }) {
+          Text("Activate")
+        }
+        Text(iosRollouts)
+        Toggle("Turn on RealTime RC", isOn: $turnOnRealTimeRC).toggleStyle(.button).tint(.mint)
+          .onChange(of: self.turnOnRealTimeRC, perform: { value in
+            rc.addOnConfigUpdateListener { u, e in rc.activate() }
+          })
+      }
+      .navigationTitle("Remote Config Example")
+    }
+  }
+}

+ 1 - 0
FirebaseRemoteConfig/Tests/Sample/Podfile

@@ -14,6 +14,7 @@ target 'RemoteConfigSampleApp' do
   pod 'FirebaseInstallations', :path => '../../../'
   pod 'FirebaseInstallations', :path => '../../../'
   pod 'FirebaseRemoteConfig', :path => '../../../'
   pod 'FirebaseRemoteConfig', :path => '../../../'
   pod 'FirebaseABTesting', :path => '../../..'
   pod 'FirebaseABTesting', :path => '../../..'
+  pod 'FirebaseRemoteConfigInterop', :path => '../../..'
 
 
   # Pods for RemoteConfigSampleApp
   # Pods for RemoteConfigSampleApp
 
 

+ 6 - 3
FirebaseRemoteConfig/Tests/Sample/RemoteConfigSampleApp/ViewController.m

@@ -21,6 +21,7 @@
 #import <FirebaseRemoteConfig/FirebaseRemoteConfig.h>
 #import <FirebaseRemoteConfig/FirebaseRemoteConfig.h>
 #import "../../../Sources/Private/FIRRemoteConfig_Private.h"
 #import "../../../Sources/Private/FIRRemoteConfig_Private.h"
 #import "FRCLog.h"
 #import "FRCLog.h"
+@import FirebaseRemoteConfigInterop;
 
 
 static NSString *const FIRPerfNamespace = @"fireperf";
 static NSString *const FIRPerfNamespace = @"fireperf";
 static NSString *const FIRDefaultFIRAppName = @"__FIRAPP_DEFAULT";
 static NSString *const FIRDefaultFIRAppName = @"__FIRAPP_DEFAULT";
@@ -81,7 +82,8 @@ static NSString *const FIRSecondFIRAppName = @"secondFIRApp";
 
 
   // TODO(mandard): Add support for deleting and adding namespaces in the app.
   // TODO(mandard): Add support for deleting and adding namespaces in the app.
   self.namespacePickerData =
   self.namespacePickerData =
-      [[NSArray alloc] initWithObjects:FIRNamespaceGoogleMobilePlatform, FIRPerfNamespace, nil];
+      [[NSArray alloc] initWithObjects:FIRRemoteConfigConstants.FIRNamespaceGoogleMobilePlatform,
+                                       FIRPerfNamespace, nil];
   self.appPickerData =
   self.appPickerData =
       [[NSArray alloc] initWithObjects:FIRDefaultFIRAppName, FIRSecondFIRAppName, nil];
       [[NSArray alloc] initWithObjects:FIRDefaultFIRAppName, FIRSecondFIRAppName, nil];
   self.RCInstances = [[NSMutableDictionary alloc] init];
   self.RCInstances = [[NSMutableDictionary alloc] init];
@@ -91,7 +93,8 @@ static NSString *const FIRSecondFIRAppName = @"secondFIRApp";
       if (!self.RCInstances[namespaceString]) {
       if (!self.RCInstances[namespaceString]) {
         self.RCInstances[namespaceString] = [[NSMutableDictionary alloc] init];
         self.RCInstances[namespaceString] = [[NSMutableDictionary alloc] init];
       }
       }
-      if ([namespaceString isEqualToString:FIRNamespaceGoogleMobilePlatform] &&
+      if ([namespaceString
+              isEqualToString:FIRRemoteConfigConstants.FIRNamespaceGoogleMobilePlatform] &&
           [appString isEqualToString:FIRDefaultFIRAppName]) {
           [appString isEqualToString:FIRDefaultFIRAppName]) {
         self.RCInstances[namespaceString][appString] = [FIRRemoteConfig remoteConfig];
         self.RCInstances[namespaceString][appString] = [FIRRemoteConfig remoteConfig];
       } else {
       } else {
@@ -120,7 +123,7 @@ static NSString *const FIRSecondFIRAppName = @"secondFIRApp";
   [alert addAction:defaultAction];
   [alert addAction:defaultAction];
 
 
   // Add realtime listener for firebase namespace
   // Add realtime listener for firebase namespace
-  [self.RCInstances[FIRNamespaceGoogleMobilePlatform][FIRDefaultFIRAppName]
+  [self.RCInstances[FIRRemoteConfigConstants.FIRNamespaceGoogleMobilePlatform][FIRDefaultFIRAppName]
       addOnConfigUpdateListener:^(FIRRemoteConfigUpdate *_Nullable update,
       addOnConfigUpdateListener:^(FIRRemoteConfigUpdate *_Nullable update,
                                   NSError *_Nullable error) {
                                   NSError *_Nullable error) {
         if (error != nil) {
         if (error != nil) {

+ 65 - 0
FirebaseRemoteConfig/Tests/SwiftUnit/RemoteConfigInteropTests.swift

@@ -0,0 +1,65 @@
+// 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 FirebaseRemoteConfigInterop
+import XCTest
+
+class MockRCInterop: RemoteConfigInterop {
+  weak var subscriber: FirebaseRemoteConfigInterop.RolloutsStateSubscriber?
+  func registerRolloutsStateSubscriber(_ subscriber: FirebaseRemoteConfigInterop
+    .RolloutsStateSubscriber,
+    for namespace: String) {
+    self.subscriber = subscriber
+  }
+}
+
+class MockRolloutSubscriber: RolloutsStateSubscriber {
+  var isSubscriberCalled = false
+  var rolloutsState: RolloutsState?
+  func rolloutsStateDidChange(_ rolloutsState: FirebaseRemoteConfigInterop.RolloutsState) {
+    isSubscriberCalled = true
+    self.rolloutsState = rolloutsState
+  }
+}
+
+final class RemoteConfigInteropTests: XCTestCase {
+  let rollouts: RolloutsState = {
+    let assignment1 = RolloutAssignment(
+      rolloutId: "rollout_1",
+      variantId: "control",
+      templateVersion: 1,
+      parameterKey: "my_feature",
+      parameterValue: "false"
+    )
+    let assignment2 = RolloutAssignment(
+      rolloutId: "rollout_2",
+      variantId: "enabled",
+      templateVersion: 123,
+      parameterKey: "themis_big_feature",
+      parameterValue: "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111"
+    )
+    let rollouts = RolloutsState(assignmentList: [assignment1, assignment2])
+    return rollouts
+  }()
+
+  func testRemoteConfigIntegration() throws {
+    let rcSubscriber = MockRolloutSubscriber()
+    let rcInterop = MockRCInterop()
+    rcInterop.registerRolloutsStateSubscriber(rcSubscriber, for: "namespace")
+    rcInterop.subscriber?.rolloutsStateDidChange(rollouts)
+
+    XCTAssertTrue(rcSubscriber.isSubscriberCalled)
+    XCTAssertEqual(rcSubscriber.rolloutsState?.assignments.count, 2)
+  }
+}

+ 42 - 1
FirebaseRemoteConfig/Tests/Unit/FIRRemoteConfigComponentTest.m

@@ -20,6 +20,7 @@
 #import "FirebaseRemoteConfig/Sources/FIRRemoteConfigComponent.h"
 #import "FirebaseRemoteConfig/Sources/FIRRemoteConfigComponent.h"
 #import "FirebaseRemoteConfig/Sources/Private/FIRRemoteConfig_Private.h"
 #import "FirebaseRemoteConfig/Sources/Private/FIRRemoteConfig_Private.h"
 #import "FirebaseRemoteConfig/Tests/Unit/RCNTestUtilities.h"
 #import "FirebaseRemoteConfig/Tests/Unit/RCNTestUtilities.h"
+@import FirebaseRemoteConfigInterop;
 
 
 @interface FIRRemoteConfigComponentTest : XCTestCase
 @interface FIRRemoteConfigComponentTest : XCTestCase
 @end
 @end
@@ -31,6 +32,7 @@
 
 
   // Clear out any apps that were called with `configure`.
   // Clear out any apps that were called with `configure`.
   [FIRApp resetApps];
   [FIRApp resetApps];
+  [FIRRemoteConfigComponent clearAllComponentInstances];
 }
 }
 
 
 - (void)testRCInstanceCreationAndCaching {
 - (void)testRCInstanceCreationAndCaching {
@@ -92,7 +94,8 @@
 }
 }
 
 
 - (void)testRegistersAsLibrary {
 - (void)testRegistersAsLibrary {
-  XCTAssertEqual([FIRRemoteConfigComponent componentsToRegister].count, 1);
+  // Now component has two register, one is provider and another one is Interop
+  XCTAssertEqual([FIRRemoteConfigComponent componentsToRegister].count, 2);
 
 
   // Configure a test FIRApp for fetching an instance of the FIRRemoteConfigProvider.
   // Configure a test FIRApp for fetching an instance of the FIRRemoteConfigProvider.
   NSString *appName = [self generatedTestAppName];
   NSString *appName = [self generatedTestAppName];
@@ -101,12 +104,50 @@
 
 
   // Attempt to fetch the component and verify it's a valid instance.
   // Attempt to fetch the component and verify it's a valid instance.
   id<FIRRemoteConfigProvider> provider = FIR_COMPONENT(FIRRemoteConfigProvider, app.container);
   id<FIRRemoteConfigProvider> provider = FIR_COMPONENT(FIRRemoteConfigProvider, app.container);
+  id<FIRRemoteConfigInterop> interop = FIR_COMPONENT(FIRRemoteConfigInterop, app.container);
   XCTAssertNotNil(provider);
   XCTAssertNotNil(provider);
+  XCTAssertNotNil(interop);
 
 
   // Ensure that the instance that comes from the container is cached.
   // Ensure that the instance that comes from the container is cached.
   id<FIRRemoteConfigProvider> sameProvider = FIR_COMPONENT(FIRRemoteConfigProvider, app.container);
   id<FIRRemoteConfigProvider> sameProvider = FIR_COMPONENT(FIRRemoteConfigProvider, app.container);
+  id<FIRRemoteConfigInterop> sameInterop = FIR_COMPONENT(FIRRemoteConfigInterop, app.container);
   XCTAssertNotNil(sameProvider);
   XCTAssertNotNil(sameProvider);
+  XCTAssertNotNil(sameInterop);
   XCTAssertEqual(provider, sameProvider);
   XCTAssertEqual(provider, sameProvider);
+  XCTAssertEqual(interop, sameInterop);
+
+  // Dynamic typing, both prototols are refering to the same component instance
+  id providerID = provider;
+  id interopID = interop;
+  XCTAssertEqualObjects(providerID, interopID);
+}
+
+- (void)testTwoAppsCreateTwoComponents {
+  NSString *appName = [self generatedTestAppName];
+  [FIRApp configureWithName:appName options:[self fakeOptions]];
+  FIRApp *app = [FIRApp appNamed:appName];
+
+  [FIRApp configureWithOptions:[self fakeOptions]];
+  FIRApp *defaultApp = [FIRApp defaultApp];
+  XCTAssertNotNil(defaultApp);
+  XCTAssertNotEqualObjects(app, defaultApp);
+
+  id<FIRRemoteConfigProvider> provider = FIR_COMPONENT(FIRRemoteConfigProvider, app.container);
+  id<FIRRemoteConfigInterop> interop = FIR_COMPONENT(FIRRemoteConfigInterop, app.container);
+  id<FIRRemoteConfigProvider> defaultAppProvider =
+      FIR_COMPONENT(FIRRemoteConfigProvider, defaultApp.container);
+  id<FIRRemoteConfigInterop> defaultAppInterop =
+      FIR_COMPONENT(FIRRemoteConfigInterop, defaultApp.container);
+
+  id providerID = provider;
+  id interopID = interop;
+  id defaultAppProviderID = defaultAppProvider;
+  id defaultAppInteropID = defaultAppInterop;
+
+  XCTAssertEqualObjects(providerID, interopID);
+  XCTAssertEqualObjects(defaultAppProviderID, defaultAppInteropID);
+  // Check two apps get their own component to register
+  XCTAssertNotEqualObjects(interopID, defaultAppInteropID);
 }
 }
 
 
 - (void)testThrowsWithEmptyGoogleAppID {
 - (void)testThrowsWithEmptyGoogleAppID {

+ 184 - 25
FirebaseRemoteConfig/Tests/Unit/RCNConfigContentTest.m

@@ -24,6 +24,7 @@
 #import "FirebaseRemoteConfig/Sources/RCNConfigDBManager.h"
 #import "FirebaseRemoteConfig/Sources/RCNConfigDBManager.h"
 #import "FirebaseRemoteConfig/Sources/RCNConfigValue_Internal.h"
 #import "FirebaseRemoteConfig/Sources/RCNConfigValue_Internal.h"
 #import "FirebaseRemoteConfig/Tests/Unit/RCNTestUtilities.h"
 #import "FirebaseRemoteConfig/Tests/Unit/RCNTestUtilities.h"
+@import FirebaseRemoteConfigInterop;
 
 
 @interface RCNConfigContent (Testing)
 @interface RCNConfigContent (Testing)
 - (BOOL)checkAndWaitForInitialDatabaseLoad;
 - (BOOL)checkAndWaitForInitialDatabaseLoad;
@@ -44,7 +45,7 @@ extern const NSTimeInterval kDatabaseLoadTimeoutSecs;
   dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(justSmallDelay * NSEC_PER_SEC)),
   dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(justSmallDelay * NSEC_PER_SEC)),
                  dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
                  dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
                    self.isLoadMainCompleted = YES;
                    self.isLoadMainCompleted = YES;
-                   handler(YES, nil, nil, nil);
+                   handler(YES, nil, nil, nil, nil);
                  });
                  });
 }
 }
 - (void)loadPersonalizationWithCompletionHandler:(RCNDBLoadCompletion)handler {
 - (void)loadPersonalizationWithCompletionHandler:(RCNDBLoadCompletion)handler {
@@ -53,7 +54,7 @@ extern const NSTimeInterval kDatabaseLoadTimeoutSecs;
   dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(justOtherSmallDelay * NSEC_PER_SEC)),
   dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(justOtherSmallDelay * NSEC_PER_SEC)),
                  dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
                  dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
                    self.isLoadPersonalizationCompleted = YES;
                    self.isLoadPersonalizationCompleted = YES;
-                   handler(YES, nil, nil, nil);
+                   handler(YES, nil, nil, nil, nil);
                  });
                  });
 }
 }
 @end
 @end
@@ -62,6 +63,7 @@ extern const NSTimeInterval kDatabaseLoadTimeoutSecs;
   NSTimeInterval _expectationTimeout;
   NSTimeInterval _expectationTimeout;
   RCNConfigContent *_configContent;
   RCNConfigContent *_configContent;
   NSString *namespaceApp1, *namespaceApp2;
   NSString *namespaceApp1, *namespaceApp2;
+  NSString *_namespaceGoogleMobilePlatform;
 }
 }
 @end
 @end
 
 
@@ -70,11 +72,12 @@ extern const NSTimeInterval kDatabaseLoadTimeoutSecs;
 - (void)setUp {
 - (void)setUp {
   [super setUp];
   [super setUp];
   _expectationTimeout = 1.0;
   _expectationTimeout = 1.0;
+  _namespaceGoogleMobilePlatform = FIRRemoteConfigConstants.FIRNamespaceGoogleMobilePlatform;
 
 
   namespaceApp1 = [NSString
   namespaceApp1 = [NSString
-      stringWithFormat:@"%@:%@", FIRNamespaceGoogleMobilePlatform, RCNTestsDefaultFIRAppName];
+      stringWithFormat:@"%@:%@", _namespaceGoogleMobilePlatform, RCNTestsDefaultFIRAppName];
   namespaceApp2 = [NSString
   namespaceApp2 = [NSString
-      stringWithFormat:@"%@:%@", FIRNamespaceGoogleMobilePlatform, RCNTestsSecondFIRAppName];
+      stringWithFormat:@"%@:%@", _namespaceGoogleMobilePlatform, RCNTestsSecondFIRAppName];
 
 
   _configContent = [[RCNConfigContent alloc] initWithDBManager:nil];
   _configContent = [[RCNConfigContent alloc] initWithDBManager:nil];
 
 
@@ -129,14 +132,14 @@ extern const NSTimeInterval kDatabaseLoadTimeoutSecs;
   NSDictionary *entries = @{@"key1" : @"value1", @"key2" : @"value2"};
   NSDictionary *entries = @{@"key1" : @"value1", @"key2" : @"value2"};
   [configToSet setValue:entries forKey:@"entries"];
   [configToSet setValue:entries forKey:@"entries"];
   [_configContent updateConfigContentWithResponse:configToSet
   [_configContent updateConfigContentWithResponse:configToSet
-                                     forNamespace:FIRNamespaceGoogleMobilePlatform];
+                                     forNamespace:_namespaceGoogleMobilePlatform];
 
 
   NSDictionary *fetchedConfig = _configContent.fetchedConfig;
   NSDictionary *fetchedConfig = _configContent.fetchedConfig;
-  XCTAssertNotNil(fetchedConfig[FIRNamespaceGoogleMobilePlatform][@"key1"]);
-  XCTAssertEqualObjects([fetchedConfig[FIRNamespaceGoogleMobilePlatform][@"key1"] stringValue],
+  XCTAssertNotNil(fetchedConfig[_namespaceGoogleMobilePlatform][@"key1"]);
+  XCTAssertEqualObjects([fetchedConfig[_namespaceGoogleMobilePlatform][@"key1"] stringValue],
                         @"value1");
                         @"value1");
-  XCTAssertNotNil(fetchedConfig[FIRNamespaceGoogleMobilePlatform][@"key2"]);
-  XCTAssertEqualObjects([fetchedConfig[FIRNamespaceGoogleMobilePlatform][@"key2"] stringValue],
+  XCTAssertNotNil(fetchedConfig[_namespaceGoogleMobilePlatform][@"key2"]);
+  XCTAssertEqualObjects([fetchedConfig[_namespaceGoogleMobilePlatform][@"key2"] stringValue],
                         @"value2");
                         @"value2");
 }
 }
 
 
@@ -147,20 +150,20 @@ extern const NSTimeInterval kDatabaseLoadTimeoutSecs;
   NSDictionary *entries = @{@"key1" : @"value1"};
   NSDictionary *entries = @{@"key1" : @"value1"};
   [configToSet setValue:entries forKey:@"entries"];
   [configToSet setValue:entries forKey:@"entries"];
   [_configContent updateConfigContentWithResponse:configToSet
   [_configContent updateConfigContentWithResponse:configToSet
-                                     forNamespace:FIRNamespaceGoogleMobilePlatform];
+                                     forNamespace:_namespaceGoogleMobilePlatform];
   configToSet = [[NSMutableDictionary alloc] initWithObjectsAndKeys:@"UPDATE", @"state", nil];
   configToSet = [[NSMutableDictionary alloc] initWithObjectsAndKeys:@"UPDATE", @"state", nil];
   entries = @{@"key2" : @"value2", @"key3" : @"value3"};
   entries = @{@"key2" : @"value2", @"key3" : @"value3"};
   [configToSet setValue:entries forKey:@"entries"];
   [configToSet setValue:entries forKey:@"entries"];
   [_configContent updateConfigContentWithResponse:configToSet
   [_configContent updateConfigContentWithResponse:configToSet
-                                     forNamespace:FIRNamespaceGoogleMobilePlatform];
+                                     forNamespace:_namespaceGoogleMobilePlatform];
 
 
   NSDictionary *fetchedConfig = _configContent.fetchedConfig;
   NSDictionary *fetchedConfig = _configContent.fetchedConfig;
-  XCTAssertNil(fetchedConfig[FIRNamespaceGoogleMobilePlatform][@"key1"]);
-  XCTAssertNotNil(fetchedConfig[FIRNamespaceGoogleMobilePlatform][@"key2"]);
-  XCTAssertEqualObjects([fetchedConfig[FIRNamespaceGoogleMobilePlatform][@"key2"] stringValue],
+  XCTAssertNil(fetchedConfig[_namespaceGoogleMobilePlatform][@"key1"]);
+  XCTAssertNotNil(fetchedConfig[_namespaceGoogleMobilePlatform][@"key2"]);
+  XCTAssertEqualObjects([fetchedConfig[_namespaceGoogleMobilePlatform][@"key2"] stringValue],
                         @"value2");
                         @"value2");
-  XCTAssertNotNil(fetchedConfig[FIRNamespaceGoogleMobilePlatform][@"key3"]);
-  XCTAssertEqualObjects([fetchedConfig[FIRNamespaceGoogleMobilePlatform][@"key3"] stringValue],
+  XCTAssertNotNil(fetchedConfig[_namespaceGoogleMobilePlatform][@"key3"]);
+  XCTAssertEqualObjects([fetchedConfig[_namespaceGoogleMobilePlatform][@"key3"] stringValue],
                         @"value3");
                         @"value3");
 }
 }
 
 
@@ -332,7 +335,9 @@ extern const NSTimeInterval kDatabaseLoadTimeoutSecs;
 
 
   // populate fetched config
   // populate fetched config
   NSMutableDictionary *fetchResponse =
   NSMutableDictionary *fetchResponse =
-      [self createFetchResponseWithConfigEntries:@{@"key1" : @"value1"} p13nMetadata:nil];
+      [self createFetchResponseWithConfigEntries:@{@"key1" : @"value1"}
+                                    p13nMetadata:nil
+                                 rolloutMetadata:nil];
   [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace];
   [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace];
 
 
   // active config is the same as fetched config
   // active config is the same as fetched config
@@ -365,7 +370,8 @@ extern const NSTimeInterval kDatabaseLoadTimeoutSecs;
   // fetch response has new param
   // fetch response has new param
   NSMutableDictionary *fetchResponse =
   NSMutableDictionary *fetchResponse =
       [self createFetchResponseWithConfigEntries:@{@"key1" : @"value1", newParam : @"value2"}
       [self createFetchResponseWithConfigEntries:@{@"key1" : @"value1", newParam : @"value2"}
-                                    p13nMetadata:nil];
+                                    p13nMetadata:nil
+                                 rolloutMetadata:nil];
   [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace];
   [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace];
 
 
   FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace];
   FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace];
@@ -391,7 +397,9 @@ extern const NSTimeInterval kDatabaseLoadTimeoutSecs;
 
 
   // fetch response contains updated value
   // fetch response contains updated value
   NSMutableDictionary *fetchResponse =
   NSMutableDictionary *fetchResponse =
-      [self createFetchResponseWithConfigEntries:@{existingParam : updatedValue} p13nMetadata:nil];
+      [self createFetchResponseWithConfigEntries:@{existingParam : updatedValue}
+                                    p13nMetadata:nil
+                                 rolloutMetadata:nil];
   [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace];
   [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace];
 
 
   FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace];
   FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace];
@@ -417,7 +425,9 @@ extern const NSTimeInterval kDatabaseLoadTimeoutSecs;
 
 
   // fetch response does not contain existing param
   // fetch response does not contain existing param
   NSMutableDictionary *fetchResponse =
   NSMutableDictionary *fetchResponse =
-      [self createFetchResponseWithConfigEntries:@{newParam : value1} p13nMetadata:nil];
+      [self createFetchResponseWithConfigEntries:@{newParam : value1}
+                                    p13nMetadata:nil
+                                 rolloutMetadata:nil];
   [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace];
   [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace];
 
 
   FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace];
   FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace];
@@ -437,7 +447,8 @@ extern const NSTimeInterval kDatabaseLoadTimeoutSecs;
   // popuate fetched config
   // popuate fetched config
   NSMutableDictionary *fetchResponse =
   NSMutableDictionary *fetchResponse =
       [self createFetchResponseWithConfigEntries:@{existingParam : value1}
       [self createFetchResponseWithConfigEntries:@{existingParam : value1}
-                                    p13nMetadata:@{existingParam : oldMetadata}];
+                                    p13nMetadata:@{existingParam : oldMetadata}
+                                 rolloutMetadata:nil];
   [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace];
   [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace];
 
 
   // populate active config with the same content
   // populate active config with the same content
@@ -461,6 +472,148 @@ extern const NSTimeInterval kDatabaseLoadTimeoutSecs;
   XCTAssertTrue([[update updatedKeys] containsObject:existingParam]);
   XCTAssertTrue([[update updatedKeys] containsObject:existingParam]);
 }
 }
 
 
+- (void)testConfigUpdate_rolloutMetadataUpdated_returnsKey {
+  NSString *namespace = @"test_namespace";
+  NSString *key1 = @"key1";
+  NSString *key2 = @"kety2";
+  NSString *value = @"value";
+  NSString *rolloutId1 = @"1";
+  NSString *rolloutId2 = @"2";
+  NSString *variantId1 = @"A";
+  NSString *variantId2 = @"B";
+  NSArray *rolloutMetadata = @[ @{
+    RCNFetchResponseKeyRolloutID : rolloutId1,
+    RCNFetchResponseKeyVariantID : variantId1,
+    RCNFetchResponseKeyAffectedParameterKeys : @[ key1 ]
+  } ];
+  // Update rolltou metadata
+  NSArray *updatedRolloutMetadata = @[
+    @{
+      RCNFetchResponseKeyRolloutID : rolloutId1,
+      RCNFetchResponseKeyVariantID : variantId2,
+      RCNFetchResponseKeyAffectedParameterKeys : @[ key1 ]
+    },
+    @{
+      RCNFetchResponseKeyRolloutID : rolloutId2,
+      RCNFetchResponseKeyVariantID : variantId1,
+      RCNFetchResponseKeyAffectedParameterKeys : @[ key2 ]
+    },
+  ];
+  // Populate fetched config
+  NSMutableDictionary *fetchResponse = [self createFetchResponseWithConfigEntries:@{key1 : value}
+                                                                     p13nMetadata:nil
+                                                                  rolloutMetadata:rolloutMetadata];
+  [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace];
+  // populate active config with the same content
+  [_configContent activateRolloutMetadata:nil];
+  XCTAssertEqualObjects(rolloutMetadata, _configContent.activeRolloutMetadata);
+  FIRRemoteConfigValue *rcValue =
+      [[FIRRemoteConfigValue alloc] initWithData:[value dataUsingEncoding:NSUTF8StringEncoding]
+                                          source:FIRRemoteConfigSourceRemote];
+
+  NSDictionary *namespaceToConfig = @{namespace : @{key1 : rcValue}};
+  [_configContent copyFromDictionary:namespaceToConfig
+                            toSource:RCNDBSourceActive
+                        forNamespace:namespace];
+  // New fetch response has updated rollout metadata
+  [fetchResponse setValue:updatedRolloutMetadata forKey:RCNFetchResponseKeyRolloutMetadata];
+  [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace];
+
+  FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace];
+
+  XCTAssertTrue([update updatedKeys].count == 2);
+  XCTAssertTrue([[update updatedKeys] containsObject:key1]);
+  XCTAssertTrue([[update updatedKeys] containsObject:key2]);
+}
+
+- (void)testConfigUpdate_rolloutMetadataDeleted_returnsKey {
+  NSString *namespace = @"test_namespace";
+  NSString *key1 = @"key1";
+  NSString *key2 = @"key2";
+  NSString *value = @"value";
+  NSString *rolloutId1 = @"1";
+  NSString *variantId1 = @"A";
+  NSArray *rolloutMetadata = @[ @{
+    RCNFetchResponseKeyRolloutID : rolloutId1,
+    RCNFetchResponseKeyVariantID : variantId1,
+    RCNFetchResponseKeyAffectedParameterKeys : @[ key1, key2 ]
+  } ];
+  // Remove key2 from rollout metadata
+  NSArray *updatedRolloutMetadata = @[ @{
+    RCNFetchResponseKeyRolloutID : rolloutId1,
+    RCNFetchResponseKeyVariantID : variantId1,
+    RCNFetchResponseKeyAffectedParameterKeys : @[ key1 ]
+  } ];
+  // Populate fetched config
+  NSMutableDictionary *fetchResponse =
+      [self createFetchResponseWithConfigEntries:@{key1 : value, key2 : value}
+                                    p13nMetadata:nil
+                                 rolloutMetadata:rolloutMetadata];
+  [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace];
+  // populate active config with the same content
+  [_configContent activateRolloutMetadata:nil];
+  XCTAssertEqualObjects(rolloutMetadata, _configContent.activeRolloutMetadata);
+  FIRRemoteConfigValue *rcValue =
+      [[FIRRemoteConfigValue alloc] initWithData:[value dataUsingEncoding:NSUTF8StringEncoding]
+                                          source:FIRRemoteConfigSourceRemote];
+
+  NSDictionary *namespaceToConfig = @{namespace : @{key1 : rcValue, key2 : rcValue}};
+  [_configContent copyFromDictionary:namespaceToConfig
+                            toSource:RCNDBSourceActive
+                        forNamespace:namespace];
+  // New fetch response has updated rollout metadata
+  [fetchResponse setValue:updatedRolloutMetadata forKey:RCNFetchResponseKeyRolloutMetadata];
+  [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace];
+
+  FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace];
+
+  XCTAssertTrue([update updatedKeys].count == 1);
+  XCTAssertTrue([[update updatedKeys] containsObject:key2]);
+}
+
+- (void)testConfigUpdate_rolloutMetadataDeletedAll_returnsKey {
+  NSString *namespace = @"test_namespace";
+  NSString *key = @"key";
+  NSString *value = @"value";
+  NSString *rolloutId1 = @"1";
+  NSString *variantId1 = @"A";
+  NSArray *rolloutMetadata = @[ @{
+    RCNFetchResponseKeyRolloutID : rolloutId1,
+    RCNFetchResponseKeyVariantID : variantId1,
+    RCNFetchResponseKeyAffectedParameterKeys : @[ key ]
+  } ];
+  // Populate fetched config
+  NSMutableDictionary *fetchResponse = [self createFetchResponseWithConfigEntries:@{key : value}
+                                                                     p13nMetadata:nil
+                                                                  rolloutMetadata:rolloutMetadata];
+  [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace];
+  // populate active config with the same content
+  [_configContent activateRolloutMetadata:nil];
+  XCTAssertEqualObjects(rolloutMetadata, _configContent.activeRolloutMetadata);
+  FIRRemoteConfigValue *rcValue =
+      [[FIRRemoteConfigValue alloc] initWithData:[value dataUsingEncoding:NSUTF8StringEncoding]
+                                          source:FIRRemoteConfigSourceRemote];
+
+  NSDictionary *namespaceToConfig = @{namespace : @{key : rcValue}};
+  [_configContent copyFromDictionary:namespaceToConfig
+                            toSource:RCNDBSourceActive
+                        forNamespace:namespace];
+
+  // New fetch response has updated rollout metadata
+  NSMutableDictionary *updateFetchResponse =
+      [self createFetchResponseWithConfigEntries:@{key : value}
+                                    p13nMetadata:nil
+                                 rolloutMetadata:nil];
+  [_configContent updateConfigContentWithResponse:updateFetchResponse forNamespace:namespace];
+
+  FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace];
+  [_configContent activateRolloutMetadata:nil];
+
+  XCTAssertTrue([update updatedKeys].count == 1);
+  XCTAssertTrue([[update updatedKeys] containsObject:key]);
+  XCTAssertTrue(_configContent.activeRolloutMetadata.count == 0);
+}
+
 - (void)testConfigUpdate_valueSourceChanged_returnsKey {
 - (void)testConfigUpdate_valueSourceChanged_returnsKey {
   NSString *namespace = @"test_namespace";
   NSString *namespace = @"test_namespace";
   NSString *existingParam = @"key1";
   NSString *existingParam = @"key1";
@@ -477,7 +630,9 @@ extern const NSTimeInterval kDatabaseLoadTimeoutSecs;
 
 
   // fetch response contains same key->value
   // fetch response contains same key->value
   NSMutableDictionary *fetchResponse =
   NSMutableDictionary *fetchResponse =
-      [self createFetchResponseWithConfigEntries:@{existingParam : value1} p13nMetadata:nil];
+      [self createFetchResponseWithConfigEntries:@{existingParam : value1}
+                                    p13nMetadata:nil
+                                 rolloutMetadata:nil];
   [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace];
   [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace];
 
 
   FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace];
   FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace];
@@ -489,14 +644,18 @@ extern const NSTimeInterval kDatabaseLoadTimeoutSecs;
 #pragma mark - Test Helpers
 #pragma mark - Test Helpers
 
 
 - (NSMutableDictionary *)createFetchResponseWithConfigEntries:(NSDictionary *)config
 - (NSMutableDictionary *)createFetchResponseWithConfigEntries:(NSDictionary *)config
-                                                 p13nMetadata:(NSDictionary *)metadata {
+                                                 p13nMetadata:(NSDictionary *)p13nMetadata
+                                              rolloutMetadata:(NSArray *)rolloutMetadata {
   NSMutableDictionary *fetchResponse = [[NSMutableDictionary alloc]
   NSMutableDictionary *fetchResponse = [[NSMutableDictionary alloc]
       initWithObjectsAndKeys:RCNFetchResponseKeyStateUpdate, RCNFetchResponseKeyState, nil];
       initWithObjectsAndKeys:RCNFetchResponseKeyStateUpdate, RCNFetchResponseKeyState, nil];
   if (config) {
   if (config) {
     [fetchResponse setValue:config forKey:RCNFetchResponseKeyEntries];
     [fetchResponse setValue:config forKey:RCNFetchResponseKeyEntries];
   }
   }
-  if (metadata) {
-    [fetchResponse setValue:metadata forKey:RCNFetchResponseKeyPersonalizationMetadata];
+  if (p13nMetadata) {
+    [fetchResponse setValue:p13nMetadata forKey:RCNFetchResponseKeyPersonalizationMetadata];
+  }
+  if (rolloutMetadata) {
+    [fetchResponse setValue:rolloutMetadata forKey:RCNFetchResponseKeyRolloutMetadata];
   }
   }
   return fetchResponse;
   return fetchResponse;
 }
 }

+ 149 - 16
FirebaseRemoteConfig/Tests/Unit/RCNConfigDBManagerTest.m

@@ -83,8 +83,8 @@
                            BOOL loadSuccess,
                            BOOL loadSuccess,
                            NSDictionary<NSString *, NSDictionary<NSString *, id> *> *fetchedConfig,
                            NSDictionary<NSString *, NSDictionary<NSString *, id> *> *fetchedConfig,
                            NSDictionary<NSString *, NSDictionary<NSString *, id> *> *activeConfig,
                            NSDictionary<NSString *, NSDictionary<NSString *, id> *> *activeConfig,
-                           NSDictionary<NSString *, NSDictionary<NSString *, id> *>
-                               *defaultConfig) {
+                           NSDictionary<NSString *, NSDictionary<NSString *, id> *> *defaultConfig,
+                           NSDictionary *unusedRolloutMetadata) {
                          XCTAssertTrue(loadSuccess);
                          XCTAssertTrue(loadSuccess);
                          NSString *fullyQualifiedNamespace =
                          NSString *fullyQualifiedNamespace =
                              [NSString stringWithFormat:@"%@:%@", namespace_p, kFIRDefaultAppName];
                              [NSString stringWithFormat:@"%@:%@", namespace_p, kFIRDefaultAppName];
@@ -125,18 +125,19 @@
       XCTAssertTrue(success);
       XCTAssertTrue(success);
       if (count == 100) {
       if (count == 100) {
         // check DB read correctly
         // check DB read correctly
-        [self->_DBManager loadMainWithBundleIdentifier:bundleIdentifier
-                                     completionHandler:^(BOOL success, NSDictionary *fetchedConfig,
-                                                         NSDictionary *activeConfig,
-                                                         NSDictionary *defaultConfig) {
-                                       NSMutableDictionary *res = [fetchedConfig mutableCopy];
-                                       XCTAssertTrue(success);
-                                       FIRRemoteConfigValue *value = res[namespace_p][@"key100"];
-                                       XCTAssertEqualObjects(value.stringValue, @"value100");
-                                       if (success) {
-                                         [loadConfigContentExpectation fulfill];
-                                       }
-                                     }];
+        [self->_DBManager
+            loadMainWithBundleIdentifier:bundleIdentifier
+                       completionHandler:^(BOOL success, NSDictionary *fetchedConfig,
+                                           NSDictionary *activeConfig, NSDictionary *defaultConfig,
+                                           NSDictionary *unusedRolloutMetadata) {
+                         NSMutableDictionary *res = [fetchedConfig mutableCopy];
+                         XCTAssertTrue(success);
+                         FIRRemoteConfigValue *value = res[namespace_p][@"key100"];
+                         XCTAssertEqualObjects(value.stringValue, @"value100");
+                         if (success) {
+                           [loadConfigContentExpectation fulfill];
+                         }
+                       }];
       }
       }
     };
     };
     NSString *value = [NSString stringWithFormat:@"value%d", i];
     NSString *value = [NSString stringWithFormat:@"value%d", i];
@@ -382,7 +383,8 @@
     [self->_DBManager
     [self->_DBManager
         loadMainWithBundleIdentifier:bundleIdentifier
         loadMainWithBundleIdentifier:bundleIdentifier
                    completionHandler:^(BOOL success, NSDictionary *fetchedConfig,
                    completionHandler:^(BOOL success, NSDictionary *fetchedConfig,
-                                       NSDictionary *activeConfig, NSDictionary *defaultConfig) {
+                                       NSDictionary *activeConfig, NSDictionary *defaultConfig,
+                                       NSDictionary *unusedRolloutMetadata) {
                      NSMutableDictionary *res = [activeConfig mutableCopy];
                      NSMutableDictionary *res = [activeConfig mutableCopy];
                      XCTAssertTrue(success);
                      XCTAssertTrue(success);
                      FIRRemoteConfigValue *value = res[namespaceToDelete][@"keyToDelete"];
                      FIRRemoteConfigValue *value = res[namespaceToDelete][@"keyToDelete"];
@@ -403,7 +405,8 @@
     [self->_DBManager
     [self->_DBManager
         loadMainWithBundleIdentifier:bundleIdentifier
         loadMainWithBundleIdentifier:bundleIdentifier
                    completionHandler:^(BOOL success, NSDictionary *fetchedConfig,
                    completionHandler:^(BOOL success, NSDictionary *fetchedConfig,
-                                       NSDictionary *activeConfig, NSDictionary *defaultConfig) {
+                                       NSDictionary *activeConfig, NSDictionary *defaultConfig,
+                                       NSDictionary *unusedRolloutMetadata) {
                      NSMutableDictionary *res = [activeConfig mutableCopy];
                      NSMutableDictionary *res = [activeConfig mutableCopy];
                      XCTAssertTrue(success);
                      XCTAssertTrue(success);
                      FIRRemoteConfigValue *value2 = res[namespaceToKeep][@"keyToRetain"];
                      FIRRemoteConfigValue *value2 = res[namespaceToKeep][@"keyToRetain"];
@@ -587,6 +590,136 @@
   [self waitForExpectationsWithTimeout:_expectionTimeout handler:nil];
   [self waitForExpectationsWithTimeout:_expectionTimeout handler:nil];
 }
 }
 
 
+- (void)testWriteAndLoadFetchedAndActiveRollout {
+  XCTestExpectation *writeAndLoadFetchedRolloutExpectation =
+      [self expectationWithDescription:@"Write and load rollout in database successfully"];
+
+  NSString *bundleIdentifier = [NSBundle mainBundle].bundleIdentifier;
+
+  NSArray *fetchedRollout = @[
+    @{
+      @"rollout_id" : @"1",
+      @"variant_id" : @"B",
+      @"affected_parameter_keys" : @[ @"key_1", @"key_2" ]
+    },
+    @{
+      @"rollout_id" : @"2",
+      @"variant_id" : @"1",
+      @"affected_parameter_keys" : @[ @"key_1", @"key_3" ]
+    }
+  ];
+
+  NSArray *activeRollout = @[
+    @{
+      @"rollout_id" : @"1",
+      @"variant_id" : @"B",
+      @"affected_parameter_keys" : @[ @"key_1", @"key_2" ]
+    },
+    @{
+      @"rollout_id" : @"3",
+      @"variant_id" : @"a",
+      @"affected_parameter_keys" : @[ @"key_1", @"key_3" ]
+    }
+  ];
+
+  RCNDBCompletion writeRolloutCompletion = ^(BOOL success, NSDictionary *result) {
+    XCTAssertTrue(success);
+    RCNDBLoadCompletion loadCompletion = ^(
+        BOOL success, NSDictionary *unusedFetchedConfig, NSDictionary *unusedActiveConfig,
+        NSDictionary *unusedDefaultConfig, NSDictionary *rolloutMetadata) {
+      XCTAssertTrue(success);
+      XCTAssertNotNil(rolloutMetadata[@RCNRolloutTableKeyFetchedMetadata]);
+      XCTAssertEqualObjects(fetchedRollout, rolloutMetadata[@RCNRolloutTableKeyFetchedMetadata]);
+      XCTAssertNotNil(rolloutMetadata[@RCNRolloutTableKeyActiveMetadata]);
+      XCTAssertEqualObjects(activeRollout, rolloutMetadata[@RCNRolloutTableKeyActiveMetadata]);
+
+      [writeAndLoadFetchedRolloutExpectation fulfill];
+    };
+    [self->_DBManager loadMainWithBundleIdentifier:bundleIdentifier
+                                 completionHandler:loadCompletion];
+  };
+  [_DBManager insertOrUpdateRolloutTableWithKey:@RCNRolloutTableKeyFetchedMetadata
+                                          value:fetchedRollout
+                              completionHandler:nil];
+  [_DBManager insertOrUpdateRolloutTableWithKey:@RCNRolloutTableKeyActiveMetadata
+                                          value:activeRollout
+                              completionHandler:writeRolloutCompletion];
+
+  [self waitForExpectationsWithTimeout:_expectionTimeout handler:nil];
+}
+
+- (void)testUpdateAndLoadRollout {
+  XCTestExpectation *updateAndLoadFetchedRolloutExpectation =
+      [self expectationWithDescription:@"Update and load rollout in database successfully"];
+
+  NSString *bundleIdentifier = [NSBundle mainBundle].bundleIdentifier;
+
+  NSArray *fetchedRollout = @[ @{
+    @"rollout_id" : @"1",
+    @"variant_id" : @"B",
+    @"affected_parameter_keys" : @[ @"key_1", @"key_2" ]
+  } ];
+
+  NSArray *updatedFetchedRollout = @[
+    @{
+      @"rollout_id" : @"1",
+      @"variant_id" : @"B",
+      @"affected_parameter_keys" : @[ @"key_1", @"key_2" ]
+    },
+    @{
+      @"rollout_id" : @"2",
+      @"variant_id" : @"1",
+      @"affected_parameter_keys" : @[ @"key_1", @"key_3" ]
+    }
+  ];
+
+  RCNDBCompletion writeRolloutCompletion = ^(BOOL success, NSDictionary *result) {
+    XCTAssertTrue(success);
+    RCNDBLoadCompletion loadCompletion =
+        ^(BOOL success, NSDictionary *unusedFetchedConfig, NSDictionary *unusedActiveConfig,
+          NSDictionary *unusedDefaultConfig, NSDictionary *rolloutMetadata) {
+          XCTAssertTrue(success);
+          XCTAssertNotNil(rolloutMetadata[@RCNRolloutTableKeyFetchedMetadata]);
+          XCTAssertEqualObjects(updatedFetchedRollout,
+                                rolloutMetadata[@RCNRolloutTableKeyFetchedMetadata]);
+
+          [updateAndLoadFetchedRolloutExpectation fulfill];
+        };
+    [self->_DBManager loadMainWithBundleIdentifier:bundleIdentifier
+                                 completionHandler:loadCompletion];
+  };
+  [_DBManager insertOrUpdateRolloutTableWithKey:@RCNRolloutTableKeyFetchedMetadata
+                                          value:fetchedRollout
+                              completionHandler:nil];
+  [_DBManager insertOrUpdateRolloutTableWithKey:@RCNRolloutTableKeyFetchedMetadata
+                                          value:updatedFetchedRollout
+                              completionHandler:writeRolloutCompletion];
+
+  [self waitForExpectationsWithTimeout:_expectionTimeout handler:nil];
+}
+- (void)testLoadEmptyRollout {
+  XCTestExpectation *updateAndLoadFetchedRolloutExpectation =
+      [self expectationWithDescription:@"Load empty rollout in database successfully"];
+
+  NSString *bundleIdentifier = [NSBundle mainBundle].bundleIdentifier;
+
+  NSArray *emptyResult = [[NSArray alloc] init];
+
+  RCNDBLoadCompletion loadCompletion =
+      ^(BOOL success, NSDictionary *unusedFetchedConfig, NSDictionary *unusedActiveConfig,
+        NSDictionary *unusedDefaultConfig, NSDictionary *rolloutMetadata) {
+        XCTAssertTrue(success);
+        XCTAssertNotNil(rolloutMetadata[@RCNRolloutTableKeyFetchedMetadata]);
+        XCTAssertEqualObjects(emptyResult, rolloutMetadata[@RCNRolloutTableKeyFetchedMetadata]);
+        XCTAssertNotNil(rolloutMetadata[@RCNRolloutTableKeyActiveMetadata]);
+        XCTAssertEqualObjects(emptyResult, rolloutMetadata[@RCNRolloutTableKeyActiveMetadata]);
+
+        [updateAndLoadFetchedRolloutExpectation fulfill];
+      };
+  [self->_DBManager loadMainWithBundleIdentifier:bundleIdentifier completionHandler:loadCompletion];
+  [self waitForExpectationsWithTimeout:_expectionTimeout handler:nil];
+}
+
 - (void)testUpdateAndloadLastFetchStatus {
 - (void)testUpdateAndloadLastFetchStatus {
   XCTestExpectation *updateAndLoadMetadataExpectation = [self
   XCTestExpectation *updateAndLoadMetadataExpectation = [self
       expectationWithDescription:@"Update and load last fetch status in database successfully."];
       expectationWithDescription:@"Update and load last fetch status in database successfully."];

+ 18 - 15
FirebaseRemoteConfig/Tests/Unit/RCNConfigTest.m

@@ -23,6 +23,7 @@
 #import "FirebaseRemoteConfig/Sources/RCNConfigExperiment.h"
 #import "FirebaseRemoteConfig/Sources/RCNConfigExperiment.h"
 #import "FirebaseRemoteConfig/Sources/RCNConfigValue_Internal.h"
 #import "FirebaseRemoteConfig/Sources/RCNConfigValue_Internal.h"
 #import "FirebaseRemoteConfig/Tests/Unit/RCNTestUtilities.h"
 #import "FirebaseRemoteConfig/Tests/Unit/RCNTestUtilities.h"
+@import FirebaseRemoteConfigInterop;
 
 
 static NSString *const RCNFakeSenderID = @"855865492447";
 static NSString *const RCNFakeSenderID = @"855865492447";
 static NSString *const RCNFakeToken = @"ctToAh17Exk:"
 static NSString *const RCNFakeToken = @"ctToAh17Exk:"
@@ -48,6 +49,7 @@ static NSString *const RCNFakeSecretToken = @"6377571288467228941";
   RCNConfigExperiment *_experiment;
   RCNConfigExperiment *_experiment;
   RCNConfigFetch *_configFetch;
   RCNConfigFetch *_configFetch;
   dispatch_queue_t _queue;
   dispatch_queue_t _queue;
+  NSString *_namespaceGoogleMobilePlatform;
 }
 }
 @end
 @end
 
 
@@ -66,9 +68,10 @@ static NSString *const RCNFakeSecretToken = @"6377571288467228941";
                                                          experiment:_experiment
                                                          experiment:_experiment
                                                               queue:_queue];
                                                               queue:_queue];
   _configFetch = OCMPartialMock(fetcher);
   _configFetch = OCMPartialMock(fetcher);
+  _namespaceGoogleMobilePlatform = FIRRemoteConfigConstants.FIRNamespaceGoogleMobilePlatform;
   // Fake a response with a default namespace and a custom namespace.
   // Fake a response with a default namespace and a custom namespace.
   NSDictionary *namespaceToConfig = @{
   NSDictionary *namespaceToConfig = @{
-    FIRNamespaceGoogleMobilePlatform : @{@"key1" : @"value1", @"key2" : @"value2"},
+    _namespaceGoogleMobilePlatform : @{@"key1" : @"value1", @"key2" : @"value2"},
     FIRNamespaceGooglePlayPlatform : @{@"playerID" : @"36", @"gameLevel" : @"87"},
     FIRNamespaceGooglePlayPlatform : @{@"playerID" : @"36", @"gameLevel" : @"87"},
   };
   };
   _response =
   _response =
@@ -149,19 +152,19 @@ static NSString *const RCNFakeSecretToken = @"6377571288467228941";
         XCTAssertNotNil(result);
         XCTAssertNotNil(result);
 
 
         [self checkConfigResult:result
         [self checkConfigResult:result
-                  withNamespace:FIRNamespaceGoogleMobilePlatform
+                  withNamespace:_namespaceGoogleMobilePlatform
                             key:@"key1"
                             key:@"key1"
                           value:@"value1"];
                           value:@"value1"];
         [self checkConfigResult:result
         [self checkConfigResult:result
-                  withNamespace:FIRNamespaceGoogleMobilePlatform
+                  withNamespace:_namespaceGoogleMobilePlatform
                             key:@"key2"
                             key:@"key2"
                           value:@"value2"];
                           value:@"value2"];
         [self checkConfigResult:result
         [self checkConfigResult:result
-                  withNamespace:FIRNamespaceGooglePlayPlatform
+                  withNamespace:_namespaceGoogleMobilePlatform
                             key:@"playerID"
                             key:@"playerID"
                           value:@"36"];
                           value:@"36"];
         [self checkConfigResult:result
         [self checkConfigResult:result
-                  withNamespace:FIRNamespaceGooglePlayPlatform
+                  withNamespace:_namespaceGoogleMobilePlatform
                             key:@"gameLevel"
                             key:@"gameLevel"
                           value:@"87"];
                           value:@"87"];
         XCTAssertEqual(self->_settings.expirationInSeconds, 43200,
         XCTAssertEqual(self->_settings.expirationInSeconds, 43200,
@@ -200,11 +203,11 @@ static NSString *const RCNFakeSecretToken = @"6377571288467228941";
           NSDictionary *result = self->_configContent.fetchedConfig;
           NSDictionary *result = self->_configContent.fetchedConfig;
           XCTAssertNotNil(result);
           XCTAssertNotNil(result);
           [self checkConfigResult:result
           [self checkConfigResult:result
-                    withNamespace:FIRNamespaceGoogleMobilePlatform
+                    withNamespace:_namespaceGoogleMobilePlatform
                               key:@"key1"
                               key:@"key1"
                             value:@"value1"];
                             value:@"value1"];
           [self checkConfigResult:result
           [self checkConfigResult:result
-                    withNamespace:FIRNamespaceGoogleMobilePlatform
+                    withNamespace:_namespaceGoogleMobilePlatform
                               key:@"key2"
                               key:@"key2"
                             value:@"value2"];
                             value:@"value2"];
 
 
@@ -246,19 +249,19 @@ static NSString *const RCNFakeSecretToken = @"6377571288467228941";
         XCTAssertNotNil(result);
         XCTAssertNotNil(result);
 
 
         [self checkConfigResult:result
         [self checkConfigResult:result
-                  withNamespace:FIRNamespaceGoogleMobilePlatform
+                  withNamespace:_namespaceGoogleMobilePlatform
                             key:@"key1"
                             key:@"key1"
                           value:@"value1"];
                           value:@"value1"];
         [self checkConfigResult:result
         [self checkConfigResult:result
-                  withNamespace:FIRNamespaceGoogleMobilePlatform
+                  withNamespace:_namespaceGoogleMobilePlatform
                             key:@"key2"
                             key:@"key2"
                           value:@"value2"];
                           value:@"value2"];
         [self checkConfigResult:result
         [self checkConfigResult:result
-                  withNamespace:FIRNamespaceGooglePlayPlatform
+                  withNamespace:_namespaceGoogleMobilePlatform
                             key:@"playerID"
                             key:@"playerID"
                           value:@"36"];
                           value:@"36"];
         [self checkConfigResult:result
         [self checkConfigResult:result
-                  withNamespace:FIRNamespaceGooglePlayPlatform
+                  withNamespace:_namespaceGoogleMobilePlatform
                             key:@"gameLevel"
                             key:@"gameLevel"
                           value:@"87"];
                           value:@"87"];
 
 
@@ -340,19 +343,19 @@ static NSString *const RCNFakeSecretToken = @"6377571288467228941";
         NSDictionary *result = self->_configContent.fetchedConfig;
         NSDictionary *result = self->_configContent.fetchedConfig;
         XCTAssertNotNil(result);
         XCTAssertNotNil(result);
         [self checkConfigResult:result
         [self checkConfigResult:result
-                  withNamespace:FIRNamespaceGoogleMobilePlatform
+                  withNamespace:_namespaceGoogleMobilePlatform
                             key:@"key1"
                             key:@"key1"
                           value:@"value1"];
                           value:@"value1"];
         [self checkConfigResult:result
         [self checkConfigResult:result
-                  withNamespace:FIRNamespaceGoogleMobilePlatform
+                  withNamespace:_namespaceGoogleMobilePlatform
                             key:@"key2"
                             key:@"key2"
                           value:@"value2"];
                           value:@"value2"];
         [self checkConfigResult:result
         [self checkConfigResult:result
-                  withNamespace:FIRNamespaceGooglePlayPlatform
+                  withNamespace:_namespaceGoogleMobilePlatform
                             key:@"playerID"
                             key:@"playerID"
                           value:@"36"];
                           value:@"36"];
         [self checkConfigResult:result
         [self checkConfigResult:result
-                  withNamespace:FIRNamespaceGooglePlayPlatform
+                  withNamespace:_namespaceGoogleMobilePlatform
                             key:@"gameLevel"
                             key:@"gameLevel"
                           value:@"87"];
                           value:@"87"];
         XCTAssertEqual(
         XCTAssertEqual(

+ 3 - 1
FirebaseRemoteConfig/Tests/Unit/RCNInstanceIDTest.m

@@ -29,6 +29,7 @@
 #import <GoogleUtilities/GULNSData+zlib.h>
 #import <GoogleUtilities/GULNSData+zlib.h>
 #import "FirebaseCore/Extension/FirebaseCoreInternal.h"
 #import "FirebaseCore/Extension/FirebaseCoreInternal.h"
 #import "FirebaseInstallations/Source/Library/Private/FirebaseInstallationsInternal.h"
 #import "FirebaseInstallations/Source/Library/Private/FirebaseInstallationsInternal.h"
+@import FirebaseRemoteConfigInterop;
 
 
 @interface RCNConfigFetch (ForTest)
 @interface RCNConfigFetch (ForTest)
 - (instancetype)initWithContent:(RCNConfigContent *)content
 - (instancetype)initWithContent:(RCNConfigContent *)content
@@ -136,7 +137,8 @@ typedef NS_ENUM(NSInteger, RCNTestRCInstance) {
       case RCNTestRCInstanceSecondApp:
       case RCNTestRCInstanceSecondApp:
         currentAppName = RCNTestsSecondFIRAppName;
         currentAppName = RCNTestsSecondFIRAppName;
         currentOptions = [self secondAppOptions];
         currentOptions = [self secondAppOptions];
-        currentNamespace = FIRNamespaceGoogleMobilePlatform;
+        currentNamespace = FIRRemoteConfigConstants.FIRNamespaceGoogleMobilePlatform;
+        ;
         break;
         break;
       case RCNTestRCInstanceDefault:
       case RCNTestRCInstanceDefault:
       default:
       default:

+ 60 - 5
FirebaseRemoteConfig/Tests/Unit/RCNRemoteConfigTest.m

@@ -18,6 +18,7 @@
 #import <OCMock/OCMock.h>
 #import <OCMock/OCMock.h>
 #import <XCTest/XCTest.h>
 #import <XCTest/XCTest.h>
 
 
+#import "FirebaseRemoteConfig/Sources/FIRRemoteConfigComponent.h"
 #import "FirebaseRemoteConfig/Sources/Private/FIRRemoteConfig_Private.h"
 #import "FirebaseRemoteConfig/Sources/Private/FIRRemoteConfig_Private.h"
 #import "FirebaseRemoteConfig/Sources/Private/RCNConfigFetch.h"
 #import "FirebaseRemoteConfig/Sources/Private/RCNConfigFetch.h"
 #import "FirebaseRemoteConfig/Sources/Public/FirebaseRemoteConfig/FIRRemoteConfig.h"
 #import "FirebaseRemoteConfig/Sources/Public/FirebaseRemoteConfig/FIRRemoteConfig.h"
@@ -31,6 +32,9 @@
 
 
 #import <GoogleUtilities/GULNSData+zlib.h>
 #import <GoogleUtilities/GULNSData+zlib.h>
 #import "FirebaseCore/Extension/FirebaseCoreInternal.h"
 #import "FirebaseCore/Extension/FirebaseCoreInternal.h"
+@import FirebaseRemoteConfigInterop;
+
+@protocol FIRRolloutsStateSubscriber;
 
 
 @interface RCNConfigFetch (ForTest)
 @interface RCNConfigFetch (ForTest)
 - (instancetype)initWithContent:(RCNConfigContent *)content
 - (instancetype)initWithContent:(RCNConfigContent *)content
@@ -130,6 +134,7 @@ typedef NS_ENUM(NSInteger, RCNTestRCInstance) {
   NSTimeInterval _checkCompletionTimeout;
   NSTimeInterval _checkCompletionTimeout;
   NSMutableArray<FIRRemoteConfig *> *_configInstances;
   NSMutableArray<FIRRemoteConfig *> *_configInstances;
   NSMutableArray<NSDictionary<NSString *, NSString *> *> *_entries;
   NSMutableArray<NSDictionary<NSString *, NSString *> *> *_entries;
+  NSArray<NSDictionary *> *_rolloutMetadata;
   NSMutableArray<NSDictionary<NSString *, id> *> *_response;
   NSMutableArray<NSDictionary<NSString *, id> *> *_response;
   NSMutableArray<NSData *> *_responseData;
   NSMutableArray<NSData *> *_responseData;
   NSMutableArray<NSURLResponse *> *_URLResponse;
   NSMutableArray<NSURLResponse *> *_URLResponse;
@@ -145,6 +150,7 @@ typedef NS_ENUM(NSInteger, RCNTestRCInstance) {
   NSString *_fullyQualifiedNamespace;
   NSString *_fullyQualifiedNamespace;
   RCNConfigSettings *_settings;
   RCNConfigSettings *_settings;
   dispatch_queue_t _queue;
   dispatch_queue_t _queue;
+  NSString *_namespaceGoogleMobilePlatform;
 }
 }
 @end
 @end
 
 
@@ -180,6 +186,7 @@ typedef NS_ENUM(NSInteger, RCNTestRCInstance) {
   _URLResponse = [[NSMutableArray alloc] initWithCapacity:3];
   _URLResponse = [[NSMutableArray alloc] initWithCapacity:3];
   _configFetch = [[NSMutableArray alloc] initWithCapacity:3];
   _configFetch = [[NSMutableArray alloc] initWithCapacity:3];
   _configRealtime = [[NSMutableArray alloc] initWithCapacity:3];
   _configRealtime = [[NSMutableArray alloc] initWithCapacity:3];
+  _namespaceGoogleMobilePlatform = FIRRemoteConfigConstants.FIRNamespaceGoogleMobilePlatform;
 
 
   // Populate the default, second app, second namespace instances.
   // Populate the default, second app, second namespace instances.
   for (int i = 0; i < RCNTestRCNumTotalInstances; i++) {
   for (int i = 0; i < RCNTestRCNumTotalInstances; i++) {
@@ -204,7 +211,7 @@ typedef NS_ENUM(NSInteger, RCNTestRCInstance) {
       case RCNTestRCInstanceSecondApp:
       case RCNTestRCInstanceSecondApp:
         currentAppName = RCNTestsSecondFIRAppName;
         currentAppName = RCNTestsSecondFIRAppName;
         currentOptions = [self secondAppOptions];
         currentOptions = [self secondAppOptions];
-        currentNamespace = FIRNamespaceGoogleMobilePlatform;
+        currentNamespace = _namespaceGoogleMobilePlatform;
         break;
         break;
       case RCNTestRCInstanceDefault:
       case RCNTestRCInstanceDefault:
       default:
       default:
@@ -259,7 +266,17 @@ typedef NS_ENUM(NSInteger, RCNTestRCInstance) {
                              updateCompletionHandler:nil];
                              updateCompletionHandler:nil];
     });
     });
 
 
-    _response[i] = @{@"state" : @"UPDATE", @"entries" : _entries[i]};
+    _rolloutMetadata = @[ @{
+      RCNFetchResponseKeyRolloutID : @"1",
+      RCNFetchResponseKeyVariantID : @"0",
+      RCNFetchResponseKeyAffectedParameterKeys : @[ _entries[i].allKeys[0] ]
+    } ];
+
+    _response[i] = @{
+      @"state" : @"UPDATE",
+      @"entries" : _entries[i],
+      RCNFetchResponseKeyRolloutMetadata : _rolloutMetadata
+    };
 
 
     _responseData[i] = [NSJSONSerialization dataWithJSONObject:_response[i] options:0 error:nil];
     _responseData[i] = [NSJSONSerialization dataWithJSONObject:_response[i] options:0 error:nil];
 
 
@@ -286,6 +303,7 @@ typedef NS_ENUM(NSInteger, RCNTestRCInstance) {
 
 
 - (void)tearDown {
 - (void)tearDown {
   [_DBManager removeDatabaseOnDatabaseQueueAtPath:_DBPath];
   [_DBManager removeDatabaseOnDatabaseQueueAtPath:_DBPath];
+  [FIRRemoteConfigComponent clearAllComponentInstances];
   [[NSUserDefaults standardUserDefaults] removePersistentDomainForName:_userDefaultsSuiteName];
   [[NSUserDefaults standardUserDefaults] removePersistentDomainForName:_userDefaultsSuiteName];
   [_DBManagerMock stopMocking];
   [_DBManagerMock stopMocking];
   _DBManagerMock = nil;
   _DBManagerMock = nil;
@@ -594,7 +612,7 @@ typedef NS_ENUM(NSInteger, RCNTestRCInstance) {
       case RCNTestRCInstanceSecondApp:
       case RCNTestRCInstanceSecondApp:
         currentAppName = RCNTestsSecondFIRAppName;
         currentAppName = RCNTestsSecondFIRAppName;
         currentOptions = [self secondAppOptions];
         currentOptions = [self secondAppOptions];
-        currentNamespace = FIRNamespaceGoogleMobilePlatform;
+        currentNamespace = _namespaceGoogleMobilePlatform;
         break;
         break;
       case RCNTestRCInstanceDefault:
       case RCNTestRCInstanceDefault:
       default:
       default:
@@ -707,7 +725,7 @@ typedef NS_ENUM(NSInteger, RCNTestRCInstance) {
       case RCNTestRCInstanceSecondApp:
       case RCNTestRCInstanceSecondApp:
         currentAppName = RCNTestsSecondFIRAppName;
         currentAppName = RCNTestsSecondFIRAppName;
         currentOptions = [self secondAppOptions];
         currentOptions = [self secondAppOptions];
-        currentNamespace = FIRNamespaceGoogleMobilePlatform;
+        currentNamespace = _namespaceGoogleMobilePlatform;
         break;
         break;
       case RCNTestRCInstanceDefault:
       case RCNTestRCInstanceDefault:
       default:
       default:
@@ -911,7 +929,7 @@ typedef NS_ENUM(NSInteger, RCNTestRCInstance) {
       case RCNTestRCInstanceSecondApp:
       case RCNTestRCInstanceSecondApp:
         currentAppName = RCNTestsSecondFIRAppName;
         currentAppName = RCNTestsSecondFIRAppName;
         currentOptions = [self secondAppOptions];
         currentOptions = [self secondAppOptions];
-        currentNamespace = FIRNamespaceGoogleMobilePlatform;
+        currentNamespace = _namespaceGoogleMobilePlatform;
         break;
         break;
       case RCNTestRCInstanceDefault:
       case RCNTestRCInstanceDefault:
       default:
       default:
@@ -1782,6 +1800,43 @@ static NSString *UTCToLocal(NSString *utcTime) {
   XCTAssertTrue([strData containsString:@"appInstanceId:'iid'"]);
   XCTAssertTrue([strData containsString:@"appInstanceId:'iid'"]);
 }
 }
 
 
+- (void)testFetchAndActivateRolloutsNotifyInterop {
+  id mockNotificationCenter = [OCMockObject mockForClass:[NSNotificationCenter class]];
+  [[mockNotificationCenter expect] postNotificationName:@"RolloutsStateDidChangeNotification"
+                                                 object:[OCMArg any]
+                                               userInfo:[OCMArg any]];
+  id mockSubscriber = [OCMockObject mockForProtocol:@protocol(FIRRolloutsStateSubscriber)];
+  [[mockSubscriber expect] rolloutsStateDidChange:[OCMArg any]];
+
+  XCTestExpectation *expectation = [self
+      expectationWithDescription:[NSString
+                                     stringWithFormat:@"Test rollout update send notification"]];
+
+  XCTAssertEqual(_configInstances[RCNTestRCInstanceDefault].lastFetchStatus,
+                 FIRRemoteConfigFetchStatusNoFetchYet);
+
+  FIRRemoteConfigFetchAndActivateCompletion fetchAndActivateCompletion =
+      ^void(FIRRemoteConfigFetchAndActivateStatus status, NSError *error) {
+        XCTAssertEqual(status, FIRRemoteConfigFetchAndActivateStatusSuccessFetchedFromRemote);
+        XCTAssertNil(error);
+
+        XCTAssertEqual(self->_configInstances[RCNTestRCInstanceDefault].lastFetchStatus,
+                       FIRRemoteConfigFetchStatusSuccess);
+        XCTAssertNotNil(self->_configInstances[RCNTestRCInstanceDefault].lastFetchTime);
+        XCTAssertGreaterThan(
+            self->_configInstances[RCNTestRCInstanceDefault].lastFetchTime.timeIntervalSince1970, 0,
+            @"last fetch time interval should be set.");
+        [expectation fulfill];
+      };
+
+  [_configInstances[RCNTestRCInstanceDefault]
+      fetchAndActivateWithCompletionHandler:fetchAndActivateCompletion];
+  [self waitForExpectationsWithTimeout:_expectationTimeout
+                               handler:^(NSError *error) {
+                                 XCTAssertNil(error);
+                               }];
+}
+
 #pragma mark - Test Helpers
 #pragma mark - Test Helpers
 
 
 - (FIROptions *)firstAppOptions {
 - (FIROptions *)firstAppOptions {

+ 13 - 10
FirebaseRemoteConfig/Tests/Unit/RCNThrottlingTests.m

@@ -25,6 +25,7 @@
 #import "FirebaseRemoteConfig/Tests/Unit/RCNTestUtilities.h"
 #import "FirebaseRemoteConfig/Tests/Unit/RCNTestUtilities.h"
 
 
 #import "FirebaseCore/Extension/FirebaseCoreInternal.h"
 #import "FirebaseCore/Extension/FirebaseCoreInternal.h"
+@import FirebaseRemoteConfigInterop;
 
 
 @interface RCNThrottlingTests : XCTestCase {
 @interface RCNThrottlingTests : XCTestCase {
   RCNConfigContent *_configContentMock;
   RCNConfigContent *_configContentMock;
@@ -53,20 +54,22 @@
   RCNConfigDBManager *DBManager = [[RCNConfigDBManager alloc] init];
   RCNConfigDBManager *DBManager = [[RCNConfigDBManager alloc] init];
 
 
   _configContentMock = OCMClassMock([RCNConfigContent class]);
   _configContentMock = OCMClassMock([RCNConfigContent class]);
-  _settings = [[RCNConfigSettings alloc] initWithDatabaseManager:DBManager
-                                                       namespace:FIRNamespaceGoogleMobilePlatform
-                                                             app:[FIRApp defaultApp]];
+  _settings = [[RCNConfigSettings alloc]
+      initWithDatabaseManager:DBManager
+                    namespace:FIRRemoteConfigConstants.FIRNamespaceGoogleMobilePlatform
+                          app:[FIRApp defaultApp]];
   _experimentMock = OCMClassMock([RCNConfigExperiment class]);
   _experimentMock = OCMClassMock([RCNConfigExperiment class]);
   dispatch_queue_t _queue = dispatch_queue_create(
   dispatch_queue_t _queue = dispatch_queue_create(
       "com.google.GoogleConfigService.FIRRemoteConfigTest", DISPATCH_QUEUE_SERIAL);
       "com.google.GoogleConfigService.FIRRemoteConfigTest", DISPATCH_QUEUE_SERIAL);
 
 
-  _configFetch = [[RCNConfigFetch alloc] initWithContent:_configContentMock
-                                               DBManager:DBManager
-                                                settings:_settings
-                                              experiment:_experimentMock
-                                                   queue:_queue
-                                               namespace:FIRNamespaceGoogleMobilePlatform
-                                                     app:[FIRApp defaultApp]];
+  _configFetch = [[RCNConfigFetch alloc]
+      initWithContent:_configContentMock
+            DBManager:DBManager
+             settings:_settings
+           experiment:_experimentMock
+                queue:_queue
+            namespace:FIRRemoteConfigConstants.FIRNamespaceGoogleMobilePlatform
+                  app:[FIRApp defaultApp]];
 }
 }
 
 
 - (void)mockFetchResponseWithStatusCode:(NSInteger)statusCode {
 - (void)mockFetchResponseWithStatusCode:(NSInteger)statusCode {

+ 21 - 6
FirebaseRemoteConfig/Tests/Unit/RCNUserDefaultsManagerTests.m

@@ -129,8 +129,17 @@ static NSString* const FQNamespace2 = @"testNamespace2:testApp";
       [[RCNUserDefaultsManager alloc] initWithAppName:AppName
       [[RCNUserDefaultsManager alloc] initWithAppName:AppName
                                              bundleID:[NSBundle mainBundle].bundleIdentifier
                                              bundleID:[NSBundle mainBundle].bundleIdentifier
                                             namespace:FQNamespace1];
                                             namespace:FQNamespace1];
-  [manager setLastTemplateVersion:@"1"];
-  XCTAssertEqual([manager lastTemplateVersion], @"1");
+  [manager setLastFetchedTemplateVersion:@"1"];
+  XCTAssertEqual([manager lastFetchedTemplateVersion], @"1");
+}
+
+- (void)testUserDefaultsActiveTemplateVersionWriteAndRead {
+  RCNUserDefaultsManager* manager =
+      [[RCNUserDefaultsManager alloc] initWithAppName:AppName
+                                             bundleID:[NSBundle mainBundle].bundleIdentifier
+                                            namespace:FQNamespace1];
+  [manager setLastActiveTemplateVersion:@"1"];
+  XCTAssertEqual([manager lastActiveTemplateVersion], @"1");
 }
 }
 
 
 - (void)testUserDefaultsRealtimeThrottleEndTimeWriteAndRead {
 - (void)testUserDefaultsRealtimeThrottleEndTimeWriteAndRead {
@@ -229,10 +238,16 @@ static NSString* const FQNamespace2 = @"testNamespace2:testApp";
   XCTAssertEqual([manager2 realtimeRetryCount], 2);
   XCTAssertEqual([manager2 realtimeRetryCount], 2);
 
 
   /// Fetch template version.
   /// Fetch template version.
-  [manager1 setLastTemplateVersion:@"1"];
-  [manager2 setLastTemplateVersion:@"2"];
-  XCTAssertEqualObjects([manager1 lastTemplateVersion], @"1");
-  XCTAssertEqualObjects([manager2 lastTemplateVersion], @"2");
+  [manager1 setLastFetchedTemplateVersion:@"1"];
+  [manager2 setLastFetchedTemplateVersion:@"2"];
+  XCTAssertEqualObjects([manager1 lastFetchedTemplateVersion], @"1");
+  XCTAssertEqualObjects([manager2 lastFetchedTemplateVersion], @"2");
+
+  /// Active template version.
+  [manager1 setLastActiveTemplateVersion:@"1"];
+  [manager2 setLastActiveTemplateVersion:@"2"];
+  XCTAssertEqualObjects([manager1 lastActiveTemplateVersion], @"1");
+  XCTAssertEqualObjects([manager2 lastActiveTemplateVersion], @"2");
 }
 }
 
 
 - (void)testUserDefaultsReset {
 - (void)testUserDefaultsReset {

+ 58 - 0
FirebaseRemoteConfig/generate_featureRolloutsTestApp.sh

@@ -0,0 +1,58 @@
+#!/bin/bash
+
+# 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.
+#
+
+readonly DIR="$( git rev-parse --show-toplevel )"
+
+#
+# This script attempts to copy the Google Services file from google3. If you are not a Google Employee, it will fail, so we'd recommend you create your own Firebase App and place the Google Services file in Tests/TestApp/Shared
+#
+
+echoColor() {
+  COLOR='\033[0;35m'
+  NC='\033[0m'
+  printf "${COLOR}$1${NC}\n"
+}
+
+echoRed() {
+  COLOR='\033[0;31m'
+  NC='\033[0m'
+  printf "${COLOR}$1${NC}\n"
+}
+
+echoColor "Generating Firebase Remote Config Feature Rolouts Test App"
+echoColor "Copying GoogleService-Info.plist from google3. Checking gcert status"
+if gcertstatus; then
+  G3Path="/google/src/files/head/depot/google3/third_party/firebase/ios/Secrets/RemoteConfig/FeatureRollouts/GoogleService-Info.plist"
+  Dest="$DIR/FirebaseRemoteConfig/Tests/FeatureRolloutsTestApp/Shared"
+  cp $G3Path $Dest
+  echoColor "Copied $G3Path to $Dest"
+else
+  echoRed "gcert token is not valid. If you are a Google Employee, run 'gcert', and then repeat this command. Non-Google employees will need to download a GoogleService-Info.plist and place it in $DIR/FirebaseRemoteConfig/Tests/FeatureRolloutsTestApp"
+fi
+
+
+echoColor "Running 'pod install'"
+cd $DIR/FirebaseRemoteConfig/Tests/FeatureRolloutsTestApp
+pod install
+
+# Upon a `pod install`, Crashlytics will copy these files at the root directory
+# due to a funky interaction with its cocoapod. This line deletes these extra
+# copies of the files as they should only live in Crashlytics/
+rm -f $DIR/run $DIR/upload-symbols
+
+open *.xcworkspace
+

+ 34 - 0
FirebaseRemoteConfigInterop.podspec

@@ -0,0 +1,34 @@
+Pod::Spec.new do |s|
+  s.name             = 'FirebaseRemoteConfigInterop'
+  s.version          = '10.23.0'
+  s.summary          = 'Interfaces that allow other Firebase SDKs to use Remote Config functionality.'
+
+  s.description      = <<-DESC
+  Not for public use.
+  A set of protocols that other Firebase SDKs can use to interoperate with FirebaseRemoetConfig in a safe
+  and reliable manner.
+                       DESC
+
+  s.homepage         = 'https://firebase.google.com'
+  s.license          = { :type => 'Apache-2.0', :file => 'LICENSE' }
+  s.authors          = 'Google, Inc.'
+
+  # NOTE that these should not be used externally, this is for Firebase pods to depend on each
+  # other.
+  s.source           = {
+    :git => 'https://github.com/firebase/firebase-ios-sdk.git',
+    :tag => 'CocoaPods-' + s.version.to_s
+  }
+
+  s.swift_version = '5.3'
+  s.cocoapods_version = '>= 1.12.0'
+  s.prefix_header_file = false
+
+  s.social_media_url = 'https://twitter.com/Firebase'
+  s.ios.deployment_target = '11.0'
+  s.osx.deployment_target = '10.13'
+  s.tvos.deployment_target = '12.0'
+  s.watchos.deployment_target = '6.0'
+
+  s.source_files = 'FirebaseRemoteConfig/Interop/*.swift'
+end

+ 1 - 0
FirebaseRemoteConfigSwift/Tests/SwiftAPI/FirebaseRemoteConfigSwift_APIBuildTests.swift

@@ -16,6 +16,7 @@ import XCTest
 
 
 import FirebaseCore
 import FirebaseCore
 import FirebaseRemoteConfig
 import FirebaseRemoteConfig
+import FirebaseRemoteConfigInterop
 import FirebaseRemoteConfigSwift
 import FirebaseRemoteConfigSwift
 
 
 final class FirebaseRemoteConfigSwift_APIBuildTests: XCTestCase {
 final class FirebaseRemoteConfigSwift_APIBuildTests: XCTestCase {

+ 1 - 0
FirebaseSessions/Tests/TestApp/Podfile

@@ -7,6 +7,7 @@ def shared_pods
   pod 'FirebaseCoreInternal', :path => '../../../'
   pod 'FirebaseCoreInternal', :path => '../../../'
   pod 'FirebaseCoreExtension', :path => '../../../'
   pod 'FirebaseCoreExtension', :path => '../../../'
   pod 'FirebaseSessions', :path => '../../../'
   pod 'FirebaseSessions', :path => '../../../'
+  pod 'FirebaseRemoteConfigInterop', :path => '../../../'
 end
 end
 
 
 target 'AppQualityDevApp_iOS' do
 target 'AppQualityDevApp_iOS' do

+ 1 - 0
IntegrationTesting/ClientApp/Podfile

@@ -17,6 +17,7 @@ target 'ClientApp-CocoaPods' do
   pod 'FirebaseAppCheck', :path => '../../'
   pod 'FirebaseAppCheck', :path => '../../'
   pod 'FirebaseRemoteConfig', :path => '../../'
   pod 'FirebaseRemoteConfig', :path => '../../'
   pod 'FirebaseRemoteConfigSwift', :path => '../../'
   pod 'FirebaseRemoteConfigSwift', :path => '../../'
+  pod 'FirebaseRemoteConfigInterop', :path => '../../'
   pod 'FirebaseAppDistribution', :path => '../../'
   pod 'FirebaseAppDistribution', :path => '../../'
   pod 'FirebaseAuth', :path => '../../'
   pod 'FirebaseAuth', :path => '../../'
   pod 'FirebaseCrashlytics', :path => '../../'
   pod 'FirebaseCrashlytics', :path => '../../'

+ 1 - 0
IntegrationTesting/CocoapodsIntegrationTest/TestEnvironments/Cocoapods_multiprojects_frameworks/Podfile

@@ -25,6 +25,7 @@ target 'CocoapodsIntegrationTest' do
   pod 'FirebaseInstallations', :path => '../../'
   pod 'FirebaseInstallations', :path => '../../'
   pod 'FirebaseMessaging', :path => '../../'
   pod 'FirebaseMessaging', :path => '../../'
   pod 'FirebaseMessagingInterop', :path => '../../'
   pod 'FirebaseMessagingInterop', :path => '../../'
+  pod 'FirebaseRemoteConfigInterop', :path => '../../'
   pod 'FirebasePerformance', :path => '../../'
   pod 'FirebasePerformance', :path => '../../'
   pod 'FirebaseStorage', :path => '../../'
   pod 'FirebaseStorage', :path => '../../'
 end
 end

+ 43 - 5
Package.swift

@@ -497,11 +497,17 @@ let package = Package(
     ),
     ),
     .target(
     .target(
       name: "FirebaseCrashlytics",
       name: "FirebaseCrashlytics",
-      dependencies: ["FirebaseCore", "FirebaseInstallations", "FirebaseSessions",
-                     .product(name: "GoogleDataTransport", package: "GoogleDataTransport"),
-                     .product(name: "GULEnvironment", package: "GoogleUtilities"),
-                     .product(name: "FBLPromises", package: "Promises"),
-                     .product(name: "nanopb", package: "nanopb")],
+      dependencies: [
+        "FirebaseCore",
+        "FirebaseInstallations",
+        "FirebaseSessions",
+        "FirebaseRemoteConfigInterop",
+        "FirebaseCrashlyticsSwift",
+        .product(name: "GoogleDataTransport", package: "GoogleDataTransport"),
+        .product(name: "GULEnvironment", package: "GoogleUtilities"),
+        .product(name: "FBLPromises", package: "Promises"),
+        .product(name: "nanopb", package: "nanopb"),
+      ],
       path: "Crashlytics",
       path: "Crashlytics",
       exclude: [
       exclude: [
         "run",
         "run",
@@ -514,6 +520,7 @@ let package = Package(
         "upload-symbols",
         "upload-symbols",
         "CrashlyticsInputFiles.xcfilelist",
         "CrashlyticsInputFiles.xcfilelist",
         "third_party/libunwind/LICENSE",
         "third_party/libunwind/LICENSE",
+        "Crashlytics/Rollouts/",
       ],
       ],
       sources: [
       sources: [
         "Crashlytics/",
         "Crashlytics/",
@@ -543,6 +550,19 @@ let package = Package(
         .linkedFramework("SystemConfiguration", .when(platforms: [.iOS, .macOS, .tvOS])),
         .linkedFramework("SystemConfiguration", .when(platforms: [.iOS, .macOS, .tvOS])),
       ]
       ]
     ),
     ),
+    .target(
+      name: "FirebaseCrashlyticsSwift",
+      dependencies: ["FirebaseRemoteConfigInterop"],
+      path: "Crashlytics",
+      sources: [
+        "Crashlytics/Rollouts/",
+      ]
+    ),
+    .testTarget(
+      name: "FirebaseCrashlyticsSwiftUnit",
+      dependencies: ["FirebaseCrashlyticsSwift"],
+      path: "Crashlytics/UnitTestsSwift/"
+    ),
     .testTarget(
     .testTarget(
       name: "FirebaseCrashlyticsUnit",
       name: "FirebaseCrashlyticsUnit",
       dependencies: ["FirebaseCrashlytics", .product(name: "OCMock", package: "ocmock")],
       dependencies: ["FirebaseCrashlytics", .product(name: "OCMock", package: "ocmock")],
@@ -967,6 +987,7 @@ let package = Package(
         "FirebaseCore",
         "FirebaseCore",
         "FirebaseABTesting",
         "FirebaseABTesting",
         "FirebaseInstallations",
         "FirebaseInstallations",
+        "FirebaseRemoteConfigInterop",
         .product(name: "GULNSData", package: "GoogleUtilities"),
         .product(name: "GULNSData", package: "GoogleUtilities"),
       ],
       ],
       path: "FirebaseRemoteConfig/Sources",
       path: "FirebaseRemoteConfig/Sources",
@@ -996,6 +1017,14 @@ let package = Package(
         .headerSearchPath("../../.."),
         .headerSearchPath("../../.."),
       ]
       ]
     ),
     ),
+    .testTarget(
+      name: "RemoteConfigSwiftUnit",
+      dependencies: ["FirebaseRemoteConfigInternal"],
+      path: "FirebaseRemoteConfig/Tests/SwiftUnit",
+      cSettings: [
+        .headerSearchPath("../../.."),
+      ]
+    ),
     .target(
     .target(
       name: "FirebaseRemoteConfig",
       name: "FirebaseRemoteConfig",
       dependencies: [
       dependencies: [
@@ -1039,6 +1068,15 @@ let package = Package(
         .headerSearchPath("../../../"),
         .headerSearchPath("../../../"),
       ]
       ]
     ),
     ),
+    // Internal headers only for consuming from other SDK.
+    .target(
+      name: "FirebaseRemoteConfigInterop",
+      path: "FirebaseRemoteConfig/Interop",
+      publicHeadersPath: ".",
+      cSettings: [
+        .headerSearchPath("../../"),
+      ]
+    ),
 
 
     // MARK: - Firebase Sessions
     // MARK: - Firebase Sessions
 
 

+ 1 - 0
ReleaseTooling/Sources/FirebaseManifest/FirebaseManifest.swift

@@ -32,6 +32,7 @@ public let shared = Manifest(
     Pod("FirebaseMessagingInterop"),
     Pod("FirebaseMessagingInterop"),
     Pod("FirebaseInstallations"),
     Pod("FirebaseInstallations"),
     Pod("FirebaseSessions"),
     Pod("FirebaseSessions"),
+    Pod("FirebaseRemoteConfigInterop"),
     Pod("GoogleAppMeasurement", isClosedSource: true),
     Pod("GoogleAppMeasurement", isClosedSource: true),
     Pod("GoogleAppMeasurementOnDeviceConversion", isClosedSource: true, platforms: ["ios"]),
     Pod("GoogleAppMeasurementOnDeviceConversion", isClosedSource: true, platforms: ["ios"]),
     Pod("FirebaseAnalytics", isClosedSource: true, zip: true),
     Pod("FirebaseAnalytics", isClosedSource: true, zip: true),

+ 1 - 0
scripts/localize_podfile.swift

@@ -39,6 +39,7 @@ let implicitPods = [
   "FirebaseAppCheckInterop", "FirebaseAuthInterop",
   "FirebaseAppCheckInterop", "FirebaseAuthInterop",
   "FirebaseMessagingInterop", "FirebaseCoreInternal",
   "FirebaseMessagingInterop", "FirebaseCoreInternal",
   "FirebaseSessions", "FirebaseSharedSwift",
   "FirebaseSessions", "FirebaseSharedSwift",
+  "FirebaseRemoteConfigInterop",
 ]
 ]
 
 
 let binaryPods = [
 let binaryPods = [