/* * 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 #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 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)timeFetcher { if (self = [super init]) { _timeFetcher = timeFetcher; } return self; } - (nullable NSArray *) 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 *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 *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 *)parseTriggeringCondition: (NSArray *)triggerConditions { if (![triggerConditions isKindOfClass:[NSArray class]] || triggerConditions.count == 0) { return nil; } NSMutableArray *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 *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