Skip to content

Commit 4c776d1

Browse files
authored
Support AB testing of Firebase In-App Messages (#5005)
* Parse out experiment payload for experimental FIAMs and store it on FIRIAMMessageDefinition * Fix bugs in parsing experimental payload * Add nullable experiment payload to FIAM message definition. Add method in FIRExperimenmtController to validate running experiments and call this when new messages are fetched from FIAM backend * Add and document new ABT methods, update FirebaseInAppMessaging.podspec to depend on ABT * Clean up deprecated public message initializers, add experiment payload to private initializers * Activate experiment for experimental messages upon impression * Clean up activateExperiment API * Add experimental payload to all message definition initializers * Add unit test coverage for new ABT methods * Add test coverage for parsing experimental messages * Verify experiment payload gets passed to message superclass * Scripts/style.sh * Update Podfile for UI test app to include AB Testing SDK * Add back deprecated initializers for now, these are needed for the test app * Test for non-nil experiment payload * Use mutable array directly, better memory usage * Bump ABTesting podspec version, depend on new ABT in FIAM * Remove mentions of FIREventOrigins.h * Remove experiment payload from public API, hide it in private * Scripts/style.sh * Fix comment indentation, allow for FIAM to depend on minor version updates of ABTesting * Move ABTExperimentPayload import to private header
1 parent 475c271 commit 4c776d1

20 files changed

+476
-96
lines changed

FirebaseABTesting.podspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
Pod::Spec.new do |s|
22
s.name = 'FirebaseABTesting'
3-
s.version = '3.1.2'
3+
s.version = '3.2.0'
44
s.summary = 'Firebase ABTesting for iOS'
55

66
s.description = <<-DESC

FirebaseABTesting/Sources/FIRExperimentController.m

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,4 +291,51 @@ - (NSTimeInterval)latestExperimentStartTimestampBetweenTimestamp:(NSTimeInterval
291291
}
292292
return timestamp;
293293
}
294+
295+
- (void)validateRunningExperimentsForServiceOrigin:(NSString *)origin
296+
runningExperimentPayloads:(NSArray<ABTExperimentPayload *> *)payloads {
297+
ABTConditionalUserPropertyController *controller =
298+
[ABTConditionalUserPropertyController sharedInstanceWithAnalytics:_analytics];
299+
300+
FIRLifecycleEvents *lifecycleEvents = [[FIRLifecycleEvents alloc] init];
301+
302+
// Get the list of experiments from Firebase Analytics.
303+
NSArray<NSDictionary<NSString *, NSString *> *> *activeExperiments =
304+
[controller experimentsWithOrigin:origin];
305+
306+
NSMutableSet *runningExperimentIDs = [NSMutableSet setWithCapacity:payloads.count];
307+
for (ABTExperimentPayload *payload in payloads) {
308+
[runningExperimentIDs addObject:payload.experimentId];
309+
}
310+
311+
for (NSDictionary<NSString *, NSString *> *activeExperimentDictionary in activeExperiments) {
312+
NSString *experimentID = activeExperimentDictionary[@"name"];
313+
if (![runningExperimentIDs containsObject:experimentID]) {
314+
NSString *variantID = activeExperimentDictionary[@"value"];
315+
316+
[controller clearExperiment:experimentID
317+
variantID:variantID
318+
withOrigin:origin
319+
payload:nil
320+
events:lifecycleEvents];
321+
}
322+
}
323+
}
324+
325+
- (void)activateExperiment:(ABTExperimentPayload *)experimentPayload
326+
forServiceOrigin:(NSString *)origin {
327+
ABTConditionalUserPropertyController *controller =
328+
[ABTConditionalUserPropertyController sharedInstanceWithAnalytics:_analytics];
329+
330+
FIRLifecycleEvents *lifecycleEvents = [[FIRLifecycleEvents alloc] init];
331+
332+
// Ensure that trigger event is nil, which will immediately set the experiment to active.
333+
experimentPayload.triggerEvent = nil;
334+
335+
[controller setExperimentWithOrigin:origin
336+
payload:experimentPayload
337+
events:lifecycleEvents
338+
policy:experimentPayload.overflowPolicy];
339+
}
340+
294341
@end

FirebaseABTesting/Sources/Public/FIRExperimentController.h

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
#import <Foundation/Foundation.h>
1616

