Files
firebase-ios-sdk/FirebaseInAppMessaging/Sources/Data/FIRIAMFetchResponseParser.m
T

452 lines
20 KiB
Objective-C

/*
* Copyright 2017 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 <TargetConditionals.h>
#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
#import "FirebaseCore/Extension/FirebaseCoreInternal.h"
#import "FirebaseInAppMessaging/Sources/FIRCore+InAppMessaging.h"
#import "FirebaseInAppMessaging/Sources/Private/Data/FIRIAMFetchResponseParser.h"
#import "FirebaseInAppMessaging/Sources/Private/Data/FIRIAMMessageContentData.h"
#import "FirebaseInAppMessaging/Sources/Private/Data/FIRIAMMessageContentDataWithImageURL.h"
#import "FirebaseInAppMessaging/Sources/Private/Data/FIRIAMMessageDefinition.h"
#import "FirebaseInAppMessaging/Sources/Private/DisplayTrigger/FIRIAMDisplayTriggerDefinition.h"
#import "FirebaseInAppMessaging/Sources/Private/Util/FIRIAMTimeFetcher.h"
#import "FirebaseInAppMessaging/Sources/Util/UIColor+FIRIAMHexString.h"
#import "FirebaseABTesting/Sources/Private/ABTExperimentPayload.h"
@interface FIRIAMFetchResponseParser ()
@property(nonatomic) id<FIRIAMTimeFetcher> timeFetcher;
@end
// Returns `value` if it is an NSString, nil otherwise. Guards against server values of an
// unexpected type being stored as strings and crashing later outside of the parser.
static NSString *_Nullable FIRIAMStringOrNil(id _Nullable value) {
return [value isKindOfClass:[NSString class]] ? value : nil;
}
// Returns `value` if it is an NSDictionary, nil otherwise. Used to safely walk nested nodes so a
// wrong-typed intermediate node (e.g. NSNull or NSString) drops just that field rather than raising
// an unrecognized selector exception that discards the whole message.
static NSDictionary *_Nullable FIRIAMDictionaryOrNil(id _Nullable value) {
return [value isKindOfClass:[NSDictionary class]] ? value : nil;
}
@implementation FIRIAMFetchResponseParser
- (instancetype)initWithTimeFetcher:(id<FIRIAMTimeFetcher>)timeFetcher {
if (self = [super init]) {
_timeFetcher = timeFetcher;
}
return self;
}
- (nullable NSArray<FIRIAMMessageDefinition *> *)
parseAPIResponseDictionary:(NSDictionary *)responseDict
discardedMsgCount:(NSInteger *)discardCount
fetchWaitTimeInSeconds:(NSNumber **)fetchWaitTime {
if (fetchWaitTime != nil) {
*fetchWaitTime = nil; // It would be set to non nil value if it's detected in responseDict
}
if (![responseDict isKindOfClass:[NSDictionary class]]) {
FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM900019", @"API response is not a dictionary: %@",
responseDict);
return nil;
}
if (fetchWaitTime != nil) {
if ([responseDict[@"expirationEpochTimestampMillis"] isKindOfClass:NSString.class]) {
NSTimeInterval nextFetchTimeInResponse =
[responseDict[@"expirationEpochTimestampMillis"] doubleValue] / 1000;
NSTimeInterval fetchWaitTimeInSeconds =
nextFetchTimeInResponse - [self.timeFetcher currentTimestampInSeconds];
FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM900005",
@"Detected next fetch epoch time in API response as %f seconds and wait for %f "
"seconds before next fetch.",
nextFetchTimeInResponse, fetchWaitTimeInSeconds);
if (fetchWaitTimeInSeconds > 0.01) {
*fetchWaitTime = @(fetchWaitTimeInSeconds);
FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM900018",
@"Fetch wait time calculated from server response is negative. Discard it.");
}
} else {
FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM900014",
@"No fetch epoch time detected in API response.");
}
}
NSArray<NSDictionary *> *messageArray = responseDict[@"messages"];
if (messageArray && ![messageArray isKindOfClass:[NSArray class]]) {
FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM900020",
@"messages node in API response is not an array: %@", messageArray);
return nil;
}
NSInteger discarded = 0;
NSMutableArray<FIRIAMMessageDefinition *> *definitions = [[NSMutableArray alloc] init];
for (NSDictionary *nextMsg in messageArray) {
FIRIAMMessageDefinition *nextDefinition =
[nextMsg isKindOfClass:[NSDictionary class]]
? [self convertToMessageDefinitionWithMessageDict:nextMsg]
: nil;
if (nextDefinition) {
[definitions addObject:nextDefinition];
} else {
FIRLogInfo(kFIRLoggerInAppMessaging, @"I-IAM900001",
@"No definition generated for message node %@", nextMsg);
discarded++;
}
}
FIRLogDebug(
kFIRLoggerInAppMessaging, @"I-IAM900002",
@"%lu message definitions were parsed out successfully and %lu messages are discarded",
(unsigned long)definitions.count, (unsigned long)discarded);
if (discardCount) {
*discardCount = discarded;
}
return [definitions copy];
}
// Return nil if no valid triggering condition can be detected
- (NSArray<FIRIAMDisplayTriggerDefinition *> *)parseTriggeringCondition:
(NSArray<NSDictionary *> *)triggerConditions {
if (![triggerConditions isKindOfClass:[NSArray class]] || triggerConditions.count == 0) {
return nil;
}
NSMutableArray<FIRIAMDisplayTriggerDefinition *> *triggers = [[NSMutableArray alloc] init];
for (NSDictionary *nextTriggerCondition in triggerConditions) {
if (![nextTriggerCondition isKindOfClass:[NSDictionary class]]) {
continue;
}
// Handle app_launch and on_foreground cases.
if (nextTriggerCondition[@"fiamTrigger"]) {
NSString *fiamTrigger = FIRIAMStringOrNil(nextTriggerCondition[@"fiamTrigger"]);
if ([fiamTrigger isEqualToString:@"ON_FOREGROUND"]) {
[triggers addObject:[[FIRIAMDisplayTriggerDefinition alloc] initForAppForegroundTrigger]];
} else if ([fiamTrigger isEqualToString:@"APP_LAUNCH"]) {
[triggers addObject:[[FIRIAMDisplayTriggerDefinition alloc] initForAppLaunchTrigger]];
}
} else if ([nextTriggerCondition[@"event"] isKindOfClass:[NSDictionary class]]) {
NSDictionary *triggeringEvent = (NSDictionary *)nextTriggerCondition[@"event"];
NSString *eventName = FIRIAMStringOrNil(triggeringEvent[@"name"]);
if (eventName.length > 0) {
[triggers addObject:[[FIRIAMDisplayTriggerDefinition alloc]
initWithFirebaseAnalyticEvent:eventName]];
}
}
}
return [triggers copy];
}
// For one element in the restful API response's messages array, convert into
// a FIRIAMMessageDefinition object. If the conversion fails, a nil is returned.
- (FIRIAMMessageDefinition *)convertToMessageDefinitionWithMessageDict:(NSDictionary *)messageNode {
@try {
BOOL isTestMessage = NO;
id isTestCampaignNode = messageNode[@"isTestCampaign"];
if ([isTestCampaignNode isKindOfClass:[NSNumber class]]) {
isTestMessage = [isTestCampaignNode boolValue];
}
id payloadNode = messageNode[@"experimentalPayload"] ?: messageNode[@"vanillaPayload"];
if (![payloadNode isKindOfClass:[NSDictionary class]]) {
FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM900012",
@"Message payload does not exist or does not represent a dictionary in "
"message node %@",
messageNode);
return nil;
}
NSString *messageID = FIRIAMStringOrNil(payloadNode[@"campaignId"]);
if (messageID.length == 0) {
FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM900010",
@"message id is missing in message node %@", messageNode);
return nil;
}
NSString *messageName = FIRIAMStringOrNil(payloadNode[@"campaignName"]);
if (!messageName && !isTestMessage) {
FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM900011",
@"campaign name is missing in non-test message node %@", messageNode);
return nil;
}
ABTExperimentPayload *experimentPayload = nil;
NSDictionary *experimentPayloadDictionary = payloadNode[@"experimentPayload"];
if ([experimentPayloadDictionary isKindOfClass:[NSDictionary class]]) {
experimentPayload =
[[ABTExperimentPayload alloc] initWithDictionary:experimentPayloadDictionary];
}
NSTimeInterval startTimeInSeconds = 0;
NSTimeInterval endTimeInSeconds = 0;
if (!isTestMessage) {
// Parsing start/end times out of non-test messages. They are strings in the
// json response.
id startTimeNode = payloadNode[@"campaignStartTimeMillis"];
if ([startTimeNode isKindOfClass:[NSString class]]) {
startTimeInSeconds = [startTimeNode doubleValue] / 1000.0;
}
id endTimeNode = payloadNode[@"campaignEndTimeMillis"];
if ([endTimeNode isKindOfClass:[NSString class]]) {
endTimeInSeconds = [endTimeNode doubleValue] / 1000.0;
}
}
id contentNode = messageNode[@"content"];
if (![contentNode isKindOfClass:[NSDictionary class]]) {
FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM900013",
@"content node does not exist or does not represent a dictionary in "
"message node %@",
messageNode);
return nil;
}
NSDictionary *content = (NSDictionary *)contentNode;
FIRIAMRenderingMode mode;
UIColor *viewCardBackgroundColor, *btnBgColor, *btnTxtColor, *secondaryBtnTxtColor,
*titleTextColor;
viewCardBackgroundColor = btnBgColor = btnTxtColor = titleTextColor = nil;
NSString *title, *body, *imageURLStr, *landscapeImageURLStr, *actionURLStr,
*secondaryActionURLStr, *actionButtonText, *secondaryActionButtonText;
title = body = imageURLStr = landscapeImageURLStr = actionButtonText =
secondaryActionButtonText = actionURLStr = secondaryActionURLStr = nil;
// TODO: Refactor this giant if-else block into separate parsing methods per message type.
if ([content[@"banner"] isKindOfClass:[NSDictionary class]]) {
NSDictionary *bannerNode = (NSDictionary *)contentNode[@"banner"];
mode = FIRIAMRenderAsBannerView;
NSDictionary *titleNode = FIRIAMDictionaryOrNil(bannerNode[@"title"]);
title = FIRIAMStringOrNil(titleNode[@"text"]);
titleTextColor = [UIColor firiam_colorWithHexString:titleNode[@"hexColor"]];
NSDictionary *bodyNode = FIRIAMDictionaryOrNil(bannerNode[@"body"]);
body = FIRIAMStringOrNil(bodyNode[@"text"]);
imageURLStr = FIRIAMStringOrNil(bannerNode[@"imageUrl"]);
NSDictionary *actionNode = FIRIAMDictionaryOrNil(bannerNode[@"action"]);
actionURLStr = FIRIAMStringOrNil(actionNode[@"actionUrl"]);
viewCardBackgroundColor =
[UIColor firiam_colorWithHexString:bannerNode[@"backgroundHexColor"]];
} else if ([content[@"modal"] isKindOfClass:[NSDictionary class]]) {
mode = FIRIAMRenderAsModalView;
NSDictionary *modalNode = (NSDictionary *)contentNode[@"modal"];
NSDictionary *titleNode = FIRIAMDictionaryOrNil(modalNode[@"title"]);
title = FIRIAMStringOrNil(titleNode[@"text"]);
titleTextColor = [UIColor firiam_colorWithHexString:titleNode[@"hexColor"]];
NSDictionary *bodyNode = FIRIAMDictionaryOrNil(modalNode[@"body"]);
body = FIRIAMStringOrNil(bodyNode[@"text"]);
imageURLStr = FIRIAMStringOrNil(modalNode[@"imageUrl"]);
NSDictionary *actionButtonNode = FIRIAMDictionaryOrNil(modalNode[@"actionButton"]);
NSDictionary *actionButtonTextNode = FIRIAMDictionaryOrNil(actionButtonNode[@"text"]);
actionButtonText = FIRIAMStringOrNil(actionButtonTextNode[@"text"]);
btnTxtColor = [UIColor firiam_colorWithHexString:actionButtonTextNode[@"hexColor"]];
btnBgColor = [UIColor firiam_colorWithHexString:actionButtonNode[@"buttonHexColor"]];
NSDictionary *actionNode = FIRIAMDictionaryOrNil(modalNode[@"action"]);
actionURLStr = FIRIAMStringOrNil(actionNode[@"actionUrl"]);
viewCardBackgroundColor =
[UIColor firiam_colorWithHexString:modalNode[@"backgroundHexColor"]];
} else if ([content[@"imageOnly"] isKindOfClass:[NSDictionary class]]) {
mode = FIRIAMRenderAsImageOnlyView;
NSDictionary *imageOnlyNode = (NSDictionary *)contentNode[@"imageOnly"];
imageURLStr = FIRIAMStringOrNil(imageOnlyNode[@"imageUrl"]);
if (!imageURLStr) {
FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM900007",
@"Image url is missing for image-only message %@", messageNode);
return nil;
}
NSDictionary *actionNode = FIRIAMDictionaryOrNil(imageOnlyNode[@"action"]);
actionURLStr = FIRIAMStringOrNil(actionNode[@"actionUrl"]);
} else if ([content[@"card"] isKindOfClass:[NSDictionary class]]) {
mode = FIRIAMRenderAsCardView;
NSDictionary *cardNode = (NSDictionary *)contentNode[@"card"];
NSDictionary *titleNode = FIRIAMDictionaryOrNil(cardNode[@"title"]);
title = FIRIAMStringOrNil(titleNode[@"text"]);
titleTextColor = [UIColor firiam_colorWithHexString:titleNode[@"hexColor"]];
NSDictionary *bodyNode = FIRIAMDictionaryOrNil(cardNode[@"body"]);
body = FIRIAMStringOrNil(bodyNode[@"text"]);
imageURLStr = FIRIAMStringOrNil(cardNode[@"portraitImageUrl"]);
landscapeImageURLStr = FIRIAMStringOrNil(cardNode[@"landscapeImageUrl"]);
viewCardBackgroundColor = [UIColor firiam_colorWithHexString:cardNode[@"backgroundHexColor"]];
NSDictionary *primaryButtonNode = FIRIAMDictionaryOrNil(cardNode[@"primaryActionButton"]);
NSDictionary *primaryButtonTextNode = FIRIAMDictionaryOrNil(primaryButtonNode[@"text"]);
actionButtonText = FIRIAMStringOrNil(primaryButtonTextNode[@"text"]);
btnTxtColor = [UIColor firiam_colorWithHexString:primaryButtonTextNode[@"hexColor"]];
NSDictionary *secondaryButtonNode = FIRIAMDictionaryOrNil(cardNode[@"secondaryActionButton"]);
NSDictionary *secondaryButtonTextNode = FIRIAMDictionaryOrNil(secondaryButtonNode[@"text"]);
secondaryActionButtonText = FIRIAMStringOrNil(secondaryButtonTextNode[@"text"]);
secondaryBtnTxtColor =
[UIColor firiam_colorWithHexString:secondaryButtonTextNode[@"hexColor"]];
NSDictionary *primaryActionNode = FIRIAMDictionaryOrNil(cardNode[@"primaryAction"]);
actionURLStr = FIRIAMStringOrNil(primaryActionNode[@"actionUrl"]);
NSDictionary *secondaryActionNode = FIRIAMDictionaryOrNil(cardNode[@"secondaryAction"]);
secondaryActionURLStr = FIRIAMStringOrNil(secondaryActionNode[@"actionUrl"]);
} else {
// Unknown message type
FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM900003",
@"Unknown message type in message node %@", messageNode);
return nil;
}
if (title == nil && mode != FIRIAMRenderAsImageOnlyView) {
FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM900004",
@"Title text is missing in message node %@", messageNode);
return nil;
}
NSURL *imageURL = [self imageURLFromURLString:imageURLStr];
NSURL *landscapeImageURL = [self imageURLFromURLString:landscapeImageURLStr];
NSURL *actionURL = [self urlFromURLString:actionURLStr];
NSURL *secondaryActionURL = [self urlFromURLString:secondaryActionURLStr];
FIRIAMRenderingEffectSetting *renderEffect =
[FIRIAMRenderingEffectSetting getDefaultRenderingEffectSetting];
renderEffect.viewMode = mode;
if (viewCardBackgroundColor) {
renderEffect.displayBGColor = viewCardBackgroundColor;
}
if (btnBgColor) {
renderEffect.btnBGColor = btnBgColor;
}
if (btnTxtColor) {
renderEffect.btnTextColor = btnTxtColor;
}
if (secondaryBtnTxtColor) {
renderEffect.secondaryActionBtnTextColor = secondaryBtnTxtColor;
}
if (titleTextColor) {
renderEffect.textColor = titleTextColor;
}
NSArray<FIRIAMDisplayTriggerDefinition *> *triggersDefinition =
[self parseTriggeringCondition:messageNode[@"triggeringConditions"]];
if (isTestMessage) {
FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM900008",
@"A test message with id %@ was parsed successfully.", messageID);
renderEffect.isTestMessage = YES;
} else {
// Triggering definitions should always be present for a non-test message.
if (!triggersDefinition || triggersDefinition.count == 0) {
FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM900009",
@"No valid triggering condition is detected in message definition"
" with id %@",
messageID);
return nil;
}
}
FIRIAMMessageContentDataWithImageURL *msgData =
[[FIRIAMMessageContentDataWithImageURL alloc] initWithMessageTitle:title
messageBody:body
actionButtonText:actionButtonText
secondaryActionButtonText:secondaryActionButtonText
actionURL:actionURL
secondaryActionURL:secondaryActionURL
imageURL:imageURL
landscapeImageURL:landscapeImageURL
usingURLSession:nil];
FIRIAMMessageRenderData *renderData =
[[FIRIAMMessageRenderData alloc] initWithMessageID:messageID
messageName:messageName
contentData:msgData
renderingEffect:renderEffect];
NSDictionary *dataBundle = nil;
id dataBundleNode = messageNode[@"dataBundle"];
if ([dataBundleNode isKindOfClass:[NSDictionary class]]) {
dataBundle = dataBundleNode;
}
if (isTestMessage) {
return [[FIRIAMMessageDefinition alloc] initTestMessageWithRenderData:renderData
appData:dataBundle
experimentPayload:experimentPayload];
} else {
return [[FIRIAMMessageDefinition alloc] initWithRenderData:renderData
startTime:startTimeInSeconds
endTime:endTimeInSeconds
triggerDefinition:triggersDefinition
appData:dataBundle
experimentPayload:experimentPayload
isTestMessage:NO];
}
} @catch (NSException *e) {
FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM900006",
@"Error in parsing message node %@ "
"with error %@",
messageNode, e);
return nil;
}
}
- (nullable NSURL *)imageURLFromURLString:(NSString *)string {
NSURL *url = [self urlFromURLString:string];
// Image URLs must be valid HTTPS links, according to the Firebase Console.
if (![url.scheme.lowercaseString isEqualToString:@"https"]) return nil;
return url;
}
- (nullable NSURL *)urlFromURLString:(NSString *)string {
NSString *sanitizedString = [self sanitizedURLStringFromString:string];
if (sanitizedString.length == 0) return nil;
return [NSURL URLWithString:sanitizedString];
}
- (NSString *)sanitizedURLStringFromString:(NSString *)string {
return [string stringByReplacingOccurrencesOfString:@" " withString:@""];
}
@end
#endif // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION