diff --git a/.travis.yml b/.travis.yml index f9ec6a9c07..508bc489c2 100644 --- a/.travis.yml +++ b/.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: diff --git a/FirebaseABTesting.podspec b/FirebaseABTesting.podspec new file mode 100644 index 0000000000..36a4d56769 --- /dev/null +++ b/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 diff --git a/FirebaseABTesting/README.md b/FirebaseABTesting/README.md new file mode 100644 index 0000000000..45323dffa2 --- /dev/null +++ b/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. diff --git a/FirebaseABTesting/Sources/ABTConditionalUserPropertyController.h b/FirebaseABTesting/Sources/ABTConditionalUserPropertyController.h new file mode 100644 index 0000000000..7ff4b35df2 --- /dev/null +++ b/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 + +#import "FirebaseABTesting/Sources/Protos/developers/mobile/abt/proto/ExperimentPayload.pbobjc.h" + +#import + +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 _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 diff --git a/FirebaseABTesting/Sources/ABTConditionalUserPropertyController.m b/FirebaseABTesting/Sources/ABTConditionalUserPropertyController.m new file mode 100644 index 0000000000..40e5214ab0 --- /dev/null +++ b/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 +#import +#import +#import "FirebaseABTesting/Sources/ABTConstants.h" + +@implementation ABTConditionalUserPropertyController { + dispatch_queue_t _analyticOperationQueue; + id _Nullable _analytics; +} + +/// Returns the ABTConditionalUserPropertyController singleton. ++ (instancetype)sharedInstanceWithAnalytics:(id _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 _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 *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 *)createExperimentFromOrigin:(NSString *)origin + payload:(ABTExperimentPayload *)payload + events:(FIRLifecycleEvents *)events { + NSMutableDictionary *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 *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 *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 *expiredEvent = [self eventDictionaryWithOrigin:origin + eventName:expiredEventName + params:eventParams]; + [experiment setValue:expiredEvent forKey:kABTExperimentDictionaryExpiredEventKey]; + return experiment; +} + +- (NSDictionary *) + eventDictionaryWithOrigin:(nonnull NSString *)origin + eventName:(nonnull NSString *)eventName + params:(nonnull NSDictionary *)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 *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 diff --git a/FirebaseABTesting/Sources/ABTConstants.h b/FirebaseABTesting/Sources/ABTConstants.h new file mode 100644 index 0000000000..2acbbb2d1d --- /dev/null +++ b/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"; diff --git a/FirebaseABTesting/Sources/FIRExperimentController.m b/FirebaseABTesting/Sources/FIRExperimentController.m new file mode 100644 index 0000000000..455322410c --- /dev/null +++ b/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 + +#import +#import +#import "FirebaseABTesting/Sources/ABTConditionalUserPropertyController.h" +#import "FirebaseABTesting/Sources/ABTConstants.h" + +#import +#import +#import +#import +#import +#import + +#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 *ABTExperimentsToSetFromPayloads( + NSArray *payloads, + NSArray *> *experiments, + id _Nullable analytics) { + NSArray *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 *payloads, + NSArray *> *experiments, + id _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 () +@property(nonatomic, readwrite, strong) id _Nullable analytics; +@end + +@implementation FIRExperimentController + ++ (void)load { + [FIRApp registerInternalLibrary:(Class)self + withName:@"fire-abt" + withVersion:[NSString stringWithUTF8String:STR(FIRABTesting_VERSION)]]; +} + ++ (nonnull NSArray *)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 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)analytics { + self = [super init]; + if (self != nil) { + _analytics = analytics; + } + return self; +} + ++ (FIRExperimentController *)sharedInstance { + FIRApp *defaultApp = [FIRApp defaultApp]; // Missing configure will be logged here. + id 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 *)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 *)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 *experimentsToSet = + ABTExperimentsToSetFromPayloads(payloads, experiments, _analytics); + NSArray *> *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 *)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 diff --git a/FirebaseABTesting/Sources/FIRLifecycleEvents.m b/FirebaseABTesting/Sources/FIRLifecycleEvents.m new file mode 100644 index 0000000000..7a2e5393ba --- /dev/null +++ b/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 + +#import + +/// 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 diff --git a/FirebaseABTesting/Sources/Protos/PortableProtoFilterTemplate.asciipb b/FirebaseABTesting/Sources/Protos/PortableProtoFilterTemplate.asciipb new file mode 100644 index 0000000000..5e858c58d9 --- /dev/null +++ b/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" diff --git a/FirebaseABTesting/Sources/Protos/developers/mobile/abt/proto/ExperimentPayload.pbobjc.h b/FirebaseABTesting/Sources/Protos/developers/mobile/abt/proto/ExperimentPayload.pbobjc.h new file mode 100644 index 0000000000..3fba2c4a36 --- /dev/null +++ b/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 +#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 *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) diff --git a/FirebaseABTesting/Sources/Protos/developers/mobile/abt/proto/ExperimentPayload.pbobjc.m b/FirebaseABTesting/Sources/Protos/developers/mobile/abt/proto/ExperimentPayload.pbobjc.m new file mode 100644 index 0000000000..5819293144 --- /dev/null +++ b/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 +#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) diff --git a/FirebaseABTesting/Sources/Public/FIRExperimentController.h b/FirebaseABTesting/Sources/Public/FIRExperimentController.h new file mode 100644 index 0000000000..907119d3f7 --- /dev/null +++ b/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 + +// 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 *)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 *)payloads; +@end + +NS_ASSUME_NONNULL_END diff --git a/FirebaseABTesting/Sources/Public/FIRLifecycleEvents.h b/FirebaseABTesting/Sources/Public/FIRLifecycleEvents.h new file mode 100644 index 0000000000..46b29a0a7c --- /dev/null +++ b/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 + +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 diff --git a/FirebaseABTesting/Sources/Public/FirebaseABTesting.h b/FirebaseABTesting/Sources/Public/FirebaseABTesting.h new file mode 100755 index 0000000000..cfa99b4aca --- /dev/null +++ b/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" diff --git a/FirebaseABTesting/Tests/Unit/ABTConditionalUserPropertyControllerTest.m b/FirebaseABTesting/Tests/Unit/ABTConditionalUserPropertyControllerTest.m new file mode 100644 index 0000000000..348a7d54a2 --- /dev/null +++ b/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 + +#import +#import +#import +#import +#import +#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)analytics; +@end + +typedef void (^FakeAnalyticsLogEventWithOriginNameParametersHandler)( + NSString *origin, NSString *name, NSDictionary *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 *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 *experiment = + [_ABTCUPController createExperimentFromOrigin:gABTTestOrigin payload:payload events:events]; + + NSDictionary *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 *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 *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 *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 diff --git a/FirebaseABTesting/Tests/Unit/ABTFakeFIRAConditionalUserPropertyController.h b/FirebaseABTesting/Tests/Unit/ABTFakeFIRAConditionalUserPropertyController.h new file mode 100644 index 0000000000..d87e1e2f28 --- /dev/null +++ b/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 + +#ifdef COCOAPODS +#import +#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 *)conditionalUserProperty; +- (void)clearConditionalUserPropertyWithName:(NSString *)conditionalUserPropertyName; +- (NSArray *) + conditionalUserPropertiesWithNamePrefix:(NSString *)namePrefix + filterByOrigin:(NSString *)origin; +- (NSInteger)maxUserPropertiesForOrigin:(NSString *)origin; +- (void)resetExperiments; +@end + +@interface FakeAnalytics : NSObject { + ABTFakeFIRAConditionalUserPropertyController *_fakeController; +} +- (instancetype)initWithFakeController: + (ABTFakeFIRAConditionalUserPropertyController *)fakeController; +@end diff --git a/FirebaseABTesting/Tests/Unit/ABTFakeFIRAConditionalUserPropertyController.m b/FirebaseABTesting/Tests/Unit/ABTFakeFIRAConditionalUserPropertyController.m new file mode 100644 index 0000000000..4de3f2bb27 --- /dev/null +++ b/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 *> *_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 *)cupProperties { + [_experiments addObject:cupProperties]; +} + +- (void)clearConditionalUserPropertyWithName:(NSString *)conditionalUserPropertyName { + for (NSDictionary *experiment in _experiments) { + if ([experiment[@"name"] isEqualToString:conditionalUserPropertyName]) { + [_experiments removeObject:experiment]; + return; + } + } +} + +- (NSArray *) + 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 *) + 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 *)clearEventParameters { + [_fakeController clearConditionalUserPropertyWithName:userPropertyName]; +} + +- (void)setConditionalUserProperty:(nonnull NSDictionary *)conditionalUserProperty { + [_fakeController setConditionalUserProperty:conditionalUserProperty]; +} + +- (NSInteger)maxUserProperties:(nonnull NSString *)origin { + return 3; +} + +- (void)setConditionalUserPropertyControllerProperties:(NSDictionary *)properties { + for (NSString *key in properties) { + [[ABTFakeFIRAConditionalUserPropertyController sharedInstance] + setValue:[properties objectForKey:key] + forKey:key]; + } +} + +- (FIRAEvent *)eventWithOrigin:(NSString *)origin + eventName:(NSString *)eventName + params:(NSDictionary *)params { + return nil; +} + +// Stubs +- (void)logEventWithOrigin:(nonnull NSString *)origin + name:(nonnull NSString *)name + parameters:(nullable NSDictionary *)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)listener + withOrigin:(nonnull NSString *)origin { +} + +- (void)unregisterAnalyticsListenerWithOrigin:(nonnull NSString *)origin { +} + +@end diff --git a/FirebaseABTesting/Tests/Unit/ABTTestUniversalConstants.h b/FirebaseABTesting/Tests/Unit/ABTTestUniversalConstants.h new file mode 100644 index 0000000000..efb6952b58 --- /dev/null +++ b/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 + +extern NSString *const gABTTestOrigin; diff --git a/FirebaseABTesting/Tests/Unit/ABTTestUniversalConstants.m b/FirebaseABTesting/Tests/Unit/ABTTestUniversalConstants.m new file mode 100644 index 0000000000..5a0efb5781 --- /dev/null +++ b/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"; diff --git a/FirebaseABTesting/Tests/Unit/FIRExperimentControllerTest.m b/FirebaseABTesting/Tests/Unit/FIRExperimentControllerTest.m new file mode 100644 index 0000000000..bfeccbd48b --- /dev/null +++ b/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 + +#import +#import +#import +#import +#import +#import +#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 *ABTExperimentsToSetFromPayloads( + NSArray *payloads, + NSArray *> *experiments, + id _Nullable analytics); +extern NSArray *ABTExperimentsToClearFromPayloads( + NSArray *payloads, + NSArray *> *experiments, + id _Nullable analytics); + +@interface FIRExperimentController (ExposedForTest) +- (void) + updateExperimentsInBackgroundQueueWithServiceOrigin:(NSString *)origin + events:(FIRLifecycleEvents *)events + policy: + (ABTExperimentPayload_ExperimentOverflowPolicy) + policy + lastStartTime:(NSTimeInterval)lastStartTime + payloads:(NSArray *)payloads; + +/// Surface internal initializer to avoid singleton usage during tests. +- (instancetype)initWithAnalytics:(nullable id)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 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 *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 *CUP1 = @{@"name" : @"exp_1", @"value" : @"v1"}; + [currentExperiments addObject:CUP1]; + + NSDictionary *CUP2 = @{@"name" : @"exp_2", @"value" : @"v2"}; + [currentExperiments addObject:CUP2]; + + // Mock incoming payloads + NSMutableArray *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 *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 *CUP1 = @{@"name" : @"exp_1", @"value" : @"v1"}; + [currentExperiments addObject:CUP1]; + + NSDictionary *CUP2 = @{@"name" : @"exp_2", @"value" : @"v2"}; + [currentExperiments addObject:CUP2]; + + // Mock incoming payloads + NSMutableArray *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 *> *experimentsToClear = + ABTExperimentsToClearFromPayloads(payloads, currentExperiments, nil); + + XCTAssertEqual(experimentsToClear.count, 1); + NSDictionary *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 *payloads = [[NSMutableArray alloc] init]; + + FIRLifecycleEvents *events = [[FIRLifecycleEvents alloc] init]; + [_experimentController + updateExperimentsInBackgroundQueueWithServiceOrigin:gABTTestOrigin + events:events + policy: + ABTExperimentPayload_ExperimentOverflowPolicy_DiscardOldest // NOLINT + lastStartTime:-1 + payloads:payloads]; +} +@end diff --git a/README.md b/README.md index bf397f0578..d75ae8cb12 100644 --- a/README.md +++ b/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' diff --git a/scripts/if_changed.sh b/scripts/if_changed.sh index f92e51c08c..5116e8c97b 100755 --- a/scripts/if_changed.sh +++ b/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)' ;;