17+
@class ABTExperimentPayload;
18+
1719
// Forward declaration to avoid importing into the module header
1820
typedef NS_ENUM(int32_t, ABTExperimentPayload_ExperimentOverflowPolicy);
1921

@@ -37,8 +39,7 @@ NS_SWIFT_NAME(ExperimentController)
3739
/// existing in payloads are not affected, whose state and payload is preserved. This method
3840
/// compares whether the experiments have changed or not by their variant ID. This runs in a
3941
/// background queue and calls the completion handler when finished executing.
40-
/// @param origin The originating service affected by the experiment, it is defined at
41-
/// Firebase Analytics FIREventOrigins.h.
42+
/// @param origin The originating service affected by the experiment.
4243
/// @param events A list of event names to be used for logging experiment lifecycle events,
4344
/// if they are not defined in the payload.
4445
/// @param policy The policy to handle new experiments when slots are full.
@@ -60,8 +61,7 @@ NS_SWIFT_NAME(ExperimentController)
6061
/// existing in payloads are not affected, whose state and payload is preserved. This method
6162
/// compares whether the experiments have changed or not by their variant ID. This runs in a
6263
/// background queue..
63-
/// @param origin The originating service affected by the experiment, it is defined at
64-
/// Firebase Analytics FIREventOrigins.h.
64+
/// @param origin The originating service affected by the experiment.
6565
/// @param events A list of event names to be used for logging experiment lifecycle events,
6666
/// if they are not defined in the payload.
6767
/// @param policy The policy to handle new experiments when slots are full.
@@ -86,6 +86,19 @@ NS_SWIFT_NAME(ExperimentController)
8686
/// @param payloads List of experiment metadata.
8787
- (NSTimeInterval)latestExperimentStartTimestampBetweenTimestamp:(NSTimeInterval)timestamp
8888
andPayloads:(NSArray<NSData *> *)payloads;
89+
90+
/// Expires experiments that aren't in the list of running experiment payloads.
91+
/// @param origin The originating service affected by the experiment.
92+
/// @param payloads The list of valid, running experiments.
93+
- (void)validateRunningExperimentsForServiceOrigin:(NSString *)origin
94+
runningExperimentPayloads:(NSArray<ABTExperimentPayload *> *)payloads;
95+
96+
/// Directly sets a given experiment to be active.
97+
/// @param experimentPayload The payload for the experiment that should be activated.
98+
/// @param origin The originating service affected by the experiment.
99+
- (void)activateExperiment:(ABTExperimentPayload *)experimentPayload
100+
forServiceOrigin:(NSString *)origin;
101+
89102
@end
90103

91104
NS_ASSUME_NONNULL_END

