Explorar el Código

Open Source ABTesting (#3507)

* CI for ABTesting
* README update
Paul Beusterien hace 6 años
padre
commit
3b0ed0ee4a

+ 8 - 1
.travis.yml

@@ -42,7 +42,14 @@ jobs:
       script:
         - travis_retry ./scripts/if_changed.sh ./scripts/pod_lib_lint.rb FirebaseCore.podspec
         - travis_retry ./scripts/if_changed.sh ./scripts/pod_lib_lint.rb FirebaseCore.podspec --use-libraries
-        - travis_retry ./scripts/if_changed.sh ./scripts/pod_lib_lint.rb FirebaseCore.podspec --use-modular-headers
+
+    - stage: test
+      env:
+        - PROJECT=ABTesting PLATFORM=iOS METHOD=pod-lib-lint
+      script:
+        - travis_retry ./scripts/if_changed.sh ./scripts/pod_lib_lint.rb FirebaseABTesting.podspec --platforms=ios
+        - travis_retry ./scripts/if_changed.sh ./scripts/pod_lib_lint.rb FirebaseABTesting.podspec --platforms=tvos
+        - travis_retry ./scripts/if_changed.sh ./scripts/pod_lib_lint.rb FirebaseABTesting.podspec --platforms=macos
 
     - stage: test
       env:

+ 51 - 0
FirebaseABTesting.podspec

@@ -0,0 +1,51 @@
+Pod::Spec.new do |s|
+  s.name             = 'FirebaseABTesting'
+  s.version          = '3.1.0'
+  s.summary          = 'Firebase ABTesting for iOS'
+
+  s.description      = <<-DESC
+A/B testing is a Firebase service that lets you run experiments across users of
+your iOS and Android apps. It lets you learn how well one or more changes to
+your app work with a smaller set of users before you roll out changes to all
+users. You can run experiments to find the most effective ways to use
+Firebase Cloud Messaging and Firebase Remote Config in your app.
+                       DESC
+
+  s.homepage         = 'https://firebase.google.com'
+  s.license          = { :type => 'Apache', :file => 'LICENSE' }
+  s.authors          = 'Google, Inc.'
+
+  s.source           = {
+    :git => 'https://github.com/firebase/firebase-ios-sdk.git',
+    :tag => 'ABTesting-' + s.version.to_s
+  }
+  s.social_media_url = 'https://twitter.com/Firebase'
+  s.ios.deployment_target = '8.0'
+  s.osx.deployment_target = '10.11'
+  s.tvos.deployment_target = '10.0'
+
+  s.cocoapods_version = '>= 1.4.0'
+  s.static_framework = true
+  s.prefix_header_file = false
+
+  base_dir = "FirebaseABTesting/Sources/"
+  s.source_files = base_dir + '**/*.[mh]'
+  s.requires_arc = base_dir + '*.m'
+  s.public_header_files = base_dir + 'Public/*.h'
+  s.pod_target_xcconfig = {
+    'GCC_C_LANGUAGE_STANDARD' => 'c99',
+    'GCC_PREPROCESSOR_DEFINITIONS' =>
+      'GPB_USE_PROTOBUF_FRAMEWORK_IMPORTS=1 ' +
+      'FIRABTesting_VERSION=' + String(s.version),
+    'HEADER_SEARCH_PATHS' => '"${PODS_TARGET_SRCROOT}"'
+  }
+  s.dependency 'FirebaseAnalyticsInterop', '~> 1.3'
+  s.dependency 'FirebaseCore', '~> 6.1'
+  s.dependency 'Protobuf', '~> 3.8'
+
+  s.test_spec 'unit' do |unit_tests|
+    unit_tests.source_files = 'FirebaseABTesting/Tests/Unit/*.[mh]'
+    unit_tests.requires_app_host = true
+    unit_tests.dependency 'OCMock'
+  end
+end

+ 11 - 0
FirebaseABTesting/README.md

@@ -0,0 +1,11 @@
+# Firebase ABTesting SDK for iOS
+
+A/B testing is a Firebase service that lets you run experiments across users of
+your iOS and Android apps. It lets you learn how well one or more changes to
+your app work with a smaller set of users before you roll out changes to all
+users. You can run experiments to find the most effective ways to use
+Firebase Cloud Messaging and Firebase Remote Config in your app.
+
+Please visit [our developer site]
+(https://firebase.google.com/docs/ab-testing/) for integration instructions,
+documentations, support information, and terms of service.

+ 80 - 0
FirebaseABTesting/Sources/ABTConditionalUserPropertyController.h

@@ -0,0 +1,80 @@
+// Copyright 2019 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 "FirebaseABTesting/Sources/Protos/developers/mobile/abt/proto/ExperimentPayload.pbobjc.h"
+
+#import <FirebaseAnalyticsInterop/FIRAnalyticsInterop.h>
+
+NS_ASSUME_NONNULL_BEGIN
+
+@class FIRLifecycleEvents;
+
+/// This class dynamically calls Firebase Analytics API to collect or update experiments
+/// information.
+/// The experiment in Firebase Analytics is named as conditional user property (CUP) object defined
+/// in FIRAConditionalUserProperty.h.
+@interface ABTConditionalUserPropertyController : NSObject
+
+/// Returns the ABTConditionalUserPropertyController singleton.
++ (instancetype)sharedInstanceWithAnalytics:(id<FIRAnalyticsInterop> _Nullable)analytics;
+
+/// Returns the list of currently set experiments from Firebase Analytics for the provided origin.
+- (NSArray *)experimentsWithOrigin:(NSString *)origin;
+
+/// Returns the experiment ID from Firebase Analytics given an experiment object. Returns empty
+/// string if can't find Firebase Analytics service.
+- (NSString *)experimentIDOfExperiment:(nullable id)experiment;
+
+/// Returns the variant ID from Firebase Analytics given an experiment object. Returns empty string
+/// if can't find Firebase Analytics service.
+- (NSString *)variantIDOfExperiment:(nullable id)experiment;
+
+/// Returns whether the experiment is the same as the one in the provided payload.
+- (BOOL)isExperiment:(id)experiment theSameAsPayload:(ABTExperimentPayload *)payload;
+
+/// Clears the experiment in Firebase Analytics.
+/// @param experimentID  Experiment ID to clear.
+/// @param variantID     Variant ID to clear.
+/// @param origin        Impacted originating service, it is defined at Firebase Analytics
+///                      FIREventOrigins.h.
+/// @param payload       Payload to overwrite event name in events. DO NOT use payload's experiment
+///                      ID and variant ID as the experiment to clear.
+/// @param events        Events name for clearing the experiment.
+- (void)clearExperiment:(NSString *)experimentID
+              variantID:(NSString *)variantID
+             withOrigin:(NSString *)origin
+                payload:(nullable ABTExperimentPayload *)payload
+                 events:(FIRLifecycleEvents *)events;
+
+/// Sets the experiment in Firebase Analytics.
+/// @param origin        Impacted originating service, it is defined at Firebase Analytics
+///                      FIREventOrigins.h.
+/// @param payload       Payload to overwrite event name in events. DO NOT use payload's experiment
+///                      ID and variant ID as the experiment to set.
+/// @param events        Events name for setting the experiment.
+/// @param policy        Overflow policy when the number of experiments is over the limit.
+- (void)setExperimentWithOrigin:(NSString *)origin
+                        payload:(ABTExperimentPayload *)payload
+                         events:(FIRLifecycleEvents *)events
+                         policy:(ABTExperimentPayload_ExperimentOverflowPolicy)policy;
+
+/**
+ *  Unavailable. Use sharedInstanceWithAnalytics: instead.
+ */
+- (instancetype)init __attribute__((unavailable("Use +sharedInstanceWithAnalytics: instead.")));
+@end
+
+NS_ASSUME_NONNULL_END

+ 280 - 0
FirebaseABTesting/Sources/ABTConditionalUserPropertyController.m

@@ -0,0 +1,280 @@
+// Copyright 2019 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 "FirebaseABTesting/Sources/ABTConditionalUserPropertyController.h"
+
+#import <FirebaseABTesting/FIRLifecycleEvents.h>
+#import <FirebaseAnalyticsInterop/FIRAnalyticsInterop.h>
+#import <FirebaseCore/FIRLogger.h>
+#import "FirebaseABTesting/Sources/ABTConstants.h"
+
+@implementation ABTConditionalUserPropertyController {
+  dispatch_queue_t _analyticOperationQueue;
+  id<FIRAnalyticsInterop> _Nullable _analytics;
+}
+
+/// Returns the ABTConditionalUserPropertyController singleton.
++ (instancetype)sharedInstanceWithAnalytics:(id<FIRAnalyticsInterop> _Nullable)analytics {
+  static ABTConditionalUserPropertyController *sharedInstance = nil;
+  static dispatch_once_t onceToken = 0;
+  dispatch_once(&onceToken, ^{
+    sharedInstance = [[ABTConditionalUserPropertyController alloc] initWithAnalytics:analytics];
+  });
+  return sharedInstance;
+}
+
+- (instancetype)initWithAnalytics:(id<FIRAnalyticsInterop> _Nullable)analytics {
+  self = [super init];
+  if (self) {
+    _analyticOperationQueue =
+        dispatch_queue_create("com.google.FirebaseABTesting.analytics", DISPATCH_QUEUE_SERIAL);
+    _analytics = analytics;
+  }
+  return self;
+}
+
+#pragma mark - experiments proxy methods on Firebase Analytics
+
+- (NSArray *)experimentsWithOrigin:(NSString *)origin {
+  return [_analytics conditionalUserProperties:origin propertyNamePrefix:@""];
+}
+
+- (void)clearExperiment:(NSString *)experimentID
+              variantID:(NSString *)variantID
+             withOrigin:(NSString *)origin
+                payload:(ABTExperimentPayload *)payload
+                 events:(FIRLifecycleEvents *)events {
+  // Payload always overwrite event names.
+  NSString *clearExperimentEventName = events.clearExperimentEventName;
+  if (payload && payload.clearEventToLog && payload.clearEventToLog.length) {
+    clearExperimentEventName = payload.clearEventToLog;
+  }
+
+  [_analytics clearConditionalUserProperty:experimentID
+                                 forOrigin:origin
+                            clearEventName:clearExperimentEventName
+                      clearEventParameters:@{experimentID : variantID}];
+
+  FIRLogDebug(kFIRLoggerABTesting, @"I-ABT000015", @"Clear Experiment ID %@, variant ID %@.",
+              experimentID, variantID);
+}
+
+- (void)setExperimentWithOrigin:(NSString *)origin
+                        payload:(ABTExperimentPayload *)payload
+                         events:(FIRLifecycleEvents *)events
+                         policy:(ABTExperimentPayload_ExperimentOverflowPolicy)policy {
+  NSInteger maxNumOfExperiments = [self maxNumberOfExperimentsOfOrigin:origin];
+  if (maxNumOfExperiments < 0) {
+    return;
+  }
+
+  // Clear experiments if overflow
+  NSArray *experiments = [self experimentsWithOrigin:origin];
+  if (!experiments) {
+    FIRLogInfo(kFIRLoggerABTesting, @"I-ABT000003",
+               @"Failed to get conditional user properties from Firebase Analytics.");
+    return;
+  }
+
+  if (maxNumOfExperiments <= experiments.count) {
+    ABTExperimentPayload_ExperimentOverflowPolicy overflowPolicy =
+        [self overflowPolicyWithPayload:payload originalPolicy:policy];
+    id experimentToClear = experiments.firstObject;
+    if (overflowPolicy == ABTExperimentPayload_ExperimentOverflowPolicy_DiscardOldest &&
+        experimentToClear) {
+      NSString *expID = [self experimentIDOfExperiment:experimentToClear];
+      NSString *varID = [self variantIDOfExperiment:experimentToClear];
+
+      [self clearExperiment:expID variantID:varID withOrigin:origin payload:payload events:events];
+      FIRLogDebug(kFIRLoggerABTesting, @"I-ABT000016",
+                  @"Clear experiment ID %@ variant ID %@ due to "
+                  @"overflow policy.",
+                  expID, varID);
+
+    } else {
+      FIRLogDebug(kFIRLoggerABTesting, @"I-ABT000017",
+                  @"Experiment ID %@ variant ID %@ won't be set due to "
+                  @"overflow policy.",
+                  payload.experimentId, payload.variantId);
+
+      return;
+    }
+  }
+
+  // Clear experiment if other variant ID exists.
+  NSString *experimentID = payload.experimentId;
+  NSString *variantID = payload.variantId;
+  for (id experiment in experiments) {
+    NSString *expID = [self experimentIDOfExperiment:experiment];
+    NSString *varID = [self variantIDOfExperiment:experiment];
+    if ([expID isEqualToString:experimentID] && ![varID isEqualToString:variantID]) {
+      FIRLogDebug(kFIRLoggerABTesting, @"I-ABT000018",
+                  @"Clear experiment ID %@ with variant ID %@ because "
+                  @"only one variant ID can be existed "
+                  @"at any time.",
+                  expID, varID);
+      [self clearExperiment:expID variantID:varID withOrigin:origin payload:payload events:events];
+    }
+  }
+
+  // Set experiment
+  NSDictionary<NSString *, id> *experiment = [self createExperimentFromOrigin:origin
+                                                                      payload:payload
+                                                                       events:events];
+
+  [_analytics setConditionalUserProperty:experiment];
+
+  FIRLogDebug(kFIRLoggerABTesting, @"I-ABT000019",
+              @"Set conditional user property, experiment ID %@ with "
+              @"variant ID %@ triggered event %@.",
+              experimentID, variantID, payload.triggerEvent);
+
+  // Log setEvent (experiment lifecycle event to be set when an experiment is set)
+  [self logEventWithOrigin:origin payload:payload events:events];
+}
+
+- (NSMutableDictionary<NSString *, id> *)createExperimentFromOrigin:(NSString *)origin
+                                                            payload:(ABTExperimentPayload *)payload
+                                                             events:(FIRLifecycleEvents *)events {
+  NSMutableDictionary<NSString *, id> *experiment = [[NSMutableDictionary alloc] init];
+  NSString *experimentID = payload.experimentId;
+  NSString *variantID = payload.variantId;
+
+  NSDictionary *eventParams = @{experimentID : variantID};
+
+  [experiment setValue:origin forKey:kABTExperimentDictionaryOriginKey];
+
+  NSTimeInterval creationTimestamp = (double)(payload.experimentStartTimeMillis / ABT_MSEC_PER_SEC);
+  [experiment setValue:@(creationTimestamp) forKey:kABTExperimentDictionaryCreationTimestampKey];
+  [experiment setValue:experimentID forKey:kABTExperimentDictionaryExperimentIDKey];
+  [experiment setValue:variantID forKey:kABTExperimentDictionaryVariantIDKey];
+
+  // For the experiment to be immediately activated/triggered, its trigger event must be null.
+  // Double check if payload's trigger event is empty string, it must be set to null to trigger.
+  if (payload && payload.triggerEvent && payload.triggerEvent.length) {
+    [experiment setValue:payload.triggerEvent forKey:kABTExperimentDictionaryTriggeredEventNameKey];
+  } else {
+    [experiment setValue:nil forKey:kABTExperimentDictionaryTriggeredEventNameKey];
+  }
+
+  // Set timeout event name and params.
+  NSString *timeoutEventName = events.timeoutExperimentEventName;
+  if (payload && payload.timeoutEventToLog && payload.timeoutEventToLog.length) {
+    timeoutEventName = payload.timeoutEventToLog;
+  }
+  NSDictionary<NSString *, id> *timeoutEvent = [self eventDictionaryWithOrigin:origin
+                                                                     eventName:timeoutEventName
+                                                                        params:eventParams];
+  [experiment setValue:timeoutEvent forKey:kABTExperimentDictionaryTimedOutEventKey];
+
+  // Set trigger timeout information on how long to wait for trigger event.
+  NSTimeInterval triggerTimeout = (double)(payload.triggerTimeoutMillis / ABT_MSEC_PER_SEC);
+  [experiment setValue:@(triggerTimeout) forKey:kABTExperimentDictionaryTriggerTimeoutKey];
+
+  // Set activate event name and params.
+  NSString *activateEventName = events.activateExperimentEventName;
+  if (payload && payload.activateEventToLog && payload.activateEventToLog.length) {
+    activateEventName = payload.activateEventToLog;
+  }
+  NSDictionary<NSString *, id> *triggeredEvent = [self eventDictionaryWithOrigin:origin
+                                                                       eventName:activateEventName
+                                                                          params:eventParams];
+  [experiment setValue:triggeredEvent forKey:kABTExperimentDictionaryTriggeredEventKey];
+
+  // Set time to live information for how long the experiment lasts.
+  NSTimeInterval timeToLive = (double)(payload.timeToLiveMillis / ABT_MSEC_PER_SEC);
+  [experiment setValue:@(timeToLive) forKey:kABTExperimentDictionaryTimeToLiveKey];
+
+  // Set expired event name and params.
+  NSString *expiredEventName = events.expireExperimentEventName;
+  if (payload && payload.ttlExpiryEventToLog && payload.ttlExpiryEventToLog.length) {
+    expiredEventName = payload.ttlExpiryEventToLog;
+  }
+  NSDictionary<NSString *, id> *expiredEvent = [self eventDictionaryWithOrigin:origin
+                                                                     eventName:expiredEventName
+                                                                        params:eventParams];
+  [experiment setValue:expiredEvent forKey:kABTExperimentDictionaryExpiredEventKey];
+  return experiment;
+}
+
+- (NSDictionary<NSString *, id> *)
+    eventDictionaryWithOrigin:(nonnull NSString *)origin
+                    eventName:(nonnull NSString *)eventName
+                       params:(nonnull NSDictionary<NSString *, NSString *> *)params {
+  return @{
+    kABTEventDictionaryOriginKey : origin,
+    kABTEventDictionaryNameKey : eventName,
+    kABTEventDictionaryTimestampKey : @([NSDate date].timeIntervalSince1970),
+    kABTEventDictionaryParametersKey : params
+  };
+}
+
+#pragma mark - experiment properties
+- (NSString *)experimentIDOfExperiment:(id)experiment {
+  if (!experiment) {
+    return @"";
+  }
+  return [experiment valueForKey:kABTExperimentDictionaryExperimentIDKey];
+}
+
+- (NSString *)variantIDOfExperiment:(id)experiment {
+  if (!experiment) {
+    return @"";
+  }
+  return [experiment valueForKey:kABTExperimentDictionaryVariantIDKey];
+}
+
+- (NSInteger)maxNumberOfExperimentsOfOrigin:(NSString *)origin {
+  if (!_analytics) {
+    return 0;
+  }
+  return [_analytics maxUserProperties:origin];
+}
+
+#pragma mark - analytics internal methods
+
+- (void)logEventWithOrigin:(NSString *)origin
+                   payload:(ABTExperimentPayload *)payload
+                    events:(FIRLifecycleEvents *)events {
+  NSString *setExperimentEventName = events.setExperimentEventName;
+  if (payload && payload.setEventToLog && payload.setEventToLog.length) {
+    setExperimentEventName = payload.setEventToLog;
+  }
+  NSDictionary<NSString *, NSString *> *params = @{payload.experimentId : payload.variantId};
+  [_analytics logEventWithOrigin:origin name:setExperimentEventName parameters:params];
+}
+
+#pragma mark - helper
+
+- (BOOL)isExperiment:(id)experiment theSameAsPayload:(ABTExperimentPayload *)payload {
+  NSString *experimentID = [self experimentIDOfExperiment:experiment];
+  NSString *variantID = [self variantIDOfExperiment:experiment];
+  return [experimentID isEqualToString:payload.experimentId] &&
+         [variantID isEqualToString:payload.variantId];
+}
+
+- (ABTExperimentPayload_ExperimentOverflowPolicy)
+    overflowPolicyWithPayload:(ABTExperimentPayload *)payload
+               originalPolicy:(ABTExperimentPayload_ExperimentOverflowPolicy)originalPolicy {
+  if (payload.overflowPolicy != ABTExperimentPayload_ExperimentOverflowPolicy_PolicyUnspecified) {
+    return payload.overflowPolicy;
+  }
+  if (originalPolicy != ABTExperimentPayload_ExperimentOverflowPolicy_PolicyUnspecified &&
+      ABTExperimentPayload_ExperimentOverflowPolicy_IsValidValue(originalPolicy)) {
+    return originalPolicy;
+  }
+  return ABTExperimentPayload_ExperimentOverflowPolicy_DiscardOldest;
+}
+
+@end

+ 35 - 0
FirebaseABTesting/Sources/ABTConstants.h

@@ -0,0 +1,35 @@
+// Copyright 2019 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.
+
+#define ABT_MSEC_PER_SEC 1000ull
+
+#pragma mark - Keys for experiment dictionaries.
+
+static NSString *const kABTExperimentDictionaryCreationTimestampKey = @"creationTimestamp";
+static NSString *const kABTExperimentDictionaryExperimentIDKey = @"name";
+static NSString *const kABTExperimentDictionaryExpiredEventKey = @"expiredEvent";
+static NSString *const kABTExperimentDictionaryOriginKey = @"origin";
+static NSString *const kABTExperimentDictionaryTimedOutEventKey = @"timedOutEvent";
+static NSString *const kABTExperimentDictionaryTimeToLiveKey = @"timeToLive";
+static NSString *const kABTExperimentDictionaryTriggeredEventKey = @"triggeredEvent";
+static NSString *const kABTExperimentDictionaryTriggeredEventNameKey = @"triggerEventName";
+static NSString *const kABTExperimentDictionaryTriggerTimeoutKey = @"triggerTimeout";
+static NSString *const kABTExperimentDictionaryVariantIDKey = @"value";
+
+#pragma mark - Keys for event dictionaries.
+
+static NSString *const kABTEventDictionaryNameKey = @"name";
+static NSString *const kABTEventDictionaryOriginKey = @"origin";
+static NSString *const kABTEventDictionaryParametersKey = @"parameters";
+static NSString *const kABTEventDictionaryTimestampKey = @"timestamp";

+ 262 - 0
FirebaseABTesting/Sources/FIRExperimentController.m

@@ -0,0 +1,262 @@
+// Copyright 2019 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 <FirebaseABTesting/FIRExperimentController.h>
+
+#import <FirebaseABTesting/FIRLifecycleEvents.h>
+#import <FirebaseCore/FIRLogger.h>
+#import "FirebaseABTesting/Sources/ABTConditionalUserPropertyController.h"
+#import "FirebaseABTesting/Sources/ABTConstants.h"
+
+#import <FirebaseAnalyticsInterop/FIRAnalyticsInterop.h>
+#import <FirebaseCore/FIRAppInternal.h>
+#import <FirebaseCore/FIRComponent.h>
+#import <FirebaseCore/FIRComponentContainer.h>
+#import <FirebaseCore/FIRDependency.h>
+#import <FirebaseCore/FIRLibrary.h>
+
+#ifndef FIRABTesting_VERSION
+#error "FIRABTesting_VERSION is not defined: \
+add -DFIRABTesting_VERSION=... to the build invocation"
+#endif
+
+// The following two macros supply the incantation so that the C
+// preprocessor does not try to parse the version as a floating
+// point number. See
+// https://www.guyrutenberg.com/2008/12/20/expanding-macros-into-string-constants-in-c/
+#define STR(x) STR_EXPAND(x)
+#define STR_EXPAND(x) #x
+
+/// Default experiment overflow policy.
+const ABTExperimentPayload_ExperimentOverflowPolicy FIRDefaultExperimentOverflowPolicy =
+    ABTExperimentPayload_ExperimentOverflowPolicy_DiscardOldest;
+
+/// Deserialize the experiment payloads.
+ABTExperimentPayload *ABTDeserializeExperimentPayload(NSData *payload) {
+  NSError *error;
+  ABTExperimentPayload *experimentPayload = [ABTExperimentPayload parseFromData:payload
+                                                                          error:&error];
+  if (error) {
+    FIRLogError(kFIRLoggerABTesting, @"I-ABT000001", @"Failed to parse experiment payload: %@",
+                error.debugDescription);
+  }
+  return experimentPayload;
+}
+
+/// Returns a list of experiments to be set given the payloads and current list of experiments from
+/// Firebase Analytics. If an experiment is in payloads but not in experiments, it should be set to
+/// Firebase Analytics.
+NSArray<ABTExperimentPayload *> *ABTExperimentsToSetFromPayloads(
+    NSArray<NSData *> *payloads,
+    NSArray<NSDictionary<NSString *, NSString *> *> *experiments,
+    id<FIRAnalyticsInterop> _Nullable analytics) {
+  NSArray<NSData *> *payloadsCopy = [payloads copy];
+  NSArray *experimentsCopy = [experiments copy];
+  NSMutableArray *experimentsToSet = [[NSMutableArray alloc] init];
+  ABTConditionalUserPropertyController *controller =
+      [ABTConditionalUserPropertyController sharedInstanceWithAnalytics:analytics];
+
+  // Check if the experiment is in payloads but not in experiments.
+  for (NSData *payload in payloadsCopy) {
+    ABTExperimentPayload *experimentPayload = ABTDeserializeExperimentPayload(payload);
+    if (!experimentPayload) {
+      FIRLogInfo(kFIRLoggerABTesting, @"I-ABT000002",
+                 @"Either payload is not set or it cannot be deserialized.");
+      continue;
+    }
+
+    BOOL isExperimentSet = NO;
+    for (id experiment in experimentsCopy) {
+      if ([controller isExperiment:experiment theSameAsPayload:experimentPayload]) {
+        isExperimentSet = YES;
+        break;
+      }
+    }
+
+    if (!isExperimentSet) {
+      [experimentsToSet addObject:experimentPayload];
+    }
+  }
+  return [experimentsToSet copy];
+}
+
+/// Returns a list of experiments to be clearred given the payloads and current list of
+/// experiments from Firebase Analytics. If an experiment is in experiments but not in payloads, it
+/// should be clearred in Firebase Analytics.
+NSArray *ABTExperimentsToClearFromPayloads(
+    NSArray<NSData *> *payloads,
+    NSArray<NSDictionary<NSString *, NSString *> *> *experiments,
+    id<FIRAnalyticsInterop> _Nullable analytics) {
+  NSMutableArray *experimentsToClear = [[NSMutableArray alloc] init];
+  ABTConditionalUserPropertyController *controller =
+      [ABTConditionalUserPropertyController sharedInstanceWithAnalytics:analytics];
+
+  // Check if the experiment is in experiments but not payloads.
+  for (id experiment in experiments) {
+    BOOL doesExperimentNoLongerExist = YES;
+    for (NSData *payload in payloads) {
+      ABTExperimentPayload *experimentPayload = ABTDeserializeExperimentPayload(payload);
+      if (!experimentPayload) {
+        FIRLogInfo(kFIRLoggerABTesting, @"I-ABT000002",
+                   @"Either payload is not set or it cannot be deserialized.");
+        continue;
+      }
+
+      if ([controller isExperiment:experiment theSameAsPayload:experimentPayload]) {
+        doesExperimentNoLongerExist = NO;
+      }
+    }
+    if (doesExperimentNoLongerExist) {
+      [experimentsToClear addObject:experiment];
+    }
+  }
+  return experimentsToClear;
+}
+
+// ABT doesn't provide any functionality to other components,
+// so it provides a private, empty protocol that it conforms to and use it for registration.
+
+@protocol FIRABTInstanceProvider
+@end
+
+@interface FIRExperimentController () <FIRABTInstanceProvider, FIRLibrary>
+@property(nonatomic, readwrite, strong) id<FIRAnalyticsInterop> _Nullable analytics;
+@end
+
+@implementation FIRExperimentController
+
++ (void)load {
+  [FIRApp registerInternalLibrary:(Class<FIRLibrary>)self
+                         withName:@"fire-abt"
+                      withVersion:[NSString stringWithUTF8String:STR(FIRABTesting_VERSION)]];
+}
+
++ (nonnull NSArray<FIRComponent *> *)componentsToRegister {
+  FIRDependency *analyticsDep = [FIRDependency dependencyWithProtocol:@protocol(FIRAnalyticsInterop)
+                                                           isRequired:NO];
+  FIRComponentCreationBlock creationBlock =
+      ^id _Nullable(FIRComponentContainer *container, BOOL *isCacheable) {
+    // Ensure it's cached so it returns the same instance every time ABTesting is called.
+    *isCacheable = YES;
+    id<FIRAnalyticsInterop> analytics = FIR_COMPONENT(FIRAnalyticsInterop, container);
+    return [[FIRExperimentController alloc] initWithAnalytics:analytics];
+  };
+  FIRComponent *abtProvider = [FIRComponent componentWithProtocol:@protocol(FIRABTInstanceProvider)
+                                              instantiationTiming:FIRInstantiationTimingLazy
+                                                     dependencies:@[ analyticsDep ]
+                                                    creationBlock:creationBlock];
+
+  return @[ abtProvider ];
+}
+
+- (instancetype)initWithAnalytics:(nullable id<FIRAnalyticsInterop>)analytics {
+  self = [super init];
+  if (self != nil) {
+    _analytics = analytics;
+  }
+  return self;
+}
+
++ (FIRExperimentController *)sharedInstance {
+  FIRApp *defaultApp = [FIRApp defaultApp];  // Missing configure will be logged here.
+  id<FIRABTInstanceProvider> instance = FIR_COMPONENT(FIRABTInstanceProvider, defaultApp.container);
+
+  // We know the instance coming from the container is a FIRExperimentController instance, cast it.
+  return (FIRExperimentController *)instance;
+}
+
+- (void)updateExperimentsWithServiceOrigin:(NSString *)origin
+                                    events:(FIRLifecycleEvents *)events
+                                    policy:(ABTExperimentPayload_ExperimentOverflowPolicy)policy
+                             lastStartTime:(NSTimeInterval)lastStartTime
+                                  payloads:(NSArray<NSData *> *)payloads {
+  FIRExperimentController *__weak weakSelf = self;
+  dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), ^{
+    FIRExperimentController *strongSelf = weakSelf;
+    [strongSelf updateExperimentsInBackgroundQueueWithServiceOrigin:origin
+                                                             events:events
+                                                             policy:policy
+                                                      lastStartTime:lastStartTime
+                                                           payloads:payloads];
+  });
+}
+
+- (void)
+    updateExperimentsInBackgroundQueueWithServiceOrigin:(NSString *)origin
+                                                 events:(FIRLifecycleEvents *)events
+                                                 policy:
+                                                     (ABTExperimentPayload_ExperimentOverflowPolicy)
+                                                         policy
+                                          lastStartTime:(NSTimeInterval)lastStartTime
+                                               payloads:(NSArray<NSData *> *)payloads {
+  ABTConditionalUserPropertyController *controller =
+      [ABTConditionalUserPropertyController sharedInstanceWithAnalytics:_analytics];
+
+  // Get the list of expriments from Firebase Analytics.
+  NSArray *experiments = [controller experimentsWithOrigin:origin];
+  if (!experiments) {
+    FIRLogInfo(kFIRLoggerABTesting, @"I-ABT000003",
+               @"Failed to get conditional user properties from Firebase Analytics.");
+    return;
+  }
+  NSArray<ABTExperimentPayload *> *experimentsToSet =
+      ABTExperimentsToSetFromPayloads(payloads, experiments, _analytics);
+  NSArray<NSDictionary<NSString *, NSString *> *> *experimentsToClear =
+      ABTExperimentsToClearFromPayloads(payloads, experiments, _analytics);
+
+  for (id experiment in experimentsToClear) {
+    NSString *experimentID = [controller experimentIDOfExperiment:experiment];
+    NSString *variantID = [controller variantIDOfExperiment:experiment];
+    [controller clearExperiment:experimentID
+                      variantID:variantID
+                     withOrigin:origin
+                        payload:nil
+                         events:events];
+  }
+
+  for (ABTExperimentPayload *experimentPayload in experimentsToSet) {
+    if (experimentPayload.experimentStartTimeMillis > lastStartTime * ABT_MSEC_PER_SEC) {
+      [controller setExperimentWithOrigin:origin
+                                  payload:experimentPayload
+                                   events:events
+                                   policy:policy];
+      FIRLogInfo(kFIRLoggerABTesting, @"I-ABT000008",
+                 @"Set Experiment ID %@, variant ID %@ to Firebase Analytics.",
+                 experimentPayload.experimentId, experimentPayload.variantId);
+
+    } else {
+      FIRLogInfo(kFIRLoggerABTesting, @"I-ABT000009",
+                 @"Not setting experiment ID %@, variant ID %@ due to the last update time %lld.",
+                 experimentPayload.experimentId, experimentPayload.variantId,
+                 (long)lastStartTime * ABT_MSEC_PER_SEC);
+    }
+  }
+}
+
+- (NSTimeInterval)latestExperimentStartTimestampBetweenTimestamp:(NSTimeInterval)timestamp
+                                                     andPayloads:(NSArray<NSData *> *)payloads {
+  for (NSData *payload in payloads) {
+    ABTExperimentPayload *experimentPayload = ABTDeserializeExperimentPayload(payload);
+    if (!experimentPayload) {
+      FIRLogInfo(kFIRLoggerABTesting, @"I-ABT000002",
+                 @"Either payload is not set or it cannot be deserialized.");
+      continue;
+    }
+    if (experimentPayload.experimentStartTimeMillis > timestamp * ABT_MSEC_PER_SEC) {
+      timestamp = (double)(experimentPayload.experimentStartTimeMillis / ABT_MSEC_PER_SEC);
+    }
+  }
+  return timestamp;
+}
+@end

