mirror of
https://github.com/firebase/firebase-ios-sdk
synced 2026-11-09 22:36:01 +05:45
Co-authored-by: Nick Cooke <36927374+ncooke3@users.noreply.github.com>
312 lines
13 KiB
Objective-C
312 lines
13 KiB
Objective-C
// 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<ABTExperimentPayload *> *ABTExperimentsToSetFromPayloads(
|
|
NSArray<NSData *> *payloads,
|
|
NSArray<NSDictionary<NSString *, NSString *> *> *experiments,
|
|
id<FIRAnalyticsInterop> _Nullable analytics) {
|
|
NSArray<NSData *> *payloadsCopy = [payloads copy];
|
|
NSArray *experimentsCopy = [experiments copy];
|
|
NSMutableArray *experimentsToSet = [[NSMutableArray alloc] init];
|
|
ABTConditionalUserPropertyController *controller =
|
|
[ABTConditionalUserPropertyController sharedInstanceWithAnalytics:analytics];
|
|
|
|
// Check if the experiment is in payloads but not in experiments.
|
|
for (NSData *payload in payloadsCopy) {
|
|
ABTExperimentPayload *experimentPayload = ABTDeserializeExperimentPayload(payload);
|
|
if (!experimentPayload) {
|
|
FIRLogInfo(kFIRLoggerABTesting, @"I-ABT000002",
|
|
@"Either payload is not set or it cannot be deserialized.");
|
|
continue;
|
|
}
|
|
|
|
BOOL isExperimentSet = NO;
|
|
for (id experiment in experimentsCopy) {
|
|
if ([controller isExperiment:experiment theSameAsPayload:experimentPayload]) {
|
|
isExperimentSet = YES;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!isExperimentSet) {
|
|
[experimentsToSet addObject:experimentPayload];
|
|
}
|
|
}
|
|
return [experimentsToSet copy];
|
|
}
|
|
|
|
/// Returns a list of experiments to be 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<NSData *> *payloads,
|
|
NSArray<NSDictionary<NSString *, NSString *> *> *experiments,
|
|
id<FIRAnalyticsInterop> _Nullable analytics) {
|
|
NSMutableArray *experimentsToClear = [[NSMutableArray alloc] init];
|
|
ABTConditionalUserPropertyController *controller =
|
|
[ABTConditionalUserPropertyController sharedInstanceWithAnalytics:analytics];
|
|
|
|
// Check if the experiment is in experiments but not payloads.
|
|
for (id experiment in [experiments copy]) {
|
|
BOOL doesExperimentNoLongerExist = YES;
|
|
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 ([controller isExperiment:experiment theSameAsPayload:experimentPayload]) {
|
|
doesExperimentNoLongerExist = NO;
|
|
}
|
|
}
|
|
if (doesExperimentNoLongerExist) {
|
|
[experimentsToClear addObject:experiment];
|
|
}
|
|
}
|
|
return experimentsToClear;
|
|
}
|
|
|
|
// ABT doesn't provide any functionality to other components,
|
|
// so it provides a private, empty protocol that it conforms to and use it for registration.
|
|
|
|
@protocol FIRABTInstanceProvider
|
|
@end
|
|
|
|
@interface FIRExperimentController () <FIRABTInstanceProvider, FIRLibrary>
|
|
@property(nonatomic, readwrite, strong) id<FIRAnalyticsInterop> _Nullable analytics;
|
|
@end
|
|
|
|
@implementation FIRExperimentController
|
|
|
|
+ (void)load {
|
|
[FIRApp registerInternalLibrary:(Class<FIRLibrary>)self withName:@"fire-abt"];
|
|
}
|
|
|
|
+ (nonnull NSArray<FIRComponent *> *)componentsToRegister {
|
|
FIRComponentCreationBlock creationBlock =
|
|
^id _Nullable(FIRComponentContainer *container, BOOL *isCacheable) {
|
|
// Ensure it's cached so it returns the same instance every time ABTesting is called.
|
|
*isCacheable = YES;
|
|
id<FIRAnalyticsInterop> analytics = FIR_COMPONENT(FIRAnalyticsInterop, container);
|
|
return [[FIRExperimentController alloc] initWithAnalytics:analytics];
|
|
};
|
|
FIRComponent *abtProvider = [FIRComponent componentWithProtocol:@protocol(FIRABTInstanceProvider)
|
|
instantiationTiming:FIRInstantiationTimingLazy
|
|
creationBlock:creationBlock];
|
|
|
|
return @[ abtProvider ];
|
|
}
|
|
|
|
- (instancetype)initWithAnalytics:(nullable id<FIRAnalyticsInterop>)analytics {
|
|
self = [super init];
|
|
if (self != nil) {
|
|
_analytics = analytics;
|
|
}
|
|
return self;
|
|
}
|
|
|
|
+ (FIRExperimentController *)sharedInstance {
|
|
FIRApp *defaultApp = [FIRApp defaultApp]; // Missing configure will be logged here.
|
|
id<FIRABTInstanceProvider> instance = FIR_COMPONENT(FIRABTInstanceProvider, defaultApp.container);
|
|
|
|
// We know the instance coming from the container is a FIRExperimentController instance, cast it.
|
|
return (FIRExperimentController *)instance;
|
|
}
|
|
|
|
- (void)updateExperimentsWithServiceOrigin:(NSString *)origin
|
|
events:(FIRLifecycleEvents *)events
|
|
policy:(ABTExperimentPayloadExperimentOverflowPolicy)policy
|
|
lastStartTime:(NSTimeInterval)lastStartTime
|
|
payloads:(NSArray<NSData *> *)payloads
|
|
completionHandler:
|
|
(nullable void (^)(NSError *_Nullable error))completionHandler {
|
|
NSArray<NSData *> *payloadsCopy = [payloads copy];
|
|
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:payloadsCopy
|
|
completionHandler:completionHandler];
|
|
});
|
|
}
|
|
|
|
- (void)
|
|
updateExperimentConditionalUserPropertiesWithServiceOrigin:(NSString *)origin
|
|
events:(FIRLifecycleEvents *)events
|
|
policy:
|
|
(ABTExperimentPayloadExperimentOverflowPolicy)
|
|
policy
|
|
lastStartTime:(NSTimeInterval)lastStartTime
|
|
payloads:(NSArray<NSData *> *)payloads
|
|
completionHandler:
|
|
(nullable void (^)(NSError *_Nullable error))
|
|
completionHandler {
|
|
ABTConditionalUserPropertyController *controller =
|
|
[ABTConditionalUserPropertyController sharedInstanceWithAnalytics:_analytics];
|
|
|
|
// Get the list of experiments 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<ABTExperimentPayload *> *experimentsToSet =
|
|
ABTExperimentsToSetFromPayloads(payloads, experiments, _analytics);
|
|
NSArray<NSDictionary<NSString *, NSString *> *> *experimentsToClear =
|
|
ABTExperimentsToClearFromPayloads(payloads, experiments, _analytics);
|
|
|
|
for (id experiment in experimentsToClear) {
|
|
NSString *experimentID = [controller experimentIDOfExperiment:experiment];
|
|
NSString *variantID = [controller variantIDOfExperiment:experiment];
|
|
[controller clearExperiment:experimentID
|
|
variantID:variantID
|
|
withOrigin:origin
|
|
payload:nil
|
|
events:events];
|
|
}
|
|
|
|
for (ABTExperimentPayload *experimentPayload in experimentsToSet) {
|
|
if (experimentPayload.experimentStartTimeMillis > lastStartTime * ABT_MSEC_PER_SEC) {
|
|
[controller setExperimentWithOrigin:origin
|
|
payload:experimentPayload
|
|
events:events
|
|
policy:policy];
|
|
FIRLogInfo(kFIRLoggerABTesting, @"I-ABT000008",
|
|
@"Set Experiment ID %@, variant ID %@ to Firebase Analytics.",
|
|
experimentPayload.experimentId, experimentPayload.variantId);
|
|
|
|
} else {
|
|
FIRLogInfo(kFIRLoggerABTesting, @"I-ABT000009",
|
|
@"Not setting experiment ID %@, variant ID %@ due to the last update time %lld.",
|
|
experimentPayload.experimentId, experimentPayload.variantId,
|
|
(long)lastStartTime * ABT_MSEC_PER_SEC);
|
|
}
|
|
}
|
|
|
|
if (completionHandler) {
|
|
completionHandler(nil);
|
|
}
|
|
}
|
|
|
|
- (NSTimeInterval)latestExperimentStartTimestampBetweenTimestamp:(NSTimeInterval)timestamp
|
|
andPayloads:(NSArray<NSData *> *)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<ABTExperimentPayload *> *)payloads {
|
|
ABTConditionalUserPropertyController *controller =
|
|
[ABTConditionalUserPropertyController sharedInstanceWithAnalytics:_analytics];
|
|
|
|
FIRLifecycleEvents *lifecycleEvents = [[FIRLifecycleEvents alloc] init];
|
|
|
|
// Get the list of experiments from Firebase Analytics.
|
|
NSArray<NSDictionary<NSString *, NSString *> *> *activeExperiments =
|
|
[controller experimentsWithOrigin:origin];
|
|
|
|
NSMutableSet *runningExperimentIDs = [NSMutableSet setWithCapacity:payloads.count];
|
|
for (ABTExperimentPayload *payload in payloads) {
|
|
[runningExperimentIDs addObject:payload.experimentId];
|
|
}
|
|
|
|
for (NSDictionary<NSString *, NSString *> *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
|