FirebaseABTesting/Tests/Unit/FIRExperimentControllerTest.m

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,4 +389,157 @@ - (void)testUpdateExperimentsWithNoCompletion {
389389

390390
[experimentControllerMock verify];
391391
}
392+
393+
- (void)testValidateRunningExperimentsWithEmptyArray {
394+
NSTimeInterval now = [[NSDate date] timeIntervalSince1970];
395+
396+
ABTExperimentPayload *payload2 = [[ABTExperimentPayload alloc] init];
397+
payload2.experimentId = @"exp_2";
398+
payload2.variantId = @"v200";
399+
payload2.experimentStartTimeMillis =
400+
(now + 1500) * ABT_MSEC_PER_SEC; // start time > last start time, do set
401+
ABTExperimentLite *ongoingExperiment = [[ABTExperimentLite alloc] init];
402+
ongoingExperiment.experimentId = @"exp_1";
403+
[payload2.ongoingExperimentsArray addObject:ongoingExperiment];
404+
405+
ABTExperimentPayload *payload3 = [[ABTExperimentPayload alloc] init];
406+
payload3.experimentId = @"exp_3";
407+
payload3.variantId = @"v200";
408+
payload3.experimentStartTimeMillis =
409+
(now + 900) * ABT_MSEC_PER_SEC; // start time > last start time, do set
410+
ongoingExperiment = [[ABTExperimentLite alloc] init];
411+
ongoingExperiment.experimentId = @"exp_2";
412+
[payload3.ongoingExperimentsArray addObject:ongoingExperiment];
413+
414+
FIRLifecycleEvents *events = [[FIRLifecycleEvents alloc] init];
415+
NSArray *payloads = @[ [payload2 data], [payload3 data] ];
416+
[_experimentController
417+
updateExperimentConditionalUserPropertiesWithServiceOrigin:gABTTestOrigin
418+
events:events
419+
policy:
420+
ABTExperimentPayload_ExperimentOverflowPolicy_DiscardOldest // NOLINT
421+
lastStartTime:now
422+
payloads:payloads
423+
completionHandler:nil];
424+
425+
XCTAssertEqual([_mockCUPController experimentsWithOrigin:gABTTestOrigin].count, 2);
426+
427+
[_experimentController validateRunningExperimentsForServiceOrigin:gABTTestOrigin
428+
runningExperimentPayloads:[NSArray array]];
429+
430+
// Expect all experiments have been cleared.
431+
XCTAssertEqual([_mockCUPController experimentsWithOrigin:gABTTestOrigin].count, 0);
432+
}
433+
434+
- (void)testValidateRunningExperimentsClearingOne {
435+
NSTimeInterval now = [[NSDate date] timeIntervalSince1970];
436+
437+
ABTExperimentPayload *payload2 = [[ABTExperimentPayload alloc] init];
438+
payload2.experimentId = @"exp_2";
439+
payload2.variantId = @"v200";
440+
payload2.experimentStartTimeMillis =
441+
(now + 1500) * ABT_MSEC_PER_SEC; // start time > last start time, do set
442+
ABTExperimentLite *ongoingExperiment = [[ABTExperimentLite alloc] init];
443+
ongoingExperiment.experimentId = @"exp_1";
444+
[payload2.ongoingExperimentsArray addObject:ongoingExperiment];
445+
446+
ABTExperimentPayload *payload3 = [[ABTExperimentPayload alloc] init];
447+
payload3.experimentId = @"exp_3";
448+
payload3.variantId = @"v200";
449+
payload3.experimentStartTimeMillis =
450+
(now + 900) * ABT_MSEC_PER_SEC; // start time > last start time, do set
451+
ongoingExperiment = [[ABTExperimentLite alloc] init];
452+
ongoingExperiment.experimentId = @"exp_2";
453+
[payload3.ongoingExperimentsArray addObject:ongoingExperiment];
454+
455+
FIRLifecycleEvents *events = [[FIRLifecycleEvents alloc] init];
456+
NSArray *payloads = @[ [payload2 data], [payload3 data] ];
457+
[_experimentController
458+
updateExperimentConditionalUserPropertiesWithServiceOrigin:gABTTestOrigin
459+
events:events
460+
policy:
461+
ABTExperimentPayload_ExperimentOverflowPolicy_DiscardOldest // NOLINT
462+
lastStartTime:now
463+
payloads:payloads
464+
completionHandler:nil];
465+
466+
XCTAssertEqual([_mockCUPController experimentsWithOrigin:gABTTestOrigin].count, 2);
467+
468+
ABTExperimentPayload *validatingPayload2 = [[ABTExperimentPayload alloc] init];
469+
validatingPayload2.experimentId = @"exp_2";
470+
validatingPayload2.variantId = @"v200";
471+
472+
[_experimentController validateRunningExperimentsForServiceOrigin:gABTTestOrigin
473+
runningExperimentPayloads:@[ validatingPayload2 ]];
474+
475+
// Expect no experiments have been cleared.
476+
XCTAssertEqual([_mockCUPController experimentsWithOrigin:gABTTestOrigin].count, 1);
477+
}
478+
479+
- (void)testValidateRunningExperimentsKeepingAll {
480+
NSTimeInterval now = [[NSDate date] timeIntervalSince1970];
481+
482+
ABTExperimentPayload *payload2 = [[ABTExperimentPayload alloc] init];
483+
payload2.experimentId = @"exp_2";
484+
payload2.variantId = @"v200";
485+
payload2.experimentStartTimeMillis =
486+
(now + 1500) * ABT_MSEC_PER_SEC; // start time > last start time, do set
487+
ABTExperimentLite *ongoingExperiment = [[ABTExperimentLite alloc] init];
488+
ongoingExperiment.experimentId = @"exp_1";
489+
[payload2.ongoingExperimentsArray addObject:ongoingExperiment];
490+
491+
ABTExperimentPayload *payload3 = [[ABTExperimentPayload alloc] init];
492+
payload3.experimentId = @"exp_3";
493+
payload3.variantId = @"v200";
494+
payload3.experimentStartTimeMillis =
495+
(now + 900) * ABT_MSEC_PER_SEC; // start time > last start time, do set
496+
ongoingExperiment = [[ABTExperimentLite alloc] init];
497+
ongoingExperiment.experimentId = @"exp_2";
498+
[payload3.ongoingExperimentsArray addObject:ongoingExperiment];
499+
500+
FIRLifecycleEvents *events = [[FIRLifecycleEvents alloc] init];
501+
NSArray *payloads = @[ [payload2 data], [payload3 data] ];
502+
[_experimentController
503+
updateExperimentConditionalUserPropertiesWithServiceOrigin:gABTTestOrigin
504+
events:events
505+
policy:
506+
ABTExperimentPayload_ExperimentOverflowPolicy_DiscardOldest // NOLINT
507+
lastStartTime:now
508+
payloads:payloads
509+
completionHandler:nil];
510+
511+
XCTAssertEqual([_mockCUPController experimentsWithOrigin:gABTTestOrigin].count, 2);
512+
513+
ABTExperimentPayload *validatingPayload2 = [[ABTExperimentPayload alloc] init];
514+
validatingPayload2.experimentId = @"exp_2";
515+
validatingPayload2.variantId = @"v200";
516+
517+
ABTExperimentPayload *validatingPayload3 = [[ABTExperimentPayload alloc] init];
518+
validatingPayload3.experimentId = @"exp_3";
519+
validatingPayload3.variantId = @"v200";
520+
521+
[_experimentController
522+
validateRunningExperimentsForServiceOrigin:gABTTestOrigin
523+
runningExperimentPayloads:@[ validatingPayload2, validatingPayload3 ]];
524+
525+
// Expect no experiments have been cleared.
526+
XCTAssertEqual([_mockCUPController experimentsWithOrigin:gABTTestOrigin].count, 2);
527+
}
528+
529+
- (void)testActivateExperiment {
530+
ABTExperimentPayload *activeExperiment = [[ABTExperimentPayload alloc] init];
531+
activeExperiment.experimentId = @"exp_3";
532+
activeExperiment.variantId = @"v200";
533+
activeExperiment.triggerEvent = @"trigger";
534+
535+
[_experimentController activateExperiment:activeExperiment forServiceOrigin:gABTTestOrigin];
536+
537+
NSArray *experiments = [_mockCUPController experimentsWithOrigin:gABTTestOrigin];
538+
539+
FIRAConditionalUserProperty *userPropertyForExperiment = [experiments firstObject];
540+
541+
// Verify that the triggerEventName is cleared, making this experiment active.
542+
XCTAssertNil([userPropertyForExperiment valueForKeyPath:@"triggerEventName"]);
543+
}
544+
392545
@end