+ 88 - 0
FirebaseABTesting/Sources/FIRLifecycleEvents.m

@@ -0,0 +1,88 @@
+// Copyright 2019 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 <FirebaseABTesting/FIRLifecycleEvents.h>
+
+#import <FirebaseABTesting/FIRExperimentController.h>
+
+/// Default name of the analytics event to be logged when an experiment is set.
+NSString *const FIRSetExperimentEventName = @"_exp_set";
+/// Default name of the analytics event to be logged when an experiment is activated.
+NSString *const FIRActivateExperimentEventName = @"_exp_activate";
+/// Default name of the analytics event to be logged when an experiment is cleared.
+NSString *const FIRClearExperimentEventName = @"_exp_clear";
+/// Default name of the analytics event to be logged when an experiment times out for being
+/// activated.
+NSString *const FIRTimeoutExperimentEventName = @"_exp_timeout";
+/// Default name of the analytics event to be logged when an experiment is expired as it reaches the
+/// end of TTL.
+NSString *const FIRExpireExperimentEventName = @"_exp_expire";
+/// Prefix for lifecycle event names.
+static NSString *const kLifecycleEventPrefix = @"_";
+
+@implementation FIRLifecycleEvents
+- (instancetype)init {
+  self = [super init];
+  if (self) {
+    _setExperimentEventName = FIRSetExperimentEventName;
+    _activateExperimentEventName = FIRActivateExperimentEventName;
+    _clearExperimentEventName = FIRClearExperimentEventName;
+    _timeoutExperimentEventName = FIRTimeoutExperimentEventName;
+    _expireExperimentEventName = FIRExpireExperimentEventName;
+  }
+  return self;
+}
+
+- (void)setSetExperimentEventName:(NSString *)setExperimentEventName {
+  if (setExperimentEventName && [setExperimentEventName hasPrefix:kLifecycleEventPrefix]) {
+    _setExperimentEventName = setExperimentEventName;
+  } else {
+    _setExperimentEventName = FIRSetExperimentEventName;
+  }
+}
+
+- (void)setActivateExperimentEventName:(NSString *)activateExperimentEventName {
+  if (activateExperimentEventName &&
+      [activateExperimentEventName hasPrefix:kLifecycleEventPrefix]) {
+    _activateExperimentEventName = activateExperimentEventName;
+  } else {
+    _activateExperimentEventName = FIRActivateExperimentEventName;
+  }
+}
+
+- (void)setClearExperimentEventName:(NSString *)clearExperimentEventName {
+  if (clearExperimentEventName && [clearExperimentEventName hasPrefix:kLifecycleEventPrefix]) {
+    _clearExperimentEventName = clearExperimentEventName;
+  } else {
+    _clearExperimentEventName = FIRClearExperimentEventName;
+  }
+}
+
+- (void)setTimeoutExperimentEventName:(NSString *)timeoutExperimentEventName {
+  if (timeoutExperimentEventName && [timeoutExperimentEventName hasPrefix:kLifecycleEventPrefix]) {
+    _timeoutExperimentEventName = timeoutExperimentEventName;
+  } else {
+    _timeoutExperimentEventName = FIRTimeoutExperimentEventName;
+  }
+}
+
+- (void)setExpireExperimentEventName:(NSString *)expireExperimentEventName {
+  if (expireExperimentEventName && [_timeoutExperimentEventName hasPrefix:kLifecycleEventPrefix]) {
+    _expireExperimentEventName = expireExperimentEventName;
+  } else {
+    _expireExperimentEventName = FIRExpireExperimentEventName;
+  }
+}
+
+@end

