// 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/Public/FirebaseABTesting/FIRExperimentController.h" #import "FirebaseABTesting/Sources/ABTConditionalUserPropertyController.h" #import "FirebaseABTesting/Sources/ABTConstants.h" #import "FirebaseABTesting/Sources/Private/ABTExperimentPayload.h" #import "FirebaseABTesting/Sources/Public/FirebaseABTesting/FIRLifecycleEvents.h" #import "FirebaseCore/Extension/FirebaseCoreInternal.h" #import "Interop/Analytics/Public/FIRAnalyticsInterop.h" /// Logger Service String. FIRLoggerService kFIRLoggerABTesting = @"[FirebaseABTesting]"; /// Default experiment overflow policy. const ABTExperimentPayloadExperimentOverflowPolicy FIRDefaultExperimentOverflowPolicy = ABTExperimentPayloadExperimentOverflowPolicyDiscardOldest; /// Deserialize the experiment payloads. ABTExperimentPayload *ABTDeserializeExperimentPayload(NSData *payload) { // Verify that we have a JSON object. NSError *error; id JSONObject = [NSJSONSerialization JSONObjectWithData:payload options:kNilOptions error:&error]; if (JSONObject == nil) { FIRLogError(kFIRLoggerABTesting, @"I-ABT000001", @"Failed to parse experiment payload: %@", error.debugDescription); } return [ABTExperimentPayload parseFromData:payload]; } /// 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 cleared given the payloads and current list of /// experiments from Firebase Analytics. If an experiment is in experiments but not in payloads, it /// should be cleared 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"]; } + (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:(ABTExperimentPayloadExperimentOverflowPolicy)policy lastStartTime:(NSTimeInterval)lastStartTime payloads:(NSArray *)payloads completionHandler: (nullable void (^)(NSError *_Nullable error))completionHandler { FIRExperimentController *__weak weakSelf = self; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), ^{ FIRExperimentController *strongSelf = weakSelf; [strongSelf updateExperimentConditionalUserPropertiesWithServiceOrigin:origin events:events policy:policy lastStartTime:lastStartTime payloads:payloads completionHandler:completionHandler]; }); } - (void) updateExperimentConditionalUserPropertiesWithServiceOrigin:(NSString *)origin events:(FIRLifecycleEvents *)events policy: (ABTExperimentPayloadExperimentOverflowPolicy) policy lastStartTime:(NSTimeInterval)lastStartTime payloads:(NSArray *)payloads completionHandler: (nullable void (^)(NSError *_Nullable error)) completionHandler { ABTConditionalUserPropertyController *controller = [ABTConditionalUserPropertyController sharedInstanceWithAnalytics:_analytics]; // Get the list of expriments from Firebase Analytics. NSArray *experiments = [controller experimentsWithOrigin:origin]; if (!experiments) { NSString *errorDescription = @"Failed to get conditional user properties from Firebase Analytics."; FIRLogInfo(kFIRLoggerABTesting, @"I-ABT000003", @"%@", errorDescription); if (completionHandler) { completionHandler([NSError errorWithDomain:kABTErrorDomain code:kABTInternalErrorFailedToFetchConditionalUserProperties userInfo:@{NSLocalizedDescriptionKey : errorDescription}]); } 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); } } if (completionHandler) { completionHandler(nil); } } - (NSTimeInterval)latestExperimentStartTimestampBetweenTimestamp:(NSTimeInterval)timestamp andPayloads:(NSArray *)payloads { for (NSData *payload in [payloads copy]) { 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; } - (void)validateRunningExperimentsForServiceOrigin:(NSString *)origin runningExperimentPayloads:(NSArray *)payloads { ABTConditionalUserPropertyController *controller = [ABTConditionalUserPropertyController sharedInstanceWithAnalytics:_analytics]; FIRLifecycleEvents *lifecycleEvents = [[FIRLifecycleEvents alloc] init]; // Get the list of experiments from Firebase Analytics. NSArray *> *activeExperiments = [controller experimentsWithOrigin:origin]; NSMutableSet *runningExperimentIDs = [NSMutableSet setWithCapacity:payloads.count]; for (ABTExperimentPayload *payload in payloads) { [runningExperimentIDs addObject:payload.experimentId]; } for (NSDictionary *activeExperimentDictionary in activeExperiments) { NSString *experimentID = activeExperimentDictionary[@"name"]; if (![runningExperimentIDs containsObject:experimentID]) { NSString *variantID = activeExperimentDictionary[@"value"]; [controller clearExperiment:experimentID variantID:variantID withOrigin:origin payload:nil events:lifecycleEvents]; } } } - (void)activateExperiment:(ABTExperimentPayload *)experimentPayload forServiceOrigin:(NSString *)origin { ABTConditionalUserPropertyController *controller = [ABTConditionalUserPropertyController sharedInstanceWithAnalytics:_analytics]; FIRLifecycleEvents *lifecycleEvents = [[FIRLifecycleEvents alloc] init]; // Ensure that trigger event is nil, which will immediately set the experiment to active. [experimentPayload clearTriggerEvent]; [controller setExperimentWithOrigin:origin payload:experimentPayload events:lifecycleEvents policy:experimentPayload.overflowPolicy]; } @end