FirebaseInAppMessaging.podspec

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ See more product details at https://firebase.google.com/products/in-app-messagin
3535
}
3636

3737
s.pod_target_xcconfig = { 'GCC_PREPROCESSOR_DEFINITIONS' =>
38+
'GPB_USE_PROTOBUF_FRAMEWORK_IMPORTS=1 ' +
3839
'$(inherited) ' +
3940
'FIRInAppMessaging_LIB_VERSION=' + String(s.version) + ' ' +
4041
'PB_FIELD_32BIT=1 PB_NO_PACKED_STRUCTS=1 PB_ENABLE_MALLOC=1'
@@ -44,6 +45,7 @@ See more product details at https://firebase.google.com/products/in-app-messagin
4445
s.ios.dependency 'FirebaseAnalyticsInterop', '~> 1.3'
4546
s.dependency 'FirebaseInstanceID', '~> 4.0'
4647
s.dependency 'GoogleDataTransportCCTSupport', '~> 1.0'
48+
s.dependency 'FirebaseABTesting', '~> 3.2'
4749

4850
s.test_spec 'unit' do |unit_tests|
4951
unit_tests.source_files = 'FirebaseInAppMessaging/Tests/Unit/*.[mh]'

FirebaseInAppMessaging/Sources/Data/FIRIAMFetchResponseParser.m

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
#import "FIRIAMTimeFetcher.h"
2626
#import "UIColor+FIRIAMHexString.h"
2727

28+
#import <FirebaseABTesting/ExperimentPayload.pbobjc.h>
29+
2830
@interface FIRIAMFetchResponseParser ()
2931
@property(nonatomic) id<FIRIAMTimeFetcher> timeFetcher;
3032
@end
@@ -131,40 +133,59 @@ - (FIRIAMMessageDefinition *)convertToMessageDefinitionWithMessageDict:(NSDictio
131133
isTestMessage = [isTestCampaignNode boolValue];
132134
}
133135

134-
id vanillaPayloadNode = messageNode[@"vanillaPayload"];
135-
if (![vanillaPayloadNode isKindOfClass:[NSDictionary class]]) {
136+
id payloadNode = messageNode[@"experimentalPayload"] ?: messageNode[@"vanillaPayload"];
137+
138+
if (![payloadNode isKindOfClass:[NSDictionary class]]) {
136139
FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM900012",
137-
@"vanillaPayload does not exist or does not represent a dictionary in "
140+
@"Message payload does not exist or does not represent a dictionary in "
138141
"message node %@",
139142
messageNode);
140143
return nil;
141144
}
142145

143-
NSString *messageID = vanillaPayloadNode[@"campaignId"];
146+
NSString *messageID = payloadNode[@"campaignId"];
144147
if (!messageID) {
145148
FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM900010",
146149
@"messsage id is missing in message node %@", messageNode);
147150
return nil;
148151
}
149152