+ 3 - 0
FirebaseABTesting/Sources/Protos/PortableProtoFilterTemplate.asciipb

@@ -0,0 +1,3 @@
+allowed_message: "developers.mobile.abt.ExperimentLite"
+allowed_message: "developers.mobile.abt.ExperimentPayload"
+allowed_enum: "developers.mobile.abt.ExperimentPayload.ExperimentOverflowPolicy"

+ 179 - 0
FirebaseABTesting/Sources/Protos/developers/mobile/abt/proto/ExperimentPayload.pbobjc.h

@@ -0,0 +1,179 @@
+// Copyright 2019 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.
+
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// source: developers/mobile/abt/proto/experiment_payload.proto
+
+// This CPP symbol can be defined to use imports that match up to the framework
+// imports needed when using CocoaPods.
+#if !defined(GPB_USE_PROTOBUF_FRAMEWORK_IMPORTS)
+ #define GPB_USE_PROTOBUF_FRAMEWORK_IMPORTS 0
+#endif
+
+#if GPB_USE_PROTOBUF_FRAMEWORK_IMPORTS
+ #import <Protobuf/GPBProtocolBuffers.h>
+#else
+ #import "GPBProtocolBuffers.h"
+#endif
+
+#if GOOGLE_PROTOBUF_OBJC_VERSION < 30002
+#error This file was generated by a newer version of protoc which is incompatible with your Protocol Buffer library sources.
+#endif
+#if 30002 < GOOGLE_PROTOBUF_OBJC_MIN_SUPPORTED_VERSION
+#error This file was generated by an older version of protoc which is incompatible with your Protocol Buffer library sources.
+#endif
+
+// @@protoc_insertion_point(imports)
+
+#pragma clang diagnostic push
+#pragma clang diagnostic ignored "-Wdeprecated-declarations"
+
+CF_EXTERN_C_BEGIN
+
+@class ABTExperimentLite;
+
+NS_ASSUME_NONNULL_BEGIN
+
+#pragma mark - Enum ABTExperimentPayload_ExperimentOverflowPolicy
+
+typedef GPB_ENUM(ABTExperimentPayload_ExperimentOverflowPolicy) {
+  /**
+   * Value used if any message's field encounters a value that is not defined
+   * by this enum. The message will also have C functions to get/set the rawValue
+   * of the field.
+   **/
+  ABTExperimentPayload_ExperimentOverflowPolicy_GPBUnrecognizedEnumeratorValue = kGPBUnrecognizedEnumeratorValue,
+  ABTExperimentPayload_ExperimentOverflowPolicy_PolicyUnspecified = 0,
+  ABTExperimentPayload_ExperimentOverflowPolicy_DiscardOldest = 1,
+  ABTExperimentPayload_ExperimentOverflowPolicy_IgnoreNewest = 2,
+};
+
+GPBEnumDescriptor *ABTExperimentPayload_ExperimentOverflowPolicy_EnumDescriptor(void);
+
+/**
+ * Checks to see if the given value is defined by the enum or was not known at
+ * the time this source was generated.
+ **/
+BOOL ABTExperimentPayload_ExperimentOverflowPolicy_IsValidValue(int32_t value);
+
+#pragma mark - ABTExperimentPayloadRoot
+
+/**
+ * Exposes the extension registry for this file.
+ *
+ * The base class provides:
+ * @code
+ *   + (GPBExtensionRegistry *)extensionRegistry;
+ * @endcode
+ * which is a @c GPBExtensionRegistry that includes all the extensions defined by
+ * this file and all files that it depends on.
+ **/
+@interface ABTExperimentPayloadRoot : GPBRootObject
+@end
+
+#pragma mark - ABTExperimentLite
+
+typedef GPB_ENUM(ABTExperimentLite_FieldNumber) {
+  ABTExperimentLite_FieldNumber_ExperimentId = 1,
+};
+
+@interface ABTExperimentLite : GPBMessage
+
+
+@property(nonatomic, readwrite, copy, null_resettable) NSString *experimentId;
+
+@end
+
+#pragma mark - ABTExperimentPayload
+
+typedef GPB_ENUM(ABTExperimentPayload_FieldNumber) {
+  ABTExperimentPayload_FieldNumber_ExperimentId = 1,
+  ABTExperimentPayload_FieldNumber_VariantId = 2,
+  ABTExperimentPayload_FieldNumber_ExperimentStartTimeMillis = 3,
+  ABTExperimentPayload_FieldNumber_TriggerEvent = 4,
+  ABTExperimentPayload_FieldNumber_TriggerTimeoutMillis = 5,
+  ABTExperimentPayload_FieldNumber_TimeToLiveMillis = 6,
+  ABTExperimentPayload_FieldNumber_SetEventToLog = 7,
+  ABTExperimentPayload_FieldNumber_ActivateEventToLog = 8,
+  ABTExperimentPayload_FieldNumber_ClearEventToLog = 9,
+  ABTExperimentPayload_FieldNumber_TimeoutEventToLog = 10,
+  ABTExperimentPayload_FieldNumber_TtlExpiryEventToLog = 11,
+  ABTExperimentPayload_FieldNumber_OverflowPolicy = 12,
+  ABTExperimentPayload_FieldNumber_OngoingExperimentsArray = 13,
+};
+
+@interface ABTExperimentPayload : GPBMessage
+
+
+@property(nonatomic, readwrite, copy, null_resettable) NSString *experimentId;
+
+
+@property(nonatomic, readwrite, copy, null_resettable) NSString *variantId;
+
+
+@property(nonatomic, readwrite) int64_t experimentStartTimeMillis;
+
+
+@property(nonatomic, readwrite, copy, null_resettable) NSString *triggerEvent;
+
+
+@property(nonatomic, readwrite) int64_t triggerTimeoutMillis;
+
+
+@property(nonatomic, readwrite) int64_t timeToLiveMillis;
+
+
+@property(nonatomic, readwrite, copy, null_resettable) NSString *setEventToLog;
+
+
+@property(nonatomic, readwrite, copy, null_resettable) NSString *activateEventToLog;
+
+
+@property(nonatomic, readwrite, copy, null_resettable) NSString *clearEventToLog;
+
+
+@property(nonatomic, readwrite, copy, null_resettable) NSString *timeoutEventToLog;
+
+
+@property(nonatomic, readwrite, copy, null_resettable) NSString *ttlExpiryEventToLog;
+
+
+@property(nonatomic, readwrite) ABTExperimentPayload_ExperimentOverflowPolicy overflowPolicy;
+
+
+@property(nonatomic, readwrite, strong, null_resettable) NSMutableArray<ABTExperimentLite*> *ongoingExperimentsArray;
+/** The number of items in @c ongoingExperimentsArray without causing the array to be created. */
+@property(nonatomic, readonly) NSUInteger ongoingExperimentsArray_Count;
+
+@end
+
+/**
+ * Fetches the raw value of a @c ABTExperimentPayload's @c overflowPolicy property, even
+ * if the value was not defined by the enum at the time the code was generated.
+ **/
+int32_t ABTExperimentPayload_OverflowPolicy_RawValue(ABTExperimentPayload *message);
+/**
+ * Sets the raw value of an @c ABTExperimentPayload's @c overflowPolicy property, allowing
+ * it to be set to a value that was not defined by the enum at the time the code
+ * was generated.
+ **/
+void SetABTExperimentPayload_OverflowPolicy_RawValue(ABTExperimentPayload *message, int32_t value);
+
+NS_ASSUME_NONNULL_END
+
+CF_EXTERN_C_END
+
+#pragma clang diagnostic pop
+
+// @@protoc_insertion_point(global_scope)

+ 330 - 0
FirebaseABTesting/Sources/Protos/developers/mobile/abt/proto/ExperimentPayload.pbobjc.m

@@ -0,0 +1,330 @@
+// Copyright 2019 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.
+
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// source: developers/mobile/abt/proto/experiment_payload.proto
+
+// This CPP symbol can be defined to use imports that match up to the framework
+// imports needed when using CocoaPods.
+#if !defined(GPB_USE_PROTOBUF_FRAMEWORK_IMPORTS)
+ #define GPB_USE_PROTOBUF_FRAMEWORK_IMPORTS 0
+#endif
+
+#if GPB_USE_PROTOBUF_FRAMEWORK_IMPORTS
+ #import <Protobuf/GPBProtocolBuffers_RuntimeSupport.h>
+#else
+ #import "GPBProtocolBuffers_RuntimeSupport.h"
+#endif
+
+ #import "FirebaseABTesting/Sources/Protos/developers/mobile/abt/proto/ExperimentPayload.pbobjc.h"
+// @@protoc_insertion_point(imports)
+
+#pragma clang diagnostic push
+#pragma clang diagnostic ignored "-Wdeprecated-declarations"
+
+#pragma mark - ABTExperimentPayloadRoot
+
+@implementation ABTExperimentPayloadRoot
+
+// No extensions in the file and no imports, so no need to generate
+// +extensionRegistry.
+
+@end
+
+#pragma mark - ABTExperimentPayloadRoot_FileDescriptor
+
+static GPBFileDescriptor *ABTExperimentPayloadRoot_FileDescriptor(void) {
+  // This is called by +initialize so there is no need to worry
+  // about thread safety of the singleton.
+  static GPBFileDescriptor *descriptor = NULL;
+  if (!descriptor) {
+    GPB_DEBUG_CHECK_RUNTIME_VERSIONS();
+    descriptor = [[GPBFileDescriptor alloc] initWithPackage:@"developers.mobile.abt"
+                                                 objcPrefix:@"ABT"
+                                                     syntax:GPBFileSyntaxProto3];
+  }
+  return descriptor;
+}
+
+#pragma mark - ABTExperimentLite
+
+@implementation ABTExperimentLite
+
+@dynamic experimentId;
+
+typedef struct ABTExperimentLite__storage_ {
+  uint32_t _has_storage_[1];
+  NSString *experimentId;
+} ABTExperimentLite__storage_;
+
+// This method is threadsafe because it is initially called
+// in +initialize for each subclass.
++ (GPBDescriptor *)descriptor {
+  static GPBDescriptor *descriptor = nil;
+  if (!descriptor) {
+    static GPBMessageFieldDescription fields[] = {
+      {
+        .name = "experimentId",
+        .dataTypeSpecific.className = NULL,
+        .number = ABTExperimentLite_FieldNumber_ExperimentId,
+        .hasIndex = 0,
+        .offset = (uint32_t)offsetof(ABTExperimentLite__storage_, experimentId),
+        .flags = GPBFieldOptional,
+        .dataType = GPBDataTypeString,
+      },
+    };
+    GPBDescriptor *localDescriptor =
+        [GPBDescriptor allocDescriptorForClass:[ABTExperimentLite class]
+                                     rootClass:[ABTExperimentPayloadRoot class]
+                                          file:ABTExperimentPayloadRoot_FileDescriptor()
+                                        fields:fields
+                                    fieldCount:(uint32_t)(sizeof(fields) / sizeof(GPBMessageFieldDescription))
+                                   storageSize:sizeof(ABTExperimentLite__storage_)
+                                         flags:GPBDescriptorInitializationFlag_None];
+    NSAssert(descriptor == nil, @"Startup recursed!");
+    descriptor = localDescriptor;
+  }
+  return descriptor;
+}
+
+@end
+
+#pragma mark - ABTExperimentPayload
+
+@implementation ABTExperimentPayload
+
+@dynamic experimentId;
+@dynamic variantId;
+@dynamic experimentStartTimeMillis;
+@dynamic triggerEvent;
+@dynamic triggerTimeoutMillis;
+@dynamic timeToLiveMillis;
+@dynamic setEventToLog;
+@dynamic activateEventToLog;
+@dynamic clearEventToLog;
+@dynamic timeoutEventToLog;
+@dynamic ttlExpiryEventToLog;
+@dynamic overflowPolicy;
+@dynamic ongoingExperimentsArray, ongoingExperimentsArray_Count;
+
+typedef struct ABTExperimentPayload__storage_ {
+  uint32_t _has_storage_[1];
+  ABTExperimentPayload_ExperimentOverflowPolicy overflowPolicy;
+  NSString *experimentId;
+  NSString *variantId;
+  NSString *triggerEvent;
+  NSString *setEventToLog;
+  NSString *activateEventToLog;
+  NSString *clearEventToLog;
+  NSString *timeoutEventToLog;
+  NSString *ttlExpiryEventToLog;
+  NSMutableArray *ongoingExperimentsArray;
+  int64_t experimentStartTimeMillis;
+  int64_t triggerTimeoutMillis;
+  int64_t timeToLiveMillis;
+} ABTExperimentPayload__storage_;
+
+// This method is threadsafe because it is initially called
+// in +initialize for each subclass.
++ (GPBDescriptor *)descriptor {
+  static GPBDescriptor *descriptor = nil;
+  if (!descriptor) {
+    static GPBMessageFieldDescription fields[] = {
+      {
+        .name = "experimentId",
+        .dataTypeSpecific.className = NULL,
+        .number = ABTExperimentPayload_FieldNumber_ExperimentId,
+        .hasIndex = 0,
+        .offset = (uint32_t)offsetof(ABTExperimentPayload__storage_, experimentId),
+        .flags = GPBFieldOptional,
+        .dataType = GPBDataTypeString,
+      },
+      {
+        .name = "variantId",
+        .dataTypeSpecific.className = NULL,
+        .number = ABTExperimentPayload_FieldNumber_VariantId,
+        .hasIndex = 1,
+        .offset = (uint32_t)offsetof(ABTExperimentPayload__storage_, variantId),
+        .flags = GPBFieldOptional,
+        .dataType = GPBDataTypeString,
+      },
+      {
+        .name = "experimentStartTimeMillis",
+        .dataTypeSpecific.className = NULL,
+        .number = ABTExperimentPayload_FieldNumber_ExperimentStartTimeMillis,
+        .hasIndex = 2,
+        .offset = (uint32_t)offsetof(ABTExperimentPayload__storage_, experimentStartTimeMillis),
+        .flags = GPBFieldOptional,
+        .dataType = GPBDataTypeInt64,
+      },
+      {
+        .name = "triggerEvent",
+        .dataTypeSpecific.className = NULL,
+        .number = ABTExperimentPayload_FieldNumber_TriggerEvent,
+        .hasIndex = 3,
+        .offset = (uint32_t)offsetof(ABTExperimentPayload__storage_, triggerEvent),
+        .flags = GPBFieldOptional,
+        .dataType = GPBDataTypeString,
+      },
+      {
+        .name = "triggerTimeoutMillis",
+        .dataTypeSpecific.className = NULL,
+        .number = ABTExperimentPayload_FieldNumber_TriggerTimeoutMillis,
+        .hasIndex = 4,
+        .offset = (uint32_t)offsetof(ABTExperimentPayload__storage_, triggerTimeoutMillis),
+        .flags = GPBFieldOptional,
+        .dataType = GPBDataTypeInt64,
+      },
+      {
+        .name = "timeToLiveMillis",
+        .dataTypeSpecific.className = NULL,
+        .number = ABTExperimentPayload_FieldNumber_TimeToLiveMillis,
+        .hasIndex = 5,
+        .offset = (uint32_t)offsetof(ABTExperimentPayload__storage_, timeToLiveMillis),
+        .flags = GPBFieldOptional,
+        .dataType = GPBDataTypeInt64,
+      },
+      {
+        .name = "setEventToLog",
+        .dataTypeSpecific.className = NULL,
+        .number = ABTExperimentPayload_FieldNumber_SetEventToLog,
+        .hasIndex = 6,
+        .offset = (uint32_t)offsetof(ABTExperimentPayload__storage_, setEventToLog),
+        .flags = GPBFieldOptional,
+        .dataType = GPBDataTypeString,
+      },
+      {
+        .name = "activateEventToLog",
+        .dataTypeSpecific.className = NULL,
+        .number = ABTExperimentPayload_FieldNumber_ActivateEventToLog,
+        .hasIndex = 7,
+        .offset = (uint32_t)offsetof(ABTExperimentPayload__storage_, activateEventToLog),
+        .flags = GPBFieldOptional,
+        .dataType = GPBDataTypeString,
+      },
+      {
+        .name = "clearEventToLog",
+        .dataTypeSpecific.className = NULL,
+        .number = ABTExperimentPayload_FieldNumber_ClearEventToLog,
+        .hasIndex = 8,
+        .offset = (uint32_t)offsetof(ABTExperimentPayload__storage_, clearEventToLog),
+        .flags = GPBFieldOptional,
+        .dataType = GPBDataTypeString,
+      },
+      {
+        .name = "timeoutEventToLog",
+        .dataTypeSpecific.className = NULL,
+        .number = ABTExperimentPayload_FieldNumber_TimeoutEventToLog,
+        .hasIndex = 9,
+        .offset = (uint32_t)offsetof(ABTExperimentPayload__storage_, timeoutEventToLog),
+        .flags = GPBFieldOptional,
+        .dataType = GPBDataTypeString,
+      },
+      {
+        .name = "ttlExpiryEventToLog",
+        .dataTypeSpecific.className = NULL,
+        .number = ABTExperimentPayload_FieldNumber_TtlExpiryEventToLog,
+        .hasIndex = 10,
+        .offset = (uint32_t)offsetof(ABTExperimentPayload__storage_, ttlExpiryEventToLog),
+        .flags = GPBFieldOptional,
+        .dataType = GPBDataTypeString,
+      },
+      {
+        .name = "overflowPolicy",
+        .dataTypeSpecific.enumDescFunc = ABTExperimentPayload_ExperimentOverflowPolicy_EnumDescriptor,
+        .number = ABTExperimentPayload_FieldNumber_OverflowPolicy,
+        .hasIndex = 11,
+        .offset = (uint32_t)offsetof(ABTExperimentPayload__storage_, overflowPolicy),
+        .flags = (GPBFieldFlags)(GPBFieldOptional | GPBFieldHasEnumDescriptor),
+        .dataType = GPBDataTypeEnum,
+      },
+      {
+        .name = "ongoingExperimentsArray",
+        .dataTypeSpecific.className = GPBStringifySymbol(ABTExperimentLite),
+        .number = ABTExperimentPayload_FieldNumber_OngoingExperimentsArray,
+        .hasIndex = GPBNoHasBit,
+        .offset = (uint32_t)offsetof(ABTExperimentPayload__storage_, ongoingExperimentsArray),
+        .flags = GPBFieldRepeated,
+        .dataType = GPBDataTypeMessage,
+      },
+    };
+    GPBDescriptor *localDescriptor =
+        [GPBDescriptor allocDescriptorForClass:[ABTExperimentPayload class]
+                                     rootClass:[ABTExperimentPayloadRoot class]
+                                          file:ABTExperimentPayloadRoot_FileDescriptor()
+                                        fields:fields
+                                    fieldCount:(uint32_t)(sizeof(fields) / sizeof(GPBMessageFieldDescription))
+                                   storageSize:sizeof(ABTExperimentPayload__storage_)
+                                         flags:GPBDescriptorInitializationFlag_None];
+    NSAssert(descriptor == nil, @"Startup recursed!");
+    descriptor = localDescriptor;
+  }
+  return descriptor;
+}
+
+@end
+
+int32_t ABTExperimentPayload_OverflowPolicy_RawValue(ABTExperimentPayload *message) {
+  GPBDescriptor *descriptor = [ABTExperimentPayload descriptor];
+  GPBFieldDescriptor *field = [descriptor fieldWithNumber:ABTExperimentPayload_FieldNumber_OverflowPolicy];
+  return GPBGetMessageInt32Field(message, field);
+}
+
+void SetABTExperimentPayload_OverflowPolicy_RawValue(ABTExperimentPayload *message, int32_t value) {
+  GPBDescriptor *descriptor = [ABTExperimentPayload descriptor];
+  GPBFieldDescriptor *field = [descriptor fieldWithNumber:ABTExperimentPayload_FieldNumber_OverflowPolicy];
+  GPBSetInt32IvarWithFieldInternal(message, field, value, descriptor.file.syntax);
+}
+
+#pragma mark - Enum ABTExperimentPayload_ExperimentOverflowPolicy
+
+GPBEnumDescriptor *ABTExperimentPayload_ExperimentOverflowPolicy_EnumDescriptor(void) {
+  static GPBEnumDescriptor *descriptor = NULL;
+  if (!descriptor) {
+    static const char *valueNames =
+        "PolicyUnspecified\000DiscardOldest\000IgnoreNe"
+        "west\000";
+    static const int32_t values[] = {
+        ABTExperimentPayload_ExperimentOverflowPolicy_PolicyUnspecified,
+        ABTExperimentPayload_ExperimentOverflowPolicy_DiscardOldest,
+        ABTExperimentPayload_ExperimentOverflowPolicy_IgnoreNewest,
+    };
+    GPBEnumDescriptor *worker =
+        [GPBEnumDescriptor allocDescriptorForName:GPBNSStringifySymbol(ABTExperimentPayload_ExperimentOverflowPolicy)
+                                       valueNames:valueNames
+                                           values:values
+                                            count:(uint32_t)(sizeof(values) / sizeof(int32_t))
+                                     enumVerifier:ABTExperimentPayload_ExperimentOverflowPolicy_IsValidValue];
+    if (!OSAtomicCompareAndSwapPtrBarrier(nil, worker, (void * volatile *)&descriptor)) {
+      [worker release];
+    }
+  }
+  return descriptor;
+}
+
+BOOL ABTExperimentPayload_ExperimentOverflowPolicy_IsValidValue(int32_t value__) {
+  switch (value__) {
+    case ABTExperimentPayload_ExperimentOverflowPolicy_PolicyUnspecified:
+    case ABTExperimentPayload_ExperimentOverflowPolicy_DiscardOldest:
+    case ABTExperimentPayload_ExperimentOverflowPolicy_IgnoreNewest:
+      return YES;
+    default:
+      return NO;
+  }
+}
+
+
+#pragma clang diagnostic pop
+
+// @@protoc_insertion_point(global_scope)