150-
NSString *messageName = vanillaPayloadNode[@"campaignName"];
153+
NSString *messageName = payloadNode[@"campaignName"];
151154
if (!messageName && !isTestMessage) {
152155
FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM900011",
153156
@"campaign name is missing in non-test message node %@", messageNode);
154157
return nil;
155158
}
156159

160+
ABTExperimentPayload *experimentPayload = nil;
161+
NSDictionary *experimentPayloadDictionary = payloadNode[@"experimentPayload"];
162+
163+
if (experimentPayloadDictionary) {
164+
experimentPayload = [ABTExperimentPayload message];
165+
experimentPayload.experimentId = experimentPayloadDictionary[@"experimentId"];
166+
experimentPayload.experimentStartTimeMillis =
167+
[experimentPayloadDictionary[@"experimentStartTimeMillis"] integerValue];
168+
// FIAM experiments always use the "discard oldest" overflow policy.
169+
experimentPayload.overflowPolicy =
170+
ABTExperimentPayload_ExperimentOverflowPolicy_DiscardOldest;
171+
experimentPayload.timeToLiveMillis =
172+
[experimentPayloadDictionary[@"timeToLiveMillis"] integerValue];
173+
experimentPayload.triggerTimeoutMillis =
174+
[experimentPayloadDictionary[@"triggerTimeoutMillis"] integerValue];
175+
experimentPayload.variantId = experimentPayloadDictionary[@"variantId"];
176+
}
177+
157178
NSTimeInterval startTimeInSeconds = 0;
158179
NSTimeInterval endTimeInSeconds = 0;
159180
if (!isTestMessage) {
160181
// Parsing start/end times out of non-test messages. They are strings in the
161182
// json response.
162-
id startTimeNode = vanillaPayloadNode[@"campaignStartTimeMillis"];
183+
id startTimeNode = payloadNode[@"campaignStartTimeMillis"];
163184
if ([startTimeNode isKindOfClass:[NSString class]]) {
164185
startTimeInSeconds = [startTimeNode doubleValue] / 1000.0;
165186
}
166187

167-
id endTimeNode = vanillaPayloadNode[@"campaignEndTimeMillis"];
188+
id endTimeNode = payloadNode[@"campaignEndTimeMillis"];
168189
if ([endTimeNode isKindOfClass:[NSString class]]) {
169190
endTimeInSeconds = [endTimeNode doubleValue] / 1000.0;
170191
}
@@ -341,13 +362,15 @@ - (FIRIAMMessageDefinition *)convertToMessageDefinitionWithMessageDict:(NSDictio
341362
dataBundle = dataBundleNode;
342363
}
343364
if (isTestMessage) {
344-
return [[FIRIAMMessageDefinition alloc] initTestMessageWithRenderData:renderData];
365+
return [[FIRIAMMessageDefinition alloc] initTestMessageWithRenderData:renderData
366+
experimentPayload:experimentPayload];
345367
} else {
346368
return [[FIRIAMMessageDefinition alloc] initWithRenderData:renderData
347369
startTime:startTimeInSeconds
348370
endTime:endTimeInSeconds
349371
triggerDefinition:triggersDefinition
350372
appData:dataBundle
373+
experimentPayload:experimentPayload
351374
isTestMessage:NO];
352375
}
353376
} @catch (NSException *e) {

0 commit comments

Comments
 (0)