+ 64 - 0
FirebaseABTesting/Sources/Public/FIRExperimentController.h

@@ -0,0 +1,64 @@
+// Copyright 2019 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>
+
+// Forward declaration to avoid importing into the module header
+typedef NS_ENUM(int32_t, ABTExperimentPayload_ExperimentOverflowPolicy);
+
+NS_ASSUME_NONNULL_BEGIN
+
+@class FIRLifecycleEvents;
+
+/// The default experiment overflow policy, that is to discard the experiment with the oldest start
+/// time when users start the experiment on the web console.
+extern const ABTExperimentPayload_ExperimentOverflowPolicy FIRDefaultExperimentOverflowPolicy;
+
+/// This class is for Firebase services to handle experiments updates to Firebase Analytics.
+/// Experiments can be set, cleared and updated through this controller.
+NS_SWIFT_NAME(ExperimentController)
+@interface FIRExperimentController : NSObject
+
+/// Returns the FIRExperimentController singleton.
++ (FIRExperimentController *)sharedInstance;
+
+/// Updates the list of experiments. Experiments already existing in payloads are not affected,
+/// whose state and payload is preserved. This method compares whether the experiments have changed
+/// or not by their variant ID. This runs in a background queue.
+/// @param origin         The originating service affected by the experiment, it is defined at
+///                       Firebase Analytics FIREventOrigins.h.
+/// @param events         A list of event names to be used for logging experiment lifecycle events,
+///                       if they are not defined in the payload.
+/// @param policy         The policy to handle new experiments when slots are full.
+/// @param lastStartTime  The last known experiment start timestamp for this affected service.
+///                       (Timestamps are specified by the number of seconds from 00:00:00 UTC on 1
+///                       January 1970.).
+/// @param payloads       List of experiment metadata.
+- (void)updateExperimentsWithServiceOrigin:(NSString *)origin
+                                    events:(FIRLifecycleEvents *)events
+                                    policy:(ABTExperimentPayload_ExperimentOverflowPolicy)policy
+                             lastStartTime:(NSTimeInterval)lastStartTime
+                                  payloads:(NSArray<NSData *> *)payloads;
+
+/// Returns the latest experiment start timestamp given a current latest timestamp and a list of
+/// experiment payloads. Timestamps are specified by the number of seconds from 00:00:00 UTC on 1
+/// January 1970.
+/// @param timestamp  Current latest experiment start timestamp. If not known, affected service
+///                   should specify -1;
+/// @param payloads   List of experiment metadata.
+- (NSTimeInterval)latestExperimentStartTimestampBetweenTimestamp:(NSTimeInterval)timestamp
+                                                     andPayloads:(NSArray<NSData *> *)payloads;
+@end
+
+NS_ASSUME_NONNULL_END

+ 63 - 0
FirebaseABTesting/Sources/Public/FIRLifecycleEvents.h

@@ -0,0 +1,63 @@
+// Copyright 2019 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>
+
+NS_ASSUME_NONNULL_BEGIN
+
+/// Default event name for when an experiment is set.
+extern NSString *const FIRSetExperimentEventName NS_SWIFT_NAME(DefaultSetExperimentEventName);
+/// Default event name for when an experiment is activated.
+extern NSString *const FIRActivateExperimentEventName
+    NS_SWIFT_NAME(DefaultActivateExperimentEventName);
+/// Default event name for when an experiment is cleared.
+extern NSString *const FIRClearExperimentEventName NS_SWIFT_NAME(DefaultClearExperimentEventName);
+/// Default event name for when an experiment times out for being activated.
+extern NSString *const FIRTimeoutExperimentEventName
+    NS_SWIFT_NAME(DefaultTimeoutExperimentEventName);
+/// Default event name for when an experiment is expired as it reaches the end of TTL.
+extern NSString *const FIRExpireExperimentEventName NS_SWIFT_NAME(DefaultExpireExperimentEventName);
+
+/// An Experiment Lifecycle Event Object that specifies the name of the experiment event to be
+/// logged by Firebase Analytics.
+NS_SWIFT_NAME(LifecycleEvents)
+@interface FIRLifecycleEvents : NSObject
+
+/// Event name for when an experiment is set. It is default to FIRSetExperimentEventName and can be
+/// overridden. If experiment payload has a valid string of this field, always use experiment
+/// payload.
+@property(nonatomic, copy) NSString *setExperimentEventName;
+
+/// Event name for when an experiment is activated. It is default to FIRActivateExperimentEventName
+/// and can be overridden. If experiment payload has a valid string of this field, always use
+/// experiment payload.
+@property(nonatomic, copy) NSString *activateExperimentEventName;
+
+/// Event name for when an experiment is clearred. It is default to FIRClearExperimentEventName and
+/// can be overridden. If experiment payload has a valid string of this field, always use experiment
+/// payload.
+@property(nonatomic, copy) NSString *clearExperimentEventName;
+/// Event name for when an experiment is timeout from being STANDBY. It is default to
+/// FIRTimeoutExperimentEventName and can be overridden. If experiment payload has a valid string
+/// of this field, always use experiment payload.
+@property(nonatomic, copy) NSString *timeoutExperimentEventName;
+
+/// Event name when an experiment is expired when it reaches the end of its TTL.
+/// It is default to FIRExpireExperimentEventName and can be overridden. If experiment payload has a
+/// valid string of this field, always use experiment payload.
+@property(nonatomic, copy) NSString *expireExperimentEventName;
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 16 - 0
FirebaseABTesting/Sources/Public/FirebaseABTesting.h

@@ -0,0 +1,16 @@
+// Copyright 2019 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 "FIRExperimentController.h"
+#import "FIRLifecycleEvents.h"

+ 362 - 0
FirebaseABTesting/Tests/Unit/ABTConditionalUserPropertyControllerTest.m

@@ -0,0 +1,362 @@
+// Copyright 2019 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 <XCTest/XCTest.h>
+
+#import <FirebaseABTesting/FIRExperimentController.h>
+#import <FirebaseABTesting/FIRLifecycleEvents.h>
+#import <FirebaseCore/FIRAppInternal.h>
+#import <FirebaseCore/FIROptionsInternal.h>
+#import <OCMock/OCMock.h>
+#import "FirebaseABTesting/Sources/ABTConditionalUserPropertyController.h"
+#import "FirebaseABTesting/Sources/ABTConstants.h"
+#import "FirebaseABTesting/Tests/Unit/ABTFakeFIRAConditionalUserPropertyController.h"
+#import "FirebaseABTesting/Tests/Unit/ABTTestUniversalConstants.h"
+
+@interface ABTConditionalUserPropertyController (ExposedForTest)
+- (NSInteger)maxNumberOfExperimentsOfOrigin:(NSString *)origin;
+- (void)maxNumberOfExperimentsOfOrigin:(NSString *)origin
+                     completionHandler:(void (^)(int32_t))completionHandler;
+- (id)createExperimentFromOrigin:(NSString *)origin
+                         payload:(ABTExperimentPayload *)payload
+                          events:(FIRLifecycleEvents *)events;
+- (ABTExperimentPayload_ExperimentOverflowPolicy)
+    overflowPolicyWithPayload:(ABTExperimentPayload *)payload
+               originalPolicy:(ABTExperimentPayload_ExperimentOverflowPolicy)originalPolicy;
+/// Surface internal initializer to avoid singleton usage during tests.
+- (instancetype)initWithAnalytics:(nullable id<FIRAnalyticsInterop>)analytics;
+@end
+
+typedef void (^FakeAnalyticsLogEventWithOriginNameParametersHandler)(
+    NSString *origin, NSString *name, NSDictionary<NSString *, id> *parameters);
+
+@interface ABTConditionalUserPropertyControllerTest : XCTestCase {
+  ABTConditionalUserPropertyController *_ABTCUPController;
+  ABTFakeFIRAConditionalUserPropertyController *_fakeController;
+  id _mockCUPController;
+}
+@end
+
+@implementation ABTConditionalUserPropertyControllerTest
+- (void)setUp {
+  [super setUp];
+
+  _fakeController = [ABTFakeFIRAConditionalUserPropertyController sharedInstance];
+  _ABTCUPController = [[ABTConditionalUserPropertyController alloc]
+      initWithAnalytics:[[FakeAnalytics alloc] initWithFakeController:_fakeController]];
+  _mockCUPController = OCMPartialMock(_ABTCUPController);
+  OCMStub([_mockCUPController maxNumberOfExperimentsOfOrigin:[OCMArg any]]).andReturn(3);
+
+  // Must initialize FIRApp before calling set experiment as Firebase Analytics internal event
+  // logging requires it.
+  NSDictionary *optionsDictionary = @{
+    kFIRGoogleAppID : @"1:123456789012:ios:1234567890123456",
+    @"GCM_SENDER_ID" : @"123456789012"
+  };
+  FIROptions *options = [[FIROptions alloc] initInternalWithOptionsDictionary:optionsDictionary];
+  [FIRApp configureWithOptions:options];
+}
+
+- (void)tearDown {
+  [_fakeController resetExperiments];
+  [_mockCUPController stopMocking];
+  [FIRApp resetApps];
+  [super tearDown];
+}
+
+#pragma mark - test proxy methods on Firebase Analytics
+- (void)testSetExperiment {
+  ABTExperimentPayload *payload = [[ABTExperimentPayload alloc] init];
+  payload.experimentId = @"exp_0";
+  FIRLifecycleEvents *events = [[FIRLifecycleEvents alloc] init];
+  [_ABTCUPController
+      setExperimentWithOrigin:gABTTestOrigin
+                      payload:payload
+                       events:events
+                       policy:ABTExperimentPayload_ExperimentOverflowPolicy_IgnoreNewest];
+
+  NSArray *experiments = [_ABTCUPController experimentsWithOrigin:gABTTestOrigin];
+  XCTAssertEqual(experiments.count, 1);
+}
+
+- (void)testSetExperimentWhenOverflow {
+  ABTExperimentPayload *payload = [[ABTExperimentPayload alloc] init];
+  payload.experimentId = @"exp_1";
+  payload.variantId = @"v1";
+  FIRLifecycleEvents *events = [[FIRLifecycleEvents alloc] init];
+  [_ABTCUPController
+      setExperimentWithOrigin:gABTTestOrigin
+                      payload:payload
+                       events:events
+                       policy:ABTExperimentPayload_ExperimentOverflowPolicy_IgnoreNewest];
+
+  NSArray *experiments = [_ABTCUPController experimentsWithOrigin:gABTTestOrigin];
+  XCTAssertEqual(experiments.count, 1);
+
+  payload.experimentId = @"exp_2";
+  payload.variantId = @"v1";
+  [_ABTCUPController
+      setExperimentWithOrigin:gABTTestOrigin
+                      payload:payload
+                       events:events
+                       policy:ABTExperimentPayload_ExperimentOverflowPolicy_IgnoreNewest];
+
+  experiments = [_ABTCUPController experimentsWithOrigin:gABTTestOrigin];
+  XCTAssertEqual(experiments.count, 2);
+
+  payload.experimentId = @"exp_3";
+  payload.variantId = @"v1";
+  [_ABTCUPController
+      setExperimentWithOrigin:gABTTestOrigin
+                      payload:payload
+                       events:events
+                       policy:ABTExperimentPayload_ExperimentOverflowPolicy_IgnoreNewest];
+
+  experiments = [_ABTCUPController experimentsWithOrigin:gABTTestOrigin];
+  XCTAssertEqual(experiments.count, 3);
+
+  // Now it's overflowed, try setting a new experiment exp_4.
+  payload.experimentId = @"exp_4";
+  payload.variantId = @"v1";
+  // Try setting a new experiment with ignore newest policy.
+  [_ABTCUPController
+      setExperimentWithOrigin:gABTTestOrigin
+                      payload:payload
+                       events:events
+                       policy:ABTExperimentPayload_ExperimentOverflowPolicy_IgnoreNewest];
+
+  experiments = [_ABTCUPController experimentsWithOrigin:gABTTestOrigin];
+  XCTAssertEqual(experiments.count, 3);
+
+  XCTAssertTrue([self isExperimentID:@"exp_1" variantID:@"v1" inExperiments:experiments]);
+  XCTAssertTrue([self isExperimentID:@"exp_2" variantID:@"v1" inExperiments:experiments]);
+  XCTAssertTrue([self isExperimentID:@"exp_3" variantID:@"v1" inExperiments:experiments]);
+  XCTAssertFalse([self isExperimentID:@"exp_4" variantID:@"v1" inExperiments:experiments]);
+
+  // Try setting a new experiment with discard oldest policy.
+  [_ABTCUPController
+      setExperimentWithOrigin:gABTTestOrigin
+                      payload:payload
+                       events:events
+                       policy:ABTExperimentPayload_ExperimentOverflowPolicy_DiscardOldest];
+  experiments = [_ABTCUPController experimentsWithOrigin:gABTTestOrigin];
+  XCTAssertEqual(experiments.count, 3);
+  XCTAssertFalse([self isExperimentID:@"exp_1" variantID:@"v1" inExperiments:experiments]);
+  XCTAssertTrue([self isExperimentID:@"exp_4" variantID:@"v1" inExperiments:experiments]);
+
+  // Try setting a new experiment with unspecified policy
+  payload.experimentId = @"exp_5";
+  payload.variantId = @"v1";
+  [_ABTCUPController
+      setExperimentWithOrigin:gABTTestOrigin
+                      payload:payload
+                       events:events
+                       policy:ABTExperimentPayload_ExperimentOverflowPolicy_PolicyUnspecified];
+
+  experiments = [_ABTCUPController experimentsWithOrigin:gABTTestOrigin];
+  XCTAssertEqual(experiments.count, 3);
+  XCTAssertFalse([self isExperimentID:@"exp_2" variantID:@"v1" inExperiments:experiments]);
+  XCTAssertTrue([self isExperimentID:@"exp_3" variantID:@"v1" inExperiments:experiments]);
+  XCTAssertTrue([self isExperimentID:@"exp_4" variantID:@"v1" inExperiments:experiments]);
+  XCTAssertTrue([self isExperimentID:@"exp_5" variantID:@"v1" inExperiments:experiments]);
+}
+
+- (void)testSetExperimentWithTheSameVariantID {
+  ABTExperimentPayload *payload = [[ABTExperimentPayload alloc] init];
+  payload.experimentId = @"exp_1";
+  payload.variantId = @"v1";
+  FIRLifecycleEvents *events = [[FIRLifecycleEvents alloc] init];
+  [_ABTCUPController
+      setExperimentWithOrigin:gABTTestOrigin
+                      payload:payload
+                       events:events
+                       policy:ABTExperimentPayload_ExperimentOverflowPolicy_IgnoreNewest];
+
+  NSArray *experiments = [_ABTCUPController experimentsWithOrigin:gABTTestOrigin];
+  XCTAssertEqual(experiments.count, 1);
+  XCTAssertTrue([self isExperimentID:@"exp_1" variantID:@"v1" inExperiments:experiments]);
+
+  payload.experimentId = @"exp_1";
+  payload.variantId = @"v2";
+  [_ABTCUPController
+      setExperimentWithOrigin:gABTTestOrigin
+                      payload:payload
+                       events:events
+                       policy:ABTExperimentPayload_ExperimentOverflowPolicy_IgnoreNewest];
+
+  experiments = [_ABTCUPController experimentsWithOrigin:gABTTestOrigin];
+  XCTAssertEqual(experiments.count, 1);
+  XCTAssertTrue([self isExperimentID:@"exp_1" variantID:@"v2" inExperiments:experiments]);
+}
+
+- (BOOL)isExperimentID:(NSString *)experimentID
+             variantID:(NSString *)variantID
+         inExperiments:(NSArray *)experiments {
+  for (NSDictionary<NSString *, NSString *> *experiment in experiments) {
+    if ([experiment[@"name"] isEqualToString:experimentID] &&
+        [experiment[@"value"] isEqualToString:variantID]) {
+      return YES;
+    }
+  }
+  return NO;
+}
+
+- (void)testClearExperiment {
+  ABTExperimentPayload *payload = [[ABTExperimentPayload alloc] init];
+  payload.experimentId = @"exp_1";
+  payload.variantId = @"v1";
+  // TODO(chliang) to check this name is logged in scion.
+  payload.clearEventToLog = @"override_clear_event";
+  FIRLifecycleEvents *events = [[FIRLifecycleEvents alloc] init];
+  [_ABTCUPController
+      setExperimentWithOrigin:gABTTestOrigin
+                      payload:payload
+                       events:events
+                       policy:ABTExperimentPayload_ExperimentOverflowPolicy_IgnoreNewest];
+
+  NSArray *experiments = [_ABTCUPController experimentsWithOrigin:gABTTestOrigin];
+  XCTAssertEqual(experiments.count, 1);
+
+  [_ABTCUPController clearExperiment:@"exp_1"
+                           variantID:@"v1"
+                          withOrigin:gABTTestOrigin
+                             payload:payload
+                              events:events];
+  experiments = [_ABTCUPController experimentsWithOrigin:gABTTestOrigin];
+  XCTAssertEqual(experiments.count, 0);
+}
+
+- (void)testMaxNumberOfExperiments {
+  XCTAssertEqual([_ABTCUPController maxNumberOfExperimentsOfOrigin:gABTTestOrigin], 3);
+}
+
+- (void)testCreateExperiment {
+  ABTExperimentPayload *payload = [[ABTExperimentPayload alloc] init];
+  payload.experimentId = @"exp_1";
+  payload.variantId = @"variant_B";
+  NSTimeInterval now = [[NSDate date] timeIntervalSince1970];
+  payload.experimentStartTimeMillis = now * ABT_MSEC_PER_SEC;
+  payload.triggerEvent = @"";
+  int64_t triggerTimeout = now + 1500;
+  payload.triggerTimeoutMillis = triggerTimeout * ABT_MSEC_PER_SEC;
+  payload.timeToLiveMillis = (now + 60000) * ABT_MSEC_PER_SEC;
+
+  FIRLifecycleEvents *events = [[FIRLifecycleEvents alloc] init];
+  events.activateExperimentEventName = @"_lifecycle_override_activate";
+  events.expireExperimentEventName = @"lifecycle_override_time_to_live";
+
+  NSDictionary<NSString *, id> *experiment =
+      [_ABTCUPController createExperimentFromOrigin:gABTTestOrigin payload:payload events:events];
+
+  NSDictionary<NSString *, id> *triggeredEvent = [experiment objectForKey:@"triggeredEvent"];
+  XCTAssertEqualObjects([experiment objectForKey:@"name"], @"exp_1");
+  XCTAssertEqualObjects([experiment objectForKey:@"value"], @"variant_B");
+  XCTAssertEqualObjects(gABTTestOrigin, [experiment objectForKey:@"origin"]);
+  XCTAssertEqualWithAccuracy(
+      now, [(NSNumber *)[experiment objectForKey:@"creationTimestamp"] doubleValue], 1.0);
+
+  // Trigger event
+  XCTAssertEqualObjects(gABTTestOrigin, triggeredEvent[@"origin"]);
+  XCTAssertEqualObjects(triggeredEvent[@"name"], @"_lifecycle_override_activate",
+                        @"Activate event name is overrided by lifecycle events.");
+
+  // Timeout event
+  NSDictionary<NSString *, id> *timedOutEvent = [experiment objectForKey:@"timedOutEvent"];
+  XCTAssertEqualObjects(gABTTestOrigin, timedOutEvent[@"origin"]);
+  XCTAssertEqualObjects(FIRTimeoutExperimentEventName, timedOutEvent[@"name"],
+                        @"payload doesn't have timeout event name, use default one");
+
+  // Expired event
+  NSDictionary<NSString *, id> *expiredEvent = [experiment objectForKey:@"expiredEvent"];
+  XCTAssertEqualObjects(gABTTestOrigin, expiredEvent[@"origin"]);
+  XCTAssertEqualObjects(
+      @"lifecycle_override_time_to_live", expiredEvent[@"name"],
+      @"payload doesn't have expiry event name, but lifecycle event does, use lifecycle event");
+
+  // Trigger event name
+  XCTAssertEqualObjects(nil, [experiment objectForKey:@"triggerEventName"],
+                        @"Empty trigger event must be set to nil");
+
+  // trigger timeout
+  XCTAssertEqualWithAccuracy(
+      now + 1500, [(NSNumber *)([experiment objectForKey:@"triggerTimeout"]) doubleValue], 1.0);
+
+  // time to live
+  XCTAssertEqualWithAccuracy(
+      now + 60000, [(NSNumber *)[experiment objectForKey:@"timeToLive"] doubleValue], 1.0);
+
+  // Overwrite all event names
+  payload.activateEventToLog = @"payload_override_activate";
+  payload.ttlExpiryEventToLog = @"payload_override_time_to_live";
+  payload.timeoutEventToLog = @"payload_override_timeout";
+  payload.triggerEvent = @"payload_override_trigger_event";
+
+  experiment = [_ABTCUPController createExperimentFromOrigin:gABTTestOrigin
+                                                     payload:payload
+                                                      events:events];
+  triggeredEvent = [experiment objectForKey:@"triggeredEvent"];
+  XCTAssertEqual(triggeredEvent[@"name"], @"payload_override_activate");
+  timedOutEvent = [experiment objectForKey:@"timedOutEvent"];
+  XCTAssertEqualObjects(timedOutEvent[@"name"], @"payload_override_timeout");
+  expiredEvent = [experiment objectForKey:@"expiredEvent"];
+  XCTAssertEqual(expiredEvent[@"name"], @"payload_override_time_to_live");
+  XCTAssertEqual([experiment objectForKey:@"triggerEventName"], @"payload_override_trigger_event");
+}
+
+#pragma mark - helpers
+
+- (void)testIsExperimentTheSameAsPayload {
+  NSDictionary<NSString *, NSString *> *experiment =
+      @{@"name" : @"exp_1", @"value" : @"variant_control_group"};
+
+  ABTExperimentPayload *payload = [[ABTExperimentPayload alloc] init];
+  payload.experimentId = @"exp_2";
+  payload.variantId = @"variant_group_A";
+
+  XCTAssertFalse([_ABTCUPController isExperiment:experiment theSameAsPayload:payload]);
+
+  payload.experimentId = @"exp_1";
+  XCTAssertFalse([_ABTCUPController isExperiment:experiment theSameAsPayload:payload]);
+
+  payload.variantId = @"variant_control_group";
+  XCTAssertTrue([_ABTCUPController isExperiment:experiment theSameAsPayload:payload]);
+}
+
+- (void)testOverflowPolicyWithPayload {
+  ABTExperimentPayload *payload = [[ABTExperimentPayload alloc] init];
+
+  XCTAssertEqual(ABTExperimentPayload_ExperimentOverflowPolicy_DiscardOldest,
+                 [_ABTCUPController overflowPolicyWithPayload:payload originalPolicy:-1000],
+                 @"Payload policy is unspecified, original policy is invalid, should return "
+                 @"default: DiscardOldest.");
+
+  XCTAssertEqual(
+      ABTExperimentPayload_ExperimentOverflowPolicy_IgnoreNewest,
+      [_ABTCUPController
+          overflowPolicyWithPayload:payload
+                     originalPolicy:ABTExperimentPayload_ExperimentOverflowPolicy_IgnoreNewest],
+      @"Payload policy is unspecified, original policy is valid, use "
+      @"original policy.");
+
+  payload.overflowPolicy = ABTExperimentPayload_ExperimentOverflowPolicy_DiscardOldest;
+  XCTAssertEqual(
+      ABTExperimentPayload_ExperimentOverflowPolicy_DiscardOldest,
+      [_ABTCUPController
+          overflowPolicyWithPayload:payload
+                     originalPolicy:ABTExperimentPayload_ExperimentOverflowPolicy_IgnoreNewest],
+      @"Payload policy is specified, original policy is valid, but "
+      @"use Payload because Payload always wins.");
+}
+
+@end

+ 46 - 0
FirebaseABTesting/Tests/Unit/ABTFakeFIRAConditionalUserPropertyController.h

@@ -0,0 +1,46 @@
+// Copyright 2019 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>
+
+#ifdef COCOAPODS
+#import <FirebaseAnalyticsInterop/FIRAnalyticsInterop.h>
+#else
+#import "third_party/firebase/ios/Releases/FirebaseInterop/Analytics/Public/FIRAnalyticsInterop.h"
+#endif
+
+@class FIRAConditionalUserProperty;
+@class FIRAEvent;
+
+/// Fake Firebase Analytics Conditional User Property Controller Class.
+/// This is a lightweight class to test experiments set and clean, events logging in unit tests.
+@interface ABTFakeFIRAConditionalUserPropertyController : NSObject
+
+/// Returns the FIRAConditionalUserPropertyController singleton.
++ (instancetype)sharedInstance;
+- (void)setConditionalUserProperty:(NSDictionary<NSString *, id> *)conditionalUserProperty;
+- (void)clearConditionalUserPropertyWithName:(NSString *)conditionalUserPropertyName;
+- (NSArray<FIRAConditionalUserProperty *> *)
+    conditionalUserPropertiesWithNamePrefix:(NSString *)namePrefix
+                             filterByOrigin:(NSString *)origin;
+- (NSInteger)maxUserPropertiesForOrigin:(NSString *)origin;
+- (void)resetExperiments;
+@end
+
+@interface FakeAnalytics : NSObject <FIRAnalyticsInterop> {
+  ABTFakeFIRAConditionalUserPropertyController *_fakeController;
+}
+- (instancetype)initWithFakeController:
+    (ABTFakeFIRAConditionalUserPropertyController *)fakeController;
+@end

+ 140 - 0
FirebaseABTesting/Tests/Unit/ABTFakeFIRAConditionalUserPropertyController.m

@@ -0,0 +1,140 @@
+// Copyright 2019 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 "FirebaseABTesting/Tests/Unit/ABTFakeFIRAConditionalUserPropertyController.h"
+
+@implementation ABTFakeFIRAConditionalUserPropertyController {
+  NSMutableArray<NSDictionary<NSString *, id> *> *_experiments;
+}
+
++ (instancetype)sharedInstance {
+  static ABTFakeFIRAConditionalUserPropertyController *sharedInstance = nil;
+  static dispatch_once_t onceToken = 0;
+
+  dispatch_once(&onceToken, ^{
+    sharedInstance = [[ABTFakeFIRAConditionalUserPropertyController alloc] init];
+  });
+  return sharedInstance;
+}
+
+- (instancetype)init {
+  self = [super init];
+  if (self) {
+    _experiments = [[NSMutableArray alloc] init];
+  }
+  return self;
+}
+
+- (void)setConditionalUserProperty:(NSDictionary<NSString *, id> *)cupProperties {
+  [_experiments addObject:cupProperties];
+}
+
+- (void)clearConditionalUserPropertyWithName:(NSString *)conditionalUserPropertyName {
+  for (NSDictionary<NSString *, id> *experiment in _experiments) {
+    if ([experiment[@"name"] isEqualToString:conditionalUserPropertyName]) {
+      [_experiments removeObject:experiment];
+      return;
+    }
+  }
+}
+
+- (NSArray<FIRAConditionalUserProperty *> *)
+    conditionalUserPropertiesWithNamePrefix:(NSString *)namePrefix
+                             filterByOrigin:(NSString *)origin {
+  return [_experiments copy];
+}
+
+/// Returns the max number of User Properties for the given origin.
+- (NSInteger)maxUserPropertiesForOrigin:(NSString *)origin {
+  return 3;
+}
+
+- (void)resetExperiments {
+  [_experiments removeAllObjects];
+}
+@end
+
+@implementation FakeAnalytics
+
+- (instancetype)initWithFakeController:
+    (ABTFakeFIRAConditionalUserPropertyController *)fakeController {
+  self = [super init];
+  if (self) {
+    _fakeController = fakeController;
+  }
+  return self;
+}
+
+- (nonnull NSArray<FIRAConditionalUserProperty *> *)
+    conditionalUserProperties:(nonnull NSString *)origin
+           propertyNamePrefix:(nonnull NSString *)propertyNamePrefix {
+  return [_fakeController conditionalUserPropertiesWithNamePrefix:propertyNamePrefix
+                                                   filterByOrigin:origin];
+}
+
+- (void)clearConditionalUserProperty:(nonnull NSString *)userPropertyName
+                           forOrigin:(NSString *)origin
+                      clearEventName:(nonnull NSString *)clearEventName
+                clearEventParameters:
+                    (nonnull NSDictionary<NSString *, NSString *> *)clearEventParameters {
+  [_fakeController clearConditionalUserPropertyWithName:userPropertyName];
+}
+
+- (void)setConditionalUserProperty:(nonnull NSDictionary<NSString *, id> *)conditionalUserProperty {
+  [_fakeController setConditionalUserProperty:conditionalUserProperty];
+}
+
+- (NSInteger)maxUserProperties:(nonnull NSString *)origin {
+  return 3;
+}
+
+- (void)setConditionalUserPropertyControllerProperties:(NSDictionary<NSString *, id> *)properties {
+  for (NSString *key in properties) {
+    [[ABTFakeFIRAConditionalUserPropertyController sharedInstance]
+        setValue:[properties objectForKey:key]
+          forKey:key];
+  }
+}
+
+- (FIRAEvent *)eventWithOrigin:(NSString *)origin
+                     eventName:(NSString *)eventName
+                        params:(NSDictionary<NSString *, NSString *> *)params {
+  return nil;
+}
+
+// Stubs
+- (void)logEventWithOrigin:(nonnull NSString *)origin
+                      name:(nonnull NSString *)name
+                parameters:(nullable NSDictionary<NSString *, id> *)parameters {
+}
+
+- (void)setUserPropertyWithOrigin:(nonnull NSString *)origin
+                             name:(nonnull NSString *)name
+                            value:(nonnull id)value {
+}
+
+- (void)checkLastNotificationForOrigin:(nonnull NSString *)origin
+                                 queue:(nonnull dispatch_queue_t)queue
+                              callback:(nonnull void (^)(NSString *_Nullable))
+                                           currentLastNotificationProperty {
+}
+
+- (void)registerAnalyticsListener:(nonnull id<FIRAnalyticsInteropListener>)listener
+                       withOrigin:(nonnull NSString *)origin {
+}
+
+- (void)unregisterAnalyticsListenerWithOrigin:(nonnull NSString *)origin {
+}
+
+@end

+ 17 - 0
FirebaseABTesting/Tests/Unit/ABTTestUniversalConstants.h

@@ -0,0 +1,17 @@
+// Copyright 2019 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>
+
+extern NSString *const gABTTestOrigin;

+ 17 - 0
FirebaseABTesting/Tests/Unit/ABTTestUniversalConstants.m

@@ -0,0 +1,17 @@
+// Copyright 2019 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 "FirebaseABTesting/Tests/Unit/ABTTestUniversalConstants.h"
+
+NSString *const gABTTestOrigin = @"remoteconfigtest";

+ 338 - 0
FirebaseABTesting/Tests/Unit/FIRExperimentControllerTest.m

@@ -0,0 +1,338 @@
+// Copyright 2019 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 <XCTest/XCTest.h>
+
+#import <FirebaseABTesting/FIRExperimentController.h>
+#import <FirebaseABTesting/FIRLifecycleEvents.h>
+#import <FirebaseAnalyticsInterop/FIRAnalyticsInterop.h>
+#import <FirebaseCore/FIRAppInternal.h>
+#import <FirebaseCore/FIROptionsInternal.h>
+#import <OCMock/OCMock.h>
+#import "FirebaseABTesting/Sources/ABTConditionalUserPropertyController.h"
+#import "FirebaseABTesting/Sources/ABTConstants.h"
+#import "FirebaseABTesting/Tests/Unit/ABTFakeFIRAConditionalUserPropertyController.h"
+#import "FirebaseABTesting/Tests/Unit/ABTTestUniversalConstants.h"
+
+extern ABTExperimentPayload *ABTDeserializeExperimentPayload(NSData *payload);
+
+extern NSArray<ABTExperimentPayload *> *ABTExperimentsToSetFromPayloads(
+    NSArray<NSData *> *payloads,
+    NSArray<NSDictionary<NSString *, NSString *> *> *experiments,
+    id<FIRAnalyticsInterop> _Nullable analytics);
+extern NSArray *ABTExperimentsToClearFromPayloads(
+    NSArray<NSData *> *payloads,
+    NSArray<NSDictionary<NSString *, NSString *> *> *experiments,
+    id<FIRAnalyticsInterop> _Nullable analytics);
+
+@interface FIRExperimentController (ExposedForTest)
+- (void)
+    updateExperimentsInBackgroundQueueWithServiceOrigin:(NSString *)origin
+                                                 events:(FIRLifecycleEvents *)events
+                                                 policy:
+                                                     (ABTExperimentPayload_ExperimentOverflowPolicy)
+                                                         policy
+                                          lastStartTime:(NSTimeInterval)lastStartTime
+                                               payloads:(NSArray<NSData *> *)payloads;
+
+/// Surface internal initializer to avoid singleton usage during tests.
+- (instancetype)initWithAnalytics:(nullable id<FIRAnalyticsInterop>)analytics;
+@end
+
+@interface ABTConditionalUserPropertyController (ExposedForTest)
+- (void)maxNumberOfExperimentsOfOrigin:(NSString *)origin
+                     completionHandler:(void (^)(int32_t))completionHandler;
+- (int32_t)maxNumberOfExperimentsOfOrigin:(NSString *)origin;
+- (id)createExperimentFromOrigin:(NSString *)origin
+                         payload:(ABTExperimentPayload *)payload
+                          events:(FIRLifecycleEvents *)events;
+- (ABTExperimentPayload_ExperimentOverflowPolicy)
+    overflowPolicyWithPayload:(ABTExperimentPayload *)payload
+               originalPolicy:(ABTExperimentPayload_ExperimentOverflowPolicy)originalPolicy;
+@end
+
+@interface FIRExperimentControllerTest : XCTestCase {
+  FIRExperimentController *_experimentController;
+  ABTFakeFIRAConditionalUserPropertyController *_fakeController;
+  id _mockCUPController;
+}
+@end
+
+@implementation FIRExperimentControllerTest
+
+- (void)setUp {
+  [super setUp];
+  _fakeController = [ABTFakeFIRAConditionalUserPropertyController sharedInstance];
+  id<FIRAnalyticsInterop> fakeAnalytics =
+      [[FakeAnalytics alloc] initWithFakeController:_fakeController];
+  _experimentController = [[FIRExperimentController alloc] initWithAnalytics:fakeAnalytics];
+
+  ABTConditionalUserPropertyController *controller =
+      [ABTConditionalUserPropertyController sharedInstanceWithAnalytics:fakeAnalytics];
+  _mockCUPController = OCMPartialMock(controller);
+  OCMStub([_mockCUPController maxNumberOfExperimentsOfOrigin:[OCMArg any]]).andReturn(3);
+}
+
+- (void)tearDown {
+  [_fakeController resetExperiments];
+  [_mockCUPController stopMocking];
+  [super tearDown];
+}
+
+- (void)testDeserializeInvalidPayload {
+  FIRExperimentController *controller = _experimentController;
+  XCTAssertNotNil(controller);
+  NSString *sampleString = @"sample_invalid_payload";
+  NSData *invalidData = [sampleString dataUsingEncoding:NSUTF8StringEncoding];
+  XCTAssertNil(ABTDeserializeExperimentPayload(invalidData));
+  XCTAssertNotNil(ABTDeserializeExperimentPayload(nil));
+}
+
+- (void)testLifecycleEvents {
+  FIRLifecycleEvents *events = [[FIRLifecycleEvents alloc] init];
+  XCTAssertEqualObjects(FIRSetExperimentEventName, events.setExperimentEventName);
+  XCTAssertEqualObjects(FIRActivateExperimentEventName, events.activateExperimentEventName);
+  XCTAssertEqualObjects(FIRTimeoutExperimentEventName, events.timeoutExperimentEventName);
+  XCTAssertEqualObjects(FIRExpireExperimentEventName, events.expireExperimentEventName);
+  XCTAssertEqualObjects(FIRClearExperimentEventName, events.clearExperimentEventName);
+
+  // Should be able to override event name values.
+  events.setExperimentEventName = @"_new_set_experiment";
+  XCTAssertEqualObjects(events.setExperimentEventName, @"_new_set_experiment");
+  events.setExperimentEventName = @"name_without_prefix";
+  XCTAssertEqualObjects(FIRSetExperimentEventName, events.setExperimentEventName);
+
+  events.activateExperimentEventName = @"_new_activate_experiment";
+  XCTAssertEqualObjects(events.activateExperimentEventName, @"_new_activate_experiment");
+  events.activateExperimentEventName = @"";
+  XCTAssertEqualObjects(FIRActivateExperimentEventName, events.activateExperimentEventName);
+
+  events.timeoutExperimentEventName = @"__";
+  XCTAssertEqualObjects(events.timeoutExperimentEventName, @"__");
+  events.timeoutExperimentEventName = @"name_with_";
+  XCTAssertEqualObjects(FIRTimeoutExperimentEventName, events.timeoutExperimentEventName);
+
+  events.expireExperimentEventName = @"_";
+  XCTAssertEqualObjects(events.expireExperimentEventName, @"_");
+#pragma clang diagnostic push
+#pragma clang diagnostic ignored "-Wnonnull"
+  events.expireExperimentEventName = nil;
+#pragma clang diagnostic pop
+  XCTAssertEqualObjects(FIRExpireExperimentEventName, events.expireExperimentEventName);
+
+  events.clearExperimentEventName = @"_new_set_experiment";
+  XCTAssertEqualObjects(events.clearExperimentEventName, @"_new_set_experiment");
+  events.clearExperimentEventName = @"";
+  XCTAssertEqualObjects(FIRClearExperimentEventName, events.clearExperimentEventName);
+}
+
+- (void)testSetExperimentWithBadPayload {
+  [[_mockCUPController reject]
+      setExperimentWithOrigin:[OCMArg any]
+                      payload:[OCMArg any]
+                       events:[OCMArg any]
+                       policy:ABTExperimentPayload_ExperimentOverflowPolicy_DiscardOldest];
+  NSString *sampleString = @"sample_invalid_payload";
+  NSData *invalidData = [sampleString dataUsingEncoding:NSUTF8StringEncoding];
+  XCTAssertNil(ABTDeserializeExperimentPayload(invalidData));
+}
+
+- (void)testUpdateExperiments {
+  NSTimeInterval now = [[NSDate date] timeIntervalSince1970];
+
+  ABTExperimentPayload *payload2 = [[ABTExperimentPayload alloc] init];
+  payload2.experimentId = @"exp_2";
+  payload2.variantId = @"v200";
+  payload2.experimentStartTimeMillis =
+      (now + 1500) * ABT_MSEC_PER_SEC;  // start time > last start time, do set
+  ABTExperimentLite *ongoingExperiment = [[ABTExperimentLite alloc] init];
+  ongoingExperiment.experimentId = @"exp_1";
+  [payload2.ongoingExperimentsArray addObject:ongoingExperiment];
+
+  ABTExperimentPayload *payload3 = [[ABTExperimentPayload alloc] init];
+  payload3.experimentId = @"exp_3";
+  payload3.variantId = @"v200";
+  payload3.experimentStartTimeMillis =
+      (now + 900) * ABT_MSEC_PER_SEC;  // start time > last start time, do set
+  ongoingExperiment = [[ABTExperimentLite alloc] init];
+  ongoingExperiment.experimentId = @"exp_2";
+  [payload3.ongoingExperimentsArray addObject:ongoingExperiment];
+
+  ABTExperimentPayload *payload4 = [[ABTExperimentPayload alloc] init];
+  payload4.experimentId = @"exp_4";
+  payload4.variantId = @"v200";
+  payload4.experimentStartTimeMillis =
+      (now - 900) * ABT_MSEC_PER_SEC;  // start time < last start time, do not set.
+  ongoingExperiment = [[ABTExperimentLite alloc] init];
+  ongoingExperiment.experimentId = @"exp_2";
+  [payload4.ongoingExperimentsArray addObject:ongoingExperiment];
+
+  FIRLifecycleEvents *events = [[FIRLifecycleEvents alloc] init];
+  NSArray *payloads = @[ [payload2 data], [payload3 data], [payload4 data] ];
+  [_experimentController
+      updateExperimentsInBackgroundQueueWithServiceOrigin:gABTTestOrigin
+                                                   events:events
+                                                   policy:
+                                                       ABTExperimentPayload_ExperimentOverflowPolicy_DiscardOldest  // NOLINT
+                                            lastStartTime:now
+                                                 payloads:payloads];
+
+  XCTAssertEqual([_mockCUPController experimentsWithOrigin:gABTTestOrigin].count, 2);
+
+  // Second time update exp_1 no longer exist, should be cleared from experiments.
+  payloads = @[ [payload3 data], [payload4 data] ];
+  [_experimentController
+      updateExperimentsInBackgroundQueueWithServiceOrigin:gABTTestOrigin
+                                                   events:events
+                                                   policy:
+                                                       ABTExperimentPayload_ExperimentOverflowPolicy_DiscardOldest  // NOLINT
+                                            lastStartTime:now
+                                                 payloads:payloads];
+
+  XCTAssertEqual([_mockCUPController experimentsWithOrigin:gABTTestOrigin].count, 1);
+}
+
+- (void)testLatestExperimentStartTimestamps {
+  // Mock incoming payloads
+  NSMutableArray<NSData *> *payloads = [[NSMutableArray alloc] init];
+  NSTimeInterval now = [[NSDate date] timeIntervalSince1970];
+
+  ABTExperimentPayload *payload1 = [[ABTExperimentPayload alloc] init];
+  payload1.experimentId = @"exp_1";
+  payload1.variantId = @"v3";
+  payload1.experimentStartTimeMillis = now * ABT_MSEC_PER_SEC;
+  [payloads addObject:[payload1 data]];
+
+  ABTExperimentPayload *payload2 = [[ABTExperimentPayload alloc] init];
+  payload2.experimentId = @"exp_2";
+  payload2.variantId = @"v2";
+  payload2.experimentStartTimeMillis = (now + 500) * ABT_MSEC_PER_SEC;
+  [payloads addObject:[payload2 data]];
+
+  NSString *sampleString = @"sample_invalid_payload";
+  NSData *invalidPayload = [sampleString dataUsingEncoding:NSUTF8StringEncoding];
+  [payloads addObject:invalidPayload];
+
+  XCTAssertEqualWithAccuracy(
+      now + 500,
+      [_experimentController latestExperimentStartTimestampBetweenTimestamp:now + 200
+                                                                andPayloads:payloads],
+      1);
+  XCTAssertEqualWithAccuracy(
+      now + 1000,
+      [_experimentController latestExperimentStartTimestampBetweenTimestamp:now + 1000
+                                                                andPayloads:payloads],
+      1);
+  XCTAssertEqualWithAccuracy(
+      now + 500,
+      [_experimentController latestExperimentStartTimestampBetweenTimestamp:now - 10000
+                                                                andPayloads:payloads],
+      1);
+}
+
+- (void)testExperimentsToSetFromPayloads {
+  // Mock conditional user property objects in experiments.
+  NSMutableArray *currentExperiments = [[NSMutableArray alloc] init];
+
+  NSDictionary<NSString *, NSString *> *CUP1 = @{@"name" : @"exp_1", @"value" : @"v1"};
+  [currentExperiments addObject:CUP1];
+
+  NSDictionary<NSString *, NSString *> *CUP2 = @{@"name" : @"exp_2", @"value" : @"v2"};
+  [currentExperiments addObject:CUP2];
+
+  // Mock incoming payloads
+  NSMutableArray<NSData *> *payloads = [[NSMutableArray alloc] init];
+  ABTExperimentPayload *payload1 = [[ABTExperimentPayload alloc] init];
+  payload1.experimentId = @"exp_1";
+  payload1.variantId = @"v3";
+  [payloads addObject:[payload1 data]];
+
+  ABTExperimentPayload *payload2 = [[ABTExperimentPayload alloc] init];
+  payload2.experimentId = @"exp_2";
+  payload2.variantId = @"v2";
+  [payloads addObject:[payload2 data]];
+
+  NSString *sampleString = @"sample_invalid_payload";
+  NSData *invalidPayload = [sampleString dataUsingEncoding:NSUTF8StringEncoding];
+  [payloads addObject:invalidPayload];
+
+  NSArray<ABTExperimentPayload *> *experimentsToSet =
+      ABTExperimentsToSetFromPayloads(payloads, currentExperiments, nil);
+
+  XCTAssertEqual(experimentsToSet.count, 1);
+  ABTExperimentPayload *payloadToAdd = experimentsToSet.firstObject;
+  XCTAssertEqualObjects(payloadToAdd.experimentId, @"exp_1");
+  XCTAssertEqualObjects(payloadToAdd.variantId, @"v3");
+}
+
+- (void)testExperimentsToClearFromPaylods {
+  // Mock conditional user property objects in experiments.
+  NSMutableArray *currentExperiments = [[NSMutableArray alloc] init];
+
+  NSDictionary<NSString *, NSString *> *CUP1 = @{@"name" : @"exp_1", @"value" : @"v1"};
+  [currentExperiments addObject:CUP1];
+
+  NSDictionary<NSString *, NSString *> *CUP2 = @{@"name" : @"exp_2", @"value" : @"v2"};
+  [currentExperiments addObject:CUP2];
+
+  // Mock incoming payloads
+  NSMutableArray<NSData *> *payloads = [[NSMutableArray alloc] init];
+  ABTExperimentPayload *payload1 = [[ABTExperimentPayload alloc] init];
+  payload1.experimentId = @"exp_1";
+  payload1.variantId = @"v3";
+  [payloads addObject:[payload1 data]];
+
+  ABTExperimentPayload *payload2 = [[ABTExperimentPayload alloc] init];
+  payload2.experimentId = @"exp_2";
+  payload2.variantId = @"v2";
+  [payloads addObject:[payload2 data]];
+
+  NSString *sampleString = @"sample_invalid_payload";
+  NSData *invalidPayload = [sampleString dataUsingEncoding:NSUTF8StringEncoding];
+  [payloads addObject:invalidPayload];
+
+  NSArray<NSDictionary<NSString *, NSString *> *> *experimentsToClear =
+      ABTExperimentsToClearFromPayloads(payloads, currentExperiments, nil);
+
+  XCTAssertEqual(experimentsToClear.count, 1);
+  NSDictionary<NSString *, NSString *> *experimentToRemove = experimentsToClear.firstObject;
+  XCTAssertEqualObjects(experimentToRemove[@"name"], @"exp_1");
+  XCTAssertEqualObjects(experimentToRemove[@"value"], @"v1");
+}
+
+- (void)testInvalidExperiments {
+  [[_mockCUPController reject]
+      setExperimentWithOrigin:[OCMArg any]
+                      payload:[OCMArg any]
+                       events:[OCMArg any]
+                       policy:ABTExperimentPayload_ExperimentOverflowPolicy_DiscardOldest];
+  [[_mockCUPController reject]
+      setExperimentWithOrigin:[OCMArg any]
+                      payload:[OCMArg any]
+                       events:[OCMArg any]
+                       policy:ABTExperimentPayload_ExperimentOverflowPolicy_DiscardOldest];
+
+  OCMStub([_mockCUPController experimentsWithOrigin:gABTTestOrigin]).andReturn(nil);
+  NSMutableArray<NSData *> *payloads = [[NSMutableArray alloc] init];
+
+  FIRLifecycleEvents *events = [[FIRLifecycleEvents alloc] init];
+  [_experimentController
+      updateExperimentsInBackgroundQueueWithServiceOrigin:gABTTestOrigin
+                                                   events:events
+                                                   policy:
+                                                       ABTExperimentPayload_ExperimentOverflowPolicy_DiscardOldest  // NOLINT
+                                            lastStartTime:-1
+                                                 payloads:payloads];
+}
+@end

+ 7 - 6
README.md

@@ -1,8 +1,8 @@
 # Firebase iOS Open Source Development [![Build Status](https://travis-ci.org/firebase/firebase-ios-sdk.svg?branch=master)](https://travis-ci.org/firebase/firebase-ios-sdk)
 
 This repository contains a subset of the Firebase iOS SDK source. It currently
-includes FirebaseCore, FirebaseAuth, FirebaseDatabase, FirebaseFirestore,
-FirebaseFunctions, FirebaseInstanceID, FirebaseInAppMessaging,
+includes FirebaseCore, FirebaseABTesting, FirebaseAuth, FirebaseDatabase,
+FirebaseFirestore, FirebaseFunctions, FirebaseInstanceID, FirebaseInAppMessaging,
 FirebaseInAppMessagingDisplay, FirebaseMessaging and FirebaseStorage.
 
 The repository also includes GoogleUtilities source. The
@@ -80,9 +80,8 @@ For the pod that you want to develop:
 
 `pod gen Firebase{name here}.podspec --local-sources=./ --auto-open`
 
-Firestore and Functions have self contained Xcode projects. See
-[Firestore/README.md](Firestore/README.md) and
-[Functions/README.md](Functions/README.md).
+Firestore has a self contained Xcode project. See
+[Firestore/README.md](Firestore/README.md).
 
 ### Adding a New Firebase Pod
 
@@ -179,7 +178,8 @@ very grateful!  We'd like to empower as many developers as we can to be able to
 participate in the Firebase community.
 
 ### macOS and tvOS
-Thanks to contributions from the community, FirebaseAuth, FirebaseCore, FirebaseDatabase, FirebaseMessaging,
+Thanks to contributions from the community, FirebaseABTesting, FirebaseAuth, FirebaseCore,
+FirebaseDatabase, FirebaseMessaging,
 FirebaseFirestore, FirebaseFunctions and FirebaseStorage now compile, run unit tests, and work on
 macOS and tvOS.
 
@@ -195,6 +195,7 @@ Note that the Firebase pod is not available for macOS and tvOS.
 To install, add a subset of the following to the Podfile:
 
 ```
+pod 'FirebaseABTesting'
 pod 'FirebaseAuth'
 pod 'FirebaseCore'
 pod 'FirebaseDatabase'

+ 4 - 0
scripts/if_changed.sh

@@ -70,6 +70,10 @@ else
       check_changes '^(Firebase/Core|Example/Core/Tests|GoogleUtilities|FirebaseCore.podspec)'
       ;;
 
+    ABTesting-*)
+      check_changes '^(Firebase/Core|FirebaseABTesting)'
+      ;;
+
     Auth-*)
       check_changes '^(Firebase/Core|Firebase/Auth|Example/Auth|GoogleUtilities|FirebaseAuth.podspec)'
       ;;