diff --git a/Firebase/InAppMessaging/Analytics/FIRIAMAnalyticsEventLogger.h b/Firebase/InAppMessaging/Analytics/FIRIAMAnalyticsEventLogger.h new file mode 100644 index 0000000000..0599159c69 --- /dev/null +++ b/Firebase/InAppMessaging/Analytics/FIRIAMAnalyticsEventLogger.h @@ -0,0 +1,61 @@ +/* + * Copyright 2018 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 +#import "FIRIAMClientInfoFetcher.h" +#import "FIRIAMTimeFetcher.h" + +NS_ASSUME_NONNULL_BEGIN + +/// Values for different fiam activity types. +typedef NS_ENUM(NSInteger, FIRIAMAnalyticsLogEventType) { + + FIRIAMAnalyticsLogEventUnknown = -1, + + FIRIAMAnalyticsEventMessageImpression = 0, + FIRIAMAnalyticsEventActionURLFollow = 1, + FIRIAMAnalyticsEventMessageDismissAuto = 2, + FIRIAMAnalyticsEventMessageDismissClick = 3, + FIRIAMAnalyticsEventMessageDismissSwipe = 4, + + // category: errors happened + FIRIAMAnalyticsEventImageFetchError = 11, + FIRIAMAnalyticsEventImageFormatUnsupported = 12, + + FIRIAMAnalyticsEventFetchAPINetworkError = 13, + FIRIAMAnalyticsEventFetchAPIClientError = 14, // server returns 4xx status code + FIRIAMAnalyticsEventFetchAPIServerError = 15, // server returns 5xx status code + + // Events for test messages + FIRIAMAnalyticsEventTestMessageImpression = 16, + FIRIAMAnalyticsEventTestMessageClick = 17, +}; + +// a protocol for collecting Analytics log records. It's implementation will decide +// what to do with that analytics log record +@protocol FIRIAMAnalyticsEventLogger +/** + * Adds an analytics log record. + * @param eventTimeInMs the timestamp in ms for when the event happened. + * if it's nil, the implementation will use the current system for this info. + */ +- (void)logAnalyticsEventForType:(FIRIAMAnalyticsLogEventType)eventType + forCampaignID:(NSString *)campaignID + withCampaignName:(NSString *)campaignName + eventTimeInMs:(nullable NSNumber *)eventTimeInMs + completion:(void (^)(BOOL success))completion; +@end +NS_ASSUME_NONNULL_END diff --git a/Firebase/InAppMessaging/Analytics/FIRIAMAnalyticsEventLoggerImpl.h b/Firebase/InAppMessaging/Analytics/FIRIAMAnalyticsEventLoggerImpl.h new file mode 100644 index 0000000000..41e289aaf7 --- /dev/null +++ b/Firebase/InAppMessaging/Analytics/FIRIAMAnalyticsEventLoggerImpl.h @@ -0,0 +1,45 @@ +/* + * Copyright 2018 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 + +#import "FIRIAMAnalyticsEventLogger.h" + +@class FIRIAMClearcutLogger; +@protocol FIRIAMTimeFetcher; +@protocol FIRAnalyticsInterop; + +NS_ASSUME_NONNULL_BEGIN +/** + * Implementation of protocol FIRIAMAnalyticsEventLogger by doing two things + * 1 Firing Firebase Analytics Events for impressions and clicks and dismisses + * 2 Making clearcut logging for all other types of analytics events + */ +@interface FIRIAMAnalyticsEventLoggerImpl : NSObject +- (instancetype)init NS_UNAVAILABLE; + +/** + * + * @param userDefaults needed for tracking upload timing info persistently.If nil, using + * NSUserDefaults standardUserDefaults. It's defined as a parameter to help with + * unit testing mocking + */ +- (instancetype)initWithClearcutLogger:(FIRIAMClearcutLogger *)ctLogger + usingTimeFetcher:(id)timeFetcher + usingUserDefaults:(nullable NSUserDefaults *)userDefaults + analytics:(nullable id)analytics; +@end +NS_ASSUME_NONNULL_END diff --git a/Firebase/InAppMessaging/Analytics/FIRIAMAnalyticsEventLoggerImpl.m b/Firebase/InAppMessaging/Analytics/FIRIAMAnalyticsEventLoggerImpl.m new file mode 100644 index 0000000000..9e99a37030 --- /dev/null +++ b/Firebase/InAppMessaging/Analytics/FIRIAMAnalyticsEventLoggerImpl.m @@ -0,0 +1,170 @@ +/* + * Copyright 2018 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 "FIRIAMAnalyticsEventLoggerImpl.h" + +#import +#import +#import "FIRCore+InAppMessaging.h" +#import "FIRIAMClearcutLogger.h" + +typedef void (^FIRAUserPropertiesCallback)(NSDictionary *userProperties); + +@interface FIRIAMAnalyticsEventLoggerImpl () +@property(readonly, nonatomic) FIRIAMClearcutLogger *clearCutLogger; +@property(readonly, nonatomic) id timeFetcher; +@property(nonatomic, readonly) NSUserDefaults *userDefaults; +@end + +// in these kFAXX constants, FA represents FirebaseAnalytics +static NSString *const kFIREventOriginFIAM = @"fiam"; +; +static NSString *const kFAEventNameForImpression = @"firebase_in_app_message_impression"; +static NSString *const kFAEventNameForAction = @"firebase_in_app_message_action"; +static NSString *const kFAEventNameForDismiss = @"firebase_in_app_message_dismiss"; + +// In order to support tracking conversions from clicking a fiam event, we need to set +// an analytics user property with the fiam message's campaign id. +// This is the user property as kFIRUserPropertyLastNotification defined for FCM. +// Unlike FCM, FIAM would only allow the user property to exist up to certain expiration time +// after which, we stop attributing any further conversions to that fiam message click. +// So we include kFAUserPropertyPrefixForFIAM as the prefix for the entry written by fiam SDK +// to avoid removing entries written by FCM SDK +static NSString *const kFAUserPropertyForLastNotification = @"_ln"; +static NSString *const kFAUserPropertyPrefixForFIAM = @"fiam:"; + +// This user defaults key is for the entry to tell when we should remove the private user +// property from a prior action url click to stop conversion attribution for a campaign +static NSString *const kFIAMUserDefaualtsKeyForRemoveUserPropertyTimeInSeconds = + @"firebase-iam-conversion-tracking-expires-in-seconds"; + +@implementation FIRIAMAnalyticsEventLoggerImpl { + id _analytics; +} + +- (instancetype)initWithClearcutLogger:(FIRIAMClearcutLogger *)ctLogger + usingTimeFetcher:(id)timeFetcher + usingUserDefaults:(nullable NSUserDefaults *)userDefaults + analytics:(nullable id)analytics { + if (self = [super init]) { + _clearCutLogger = ctLogger; + _timeFetcher = timeFetcher; + _analytics = analytics; + _userDefaults = userDefaults ? userDefaults : [NSUserDefaults standardUserDefaults]; + + if (!_analytics) { + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM280002", + @"Firebase In App Messaging was not configured with FirebaseAnalytics."); + } + } + return self; +} + +- (NSDictionary *)constructFAEventParamsWithCampaignID:(NSString *)campaignID + campaignName:(NSString *)campaignName { + // event parameter names are aligned with definitions in event_names_util.cc + return @{ + @"_nmn" : campaignName ?: @"unknown", + @"_nmid" : campaignID ?: @"unknown", + @"_ndt" : @([self.timeFetcher currentTimestampInSeconds]) + }; +} + +- (void)logFAEventsForMessageImpressionWithcampaignID:(NSString *)campaignID + campaignName:(NSString *)campaignName { + if (_analytics) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM280001", + @"Log campaign impression Firebase Analytics event for campaign ID %@", campaignID); + + NSDictionary *params = [self constructFAEventParamsWithCampaignID:campaignID + campaignName:campaignName]; + [_analytics logEventWithOrigin:kFIREventOriginFIAM + name:kFAEventNameForImpression + parameters:params]; + } +} + +- (BOOL)setAnalyticsUserPropertyForKey:(NSString *)key withValue:(NSString *)value { + if (!_analytics || !key || !value) { + return NO; + } + [_analytics setUserPropertyWithOrigin:kFIREventOriginFIAM name:key value:value]; + return YES; +} + +- (void)logFAEventsForMessageActionWithCampaignID:(NSString *)campaignID + campaignName:(NSString *)campaignName { + if (_analytics) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM280004", + @"Log action click Firebase Analytics event for campaign ID %@", campaignID); + + NSDictionary *params = [self constructFAEventParamsWithCampaignID:campaignID + campaignName:campaignName]; + + [_analytics logEventWithOrigin:kFIREventOriginFIAM + name:kFAEventNameForAction + parameters:params]; + } + + // set a special user property so that conversion events can be queried based on that + // for reporting purpose + NSString *conversionTrackingUserPropertyValue = + [NSString stringWithFormat:@"%@%@", kFAUserPropertyPrefixForFIAM, campaignID]; + + if ([self setAnalyticsUserPropertyForKey:kFAUserPropertyForLastNotification + withValue:conversionTrackingUserPropertyValue]) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM280009", + @"User property for conversion tracking was set for campaign %@", campaignID); + } +} + +- (void)logFAEventsForMessageDismissWithcampaignID:(NSString *)campaignID + campaignName:(NSString *)campaignName { + if (_analytics) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM280007", + @"Log message dismiss Firebase Analytics event for campaign ID %@", campaignID); + + NSDictionary *params = [self constructFAEventParamsWithCampaignID:campaignID + campaignName:campaignName]; + [_analytics logEventWithOrigin:kFIREventOriginFIAM + name:kFAEventNameForDismiss + parameters:params]; + } +} + +- (void)logAnalyticsEventForType:(FIRIAMAnalyticsLogEventType)eventType + forCampaignID:(NSString *)campaignID + withCampaignName:(NSString *)campaignName + eventTimeInMs:(nullable NSNumber *)eventTimeInMs + completion:(void (^)(BOOL success))completion { + // log Firebase Analytics event first + if (eventType == FIRIAMAnalyticsEventMessageImpression) { + [self logFAEventsForMessageImpressionWithcampaignID:campaignID campaignName:campaignName]; + } else if (eventType == FIRIAMAnalyticsEventActionURLFollow) { + [self logFAEventsForMessageActionWithCampaignID:campaignID campaignName:campaignName]; + } else if (eventType == FIRIAMAnalyticsEventMessageDismissAuto || + eventType == FIRIAMAnalyticsEventMessageDismissClick) { + [self logFAEventsForMessageDismissWithcampaignID:campaignID campaignName:campaignName]; + } + + // and do clearcut logging as well + [self.clearCutLogger logAnalyticsEventForType:eventType + forCampaignID:campaignID + withCampaignName:campaignName + eventTimeInMs:eventTimeInMs + completion:completion]; +} +@end diff --git a/Firebase/InAppMessaging/Analytics/FIRIAMClearcutHttpRequestSender.h b/Firebase/InAppMessaging/Analytics/FIRIAMClearcutHttpRequestSender.h new file mode 100644 index 0000000000..7cacd37eaa --- /dev/null +++ b/Firebase/InAppMessaging/Analytics/FIRIAMClearcutHttpRequestSender.h @@ -0,0 +1,51 @@ +/* + * Copyright 2018 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 + +@class FIRIAMClearcutLogRecord; +@protocol FIRIAMTimeFetcher; + +NS_ASSUME_NONNULL_BEGIN +// class for sending requests to clearcut over its http API +@interface FIRIAMClearcutHttpRequestSender : NSObject + +/** + * Create an FIRIAMClearcutHttpRequestSender instance with specified clearcut server. + * + * @param serverHost API server host. + * @param osMajorVersion detected iOS major version of the current device + */ +- (instancetype)initWithClearcutHost:(NSString *)serverHost + usingTimeFetcher:(id)timeFetcher + withOSMajorVersion:(NSString *)osMajorVersion; + +/** + * Sends a batch of FIRIAMClearcutLogRecord records to clearcut server. + * @param logs an array of log records to be sent. + * @param completion is the handler to triggered upon completion. 'success' is a bool + * to indicate if the sending is successful. 'shouldRetryLogs' indicates if these + * logs need to be retried later on. On success case, waitTimeInMills is the value + * returned from clearcut server to indicate the minimal wait time before another + * send request can be attempted. + */ + +- (void)sendClearcutHttpRequestForLogs:(NSArray *)logs + withCompletion:(void (^)(BOOL success, + BOOL shouldRetryLogs, + int64_t waitTimeInMills))completion; +@end +NS_ASSUME_NONNULL_END diff --git a/Firebase/InAppMessaging/Analytics/FIRIAMClearcutHttpRequestSender.m b/Firebase/InAppMessaging/Analytics/FIRIAMClearcutHttpRequestSender.m new file mode 100644 index 0000000000..ad292c721f --- /dev/null +++ b/Firebase/InAppMessaging/Analytics/FIRIAMClearcutHttpRequestSender.m @@ -0,0 +1,202 @@ +/* + * Copyright 2018 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 + +#import "FIRCore+InAppMessaging.h" +#import "FIRIAMClearcutHttpRequestSender.h" +#import "FIRIAMClearcutLogStorage.h" +#import "FIRIAMClientInfoFetcher.h" +#import "FIRIAMTimeFetcher.h" + +@interface FIRIAMClearcutHttpRequestSender () +@property(readonly, copy, nonatomic) NSString *serverHostName; + +@property(readwrite, nonatomic) id timeFetcher; +@property(readonly, copy, nonatomic) NSString *osMajorVersion; +@end + +@implementation FIRIAMClearcutHttpRequestSender + +- (instancetype)initWithClearcutHost:(NSString *)serverHost + usingTimeFetcher:(id)timeFetcher + withOSMajorVersion:(NSString *)osMajorVersion { + if (self = [super init]) { + _serverHostName = [serverHost copy]; + _timeFetcher = timeFetcher; + _osMajorVersion = [osMajorVersion copy]; + } + return self; +} + +- (void)updateRequestBodyWithClearcutEnvelopeFields:(NSMutableDictionary *)bodyDict { + bodyDict[@"client_info"] = @{ + @"client_type" : @15, // 15 is the enum value for IOS_FIREBASE client + @"ios_client_info" : @{@"os_major_version" : self.osMajorVersion ?: @""} + }; + bodyDict[@"log_source"] = @"FIREBASE_INAPPMESSAGING"; + + NSTimeInterval nowInMs = [self.timeFetcher currentTimestampInSeconds] * 1000; + bodyDict[@"request_time_ms"] = @((long)nowInMs); +} + +- (NSArray *)constructLogEventsArrayLogRecords: + (NSArray *)logRecords { + NSMutableArray *logEvents = [[NSMutableArray alloc] init]; + for (id next in logRecords) { + FIRIAMClearcutLogRecord *logRecord = (FIRIAMClearcutLogRecord *)next; + [logEvents addObject:@{ + @"event_time_ms" : @((long)logRecord.eventTimestampInSeconds * 1000), + @"source_extension_json" : logRecord.eventExtensionJsonString ?: @"" + }]; + } + + return [logEvents copy]; +} + +// @return nil if error happened in constructing the body +- (NSDictionary *)constructRequestBodyWithRetryRecords: + (NSArray *)logRecords { + NSMutableDictionary *body = [[NSMutableDictionary alloc] init]; + [self updateRequestBodyWithClearcutEnvelopeFields:body]; + body[@"log_event"] = [self constructLogEventsArrayLogRecords:logRecords]; + return [body copy]; +} + +// a helper method for dealing with the response received from +// executing NSURLSessionDataTask. Triggers the completion callback accordingly +- (void)handleClearcutAPICallResponseWithData:(NSData *)data + response:(NSURLResponse *)response + error:(NSError *)error + completion: + (nonnull void (^)(BOOL success, + BOOL shouldRetryLogs, + int64_t waitTimeInMills))completion { + if (error) { + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM250003", + @"Internal error: encountered error in uploading clearcut message" + ":%@", + error); + completion(NO, YES, 0); + return; + } + + if (![response isKindOfClass:[NSHTTPURLResponse class]]) { + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM250008", + @"Received non http response from sending " + "clearcut requests %@", + response); + completion(NO, YES, 0); + return; + } + + NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response; + if (httpResponse.statusCode == 200) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM250004", + @"Sending clearcut logging request was successful"); + + NSError *errorJson = nil; + NSDictionary *responseDict = [NSJSONSerialization JSONObjectWithData:data + options:kNilOptions + error:&errorJson]; + + int64_t waitTimeFromClearcutServer = 0; + if (!errorJson && responseDict[@"next_request_wait_millis"]) { + waitTimeFromClearcutServer = [responseDict[@"next_request_wait_millis"] longLongValue]; + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM250007", + @"Wait time from clearcut server response is %d seconds", + (int)waitTimeFromClearcutServer / 1000); + } + completion(YES, NO, waitTimeFromClearcutServer); + } else if (httpResponse.statusCode == 400) { + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM250012", + @"Seeing 400 status code in response and we are discarding this log" + @"record"); + // 400 means bad request data and it won't be successful with retries. So + // we give up on these log records + completion(NO, NO, 0); + } else { + // May need to handle 401 errors if we do authentication in the future + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM250005", + @"Other http status code seen in clearcut request response %d", + (int)httpResponse.statusCode); + // can be retried + completion(NO, YES, 0); + } +} + +- (void)sendClearcutHttpRequestForLogs:(NSArray *)logs + withCompletion:(nonnull void (^)(BOOL success, + BOOL shouldRetryLogs, + int64_t waitTimeInMills))completion { + NSDictionary *requestBody = [self constructRequestBodyWithRetryRecords:logs]; + + if (!requestBody) { + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM250014", + @"Not able to construct request body for clearcut request, giving up"); + completion(NO, NO, 0); + } else { + // sending the log via a http request + NSURLSession *URLSession = [NSURLSession sharedSession]; + NSMutableURLRequest *request = [[NSMutableURLRequest alloc] init]; + [request setHTTPMethod:@"POST"]; + [request addValue:@"application/json" forHTTPHeaderField:@"Content-Type"]; + [request addValue:@"application/json" forHTTPHeaderField:@"Accept"]; + + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM250001", + @"Request body dictionary is %@ for clearcut logging request", requestBody); + + NSError *error; + NSData *requestBodyData = [NSJSONSerialization dataWithJSONObject:requestBody + options:0 + error:&error]; + + if (error) { + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM250011", + @"Error in creating request body json for clearcut requests:%@", error); + completion(NO, NO, 0); + return; + } + + NSString *requestURLString = + [NSString stringWithFormat:@"https://%@/log?format=json_proto", self.serverHostName]; + [request setURL:[NSURL URLWithString:requestURLString]]; + [request setHTTPBody:requestBodyData]; + + NSURLSessionDataTask *clearCutLogDataTask = + [URLSession dataTaskWithRequest:request + completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + [self handleClearcutAPICallResponseWithData:data + response:response + error:error + completion:completion]; + }]; + + if (clearCutLogDataTask == nil) { + NSString *errorDesc = @"Internal error: NSURLSessionDataTask failed to be created due to " + "possibly incorrect parameters"; + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM250005", @"%@", errorDesc); + completion(NO, NO, 0); + } else { + [clearCutLogDataTask resume]; + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM250002", + @"Making a restful api for sending clearcut logging data with " + "a NSURLSessionDataTask request as %@", + clearCutLogDataTask.currentRequest); + } + } +} +@end diff --git a/Firebase/InAppMessaging/Analytics/FIRIAMClearcutLogStorage.h b/Firebase/InAppMessaging/Analytics/FIRIAMClearcutLogStorage.h new file mode 100644 index 0000000000..d24a317793 --- /dev/null +++ b/Firebase/InAppMessaging/Analytics/FIRIAMClearcutLogStorage.h @@ -0,0 +1,48 @@ +/* + * Copyright 2018 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 + +NS_ASSUME_NONNULL_BEGIN +@interface FIRIAMClearcutLogRecord : NSObject +@property(nonatomic, copy, readonly) NSString *eventExtensionJsonString; +@property(nonatomic, readonly) NSInteger eventTimestampInSeconds; +- (instancetype)initWithExtensionJsonString:(NSString *)jsonString + eventTimestampInSeconds:(NSInteger)eventTimestampInSeconds; +@end + +@protocol FIRIAMTimeFetcher; + +// A local persistent storage for saving FIRIAMClearcutLogRecord objects +// so that they can be delivered to clearcut server. +// Based on the clearcut log structure, our strategy is to store the json string +// for the source extension since it does not need to be modified upon delivery retries. +// The envelope of the clearcut log will be reconstructed when delivery is +// attempted. + +@interface FIRIAMClearcutLogStorage : NSObject +- (instancetype)initWithExpireAfterInSeconds:(NSInteger)expireInSeconds + withTimeFetcher:(id)timeFetcher; + +// add new records into the storage +- (void)pushRecords:(NSArray *)newRecords; + +// pop all the records that have not expired yet. With this call, these +// records are removed from the book of this local storage object. +// @param upTo the cap on how many records to be popped. +- (NSArray *)popStillValidRecordsForUpTo:(NSInteger)upTo; +@end +NS_ASSUME_NONNULL_END diff --git a/Firebase/InAppMessaging/Analytics/FIRIAMClearcutLogStorage.m b/Firebase/InAppMessaging/Analytics/FIRIAMClearcutLogStorage.m new file mode 100644 index 0000000000..e4ee2d272d --- /dev/null +++ b/Firebase/InAppMessaging/Analytics/FIRIAMClearcutLogStorage.m @@ -0,0 +1,171 @@ +/* + * Copyright 2018 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 + +#import + +#import "FIRCore+InAppMessaging.h" +#import "FIRIAMClearcutLogStorage.h" +#import "FIRIAMTimeFetcher.h" + +@implementation FIRIAMClearcutLogRecord +static NSString *const kEventTimestampKey = @"event_ts_seconds"; +static NSString *const kEventExtensionJson = @"extension_js"; + ++ (BOOL)supportsSecureCoding { + return YES; +} + +- (instancetype)initWithExtensionJsonString:(NSString *)jsonString + eventTimestampInSeconds:(NSInteger)eventTimestampInSeconds { + self = [super init]; + if (self != nil) { + _eventTimestampInSeconds = eventTimestampInSeconds; + _eventExtensionJsonString = jsonString; + } + return self; +} + +- (id)initWithCoder:(NSCoder *)decoder { + self = [super init]; + if (self != nil) { + _eventTimestampInSeconds = [decoder decodeIntegerForKey:kEventTimestampKey]; + _eventExtensionJsonString = [decoder decodeObjectOfClass:[NSString class] + forKey:kEventExtensionJson]; + } + return self; +} + +- (void)encodeWithCoder:(NSCoder *)encoder { + [encoder encodeInteger:self.eventTimestampInSeconds forKey:kEventTimestampKey]; + [encoder encodeObject:self.eventExtensionJsonString forKey:kEventExtensionJson]; +} +@end + +@interface FIRIAMClearcutLogStorage () +@property(nonatomic) NSInteger recordExpiresInSeconds; +@property(nonatomic) NSMutableArray *records; +@property(nonatomic) id timeFetcher; +@end + +// We keep all the records in memory and flush them into files upon receiving +// applicationDidEnterBackground notifications. +@implementation FIRIAMClearcutLogStorage + ++ (NSString *)determineCacheFilePath { + static NSString *logCachePath; + static dispatch_once_t onceToken; + + dispatch_once(&onceToken, ^{ + NSString *libraryDirPath = + NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES)[0]; + logCachePath = + [NSString stringWithFormat:@"%@/firebase-iam-clearcut-retry-records", libraryDirPath]; + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM230001", + @"Persistent file path for clearcut log records is %@", logCachePath); + }); + return logCachePath; +} + +- (instancetype)initWithExpireAfterInSeconds:(NSInteger)expireInSeconds + withTimeFetcher:(id)timeFetcher { + if (self = [super init]) { + _records = [[NSMutableArray alloc] init]; + _timeFetcher = timeFetcher; + _recordExpiresInSeconds = expireInSeconds; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(appWillBecomeInactive) + name:UIApplicationWillResignActiveNotification + object:nil]; + @try { + [self loadFromCachePath:nil]; + } @catch (NSException *exception) { + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM230004", + @"Non-fatal exception in loading persisted clearcut log records: %@.", + exception); + } + } + return self; +} + +- (void)appWillBecomeInactive { + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0ul), ^{ + [self saveIntoCacheWithPath:nil]; + }); +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +- (void)pushRecords:(NSArray *)newRecords { + @synchronized(self) { + [self.records addObjectsFromArray:newRecords]; + } +} + +- (NSArray *)popStillValidRecordsForUpTo:(NSInteger)upTo { + NSMutableArray *resultArray = [[NSMutableArray alloc] init]; + NSInteger nowInSeconds = (NSInteger)[self.timeFetcher currentTimestampInSeconds]; + + NSInteger next = 0; + + @synchronized(self) { + while (resultArray.count < upTo && next < self.records.count) { + FIRIAMClearcutLogRecord *nextRecord = self.records[next++]; + if (nextRecord.eventTimestampInSeconds > nowInSeconds - self.recordExpiresInSeconds) { + // record not expired yet + [resultArray addObject:nextRecord]; + } + } + + [self.records removeObjectsInRange:NSMakeRange(0, next)]; + } + + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM230005", + @"Returning %d clearcut retry records from popStillValidRecords", + (int)resultArray.count); + return resultArray; +} + +- (void)loadFromCachePath:(NSString *)cacheFilePath { + NSString *filePath = cacheFilePath == nil ? [self.class determineCacheFilePath] : cacheFilePath; + + NSTimeInterval start = [self.timeFetcher currentTimestampInSeconds]; + id fetchedClearcutRetryRecords = [NSKeyedUnarchiver unarchiveObjectWithFile:filePath]; + if (fetchedClearcutRetryRecords) { + @synchronized(self) { + self.records = (NSMutableArray *)fetchedClearcutRetryRecords; + } + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM230002", + @"Loaded %d clearcut log records from file in %lf seconds", (int)self.records.count, + (double)[self.timeFetcher currentTimestampInSeconds] - start); + } +} + +- (BOOL)saveIntoCacheWithPath:(NSString *)cacheFilePath { + NSString *filePath = cacheFilePath == nil ? [self.class determineCacheFilePath] : cacheFilePath; + @synchronized(self) { + BOOL saveResult = [NSKeyedArchiver archiveRootObject:self.records toFile:filePath]; + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM230003", + @"Saving %d clearcut log records into file is %@", (int)self.records.count, + saveResult ? @"successful" : @"failure"); + + return saveResult; + } +} +@end diff --git a/Firebase/InAppMessaging/Analytics/FIRIAMClearcutLogger.h b/Firebase/InAppMessaging/Analytics/FIRIAMClearcutLogger.h new file mode 100644 index 0000000000..d894a00f14 --- /dev/null +++ b/Firebase/InAppMessaging/Analytics/FIRIAMClearcutLogger.h @@ -0,0 +1,46 @@ +/* + * Copyright 2018 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 +#import "FIRIAMAnalyticsEventLogger.h" +#import "FIRIAMClientInfoFetcher.h" +#import "FIRIAMTimeFetcher.h" + +@class FIRIAMClearcutUploader; + +NS_ASSUME_NONNULL_BEGIN +// FIRIAMAnalyticsEventLogger implementation using Clearcut. It turns a IAM analytics event +// into the corresponding FIRIAMClearcutLogRecord and then hand it over to +// a FIRIAMClearcutUploader instance for the actual sending and potential failure and retry +// logic +@interface FIRIAMClearcutLogger : NSObject + +- (instancetype)init NS_UNAVAILABLE; + +/** + * Create an instance which uses NSURLSession to make clearcut api calls. + * + * @param clientInfoFetcher used to fetch iid info for the current app. + * @param timeFetcher time fetcher object + * @param uploader FIRIAMClearcutUploader object for receiving the log record + */ +- (instancetype)initWithFBProjectNumber:(NSString *)fbProjectNumber + fbAppId:(NSString *)fbAppId + clientInfoFetcher:(FIRIAMClientInfoFetcher *)clientInfoFetcher + usingTimeFetcher:(id)timeFetcher + usingUploader:(FIRIAMClearcutUploader *)uploader; +@end +NS_ASSUME_NONNULL_END diff --git a/Firebase/InAppMessaging/Analytics/FIRIAMClearcutLogger.m b/Firebase/InAppMessaging/Analytics/FIRIAMClearcutLogger.m new file mode 100644 index 0000000000..4cb8a134b8 --- /dev/null +++ b/Firebase/InAppMessaging/Analytics/FIRIAMClearcutLogger.m @@ -0,0 +1,203 @@ +/* + * Copyright 2018 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 +#import + +#import "FIRCore+InAppMessaging.h" +#import "FIRIAMClearcutLogStorage.h" +#import "FIRIAMClearcutLogger.h" +#import "FIRIAMClearcutUploader.h" + +@interface FIRIAMClearcutLogger () + +// these two writable for assisting unit testing need +@property(readwrite, nonatomic) FIRIAMClearcutHttpRequestSender *requestSender; +@property(readwrite, nonatomic) id timeFetcher; + +@property(readonly, nonatomic) FIRIAMClientInfoFetcher *clientInfoFetcher; +@property(readonly, nonatomic) FIRIAMClearcutUploader *ctUploader; + +@property(readonly, copy, nonatomic) NSString *fbProjectNumber; +@property(readonly, copy, nonatomic) NSString *fbAppId; + +@end + +@implementation FIRIAMClearcutLogger { + NSString *_iid; +} +- (instancetype)initWithFBProjectNumber:(NSString *)fbProjectNumber + fbAppId:(NSString *)fbAppId + clientInfoFetcher:(FIRIAMClientInfoFetcher *)clientInfoFetcher + usingTimeFetcher:(id)timeFetcher + usingUploader:(FIRIAMClearcutUploader *)uploader { + if (self = [super init]) { + _fbProjectNumber = fbProjectNumber; + _fbAppId = fbAppId; + _clientInfoFetcher = clientInfoFetcher; + _timeFetcher = timeFetcher; + _ctUploader = uploader; + } + return self; +} + ++ (void)updateSourceExtensionDictWithAnalyticsEventEnumType:(FIRIAMAnalyticsLogEventType)eventType + forDict:(NSMutableDictionary *)dict { + switch (eventType) { + case FIRIAMAnalyticsEventMessageImpression: + dict[@"event_type"] = @"IMPRESSION_EVENT_TYPE"; + break; + case FIRIAMAnalyticsEventActionURLFollow: + dict[@"event_type"] = @"CLICK_EVENT_TYPE"; + break; + case FIRIAMAnalyticsEventMessageDismissAuto: + dict[@"dismiss_type"] = @"AUTO"; + break; + case FIRIAMAnalyticsEventMessageDismissClick: + dict[@"dismiss_type"] = @"CLICK"; + break; + case FIRIAMAnalyticsEventMessageDismissSwipe: + dict[@"dismiss_type"] = @"SWIPE"; + break; + case FIRIAMAnalyticsEventImageFetchError: + dict[@"render_error_reason"] = @"IMAGE_FETCH_ERROR"; + break; + case FIRIAMAnalyticsEventImageFormatUnsupported: + dict[@"render_error_reason"] = @"IMAGE_UNSUPPORTED_FORMAT"; + break; + case FIRIAMAnalyticsEventFetchAPIClientError: + dict[@"fetch_error_reason"] = @"CLIENT_ERROR"; + break; + case FIRIAMAnalyticsEventFetchAPIServerError: + dict[@"fetch_error_reason"] = @"SERVER_ERROR"; + break; + case FIRIAMAnalyticsEventFetchAPINetworkError: + dict[@"fetch_error_reason"] = @"NETWORK_ERROR"; + break; + case FIRIAMAnalyticsEventTestMessageImpression: + dict[@"event_type"] = @"TEST_MESSAGE_IMPRESSION_EVENT_TYPE"; + break; + case FIRIAMAnalyticsEventTestMessageClick: + dict[@"event_type"] = @"TEST_MESSAGE_CLICK_EVENT_TYPE"; + break; + case FIRIAMAnalyticsLogEventUnknown: + break; + } +} + +// constructing CampaignAnalytics proto defined in campaign_analytics.proto and serialize it into +// a string. +// @return nil if error happened +- (NSString *)constructSourceExtensionJsonForClearcutWithEventType: + (FIRIAMAnalyticsLogEventType)eventType + forCampaignID:(NSString *)campaignID + eventTimeInMs:(NSNumber *)eventTimeInMs + instanceID:(NSString *)instanceID { + NSMutableDictionary *campaignAnalyticsDict = [[NSMutableDictionary alloc] init]; + + campaignAnalyticsDict[@"project_number"] = self.fbProjectNumber; + campaignAnalyticsDict[@"campaign_id"] = campaignID; + campaignAnalyticsDict[@"client_app"] = + @{@"google_app_id" : self.fbAppId, @"firebase_instance_id" : instanceID}; + campaignAnalyticsDict[@"client_timestamp_millis"] = eventTimeInMs; + [self.class updateSourceExtensionDictWithAnalyticsEventEnumType:eventType + forDict:campaignAnalyticsDict]; + + campaignAnalyticsDict[@"fiam_sdk_version"] = [self.clientInfoFetcher getIAMSDKVersion]; + + // turn campaignAnalyticsDict into a json string + NSError *error; + NSData *jsonData = [NSJSONSerialization + dataWithJSONObject:campaignAnalyticsDict // Here you can pass array or dictionary + options:0 // Pass 0 if you don't care about the readability of the generated + // string + error:&error]; + + if (jsonData) { + NSString *jsonString; + jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM210006", + @"Source extension json string produced as %@", jsonString); + return jsonString; + } else { + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM210007", + @"Error in generating source extension json string: %@", error); + return nil; + } +} + +- (void)logAnalyticsEventForType:(FIRIAMAnalyticsLogEventType)eventType + forCampaignID:(NSString *)campaignID + withEventTimeInMs:(nullable NSNumber *)eventTimeInMs + IID:(NSString *)iid + completion:(void (^)(BOOL success))completion { + NSTimeInterval nowInMs = [self.timeFetcher currentTimestampInSeconds] * 1000; + if (!eventTimeInMs) { + eventTimeInMs = @((long)nowInMs); + } + + NSString *sourceExtensionJsonString = + [self constructSourceExtensionJsonForClearcutWithEventType:eventType + forCampaignID:campaignID + eventTimeInMs:eventTimeInMs + instanceID:iid]; + + FIRIAMClearcutLogRecord *newRecord = [[FIRIAMClearcutLogRecord alloc] + initWithExtensionJsonString:sourceExtensionJsonString + eventTimestampInSeconds:eventTimeInMs.integerValue / 1000]; + [self.ctUploader addNewLogRecord:newRecord]; + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM210003", + @"One more clearcut log record created and sent to uploader with source extension %@", + sourceExtensionJsonString); + completion(YES); +} + +- (void)logAnalyticsEventForType:(FIRIAMAnalyticsLogEventType)eventType + forCampaignID:(NSString *)campaignID + withCampaignName:(NSString *)campaignName + eventTimeInMs:(nullable NSNumber *)eventTimeInMs + completion:(void (^)(BOOL success))completion { + if (!_iid) { + [self.clientInfoFetcher + fetchFirebaseIIDDataWithProjectNumber:self.fbProjectNumber + withCompletion:^(NSString *_Nullable iid, NSString *_Nullable token, + NSError *_Nullable error) { + if (error) { + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM210001", + @"Failed to get iid value for clearcut logging %@", + error); + completion(NO); + } else { + // persist iid through the whole life-cycle + self->_iid = iid; + [self logAnalyticsEventForType:eventType + forCampaignID:campaignID + withEventTimeInMs:eventTimeInMs + IID:iid + completion:completion]; + } + }]; + } else { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM210004", + @"Using remembered iid for event logging"); + [self logAnalyticsEventForType:eventType + forCampaignID:campaignID + withEventTimeInMs:eventTimeInMs + IID:_iid + completion:completion]; + } +} +@end diff --git a/Firebase/InAppMessaging/Analytics/FIRIAMClearcutUploader.h b/Firebase/InAppMessaging/Analytics/FIRIAMClearcutUploader.h new file mode 100644 index 0000000000..9c0d1139b3 --- /dev/null +++ b/Firebase/InAppMessaging/Analytics/FIRIAMClearcutUploader.h @@ -0,0 +1,75 @@ +/* + * Copyright 2018 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 + +@class FIRIAMClearcutLogRecord; +@class FIRIAMClearcutHttpRequestSender; +@class FIRIAMClearcutLogStorage; + +@protocol FIRIAMTimeFetcher; + +NS_ASSUME_NONNULL_BEGIN + +// class for defining a number of configs to control clearcut upload behavior +@interface FIRIAMClearcutStrategy : NSObject + +// minimalWaitTimeInMills and maximumWaitTimeInMills defines the bottom and +// upper bound of the wait time before next upload if prior upload attempt was +// successful. Clearcut may return a value to give the wait time guidance in +// the upload response, but we also use these two values for sanity check to avoid +// too crazy behavior if the guidance value from server does not make sense +@property(nonatomic, readonly) NSInteger minimalWaitTimeInMills; +@property(nonatomic, readonly) NSInteger maximumWaitTimeInMills; + +// back off wait time in mills if a prior upload attempt fails +@property(nonatomic, readonly) NSInteger failureBackoffTimeInMills; + +// the maximum number of log records to be sent in one upload attempt +@property(nonatomic, readonly) NSInteger batchSendSize; + +- (instancetype)init NS_UNAVAILABLE; +- (instancetype)initWithMinWaitTimeInMills:(NSInteger)minWaitTimeInMills + maxWaitTimeInMills:(NSInteger)maxWaitTimeInMills + failureBackoffTimeInMills:(NSInteger)failureBackoffTimeInMills + batchSendSize:(NSInteger)batchSendSize; + +- (NSString *)description; +@end + +// A class for accepting new clearcut logs and scheduling the uploading of the logs in batches +// based on defined strategies. +@interface FIRIAMClearcutUploader : NSObject +- (instancetype)init NS_UNAVAILABLE; + +/** + * + * @param userDefaults needed for tracking upload timing info persistently.If nil, using + * NSUserDefaults standardUserDefaults. It's defined as a parameter to help with + * unit testing mocking + */ +- (instancetype)initWithRequestSender:(FIRIAMClearcutHttpRequestSender *)requestSender + timeFetcher:(id)timeFetcher + logStorage:(FIRIAMClearcutLogStorage *)retryStorage + usingStrategy:(FIRIAMClearcutStrategy *)strategy + usingUserDefaults:(nullable NSUserDefaults *)userDefaults; +/** + * This should return very quickly without blocking on and actual log uploading to + * clearcut server, which is done asynchronously + */ +- (void)addNewLogRecord:(FIRIAMClearcutLogRecord *)record; +@end +NS_ASSUME_NONNULL_END diff --git a/Firebase/InAppMessaging/Analytics/FIRIAMClearcutUploader.m b/Firebase/InAppMessaging/Analytics/FIRIAMClearcutUploader.m new file mode 100644 index 0000000000..1e676536f1 --- /dev/null +++ b/Firebase/InAppMessaging/Analytics/FIRIAMClearcutUploader.m @@ -0,0 +1,233 @@ +/* + * Copyright 2018 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 +#import + +#import "FIRCore+InAppMessaging.h" +#import "FIRIAMClearcutUploader.h" +#import "FIRIAMTimeFetcher.h" + +#import "FIRIAMClearcutHttpRequestSender.h" +#import "FIRIAMClearcutLogStorage.h" + +// a macro for turning a millisecond value into seconds +#define MILLS_TO_SECONDS(x) (((long)x) / 1000) + +@implementation FIRIAMClearcutStrategy +- (instancetype)initWithMinWaitTimeInMills:(NSInteger)minWaitTimeInMills + maxWaitTimeInMills:(NSInteger)maxWaitTimeInMills + failureBackoffTimeInMills:(NSInteger)failureBackoffTimeInMills + batchSendSize:(NSInteger)batchSendSize { + if (self = [super init]) { + _minimalWaitTimeInMills = minWaitTimeInMills; + _maximumWaitTimeInMills = maxWaitTimeInMills; + _failureBackoffTimeInMills = failureBackoffTimeInMills; + _batchSendSize = batchSendSize; + } + return self; +} + +- (NSString *)description { + return [NSString stringWithFormat:@"min wait time in seconds:%ld;max wait time in seconds:%ld;" + "failure backoff time in seconds:%ld;batch send size:%d", + MILLS_TO_SECONDS(self.minimalWaitTimeInMills), + MILLS_TO_SECONDS(self.maximumWaitTimeInMills), + MILLS_TO_SECONDS(self.failureBackoffTimeInMills), + (int)self.batchSendSize]; +} +@end + +@interface FIRIAMClearcutUploader () { + dispatch_queue_t _queue; + BOOL _nextSendScheduled; +} + +@property(readwrite, nonatomic) FIRIAMClearcutHttpRequestSender *requestSender; +@property(nonatomic, assign) int64_t nextValidSendTimeInMills; + +@property(nonatomic, readonly) id timeFetcher; +@property(nonatomic, readonly) FIRIAMClearcutLogStorage *logStorage; + +@property(nonatomic, readonly) FIRIAMClearcutStrategy *strategy; +@property(nonatomic, readonly) NSUserDefaults *userDefaults; +@end + +static NSString *FIRIAM_UserDefaultsKeyForNextValidClearcutUploadTimeInMills = + @"firebase-iam-next-clearcut-upload-timestamp-in-mills"; + +/** + * The high level behavior in this implementation is like this + * 1 New records always pushed into FIRIAMClearcutLogStorage first. + * 2 Upload log records in batches. + * 3 If prior upload was successful, next upload would wait for the time parsed out of the + * clearcut response body. + * 4 If prior upload failed, next upload attempt would wait for failureBackoffTimeInMills defined + * in strategy + * 5 When app + */ + +@implementation FIRIAMClearcutUploader + +- (instancetype)initWithRequestSender:(FIRIAMClearcutHttpRequestSender *)requestSender + timeFetcher:(id)timeFetcher + logStorage:(FIRIAMClearcutLogStorage *)logStorage + usingStrategy:(FIRIAMClearcutStrategy *)strategy + usingUserDefaults:(nullable NSUserDefaults *)userDefaults { + if (self = [super init]) { + _nextSendScheduled = NO; + _timeFetcher = timeFetcher; + _requestSender = requestSender; + _logStorage = logStorage; + _strategy = strategy; + _queue = dispatch_queue_create("com.google.firebase.inappmessaging.clearcut_upload", NULL); + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(appWillEnterForeground:) + name:UIApplicationWillEnterForegroundNotification + object:nil]; + + _userDefaults = userDefaults ? userDefaults : [NSUserDefaults standardUserDefaults]; + // it would be 0 if it does not exist, which is equvilent to saying that + // you can send now + _nextValidSendTimeInMills = (int64_t) + [_userDefaults doubleForKey:FIRIAM_UserDefaultsKeyForNextValidClearcutUploadTimeInMills]; + + // seed the first send upon SDK start-up + [self scheduleNextSend]; + + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM260001", + @"FIRIAMClearcutUploader created with strategy as %@", self.strategy); + } + return self; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +- (void)appWillEnterForeground:(UIApplication *)application { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM260010", + @"App foregrounded, FIRIAMClearcutUploader will seed next send"); + [self scheduleNextSend]; +} + +- (void)addNewLogRecord:(FIRIAMClearcutLogRecord *)record { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM260002", + @"New log record sent to clearcut uploader"); + + [self.logStorage pushRecords:@[ record ]]; + [self scheduleNextSend]; +} + +- (void)attemptUploading { + NSArray *availbleLogs = + [self.logStorage popStillValidRecordsForUpTo:self.strategy.batchSendSize]; + + if (availbleLogs.count > 0) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM260011", @"Deliver %d clearcut records", + (int)availbleLogs.count); + [self.requestSender + sendClearcutHttpRequestForLogs:availbleLogs + withCompletion:^(BOOL success, BOOL shouldRetryLogs, + int64_t waitTimeInMills) { + if (success) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM260003", + @"Delivering %d clearcut records was successful", + (int)availbleLogs.count); + // make sure the effective wait time is between two bounds + // defined in strategy + waitTimeInMills = + MAX(self.strategy.minimalWaitTimeInMills, waitTimeInMills); + + waitTimeInMills = + MIN(waitTimeInMills, self.strategy.maximumWaitTimeInMills); + } else { + // failed to deliver + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM260004", + @"Failed to attempt the delivery of %d clearcut " + @"records and should-retry for them is %@", + (int)availbleLogs.count, shouldRetryLogs ? @"YES" : @"NO"); + if (shouldRetryLogs) { + /** + * Note that there is a chance that the app crashes before we can + * call pushRecords: on the logStorage below which means we lost + * these log records permanently. This is a trade-off between handling + * duplicate records on server side vs taking the risk of lossing + * data. This implementation picks the latter. + */ + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM260007", + @"Push failed log records back to storage"); + [self.logStorage pushRecords:availbleLogs]; + } + + waitTimeInMills = (int64_t)self.strategy.failureBackoffTimeInMills; + } + + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM260005", + @"Wait for at least %ld seconds before next upload attempt", + MILLS_TO_SECONDS(waitTimeInMills)); + + self.nextValidSendTimeInMills = + (int64_t)[self.timeFetcher currentTimestampInSeconds] * 1000 + + waitTimeInMills; + + // persisted so that it can be recovered next time the app runs + [self.userDefaults + setDouble:(double)self.nextValidSendTimeInMills + forKey: + FIRIAM_UserDefaultsKeyForNextValidClearcutUploadTimeInMills]; + + @synchronized(self) { + self->_nextSendScheduled = NO; + } + [self scheduleNextSend]; + }]; + + } else { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM260007", @"No clearcut records to be uploaded"); + @synchronized(self) { + _nextSendScheduled = NO; + } + } +} + +- (void)scheduleNextSend { + @synchronized(self) { + if (_nextSendScheduled) { + // already scheduled next send, don't do it again + return; + } else { + _nextSendScheduled = YES; + } + } + + int64_t delayTimeInMills = + self.nextValidSendTimeInMills - (int64_t)[self.timeFetcher currentTimestampInSeconds] * 1000; + + if (delayTimeInMills <= 0) { + delayTimeInMills = 0; // no need to delay since we can send now + } + + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM260006", + @"Next upload attempt scheduled in %d seconds", (int)delayTimeInMills / 1000); + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delayTimeInMills * (int64_t)NSEC_PER_MSEC), + _queue, ^{ + [self attemptUploading]; + }); +} + +@end diff --git a/Firebase/InAppMessaging/CHANGELOG.md b/Firebase/InAppMessaging/CHANGELOG.md new file mode 100644 index 0000000000..a9c4ea6812 --- /dev/null +++ b/Firebase/InAppMessaging/CHANGELOG.md @@ -0,0 +1,6 @@ +# 2018-09-25 -- v0.12.0 +- Separated UI functionality into a new open source SDK called FirebaseInAppMessagingDisplay. +- Respect fetch between wait time returned from API responses. + +# 2018-08-15 -- v0.11.0 +- First Beta Release. diff --git a/Firebase/InAppMessaging/Data/FIRIAMFetchResponseParser.h b/Firebase/InAppMessaging/Data/FIRIAMFetchResponseParser.h new file mode 100644 index 0000000000..3146a0570e --- /dev/null +++ b/Firebase/InAppMessaging/Data/FIRIAMFetchResponseParser.h @@ -0,0 +1,40 @@ +/* + * 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 + +@class FIRIAMMessageDefinition; +@protocol FIRIAMTimeFetcher; + +NS_ASSUME_NONNULL_BEGIN + +// Class responsible for parsing the json response data from the restful API endpoint +// for serving eligible messages for the current SDK clients. +@interface FIRIAMFetchResponseParser : NSObject + +// Turn the API response into a number of FIRIAMMessageDefinition objects. If any of them is invalid +// it would be ignored and not represented in the response array. +// @param discardCount if not nil, it would contain, on return, the number of invalid messages +// detected uring parsing. +// @param fetchWaitTime would be non nil if fetch wait time data is found in the api response. +- (NSArray *)parseAPIResponseDictionary:(NSDictionary *)responseDict + discardedMsgCount:(NSInteger *)discardCount + fetchWaitTimeInSeconds:(NSNumber **)fetchWaitTime; + +- (instancetype)init NS_UNAVAILABLE; +- (instancetype)initWithTimeFetcher:(id)timeFetcher; +@end +NS_ASSUME_NONNULL_END diff --git a/Firebase/InAppMessaging/Data/FIRIAMFetchResponseParser.m b/Firebase/InAppMessaging/Data/FIRIAMFetchResponseParser.m new file mode 100644 index 0000000000..771c4eb51c --- /dev/null +++ b/Firebase/InAppMessaging/Data/FIRIAMFetchResponseParser.m @@ -0,0 +1,313 @@ +/* + * 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 + +#import "FIRCore+InAppMessaging.h" +#import "FIRIAMDisplayTriggerDefinition.h" +#import "FIRIAMFetchResponseParser.h" +#import "FIRIAMMessageContentData.h" +#import "FIRIAMMessageContentDataWithImageURL.h" +#import "FIRIAMMessageDefinition.h" +#import "FIRIAMTimeFetcher.h" +#import "UIColor+FIRIAMHexString.h" + +@interface FIRIAMFetchResponseParser () +@property(nonatomic) id timeFetcher; +@end + +@implementation FIRIAMFetchResponseParser + +- (instancetype)initWithTimeFetcher:(id)timeFetcher { + if (self = [super init]) { + _timeFetcher = timeFetcher; + } + return self; +} + +- (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[@"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"]; + NSInteger discarded = 0; + + NSMutableArray *definitions = [[NSMutableArray alloc] init]; + for (NSDictionary *nextMsg in messageArray) { + FIRIAMMessageDefinition *nextDefinition = + [self convertToMessageDefinitionWithMessageDict:nextMsg]; + 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 == nil || triggerConditions.count == 0) { + return nil; + } + + NSMutableArray *triggers = [[NSMutableArray alloc] init]; + + for (NSDictionary *nextTriggerCondition in triggerConditions) { + if (nextTriggerCondition[@"fiamTrigger"]) { + if ([nextTriggerCondition[@"fiamTrigger"] isEqualToString:@"ON_FOREGROUND"]) { + [triggers addObject:[[FIRIAMDisplayTriggerDefinition alloc] initForAppForegroundTrigger]]; + } + } else if ([nextTriggerCondition[@"event"] isKindOfClass:[NSDictionary class]]) { + NSDictionary *triggeringEvent = (NSDictionary *)nextTriggerCondition[@"event"]; + if (triggeringEvent[@"name"]) { + [triggers addObject:[[FIRIAMDisplayTriggerDefinition alloc] + initWithFirebaseAnalyticEvent:triggeringEvent[@"name"]]]; + } + } + } + + 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 vanillaPayloadNode = messageNode[@"vanillaPayload"]; + if (![vanillaPayloadNode isKindOfClass:[NSDictionary class]]) { + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM900012", + @"vanillaPayload does not exist or does not represent a dictionary in " + "message node %@", + messageNode); + return nil; + } + + NSString *messageID = vanillaPayloadNode[@"campaignId"]; + if (!messageID) { + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM900010", + @"messsage id is missing in message node %@", messageNode); + return nil; + } + + NSString *messageName = vanillaPayloadNode[@"campaignName"]; + if (!messageName && !isTestMessage) { + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM900011", + @"campaign name is missing in non-test message node %@", messageNode); + return nil; + } + + 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 = vanillaPayloadNode[@"campaignStartTimeMillis"]; + if ([startTimeNode isKindOfClass:[NSString class]]) { + startTimeInSeconds = [startTimeNode doubleValue] / 1000.0; + } + + id endTimeNode = vanillaPayloadNode[@"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, *titleTextColor; + viewCardBackgroundColor = btnBgColor = btnTxtColor = titleTextColor = nil; + + NSString *title, *body, *imageURLStr, *actionURLStr, *actionButtonText; + title = body = imageURLStr = actionButtonText = actionURLStr = nil; + + if ([content[@"banner"] isKindOfClass:[NSDictionary class]]) { + NSDictionary *bannerNode = (NSDictionary *)contentNode[@"banner"]; + mode = FIRIAMRenderAsBannerView; + + title = bannerNode[@"title"][@"text"]; + titleTextColor = [UIColor firiam_colorWithHexString:bannerNode[@"title"][@"hexColor"]]; + + body = bannerNode[@"body"][@"text"]; + + imageURLStr = bannerNode[@"imageUrl"]; + actionURLStr = bannerNode[@"action"][@"actionUrl"]; + viewCardBackgroundColor = + [UIColor firiam_colorWithHexString:bannerNode[@"backgroundHexColor"]]; + + } else if ([content[@"modal"] isKindOfClass:[NSDictionary class]]) { + mode = FIRIAMRenderAsModalView; + + NSDictionary *modalNode = (NSDictionary *)contentNode[@"modal"]; + title = modalNode[@"title"][@"text"]; + titleTextColor = [UIColor firiam_colorWithHexString:modalNode[@"title"][@"hexColor"]]; + + body = modalNode[@"body"][@"text"]; + + imageURLStr = modalNode[@"imageUrl"]; + actionButtonText = modalNode[@"actionButton"][@"text"][@"text"]; + btnBgColor = + [UIColor firiam_colorWithHexString:modalNode[@"actionButton"][@"buttonHexColor"]]; + + actionURLStr = modalNode[@"action"][@"actionUrl"]; + viewCardBackgroundColor = + [UIColor firiam_colorWithHexString:modalNode[@"backgroundHexColor"]]; + } else if ([content[@"imageOnly"] isKindOfClass:[NSDictionary class]]) { + mode = FIRIAMRenderAsImageOnlyView; + NSDictionary *imageOnlyNode = (NSDictionary *)contentNode[@"imageOnly"]; + + imageURLStr = imageOnlyNode[@"imageUrl"]; + + if (!imageURLStr) { + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM900007", + @"Image url is missing for image-only message %@", messageNode); + return nil; + } + actionURLStr = imageOnlyNode[@"action"][@"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 = (imageURLStr.length == 0) ? nil : [NSURL URLWithString:imageURLStr]; + NSURL *actionURL = (actionURLStr.length == 0) ? nil : [NSURL URLWithString:actionURLStr]; + FIRIAMRenderingEffectSetting *renderEffect = + [FIRIAMRenderingEffectSetting getDefaultRenderingEffectSetting]; + renderEffect.viewMode = mode; + + if (viewCardBackgroundColor) { + renderEffect.displayBGColor = viewCardBackgroundColor; + } + + if (btnBgColor) { + renderEffect.btnBGColor = btnBgColor; + } + + if (btnTxtColor) { + renderEffect.btnTextColor = btnTxtColor; + } + + 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 + actionURL:actionURL + imageURL:imageURL + usingURLSession:nil]; + + FIRIAMMessageRenderData *renderData = + [[FIRIAMMessageRenderData alloc] initWithMessageID:messageID + messageName:messageName + contentData:msgData + renderingEffect:renderEffect]; + + if (isTestMessage) { + return [[FIRIAMMessageDefinition alloc] initTestMessageWithRenderData:renderData]; + } else { + return [[FIRIAMMessageDefinition alloc] initWithRenderData:renderData + startTime:startTimeInSeconds + endTime:endTimeInSeconds + triggerDefinition:triggersDefinition]; + } + } @catch (NSException *e) { + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM900006", + @"Error in parsing message node %@ " + "with error %@", + messageNode, e); + return nil; + } +} +@end diff --git a/Firebase/InAppMessaging/Data/FIRIAMMessageContentData.h b/Firebase/InAppMessaging/Data/FIRIAMMessageContentData.h new file mode 100644 index 0000000000..663096e192 --- /dev/null +++ b/Firebase/InAppMessaging/Data/FIRIAMMessageContentData.h @@ -0,0 +1,42 @@ +/* + * 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 + +NS_ASSUME_NONNULL_BEGIN +/** + * This protocol models the message content (non-ui related) data for an in-app message. + */ +@protocol FIRIAMMessageContentData +@property(nonatomic, readonly, nonnull) NSString *titleText; +@property(nonatomic, readonly, nonnull) NSString *bodyText; +@property(nonatomic, readonly, nullable) NSString *actionButtonText; +@property(nonatomic, readonly, nullable) NSURL *actionURL; +@property(nonatomic, readonly, nullable) NSURL *imageURL; + +// Load image data and report the result in the callback block. +// Expect these cases in the callback block +// If error happens, error parameter will be non-nil. +// If no error happens and imageData parameter is nil, it indicates the case that there +// is no image assoicated with the message. +// If error is nil and imageData is not nil, then the image data is loaded successfully +- (void)loadImageDataWithBlock:(void (^)(NSData *_Nullable imageData, + NSError *_Nullable error))block; + +// convert to a description string of the content +- (NSString *)description; +@end +NS_ASSUME_NONNULL_END diff --git a/Firebase/InAppMessaging/Data/FIRIAMMessageContentDataWithImageURL.h b/Firebase/InAppMessaging/Data/FIRIAMMessageContentDataWithImageURL.h new file mode 100644 index 0000000000..eecff0986e --- /dev/null +++ b/Firebase/InAppMessaging/Data/FIRIAMMessageContentDataWithImageURL.h @@ -0,0 +1,47 @@ +/* + * 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 + +#import "FIRIAMMessageContentData.h" + +NS_ASSUME_NONNULL_BEGIN +/** + * An implementation for protocol FIRIAMMessageContentData. This class takes a image url + * and fetch it over the network to retrieve the image data. + */ +@interface FIRIAMMessageContentDataWithImageURL : NSObject +/** + * Create an instance which uses NSURLSession to do the image data fetching. + * + * @param title Message title text. + * @param body Message body text. + * @param actionButtonText Text for action button. + * @param actionURL url string for action. + * @param imageURL the url to the image. It can be nil to indicate the non-image in-app + * message case. + * @param URLSession can be nil in which case the class would create NSURLSession + * internally to perform the network request. Having it here so that + * it's easier for doing mocking with unit testing. + */ +- (instancetype)initWithMessageTitle:(NSString *)title + messageBody:(NSString *)body + actionButtonText:(nullable NSString *)actionButtonText + actionURL:(nullable NSURL *)actionURL + imageURL:(nullable NSURL *)imageURL + usingURLSession:(nullable NSURLSession *)URLSession; +@end +NS_ASSUME_NONNULL_END diff --git a/Firebase/InAppMessaging/Data/FIRIAMMessageContentDataWithImageURL.m b/Firebase/InAppMessaging/Data/FIRIAMMessageContentDataWithImageURL.m new file mode 100644 index 0000000000..82a09f87b9 --- /dev/null +++ b/Firebase/InAppMessaging/Data/FIRIAMMessageContentDataWithImageURL.m @@ -0,0 +1,138 @@ +/* + * 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 +#import + +#import "FIRCore+InAppMessaging.h" +#import "FIRIAMMessageContentData.h" +#import "FIRIAMMessageContentDataWithImageURL.h" +#import "FIRIAMSDKRuntimeErrorCodes.h" + +static NSInteger const SuccessHTTPStatusCode = 200; + +@interface FIRIAMMessageContentDataWithImageURL () +@property(nonatomic, readwrite, nonnull, copy) NSString *titleText; +@property(nonatomic, readwrite, nonnull, copy) NSString *bodyText; +@property(nonatomic, copy, nullable) NSString *actionButtonText; +@property(nonatomic, copy, nullable) NSURL *actionURL; +@property(nonatomic, nullable, copy) NSURL *imageURL; +@property(readonly) NSURLSession *URLSession; +@end + +@implementation FIRIAMMessageContentDataWithImageURL +- (instancetype)initWithMessageTitle:(NSString *)title + messageBody:(NSString *)body + actionButtonText:(nullable NSString *)actionButtonText + actionURL:(nullable NSURL *)actionURL + imageURL:(nullable NSURL *)imageURL + usingURLSession:(nullable NSURLSession *)URLSession { + if (self = [super init]) { + _titleText = title; + _bodyText = body; + _imageURL = imageURL; + _actionButtonText = actionButtonText; + _actionURL = actionURL; + + if (imageURL) { + _URLSession = URLSession ? URLSession : [NSURLSession sharedSession]; + } + } + return self; +} + +#pragma protocol FIRIAMMessageContentData + +- (NSString *)description { + return [NSString stringWithFormat:@"Message content: title '%@'," + "body '%@', imageURL '%@', action URL '%@'", + self.titleText, self.bodyText, self.imageURL, self.actionURL]; +} + +- (NSString *)getTitleText { + return _titleText; +} + +- (NSString *)getBodyText { + return _bodyText; +} + +- (nullable NSString *)getActionButtonText { + return _actionButtonText; +} + +- (void)loadImageDataWithBlock:(void (^)(NSData *_Nullable imageData, + NSError *_Nullable error))block { + if (!block) { + // no need for any further action if block is nil + return; + } + + if (!_imageURL) { + // no image data since image url is nil + block(nil, nil); + } else { + NSURLRequest *imageDataRequest = [NSURLRequest requestWithURL:_imageURL]; + NSURLSessionDataTask *task = [_URLSession + dataTaskWithRequest:imageDataRequest + completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + if (error) { + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM000003", + @"Error in fetching image: %@", error); + block(nil, error); + } else { + if ([response isKindOfClass:[NSHTTPURLResponse class]]) { + NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response; + if (httpResponse.statusCode == SuccessHTTPStatusCode) { + if (httpResponse.MIMEType == nil || ![httpResponse.MIMEType hasPrefix:@"image"]) { + NSString *errorDesc = + [NSString stringWithFormat:@"None image MIME type %@" + " detected for url %@", + httpResponse.MIMEType, self.imageURL]; + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM000004", @"%@", errorDesc); + + NSError *error = + [NSError errorWithDomain:kFirebaseInAppMessagingErrorDomain + code:FIRIAMSDKRuntimeErrorNonImageMimetypeFromImageURL + userInfo:@{NSLocalizedDescriptionKey : errorDesc}]; + block(nil, error); + } else { + block(data, nil); + } + } else { + NSString *errorDesc = + [NSString stringWithFormat:@"Failed HTTP request to crawl image %@: " + "HTTP status code as %ld", + self->_imageURL, (long)httpResponse.statusCode]; + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM000001", @"%@", errorDesc); + NSError *error = + [NSError errorWithDomain:NSURLErrorDomain + code:httpResponse.statusCode + userInfo:@{NSLocalizedDescriptionKey : errorDesc}]; + block(nil, error); + } + } else { + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM000002", + @"Internal error: got a non http response from fetching image for " + @"image url as %@", + self->_imageURL); + } + } + }]; + [task resume]; + } +} +@end diff --git a/Firebase/InAppMessaging/Data/FIRIAMMessageDefinition.h b/Firebase/InAppMessaging/Data/FIRIAMMessageDefinition.h new file mode 100644 index 0000000000..ecce5246c5 --- /dev/null +++ b/Firebase/InAppMessaging/Data/FIRIAMMessageDefinition.h @@ -0,0 +1,60 @@ +/* + * 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 + +#import "FIRIAMMessageRenderData.h" + +@class FIRIAMDisplayTriggerDefinition; + +NS_ASSUME_NONNULL_BEGIN +@interface FIRIAMMessageDefinition : NSObject +@property(nonatomic, nonnull, readonly) FIRIAMMessageRenderData *renderData; + +// metadata data that does not affect the rendering content/effect directly +@property(nonatomic, readonly) NSTimeInterval startTime; +@property(nonatomic, readonly) NSTimeInterval endTime; + +// a fiam message can have multiple triggers and any of them on its own can cause +// the message to be rendered +@property(nonatomic, readonly) NSArray *renderTriggers; + +/// A flag for client-side testing messages +@property(nonatomic, readonly) BOOL isTestMessage; + +- (instancetype)init NS_UNAVAILABLE; + +/** + * Create a regular message definition. + */ +- (instancetype)initWithRenderData:(FIRIAMMessageRenderData *)renderData + startTime:(NSTimeInterval)startTime + endTime:(NSTimeInterval)endTime + triggerDefinition:(NSArray *)renderTriggers; + +/** + * Create a test message definition. + */ +- (instancetype)initTestMessageWithRenderData:(FIRIAMMessageRenderData *)renderData; + +- (BOOL)messageHasExpired; +- (BOOL)messageHasStarted; + +// should this message be rendered when the app gets foregrounded? +- (BOOL)messageRenderedOnAppForegroundEvent; +// should this message be rendered when a given analytics event is fired? +- (BOOL)messageRenderedOnAnalyticsEvent:(NSString *)eventName; +@end +NS_ASSUME_NONNULL_END diff --git a/Firebase/InAppMessaging/Data/FIRIAMMessageDefinition.m b/Firebase/InAppMessaging/Data/FIRIAMMessageDefinition.m new file mode 100644 index 0000000000..fa083fbe44 --- /dev/null +++ b/Firebase/InAppMessaging/Data/FIRIAMMessageDefinition.m @@ -0,0 +1,85 @@ +/* + * 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 "FIRIAMMessageDefinition.h" +#import "FIRIAMDisplayTriggerDefinition.h" + +@implementation FIRIAMMessageRenderData + +- (instancetype)initWithMessageID:(NSString *)messageID + messageName:(NSString *)messageName + contentData:(id)contentData + renderingEffect:(FIRIAMRenderingEffectSetting *)renderEffect { + if (self = [super init]) { + _contentData = contentData; + _renderingEffectSettings = renderEffect; + _messageID = [messageID copy]; + _name = [messageName copy]; + } + return self; +} +@end + +@implementation FIRIAMMessageDefinition +- (instancetype)initWithRenderData:(FIRIAMMessageRenderData *)renderData + startTime:(NSTimeInterval)startTime + endTime:(NSTimeInterval)endTime + triggerDefinition:(NSArray *)renderTriggers { + if (self = [super init]) { + _renderData = renderData; + _renderTriggers = renderTriggers; + _startTime = startTime; + _endTime = endTime; + _isTestMessage = NO; + } + return self; +} + +- (instancetype)initTestMessageWithRenderData:(FIRIAMMessageRenderData *)renderData { + if (self = [super init]) { + _renderData = renderData; + _isTestMessage = YES; + } + return self; +} + +- (BOOL)messageHasExpired { + return self.endTime < [[NSDate date] timeIntervalSince1970]; +} + +- (BOOL)messageRenderedOnAppForegroundEvent { + for (FIRIAMDisplayTriggerDefinition *nextTrigger in self.renderTriggers) { + if (nextTrigger.triggerType == FIRIAMRenderTriggerOnAppForeground) { + return YES; + } + } + return NO; +} + +- (BOOL)messageRenderedOnAnalyticsEvent:(NSString *)eventName { + for (FIRIAMDisplayTriggerDefinition *nextTrigger in self.renderTriggers) { + if (nextTrigger.triggerType == FIRIAMRenderTriggerOnFirebaseAnalyticsEvent && + [nextTrigger.firebaseEventName isEqualToString:eventName]) { + return YES; + } + } + return NO; +} + +- (BOOL)messageHasStarted { + return self.startTime < [[NSDate date] timeIntervalSince1970]; +} +@end diff --git a/Firebase/InAppMessaging/Data/FIRIAMMessageRenderData.h b/Firebase/InAppMessaging/Data/FIRIAMMessageRenderData.h new file mode 100644 index 0000000000..b414657dcd --- /dev/null +++ b/Firebase/InAppMessaging/Data/FIRIAMMessageRenderData.h @@ -0,0 +1,36 @@ +/* + * 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 +#import "FIRIAMRenderingEffectSetting.h" + +@protocol FIRIAMMessageContentData; +NS_ASSUME_NONNULL_BEGIN +// This wraps the data that's needed for render the message's content in UI. It also contains +// certain meta data that's needed in responding to user's action +@interface FIRIAMMessageRenderData : NSObject +@property(nonatomic, nonnull, readonly) id contentData; +@property(nonatomic, nonnull, readonly) FIRIAMRenderingEffectSetting *renderingEffectSettings; +@property(nonatomic, nonnull, copy, readonly) NSString *messageID; +@property(nonatomic, nonnull, copy, readonly) NSString *name; + +- (instancetype)init NS_UNAVAILABLE; +- (instancetype)initWithMessageID:(NSString *)messageID + messageName:(NSString *)messageName + contentData:(id)contentData + renderingEffect:(FIRIAMRenderingEffectSetting *)renderEffect; +@end +NS_ASSUME_NONNULL_END diff --git a/Firebase/InAppMessaging/Data/FIRIAMRenderingEffectSetting.h b/Firebase/InAppMessaging/Data/FIRIAMRenderingEffectSetting.h new file mode 100644 index 0000000000..dfc648ca34 --- /dev/null +++ b/Firebase/InAppMessaging/Data/FIRIAMRenderingEffectSetting.h @@ -0,0 +1,56 @@ +/* + * 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 +#import + +NS_ASSUME_NONNULL_BEGIN + +typedef NS_ENUM(NSInteger, FIRIAMRenderingMode) { + FIRIAMRenderAsBannerView, + FIRIAMRenderAsModalView, + FIRIAMRenderAsImageOnlyView +}; + +/** + * A class for modeling rendering effect settings for in-app messaging + */ +@interface FIRIAMRenderingEffectSetting : NSObject + +@property(nonatomic) FIRIAMRenderingMode viewMode; + +// background color for the display area, including both the text's background and +// padding's background +@property(nonatomic, copy) UIColor *displayBGColor; + +// text color, covering both the title and body texts +@property(nonatomic, copy) UIColor *textColor; + +// text color for action button +@property(nonatomic, copy) UIColor *btnTextColor; + +// background color for action button +@property(nonatomic, copy) UIColor *btnBGColor; + +// duration of the banner view before triggering auto-dismiss +@property(nonatomic) CGFloat autoDimissBannerAfterNSeconds; + +// A flag to control rendering the message as a client-side testing message +@property(nonatomic) BOOL isTestMessage; + ++ (instancetype)getDefaultRenderingEffectSetting; +@end +NS_ASSUME_NONNULL_END diff --git a/Firebase/InAppMessaging/Data/FIRIAMRenderingEffectSetting.m b/Firebase/InAppMessaging/Data/FIRIAMRenderingEffectSetting.m new file mode 100644 index 0000000000..6fd7fb43f7 --- /dev/null +++ b/Firebase/InAppMessaging/Data/FIRIAMRenderingEffectSetting.m @@ -0,0 +1,31 @@ +/* + * 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 "FIRIAMRenderingEffectSetting.h" + +@implementation FIRIAMRenderingEffectSetting + ++ (instancetype)getDefaultRenderingEffectSetting { + FIRIAMRenderingEffectSetting *setting = [[FIRIAMRenderingEffectSetting alloc] init]; + + setting.btnBGColor = [UIColor colorWithRed:0.3 green:0.55 blue:0.28 alpha:1.0]; + setting.displayBGColor = [UIColor whiteColor]; + setting.textColor = [UIColor blackColor]; + setting.btnTextColor = [UIColor whiteColor]; + setting.autoDimissBannerAfterNSeconds = 12; + setting.isTestMessage = NO; + return setting; +} +@end diff --git a/Firebase/InAppMessaging/DisplayTrigger/FIRIAMDisplayTriggerDefinition.h b/Firebase/InAppMessaging/DisplayTrigger/FIRIAMDisplayTriggerDefinition.h new file mode 100644 index 0000000000..cba59391c4 --- /dev/null +++ b/Firebase/InAppMessaging/DisplayTrigger/FIRIAMDisplayTriggerDefinition.h @@ -0,0 +1,34 @@ +/* + * 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 + +typedef NS_ENUM(NSInteger, FIRIAMRenderTrigger) { + FIRIAMRenderTriggerOnAppForeground, + FIRIAMRenderTriggerOnFirebaseAnalyticsEvent +}; + +NS_ASSUME_NONNULL_BEGIN +@interface FIRIAMDisplayTriggerDefinition : NSObject +@property(nonatomic, readonly) FIRIAMRenderTrigger triggerType; + +// applicable only when triggerType == FIRIAMRenderTriggerOnFirebaseAnalyticsEvent +@property(nonatomic, copy, nullable, readonly) NSString *firebaseEventName; + +- (instancetype)initForAppForegroundTrigger; +- (instancetype)initWithFirebaseAnalyticEvent:(NSString *)title; +@end +NS_ASSUME_NONNULL_END diff --git a/Firebase/InAppMessaging/DisplayTrigger/FIRIAMDisplayTriggerDefinition.m b/Firebase/InAppMessaging/DisplayTrigger/FIRIAMDisplayTriggerDefinition.m new file mode 100644 index 0000000000..3613de2469 --- /dev/null +++ b/Firebase/InAppMessaging/DisplayTrigger/FIRIAMDisplayTriggerDefinition.m @@ -0,0 +1,33 @@ +/* + * 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 "FIRIAMDisplayTriggerDefinition.h" + +@implementation FIRIAMDisplayTriggerDefinition +- (instancetype)initForAppForegroundTrigger { + if (self = [super init]) { + _triggerType = FIRIAMRenderTriggerOnAppForeground; + } + return self; +} +- (instancetype)initWithFirebaseAnalyticEvent:(NSString *)title { + if (self = [super init]) { + _triggerType = FIRIAMRenderTriggerOnFirebaseAnalyticsEvent; + _firebaseEventName = title; + } + return self; +} +@end diff --git a/Firebase/InAppMessaging/FIRCore+InAppMessaging.h b/Firebase/InAppMessaging/FIRCore+InAppMessaging.h new file mode 100644 index 0000000000..e38593b26a --- /dev/null +++ b/Firebase/InAppMessaging/FIRCore+InAppMessaging.h @@ -0,0 +1,36 @@ +/* + * 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 + +// This file contains declarations that should go into FirebaseCore when +// Firebase InAppMessaging is merged into master. Keep them separate now to help +// with build from development folder and avoid merge conflicts. + +// this should eventually be in FIRLogger.h +extern FIRLoggerService kFIRLoggerInAppMessaging; + +// this should eventually be in FIRError.h +extern NSString *const kFirebaseInAppMessagingErrorDomain; + +// this should eventually be in FIRError.h FIRAppInternal.h:46: +extern NSString *const kFIRServiceInAppMessaging; + +// InAppMessaging 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 FIRInAppMessagingInstanceProvider +@end diff --git a/Firebase/InAppMessaging/FIRCore+InAppMessaging.m b/Firebase/InAppMessaging/FIRCore+InAppMessaging.m new file mode 100644 index 0000000000..219c48bedf --- /dev/null +++ b/Firebase/InAppMessaging/FIRCore+InAppMessaging.m @@ -0,0 +1,22 @@ +/* + * 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 +#import "FIRCore+InAppMessaging.h" + +NSString *const kFIRServiceInAppMessaging = @"InAppMessaging"; +NSString *const kFirebaseInAppMessagingErrorDomain = @"com.firebase.inappmessaging"; +FIRLoggerService kFIRLoggerInAppMessaging = @"[Firebase/InAppMessaging]"; diff --git a/Firebase/InAppMessaging/FIRInAppMessaging.m b/Firebase/InAppMessaging/FIRInAppMessaging.m new file mode 100644 index 0000000000..8ae243cbde --- /dev/null +++ b/Firebase/InAppMessaging/FIRInAppMessaging.m @@ -0,0 +1,144 @@ +/* + * 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 "FIRInAppMessaging.h" + +#import + +#import +#import +#import +#import +#import + +#import "FIRCore+InAppMessaging.h" +#import "FIRIAMDisplayExecutor.h" +#import "FIRIAMRuntimeManager.h" +#import "FIRInAppMessaging+Bootstrap.h" +#import "FIRInAppMessagingPrivate.h" + +static BOOL _autoBootstrapOnFIRAppInit = YES; + +@implementation FIRInAppMessaging { + BOOL _messageDisplaySuppressed; +} + +// Call this to present the SDK being auto bootstrapped with other Firebase SDKs. It needs +// to be triggered before [FIRApp configure] is executed. This should only be needed for +// testing app that wants to use custom fiam SDK settings. ++ (void)disableAutoBootstrapWithFIRApp { + _autoBootstrapOnFIRAppInit = NO; +} + +// extract macro value into a C string +#define STR_FROM_MACRO(x) #x +#define STR(x) STR_FROM_MACRO(x) + ++ (void)load { + [FIRApp + registerInternalLibrary:(Class)self + withName:@"fire-iam" + withVersion:[NSString stringWithUTF8String:STR(FIRInAppMessaging_LIB_VERSION)]]; +} + ++ (nonnull NSArray *)componentsToRegister { + FIRDependency *analyticsDep = [FIRDependency dependencyWithProtocol:@protocol(FIRAnalyticsInterop) + isRequired:YES]; + FIRComponentCreationBlock creationBlock = + ^id _Nullable(FIRComponentContainer *container, BOOL *isCacheable) { + // Ensure it's cached so it returns the same instance every time fiam is called. + *isCacheable = YES; + id analytics = FIR_COMPONENT(FIRAnalyticsInterop, container); + return [[FIRInAppMessaging alloc] initWithAnalytics:analytics]; + }; + FIRComponent *fiamProvider = + [FIRComponent componentWithProtocol:@protocol(FIRInAppMessagingInstanceProvider) + instantiationTiming:FIRInstantiationTimingLazy + dependencies:@[ analyticsDep ] + creationBlock:creationBlock]; + + return @[ fiamProvider ]; +} + ++ (void)configureWithApp:(FIRApp *)app { + if (!app.isDefaultApp) { + // Only configure for the default FIRApp. + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM170000", + @"Firebase InAppMessaging only works with the default app."); + return; + } + + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM170001", + @"Got notification for kFIRAppReadyToConfigureSDKNotification"); + if (_autoBootstrapOnFIRAppInit) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM170002", + @"Auto bootstrap Firebase in-app messaging SDK"); + [self bootstrapIAMFromFIRApp:app]; + } else { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM170003", + @"No auto bootstrap Firebase in-app messaging SDK"); + } +} + +- (instancetype)initWithAnalytics:(id)analytics { + if (self = [super init]) { + _messageDisplaySuppressed = NO; + _analytics = analytics; + } + return self; +} + ++ (FIRInAppMessaging *)inAppMessaging { + FIRApp *defaultApp = [FIRApp defaultApp]; // Missing configure will be logged here. + id inAppMessaging = + FIR_COMPONENT(FIRInAppMessagingInstanceProvider, defaultApp.container); + return (FIRInAppMessaging *)inAppMessaging; +} + +- (BOOL)messageDisplaySuppressed { + return _messageDisplaySuppressed; +} + +- (void)setMessageDisplaySuppressed:(BOOL)suppressed { + _messageDisplaySuppressed = suppressed; + [[FIRIAMRuntimeManager getSDKRuntimeInstance] setShouldSuppressMessageDisplay:suppressed]; +} + +- (BOOL)automaticDataCollectionEnabled { + return [FIRIAMRuntimeManager getSDKRuntimeInstance].automaticDataCollectionEnabled; +} + +- (void)setAutomaticDataCollectionEnabled:(BOOL)automaticDataCollectionEnabled { + [FIRIAMRuntimeManager getSDKRuntimeInstance].automaticDataCollectionEnabled = + automaticDataCollectionEnabled; +} + +- (void)setMessageDisplayComponent:(id)messageDisplayComponent { + _messageDisplayComponent = messageDisplayComponent; + + if (messageDisplayComponent == nil) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM290002", @"messageDisplayComponent set to nil."); + } else { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM290001", + @"Setting a non-nil message display component"); + } + + // Forward the setting to the display executor. + [FIRIAMRuntimeManager getSDKRuntimeInstance].displayExecutor.messageDisplayComponent = + messageDisplayComponent; +} + +@end diff --git a/Firebase/InAppMessaging/FIRInAppMessagingPrivate.h b/Firebase/InAppMessaging/FIRInAppMessagingPrivate.h new file mode 100644 index 0000000000..87c0a3cd7b --- /dev/null +++ b/Firebase/InAppMessaging/FIRInAppMessagingPrivate.h @@ -0,0 +1,26 @@ +/* + * 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 +#import "FIRCore+InAppMessaging.h" +#import "FIRInAppMessaging.h" + +@protocol FIRInAppMessagingInstanceProvider; +@protocol FIRLibrary; + +@interface FIRInAppMessaging () +@property(nonatomic, readwrite, strong) id _Nullable analytics; +@end diff --git a/Firebase/InAppMessaging/Flows/FIRIAMActivityLogger.h b/Firebase/InAppMessaging/Flows/FIRIAMActivityLogger.h new file mode 100644 index 0000000000..82fb06f41a --- /dev/null +++ b/Firebase/InAppMessaging/Flows/FIRIAMActivityLogger.h @@ -0,0 +1,89 @@ +/* + * 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 + +/// Values for different fiam activity types. +typedef NS_ENUM(NSInteger, FIRIAMActivityType) { + FIRIAMActivityTypeFetchMessage = 0, + FIRIAMActivityTypeRenderMessage = 1, + FIRIAMActivityTypeDismissMessage = 2, + + // Triggered checks + FIRIAMActivityTypeCheckForOnOpenMessage = 3, + FIRIAMActivityTypeCheckForAnalyticsEventMessage = 4, + FIRIAMActivityTypeCheckForFetch = 5, +}; + +NS_ASSUME_NONNULL_BEGIN +@interface FIRIAMActivityRecord : NSObject +@property(nonatomic, nonnull, readonly) NSDate *timestamp; +@property(nonatomic, readonly) FIRIAMActivityType activityType; +@property(nonatomic, readonly) BOOL success; +@property(nonatomic, copy, nonnull, readonly) NSString *detail; + +- (instancetype)init NS_UNAVAILABLE; +// Current timestamp would be fetched if parameter 'timestamp' is passed in as null +- (instancetype)initWithActivityType:(FIRIAMActivityType)type + isSuccessful:(BOOL)isSuccessful + withDetail:(NSString *)detail + timestamp:(nullable NSDate *)timestamp; + +- (NSString *)displayStringForActivityType; +@end + +/** + * This is the class for tracking fiam flow related activity logs. Its content can later on be + * retrieved for debugging/reporting purpose. + */ +@interface FIRIAMActivityLogger : NSObject + +// If it's NO, activity logs of certain types won't get recorded by Logger. Consult +// isMandatoryType implementation to tell what are the types belong to verbose mode +// Turn it on for debugging cases +@property(nonatomic, readonly) BOOL verboseMode; + +- (instancetype)init NS_UNAVAILABLE; + +/** + * Parameter maxBeforeReduce and sizeAfterReduce defines the shrinking behavior when we reach + * the size cap of log storage: when we see the number of log records goes beyond + * maxBeforeReduce, we would trigger a reduction action which would bring the array length to be + * the size as defined by sizeAfterReduce + * + * @param verboseMode see the comments for the verboseMode property + * @param loadFromCache loads from cache to initialize the log list if it's true. Be aware that + * in this case, you should not call this method in main thread since reading the cache file + * can take time. + */ +- (instancetype)initWithMaxCountBeforeReduce:(NSInteger)maxBeforeReduce + withSizeAfterReduce:(NSInteger)sizeAfterReduce + verboseMode:(BOOL)verboseMode + loadFromCache:(BOOL)loadFromCache; + +/** + * Inserting a new record into activity log. + * + * @param newRecord new record to be inserted + */ +- (void)addLogRecord:(FIRIAMActivityRecord *)newRecord; + +/** + * Get a immutable copy of the existing activity log records. + */ +- (NSArray *)readRecords; +@end +NS_ASSUME_NONNULL_END diff --git a/Firebase/InAppMessaging/Flows/FIRIAMActivityLogger.m b/Firebase/InAppMessaging/Flows/FIRIAMActivityLogger.m new file mode 100644 index 0000000000..95c344847a --- /dev/null +++ b/Firebase/InAppMessaging/Flows/FIRIAMActivityLogger.m @@ -0,0 +1,215 @@ +/* + * 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 + +#import + +#import "FIRCore+InAppMessaging.h" +#import "FIRIAMActivityLogger.h" +@implementation FIRIAMActivityRecord + +static NSString *const kActiveTypeArchiveKey = @"type"; +static NSString *const kIsSuccessArchiveKey = @"is_success"; +static NSString *const kTimeStampArchiveKey = @"timestamp"; +static NSString *const kDetailArchiveKey = @"detail"; + +- (id)initWithCoder:(NSCoder *)decoder { + self = [super init]; + if (self != nil) { + _activityType = [decoder decodeIntegerForKey:kActiveTypeArchiveKey]; + _timestamp = [decoder decodeObjectForKey:kTimeStampArchiveKey]; + _success = [decoder decodeBoolForKey:kIsSuccessArchiveKey]; + _detail = [decoder decodeObjectForKey:kDetailArchiveKey]; + } + return self; +} + +- (void)encodeWithCoder:(NSCoder *)encoder { + [encoder encodeInteger:self.activityType forKey:kActiveTypeArchiveKey]; + [encoder encodeObject:self.timestamp forKey:kTimeStampArchiveKey]; + [encoder encodeBool:self.success forKey:kIsSuccessArchiveKey]; + [encoder encodeObject:self.detail forKey:kDetailArchiveKey]; +} + +- (instancetype)initWithActivityType:(FIRIAMActivityType)type + isSuccessful:(BOOL)isSuccessful + withDetail:(NSString *)detail + timestamp:(nullable NSDate *)timestamp { + if (self = [super init]) { + _activityType = type; + _success = isSuccessful; + _detail = detail; + _timestamp = timestamp ? timestamp : [[NSDate alloc] init]; + } + return self; +} + +- (NSString *)displayStringForActivityType { + switch (self.activityType) { + case FIRIAMActivityTypeFetchMessage: + return @"Message Fetching"; + case FIRIAMActivityTypeRenderMessage: + return @"Message Rendering"; + case FIRIAMActivityTypeDismissMessage: + return @"Message Dismiss"; + case FIRIAMActivityTypeCheckForOnOpenMessage: + return @"OnOpen Msg Check"; + case FIRIAMActivityTypeCheckForAnalyticsEventMessage: + return @"Analytic Msg Check"; + case FIRIAMActivityTypeCheckForFetch: + return @"Fetch Check"; + } +} +@end + +@interface FIRIAMActivityLogger () +@property(nonatomic) BOOL isDirty; + +// always insert at the head of this array so that they are always in anti-chronological order +@property(nonatomic, nonnull) NSMutableArray *activityRecords; + +// When we see the number of log records goes beyond maxRecordCountBeforeReduce, we would trigger +// a reduction action which would bring the array length to be the size as defined by +// newSizeAfterReduce +@property(nonatomic, readonly) NSInteger maxRecordCountBeforeReduce; +@property(nonatomic, readonly) NSInteger newSizeAfterReduce; + +@end + +@implementation FIRIAMActivityLogger +- (instancetype)initWithMaxCountBeforeReduce:(NSInteger)maxBeforeReduce + withSizeAfterReduce:(NSInteger)sizeAfterReduce + verboseMode:(BOOL)verboseMode + loadFromCache:(BOOL)loadFromCache { + if (self = [super init]) { + _maxRecordCountBeforeReduce = maxBeforeReduce; + _newSizeAfterReduce = sizeAfterReduce; + _activityRecords = [[NSMutableArray alloc] init]; + _verboseMode = verboseMode; + _isDirty = NO; + + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(appWillBecomeInactive) + name:UIApplicationWillResignActiveNotification + object:nil]; + + if (loadFromCache) { + @try { + [self loadFromCachePath:nil]; + } @catch (NSException *exception) { + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM310003", + @"Non-fatal exception in loading persisted activity log records: %@.", + exception); + } + } + } + return self; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + ++ (NSString *)determineCacheFilePath { + static NSString *logCachePath; + static dispatch_once_t onceToken; + + dispatch_once(&onceToken, ^{ + NSString *cacheDirPath = + NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES)[0]; + logCachePath = [NSString stringWithFormat:@"%@/firebase-iam-activity-log-cache", cacheDirPath]; + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM310001", + @"Persistent file path for activity log data is %@", logCachePath); + }); + return logCachePath; +} + +- (void)loadFromCachePath:(NSString *)cacheFilePath { + NSString *filePath = cacheFilePath == nil ? [self.class determineCacheFilePath] : cacheFilePath; + + id fetchedActivityRecords = [NSKeyedUnarchiver unarchiveObjectWithFile:filePath]; + + if (fetchedActivityRecords) { + @synchronized(self) { + self.activityRecords = (NSMutableArray *)fetchedActivityRecords; + self.isDirty = NO; + } + } +} + +- (BOOL)saveIntoCacheWithPath:(NSString *)cacheFilePath { + NSString *filePath = cacheFilePath == nil ? [self.class determineCacheFilePath] : cacheFilePath; + @synchronized(self) { + BOOL result = [NSKeyedArchiver archiveRootObject:self.activityRecords toFile:filePath]; + if (result) { + self.isDirty = NO; + } + return result; + } +} + +- (void)appWillBecomeInactive { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM310004", + @"App will become inactive, save" + " activity logs"); + + if (self.isDirty) { + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0ul), ^{ + if ([self saveIntoCacheWithPath:nil]) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM310002", + @"Persisting activity log data is was successful"); + } else { + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM310005", + @"Persisting activity log data has failed"); + } + }); + } +} + +// Helper function to determine if a given activity type should be recorded under +// non verbose type. ++ (BOOL)isMandatoryType:(FIRIAMActivityType)type { + switch (type) { + case FIRIAMActivityTypeFetchMessage: + case FIRIAMActivityTypeRenderMessage: + case FIRIAMActivityTypeDismissMessage: + return YES; + default: + return NO; + } +} + +- (void)addLogRecord:(FIRIAMActivityRecord *)newRecord { + if (self.verboseMode || [FIRIAMActivityLogger isMandatoryType:newRecord.activityType]) { + @synchronized(self) { + [self.activityRecords insertObject:newRecord atIndex:0]; + + if (self.activityRecords.count >= self.maxRecordCountBeforeReduce) { + NSRange removeRange; + removeRange.location = self.newSizeAfterReduce; + removeRange.length = self.maxRecordCountBeforeReduce - self.newSizeAfterReduce; + [self.activityRecords removeObjectsInRange:removeRange]; + } + self.isDirty = YES; + } + } +} + +- (NSArray *)readRecords { + return [self.activityRecords copy]; +} +@end diff --git a/Firebase/InAppMessaging/Flows/FIRIAMBookKeeper.h b/Firebase/InAppMessaging/Flows/FIRIAMBookKeeper.h new file mode 100644 index 0000000000..1c9a11045a --- /dev/null +++ b/Firebase/InAppMessaging/Flows/FIRIAMBookKeeper.h @@ -0,0 +1,75 @@ +/* + * 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 + +NS_ASSUME_NONNULL_BEGIN +@interface FIRIAMImpressionRecord : NSObject +@property(nonatomic, readonly, copy) NSString *messageID; +@property(nonatomic, readonly) long impressionTimeInSeconds; + +- (NSString *)description; + +- (instancetype)init NS_UNAVAILABLE; +- (instancetype)initWithMessageID:(NSString *)messageID + impressionTimeInSeconds:(long)impressionTime NS_DESIGNATED_INITIALIZER; +@end + +// this protocol defines the interface for classes that can be used to track info regarding +// display & fetch of iam messages. The info tracked here can be used to decide if it's due for +// next display and/or fetch of iam messages. +@protocol FIRIAMBookKeeper +@property(nonatomic, readonly) double lastDisplayTime; +@property(nonatomic, readonly) double lastFetchTime; +@property(nonatomic, readonly) NSTimeInterval nextFetchWaitTime; + +// only call this when it's considered to be a valid impression (for example, meeting the minimum +// display time requirement). +- (void)recordNewImpressionForMessage:(NSString *)messageID + withStartTimestampInSeconds:(double)timestamp; + +- (void)recordNewFetchWithFetchCount:(NSInteger)fetchedMsgCount + withTimestampInSeconds:(double)fetchTimestamp + nextFetchWaitTime:(nullable NSNumber *)nextFetchWaitTime; + +// When we fetch the eligible message list from the sdk server, it can contain messages that are +// already impressed for those that are defined to be displayed repeatedly (messages with custom +// display frequency). We need then clean up the impression records for these messages so that +// they can be displayed again on client side. +- (void)clearImpressionsWithMessageList:(NSArray *)messageList; +// fetch the impression list +- (NSArray *)getImpressions; + +// For certain clients, they only need to get the list of the message ids in existing impression +// records. This is a helper method for that. +- (NSArray *)getMessageIDsFromImpressions; +@end + +// implementation of FIRIAMBookKeeper protocol by storing data within iOS UserDefaults. +// TODO: switch to something else if there is risks for the data being unintentionally deleted by +// the app +@interface FIRIAMBookKeeperViaUserDefaults : NSObject + +- (instancetype)init NS_UNAVAILABLE; +- (instancetype)initWithUserDefaults:(NSUserDefaults *)userDefaults NS_DESIGNATED_INITIALIZER; + +// for testing, don't use them for production purpose +- (void)cleanupImpressions; +- (void)cleanupFetchRecords; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Firebase/InAppMessaging/Flows/FIRIAMBookKeeper.m b/Firebase/InAppMessaging/Flows/FIRIAMBookKeeper.m new file mode 100644 index 0000000000..a7019d7bd8 --- /dev/null +++ b/Firebase/InAppMessaging/Flows/FIRIAMBookKeeper.m @@ -0,0 +1,260 @@ +/* + * 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 + +#import "FIRCore+InAppMessaging.h" +#import "FIRIAMBookKeeper.h" + +NSString *const FIRIAM_UserDefaultsKeyForImpressions = @"firebase-iam-message-impressions"; +NSString *const FIRIAM_UserDefaultsKeyForLastImpressionTimestamp = + @"firebase-iam-last-impression-timestamp"; +NSString *FIRIAM_UserDefaultsKeyForLastFetchTimestamp = @"firebase-iam-last-fetch-timestamp"; + +// The two keys used to map FIRIAMImpressionRecord object to a NSDictionary object for +// persistence. +NSString *const FIRIAM_ImpressionDictKeyForID = @"message_id"; +NSString *const FIRIAM_ImpressionDictKeyForTimestamp = @"impression_time"; + +static NSString *const kUserDefaultsKeyForFetchWaitTime = @"firebase-iam-fetch-wait-time"; + +// 24 hours +static NSTimeInterval kDefaultFetchWaitTimeInSeconds = 24 * 60 * 60; + +// 3 days +static NSTimeInterval kMaxFetchWaitTimeInSeconds = 3 * 24 * 60 * 60; + +@interface FIRIAMBookKeeperViaUserDefaults () +@property(nonatomic) double lastDisplayTime; +@property(nonatomic) double lastFetchTime; +@property(nonatomic) double nextFetchWaitTime; +@property(nonatomic, nonnull) NSUserDefaults *defaults; +@end + +@interface FIRIAMImpressionRecord () +- (instancetype)initWithStorageDictionary:(NSDictionary *)dict; +@end + +@implementation FIRIAMImpressionRecord + +- (instancetype)initWithMessageID:(NSString *)messageID + impressionTimeInSeconds:(long)impressionTime { + if (self = [super init]) { + _messageID = messageID; + _impressionTimeInSeconds = impressionTime; + } + return self; +} + +- (instancetype)initWithStorageDictionary:(NSDictionary *)dict { + id timestamp = dict[FIRIAM_ImpressionDictKeyForTimestamp]; + id messageID = dict[FIRIAM_ImpressionDictKeyForID]; + + if (![timestamp isKindOfClass:[NSNumber class]] || ![messageID isKindOfClass:[NSString class]]) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM270003", + @"Incorrect data in the dictionary object for creating a FIRIAMImpressionRecord" + " object"); + return nil; + } else { + return [self initWithMessageID:messageID + impressionTimeInSeconds:((NSNumber *)timestamp).longValue]; + } +} + +- (NSString *)description { + return [NSString stringWithFormat:@"%@ impressed at %ld in seconds", self.messageID, + self.impressionTimeInSeconds]; +} +@end + +@implementation FIRIAMBookKeeperViaUserDefaults + +- (instancetype)initWithUserDefaults:(NSUserDefaults *)userDefaults { + if (self = [super init]) { + _defaults = userDefaults; + + // ok if it returns 0 due to the entry being absent + _lastDisplayTime = [_defaults doubleForKey:FIRIAM_UserDefaultsKeyForLastImpressionTimestamp]; + _lastFetchTime = [_defaults doubleForKey:FIRIAM_UserDefaultsKeyForLastFetchTimestamp]; + + id fetchWaitTimeEntry = [_defaults objectForKey:kUserDefaultsKeyForFetchWaitTime]; + + if (![fetchWaitTimeEntry isKindOfClass:NSNumber.class]) { + // This corresponds to the case there is no wait time entry is set in user defaults yet + _nextFetchWaitTime = kDefaultFetchWaitTimeInSeconds; + } else { + _nextFetchWaitTime = ((NSNumber *)fetchWaitTimeEntry).doubleValue; + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM270009", + @"Next fetch wait time loaded from user defaults is %lf", _nextFetchWaitTime); + } + } + return self; +} + +// A helper function for reading and verifying the stored array data for impressions +// in UserDefaults. It returns nil if it does not exist or fail to pass the data type +// checking. +- (NSArray *)fetchImpressionArrayFromStorage { + id impressionsData = [self.defaults objectForKey:FIRIAM_UserDefaultsKeyForImpressions]; + + if (impressionsData && ![impressionsData isKindOfClass:[NSArray class]]) { + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM270007", + @"Found non-array data from impression userdefaults storage with key %@", + FIRIAM_UserDefaultsKeyForImpressions); + return nil; + } + return (NSArray *)impressionsData; +} + +- (void)recordNewImpressionForMessage:(NSString *)messageID + withStartTimestampInSeconds:(double)timestamp { + @synchronized(self) { + NSArray *oldImpressions = [self fetchImpressionArrayFromStorage]; + // oldImpressions could be nil at the first time + NSMutableArray *newImpressions = + oldImpressions ? [oldImpressions mutableCopy] : [[NSMutableArray alloc] init]; + + // Two cases + // If a prior impression exists for that messageID, update its impression timestamp + // If a prior impression for that messageID does not exist, add a new entry for the + // messageID. + + NSDictionary *newImpressionEntry = @{ + FIRIAM_ImpressionDictKeyForID : messageID, + FIRIAM_ImpressionDictKeyForTimestamp : [NSNumber numberWithDouble:timestamp] + }; + + BOOL oldImpressionRecordFound = NO; + + for (int i = 0; i < newImpressions.count; i++) { + if ([newImpressions[i] isKindOfClass:[NSDictionary class]]) { + NSDictionary *currentItem = (NSDictionary *)newImpressions[i]; + if ([messageID isEqualToString:currentItem[FIRIAM_ImpressionDictKeyForID]]) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM270001", + @"Updating timestamp of existing impression record to be %f for " + "message %@", + timestamp, messageID); + + [newImpressions replaceObjectAtIndex:i withObject:newImpressionEntry]; + oldImpressionRecordFound = YES; + break; + } + } + } + + if (!oldImpressionRecordFound) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM270002", + @"Insert the first impression record for message %@ with timestamp in seconds " + "as %f", + messageID, timestamp); + [newImpressions addObject:newImpressionEntry]; + } + + [self.defaults setObject:newImpressions forKey:FIRIAM_UserDefaultsKeyForImpressions]; + [self.defaults setDouble:timestamp forKey:FIRIAM_UserDefaultsKeyForLastImpressionTimestamp]; + self.lastDisplayTime = timestamp; + } +} + +- (void)clearImpressionsWithMessageList:(NSArray *)messageList { + @synchronized(self) { + NSArray *existingImpressions = [self fetchImpressionArrayFromStorage]; + + NSSet *messageIDSet = [NSSet setWithArray:messageList]; + NSPredicate *notInMessageListPredicate = + [NSPredicate predicateWithBlock:^BOOL(id evaluatedObject, NSDictionary *bindings) { + if (![evaluatedObject isKindOfClass:[NSDictionary class]]) { + return NO; // unexpected item. Throw it away + } + NSDictionary *impression = (NSDictionary *)evaluatedObject; + return impression[FIRIAM_ImpressionDictKeyForID] && + ![messageIDSet containsObject:impression[FIRIAM_ImpressionDictKeyForID]]; + }]; + + NSArray *updatedImpressions = + [existingImpressions filteredArrayUsingPredicate:notInMessageListPredicate]; + + if (existingImpressions.count != updatedImpressions.count) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM270004", + @"Updating the impression records after purging %d items based on the " + "server fetch response", + (int)(existingImpressions.count - updatedImpressions.count)); + [self.defaults setObject:updatedImpressions forKey:FIRIAM_UserDefaultsKeyForImpressions]; + } else { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM270005", + @"No impression records update due to no change after applying the server " + "message list"); + } + } +} + +- (NSArray *)getImpressions { + NSArray *impressionsFromStorage = [self fetchImpressionArrayFromStorage]; + + NSMutableArray *resultArray = [[NSMutableArray alloc] init]; + + for (NSDictionary *next in impressionsFromStorage) { + FIRIAMImpressionRecord *nextImpression = + [[FIRIAMImpressionRecord alloc] initWithStorageDictionary:next]; + [resultArray addObject:nextImpression]; + } + + return resultArray; +} + +- (NSArray *)getMessageIDsFromImpressions { + NSArray *impressionsFromStorage = [self fetchImpressionArrayFromStorage]; + + NSMutableArray *resultArray = [[NSMutableArray alloc] init]; + + for (NSDictionary *next in impressionsFromStorage) { + [resultArray addObject:next[FIRIAM_ImpressionDictKeyForID]]; + } + + return resultArray; +} + +- (void)recordNewFetchWithFetchCount:(NSInteger)fetchedMsgCount + withTimestampInSeconds:(double)fetchTimestamp + nextFetchWaitTime:(nullable NSNumber *)nextFetchWaitTime; +{ + [self.defaults setDouble:fetchTimestamp forKey:FIRIAM_UserDefaultsKeyForLastFetchTimestamp]; + self.lastFetchTime = fetchTimestamp; + + if (nextFetchWaitTime) { + if (nextFetchWaitTime.doubleValue > kMaxFetchWaitTimeInSeconds) { + FIRLogInfo(kFIRLoggerInAppMessaging, @"I-IAM270006", + @"next fetch wait time %lf is too large. Ignore it.", + nextFetchWaitTime.doubleValue); + } else { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM270008", + @"Setting next fetch wait time as %lf from fetch response.", + nextFetchWaitTime.doubleValue); + self.nextFetchWaitTime = nextFetchWaitTime.doubleValue; + [self.defaults setObject:nextFetchWaitTime forKey:kUserDefaultsKeyForFetchWaitTime]; + } + } +} + +- (void)cleanupImpressions { + [self.defaults setObject:@[] forKey:FIRIAM_UserDefaultsKeyForImpressions]; +} + +- (void)cleanupFetchRecords { + [self.defaults setDouble:0 forKey:FIRIAM_UserDefaultsKeyForLastFetchTimestamp]; + self.lastFetchTime = 0; +} +@end diff --git a/Firebase/InAppMessaging/Flows/FIRIAMClientInfoFetcher.h b/Firebase/InAppMessaging/Flows/FIRIAMClientInfoFetcher.h new file mode 100644 index 0000000000..09b1e6a2ff --- /dev/null +++ b/Firebase/InAppMessaging/Flows/FIRIAMClientInfoFetcher.h @@ -0,0 +1,39 @@ +/* + * 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 + +// A class for wrapping the interactions for retrieving client side info to be used in request +// parameter for interacting with Firebase iam servers. + +NS_ASSUME_NONNULL_BEGIN +@interface FIRIAMClientInfoFetcher : NSObject +// Fetch the up-to-date Firebase instance id and token data. Since it involves a server interaction, +// completion callback is provided for receiving the result. +- (void)fetchFirebaseIIDDataWithProjectNumber:(NSString *)projectNumber + withCompletion:(void (^)(NSString *_Nullable iid, + NSString *_Nullable token, + NSError *_Nullable error))completion; + +// Following are synchronous methods for fetching data +- (nullable NSString *)getDeviceLanguageCode; +- (nullable NSString *)getAppVersion; +- (nullable NSString *)getOSVersion; +- (nullable NSString *)getOSMajorVersion; +- (nullable NSString *)getTimezone; +- (NSString *)getIAMSDKVersion; +@end +NS_ASSUME_NONNULL_END diff --git a/Firebase/InAppMessaging/Flows/FIRIAMClientInfoFetcher.m b/Firebase/InAppMessaging/Flows/FIRIAMClientInfoFetcher.m new file mode 100644 index 0000000000..b780977052 --- /dev/null +++ b/Firebase/InAppMessaging/Flows/FIRIAMClientInfoFetcher.m @@ -0,0 +1,120 @@ +/* + * 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 +#import +#import + +#import "FIRCore+InAppMessaging.h" +#import "FIRIAMClientInfoFetcher.h" + +// declaratons for FIRInstanceID SDK +@implementation FIRIAMClientInfoFetcher +- (void)fetchFirebaseIIDDataWithProjectNumber:(NSString *)projectNumber + withCompletion:(void (^)(NSString *_Nullable iid, + NSString *_Nullable token, + NSError *_Nullable error))completion { + FIRInstanceID *iid = [FIRInstanceID instanceID]; + + // tokenWithAuthorizedEntity would only communicate with server on periodical cycles. + // For other times, it's going to fetch from local cache, so it's not causing any performance + // concern in the fetch flow. + [iid tokenWithAuthorizedEntity:projectNumber + scope:@"fiam" + options:nil + handler:^(NSString *_Nullable token, NSError *_Nullable error) { + if (error) { + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM190001", + @"Error in fetching iid token: %@", + error.localizedDescription); + completion(nil, nil, error); + } else { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM190002", + @"Successfully generated iid token"); + // now we can go ahead to fetch the id + [iid getIDWithHandler:^(NSString *_Nullable identity, + NSError *_Nullable error) { + if (error) { + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM190004", + @"Error in fetching iid value: %@", + error.localizedDescription); + } else { + FIRLogDebug( + kFIRLoggerInAppMessaging, @"I-IAM190005", + @"Successfully in fetching both iid value as %@ and iid token" + " as %@", + identity, token); + completion(identity, token, nil); + } + }]; + } + }]; +} + +- (nullable NSString *)getDeviceLanguageCode { + // No caching since it's requested at pretty low frequency and we get the benefit of seeing + // updated info the setting has changed + NSArray *preferredLanguages = [NSLocale preferredLanguages]; + return preferredLanguages.firstObject; +} + +- (nullable NSString *)getAppVersion { + // Since this won't change, read it once in the whole life-cycle of the app and cache its value + static NSString *appVersion = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + appVersion = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleShortVersionString"]; + }); + return appVersion; +} + +- (nullable NSString *)getOSVersion { + // Since this won't change, read it once in the whole life-cycle of the app and cache its value + static NSString *OSVersion = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + NSOperatingSystemVersion systemVersion = [NSProcessInfo processInfo].operatingSystemVersion; + OSVersion = [NSString stringWithFormat:@"%ld.%ld.%ld", (long)systemVersion.majorVersion, + (long)systemVersion.minorVersion, + (long)systemVersion.patchVersion]; + }); + return OSVersion; +} + +- (nullable NSString *)getOSMajorVersion { + NSArray *versionItems = [[self getOSVersion] componentsSeparatedByString:@"."]; + + if (versionItems.count > 0) { + return (NSString *)versionItems[0]; + } else { + return nil; + } +} + +- (nullable NSString *)getTimezone { + // No caching to deal with potential changes. + return [NSTimeZone localTimeZone].name; +} + +// extract macro value into a C string +#define STR_FROM_MACRO(x) #x +#define STR(x) STR_FROM_MACRO(x) + +- (NSString *)getIAMSDKVersion { + // FIRInAppMessaging_LIB_VERSION macro comes from pod definition + return [NSString stringWithUTF8String:STR(FIRInAppMessaging_LIB_VERSION)]; +} +@end diff --git a/Firebase/InAppMessaging/Flows/FIRIAMDisplayCheckOnAnalyticEventsFlow.h b/Firebase/InAppMessaging/Flows/FIRIAMDisplayCheckOnAnalyticEventsFlow.h new file mode 100644 index 0000000000..5d379911fe --- /dev/null +++ b/Firebase/InAppMessaging/Flows/FIRIAMDisplayCheckOnAnalyticEventsFlow.h @@ -0,0 +1,22 @@ +/* + * 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 "FIRIAMDisplayCheckTriggerFlow.h" + +// An implementation of FIRIAMDisplayCheckTriggerFlow by triggering the display check when +// a Firebase Analytics event is fired. +@interface FIRIAMDisplayCheckOnAnalyticEventsFlow : FIRIAMDisplayCheckTriggerFlow +@end diff --git a/Firebase/InAppMessaging/Flows/FIRIAMDisplayCheckOnAnalyticEventsFlow.m b/Firebase/InAppMessaging/Flows/FIRIAMDisplayCheckOnAnalyticEventsFlow.m new file mode 100644 index 0000000000..e83ba0a62c --- /dev/null +++ b/Firebase/InAppMessaging/Flows/FIRIAMDisplayCheckOnAnalyticEventsFlow.m @@ -0,0 +1,66 @@ +/* + * 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 +#import +#import + +#import "FIRCore+InAppMessaging.h" +#import "FIRIAMDisplayCheckOnAnalyticEventsFlow.h" +#import "FIRIAMDisplayExecutor.h" +#import "FIRInAppMessagingPrivate.h" + +@interface FIRIAMDisplayCheckOnAnalyticEventsFlow () +@end + +@implementation FIRIAMDisplayCheckOnAnalyticEventsFlow { + dispatch_queue_t eventListenerQueue; +} + +- (void)start { + @synchronized(self) { + if (eventListenerQueue == nil) { + eventListenerQueue = + dispatch_queue_create("com.google.firebase.inappmessage.firevent_listener", NULL); + } + + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM140002", + @"Start observing Firebase Analytics events for rendering messages."); + + [[FIRInAppMessaging inAppMessaging].analytics registerAnalyticsListener:self + withOrigin:@"fiam"]; + } +} + +- (void)messageTriggered:(NSString *)name parameters:(NSDictionary *)parameters { + // Dispatch to a serial queue eventListenerQueue to avoid the complications that two + // concurrent Firebase Analytics events triggering the + // checkAndDisplayNextContextualMessageForAnalyticsEvent flow concurrently. + dispatch_async(self->eventListenerQueue, ^{ + [self.displayExecutor checkAndDisplayNextContextualMessageForAnalyticsEvent:name]; + }); +} + +- (void)stop { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM140003", + @"Stop observing Firebase Analytics events for display check."); + + @synchronized(self) { + [[FIRInAppMessaging inAppMessaging].analytics unregisterAnalyticsListenerWithOrigin:@"fiam"]; + } +} + +@end diff --git a/Firebase/InAppMessaging/Flows/FIRIAMDisplayCheckOnAppForegroundFlow.h b/Firebase/InAppMessaging/Flows/FIRIAMDisplayCheckOnAppForegroundFlow.h new file mode 100644 index 0000000000..f69ee97c79 --- /dev/null +++ b/Firebase/InAppMessaging/Flows/FIRIAMDisplayCheckOnAppForegroundFlow.h @@ -0,0 +1,23 @@ +/* + * 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 "FIRIAMDisplayCheckTriggerFlow.h" + +// an implementation of FIRIAMDisplayExecutor by triggering the display when app is foregrounded +@interface FIRIAMDisplayCheckOnAppForegroundFlow : FIRIAMDisplayCheckTriggerFlow +- (void)start; +- (void)stop; +@end diff --git a/Firebase/InAppMessaging/Flows/FIRIAMDisplayCheckOnAppForegroundFlow.m b/Firebase/InAppMessaging/Flows/FIRIAMDisplayCheckOnAppForegroundFlow.m new file mode 100644 index 0000000000..11d880ae15 --- /dev/null +++ b/Firebase/InAppMessaging/Flows/FIRIAMDisplayCheckOnAppForegroundFlow.m @@ -0,0 +1,55 @@ +/* + * 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 + +#import "FIRCore+InAppMessaging.h" +#import "FIRIAMDisplayCheckOnAppForegroundFlow.h" +#import "FIRIAMDisplayExecutor.h" + +@implementation FIRIAMDisplayCheckOnAppForegroundFlow + +- (void)start { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM500002", + @"Start observing app foreground notifications for rendering messages."); + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(appWillEnterForeground:) + name:UIApplicationWillEnterForegroundNotification + object:nil]; +} + +- (void)appWillEnterForeground:(UIApplication *)application { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM500001", + @"App foregrounded, wake up to check in-app messaging."); + + // Show the message with 0.5 second delay so that the app's UI is more stable. + // When messages are displayed, the UI operation will be dispatched back to main UI thread. + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 500 * (int64_t)NSEC_PER_MSEC), + dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0ul), ^{ + [self.displayExecutor checkAndDisplayNextAppForegroundMessage]; + }); +} + +- (void)stop { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM500004", + @"Stop observing app foreground notifications."); + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} +@end diff --git a/Firebase/InAppMessaging/Flows/FIRIAMDisplayCheckOnFetchDoneNotificationFlow.h b/Firebase/InAppMessaging/Flows/FIRIAMDisplayCheckOnFetchDoneNotificationFlow.h new file mode 100644 index 0000000000..834561dd4d --- /dev/null +++ b/Firebase/InAppMessaging/Flows/FIRIAMDisplayCheckOnFetchDoneNotificationFlow.h @@ -0,0 +1,22 @@ +/* + * Copyright 2018 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 "FIRIAMDisplayCheckTriggerFlow.h" + +@interface FIRIAMDisplayCheckOnFetchDoneNotificationFlow : FIRIAMDisplayCheckTriggerFlow +- (void)start; +- (void)stop; +@end diff --git a/Firebase/InAppMessaging/Flows/FIRIAMDisplayCheckOnFetchDoneNotificationFlow.m b/Firebase/InAppMessaging/Flows/FIRIAMDisplayCheckOnFetchDoneNotificationFlow.m new file mode 100644 index 0000000000..227900e1cf --- /dev/null +++ b/Firebase/InAppMessaging/Flows/FIRIAMDisplayCheckOnFetchDoneNotificationFlow.m @@ -0,0 +1,62 @@ +/* + * Copyright 2018 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 + +#import "FIRCore+InAppMessaging.h" + +#import "FIRIAMDisplayCheckOnFetchDoneNotificationFlow.h" +#import "FIRIAMDisplayExecutor.h" + +extern NSString *const kFIRIAMFetchIsDoneNotification; + +@implementation FIRIAMDisplayCheckOnFetchDoneNotificationFlow + +- (void)start { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM240001", + @"Start observing fetch done notifications for rendering messages."); + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(fetchIsDone) + name:kFIRIAMFetchIsDoneNotification + object:nil]; +} + +- (void)checkAndRenderMessage { + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0ul), ^{ + [self.displayExecutor checkAndDisplayNextAppForegroundMessage]; + }); +} + +- (void)fetchIsDone { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM240002", + @"Fetch is done. Start message rendering flow."); + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 500 * (int64_t)NSEC_PER_MSEC), + dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0ul), ^{ + [self checkAndRenderMessage]; + }); +} + +- (void)stop { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM240003", + @"Stop observing fetch is done notifications."); + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} +@end diff --git a/Firebase/InAppMessaging/Flows/FIRIAMDisplayCheckTriggerFlow.h b/Firebase/InAppMessaging/Flows/FIRIAMDisplayCheckTriggerFlow.h new file mode 100644 index 0000000000..ed05c784b7 --- /dev/null +++ b/Firebase/InAppMessaging/Flows/FIRIAMDisplayCheckTriggerFlow.h @@ -0,0 +1,37 @@ +/* + * 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 + +@class FIRIAMDisplayExecutor; +NS_ASSUME_NONNULL_BEGIN + +// Parent class for modeling different flows in which we would trigger the check to see if there +// is appropriate in-app messaging to be rendered. Notice that the flow only triggers the check +// and whether it turns out to have any eligible message to be displayed depending on if certain +// conditions are met +@interface FIRIAMDisplayCheckTriggerFlow : NSObject + +// Accessed by subclasses, not intended by other clients +@property(nonatomic, nonnull, readonly) FIRIAMDisplayExecutor *displayExecutor; +- (instancetype)initWithDisplayFlow:(FIRIAMDisplayExecutor *)displayExecutor; + +// subclasses should implement the follow two methods to start/stop their concrete +// display check flow +- (void)start; +- (void)stop; +@end +NS_ASSUME_NONNULL_END diff --git a/Firebase/InAppMessaging/Flows/FIRIAMDisplayCheckTriggerFlow.m b/Firebase/InAppMessaging/Flows/FIRIAMDisplayCheckTriggerFlow.m new file mode 100644 index 0000000000..e95e786ef2 --- /dev/null +++ b/Firebase/InAppMessaging/Flows/FIRIAMDisplayCheckTriggerFlow.m @@ -0,0 +1,32 @@ +/* + * 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 "FIRIAMDisplayCheckTriggerFlow.h" + +@implementation FIRIAMDisplayCheckTriggerFlow +- (instancetype)initWithDisplayFlow:(FIRIAMDisplayExecutor *)displayExecutor { + if (self = [super init]) { + _displayExecutor = displayExecutor; + } + return self; +} + +// Providing fake implementations to avoid xcode complain about incomplete implementation. +- (void)start { +} +- (void)stop { +} +@end diff --git a/Firebase/InAppMessaging/Flows/FIRIAMDisplayExecutor.h b/Firebase/InAppMessaging/Flows/FIRIAMDisplayExecutor.h new file mode 100644 index 0000000000..7137121b47 --- /dev/null +++ b/Firebase/InAppMessaging/Flows/FIRIAMDisplayExecutor.h @@ -0,0 +1,61 @@ +/* + * 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 + +#import "FIRIAMActionURLFollower.h" +#import "FIRIAMActivityLogger.h" +#import "FIRIAMBookKeeper.h" +#import "FIRIAMClearcutLogger.h" +#import "FIRIAMMessageClientCache.h" +#import "FIRIAMTimeFetcher.h" +#import "FIRInAppMessagingRendering.h" + +NS_ASSUME_NONNULL_BEGIN +@interface FIRIAMDisplaySetting : NSObject +@property(nonatomic) NSTimeInterval displayMinIntervalInMinutes; +@end + +// The class for checking if there are appropriate messages to be displayed and if so, render it. +// There are other flows that would determine the timing for the checking and then use this class +// instance for the actual check/display. +// +// In addition to fetch eligible message from message cache, this class also ensures certain +// conditions are satisfied for the rendering +// 1 No current in-app message is being displayed +// 2 For non-contextual messages, the display interval in display setting is met. +@interface FIRIAMDisplayExecutor : NSObject + +- (instancetype)initWithSetting:(FIRIAMDisplaySetting *)setting + messageCache:(FIRIAMMessageClientCache *)cache + timeFetcher:(id)timeFetcher + bookKeeper:(id)displayBookKeeper + actionURLFollower:(FIRIAMActionURLFollower *)actionURLFollower + activityLogger:(FIRIAMActivityLogger *)activityLogger + analyticsEventLogger:(id)analyticsEventLogger; + +// Check and display next in-app message eligible for app open trigger +- (void)checkAndDisplayNextAppForegroundMessage; +// Check and display next in-app message eligible for analytics event trigger with given event name. +- (void)checkAndDisplayNextContextualMessageForAnalyticsEvent:(NSString *)eventName; + +// a boolean flag that can be used to suppress/resume displaying messages. +@property(nonatomic) BOOL suppressMessageDisplay; + +// This is the display component used by display executor for actual message rendering. +@property(nonatomic) id messageDisplayComponent; +@end +NS_ASSUME_NONNULL_END diff --git a/Firebase/InAppMessaging/Flows/FIRIAMDisplayExecutor.m b/Firebase/InAppMessaging/Flows/FIRIAMDisplayExecutor.m new file mode 100644 index 0000000000..5cc188df47 --- /dev/null +++ b/Firebase/InAppMessaging/Flows/FIRIAMDisplayExecutor.m @@ -0,0 +1,497 @@ +/* + * 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 + +#import "FIRCore+InAppMessaging.h" +#import "FIRIAMActivityLogger.h" +#import "FIRIAMDisplayExecutor.h" +#import "FIRIAMMessageContentData.h" +#import "FIRIAMMessageDefinition.h" +#import "FIRIAMSDKRuntimeErrorCodes.h" + +@implementation FIRIAMDisplaySetting +@end + +@interface FIRIAMDisplayExecutor () +@property(nonatomic) id timeFetcher; + +// YES if a message is being rendered at this time +@property(nonatomic) BOOL isMsgBeingDisplayed; +@property(nonatomic) NSTimeInterval lastDisplayTime; +@property(nonatomic, nonnull, readonly) FIRIAMDisplaySetting *setting; +@property(nonatomic, nonnull, readonly) FIRIAMMessageClientCache *messageCache; +@property(nonatomic, nonnull, readonly) id displayBookKeeper; +@property(nonatomic) BOOL impressionRecorded; +@property(nonatomic, nonnull, readonly) id analyticsEventLogger; +@property(nonatomic, nonnull, readonly) FIRIAMActionURLFollower *actionURLFollower; +@end + +@implementation FIRIAMDisplayExecutor { + FIRIAMMessageDefinition *_currentMsgBeingDisplayed; +} + +#pragma mark - FIRInAppMessagingDisplayDelegate methods +- (void)messageClicked { + self.isMsgBeingDisplayed = NO; + if (!_currentMsgBeingDisplayed.renderData.messageID) { + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM400030", + @"messageClicked called but " + "there is no current message ID."); + return; + } + + if (_currentMsgBeingDisplayed.isTestMessage) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM400031", + @"A test message clicked. Do test event impression/click analytics logging"); + + [self.analyticsEventLogger + logAnalyticsEventForType:FIRIAMAnalyticsEventTestMessageImpression + forCampaignID:_currentMsgBeingDisplayed.renderData.messageID + withCampaignName:_currentMsgBeingDisplayed.renderData.name + eventTimeInMs:nil + completion:^(BOOL success) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM400036", + @"Logging analytics event for url following %@", + success ? @"succeeded" : @"failed"); + }]; + + [self.analyticsEventLogger + logAnalyticsEventForType:FIRIAMAnalyticsEventTestMessageClick + forCampaignID:_currentMsgBeingDisplayed.renderData.messageID + withCampaignName:_currentMsgBeingDisplayed.renderData.name + eventTimeInMs:nil + completion:^(BOOL success) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM400039", + @"Logging analytics event for url following %@", + success ? @"succeeded" : @"failed"); + }]; + } else { + // Logging the impression + [self recordValidImpression:_currentMsgBeingDisplayed.renderData.messageID + withMessageName:_currentMsgBeingDisplayed.renderData.name]; + + [self.analyticsEventLogger + logAnalyticsEventForType:FIRIAMAnalyticsEventActionURLFollow + forCampaignID:_currentMsgBeingDisplayed.renderData.messageID + withCampaignName:_currentMsgBeingDisplayed.renderData.name + eventTimeInMs:nil + completion:^(BOOL success) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM400032", + @"Logging analytics event for url following %@", + success ? @"succeeded" : @"failed"); + }]; + } + + NSURL *actionURL = _currentMsgBeingDisplayed.renderData.contentData.actionURL; + + if (!actionURL) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM400033", + @"messageClicked called but " + "there is no action url specified in the message data."); + // it's equivalent to closing the message with no further action + return; + } else { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM400037", @"Following action url %@", + actionURL.absoluteString); + @try { + [self.actionURLFollower + followActionURL:actionURL + withCompletionBlock:^(BOOL success) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM400034", + @"Seeing %@ from following action URL", success ? @"success" : @"error"); + }]; + } @catch (NSException *e) { + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM400035", + @"Exception encountered in following " + "action url (%@): %@ ", + actionURL, e.description); + @throw; + } + } +} + +- (void)messageDismissedWithType:(FIRInAppMessagingDismissType)dismissType { + self.isMsgBeingDisplayed = NO; + if (!_currentMsgBeingDisplayed.renderData.messageID) { + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM400014", + @"messageDismissedWithType called but " + "there is no current message ID."); + return; + } + + if (_currentMsgBeingDisplayed.isTestMessage) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM400020", + @"A test message dismissed. Record the impression event."); + [self.analyticsEventLogger + logAnalyticsEventForType:FIRIAMAnalyticsEventTestMessageImpression + forCampaignID:_currentMsgBeingDisplayed.renderData.messageID + withCampaignName:_currentMsgBeingDisplayed.renderData.name + eventTimeInMs:nil + completion:^(BOOL success) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM400038", + @"Logging analytics event for url following %@", + success ? @"succeeded" : @"failed"); + }]; + + return; + } + + // Logging the impression + [self recordValidImpression:_currentMsgBeingDisplayed.renderData.messageID + withMessageName:_currentMsgBeingDisplayed.renderData.name]; + + FIRIAMAnalyticsLogEventType logEventType = dismissType == FIRInAppMessagingDismissTypeAuto + ? FIRIAMAnalyticsEventMessageDismissAuto + : FIRIAMAnalyticsEventMessageDismissClick; + + [self.analyticsEventLogger + logAnalyticsEventForType:logEventType + forCampaignID:_currentMsgBeingDisplayed.renderData.messageID + withCampaignName:_currentMsgBeingDisplayed.renderData.name + eventTimeInMs:nil + completion:^(BOOL success) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM400004", + @"Logging analytics event for message dismiss %@", + success ? @"succeeded" : @"failed"); + }]; +} + +- (void)impressionDetected { + if (!_currentMsgBeingDisplayed.renderData.messageID) { + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM400022", + @"impressionDetected called but " + "there is no current message ID."); + return; + } + + if (!_currentMsgBeingDisplayed.isTestMessage) { + // Displayed long enough to be a valid impression. + [self recordValidImpression:_currentMsgBeingDisplayed.renderData.messageID + withMessageName:_currentMsgBeingDisplayed.renderData.name]; + } else { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM400011", + @"A test message. Record the test message impression event."); + return; + } +} + +- (void)displayErrorEncountered:(NSError *)error { + self.isMsgBeingDisplayed = NO; + + if (!_currentMsgBeingDisplayed.renderData.messageID) { + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM400017", + @"displayErrorEncountered called but " + "there is no current message ID."); + return; + } + + NSString *messageID = _currentMsgBeingDisplayed.renderData.messageID; + + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM400009", + @"Display ran into error for message %@: %@", messageID, error); + + if (_currentMsgBeingDisplayed.isTestMessage) { + [self displayMessageLoadError:error]; + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM400012", + @"A test message. No analytics tracking " + "from image data loading failure"); + return; + } + + // we remove the message from the client side cache so that it won't be retried until next time + // it's fetched again from server. + [self.messageCache removeMessageWithId:messageID]; + NSString *messageName = _currentMsgBeingDisplayed.renderData.name; + + if ([error.domain isEqualToString:NSURLErrorDomain]) { + [self.analyticsEventLogger + logAnalyticsEventForType:FIRIAMAnalyticsEventImageFetchError + forCampaignID:messageID + withCampaignName:messageName + eventTimeInMs:nil + completion:^(BOOL success) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM400010", + @"Logging analytics event for image fetch error %@", + success ? @"succeeded" : @"failed"); + }]; + } else if (error.code == FIRIAMSDKRuntimeErrorNonImageMimetypeFromImageURL) { + [self.analyticsEventLogger + logAnalyticsEventForType:FIRIAMAnalyticsEventImageFormatUnsupported + forCampaignID:messageID + withCampaignName:messageName + eventTimeInMs:nil + completion:^(BOOL success) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM400013", + @"Logging analytics event for image format error %@", + success ? @"succeeded" : @"failed"); + }]; + } +} + +- (void)recordValidImpression:(NSString *)messageID withMessageName:(NSString *)messageName { + if (!self.impressionRecorded) { + [self.displayBookKeeper recordNewImpressionForMessage:messageID + withStartTimestampInSeconds:self.lastDisplayTime]; + self.impressionRecorded = YES; + [self.messageCache removeMessageWithId:messageID]; + // Log an impression analytics event as well. + [self.analyticsEventLogger + logAnalyticsEventForType:FIRIAMAnalyticsEventMessageImpression + forCampaignID:messageID + withCampaignName:messageName + eventTimeInMs:nil + completion:^(BOOL success) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM400007", + @"Logging analytics event for impression %@", + success ? @"succeeded" : @"failed"); + }]; + } +} + +- (void)displayMessageLoadError:(NSError *)error { + NSString *errorMsg = error.userInfo[NSLocalizedDescriptionKey] + ? error.userInfo[NSLocalizedDescriptionKey] + : @"Message loading failed"; + UIAlertController *alert = [UIAlertController + alertControllerWithTitle:@"Firebase InAppMessaging fail to load a test message" + message:errorMsg + preferredStyle:UIAlertControllerStyleAlert]; + + UIAlertAction *defaultAction = [UIAlertAction actionWithTitle:@"OK" + style:UIAlertActionStyleDefault + handler:^(UIAlertAction *action){ + }]; + + [alert addAction:defaultAction]; + + [[UIApplication sharedApplication].keyWindow.rootViewController presentViewController:alert + animated:YES + completion:nil]; +} + +- (instancetype)initWithSetting:(FIRIAMDisplaySetting *)setting + messageCache:(FIRIAMMessageClientCache *)cache + timeFetcher:(id)timeFetcher + bookKeeper:(id)displayBookKeeper + actionURLFollower:(FIRIAMActionURLFollower *)actionURLFollower + activityLogger:(FIRIAMActivityLogger *)activityLogger + analyticsEventLogger:(id)analyticsEventLogger { + if (self = [super init]) { + _timeFetcher = timeFetcher; + _lastDisplayTime = displayBookKeeper.lastDisplayTime; + _setting = setting; + _messageCache = cache; + _displayBookKeeper = displayBookKeeper; + _isMsgBeingDisplayed = NO; + _analyticsEventLogger = analyticsEventLogger; + _actionURLFollower = actionURLFollower; + _suppressMessageDisplay = NO; // always allow message display on startup + } + return self; +} + +- (void)checkAndDisplayNextContextualMessageForAnalyticsEvent:(NSString *)eventName { + // synchronizing on self so that we won't potentially enter the render flow from two + // threads: example like showing analytics triggered message and a regular app open + // triggered message + @synchronized(self) { + if (self.suppressMessageDisplay) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM400015", + @"Message display is being suppressed. No contextual message rendering."); + return; + } + + if (!self.messageDisplayComponent) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM400026", + @"Message display component is not present yet. No display should happen."); + return; + } + + if (self.isMsgBeingDisplayed) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM400008", + @"An in-app message display is in progress, do not check analytics event " + "based message for now."); + + return; + } + + // Pop up next analytics event based message to be displayed + FIRIAMMessageDefinition *nextAnalyticsBasedMessage = + [self.messageCache nextOnFirebaseAnalyticEventDisplayMsg:eventName]; + + if (nextAnalyticsBasedMessage) { + [self displayForMessage:nextAnalyticsBasedMessage]; + } + } +} + +- (FIRInAppMessagingBannerDisplay *) + bannerMessageWithMessageDefinition:(FIRIAMMessageDefinition *)definition + imageData:(FIRInAppMessagingImageData *)imageData { + NSString *title = definition.renderData.contentData.titleText; + NSString *body = definition.renderData.contentData.bodyText; + + FIRInAppMessagingBannerDisplay *bannerMessage = [[FIRInAppMessagingBannerDisplay alloc] + initWithMessageID:definition.renderData.messageID + renderAsTestMessage:definition.isTestMessage + titleText:title + bodyText:body + textColor:definition.renderData.renderingEffectSettings.textColor + backgroundColor:definition.renderData.renderingEffectSettings.displayBGColor + imageData:imageData]; + + return bannerMessage; +} + +- (FIRInAppMessagingImageOnlyDisplay *) + imageOnlyMessageWithMessageDefinition:(FIRIAMMessageDefinition *)definition + imageData:(FIRInAppMessagingImageData *)imageData { + FIRInAppMessagingImageOnlyDisplay *imageOnlyMessage = + [[FIRInAppMessagingImageOnlyDisplay alloc] initWithMessageID:definition.renderData.messageID + renderAsTestMessage:definition.isTestMessage + imageData:imageData]; + + return imageOnlyMessage; +} + +- (FIRInAppMessagingModalDisplay *) + modalViewMessageWithMessageDefinition:(FIRIAMMessageDefinition *)definition + imageData:(FIRInAppMessagingImageData *)imageData { + // For easier reference in this method. + FIRIAMMessageRenderData *renderData = definition.renderData; + + NSString *title = renderData.contentData.titleText; + NSString *body = renderData.contentData.bodyText; + + FIRInAppMessagingActionButton *actionButton = nil; + + if (definition.renderData.contentData.actionButtonText) { + actionButton = [[FIRInAppMessagingActionButton alloc] + initWithButtonText:renderData.contentData.actionButtonText + buttonTextColor:renderData.renderingEffectSettings.btnTextColor + backgroundColor:renderData.renderingEffectSettings.btnBGColor]; + } + + FIRInAppMessagingModalDisplay *modalViewMessage = [[FIRInAppMessagingModalDisplay alloc] + initWithMessageID:definition.renderData.messageID + renderAsTestMessage:definition.isTestMessage + titleText:title + bodyText:body + textColor:renderData.renderingEffectSettings.textColor + backgroundColor:renderData.renderingEffectSettings.displayBGColor + imageData:imageData + actionButton:actionButton]; + + return modalViewMessage; +} + +- (FIRInAppMessagingDisplayMessageBase *) + displayMessageWithMessageDefinition:(FIRIAMMessageDefinition *)definition + imageData:(FIRInAppMessagingImageData *)imageData { + switch (definition.renderData.renderingEffectSettings.viewMode) { + case FIRIAMRenderAsBannerView: + return [self bannerMessageWithMessageDefinition:definition imageData:imageData]; + case FIRIAMRenderAsModalView: + return [self modalViewMessageWithMessageDefinition:definition imageData:imageData]; + case FIRIAMRenderAsImageOnlyView: + return [self imageOnlyMessageWithMessageDefinition:definition imageData:imageData]; + default: + return nil; + } +} + +- (void)displayForMessage:(FIRIAMMessageDefinition *)message { + _currentMsgBeingDisplayed = message; + [message.renderData.contentData + loadImageDataWithBlock:^(NSData *_Nullable imageNSData, NSError *error) { + FIRInAppMessagingImageData *imageData = nil; + + if (error) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM400019", + @"Error in loading image data for the message."); + + // short-circuit to display error handling + [self displayErrorEncountered:error]; + return; + } else if (imageNSData != nil) { + imageData = [[FIRInAppMessagingImageData alloc] + initWithImageURL:message.renderData.contentData.imageURL.absoluteString + imageData:imageNSData]; + } + + self.impressionRecorded = NO; + self.isMsgBeingDisplayed = YES; + + FIRInAppMessagingDisplayMessageBase *displayMessage = + [self displayMessageWithMessageDefinition:message imageData:imageData]; + [self.messageDisplayComponent displayMessage:displayMessage displayDelegate:self]; + }]; +} + +- (BOOL)enoughIntervalFromLastDisplay { + NSTimeInterval intervalFromLastDisplayInSeconds = + [self.timeFetcher currentTimestampInSeconds] - self.lastDisplayTime; + + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM400005", + @"Interval time from last display is %lf seconds", intervalFromLastDisplayInSeconds); + + return intervalFromLastDisplayInSeconds >= self.setting.displayMinIntervalInMinutes * 60.0; +} + +- (void)checkAndDisplayNextAppForegroundMessage { + // synchronizing on self so that we won't potentially enter the render flow from two + // threads: example like showing analytics triggered message and a regular app open + // triggered message concurrently + @synchronized(self) { + if (!self.messageDisplayComponent) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM400027", + @"Message display component is not present yet. No display should happen."); + return; + } + + if (self.suppressMessageDisplay) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM400016", + @"Message display is being suppressed. No regular message rendering."); + return; + } + + if (self.isMsgBeingDisplayed) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM400002", + @"An in-app message display is in progress, do not over-display on top of it."); + return; + } + + if ([self.messageCache hasTestMessage] || [self enoughIntervalFromLastDisplay]) { + // We can display test messages anytime or display regular messages when + // the display time interval has been reached + FIRIAMMessageDefinition *nextForegroundMessage = [self.messageCache nextOnAppOpenDisplayMsg]; + + if (nextForegroundMessage) { + [self displayForMessage:nextForegroundMessage]; + self.lastDisplayTime = [self.timeFetcher currentTimestampInSeconds]; + } else { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM400001", + @"No appropriate in-app message detected for display."); + } + } else { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM400003", + @"Minimal display interval of %lf seconds has not been reached yet.", + self.setting.displayMinIntervalInMinutes * 60.0); + } + } +} +@end diff --git a/Firebase/InAppMessaging/Flows/FIRIAMFetchFlow.h b/Firebase/InAppMessaging/Flows/FIRIAMFetchFlow.h new file mode 100644 index 0000000000..cd88606bcc --- /dev/null +++ b/Firebase/InAppMessaging/Flows/FIRIAMFetchFlow.h @@ -0,0 +1,59 @@ +/* + * 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 + +#import "FIRIAMActivityLogger.h" +#import "FIRIAMBookKeeper.h" +#import "FIRIAMMessageClientCache.h" +#import "FIRIAMSDKModeManager.h" +#import "FIRIAMTimeFetcher.h" + +@protocol FIRIAMAnalyticsEventLogger; + +NS_ASSUME_NONNULL_BEGIN +@interface FIRIAMFetchSetting : NSObject +@property(nonatomic) NSTimeInterval fetchMinIntervalInMinutes; +@end + +typedef void (^FIRIAMFetchMessageCompletionHandler)( + NSArray *_Nullable messages, + NSNumber *_Nullable nextFetchWaitTime, + NSInteger discardedMessageCount, + NSError *_Nullable error); + +@protocol FIRIAMMessageFetcher +- (void)fetchMessagesWithImpressionList:(NSArray *)impressonList + withCompletion:(FIRIAMFetchMessageCompletionHandler)completion; +@end + +// Parent class for supporting different fetching flows. Subclass is supposed to trigger +// checkAndFetch at appropriate moments based on its fetch strategy +@interface FIRIAMFetchFlow : NSObject +- (instancetype)initWithSetting:(FIRIAMFetchSetting *)setting + messageCache:(FIRIAMMessageClientCache *)cache + messageFetcher:(id)messageFetcher + timeFetcher:(id)timeFetcher + bookKeeper:(id)displayBookKeeper + activityLogger:(FIRIAMActivityLogger *)activityLogger + analyticsEventLogger:(id)analyticsEventLogger + FIRIAMSDKModeManager:(FIRIAMSDKModeManager *)sdkModeManager; + +// Triggers a potential fetch of in-app messaging from the source. It would check and respect the +// the fetchMinIntervalInMinutes defined in setting +- (void)checkAndFetch; +@end +NS_ASSUME_NONNULL_END diff --git a/Firebase/InAppMessaging/Flows/FIRIAMFetchFlow.m b/Firebase/InAppMessaging/Flows/FIRIAMFetchFlow.m new file mode 100644 index 0000000000..133b79a85f --- /dev/null +++ b/Firebase/InAppMessaging/Flows/FIRIAMFetchFlow.m @@ -0,0 +1,253 @@ +/* + * 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 + +#import "FIRCore+InAppMessaging.h" +#import "FIRIAMClearcutLogger.h" +#import "FIRIAMFetchFlow.h" + +@implementation FIRIAMFetchSetting +@end + +// the notification message to say that the fetch flow is done +NSString *const kFIRIAMFetchIsDoneNotification = @"FIRIAMFetchIsDoneNotification"; + +@interface FIRIAMFetchFlow () +@property(nonatomic) id timeFetcher; +@property(nonatomic) NSTimeInterval lastFetchTime; +@property(nonatomic, nonnull, readonly) FIRIAMFetchSetting *setting; +@property(nonatomic, nonnull, readonly) FIRIAMMessageClientCache *messageCache; +@property(nonatomic) id messageFetcher; +@property(nonatomic, nonnull, readonly) id fetchBookKeeper; +@property(nonatomic, nonnull, readonly) FIRIAMActivityLogger *activityLogger; +@property(nonatomic, nonnull, readonly) id analyticsEventLogger; + +@property(nonatomic, nonnull, readonly) FIRIAMSDKModeManager *sdkModeManager; +@end + +@implementation FIRIAMFetchFlow +- (instancetype)initWithSetting:(FIRIAMFetchSetting *)setting + messageCache:(FIRIAMMessageClientCache *)cache + messageFetcher:(id)messageFetcher + timeFetcher:(id)timeFetcher + bookKeeper:(id)fetchBookKeeper + activityLogger:(FIRIAMActivityLogger *)activityLogger + analyticsEventLogger:(id)analyticsEventLogger + FIRIAMSDKModeManager:(FIRIAMSDKModeManager *)sdkModeManager { + if (self = [super init]) { + _timeFetcher = timeFetcher; + _lastFetchTime = [fetchBookKeeper lastFetchTime]; + _setting = setting; + _messageCache = cache; + _messageFetcher = messageFetcher; + _fetchBookKeeper = fetchBookKeeper; + _activityLogger = activityLogger; + _analyticsEventLogger = analyticsEventLogger; + _sdkModeManager = sdkModeManager; + } + return self; +} + +- (FIRIAMAnalyticsLogEventType)fetchErrorToLogEventType:(NSError *)error { + if ([error.domain isEqual:NSURLErrorDomain]) { + if (error.code == NSURLErrorNotConnectedToInternet) { + return FIRIAMAnalyticsEventFetchAPINetworkError; + } else { + // error.code could be a non 2xx status code + if (error.code > 0) { + if (error.code >= 400 && error.code < 500) { + return FIRIAMAnalyticsEventFetchAPIClientError; + } else { + if (error.code >= 500 && error.code < 600) { + return FIRIAMAnalyticsEventFetchAPIServerError; + } + } + } + } + } + + return FIRIAMAnalyticsLogEventUnknown; +} + +- (void)sendFetchIsDoneNotification { + [[NSNotificationCenter defaultCenter] postNotificationName:kFIRIAMFetchIsDoneNotification + object:self]; +} + +- (void)handleSuccessullyFetchedMessages:(NSArray *)messagesInResponse + withFetchWaitTime:(NSNumber *_Nullable)fetchWaitTime + requestImpressions:(NSArray *)requestImpressions { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM700004", @"%lu messages were fetched successfully.", + (unsigned long)messagesInResponse.count); + + for (FIRIAMMessageDefinition *next in messagesInResponse) { + if (next.isTestMessage && self.sdkModeManager.currentMode != FIRIAMSDKModeTesting) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM700006", + @"Seeing test message in fetch response. Turn " + "the current instance into a testing instance."); + [self.sdkModeManager becomeTestingInstance]; + } + } + + NSArray *responseMessageIDs = + [messagesInResponse valueForKeyPath:@"renderData.messageID"]; + NSArray *impressionMessageIDs = [requestImpressions valueForKey:@"messageID"]; + + // We are going to clear impression records for those IDs that are in both impressionMessageIDs + // and responseMessageIDs. This is to avoid incorrectly clearing impressions records that come + // in between the sending the request and receiving the response for the fetch operation. + // So we are computing intersection between responseMessageIDs and impressionMessageIDs and use + // that for impression log clearing. + NSMutableSet *idIntersection = [NSMutableSet setWithArray:responseMessageIDs]; + [idIntersection intersectSet:[NSSet setWithArray:impressionMessageIDs]]; + + [self.fetchBookKeeper clearImpressionsWithMessageList:[idIntersection allObjects]]; + [self.messageCache setMessageData:messagesInResponse]; + + [self.sdkModeManager registerOneMoreFetch]; + [self.fetchBookKeeper recordNewFetchWithFetchCount:messagesInResponse.count + withTimestampInSeconds:[self.timeFetcher currentTimestampInSeconds] + nextFetchWaitTime:fetchWaitTime]; +} + +- (void)checkAndFetch { + NSTimeInterval intervalFromLastFetchInSeconds = + [self.timeFetcher currentTimestampInSeconds] - self.fetchBookKeeper.lastFetchTime; + + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM700005", + @"Interval from last time fetch is %lf seconds", intervalFromLastFetchInSeconds); + + BOOL fetchIsAllowedNow = NO; + + if (intervalFromLastFetchInSeconds >= self.fetchBookKeeper.nextFetchWaitTime) { + // it's enough wait time interval from last fetch. + fetchIsAllowedNow = YES; + } else { + FIRIAMSDKMode sdkMode = [self.sdkModeManager currentMode]; + if (sdkMode == FIRIAMSDKModeNewlyInstalled || sdkMode == FIRIAMSDKModeTesting) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM700007", + @"OK to fetch due to current SDK mode being %@", + FIRIAMDescriptonStringForSDKMode(sdkMode)); + fetchIsAllowedNow = YES; + } else { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM700008", + @"Interval from last time fetch is %lf seconds, smaller than fetch wait time %lf", + intervalFromLastFetchInSeconds, self.fetchBookKeeper.nextFetchWaitTime); + } + } + + if (fetchIsAllowedNow) { + // we are allowed to fetch in-app message from time interval wise + + FIRIAMActivityRecord *record = + [[FIRIAMActivityRecord alloc] initWithActivityType:FIRIAMActivityTypeCheckForFetch + isSuccessful:YES + withDetail:@"OK to do a fetch" + timestamp:nil]; + [self.activityLogger addLogRecord:record]; + + NSArray *impressions = [self.fetchBookKeeper getImpressions]; + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM700001", @"Go ahead to fetch messages"); + + NSTimeInterval fetchStartTime = [[NSDate date] timeIntervalSince1970]; + + [self.messageFetcher + fetchMessagesWithImpressionList:impressions + withCompletion:^(NSArray *_Nullable messages, + NSNumber *_Nullable nextFetchWaitTime, + NSInteger discardedMessageCount, + NSError *_Nullable error) { + if (error) { + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM700002", + @"Error happened during message fetching %@", error); + + FIRIAMAnalyticsLogEventType eventType = + [self fetchErrorToLogEventType:error]; + + [self.analyticsEventLogger logAnalyticsEventForType:eventType + forCampaignID:@"all" + withCampaignName:@"all" + eventTimeInMs:nil + completion:^(BOOL success){ + // nothing to do + }]; + + FIRIAMActivityRecord *record = [[FIRIAMActivityRecord alloc] + initWithActivityType:FIRIAMActivityTypeFetchMessage + isSuccessful:NO + withDetail:error.description + timestamp:nil]; + [self.activityLogger addLogRecord:record]; + } else { + double fetchOperationLatencyInMills = + ([[NSDate date] timeIntervalSince1970] - fetchStartTime) * 1000; + NSString *impressionListString = + [impressions componentsJoinedByString:@","]; + NSString *activityLogDetail = @""; + + if (discardedMessageCount > 0) { + activityLogDetail = [NSString + stringWithFormat: + @"%lu messages fetched with impression list as [%@]" + " and %lu messages are discarded due to data being " + "invalid. It took" + " %lf milliseconds", + (unsigned long)messages.count, impressionListString, + (unsigned long)discardedMessageCount, + fetchOperationLatencyInMills]; + } else { + activityLogDetail = [NSString + stringWithFormat: + @"%lu messages fetched with impression list as [%@]. It took" + " %lf milliseconds", + (unsigned long)messages.count, impressionListString, + fetchOperationLatencyInMills]; + } + + FIRIAMActivityRecord *record = [[FIRIAMActivityRecord alloc] + initWithActivityType:FIRIAMActivityTypeFetchMessage + isSuccessful:YES + withDetail:activityLogDetail + timestamp:nil]; + [self.activityLogger addLogRecord:record]; + + // Now handle the fetched messages. + [self handleSuccessullyFetchedMessages:messages + withFetchWaitTime:nextFetchWaitTime + requestImpressions:impressions]; + } + // Send this regardless whether fetch is successful or not. + [self sendFetchIsDoneNotification]; + }]; + + } else { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM700003", + @"Only %lf seconds from last fetch time. No action.", + intervalFromLastFetchInSeconds); + // for no fetch case, we still send out the notification so that and display flow can continue + // from here. + [self sendFetchIsDoneNotification]; + FIRIAMActivityRecord *record = + [[FIRIAMActivityRecord alloc] initWithActivityType:FIRIAMActivityTypeCheckForFetch + isSuccessful:NO + withDetail:@"Abort due to check time interval " + "not reached yet" + timestamp:nil]; + [self.activityLogger addLogRecord:record]; + } +} +@end diff --git a/Firebase/InAppMessaging/Flows/FIRIAMFetchOnAppForegroundFlow.h b/Firebase/InAppMessaging/Flows/FIRIAMFetchOnAppForegroundFlow.h new file mode 100644 index 0000000000..ddbdb684f5 --- /dev/null +++ b/Firebase/InAppMessaging/Flows/FIRIAMFetchOnAppForegroundFlow.h @@ -0,0 +1,22 @@ +/* + * 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 "FIRIAMFetchFlow.h" + +// an implementation of FIRIAMDisplayExecutor by triggering the display when app is foregrounded +@interface FIRIAMFetchOnAppForegroundFlow : FIRIAMFetchFlow +- (void)start; +- (void)stop; +@end diff --git a/Firebase/InAppMessaging/Flows/FIRIAMFetchOnAppForegroundFlow.m b/Firebase/InAppMessaging/Flows/FIRIAMFetchOnAppForegroundFlow.m new file mode 100644 index 0000000000..3d7831eb44 --- /dev/null +++ b/Firebase/InAppMessaging/Flows/FIRIAMFetchOnAppForegroundFlow.m @@ -0,0 +1,51 @@ +/* + * 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 + +#import "FIRCore+InAppMessaging.h" +#import "FIRIAMFetchOnAppForegroundFlow.h" +@implementation FIRIAMFetchOnAppForegroundFlow +- (void)start { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM600002", + @"Start observing app foreground notifications for message fetching."); + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(appWillEnterForeground:) + name:UIApplicationWillEnterForegroundNotification + object:nil]; +} + +- (void)appWillEnterForeground:(UIApplication *)application { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM600001", + @"App foregrounded, wake up to see if we can fetch in-app messaging."); + // for fetch operation, dispatch it to non main UI thread to avoid blocking. It's ok to dispatch + // to a concurrent global queue instead of serial queue since app open event won't happen at + // fast speed to cause race conditions + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0ul), ^{ + [self checkAndFetch]; + }); +} + +- (void)stop { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM600003", + @"Stop observing app foreground notifications."); + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} +@end diff --git a/Firebase/InAppMessaging/Flows/FIRIAMMessageClientCache.h b/Firebase/InAppMessaging/Flows/FIRIAMMessageClientCache.h new file mode 100644 index 0000000000..53c1ed55d2 --- /dev/null +++ b/Firebase/InAppMessaging/Flows/FIRIAMMessageClientCache.h @@ -0,0 +1,91 @@ +/* + * 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 +#import "FIRIAMBookKeeper.h" +#import "FIRIAMFetchResponseParser.h" +#import "FIRIAMMessageDefinition.h" + +NS_ASSUME_NONNULL_BEGIN + +@class FIRIAMServerMsgFetchStorage; +@class FIRIAMDisplayCheckOnAnalyticEventsFlow; + +@interface FIRIAMContextualTrigger +@property(nonatomic, copy, readonly) NSString *eventName; +@end + +@interface FIRIAMContextualTriggerListener ++ (void)listenForTriggers:(NSArray *)triggers + withCallback:(void (^)(FIRIAMContextualTrigger *matchedTrigger))callback; +@end + +@protocol FIRIAMCacheDataObserver +- (void)dataChanged; +@end + +// This class serves as an in-memory cache of the messages that would be searched for finding next +// message to be rendered. Its content can be loaded from client persistent storage upon SDK +// initialization and then updated whenever a new fetch is made to server to receive the last +// list. In the case a message has been rendered, it's removed from the cache so that it's not +// considered next time for the message search. +// +// This class is also responsible for setting up and tearing down appropriate analytics event +// listening flow based on whether the current active event list contains any analytics event +// trigger based messages. +// +// This class exists so that we can do message match more efficiently (in-memory search vs search +// in local persistent storage) by using appropriate in-memory data structure. +@interface FIRIAMMessageClientCache : NSObject + +// used to inform the analytics event display check flow about whether it should start/stop +// analytics event listening based on the latest message definitions +// make it weak to avoid retaining cycle +@property(nonatomic, weak, nullable) + FIRIAMDisplayCheckOnAnalyticEventsFlow *analycisEventDislayCheckFlow; + +- (instancetype)init NS_UNAVAILABLE; +- (instancetype)initWithBookkeeper:(id)bookKeeper + usingResponseParser:(FIRIAMFetchResponseParser *)responseParser; + +// set an observer for watching for data changes in the cache +- (void)setDataObserver:(id)observer; + +// Returns YES if there are any test messages in the cache. +- (BOOL)hasTestMessage; + +// read all the messages as a copy stored in cache +- (NSArray *)allRegularMessages; + +// clients that are to display messages should use nextOnAppOpenDisplayMsg or +// nextOnFirebaseAnalyticEventDisplayMsg to fetch the next eligible message and use +// removeMessageWithId to remove it from cache once the message has been correctly rendered + +// Fetch next eligible messages that are appropriate for display at app open time +- (nullable FIRIAMMessageDefinition *)nextOnAppOpenDisplayMsg; +// Fetch next eligible message that matches the event triggering condition +- (nullable FIRIAMMessageDefinition *)nextOnFirebaseAnalyticEventDisplayMsg:(NSString *)eventName; + +// Call this after a message has been rendered to remove it from the cache. +- (void)removeMessageWithId:(NSString *)messgeId; + +// reset messages data +- (void)setMessageData:(NSArray *)messages; +// load messages from persistent storage +- (void)loadMessageDataFromServerFetchStorage:(FIRIAMServerMsgFetchStorage *)fetchStorage + withCompletion:(void (^)(BOOL success))completion; +@end +NS_ASSUME_NONNULL_END diff --git a/Firebase/InAppMessaging/Flows/FIRIAMMessageClientCache.m b/Firebase/InAppMessaging/Flows/FIRIAMMessageClientCache.m new file mode 100644 index 0000000000..c6c4933a41 --- /dev/null +++ b/Firebase/InAppMessaging/Flows/FIRIAMMessageClientCache.m @@ -0,0 +1,223 @@ +/* + * 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 + +#import "FIRCore+InAppMessaging.h" +#import "FIRIAMDisplayCheckOnAnalyticEventsFlow.h" +#import "FIRIAMDisplayTriggerDefinition.h" +#import "FIRIAMFetchResponseParser.h" +#import "FIRIAMMessageClientCache.h" +#import "FIRIAMServerMsgFetchStorage.h" + +@interface FIRIAMMessageClientCache () + +// messages not for client-side testing +@property(nonatomic) NSMutableArray *regularMessages; +// messages for client-side testing +@property(nonatomic) NSMutableArray *testMessages; +@property(nonatomic, weak) id observer; +@property(nonatomic) NSMutableSet *firebaseAnalyticEventsToWatch; +@property(nonatomic) id bookKeeper; +@property(readonly, nonatomic) FIRIAMFetchResponseParser *responseParser; + +@end + +// Methods doing read and write operations on messages field is synchronized to avoid +// race conditions like change the array while iterating through it +@implementation FIRIAMMessageClientCache +- (instancetype)initWithBookkeeper:(id)bookKeeper + usingResponseParser:(FIRIAMFetchResponseParser *)responseParser { + if (self = [super init]) { + _bookKeeper = bookKeeper; + _responseParser = responseParser; + } + return self; +} + +- (void)setDataObserver:(id)observer { + self.observer = observer; +} + +// reset messages data +- (void)setMessageData:(NSArray *)messages { + @synchronized(self) { + NSSet *impressionSet = + [NSSet setWithArray:[self.bookKeeper getMessageIDsFromImpressions]]; + + NSMutableArray *regularMessages = [[NSMutableArray alloc] init]; + self.testMessages = [[NSMutableArray alloc] init]; + + // split between test vs non-test messages + for (FIRIAMMessageDefinition *next in messages) { + if (next.isTestMessage) { + [self.testMessages addObject:next]; + } else { + [regularMessages addObject:next]; + } + } + + // while resetting the whole message set, we do prefiltering based on the impressions + // data to get rid of messages we don't care so that the future searches are more efficient + NSPredicate *notImpressedPredicate = + [NSPredicate predicateWithBlock:^BOOL(id evaluatedObject, NSDictionary *bindings) { + FIRIAMMessageDefinition *message = (FIRIAMMessageDefinition *)evaluatedObject; + return ![impressionSet containsObject:message.renderData.messageID]; + }]; + + self.regularMessages = + [[regularMessages filteredArrayUsingPredicate:notImpressedPredicate] mutableCopy]; + [self setupAnalyticsEventListening]; + } + + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM160001", + @"There are %lu test messages and %lu regular messages and " + "%lu Firebase Analytics events to watch after " + "resetting the message cache", + (unsigned long)self.testMessages.count, (unsigned long)self.regularMessages.count, + (unsigned long)self.firebaseAnalyticEventsToWatch.count); + [self.observer dataChanged]; +} + +// triggered after self.messages are updated so that we can correctly enable/disable listening +// on analytics event based on current fiam message set +- (void)setupAnalyticsEventListening { + self.firebaseAnalyticEventsToWatch = [[NSMutableSet alloc] init]; + for (FIRIAMMessageDefinition *nextMessage in self.regularMessages) { + // if it's event based triggering, add it to the watch set + for (FIRIAMDisplayTriggerDefinition *nextTrigger in nextMessage.renderTriggers) { + if (nextTrigger.triggerType == FIRIAMRenderTriggerOnFirebaseAnalyticsEvent) { + [self.firebaseAnalyticEventsToWatch addObject:nextTrigger.firebaseEventName]; + } + } + } + + if (self.analycisEventDislayCheckFlow) { + if ([self.firebaseAnalyticEventsToWatch count] > 0) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM160010", + @"There are analytics event trigger based messages, enable listening"); + [self.analycisEventDislayCheckFlow start]; + } else { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM160011", + @"No analytics event trigger based messages, disable listening"); + [self.analycisEventDislayCheckFlow stop]; + } + } +} + +- (NSArray *)allRegularMessages { + return [self.regularMessages copy]; +} + +- (BOOL)hasTestMessage { + return self.testMessages.count > 0; +} + +- (nullable FIRIAMMessageDefinition *)nextOnAppOpenDisplayMsg { + // search from the start to end in the list (which implies the display priority) for the + // first match (some messages in the cache may not be eligible for the current display + // message fetch + NSSet *impressionSet = + [NSSet setWithArray:[self.bookKeeper getMessageIDsFromImpressions]]; + + @synchronized(self) { + // always first check test message which always have higher prirority + if (self.testMessages.count > 0) { + FIRIAMMessageDefinition *testMessage = self.testMessages[0]; + // always remove test message right away when being fetched for display + [self.testMessages removeObjectAtIndex:0]; + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM160007", + @"Returning a test message for app foreground display"); + return testMessage; + } + + for (FIRIAMMessageDefinition *next in self.regularMessages) { + // message being active and message not impressed yet + if ([next messageHasStarted] && ![next messageHasExpired] && + ![impressionSet containsObject:next.renderData.messageID] && + [next messageRenderedOnAppForegroundEvent]) { + return next; + } + } + } + return nil; +} + +- (nullable FIRIAMMessageDefinition *)nextOnFirebaseAnalyticEventDisplayMsg:(NSString *)eventName { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM160005", + @"Inside nextOnFirebaseAnalyticEventDisplay for checking contextual trigger match"); + if (![self.firebaseAnalyticEventsToWatch containsObject:eventName]) { + return nil; + } + + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM160006", + @"There could be a potential message match for analytics event %@", eventName); + NSSet *impressionSet = + [NSSet setWithArray:[self.bookKeeper getMessageIDsFromImpressions]]; + @synchronized(self) { + for (FIRIAMMessageDefinition *next in self.regularMessages) { + // message being active and message not impressed yet and the contextual trigger condition + // match + if ([next messageHasStarted] && ![next messageHasExpired] && + ![impressionSet containsObject:next.renderData.messageID] && + [next messageRenderedOnAnalyticsEvent:eventName]) { + return next; + } + } + } + return nil; +} + +- (void)removeMessageWithId:(NSString *)messageID { + FIRIAMMessageDefinition *msgToRemove = nil; + @synchronized(self) { + for (FIRIAMMessageDefinition *next in self.regularMessages) { + if ([next.renderData.messageID isEqualToString:messageID]) { + msgToRemove = next; + break; + } + } + + if (msgToRemove) { + [self.regularMessages removeObject:msgToRemove]; + [self setupAnalyticsEventListening]; + } + } + + // triggers the observer outside synchronization block + if (msgToRemove) { + [self.observer dataChanged]; + } +} + +- (void)loadMessageDataFromServerFetchStorage:(FIRIAMServerMsgFetchStorage *)fetchStorage + withCompletion:(void (^)(BOOL success))completion { + [fetchStorage readResponseDictionary:^(NSDictionary *_Nonnull response, BOOL success) { + if (success) { + NSInteger discardCount; + NSNumber *fetchWaitTime; + NSArray *messagesFromStorage = + [self.responseParser parseAPIResponseDictionary:response + discardedMsgCount:&discardCount + fetchWaitTimeInSeconds:&fetchWaitTime]; + [self setMessageData:messagesFromStorage]; + completion(YES); + } else { + completion(NO); + } + }]; +} +@end diff --git a/Firebase/InAppMessaging/Flows/FIRIAMMsgFetcherUsingRestful.h b/Firebase/InAppMessaging/Flows/FIRIAMMsgFetcherUsingRestful.h new file mode 100644 index 0000000000..4e2777fd2e --- /dev/null +++ b/Firebase/InAppMessaging/Flows/FIRIAMMsgFetcherUsingRestful.h @@ -0,0 +1,55 @@ +/* + * 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 +#import "FIRIAMClientInfoFetcher.h" +#import "FIRIAMFetchFlow.h" +#import "FIRIAMFetchResponseParser.h" +#import "FIRIAMSDKSettings.h" +#import "FIRIAMServerMsgFetchStorage.h" + +NS_ASSUME_NONNULL_BEGIN + +// implementation of FIRIAMMessageFetcher by making Restful API requests to firebase +// in-app messaging services +@interface FIRIAMMsgFetcherUsingRestful : NSObject +/** + * Create an instance which uses NSURLSession to make the restful api call. + * + * @param serverHost API server host. + * @param fbProjectNumber project number used for the API call. It's the GCM_SENDER_ID + * field in GoogleService-Info.plist. + * @param fbAppId It's the GOOGLE_APP_ID field in GoogleService-Info.plist. + * @param apiKey API key. + * @param fetchStorage used to persist the fetched response. + * @param clientInfoFetcher used to fetch iid info for the current app. + * @param URLSession can be nil in which case the class would create NSURLSession + * internally to perform the network request. Having it here so that + * it's easier for doing mocking with unit testing. + */ +- (instancetype)initWithHost:(NSString *)serverHost + HTTPProtocol:(NSString *)HTTPProtocol + project:(NSString *)fbProjectNumber + firebaseApp:(NSString *)fbAppId + APIKey:(NSString *)apiKey + fetchStorage:(FIRIAMServerMsgFetchStorage *)fetchStorage + instanceIDFetcher:(FIRIAMClientInfoFetcher *)clientInfoFetcher + usingURLSession:(nullable NSURLSession *)URLSession + responseParser:(FIRIAMFetchResponseParser *)responseParser; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Firebase/InAppMessaging/Flows/FIRIAMMsgFetcherUsingRestful.m b/Firebase/InAppMessaging/Flows/FIRIAMMsgFetcherUsingRestful.m new file mode 100644 index 0000000000..7256906e3e --- /dev/null +++ b/Firebase/InAppMessaging/Flows/FIRIAMMsgFetcherUsingRestful.m @@ -0,0 +1,272 @@ +/* + * 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 +#import + +#import "FIRCore+InAppMessaging.h" +#import "FIRIAMFetchFlow.h" +#import "FIRIAMFetchResponseParser.h" +#import "FIRIAMMessageContentDataWithImageURL.h" +#import "FIRIAMMessageDefinition.h" +#import "FIRIAMMsgFetcherUsingRestful.h" +#import "FIRIAMSDKSettings.h" + +static NSInteger const SuccessHTTPStatusCode = 200; + +@interface FIRIAMMsgFetcherUsingRestful () +@property(readonly) NSURLSession *URLSession; +@property(readonly, copy, nonatomic) NSString *serverHostName; +@property(readonly, copy, nonatomic) NSString *appBundleID; +@property(readonly, copy, nonatomic) NSString *httpProtocol; +@property(readonly, copy, nonatomic) NSString *fbProjectNumber; +@property(readonly, copy, nonatomic) NSString *apiKey; +@property(readonly, copy, nonatomic) NSString *firebaseAppId; +@property(readonly, nonatomic) FIRIAMServerMsgFetchStorage *fetchStorage; +@property(readonly, nonatomic) FIRIAMClientInfoFetcher *clientInfoFetcher; +@property(readonly, nonatomic) FIRIAMFetchResponseParser *responseParser; +@end + +@implementation FIRIAMMsgFetcherUsingRestful +- (instancetype)initWithHost:(NSString *)serverHost + HTTPProtocol:(NSString *)HTTPProtocol + project:(NSString *)fbProjectNumber + firebaseApp:(NSString *)fbAppId + APIKey:(NSString *)apiKey + fetchStorage:(FIRIAMServerMsgFetchStorage *)fetchStorage + instanceIDFetcher:(FIRIAMClientInfoFetcher *)clientInfoFetcher + usingURLSession:(nullable NSURLSession *)URLSession + responseParser:(FIRIAMFetchResponseParser *)responseParser { + if (self = [super init]) { + _URLSession = URLSession ? URLSession : [NSURLSession sharedSession]; + _serverHostName = [serverHost copy]; + _fbProjectNumber = [fbProjectNumber copy]; + _firebaseAppId = [fbAppId copy]; + _httpProtocol = [HTTPProtocol copy]; + _apiKey = [apiKey copy]; + _clientInfoFetcher = clientInfoFetcher; + _fetchStorage = fetchStorage; + _appBundleID = [NSBundle mainBundle].bundleIdentifier; + _responseParser = responseParser; + } + return self; +} + +- (void)updatePostFetchData:(NSMutableDictionary *)postData + withImpressionList:(NSArray *)impressionList + instanceIDString:(nonnull NSString *)IIDValue + IIDToken:(nonnull NSString *)IIDToken { + NSMutableArray *impressionListForPost = [[NSMutableArray alloc] init]; + for (FIRIAMImpressionRecord *nextImpressionRecord in impressionList) { + NSDictionary *nextImpression = @{ + @"campaign_id" : nextImpressionRecord.messageID, + @"impression_timestamp_millis" : @(nextImpressionRecord.impressionTimeInSeconds * 1000) + }; + [impressionListForPost addObject:nextImpression]; + } + [postData setObject:impressionListForPost forKey:@"already_seen_campaigns"]; + + if (IIDValue) { + NSDictionary *clientAppInfo = @{ + @"gmp_app_id" : self.firebaseAppId, + @"app_instance_id" : IIDValue, + @"app_instance_id_token" : IIDToken + }; + [postData setObject:clientAppInfo forKey:@"requesting_client_app"]; + } + + NSMutableArray *clientSignals = [@{} mutableCopy]; + + // set client signal fields only when they are present + if ([self.clientInfoFetcher getAppVersion]) { + [clientSignals setValue:[self.clientInfoFetcher getAppVersion] forKey:@"app_version"]; + } + + if ([self.clientInfoFetcher getOSVersion]) { + [clientSignals setValue:[self.clientInfoFetcher getOSVersion] forKey:@"platform_version"]; + } + + if ([self.clientInfoFetcher getDeviceLanguageCode]) { + [clientSignals setValue:[self.clientInfoFetcher getDeviceLanguageCode] forKey:@"language_code"]; + } + + if ([self.clientInfoFetcher getTimezone]) { + [clientSignals setValue:[self.clientInfoFetcher getTimezone] forKey:@"time_zone"]; + } + + [postData setObject:clientSignals forKey:@"client_signals"]; +} + +- (void)fetchMessagesWithImpressionList:(NSArray *)impressonList + withIIDvalue:(NSString *)iidValue + IIDToken:(NSString *)iidToken + completion:(FIRIAMFetchMessageCompletionHandler)completion { + NSMutableURLRequest *request = [[NSMutableURLRequest alloc] init]; + [request setHTTPMethod:@"POST"]; + + if (_appBundleID.length) { + // Handle the case in which the API key is being restricted to specific iOS app bundle, + // which can be set on Google Cloud console side for API key credentials. + [request addValue:_appBundleID forHTTPHeaderField:@"X-Ios-Bundle-Identifier"]; + } + + [request addValue:@"application/json" forHTTPHeaderField:@"Content-Type"]; + [request addValue:@"application/json" forHTTPHeaderField:@"Accept"]; + + NSMutableDictionary *postFetchDict = [[NSMutableDictionary alloc] init]; + [self updatePostFetchData:postFetchDict + withImpressionList:impressonList + instanceIDString:iidValue + IIDToken:iidToken]; + + NSData *postFetchData = [NSJSONSerialization dataWithJSONObject:postFetchDict + options:0 + error:nil]; + + NSString *requestURLString = [NSString + stringWithFormat:@"%@://%@/v1/sdkServing/projects/%@/eligibleCampaigns:fetch?key=%@", + self.httpProtocol, self.serverHostName, self.fbProjectNumber, self.apiKey]; + [request setURL:[NSURL URLWithString:requestURLString]]; + [request setHTTPBody:postFetchData]; + + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM130001", + @"Making a restful API request for pulling messages with fetch POST body as %@ " + "and request headers as %@", + postFetchDict, request.allHTTPHeaderFields); + + NSURLSessionDataTask *postDataTask = [self.URLSession + dataTaskWithRequest:request + completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + if (error) { + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM130002", + @"Internal error: encountered error in pulling messages from server" + ":%@", + error); + completion(nil, nil, 0, error); + } else { + if ([response isKindOfClass:[NSHTTPURLResponse class]]) { + NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response; + if (httpResponse.statusCode == SuccessHTTPStatusCode) { + // got response data successfully + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM130007", + @"Fetch API response headers are %@", [httpResponse allHeaderFields]); + + NSError *errorJson = nil; + NSDictionary *responseDict = [NSJSONSerialization JSONObjectWithData:data + options:kNilOptions + error:&errorJson]; + if (errorJson) { + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM130003", + @"Failed to parse the response body as JSON string %@", errorJson); + completion(nil, nil, 0, errorJson); + } else { + NSInteger discardCount; + NSNumber *nextFetchWaitTimeFromResponse; + NSArray *messages = [self.responseParser + parseAPIResponseDictionary:responseDict + discardedMsgCount:&discardCount + fetchWaitTimeInSeconds:&nextFetchWaitTimeFromResponse]; + + if (messages) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM130012", + @"API request for fetching messages and parsing the response was " + "successful."); + [self.fetchStorage + saveResponseDictionary:responseDict + withCompletion:^(BOOL success) { + if (!success) + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM130010", + @"Failed to persist server fetch response"); + }]; + // always report success regardless of whether we are able to persist into + // storage. they should get fixed in the next fetch cycle if it happens. + completion(messages, nextFetchWaitTimeFromResponse, discardCount, nil); + } else { + NSString *errorDesc = + @"Failed to recognize the fiam messages in the server response"; + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM130011", @"%@", errorDesc); + NSError *error = + [NSError errorWithDomain:kFirebaseInAppMessagingErrorDomain + code:0 + userInfo:@{NSLocalizedDescriptionKey : errorDesc}]; + completion(nil, nil, 0, error); + } + } + } else { + NSString *responseBody = [[NSString alloc] initWithData:data + encoding:NSUTF8StringEncoding]; + + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM130004", + @"Failed restful api request to fetch in-app messages: seeing http " + @"status code as %ld with body as %@", + (long)httpResponse.statusCode, responseBody); + + NSError *error = [NSError errorWithDomain:NSURLErrorDomain + code:httpResponse.statusCode + userInfo:nil]; + completion(nil, nil, 0, error); + } + } else { + NSString *errorDesc = @"Got a non http response type from fetch endpoint"; + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM130005", @"%@", errorDesc); + + NSError *error = [NSError errorWithDomain:kFirebaseInAppMessagingErrorDomain + code:0 + userInfo:@{NSLocalizedDescriptionKey : errorDesc}]; + completion(nil, nil, 0, error); + } + } + }]; + + if (postDataTask == nil) { + NSString *errorDesc = + @"Internal error: NSURLSessionDataTask failed to be created due to possibly " + "incorrect parameters"; + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM130006", @"%@", errorDesc); + NSError *error = [NSError errorWithDomain:kFirebaseInAppMessagingErrorDomain + code:0 + userInfo:@{NSLocalizedDescriptionKey : errorDesc}]; + completion(nil, nil, 0, error); + } else { + [postDataTask resume]; + } +} + +#pragma mark - protocol FIRIAMMessageFetcher +- (void)fetchMessagesWithImpressionList:(NSArray *)impressonList + withCompletion:(FIRIAMFetchMessageCompletionHandler)completion { + // First step is to fetch the instance id value and token on the fly. We are not caching the data + // since the fetch operation frequency is low enough that we are not concerned about its impact + // on server load and this guarantees that we always have an up-to-date iid values and tokens. + [self.clientInfoFetcher + fetchFirebaseIIDDataWithProjectNumber:self.fbProjectNumber + withCompletion:^(NSString *_Nullable iid, NSString *_Nullable token, + NSError *_Nullable error) { + if (error) { + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM130008", + @"Not able to get iid value and/or token for " + @"talking to server: %@", + error.localizedDescription); + completion(nil, nil, 0, error); + } else { + [self fetchMessagesWithImpressionList:impressonList + withIIDvalue:iid + IIDToken:token + completion:completion]; + } + }]; +} +@end diff --git a/Firebase/InAppMessaging/Flows/FIRIAMServerMsgFetchStorage.h b/Firebase/InAppMessaging/Flows/FIRIAMServerMsgFetchStorage.h new file mode 100644 index 0000000000..61cfac3c71 --- /dev/null +++ b/Firebase/InAppMessaging/Flows/FIRIAMServerMsgFetchStorage.h @@ -0,0 +1,30 @@ +/* + * 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 + +NS_ASSUME_NONNULL_BEGIN + +// A class that will persist response data fetched from server side into a local file on +// client side. This file can be used as the cache for messages after the app has been +// killed and before it's up for next server fetch. +@interface FIRIAMServerMsgFetchStorage : NSObject +- (void)saveResponseDictionary:(NSDictionary *)response + withCompletion:(void (^)(BOOL success))completion; +- (void)readResponseDictionary:(void (^)(NSDictionary *response, BOOL success))completion; + +@end +NS_ASSUME_NONNULL_END diff --git a/Firebase/InAppMessaging/Flows/FIRIAMServerMsgFetchStorage.m b/Firebase/InAppMessaging/Flows/FIRIAMServerMsgFetchStorage.m new file mode 100644 index 0000000000..3a08b61a99 --- /dev/null +++ b/Firebase/InAppMessaging/Flows/FIRIAMServerMsgFetchStorage.m @@ -0,0 +1,64 @@ +/* + * 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 + +#import "FIRCore+InAppMessaging.h" +#import "FIRIAMServerMsgFetchStorage.h" +@implementation FIRIAMServerMsgFetchStorage +- (NSString *)determineCacheFilePath { + NSString *cachePath = + NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES)[0]; + NSString *filePath = [NSString stringWithFormat:@"%@/firebase-iam-messages-cache", cachePath]; + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM150004", + @"Persistent file path for fetch response data is %@", filePath); + return filePath; +} + +- (void)saveResponseDictionary:(NSDictionary *)response + withCompletion:(void (^)(BOOL success))completion { + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0ul), ^{ + if ([response writeToFile:[self determineCacheFilePath] atomically:YES]) { + completion(YES); + } else { + completion(NO); + } + }); +} + +- (void)readResponseDictionary:(void (^)(NSDictionary *response, BOOL success))completion { + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0ul), ^{ + NSString *storageFilePath = [self determineCacheFilePath]; + if ([[NSFileManager defaultManager] fileExistsAtPath:storageFilePath]) { + NSDictionary *dictFromFile = + [[NSMutableDictionary dictionaryWithContentsOfFile:[self determineCacheFilePath]] copy]; + if (dictFromFile) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM150001", + @"Loaded response from fetch storage successfully."); + completion(dictFromFile, YES); + } else { + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM150002", + @"Not able to read response from fetch storage."); + completion(dictFromFile, NO); + } + } else { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM150003", + @"Local fetch storage file not existent yet: first time launch of the app."); + completion(nil, YES); + } + }); +} +@end diff --git a/Firebase/InAppMessaging/Public/FIRInAppMessaging.h b/Firebase/InAppMessaging/Public/FIRInAppMessaging.h new file mode 100644 index 0000000000..7764b21777 --- /dev/null +++ b/Firebase/InAppMessaging/Public/FIRInAppMessaging.h @@ -0,0 +1,80 @@ +/* + * 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 + +@class FIRApp; + +#import "FIRInAppMessagingRendering.h" + +NS_ASSUME_NONNULL_BEGIN +/** + * The root object for in-app messaging iOS SDK. + * + * Note: Firebase InApp Messaging depends on using a Firebase Instance ID & token pair to be able + * to retrieve FIAM messages defined for the current app instance. By default, Firebase in-app + * messaging SDK would obtain the ID & token pair on app/SDK startup. As a result of using + * ID & token pair, some device client data (linked to the instance ID) would be collected and sent + * over to Firebase backend periodically. + * + * The app can tune the default data collection behavior via certain controls. They are listed in + * descending order below. If a higher-priority setting exists, lower level settings are ignored. + * + * 1. Dynamically turn on/off data collection behavior by setting the + * `automaticDataCollectionEnabled` property on the `FIRInAppMessaging` instance to true/false + * Swift or YES/NO (objective-c). + * 2. Set `FirebaseInAppMessagingAutomaticDataCollectionEnabled` to false in the app's plist file. + * 3. Global Firebase data collection setting. + **/ +NS_SWIFT_NAME(InAppMessaging) +@interface FIRInAppMessaging : NSObject +/** @fn inAppMessaging + @brief Gets the singleton FIRInAppMessaging object constructed from default Firebase App + settings. +*/ ++ (FIRInAppMessaging *)inAppMessaging NS_SWIFT_NAME(inAppMessaging()); + +/** + * Unavailable. Use +inAppMessaging instead. + */ +- (instancetype)init __attribute__((unavailable("Use +inAppMessaging instead."))); + +/** + * A boolean flag that can be used to suppress messaging display at runtime. It's + * initialized to false at app startup. Once set to true, fiam SDK would stop rendering any + * new messages until it's set back to false. + */ +@property(nonatomic) BOOL messageDisplaySuppressed; + +/** + * A boolean flag that can be set at runtime to allow/disallow fiam SDK automatically + * collect user data on app startup. Settings made via this property is persisted across app + * restarts and has higher priority over FirebaseInAppMessagingAutomaticDataCollectionEnabled + * flag (if present) in plist file. + */ +@property(nonatomic) BOOL automaticDataCollectionEnabled; + +/** + * This is the display component that will be used by FirebaseInAppMessaging to render messages. + * If it's nil (the default case when FirebaseIAppMessaging SDK starts), FirebaseInAppMessaging + * would only perform other non-rendering flows (fetching messages for example). SDK + * FirebaseInAppMessagingDisplay would set itself as the display component if it's included by + * the app. Any other custom implementation of FIRInAppMessagingDisplay would need to set this + * property so that it can be used for rendering fiam message UIs. + */ +@property(nonatomic) id messageDisplayComponent; +@end +NS_ASSUME_NONNULL_END diff --git a/Firebase/InAppMessaging/Public/FIRInAppMessagingRendering.h b/Firebase/InAppMessaging/Public/FIRInAppMessagingRendering.h new file mode 100644 index 0000000000..75e0404550 --- /dev/null +++ b/Firebase/InAppMessaging/Public/FIRInAppMessagingRendering.h @@ -0,0 +1,251 @@ +/* + * Copyright 2018 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 + +NS_ASSUME_NONNULL_BEGIN + +/** Contains the display information for an action button. + */ +NS_SWIFT_NAME(InAppMessagingActionButton) +@interface FIRInAppMessagingActionButton : NSObject + +/** + * Gets the text string for the button + */ +@property(nonatomic, nonnull, copy, readonly) NSString *buttonText; + +/** + * Gets the button's text color. + */ +@property(nonatomic, copy, nonnull, readonly) UIColor *buttonTextColor; + +/** + * Gets the button's background color + */ +@property(nonatomic, copy, nonnull, readonly) UIColor *buttonBackgroundColor; + +- (instancetype)init NS_UNAVAILABLE; +- (instancetype)initWithButtonText:(NSString *)btnText + buttonTextColor:(UIColor *)textColor + backgroundColor:(UIColor *)bkgColor NS_DESIGNATED_INITIALIZER; +@end + +/** Contain display data for an image for a fiam message. + */ +NS_SWIFT_NAME(InAppMessagingImageData) +@interface FIRInAppMessagingImageData : NSObject +@property(nonatomic, nonnull, copy, readonly) NSString *imageURL; + +/** + * Gets the downloaded image data. It can be null if headless component fails to load it. + */ +@property(nonatomic, readonly, nullable) NSData *imageRawData; + +- (instancetype)init NS_UNAVAILABLE; +- (instancetype)initWithImageURL:(NSString *)imageURL + imageData:(NSData *)imageData NS_DESIGNATED_INITIALIZER; +@end + +/** + * Base class representing a FIAM message to be displayed. Don't create instance + * of this class directly. Instantiate one of its subclasses instead. + */ +NS_SWIFT_NAME(InAppMessagingDisplayMessageBase) +@interface FIRInAppMessagingDisplayMessageBase : NSObject +@property(nonatomic, copy, nonnull, readonly) NSString *messageID; +@property(nonatomic, readonly) BOOL renderAsTestMessage; + +- (instancetype)init NS_UNAVAILABLE; +- (instancetype)initWithMessageID:(NSString *)messageID + renderAsTestMessage:(BOOL)renderAsTestMessage; +@end + +/** Class for defining a modal message for display. + */ +NS_SWIFT_NAME(InAppMessagingModalDisplay) +@interface FIRInAppMessagingModalDisplay : FIRInAppMessagingDisplayMessageBase + +/** + * Gets the title for a modal fiam message. + */ +@property(nonatomic, nonnull, copy, readonly) NSString *title; + +/** + * Gets the image data for a modal fiam message. + */ +@property(nonatomic, nullable, copy, readonly) FIRInAppMessagingImageData *imageData; + +/** + * Gets the body text for a modal fiam message. + */ +@property(nonatomic, nullable, copy, readonly) NSString *bodyText; + +/** + * Gets the action button metadata for a modal fiam message. + */ +@property(nonatomic, nullable, readonly) FIRInAppMessagingActionButton *actionButton; + +/** + * Gets the background color for a modal fiam message. + */ +@property(nonatomic, copy, nonnull) UIColor *displayBackgroundColor; + +/** + * Gets the color for text in modal fiam message. It would apply to both title and body text. + */ +@property(nonatomic, copy, nonnull) UIColor *textColor; + +- (instancetype)init NS_UNAVAILABLE; +- (instancetype)initWithMessageID:(NSString *)messageID + renderAsTestMessage:(BOOL)renderAsTestMessage + titleText:(NSString *)title + bodyText:(NSString *)bodyText + textColor:(UIColor *)textColor + backgroundColor:(UIColor *)backgroundColor + imageData:(nullable FIRInAppMessagingImageData *)imageData + actionButton:(nullable FIRInAppMessagingActionButton *)actionButton + NS_DESIGNATED_INITIALIZER; +@end + +/** Class for defining a banner message for display. + */ +NS_SWIFT_NAME(InAppMessagingBannerDisplay) +@interface FIRInAppMessagingBannerDisplay : FIRInAppMessagingDisplayMessageBase +// Title is always required for modal messages. +@property(nonatomic, nonnull, copy, readonly) NSString *title; + +// Image, body, action URL are all optional for banner messages. +@property(nonatomic, nullable, copy, readonly) FIRInAppMessagingImageData *imageData; +@property(nonatomic, nullable, copy, readonly) NSString *bodyText; + +/** + * Gets banner's background color + */ +@property(nonatomic, copy, nonnull, readonly) UIColor *displayBackgroundColor; + +/** + * Gets the color for text in banner fiam message. It would apply to both title and body text. + */ +@property(nonatomic, copy, nonnull) UIColor *textColor; + +- (instancetype)init NS_UNAVAILABLE; +- (instancetype)initWithMessageID:(NSString *)messageID + renderAsTestMessage:(BOOL)renderAsTestMessage + titleText:(NSString *)title + bodyText:(NSString *)bodyText + textColor:(UIColor *)textColor + backgroundColor:(UIColor *)backgroundColor + imageData:(nullable FIRInAppMessagingImageData *)imageData + NS_DESIGNATED_INITIALIZER; +@end + +/** Class for defining a image-only message for display. + */ +NS_SWIFT_NAME(InAppMessagingImageOnlyDisplay) +@interface FIRInAppMessagingImageOnlyDisplay : FIRInAppMessagingDisplayMessageBase + +/** + * Gets the image for this message + */ +@property(nonatomic, nonnull, copy, readonly) FIRInAppMessagingImageData *imageData; +- (instancetype)init NS_UNAVAILABLE; +- (instancetype)initWithMessageID:(NSString *)messageID + renderAsTestMessage:(BOOL)renderAsTestMessage + imageData:(FIRInAppMessagingImageData *)imageData NS_DESIGNATED_INITIALIZER; +@end + +typedef NS_ENUM(NSInteger, FIRInAppMessagingDismissType) { + FIRInAppMessagingDismissTypeUserSwipe, // user swipes away the banner view + FIRInAppMessagingDismissTypeUserTapClose, // user clicks on close buttons + FIRInAppMessagingDismissTypeAuto, // automatic dismiss from banner view + FIRInAppMessagingDismissUnspecified, // message is dismissed, but not belonging to any + // above dismiss category. +}; + +// enum integer value used in as code for NSError reported from displayErrorEncountered: callback +typedef NS_ENUM(NSInteger, FIAMDisplayRenderErrorType) { + FIAMDisplayRenderErrorTypeImageDataInvalid, // provided image data is not valid for image + // rendering + FIAMDisplayRenderErrorTypeUnspecifiedError, // error not classified, mainly unexpected + // failure cases +}; + +/** + * A protocol defining those callbacks to be triggered by the message display component + * under appropriate conditions. + */ +NS_SWIFT_NAME(InAppMessagingDisplayDelegate) +@protocol FIRInAppMessagingDisplayDelegate +/** + * Called when the message is dismissed. Should be called from main thread. + * @param dismissType specifies how the message is closed. + */ +- (void)messageDismissedWithType:(FIRInAppMessagingDismissType)dismissType + NS_SWIFT_NAME(messageDismissed(dismissType:)); + +/** + * Called when the message's action button is followed by the user. + */ +- (void)messageClicked; + +/** + * Use this to mark a message as having gone through enough impression so that + * headless component can make appropriate impression tracking for it. + * + * Calling this is optional. + * + * When messageDismissedWithType: or messageClicked is + * triggered, the message would be marked as having a valid impression implicitly. + * Use impressionDetected if the UI implementation would like to mark valid + * impression in additional cases. One example is that the message is displayed for + * N seconds and then the app is killed by the user. Neither + * onMessageDismissedWithType or onMessageClicked would be triggered + * in this case. But if the app regards this as a valid impression and does not + * want the user to see the same message again, call impressionDetected to mark + * a valid impression. + */ +- (void)impressionDetected; + +/** + * Called when the display component could not render the message due to various reason. + * It's essential for display component to call this when error does arise. On seeing + * this, the headless component of fiam would assume that a prior attempt to render a + * message has finished and therefore it's ready to render a new one when conditions are + * met. Missing this callback in failed rendering attempt would make headless + * component think a fiam message is still being rendered and therefore suppress any + * future message rendering. + */ +- (void)displayErrorEncountered:(NSError *)error; +@end + +/** + * The protocol that a FIAM display component must implement. + */ +NS_SWIFT_NAME(InAppMessagingDisplay) +@protocol FIRInAppMessagingDisplay + +/** + * Method for rendering a specified message on client side. It's called from main thread. + * @param messageForDisplay the message object. It would be of one of the three message + * types at runtime. + * @param displayDelegate the callback object used to trigger notifications about certain + * conditions related to message rendering. + */ +- (void)displayMessage:(FIRInAppMessagingDisplayMessageBase *)messageForDisplay + displayDelegate:(id)displayDelegate; +@end +NS_ASSUME_NONNULL_END diff --git a/Firebase/InAppMessaging/RenderingObjects/FIRInAppMessagingRenderingDataClasses.m b/Firebase/InAppMessaging/RenderingObjects/FIRInAppMessagingRenderingDataClasses.m new file mode 100644 index 0000000000..1148e64ccb --- /dev/null +++ b/Firebase/InAppMessaging/RenderingObjects/FIRInAppMessagingRenderingDataClasses.m @@ -0,0 +1,108 @@ +/* + * Copyright 2018 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 + +#import "FIRInAppMessagingRendering.h" + +@implementation FIRInAppMessagingDisplayMessageBase + +- (instancetype)initWithMessageID:(NSString *)messageID + renderAsTestMessage:(BOOL)renderAsTestMessage { + if (self = [super init]) { + _messageID = messageID; + _renderAsTestMessage = renderAsTestMessage; + } + return self; +} +@end + +@implementation FIRInAppMessagingBannerDisplay +- (instancetype)initWithMessageID:(NSString *)messageID + renderAsTestMessage:(BOOL)renderAsTestMessage + titleText:(NSString *)title + bodyText:(NSString *)bodyText + textColor:(UIColor *)textColor + backgroundColor:(UIColor *)backgroundColor + imageData:(nullable FIRInAppMessagingImageData *)imageData { + if (self = [super initWithMessageID:messageID renderAsTestMessage:renderAsTestMessage]) { + _title = title; + _bodyText = bodyText; + _textColor = textColor; + _displayBackgroundColor = backgroundColor; + _imageData = imageData; + } + return self; +} +@end + +@implementation FIRInAppMessagingModalDisplay + +- (instancetype)initWithMessageID:(NSString *)messageID + renderAsTestMessage:(BOOL)renderAsTestMessage + titleText:(NSString *)title + bodyText:(NSString *)bodyText + textColor:(UIColor *)textColor + backgroundColor:(UIColor *)backgroundColor + imageData:(nullable FIRInAppMessagingImageData *)imageData + actionButton:(nullable FIRInAppMessagingActionButton *)actionButton { + if (self = [super initWithMessageID:messageID renderAsTestMessage:renderAsTestMessage]) { + _title = title; + _bodyText = bodyText; + _textColor = textColor; + _displayBackgroundColor = backgroundColor; + _imageData = imageData; + _actionButton = actionButton; + } + return self; +} +@end + +@implementation FIRInAppMessagingImageOnlyDisplay + +- (instancetype)initWithMessageID:(NSString *)messageID + renderAsTestMessage:(BOOL)renderAsTestMessage + imageData:(FIRInAppMessagingImageData *)imageData { + if (self = [super initWithMessageID:messageID renderAsTestMessage:renderAsTestMessage]) { + _imageData = imageData; + } + return self; +} +@end + +@implementation FIRInAppMessagingActionButton + +- (instancetype)initWithButtonText:(NSString *)btnText + buttonTextColor:(UIColor *)textColor + backgroundColor:(UIColor *)bkgColor { + if (self = [super init]) { + _buttonText = btnText; + _buttonTextColor = textColor; + _buttonBackgroundColor = bkgColor; + } + return self; +} +@end + +@implementation FIRInAppMessagingImageData +- (instancetype)initWithImageURL:(NSString *)imageURL imageData:(NSData *)imageData { + if (self = [super init]) { + _imageURL = imageURL; + _imageRawData = imageData; + } + return self; +} +@end diff --git a/Firebase/InAppMessaging/Runtime/FIRIAMActionURLFollower.h b/Firebase/InAppMessaging/Runtime/FIRIAMActionURLFollower.h new file mode 100644 index 0000000000..a05d4b65fb --- /dev/null +++ b/Firebase/InAppMessaging/Runtime/FIRIAMActionURLFollower.h @@ -0,0 +1,46 @@ +/* + * Copyright 2018 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 +#import + +NS_ASSUME_NONNULL_BEGIN +// A class for handling action url following. +// It tries to handle these cases: +// 1 Follow a universal link. +// 2 Follow a custom url scheme link. +// 3 Follow other types of links. +@interface FIRIAMActionURLFollower : NSObject + +// Create an FIRIAMActionURLFollower object by inspecting the app's main bundle info. ++ (instancetype)actionURLFollower; + +- (instancetype)init NS_UNAVAILABLE; + +// initialize the instance with an array of supported custom url schemes and +// the main application object +- (instancetype)initWithCustomURLSchemeArray:(NSArray *)customURLScheme + withApplication:(UIApplication *)application NS_DESIGNATED_INITIALIZER; + +/** + * Follow a given URL. Report success in the completion block parameter. Notice that + * it can not always be fully sure about whether the operation is successful. So it's a clue + * in some cases. + * Check its implementation about the details in the following logic. + */ +- (void)followActionURL:(NSURL *)actionURL withCompletionBlock:(void (^)(BOOL success))completion; +@end +NS_ASSUME_NONNULL_END diff --git a/Firebase/InAppMessaging/Runtime/FIRIAMActionURLFollower.m b/Firebase/InAppMessaging/Runtime/FIRIAMActionURLFollower.m new file mode 100644 index 0000000000..58416179dd --- /dev/null +++ b/Firebase/InAppMessaging/Runtime/FIRIAMActionURLFollower.m @@ -0,0 +1,244 @@ +/* + * Copyright 2018 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 +#import + +#import "FIRCore+InAppMessaging.h" +#import "FIRIAMActionURLFollower.h" + +@interface FIRIAMActionURLFollower () +@property(nonatomic, readonly, nonnull, copy) NSSet *appCustomURLSchemesSet; +@property(nonatomic, readonly) BOOL isOldAppDelegateOpenURLDefined; +@property(nonatomic, readonly) BOOL isNewAppDelegateOpenURLDefined; +@property(nonatomic, readonly) BOOL isContinueUserActivityMethodDefined; + +@property(nonatomic, readonly, nullable) id appDelegate; +@property(nonatomic, readonly, nonnull) UIApplication *mainApplication; +@end + +@implementation FIRIAMActionURLFollower + ++ (FIRIAMActionURLFollower *)actionURLFollower { + static FIRIAMActionURLFollower *URLFollower; + static dispatch_once_t onceToken; + + dispatch_once(&onceToken, ^{ + NSMutableArray *customSchemeURLs = [[NSMutableArray alloc] init]; + + // Reading the custom url list from the environment. + NSBundle *appBundle = [NSBundle mainBundle]; + if (appBundle) { + id URLTypesID = [appBundle objectForInfoDictionaryKey:@"CFBundleURLTypes"]; + if ([URLTypesID isKindOfClass:[NSArray class]]) { + NSArray *urlTypesArray = (NSArray *)URLTypesID; + + for (id nextURLType in urlTypesArray) { + if ([nextURLType isKindOfClass:[NSDictionary class]]) { + NSDictionary *nextURLTypeDict = (NSDictionary *)nextURLType; + id nextSchemeArray = nextURLTypeDict[@"CFBundleURLSchemes"]; + if (nextSchemeArray && [nextSchemeArray isKindOfClass:[NSArray class]]) { + [customSchemeURLs addObjectsFromArray:nextSchemeArray]; + } + } + } + } + } + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM300010", + @"Detected %d custom url schems from environment", (int)customSchemeURLs.count); + + if ([NSThread isMainThread]) { + // We can not dispatch sychronously to main queue if we are already in main queue. That + // can cause deadlock. + URLFollower = [[FIRIAMActionURLFollower alloc] + initWithCustomURLSchemeArray:customSchemeURLs + withApplication:UIApplication.sharedApplication]; + } else { + // If we are not on main thread, dispatch it to main queue since it invovles calling UIKit + // methods, which are required to be carried out on main queue. + dispatch_sync(dispatch_get_main_queue(), ^{ + URLFollower = [[FIRIAMActionURLFollower alloc] + initWithCustomURLSchemeArray:customSchemeURLs + withApplication:UIApplication.sharedApplication]; + }); + } + }); + return URLFollower; +} + +- (instancetype)initWithCustomURLSchemeArray:(NSArray *)customURLScheme + withApplication:(UIApplication *)application { + if (self = [super init]) { + _appCustomURLSchemesSet = [NSSet setWithArray:customURLScheme]; + _mainApplication = application; + _appDelegate = [application delegate]; + + if (_appDelegate) { + _isOldAppDelegateOpenURLDefined = [_appDelegate + respondsToSelector:@selector(application:openURL:sourceApplication:annotation:)]; + + _isNewAppDelegateOpenURLDefined = + [_appDelegate respondsToSelector:@selector(application:openURL:options:)]; + + _isContinueUserActivityMethodDefined = [_appDelegate + respondsToSelector:@selector(application:continueUserActivity:restorationHandler:)]; + } + } + return self; +} + +- (void)followActionURL:(NSURL *)actionURL withCompletionBlock:(void (^)(BOOL success))completion { + // So this is the logic of the url following flow + // 1 If it's a http or https link + // 1.1 If delegate implements application:continueUserActivity:restorationHandler: and calling + // it returns YES: the flow stops here: we have finished the url-following action + // 1.2 In other cases: fall through to step 3 + // 2 If the URL scheme matches any element in appCustomURLSchemes + // 2.1 Triggers application:openURL:options: or + // application:openURL:sourceApplication:annotation: + // depending on their availability. + // 3 Use UIApplication openURL: or openURL:options:completionHandler: to have iOS system to deal + // with the url following. + // + // The rationale for doing step 1 and 2 instead of simply doing step 3 for all cases are: + // I) calling UIApplication openURL with the universal link targeted for current app would + // not cause the link being treated as a universal link. See apple doc at + // https://developer.apple.com/library/content/documentation/General/Conceptual/AppSearch/UniversalLinks.html + // So step 1 is trying to handle this gracefully + // II) If there are other apps on the same device declaring the same custom url scheme as for + // the current app, doing step 3 directly have the risk of triggering another app for + // handling the custom scheme url: See the note about "If more than one third-party" from + // https://developer.apple.com/library/content/documentation/iPhone/Conceptual/iPhoneOSProgrammingGuide/Inter-AppCommunication/Inter-AppCommunication.html + // So step 2 is to optimize user experience by short-circuiting the engagement with iOS + // system + + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM240007", @"Following action url %@", actionURL); + + if ([self.class isHttpOrHttpsScheme:actionURL]) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM240001", @"Try to treat it as a universal link."); + if ([self followURLWithContinueUserActivity:actionURL]) { + completion(YES); + return; // following the url has been fully handled by App Delegate's + // continueUserActivity method + } + } else if ([self isCustomSchemeForCurrentApp:actionURL]) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM240002", @"Custom URL scheme matches."); + if ([self followURLWithAppDelegateOpenURLActivity:actionURL]) { + completion(YES); + return; // following the url has been fully handled by App Delegate's openURL method + } + } + + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM240003", @"Open the url via iOS."); + [self followURLViaIOS:actionURL withCompletionBlock:completion]; +} + +// Try to handle the url as a custom scheme url link by triggering +// application:openURL:options: on App's delegate object directly. +// @returns YES if that delegate method is defined and returns YES. +- (BOOL)followURLWithAppDelegateOpenURLActivity:(NSURL *)url { + if (self.isNewAppDelegateOpenURLDefined) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM210008", + @"iOS 9+ version of App Delegate's application:openURL:options: method detected"); +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wunguarded-availability" + return [self.appDelegate application:self.mainApplication openURL:url options:@{}]; +#pragma clang pop + } + + // if we come here, we can try to trigger the older version of openURL method on the app's + // delegate + if (self.isOldAppDelegateOpenURLDefined) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM240009", + @"iOS 9 below version of App Delegate's openURL method detected"); + NSString *appBundleIdentifier = [[NSBundle mainBundle] bundleIdentifier]; + BOOL handled = [self.appDelegate application:self.mainApplication + openURL:url + sourceApplication:appBundleIdentifier + annotation:@{}]; + return handled; + } + + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM240010", + @"No approriate openURL method defined for App Delegate"); + return NO; +} + +// Try to handle the url as a universal link by triggering +// application:continueUserActivity:restorationHandler: on App's delegate object directly. +// @returns YES if that delegate method is defined and seeing a YES being returned from +// trigging it +- (BOOL)followURLWithContinueUserActivity:(NSURL *)url { + if (self.isContinueUserActivityMethodDefined) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM240004", + @"App delegate responds to application:continueUserActivity:restorationHandler:." + "Simulating action url opening from a web browser."); + NSUserActivity *userActivity = + [[NSUserActivity alloc] initWithActivityType:NSUserActivityTypeBrowsingWeb]; + userActivity.webpageURL = url; + BOOL handled = [self.appDelegate application:self.mainApplication + continueUserActivity:userActivity + restorationHandler:^(NSArray *restorableObjects) { + // mimic system behavior of triggering restoreUserActivityState: + // method on each element of restorableObjects + for (id nextRestoreObject in restorableObjects) { + if ([nextRestoreObject isKindOfClass:[UIResponder class]]) { + UIResponder *responder = (UIResponder *)nextRestoreObject; + [responder restoreUserActivityState:userActivity]; + } + } + }]; + if (handled) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM240005", + @"App handling acton URL returns YES, no more further action taken"); + } else { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM240004", @"App handling acton URL returns NO."); + } + return handled; + } else { + return NO; + } +} + +- (void)followURLViaIOS:(NSURL *)url withCompletionBlock:(void (^)(BOOL success))completion { + if ([self.mainApplication respondsToSelector:@selector(openURL:options:completionHandler:)]) { + NSDictionary *options = @{}; + [self.mainApplication + openURL:url + options:options + completionHandler:^(BOOL success) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM240006", @"openURL result is %d", success); + completion(success); + }]; + } else { + // fallback to the older version of openURL + BOOL success = [self.mainApplication openURL:url]; + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM240007", @"openURL result is %d", success); + completion(success); + } +} + +- (BOOL)isCustomSchemeForCurrentApp:(NSURL *)url { + NSString *schemeInLowerCase = [url.scheme lowercaseString]; + return [self.appCustomURLSchemesSet containsObject:schemeInLowerCase]; +} + ++ (BOOL)isHttpOrHttpsScheme:(NSURL *)url { + NSString *schemeInLowerCase = [url.scheme lowercaseString]; + return + [schemeInLowerCase isEqualToString:@"https"] || [schemeInLowerCase isEqualToString:@"http"]; +} +@end diff --git a/Firebase/InAppMessaging/Runtime/FIRIAMRuntimeManager.h b/Firebase/InAppMessaging/Runtime/FIRIAMRuntimeManager.h new file mode 100644 index 0000000000..f106fbcc09 --- /dev/null +++ b/Firebase/InAppMessaging/Runtime/FIRIAMRuntimeManager.h @@ -0,0 +1,56 @@ +/* + * 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 + +#import "FIRIAMActivityLogger.h" +#import "FIRIAMBookKeeper.h" +#import "FIRIAMDisplayExecutor.h" +#import "FIRIAMMessageClientCache.h" +#import "FIRIAMSDKSettings.h" +#import "FIRIAMServerMsgFetchStorage.h" + +NS_ASSUME_NONNULL_BEGIN +// A class for managing the objects/dependencies for supporting different fiam flows at runtime +@interface FIRIAMRuntimeManager : NSObject +@property(nonatomic, nonnull) FIRIAMSDKSettings *currentSetting; +@property(nonatomic, nonnull) FIRIAMActivityLogger *activityLogger; +@property(nonatomic, nonnull) FIRIAMBookKeeperViaUserDefaults *bookKeeper; +@property(nonatomic, nonnull) FIRIAMMessageClientCache *messageCache; +@property(nonatomic, nonnull) FIRIAMServerMsgFetchStorage *fetchResultStorage; +@property(nonatomic, nonnull) FIRIAMDisplayExecutor *displayExecutor; + +// Initialize fiam SDKs and start various flows with specified settings. +- (void)startRuntimeWithSDKSettings:(FIRIAMSDKSettings *)settings; + +// Pause runtime flows/functions to disable SDK functions at runtime +- (void)pause; + +// Resume runtime flows/functions. +- (void)resume; + +// allows app to programmatically turn on/off auto data collection for fiam, which also implies +// running/stopping fiam functionalities +@property(nonatomic) BOOL automaticDataCollectionEnabled; + +// Get the global singleton instance ++ (FIRIAMRuntimeManager *)getSDKRuntimeInstance; + +// a method used to suppress or allow message being displayed based on the parameter +// @param shouldSuppress if true, no new message is rendered by the sdk. +- (void)setShouldSuppressMessageDisplay:(BOOL)shouldSuppress; +@end +NS_ASSUME_NONNULL_END diff --git a/Firebase/InAppMessaging/Runtime/FIRIAMRuntimeManager.m b/Firebase/InAppMessaging/Runtime/FIRIAMRuntimeManager.m new file mode 100644 index 0000000000..f8ccf87b8f --- /dev/null +++ b/Firebase/InAppMessaging/Runtime/FIRIAMRuntimeManager.m @@ -0,0 +1,431 @@ +/* + * 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 + +#import "FIRCore+InAppMessaging.h" +#import "FIRIAMActivityLogger.h" +#import "FIRIAMAnalyticsEventLoggerImpl.h" +#import "FIRIAMBookKeeper.h" +#import "FIRIAMClearcutHttpRequestSender.h" +#import "FIRIAMClearcutLogStorage.h" +#import "FIRIAMClearcutLogger.h" +#import "FIRIAMClearcutUploader.h" +#import "FIRIAMClientInfoFetcher.h" +#import "FIRIAMDisplayCheckOnAnalyticEventsFlow.h" +#import "FIRIAMDisplayCheckOnAppForegroundFlow.h" +#import "FIRIAMDisplayCheckOnFetchDoneNotificationFlow.h" +#import "FIRIAMDisplayExecutor.h" +#import "FIRIAMFetchOnAppForegroundFlow.h" +#import "FIRIAMFetchResponseParser.h" +#import "FIRIAMMessageClientCache.h" +#import "FIRIAMMsgFetcherUsingRestful.h" +#import "FIRIAMRuntimeManager.h" +#import "FIRIAMSDKModeManager.h" +#import "FIRInAppMessaging.h" + +@interface FIRInAppMessaging () +@property(nonatomic, readwrite, strong) id _Nullable analytics; +@end + +// A enum indicating 3 different possiblities of a setting about auto data collection. +typedef NS_ENUM(NSInteger, FIRIAMAutoDataCollectionSetting) { + // This indicates that the config is not explicitly set. + FIRIAMAutoDataCollectionSettingNone = 0, + + // This indicates that the setting explicitly enables the auto data collection. + FIRIAMAutoDataCollectionSettingEnabled = 1, + + // This indicates that the setting explicitly disables the auto data collection. + FIRIAMAutoDataCollectionSettingDisabled = 2, +}; + +@interface FIRIAMRuntimeManager () +@property(nonatomic, nonnull) FIRIAMMsgFetcherUsingRestful *restfulFetcher; +@property(nonatomic, nonnull) FIRIAMDisplayCheckOnAppForegroundFlow *displayOnAppForegroundFlow; +@property(nonatomic, nonnull) FIRIAMDisplayCheckOnFetchDoneNotificationFlow *displayOnFetchDoneFlow; +@property(nonatomic, nonnull) + FIRIAMDisplayCheckOnAnalyticEventsFlow *displayOnFIRAnalyticEventsFlow; + +@property(nonatomic, nonnull) FIRIAMFetchOnAppForegroundFlow *fetchOnAppForegroundFlow; +@property(nonatomic, nonnull) FIRIAMClientInfoFetcher *clientInfoFetcher; +@property(nonatomic, nonnull) FIRIAMFetchResponseParser *responseParser; +@end + +static NSString *const _userDefaultsKeyForFIAMProgammaticAutoDataCollectionSetting = + @"firebase-iam-sdk-auto-data-collection"; + +@implementation FIRIAMRuntimeManager { + // since we allow the SDK feature to be disabled/enabled at runtime, we need a field to track + // its state on this + BOOL _running; +} ++ (FIRIAMRuntimeManager *)getSDKRuntimeInstance { + static FIRIAMRuntimeManager *managerInstance = nil; + static dispatch_once_t onceToken; + + dispatch_once(&onceToken, ^{ + managerInstance = [[FIRIAMRuntimeManager alloc] init]; + }); + + return managerInstance; +} + +// For protocol FIRIAMTestingModeListener. +- (void)testingModeSwitchedOn { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM180015", + @"Dynamically switch to the display flow for testing mode instance."); + + [self.displayOnAppForegroundFlow stop]; + [self.displayOnFetchDoneFlow start]; +} + +- (FIRIAMAutoDataCollectionSetting)FIAMProgrammaticAutoDataCollectionSetting { + id settingEntry = [[NSUserDefaults standardUserDefaults] + objectForKey:_userDefaultsKeyForFIAMProgammaticAutoDataCollectionSetting]; + + if (![settingEntry isKindOfClass:[NSNumber class]]) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM180014", + @"No auto data collection enable setting entry detected." + "So no FIAM programmatic setting from the app."); + return FIRIAMAutoDataCollectionSettingNone; + } else { + if ([(NSNumber *)settingEntry boolValue]) { + return FIRIAMAutoDataCollectionSettingEnabled; + } else { + return FIRIAMAutoDataCollectionSettingDisabled; + } + } +} + +// the key for the plist entry to suppress auto start +static NSString *const kFirebaseInAppMessagingAutoDataCollectionKey = + @"FirebaseInAppMessagingAutomaticDataCollectionEnabled"; + +- (FIRIAMAutoDataCollectionSetting)FIAMPlistAutoDataCollectionSetting { + id fiamAutoDataCollectionPlistEntry = [[NSBundle mainBundle] + objectForInfoDictionaryKey:kFirebaseInAppMessagingAutoDataCollectionKey]; + + if ([fiamAutoDataCollectionPlistEntry isKindOfClass:[NSNumber class]]) { + BOOL fiamDataCollectionEnabledPlistSetting = + [(NSNumber *)fiamAutoDataCollectionPlistEntry boolValue]; + + if (fiamDataCollectionEnabledPlistSetting) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM180011", + @"Auto data collection is explicitly enabled in FIAM plist entry."); + return FIRIAMAutoDataCollectionSettingEnabled; + } else { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM180012", + @"Auto data collection is explicitly disabled in FIAM plist entry."); + return FIRIAMAutoDataCollectionSettingDisabled; + } + } else { + return FIRIAMAutoDataCollectionSettingNone; + } +} + +// Whether data collection is enabled by FIAM programmatic flag. +- (BOOL)automaticDataCollectionEnabled { + return + [self FIAMProgrammaticAutoDataCollectionSetting] != FIRIAMAutoDataCollectionSettingDisabled; +} + +// Sets FIAM's programmatic flag for auto data collection. +- (void)setAutomaticDataCollectionEnabled:(BOOL)automaticDataCollectionEnabled { + if (automaticDataCollectionEnabled) { + [self resume]; + } else { + [self pause]; + } +} + +- (BOOL)shouldRunSDKFlowsOnStartup { + // This can be controlled at 3 different levels in decsending priority. If a higher-priority + // setting exists, the lower level settings are ignored. + // 1. Setting made by the app by setting FIAM SDK's automaticDataCollectionEnabled flag. + // 2. FIAM specific data collection setting in plist file. + // 3. Global Firebase auto data collecting setting (carried over by currentSetting property). + + FIRIAMAutoDataCollectionSetting programmaticSetting = + [self FIAMProgrammaticAutoDataCollectionSetting]; + + if (programmaticSetting == FIRIAMAutoDataCollectionSettingEnabled) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM180010", + @"FIAM auto data-collection is explicitly enabled, start SDK flows."); + return true; + } else if (programmaticSetting == FIRIAMAutoDataCollectionSettingDisabled) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM180013", + @"FIAM auto data-collection is explicitly disabled, do not start SDK flows."); + return false; + } else { + // No explicit setting from fiam's programmatic setting. Checking next level down. + FIRIAMAutoDataCollectionSetting fiamPlistDataCollectionSetting = + [self FIAMPlistAutoDataCollectionSetting]; + + if (fiamPlistDataCollectionSetting == FIRIAMAutoDataCollectionSettingNone) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM180018", + @"No programmatic or plist setting at FIAM level. Fallback to global Firebase " + "level setting."); + return self.currentSetting.isFirebaseAutoDataCollectionEnabled; + } else { + return fiamPlistDataCollectionSetting == FIRIAMAutoDataCollectionSettingEnabled; + } + } +} + +- (void)resume { + // persist the setting + [[NSUserDefaults standardUserDefaults] + setObject:@(YES) + forKey:_userDefaultsKeyForFIAMProgammaticAutoDataCollectionSetting]; + + @synchronized(self) { + if (!_running) { + [self.fetchOnAppForegroundFlow start]; + [self.displayOnAppForegroundFlow start]; + [self.displayOnFIRAnalyticEventsFlow start]; + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM180019", + @"Start Firebase In-App Messaging flows from inactive."); + _running = YES; + } else { + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM180004", + @"Runtime is already active, resume is just a no-op"); + } + } +} + +- (void)pause { + // persist the setting + [[NSUserDefaults standardUserDefaults] + setObject:@(NO) + forKey:_userDefaultsKeyForFIAMProgammaticAutoDataCollectionSetting]; + + @synchronized(self) { + if (_running) { + [self.fetchOnAppForegroundFlow stop]; + [self.displayOnAppForegroundFlow stop]; + [self.displayOnFIRAnalyticEventsFlow stop]; + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM180006", + @"Shutdown Firebase In-App Messaging flows."); + _running = NO; + } else { + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM180005", + @"No runtime active yet, pause is just a no-op"); + } + } +} + +- (void)setShouldSuppressMessageDisplay:(BOOL)shouldSuppress { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM180003", @"Message display suppress set to %@", + @(shouldSuppress)); + self.displayExecutor.suppressMessageDisplay = shouldSuppress; +} + +- (void)startRuntimeWithSDKSettings:(FIRIAMSDKSettings *)settings { + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0ul), ^{ + [self internalStartRuntimeWithSDKSettings:settings]; + }); +} + +- (void)internalStartRuntimeWithSDKSettings:(FIRIAMSDKSettings *)settings { + if (_running) { + // Runtime has been started previously. Stop all the flows first. + [self.fetchOnAppForegroundFlow stop]; + [self.displayOnAppForegroundFlow stop]; + [self.displayOnFIRAnalyticEventsFlow stop]; + } + + self.currentSetting = settings; + + FIRIAMTimerWithNSDate *timeFetcher = [[FIRIAMTimerWithNSDate alloc] init]; + NSTimeInterval start = [timeFetcher currentTimestampInSeconds]; + + self.activityLogger = + [[FIRIAMActivityLogger alloc] initWithMaxCountBeforeReduce:settings.loggerMaxCountBeforeReduce + withSizeAfterReduce:settings.loggerSizeAfterReduce + verboseMode:settings.loggerInVerboseMode + loadFromCache:YES]; + + self.responseParser = [[FIRIAMFetchResponseParser alloc] initWithTimeFetcher:timeFetcher]; + + self.bookKeeper = [[FIRIAMBookKeeperViaUserDefaults alloc] + initWithUserDefaults:[NSUserDefaults standardUserDefaults]]; + + self.messageCache = [[FIRIAMMessageClientCache alloc] initWithBookkeeper:self.bookKeeper + usingResponseParser:self.responseParser]; + self.fetchResultStorage = [[FIRIAMServerMsgFetchStorage alloc] init]; + self.clientInfoFetcher = [[FIRIAMClientInfoFetcher alloc] init]; + + self.restfulFetcher = + [[FIRIAMMsgFetcherUsingRestful alloc] initWithHost:settings.apiServerHost + HTTPProtocol:settings.apiHttpProtocol + project:settings.firebaseProjectNumber + firebaseApp:settings.firebaseAppId + APIKey:settings.apiKey + fetchStorage:self.fetchResultStorage + instanceIDFetcher:self.clientInfoFetcher + usingURLSession:nil + responseParser:self.responseParser]; + + // start fetch on app foreground flow + FIRIAMFetchSetting *fetchSetting = [[FIRIAMFetchSetting alloc] init]; + fetchSetting.fetchMinIntervalInMinutes = settings.fetchMinIntervalInMinutes; + + // start render on app foreground flow + FIRIAMDisplaySetting *appForegroundDisplaysetting = [[FIRIAMDisplaySetting alloc] init]; + appForegroundDisplaysetting.displayMinIntervalInMinutes = + settings.appFGRenderMinIntervalInMinutes; + + // clearcut log expires after 14 days: give up on attempting to deliver them any more + NSInteger ctLogExpiresInSeconds = 14 * 24 * 60 * 60; + + FIRIAMClearcutLogStorage *ctLogStorage = + [[FIRIAMClearcutLogStorage alloc] initWithExpireAfterInSeconds:ctLogExpiresInSeconds + withTimeFetcher:timeFetcher]; + + FIRIAMClearcutHttpRequestSender *clearcutRequestSender = [[FIRIAMClearcutHttpRequestSender alloc] + initWithClearcutHost:settings.clearcutServerHost + usingTimeFetcher:timeFetcher + withOSMajorVersion:[self.clientInfoFetcher getOSMajorVersion]]; + + FIRIAMClearcutUploader *ctUploader = + [[FIRIAMClearcutUploader alloc] initWithRequestSender:clearcutRequestSender + timeFetcher:timeFetcher + logStorage:ctLogStorage + usingStrategy:settings.clearcutStrategy + usingUserDefaults:nil]; + + FIRIAMClearcutLogger *clearcutLogger = + [[FIRIAMClearcutLogger alloc] initWithFBProjectNumber:settings.firebaseProjectNumber + fbAppId:settings.firebaseAppId + clientInfoFetcher:self.clientInfoFetcher + usingTimeFetcher:timeFetcher + usingUploader:ctUploader]; + + FIRIAMAnalyticsEventLoggerImpl *analyticsEventLogger = [[FIRIAMAnalyticsEventLoggerImpl alloc] + initWithClearcutLogger:clearcutLogger + usingTimeFetcher:timeFetcher + usingUserDefaults:nil + analytics:[FIRInAppMessaging inAppMessaging].analytics]; + + FIRIAMSDKModeManager *sdkModeManager = + [[FIRIAMSDKModeManager alloc] initWithUserDefaults:NSUserDefaults.standardUserDefaults + testingModeListener:self]; + + self.fetchOnAppForegroundFlow = + [[FIRIAMFetchOnAppForegroundFlow alloc] initWithSetting:fetchSetting + messageCache:self.messageCache + messageFetcher:self.restfulFetcher + timeFetcher:timeFetcher + bookKeeper:self.bookKeeper + activityLogger:self.activityLogger + analyticsEventLogger:analyticsEventLogger + FIRIAMSDKModeManager:sdkModeManager]; + + FIRIAMActionURLFollower *actionFollower = [FIRIAMActionURLFollower actionURLFollower]; + + self.displayExecutor = [[FIRIAMDisplayExecutor alloc] initWithSetting:appForegroundDisplaysetting + messageCache:self.messageCache + timeFetcher:timeFetcher + bookKeeper:self.bookKeeper + actionURLFollower:actionFollower + activityLogger:self.activityLogger + analyticsEventLogger:analyticsEventLogger]; + + // Setting the display component. It's needed in case headless SDK is initialized after + // the display component is already set on FIRInAppMessaging. + self.displayExecutor.messageDisplayComponent = + FIRInAppMessaging.inAppMessaging.messageDisplayComponent; + + // Both display flows are created on startup. But they would only be turned on (started) based on + // the sdk mode for the current instance + self.displayOnFetchDoneFlow = [[FIRIAMDisplayCheckOnFetchDoneNotificationFlow alloc] + initWithDisplayFlow:self.displayExecutor]; + self.displayOnAppForegroundFlow = + [[FIRIAMDisplayCheckOnAppForegroundFlow alloc] initWithDisplayFlow:self.displayExecutor]; + + self.displayOnFIRAnalyticEventsFlow = + [[FIRIAMDisplayCheckOnAnalyticEventsFlow alloc] initWithDisplayFlow:self.displayExecutor]; + + self.messageCache.analycisEventDislayCheckFlow = self.displayOnFIRAnalyticEventsFlow; + [self.messageCache + loadMessageDataFromServerFetchStorage:self.fetchResultStorage + withCompletion:^(BOOL success) { + // start flows regardless whether we can load messages from fetch + // storage successfully + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM180001", + @"Message loading from fetch storage was done."); + + if ([self shouldRunSDKFlowsOnStartup]) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM180008", + @"Start SDK runtime components."); + + [self.clientInfoFetcher + fetchFirebaseIIDDataWithProjectNumber: + self.currentSetting.firebaseProjectNumber + withCompletion:^( + NSString *_Nullable iid, + NSString *_Nullable token, + NSError *_Nullable error) { + // Always dump the instance id into + // log on startup to help developers + // to find it for their app instance. + FIRLogDebug(kFIRLoggerInAppMessaging, + @"I-IAM180017", + @"Starting " + @"InAppMessaging runtime " + @"with " + "Instance ID %@", + iid); + }]; + + [self.fetchOnAppForegroundFlow start]; + [self.displayOnFIRAnalyticEventsFlow start]; + + self->_running = YES; + + if (sdkModeManager.currentMode == FIRIAMSDKModeTesting) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM180007", + @"InAppMessaging testing mode enabled. App " + "foreground messages will be displayed following " + "fetch"); + [self.displayOnFetchDoneFlow start]; + } else { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM180020", + @"Start regular display flow for non-testing " + "instance mode"); + [self.displayOnAppForegroundFlow start]; + + // Simulate app going into foreground on startup + [self.displayExecutor checkAndDisplayNextAppForegroundMessage]; + } + + // One-time triggering of checks for both fetch flow + // upon SDK/app startup. + [self.fetchOnAppForegroundFlow checkAndFetch]; + } else { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM180009", + @"No FIAM SDK startup due to settings."); + } + }]; + + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM180002", + @"Firebase In-App Messaging SDK version %@ finished startup in %lf seconds " + "with these settings: %@", + [self.clientInfoFetcher getIAMSDKVersion], + (double)([timeFetcher currentTimestampInSeconds] - start), settings); +} +@end diff --git a/Firebase/InAppMessaging/Runtime/FIRIAMSDKModeManager.h b/Firebase/InAppMessaging/Runtime/FIRIAMSDKModeManager.h new file mode 100644 index 0000000000..65534186fe --- /dev/null +++ b/Firebase/InAppMessaging/Runtime/FIRIAMSDKModeManager.h @@ -0,0 +1,73 @@ +/* + * Copyright 2018 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 + +NS_ASSUME_NONNULL_BEGIN + +extern NSInteger const kFIRIAMMaxFetchInNewlyInstalledMode; + +/** + * At runtime a FIAM SDK client can function in one of the following modes: + * 1 Regular. This SDK client instance will conform to regular fetch minimal interval time policy. + * 2 Newly installed. This is a mode a newly installed SDK stays in until the first + * kFIRIAMMaxFetchInNewlyInstalledMode fetches have finished. In this mode, there is no + * minimal time interval between fetches: a fetch would be triggered as long as the app goes + * into foreground state. + * 3 Testing Instance. This app instance is targeted for test on device feature for fiam. When + * it's in this mode, no minimal time interval between fetches is applied. SDK turns itself + * into this mode on seeing test-on-client messages are returned in fetch responses. + */ + +typedef NS_ENUM(NSInteger, FIRIAMSDKMode) { + FIRIAMSDKModeRegular, + FIRIAMSDKModeTesting, + FIRIAMSDKModeNewlyInstalled +}; + +// turn the sdk mode enum integer value into a descriptive string +NSString *FIRIAMDescriptonStringForSDKMode(FIRIAMSDKMode mode); + +extern NSString *const kFIRIAMUserDefaultKeyForSDKMode; +extern NSString *const kFIRIAMUserDefaultKeyForServerFetchCount; +extern NSInteger const kFIRIAMMaxFetchInNewlyInstalledMode; + +@protocol FIRIAMTestingModeListener +// Triggered when the current app switches into testing mode from a using testing mode +- (void)testingModeSwitchedOn; +@end + +// A class for tracking and updating the SDK mode. The tracked mode related info is persisted +// so that it can be restored beyond app restarts +@interface FIRIAMSDKModeManager : NSObject + +- (instancetype)init NS_UNAVAILABLE; + +// having NSUserDefaults as passed-in to help with unit testing +- (instancetype)initWithUserDefaults:(NSUserDefaults *)userDefaults + testingModeListener:(id)testingModeListener; + +// returns the current SDK mode +- (FIRIAMSDKMode)currentMode; + +// turn the current SDK into 'Testing Instance' mode. +- (void)becomeTestingInstance; +// inform the manager that one more fetch is done. This is to allow +// the manager to potentially graduate from the newly installed mode. +- (void)registerOneMoreFetch; + +@end +NS_ASSUME_NONNULL_END diff --git a/Firebase/InAppMessaging/Runtime/FIRIAMSDKModeManager.m b/Firebase/InAppMessaging/Runtime/FIRIAMSDKModeManager.m new file mode 100644 index 0000000000..b59c8f784e --- /dev/null +++ b/Firebase/InAppMessaging/Runtime/FIRIAMSDKModeManager.m @@ -0,0 +1,113 @@ +/* + * Copyright 2018 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 + +#import "FIRCore+InAppMessaging.h" +#import "FIRIAMSDKModeManager.h" + +NSString *FIRIAMDescriptonStringForSDKMode(FIRIAMSDKMode mode) { + switch (mode) { + case FIRIAMSDKModeTesting: + return @"Testing Instance"; + case FIRIAMSDKModeRegular: + return @"Regular"; + case FIRIAMSDKModeNewlyInstalled: + return @"Newly Installed"; + default: + FIRLogWarning(kFIRLoggerInAppMessaging, @"I-IAM290003", @"Unknown sdk mode value %d", + (int)mode); + return @"Unknown"; + } +} + +@interface FIRIAMSDKModeManager () +@property(nonatomic, nonnull, readonly) NSUserDefaults *userDefaults; +// Make it weak so that we don't depend on its existence to avoid circular reference. +@property(nonatomic, readonly, weak) id testingModeListener; +@end + +NSString *const kFIRIAMUserDefaultKeyForSDKMode = @"firebase-iam-sdk-mode"; +NSString *const kFIRIAMUserDefaultKeyForServerFetchCount = @"firebase-iam-server-fetch-count"; +NSInteger const kFIRIAMMaxFetchInNewlyInstalledMode = 5; + +@implementation FIRIAMSDKModeManager { + FIRIAMSDKMode _sdkMode; + NSInteger _fetchCount; +} + +- (instancetype)initWithUserDefaults:(NSUserDefaults *)userDefaults + testingModeListener:(id)testingModeListener { + if (self = [super init]) { + _userDefaults = userDefaults; + _testingModeListener = testingModeListener; + + id modeEntry = [_userDefaults objectForKey:kFIRIAMUserDefaultKeyForSDKMode]; + if (modeEntry == nil) { + // no entry yet, it's a newly installed sdk instance + _sdkMode = FIRIAMSDKModeNewlyInstalled; + + // initialize the mode and fetch count in the persistent storage + [_userDefaults setObject:[NSNumber numberWithInt:_sdkMode] + forKey:kFIRIAMUserDefaultKeyForSDKMode]; + [_userDefaults setInteger:0 forKey:kFIRIAMUserDefaultKeyForServerFetchCount]; + } else { + _sdkMode = [(NSNumber *)modeEntry integerValue]; + _fetchCount = [_userDefaults integerForKey:kFIRIAMUserDefaultKeyForServerFetchCount]; + } + + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM290001", + @"SDK is in mode of %@ and has seen %d fetches.", + FIRIAMDescriptonStringForSDKMode(_sdkMode), (int)_fetchCount); + } + return self; +} + +// inform the manager that one more fetch is done. This is to allow +// the manager to potentially graduate from the newly installed mode. +- (void)registerOneMoreFetch { + // we only care about the fetch count when sdk is in newly installed mode (so that it may + // graduate from that after certain number of fetches). + if (_sdkMode == FIRIAMSDKModeNewlyInstalled) { + if (++_fetchCount >= kFIRIAMMaxFetchInNewlyInstalledMode) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM290002", + @"Coming out of newly installed mode since there have been %d fetches", + (int)_fetchCount); + + _sdkMode = FIRIAMSDKModeRegular; + [_userDefaults setObject:[NSNumber numberWithInt:_sdkMode] + forKey:kFIRIAMUserDefaultKeyForSDKMode]; + } else { + [_userDefaults setInteger:_fetchCount forKey:kFIRIAMUserDefaultKeyForServerFetchCount]; + } + } +} + +- (void)becomeTestingInstance { + _sdkMode = FIRIAMSDKModeTesting; + [_userDefaults setObject:[NSNumber numberWithInt:_sdkMode] + forKey:kFIRIAMUserDefaultKeyForSDKMode]; + + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM290004", + @"Test mode enabled, notifying test mode listener."); + [self.testingModeListener testingModeSwitchedOn]; +} + +// returns the current SDK mode +- (FIRIAMSDKMode)currentMode { + return _sdkMode; +} +@end diff --git a/Firebase/InAppMessaging/Runtime/FIRIAMSDKRuntimeErrorCodes.h b/Firebase/InAppMessaging/Runtime/FIRIAMSDKRuntimeErrorCodes.h new file mode 100644 index 0000000000..d7f3e2c8df --- /dev/null +++ b/Firebase/InAppMessaging/Runtime/FIRIAMSDKRuntimeErrorCodes.h @@ -0,0 +1,25 @@ +/* + * 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 + +typedef NS_ENUM(NSInteger, FIRIAMSDKRuntimeError) { + // fail to crawl the image url + FIRIAMSDKRuntimeErrorImageNotFetchable = 0, + + // crawling image url sees non-image type data being returned + FIRIAMSDKRuntimeErrorNonImageMimetypeFromImageURL = 1 +}; diff --git a/Firebase/InAppMessaging/Runtime/FIRIAMSDKSettings.h b/Firebase/InAppMessaging/Runtime/FIRIAMSDKSettings.h new file mode 100644 index 0000000000..63aecea9f8 --- /dev/null +++ b/Firebase/InAppMessaging/Runtime/FIRIAMSDKSettings.h @@ -0,0 +1,53 @@ +/* + * 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 + +@class FIRIAMClearcutStrategy; + +NS_ASSUME_NONNULL_BEGIN +@interface FIRIAMSDKSettings : NSObject +// settings related to communicating with in-app messaging server +@property(nonatomic, copy) NSString *firebaseProjectNumber; +@property(nonatomic, copy) NSString *firebaseAppId; +@property(nonatomic, copy) NSString *apiKey; +@property(nonatomic, copy) NSString *apiServerHost; +@property(nonatomic, copy) NSString *apiHttpProtocol; // http or https. It should be always + // https on production. Allow http to + // faciliate testing in non-prod environment +@property(nonatomic) NSTimeInterval fetchMinIntervalInMinutes; + +// settings related to activity logger +@property(nonatomic) NSInteger loggerMaxCountBeforeReduce; +@property(nonatomic) NSInteger loggerSizeAfterReduce; +@property(nonatomic) BOOL loggerInVerboseMode; + +// settings for controlling rendering frequency for messages rendered from app foreground triggers +@property(nonatomic) NSTimeInterval appFGRenderMinIntervalInMinutes; + +// host name for clearcut servers +@property(nonatomic, copy) NSString *clearcutServerHost; +// clearcut strategy +@property(nonatomic, strong) FIRIAMClearcutStrategy *clearcutStrategy; + +// The global flag at whole Firebase level for automatic data collection. On FIAM SDK startup, +// it would be retreived from FIRApp's corresponding setting. +@property(nonatomic, getter=isFirebaseAutoDataCollectionEnabled) + BOOL firebaseAutoDataCollectionEnabled; + +- (NSString *)description; +@end +NS_ASSUME_NONNULL_END diff --git a/Firebase/InAppMessaging/Runtime/FIRIAMSDKSettings.m b/Firebase/InAppMessaging/Runtime/FIRIAMSDKSettings.m new file mode 100644 index 0000000000..aa24f4f93c --- /dev/null +++ b/Firebase/InAppMessaging/Runtime/FIRIAMSDKSettings.m @@ -0,0 +1,35 @@ +/* + * 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 "FIRIAMSDKSettings.h" + +@implementation FIRIAMSDKSettings + +- (NSString *)description { + return + [NSString stringWithFormat:@"APIServer:%@;ProjectNumber:%@; API_Key:%@;Clearcut Server:%@; " + "Fetch Minimal Interval:%lu seconds; Activity Logger Max:%lu; " + "Foreground Display Trigger Minimal Interval:%lu seconds;\n" + "Clearcut strategy:%@;Global Firebase auto data collection %@\n", + self.apiServerHost, self.firebaseProjectNumber, self.apiKey, + self.clearcutServerHost, + (unsigned long)(self.fetchMinIntervalInMinutes * 60), + (unsigned long)self.loggerMaxCountBeforeReduce, + (unsigned long)(self.appFGRenderMinIntervalInMinutes * 60), + self.clearcutStrategy, + self.firebaseAutoDataCollectionEnabled ? @"enabled" : @"disabled"]; +} +@end diff --git a/Firebase/InAppMessaging/Runtime/FIRInAppMessaging+Bootstrap.h b/Firebase/InAppMessaging/Runtime/FIRInAppMessaging+Bootstrap.h new file mode 100644 index 0000000000..e2c51390ec --- /dev/null +++ b/Firebase/InAppMessaging/Runtime/FIRInAppMessaging+Bootstrap.h @@ -0,0 +1,33 @@ +/* + * 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 "FIRIAMSDKSettings.h" +#import "FIRInAppMessaging.h" + +/** + * This category extends FIRInAppMessaging with the configurations from FIRApp + */ +@interface FIRInAppMessaging (Bootstrap) + ++ (NSString *)getFiamServerHost; ++ (void)setFiamServerHostWithName:(NSString *)serverHost; + ++ (NSString *)getServer; + ++ (void)bootstrapIAMWithSettings:(FIRIAMSDKSettings *)settings; + ++ (void)bootstrapIAMFromFIRApp:(FIRApp *)app; +@end diff --git a/Firebase/InAppMessaging/Runtime/FIRInAppMessaging+Bootstrap.m b/Firebase/InAppMessaging/Runtime/FIRInAppMessaging+Bootstrap.m new file mode 100644 index 0000000000..1f3134adb4 --- /dev/null +++ b/Firebase/InAppMessaging/Runtime/FIRInAppMessaging+Bootstrap.m @@ -0,0 +1,137 @@ +/* + * 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 "FIRInAppMessaging+Bootstrap.h" + +#import +#import +#import +#import +#import + +#import "FIRCore+InAppMessaging.h" +#import "FIRIAMClearcutUploader.h" +#import "FIRIAMRuntimeManager.h" +#import "FIRIAMSDKSettings.h" +#import "FIROptionsInternal.h" +#import "NSString+FIRInterlaceStrings.h" + +@implementation FIRInAppMessaging (Bootstrap) + +static FIRIAMSDKSettings *_sdkSetting = nil; + +static NSString *_fiamServerHostName = @"firebaseinappmessaging.googleapis.com"; + ++ (NSString *)getFiamServerHost { + return _fiamServerHostName; +} + ++ (void)setFiamServerHostWithName:(NSString *)serverHost { + _fiamServerHostName = serverHost; +} + ++ (NSString *)getServer { + // Override to change to test server. + NSString *serverHostNameFirstComponent = @"pa.ogepscm"; + NSString *serverHostNameSecondComponent = @"lygolai.o"; + return [NSString fir_interlaceString:serverHostNameFirstComponent + withString:serverHostNameSecondComponent]; +} + ++ (void)bootstrapIAMFromFIRApp:(FIRApp *)app { + FIROptions *options = app.options; + NSError *error; + + if (!options.GCMSenderID.length) { + error = + [NSError errorWithDomain:kFirebaseInAppMessagingErrorDomain + code:0 + userInfo:@{ + NSLocalizedDescriptionKey : @"Google Sender ID must not be nil or empty." + }]; + + [self exitAppWithFatalError:error]; + } + + if (!options.APIKey.length) { + error = [NSError + errorWithDomain:kFirebaseInAppMessagingErrorDomain + code:0 + userInfo:@{NSLocalizedDescriptionKey : @"API key must not be nil or empty."}]; + + [self exitAppWithFatalError:error]; + } + + if (!options.googleAppID.length) { + error = + [NSError errorWithDomain:kFirebaseInAppMessagingErrorDomain + code:0 + userInfo:@{NSLocalizedDescriptionKey : @"Google App ID must not be nil."}]; + [self exitAppWithFatalError:error]; + } + + // following are the default sdk settings to be used by hosting app + _sdkSetting = [[FIRIAMSDKSettings alloc] init]; + _sdkSetting.apiServerHost = [FIRInAppMessaging getFiamServerHost]; + _sdkSetting.clearcutServerHost = [FIRInAppMessaging getServer]; + _sdkSetting.apiHttpProtocol = @"https"; + _sdkSetting.firebaseAppId = options.googleAppID; + _sdkSetting.firebaseProjectNumber = options.GCMSenderID; + _sdkSetting.apiKey = options.APIKey; + _sdkSetting.fetchMinIntervalInMinutes = 24 * 60; // fetch at most once every 24 hours + _sdkSetting.loggerMaxCountBeforeReduce = 100; + _sdkSetting.loggerSizeAfterReduce = 50; + _sdkSetting.appFGRenderMinIntervalInMinutes = 24 * 60; // render at most one message from + // app-foreground trigger every 24 hours + _sdkSetting.loggerInVerboseMode = NO; + + // TODO: once Firebase Core supports sending notifications at global Firebase level setting + // change, FIAM SDK would listen to it and respond to it. Until then, FIAM SDK only checks + // the setting once upon App/SDK startup. + _sdkSetting.firebaseAutoDataCollectionEnabled = app.isDataCollectionDefaultEnabled; + + if ([GULAppEnvironmentUtil isSimulator]) { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM170004", + @"Running in simulator. Do realtime clearcut uploading."); + _sdkSetting.clearcutStrategy = + [[FIRIAMClearcutStrategy alloc] initWithMinWaitTimeInMills:0 + maxWaitTimeInMills:0 + failureBackoffTimeInMills:60 * 60 * 1000 // 60 mins + batchSendSize:50]; + } else { + FIRLogDebug(kFIRLoggerInAppMessaging, @"I-IAM170005", + @"Not running in simulator. Use regular clearcut uploading strategy."); + _sdkSetting.clearcutStrategy = + [[FIRIAMClearcutStrategy alloc] initWithMinWaitTimeInMills:5 * 60 * 1000 // 5 mins + maxWaitTimeInMills:12 * 60 * 60 * 1000 // 12 hours + failureBackoffTimeInMills:60 * 60 * 1000 // 60 mins + batchSendSize:50]; + } + + [[FIRIAMRuntimeManager getSDKRuntimeInstance] startRuntimeWithSDKSettings:_sdkSetting]; +} + ++ (void)bootstrapIAMWithSettings:(FIRIAMSDKSettings *)settings { + _sdkSetting = settings; + [[FIRIAMRuntimeManager getSDKRuntimeInstance] startRuntimeWithSDKSettings:_sdkSetting]; +} + ++ (void)exitAppWithFatalError:(NSError *)error { + [NSException raise:kFirebaseInAppMessagingErrorDomain + format:@"Error happened %@", error.localizedDescription]; +} + +@end diff --git a/Firebase/InAppMessaging/Util/FIRIAMElapsedTimeTracker.h b/Firebase/InAppMessaging/Util/FIRIAMElapsedTimeTracker.h new file mode 100644 index 0000000000..02a48bf0b9 --- /dev/null +++ b/Firebase/InAppMessaging/Util/FIRIAMElapsedTimeTracker.h @@ -0,0 +1,29 @@ +/* + * 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 +#import "FIRIAMTimeFetcher.h" + +NS_ASSUME_NONNULL_BEGIN +// A class via which we can track elapsed time with the capability to pause and resume +// the tracking +@interface FIRIAMElapsedTimeTracker : NSObject +- (NSTimeInterval)trackedTimeSoFar; +- (void)pause; +- (void)resume; +- (instancetype)initWithTimeFetcher:(id)timeFetcher; +@end +NS_ASSUME_NONNULL_END diff --git a/Firebase/InAppMessaging/Util/FIRIAMElapsedTimeTracker.m b/Firebase/InAppMessaging/Util/FIRIAMElapsedTimeTracker.m new file mode 100644 index 0000000000..79ec0dfdbb --- /dev/null +++ b/Firebase/InAppMessaging/Util/FIRIAMElapsedTimeTracker.m @@ -0,0 +1,56 @@ +/* + * 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 "FIRIAMElapsedTimeTracker.h" +@interface FIRIAMElapsedTimeTracker () +@property(nonatomic) NSTimeInterval totalTrackedTimeSoFar; +@property(nonatomic) NSTimeInterval lastTrackingStartPoint; +@property(nonatomic, nonnull) id timeFetcher; +@property(nonatomic) BOOL tracking; +@end + +@implementation FIRIAMElapsedTimeTracker + +- (NSTimeInterval)trackedTimeSoFar { + if (_tracking) { + return self.totalTrackedTimeSoFar + [self.timeFetcher currentTimestampInSeconds] - + self.lastTrackingStartPoint; + } else { + return self.totalTrackedTimeSoFar; + } +} + +- (void)pause { + self.tracking = NO; + self.totalTrackedTimeSoFar += + [self.timeFetcher currentTimestampInSeconds] - self.lastTrackingStartPoint; +} + +- (void)resume { + self.tracking = YES; + self.lastTrackingStartPoint = [self.timeFetcher currentTimestampInSeconds]; +} + +- (instancetype)initWithTimeFetcher:(id)timeFetcher { + if (self = [super init]) { + _tracking = YES; + _timeFetcher = timeFetcher; + _totalTrackedTimeSoFar = 0; + _lastTrackingStartPoint = [timeFetcher currentTimestampInSeconds]; + } + return self; +} +@end diff --git a/Firebase/InAppMessaging/Util/FIRIAMTimeFetcher.h b/Firebase/InAppMessaging/Util/FIRIAMTimeFetcher.h new file mode 100644 index 0000000000..eacab44762 --- /dev/null +++ b/Firebase/InAppMessaging/Util/FIRIAMTimeFetcher.h @@ -0,0 +1,28 @@ +/* + * 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 + +NS_ASSUME_NONNULL_BEGIN +// A protocol wrapping around function of getting timestamp. Created to help +// unit testing in which we need to control the elapsed time. +@protocol FIRIAMTimeFetcher +- (NSTimeInterval)currentTimestampInSeconds; +@end + +@interface FIRIAMTimerWithNSDate : NSObject +@end +NS_ASSUME_NONNULL_END diff --git a/Firebase/InAppMessaging/Util/FIRIAMTimeFetcher.m b/Firebase/InAppMessaging/Util/FIRIAMTimeFetcher.m new file mode 100644 index 0000000000..d32f5b24c2 --- /dev/null +++ b/Firebase/InAppMessaging/Util/FIRIAMTimeFetcher.m @@ -0,0 +1,23 @@ +/* + * 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 "FIRIAMTimeFetcher.h" + +@implementation FIRIAMTimerWithNSDate +- (NSTimeInterval)currentTimestampInSeconds { + return [[NSDate date] timeIntervalSince1970]; +} +@end diff --git a/Firebase/InAppMessaging/Util/NSString+FIRInterlaceStrings.h b/Firebase/InAppMessaging/Util/NSString+FIRInterlaceStrings.h new file mode 100644 index 0000000000..70025110a0 --- /dev/null +++ b/Firebase/InAppMessaging/Util/NSString+FIRInterlaceStrings.h @@ -0,0 +1,30 @@ +/* + * 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 + +NS_ASSUME_NONNULL_BEGIN + +// Extension on NSString that combines two strings. +@interface NSString (FIRInterlaceStrings) + +// Returns a combined string created from iterating over both strings alternately, +// beginning with stringOne's first character. ++ (NSString *)fir_interlaceString:(NSString *)stringOne withString:(NSString *)stringTwo; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Firebase/InAppMessaging/Util/NSString+FIRInterlaceStrings.m b/Firebase/InAppMessaging/Util/NSString+FIRInterlaceStrings.m new file mode 100644 index 0000000000..ddd1aa1e93 --- /dev/null +++ b/Firebase/InAppMessaging/Util/NSString+FIRInterlaceStrings.m @@ -0,0 +1,42 @@ +/* + * 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 "NSString+FIRInterlaceStrings.h" + +@implementation NSString (InterlaceStrings) + ++ (NSString *)fir_interlaceString:(NSString *)stringOne withString:(NSString *)stringTwo { + NSMutableString *interlacedString = [NSMutableString string]; + + NSUInteger count = MAX(stringOne.length, stringTwo.length); + + for (NSUInteger i = 0; i < count; i++) { + if (i < stringOne.length) { + NSString *firstComponentChar = + [NSString stringWithFormat:@"%c", [stringOne characterAtIndex:i]]; + [interlacedString appendString:firstComponentChar]; + } + if (i < stringTwo.length) { + NSString *secondComponentChar = + [NSString stringWithFormat:@"%c", [stringTwo characterAtIndex:i]]; + [interlacedString appendString:secondComponentChar]; + } + } + + return interlacedString; +} + +@end diff --git a/Firebase/InAppMessaging/Util/UIColor+FIRIAMHexString.h b/Firebase/InAppMessaging/Util/UIColor+FIRIAMHexString.h new file mode 100644 index 0000000000..e7d1feebe5 --- /dev/null +++ b/Firebase/InAppMessaging/Util/UIColor+FIRIAMHexString.h @@ -0,0 +1,31 @@ +/* + * 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 +#import + +// Extension on UIColor to support conversion from a color hex string in the format +// of #XXXXXX +@interface UIColor (HexString) + +// Constructing UIColor object from a string with '#XXXXXX' format where 'XXXXXX' is +// the 6-digit hex value string of the rgb color. +// +// @param hexString hex string for the color. +// @return a UIColor parsed out of the hex string. Nil returned if the hexString is nil or does +// not conform the desired format. ++ (nullable UIColor *)firiam_colorWithHexString:(nullable NSString *)hexString; +@end diff --git a/Firebase/InAppMessaging/Util/UIColor+FIRIAMHexString.m b/Firebase/InAppMessaging/Util/UIColor+FIRIAMHexString.m new file mode 100644 index 0000000000..7768d646c9 --- /dev/null +++ b/Firebase/InAppMessaging/Util/UIColor+FIRIAMHexString.m @@ -0,0 +1,39 @@ +/* + * 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 "UIColor+FIRIAMHexString.h" + +@implementation UIColor (HexString) ++ (UIColor *)firiam_colorWithHexString:(nullable NSString *)hexString { + if (hexString.length < 7) { + return nil; + } + + unsigned rgbValue = 0; + NSScanner *scanner = [NSScanner scannerWithString:hexString]; + [scanner setScanLocation:1]; // bypass '#' character + + if (![scanner scanHexInt:&rgbValue]) { + // no valid heximal value is detected + return nil; + } + + return [UIColor colorWithRed:((rgbValue & 0xFF0000) >> 16) / 255.0 + green:((rgbValue & 0xFF00) >> 8) / 255.0 + blue:(rgbValue & 0xFF) / 255.0 + alpha:1.0]; +} +@end diff --git a/Firebase/InAppMessaging/firebase_28dp.png b/Firebase/InAppMessaging/firebase_28dp.png new file mode 100644 index 0000000000..51a87f4757 Binary files /dev/null and b/Firebase/InAppMessaging/firebase_28dp.png differ diff --git a/FirebaseInAppMessaging.podspec b/FirebaseInAppMessaging.podspec new file mode 100644 index 0000000000..46b2ec5358 --- /dev/null +++ b/FirebaseInAppMessaging.podspec @@ -0,0 +1,39 @@ +Pod::Spec.new do |s| + s.name = 'FirebaseInAppMessaging' + s.version = '0.12.0' + s.summary = 'Firebase In-App Messaging for iOS' + + s.description = <<-DESC +FirebaseInAppMessaging is the headless component of Firebase In-App Messaging on iOS client side. +See more product details at https://firebase.google.com/products/in-app-messaging/ about Firebase In-App Messaging. + DESC + + s.homepage = 'https://firebase.google.com' + s.license = { :type => 'Apache', :file => 'LICENSE' } + s.authors = 'Google, Inc.' + + s.source = { + :git => 'https://github.com/firebase/firebase-ios-sdk.git', + :tag => s.version.to_s + } + s.social_media_url = 'https://twitter.com/Firebase' + s.ios.deployment_target = '8.0' + + s.cocoapods_version = '>= 1.4.0' + s.static_framework = true + s.prefix_header_file = false + + base_dir = "Firebase/InAppMessaging/" + s.source_files = base_dir + '**/*.[mh]' + s.public_header_files = base_dir + 'Public/*.h' + + s.pod_target_xcconfig = { 'GCC_PREPROCESSOR_DEFINITIONS' => + '$(inherited) ' + + 'FIRInAppMessaging_LIB_VERSION=' + String(s.version) + } + + s.dependency 'FirebaseCore' + s.ios.dependency 'FirebaseAnalytics' + s.ios.dependency 'FirebaseAnalyticsInterop' + s.dependency 'FirebaseInstanceID' +end diff --git a/InAppMessaging/Example/App/InAppMessaging-Example-iOS/AppDelegate.h b/InAppMessaging/Example/App/InAppMessaging-Example-iOS/AppDelegate.h new file mode 100644 index 0000000000..fbd9afa183 --- /dev/null +++ b/InAppMessaging/Example/App/InAppMessaging-Example-iOS/AppDelegate.h @@ -0,0 +1,36 @@ +/* + * 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 + +#import "FIRIAMBookKeeper.h" +#import "FIRIAMMsgFetcherUsingRestful.h" + +NS_ASSUME_NONNULL_BEGIN +@interface AppDelegate : UIResponder +@property(strong, nonatomic) UIWindow *window; +@property(strong, nonatomic) FIRIAMActivityLogger *activityLogger; +@property(nonatomic, nullable) FIRIAMBookKeeperViaUserDefaults *bookKeeper; + +@property(nonatomic, nullable) FIRIAMMsgFetcherUsingRestful *restfulFetcher; +@property(nonatomic, copy) NSString *backendServer; +@property(nonatomic, copy) NSString *projectId; +@property(nonatomic, copy) NSString *apiKey; + +@property(nonatomic) NSInteger displayIntervalInSeconds; +@property(nonatomic) NSInteger fetchIntervalInSeconds; +@end +NS_ASSUME_NONNULL_END diff --git a/InAppMessaging/Example/App/InAppMessaging-Example-iOS/AppDelegate.m b/InAppMessaging/Example/App/InAppMessaging-Example-iOS/AppDelegate.m new file mode 100644 index 0000000000..477f300036 --- /dev/null +++ b/InAppMessaging/Example/App/InAppMessaging-Example-iOS/AppDelegate.m @@ -0,0 +1,118 @@ +/* + * 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 "AppDelegate.h" +#import "FIRIAMClearcutUploader.h" +#import "FIRIAMRuntimeManager.h" +#import "FIRInAppMessaging+Bootstrap.h" +#import "NSString+FIRInterlaceStrings.h" + +#import +#import + +@interface FIRInAppMessaging (Testing) ++ (void)disableAutoBootstrapWithFIRApp; +@end + +@implementation AppDelegate + +- (BOOL)application:(UIApplication *)application + didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { + // Override point for customization after application launch. + NSLog(@"application started"); + + [FIRInAppMessaging disableAutoBootstrapWithFIRApp]; + [FIROptions defaultOptions].deepLinkURLScheme = @"fiam-testing"; + [FIRApp configure]; + + FIRIAMSDKSettings *sdkSetting = [[FIRIAMSDKSettings alloc] init]; + + sdkSetting.apiServerHost = @"firebaseinappmessaging.googleapis.com"; + + NSString *serverHostNameFirstComponent = @"pa.ogepscm"; + NSString *serverHostNameSecondComponent = @"lygolai.o"; + + sdkSetting.clearcutServerHost = [NSString fir_interlaceString:serverHostNameFirstComponent + withString:serverHostNameSecondComponent]; + sdkSetting.apiHttpProtocol = @"https"; + sdkSetting.fetchMinIntervalInMinutes = 0.1; // ok to refetch every 6 seconds + sdkSetting.loggerMaxCountBeforeReduce = 800; + sdkSetting.loggerSizeAfterReduce = 600; + sdkSetting.appFGRenderMinIntervalInMinutes = 0.1; + sdkSetting.loggerInVerboseMode = YES; + sdkSetting.conversionTrackingExpiresInSeconds = 180; + sdkSetting.firebaseAutoDataCollectionEnabled = NO; + + sdkSetting.clearcutStrategy = + [[FIRIAMClearcutStrategy alloc] initWithMinWaitTimeInMills:5 * 1000 // 5 seconds + maxWaitTimeInMills:30 * 1000 // 30 seconds + failureBackoffTimeInMills:60 * 1000 // 60 seconds + batchSendSize:50]; + + [FIRInAppMessaging bootstrapIAMWithSettings:sdkSetting]; + return YES; +} + +- (BOOL)application:(UIApplication *)application + continueUserActivity:(NSUserActivity *)userActivity + restorationHandler:(void (^)(NSArray *))restorationHandler { + NSLog(@"handle page url %@", userActivity.webpageURL); + BOOL handled = [[FIRDynamicLinks dynamicLinks] + handleUniversalLink:userActivity.webpageURL + completion:^(FIRDynamicLink *_Nullable dynamicLink, NSError *_Nullable error) { + if (dynamicLink) { + NSLog(@"dynamic link recogized with url as %@", dynamicLink.url.absoluteString); + [self showDeepLink:dynamicLink.url.absoluteString forUrlType:@"universal link"]; + } else { + NSLog(@"error happened %@", error); + } + }]; + return handled; +} + +- (void)showDeepLink:(NSString *)url forUrlType:(NSString *)urlType { + NSString *message = [NSString stringWithFormat:@"App wants to open a %@ : %@", urlType, url]; + UIAlertController *alert = + [UIAlertController alertControllerWithTitle:@"Deep link recognized" + message:message + preferredStyle:UIAlertControllerStyleAlert]; + + UIAlertAction *defaultAction = [UIAlertAction actionWithTitle:@"OK" + style:UIAlertActionStyleDefault + handler:^(UIAlertAction *action){ + }]; + + [alert addAction:defaultAction]; + [UIApplication.sharedApplication.keyWindow.rootViewController presentViewController:alert + animated:YES + completion:nil]; +} + +- (BOOL)application:(UIApplication *)app + openURL:(NSURL *)url + options:(NSDictionary *)options { + return [self application:app openURL:url sourceApplication:@"source app" annotation:@{}]; +} + +- (BOOL)application:(UIApplication *)application + openURL:(NSURL *)url + sourceApplication:(NSString *)sourceApplication + annotation:(id)annotation { + NSLog(@"handle link with custom scheme: %@", url.absoluteString); + [self showDeepLink:url.absoluteString forUrlType:@"custom scheme url"]; + return YES; +} +@end diff --git a/InAppMessaging/Example/App/InAppMessaging-Example-iOS/Assets.xcassets/AppIcon.appiconset/Contents.json b/InAppMessaging/Example/App/InAppMessaging-Example-iOS/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..1d060ed288 --- /dev/null +++ b/InAppMessaging/Example/App/InAppMessaging-Example-iOS/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,93 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "3x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "83.5x83.5", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/InAppMessaging/Example/App/InAppMessaging-Example-iOS/AutoDisplayFlowViewController.h b/InAppMessaging/Example/App/InAppMessaging-Example-iOS/AutoDisplayFlowViewController.h new file mode 100644 index 0000000000..78cc22bc3c --- /dev/null +++ b/InAppMessaging/Example/App/InAppMessaging-Example-iOS/AutoDisplayFlowViewController.h @@ -0,0 +1,20 @@ +/* + * 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 + +@interface AutoDisplayFlowViewController : UIViewController + +@end diff --git a/InAppMessaging/Example/App/InAppMessaging-Example-iOS/AutoDisplayFlowViewController.m b/InAppMessaging/Example/App/InAppMessaging-Example-iOS/AutoDisplayFlowViewController.m new file mode 100644 index 0000000000..6ed900259c --- /dev/null +++ b/InAppMessaging/Example/App/InAppMessaging-Example-iOS/AutoDisplayFlowViewController.m @@ -0,0 +1,170 @@ +/* + * 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 "AppDelegate.h" + +#import "AutoDisplayFlowViewController.h" +#import "AutoDisplayMesagesTableVC.h" + +#import "FIRIAMDisplayCheckOnAppForegroundFlow.h" +#import "FIRIAMMessageClientCache.h" +#import "FIRIAMMessageContentDataWithImageURL.h" +#import "FIRIAMMessageDefinition.h" + +#import "FIRIAMActivityLogger.h" +#import "FIRIAMDisplayCheckOnAnalyticEventsFlow.h" +#import "FIRIAMFetchOnAppForegroundFlow.h" +#import "FIRIAMMessageClientCache.h" +#import "FIRIAMMsgFetcherUsingRestful.h" + +#import "FIRIAMRuntimeManager.h" +#import "FIRInAppMessaging.h" + +#import + +@interface AutoDisplayFlowViewController () +@property(weak, nonatomic) IBOutlet UISwitch *autoDisplayFlowSwitch; + +@property(nonatomic, weak) AutoDisplayMesagesTableVC *messageTableVC; +@property(weak, nonatomic) IBOutlet UITextField *autoDisplayIntervalText; +@property(weak, nonatomic) IBOutlet UITextField *autoFetchIntervalText; +@property(weak, nonatomic) IBOutlet UITextField *eventNameText; +@property(nonatomic) FIRIAMRuntimeManager *sdkRuntime; +@property(weak, nonatomic) IBOutlet UIButton *disableEnableSDKBtn; +@property(weak, nonatomic) IBOutlet UIButton *changeDataCollectionBtn; +@end + +@implementation AutoDisplayFlowViewController +- (IBAction)clearClientStorage:(id)sender { + [self.sdkRuntime.fetchResultStorage + saveResponseDictionary:@{} + withCompletion:^(BOOL success) { + [self.sdkRuntime.messageCache + loadMessageDataFromServerFetchStorage:self.sdkRuntime.fetchResultStorage + withCompletion:^(BOOL success) { + NSLog(@"load from storage result is %d", success); + }]; + }]; +} +- (IBAction)disableEnableClicked:(id)sender { + FIRInAppMessaging *sdk = [FIRInAppMessaging inAppMessaging]; + sdk.messageDisplaySuppressed = !sdk.messageDisplaySuppressed; + [self setupDisableEnableButtonLabel]; +} + +- (void)setupDisableEnableButtonLabel { + FIRInAppMessaging *sdk = [FIRInAppMessaging inAppMessaging]; + NSString *title = sdk.messageDisplaySuppressed ? @"allow rendering" : @"disallow rendering"; + [self.disableEnableSDKBtn setTitle:title forState:UIControlStateNormal]; +} + +- (IBAction)triggerAnalyticEventTapped:(id)sender { + NSLog(@"triggering an analytics event: %@", self.eventNameText.text); + + [FIRAnalytics logEventWithName:self.eventNameText.text parameters:@{}]; +} + +- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { + UITouch *touch = [touches anyObject]; + if (![touch.view isMemberOfClass:[UITextField class]]) { + [touch.view endEditing:YES]; + } +} +- (IBAction)changeAutoDataCollection:(id)sender { + FIRInAppMessaging *sdk = [FIRInAppMessaging inAppMessaging]; + sdk.automaticDataCollectionEnabled = !sdk.automaticDataCollectionEnabled; + [self setupChangeAutoDataCollectionButtonLabel]; +} + +- (void)setupChangeAutoDataCollectionButtonLabel { + FIRInAppMessaging *sdk = [FIRInAppMessaging inAppMessaging]; + NSString *title = sdk.automaticDataCollectionEnabled ? @"disable data-col" : @"enable data-col"; + [self.changeDataCollectionBtn setTitle:title forState:UIControlStateNormal]; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + + double delayInSeconds = 2.0; + dispatch_time_t setupTime = dispatch_time(DISPATCH_TIME_NOW, delayInSeconds * NSEC_PER_SEC); + dispatch_after(setupTime, dispatch_get_main_queue(), ^(void) { + // code to be executed on the main queue after delay + self.sdkRuntime = [FIRIAMRuntimeManager getSDKRuntimeInstance]; + self.messageTableVC.messageCache = self.sdkRuntime.messageCache; + [self.sdkRuntime.messageCache setDataObserver:self.messageTableVC]; + [self.messageTableVC.tableView reloadData]; + [self setupDisableEnableButtonLabel]; + [self setupChangeAutoDataCollectionButtonLabel]; + }); + + NSLog(@"done with set data observer"); + + self.autoFetchIntervalText.text = [[NSNumber + numberWithDouble:self.sdkRuntime.currentSetting.fetchMinIntervalInMinutes * 60] stringValue]; + self.autoDisplayIntervalText.text = + [[NSNumber numberWithDouble:self.sdkRuntime.currentSetting.appFGRenderMinIntervalInMinutes * + 60] stringValue]; +} + +- (IBAction)dumpImpressionsToConsole:(id)sender { + NSArray *impressions = [self.sdkRuntime.bookKeeper getImpressions]; + NSLog(@"impressions are %@", [impressions componentsJoinedByString:@","]); +} +- (IBAction)clearImpressionRecord:(id)sender { + [self.sdkRuntime.bookKeeper cleanupImpressions]; +} + +- (IBAction)changeAutoFetchDisplaySettings:(id)sender { + FIRIAMSDKSettings *setting = self.sdkRuntime.currentSetting; + + // set fetch interval + double intervalValue = self.autoFetchIntervalText.text.doubleValue / 60; + if (intervalValue < 0.0001) { + intervalValue = 1; + self.autoFetchIntervalText.text = [[NSNumber numberWithDouble:intervalValue * 60] stringValue]; + } + setting.fetchMinIntervalInMinutes = intervalValue; + + // set app foreground display interval + double displayIntervalValue = self.autoDisplayIntervalText.text.doubleValue / 60; + + if (displayIntervalValue < 0.0001) { + displayIntervalValue = 1; + self.autoDisplayIntervalText.text = + [[NSNumber numberWithDouble:displayIntervalValue * 60] stringValue]; + } + setting.appFGRenderMinIntervalInMinutes = displayIntervalValue; + + [self.sdkRuntime startRuntimeWithSDKSettings:setting]; +} + +- (void)didReceiveMemoryWarning { + [super didReceiveMemoryWarning]; + // Dispose of any resources that can be recreated. +} + +#pragma mark - Navigation +// In a storyboard-based application, you will often want to do a little preparation before +// navigation +- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender { + // Get the new view controller using [segue destinationViewController]. + // Pass the selected object to the new view controller. + + if ([segue.identifier isEqualToString:@"message-table-segue"]) { + self.messageTableVC = (AutoDisplayMesagesTableVC *)[segue destinationViewController]; + } +} +@end diff --git a/InAppMessaging/Example/App/InAppMessaging-Example-iOS/AutoDisplayMesagesTableVC.h b/InAppMessaging/Example/App/InAppMessaging-Example-iOS/AutoDisplayMesagesTableVC.h new file mode 100644 index 0000000000..df529553d6 --- /dev/null +++ b/InAppMessaging/Example/App/InAppMessaging-Example-iOS/AutoDisplayMesagesTableVC.h @@ -0,0 +1,23 @@ +/* + * 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 + +#import "FIRIAMMessageClientCache.h" + +@interface AutoDisplayMesagesTableVC : UITableViewController +@property(nonatomic) FIRIAMMessageClientCache *messageCache; +@end diff --git a/InAppMessaging/Example/App/InAppMessaging-Example-iOS/AutoDisplayMesagesTableVC.m b/InAppMessaging/Example/App/InAppMessaging-Example-iOS/AutoDisplayMesagesTableVC.m new file mode 100644 index 0000000000..27e5c667a9 --- /dev/null +++ b/InAppMessaging/Example/App/InAppMessaging-Example-iOS/AutoDisplayMesagesTableVC.m @@ -0,0 +1,137 @@ +/* + * 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 "AutoDisplayMesagesTableVC.h" +#import "FIRIAMDisplayTriggerDefinition.h" +#import "FIRIAMMessageContentData.h" + +@interface AutoDisplayMesagesTableVC () +@end + +@implementation AutoDisplayMesagesTableVC + +- (void)dataChanged { + dispatch_async(dispatch_get_main_queue(), ^{ + [self.tableView reloadData]; + }); +} + +- (void)viewDidLoad { + [super viewDidLoad]; + + // Uncomment the following line to preserve selection between presentations. + // self.clearsSelectionOnViewWillAppear = NO; + + // Uncomment the following line to display an Edit button in the navigation bar for this view + // controller. self.navigationItem.rightBarButtonItem = self.editButtonItem; +} + +- (void)didReceiveMemoryWarning { + [super didReceiveMemoryWarning]; + // Dispose of any resources that can be recreated. +} + +#pragma mark - Table view data source +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { + return 1; +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { + NSArray *messages = self.messageCache.allRegularMessages; + if (messages) { + return messages.count; + } else { + return 0; + } +} + +static NSString *CellIdentifier = @"CellIdentifier"; + +- (NSString *)viewModeDisplayString:(FIRIAMRenderingMode)viewMode { + switch (viewMode) { + case FIRIAMRenderAsBannerView: + return @"Banner"; + case FIRIAMRenderAsModalView: + return @"Modal"; + case FIRIAMRenderAsImageOnlyView: + return @"Image"; + default: + return @"Unknown"; + } +} + +- (NSString *)triggerDisplayString:(NSArray *)triggers { + NSMutableString *s = [[NSMutableString alloc] init]; + for (FIRIAMDisplayTriggerDefinition *trigger in triggers) { + [s appendString:[self triggerDisplayStringForOneTrigger:trigger]]; + [s appendString:@","]; + } + return [s copy]; +} + +- (NSString *)triggerDisplayStringForOneTrigger: + (FIRIAMDisplayTriggerDefinition *)triggerDefinition { + switch (triggerDefinition.triggerType) { + case FIRIAMRenderTriggerOnAppForeground: + return @"app_foreground"; + case FIRIAMRenderTriggerOnFirebaseAnalyticsEvent: + return triggerDefinition.firebaseEventName; + default: + return @"Unknown"; + } +} +- (UITableViewCell *)tableView:(UITableView *)tableView + cellForRowAtIndexPath:(NSIndexPath *)indexPath { + NSArray *messageDefs = self.messageCache.allRegularMessages; + + NSInteger rowIndex = [indexPath row]; + if (messageDefs.count > rowIndex) { + UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier]; + + if (cell == nil) { + cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle + reuseIdentifier:CellIdentifier]; + } + + UILabel *titleLabel = (UILabel *)[cell.contentView viewWithTag:10]; + UILabel *modeLabel = (UILabel *)[cell.contentView viewWithTag:20]; + UIImageView *imageView = (UIImageView *)[cell.contentView viewWithTag:30]; + UILabel *triggerLabel = (UILabel *)[cell.contentView viewWithTag:40]; + + titleLabel.text = messageDefs[rowIndex].renderData.contentData.titleText; + modeLabel.text = [self + viewModeDisplayString:messageDefs[rowIndex].renderData.renderingEffectSettings.viewMode]; + + triggerLabel.text = [self triggerDisplayString:messageDefs[rowIndex].renderTriggers]; + + [messageDefs[rowIndex].renderData.contentData + loadImageDataWithBlock:^(NSData *_Nullable imageData, NSError *error) { + if (error) { + NSLog(@"error in loading image: %@", error.localizedDescription); + } else { + UIImage *image = [UIImage imageWithData:imageData]; + dispatch_async(dispatch_get_main_queue(), ^{ + [imageView setImage:image]; + }); + } + }]; + return cell; + } else { + return nil; + } +} + +@end diff --git a/InAppMessaging/Example/App/InAppMessaging-Example-iOS/Base.lproj/LaunchScreen.storyboard b/InAppMessaging/Example/App/InAppMessaging-Example-iOS/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000000..fdf3f97d1b --- /dev/null +++ b/InAppMessaging/Example/App/InAppMessaging-Example-iOS/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/InAppMessaging/Example/App/InAppMessaging-Example-iOS/Base.lproj/Main.storyboard b/InAppMessaging/Example/App/InAppMessaging-Example-iOS/Base.lproj/Main.storyboard new file mode 100644 index 0000000000..0aa7f4cb7c --- /dev/null +++ b/InAppMessaging/Example/App/InAppMessaging-Example-iOS/Base.lproj/Main.storyboard @@ -0,0 +1,395 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/InAppMessaging/Example/App/InAppMessaging-Example-iOS/GoogleService-Info.plist b/InAppMessaging/Example/App/InAppMessaging-Example-iOS/GoogleService-Info.plist new file mode 100644 index 0000000000..3f7547fb48 --- /dev/null +++ b/InAppMessaging/Example/App/InAppMessaging-Example-iOS/GoogleService-Info.plist @@ -0,0 +1,28 @@ + + + + + API_KEY + correct_api_key + TRACKING_ID + correct_tracking_id + CLIENT_ID + correct_client_id + REVERSED_CLIENT_ID + correct_reversed_client_id + GOOGLE_APP_ID + 1:123:ios:123abc + GCM_SENDER_ID + correct_gcm_sender_id + PLIST_VERSION + 1 + BUNDLE_ID + com.google.FirebaseSDKTests + PROJECT_ID + abc-xyz-123 + DATABASE_URL + https://abc-xyz-123.firebaseio.com + STORAGE_BUCKET + project-id-123.storage.firebase.com + + diff --git a/InAppMessaging/Example/App/InAppMessaging-Example-iOS/Info.plist b/InAppMessaging/Example/App/InAppMessaging-Example-iOS/Info.plist new file mode 100644 index 0000000000..f49d10287c --- /dev/null +++ b/InAppMessaging/Example/App/InAppMessaging-Example-iOS/Info.plist @@ -0,0 +1,67 @@ + + + + + FirebaseInAppMessagingAutomaticDataCollectionEnabled + + FirebaseAutomaticDataCollectionEnabled + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0.3 + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + mytesting + CFBundleURLSchemes + + fiam-testing + + + + CFBundleVersion + 1.0.3-1 + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/InAppMessaging/Example/App/InAppMessaging-Example-iOS/LogDumpViewController.h b/InAppMessaging/Example/App/InAppMessaging-Example-iOS/LogDumpViewController.h new file mode 100644 index 0000000000..270c6cfb52 --- /dev/null +++ b/InAppMessaging/Example/App/InAppMessaging-Example-iOS/LogDumpViewController.h @@ -0,0 +1,21 @@ +/* + * 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 + +@interface LogDumpViewController : UIViewController + +@end diff --git a/InAppMessaging/Example/App/InAppMessaging-Example-iOS/LogDumpViewController.m b/InAppMessaging/Example/App/InAppMessaging-Example-iOS/LogDumpViewController.m new file mode 100644 index 0000000000..c050cf4076 --- /dev/null +++ b/InAppMessaging/Example/App/InAppMessaging-Example-iOS/LogDumpViewController.m @@ -0,0 +1,73 @@ +/* + * 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 "LogDumpViewController.h" +#import "AppDelegate.h" +#import "FIRIAMRuntimeManager.h" + +@interface LogDumpViewController () +@property(weak, nonatomic) IBOutlet UITextView *logTextView; +@end + +@implementation LogDumpViewController +- (IBAction)dumpImpressList:(id)sender { + NSArray *impressions = [[FIRIAMRuntimeManager getSDKRuntimeInstance].bookKeeper getImpressions]; + NSString *text = [NSString stringWithFormat:@"Message Impression History are :\n%@", + [impressions componentsJoinedByString:@"\n"]]; + self.logTextView.text = text; +} + +- (IBAction)dumActivityLogs:(id)sender { + NSArray *records = + [[FIRIAMRuntimeManager getSDKRuntimeInstance].activityLogger readRecords]; + + NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init]; + dateFormatter.dateStyle = NSDateFormatterShortStyle; + dateFormatter.timeStyle = NSDateFormatterMediumStyle; + + static NSString *appBuildVersion = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + appBuildVersion = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleVersion"]; + }); + + NSMutableString *dumpContent = [[NSString + stringWithFormat:@"App Build Version -- %@\n\n" + "SDK Settings -- %@\n\n" + "Activity Logs: %lu records\n\n", + appBuildVersion, [FIRIAMRuntimeManager getSDKRuntimeInstance].currentSetting, + (unsigned long)records.count] mutableCopy]; + + for (FIRIAMActivityRecord *next in records) { + NSString *nextRecordLog = [NSString + stringWithFormat:@"%@, %@, %@, %@\n", [dateFormatter stringFromDate:next.timestamp], + [next displayStringForActivityType], next.success ? @"Success" : @"Failed", + next.detail]; + [dumpContent appendString:nextRecordLog]; + } + self.logTextView.text = dumpContent; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + // Do any additional setup after loading the view. +} + +- (void)didReceiveMemoryWarning { + [super didReceiveMemoryWarning]; + // Dispose of any resources that can be recreated. +} +@end diff --git a/InAppMessaging/Example/App/InAppMessaging-Example-iOS/Podfile b/InAppMessaging/Example/App/InAppMessaging-Example-iOS/Podfile new file mode 100644 index 0000000000..f4396a22c4 --- /dev/null +++ b/InAppMessaging/Example/App/InAppMessaging-Example-iOS/Podfile @@ -0,0 +1,34 @@ + +use_frameworks! + + +target 'InAppMessaging_Example_iOS' do + platform :ios, '8.0' + + pod 'FirebaseCommunity/InAppMessaging', :path => '../..' + # Lock to the 1.0.9 version of InstanceID since 1.0.10 added a dependency + # to FirebaseCore + # pod 'FirebaseInstanceID', '1.0.9' + + #target 'InAppMessaging_Tests_iOS' do + # inherit! :search_paths + # pod 'FirebaseCommunity/InAppMessaging', :path => '../..' + # pod 'OCMock' + #end +end + + +#target 'InAppMessaging_Example_Swift_iOS' do +# platform :ios, '8.0' + +# pod 'FirebaseCommunity/InAppMessaging', :path => '../' + # Lock to the 1.0.9 version of InstanceID since 1.0.10 added a dependency + # to FirebaseCore +# pod 'FirebaseInstanceID', '1.0.9' + + #target 'Messaging_Tests_iOS' do + # inherit! :search_paths + # pod 'OCMock' + #end +#end + diff --git a/InAppMessaging/Example/App/InAppMessaging-Example-iOS/main.m b/InAppMessaging/Example/App/InAppMessaging-Example-iOS/main.m new file mode 100644 index 0000000000..3b3b323ef0 --- /dev/null +++ b/InAppMessaging/Example/App/InAppMessaging-Example-iOS/main.m @@ -0,0 +1,24 @@ +/* + * 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 +#import "AppDelegate.h" + +int main(int argc, char* argv[]) { + @autoreleasepool { + return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class])); + } +} diff --git a/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/GoogleService-Info.plist b/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/GoogleService-Info.plist new file mode 100644 index 0000000000..cb4d6f4ac0 --- /dev/null +++ b/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/GoogleService-Info.plist @@ -0,0 +1,28 @@ + + + + + API_KEY + correct_api_key + TRACKING_ID + correct_tracking_id + CLIENT_ID + correct_client_id + REVERSED_CLIENT_ID + correct_reversed_client_id + GOOGLE_APP_ID + 1:123:ios:123abc + GCM_SENDER_ID + correct_gcm_sender_id + PLIST_VERSION + 1 + BUNDLE_ID + com.google.FirebaseSDKTests + PROJECT_ID + abc-xyz-123 + DATABASE_URL + https://abc-xyz-123.firebaseio.com + STORAGE_BUCKET + project-id-123.storage.firebase.com + + diff --git a/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/Podfile b/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/Podfile new file mode 100644 index 0000000000..6e716cb976 --- /dev/null +++ b/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/Podfile @@ -0,0 +1,18 @@ +# Uncomment the next line to define a global platform for your project +platform :ios, '8.4' + +# uncomment the follow two lines if you are trying to test internal releases +#source 'sso://cpdc-internal/spec.git' +#source 'https://github.com/CocoaPods/Specs.git' + +use_frameworks! + +target 'fiam-external-ios-testing-app' do + # Uncomment the next line if you're using Swift or would like to use dynamic frameworks + # use_frameworks! + + # Pods for fiam-external-ios-testing-app + pod 'Firebase/Core' + pod 'Firebase/InAppMessagingDisplay' + pod 'Firebase/DynamicLinks' +end diff --git a/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/fiam-external-ios-testing-app.xcodeproj/project.pbxproj b/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/fiam-external-ios-testing-app.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..3f40036081 --- /dev/null +++ b/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/fiam-external-ios-testing-app.xcodeproj/project.pbxproj @@ -0,0 +1,423 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 48; + objects = { + +/* Begin PBXBuildFile section */ + 7D31D8493943DCD77743922A /* Pods_fiam_external_ios_testing_app.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 12F51E141FEC71BA1CE57DC4 /* Pods_fiam_external_ios_testing_app.framework */; }; + AD7649C71FE1B0A800378AE0 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = AD7649C61FE1B0A800378AE0 /* AppDelegate.m */; }; + AD7649CA1FE1B0A800378AE0 /* ViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = AD7649C91FE1B0A800378AE0 /* ViewController.m */; }; + AD7649CD1FE1B0A800378AE0 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AD7649CB1FE1B0A800378AE0 /* Main.storyboard */; }; + AD7649CF1FE1B0A800378AE0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AD7649CE1FE1B0A800378AE0 /* Assets.xcassets */; }; + AD7649D21FE1B0A800378AE0 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AD7649D01FE1B0A800378AE0 /* LaunchScreen.storyboard */; }; + AD7649D51FE1B0A800378AE0 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = AD7649D41FE1B0A800378AE0 /* main.m */; }; + AD7649DC1FE1B57A00378AE0 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = AD7649DB1FE1B57A00378AE0 /* GoogleService-Info.plist */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 12F51E141FEC71BA1CE57DC4 /* Pods_fiam_external_ios_testing_app.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_fiam_external_ios_testing_app.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 61D649CA05E938083C88FC6D /* Pods-fiam-external-ios-testing-app.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-fiam-external-ios-testing-app.debug.xcconfig"; path = "Pods/Target Support Files/Pods-fiam-external-ios-testing-app/Pods-fiam-external-ios-testing-app.debug.xcconfig"; sourceTree = ""; }; + AD7649C21FE1B0A800378AE0 /* fiam-external-ios-testing-app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "fiam-external-ios-testing-app.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + AD7649C51FE1B0A800378AE0 /* AppDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; + AD7649C61FE1B0A800378AE0 /* AppDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; + AD7649C81FE1B0A800378AE0 /* ViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ViewController.h; sourceTree = ""; }; + AD7649C91FE1B0A800378AE0 /* ViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ViewController.m; sourceTree = ""; }; + AD7649CC1FE1B0A800378AE0 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + AD7649CE1FE1B0A800378AE0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + AD7649D11FE1B0A800378AE0 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + AD7649D31FE1B0A800378AE0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + AD7649D41FE1B0A800378AE0 /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; + AD7649DB1FE1B57A00378AE0 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = SOURCE_ROOT; }; + EE0B5FD5B23F372E4894C799 /* Pods-fiam-external-ios-testing-app.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-fiam-external-ios-testing-app.release.xcconfig"; path = "Pods/Target Support Files/Pods-fiam-external-ios-testing-app/Pods-fiam-external-ios-testing-app.release.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + AD7649BF1FE1B0A800378AE0 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 7D31D8493943DCD77743922A /* Pods_fiam_external_ios_testing_app.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 2F924E232047E700385C2AFA /* Frameworks */ = { + isa = PBXGroup; + children = ( + 12F51E141FEC71BA1CE57DC4 /* Pods_fiam_external_ios_testing_app.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 4DC924B9E0562D822E3E68F3 /* Pods */ = { + isa = PBXGroup; + children = ( + 61D649CA05E938083C88FC6D /* Pods-fiam-external-ios-testing-app.debug.xcconfig */, + EE0B5FD5B23F372E4894C799 /* Pods-fiam-external-ios-testing-app.release.xcconfig */, + ); + name = Pods; + sourceTree = ""; + }; + AD7649B91FE1B0A800378AE0 = { + isa = PBXGroup; + children = ( + AD7649C41FE1B0A800378AE0 /* fiam-external-ios-testing-app */, + AD7649C31FE1B0A800378AE0 /* Products */, + 4DC924B9E0562D822E3E68F3 /* Pods */, + 2F924E232047E700385C2AFA /* Frameworks */, + ); + sourceTree = ""; + }; + AD7649C31FE1B0A800378AE0 /* Products */ = { + isa = PBXGroup; + children = ( + AD7649C21FE1B0A800378AE0 /* fiam-external-ios-testing-app.app */, + ); + name = Products; + sourceTree = ""; + }; + AD7649C41FE1B0A800378AE0 /* fiam-external-ios-testing-app */ = { + isa = PBXGroup; + children = ( + AD7649DB1FE1B57A00378AE0 /* GoogleService-Info.plist */, + AD7649C51FE1B0A800378AE0 /* AppDelegate.h */, + AD7649C61FE1B0A800378AE0 /* AppDelegate.m */, + AD7649C81FE1B0A800378AE0 /* ViewController.h */, + AD7649C91FE1B0A800378AE0 /* ViewController.m */, + AD7649CB1FE1B0A800378AE0 /* Main.storyboard */, + AD7649CE1FE1B0A800378AE0 /* Assets.xcassets */, + AD7649D01FE1B0A800378AE0 /* LaunchScreen.storyboard */, + AD7649D31FE1B0A800378AE0 /* Info.plist */, + AD7649D41FE1B0A800378AE0 /* main.m */, + ); + path = "fiam-external-ios-testing-app"; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + AD7649C11FE1B0A800378AE0 /* fiam-external-ios-testing-app */ = { + isa = PBXNativeTarget; + buildConfigurationList = AD7649D81FE1B0A800378AE0 /* Build configuration list for PBXNativeTarget "fiam-external-ios-testing-app" */; + buildPhases = ( + 89F39EB5CA1632B8B86E938F /* [CP] Check Pods Manifest.lock */, + AD7649BE1FE1B0A800378AE0 /* Sources */, + AD7649BF1FE1B0A800378AE0 /* Frameworks */, + AD7649C01FE1B0A800378AE0 /* Resources */, + AF2C898A9A08823BD10E1650 /* [CP] Embed Pods Frameworks */, + 638CE7E9369C7DEB067D82E7 /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "fiam-external-ios-testing-app"; + productName = "fiam-external-ios-testing-app"; + productReference = AD7649C21FE1B0A800378AE0 /* fiam-external-ios-testing-app.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + AD7649BA1FE1B0A800378AE0 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 0910; + ORGANIZATIONNAME = "Yong Mao"; + TargetAttributes = { + AD7649C11FE1B0A800378AE0 = { + CreatedOnToolsVersion = 9.1; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = AD7649BD1FE1B0A800378AE0 /* Build configuration list for PBXProject "fiam-external-ios-testing-app" */; + compatibilityVersion = "Xcode 8.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = AD7649B91FE1B0A800378AE0; + productRefGroup = AD7649C31FE1B0A800378AE0 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + AD7649C11FE1B0A800378AE0 /* fiam-external-ios-testing-app */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + AD7649C01FE1B0A800378AE0 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AD7649D21FE1B0A800378AE0 /* LaunchScreen.storyboard in Resources */, + AD7649DC1FE1B57A00378AE0 /* GoogleService-Info.plist in Resources */, + AD7649CF1FE1B0A800378AE0 /* Assets.xcassets in Resources */, + AD7649CD1FE1B0A800378AE0 /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 638CE7E9369C7DEB067D82E7 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${SRCROOT}/Pods/Target Support Files/Pods-fiam-external-ios-testing-app/Pods-fiam-external-ios-testing-app-resources.sh", + "${PODS_CONFIGURATION_BUILD_DIR}/FirebaseInAppMessagingDisplay/InAppMessagingDisplayResources.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/InAppMessagingDisplayResources.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-fiam-external-ios-testing-app/Pods-fiam-external-ios-testing-app-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 89F39EB5CA1632B8B86E938F /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-fiam-external-ios-testing-app-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + AF2C898A9A08823BD10E1650 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${SRCROOT}/Pods/Target Support Files/Pods-fiam-external-ios-testing-app/Pods-fiam-external-ios-testing-app-frameworks.sh", + "${BUILT_PRODUCTS_DIR}/GoogleUtilities/GoogleUtilities.framework", + "${BUILT_PRODUCTS_DIR}/nanopb/nanopb.framework", + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GoogleUtilities.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/nanopb.framework", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-fiam-external-ios-testing-app/Pods-fiam-external-ios-testing-app-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + AD7649BE1FE1B0A800378AE0 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AD7649CA1FE1B0A800378AE0 /* ViewController.m in Sources */, + AD7649D51FE1B0A800378AE0 /* main.m in Sources */, + AD7649C71FE1B0A800378AE0 /* AppDelegate.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + AD7649CB1FE1B0A800378AE0 /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + AD7649CC1FE1B0A800378AE0 /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + AD7649D01FE1B0A800378AE0 /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + AD7649D11FE1B0A800378AE0 /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + AD7649D61FE1B0A800378AE0 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.1; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + }; + name = Debug; + }; + AD7649D71FE1B0A800378AE0 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.1; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + AD7649D91FE1B0A800378AE0 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 61D649CA05E938083C88FC6D /* Pods-fiam-external-ios-testing-app.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = EQHXZ8M8AV; + INFOPLIST_FILE = "fiam-external-ios-testing-app/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 8.4; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = "com.google.fiam-external-ios-testing"; + PRODUCT_NAME = "$(TARGET_NAME)"; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + AD7649DA1FE1B0A800378AE0 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = EE0B5FD5B23F372E4894C799 /* Pods-fiam-external-ios-testing-app.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = EQHXZ8M8AV; + INFOPLIST_FILE = "fiam-external-ios-testing-app/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 8.4; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = "com.google.fiam-external-ios-testing"; + PRODUCT_NAME = "$(TARGET_NAME)"; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + AD7649BD1FE1B0A800378AE0 /* Build configuration list for PBXProject "fiam-external-ios-testing-app" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AD7649D61FE1B0A800378AE0 /* Debug */, + AD7649D71FE1B0A800378AE0 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + AD7649D81FE1B0A800378AE0 /* Build configuration list for PBXNativeTarget "fiam-external-ios-testing-app" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AD7649D91FE1B0A800378AE0 /* Debug */, + AD7649DA1FE1B0A800378AE0 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = AD7649BA1FE1B0A800378AE0 /* Project object */; +} diff --git a/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/fiam-external-ios-testing-app/AppDelegate.h b/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/fiam-external-ios-testing-app/AppDelegate.h new file mode 100644 index 0000000000..013891c90b --- /dev/null +++ b/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/fiam-external-ios-testing-app/AppDelegate.h @@ -0,0 +1,21 @@ +// 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 + +@interface AppDelegate : UIResponder + +@property(strong, nonatomic) UIWindow *window; + +@end diff --git a/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/fiam-external-ios-testing-app/AppDelegate.m b/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/fiam-external-ios-testing-app/AppDelegate.m new file mode 100644 index 0000000000..f645a71b70 --- /dev/null +++ b/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/fiam-external-ios-testing-app/AppDelegate.m @@ -0,0 +1,70 @@ +// 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 "AppDelegate.h" + +@import Firebase; + +@interface AppDelegate () + +@end + +@implementation AppDelegate + +- (BOOL)application:(UIApplication *)application + didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { + // Override point for customization after application launch. + // uncomment the following line for disabling the auto startup + // of the sdk + // [FIRInAppMessaging inAppMessaging].automaticDataCollectionEnabled = @NO; + + [FIROptions defaultOptions].deepLinkURLScheme = @"com.google.InAppMessagingExampleiOS"; + [FIRApp configure]; + return YES; +} + +- (BOOL)application:(UIApplication *)app + openURL:(NSURL *)url + options:(NSDictionary *)options { + NSLog(@"called here 1"); + return [self application:app + openURL:url + sourceApplication:options[UIApplicationOpenURLOptionsSourceApplicationKey] + annotation:options[UIApplicationOpenURLOptionsAnnotationKey]]; +} + +- (BOOL)application:(UIApplication *)application + openURL:(NSURL *)url + sourceApplication:(NSString *)sourceApplication + annotation:(id)annotation { + FIRDynamicLink *dynamicLink = [[FIRDynamicLinks dynamicLinks] dynamicLinkFromCustomSchemeURL:url]; + + NSLog(@"called here with %@", dynamicLink); + if (dynamicLink) { + if (dynamicLink.url) { + // Handle the deep link. For example, show the deep-linked content, + // apply a promotional offer to the user's account or show customized onboarding view. + // ... + + } else { + // Dynamic link has empty deep link. This situation will happens if + // Firebase Dynamic Links iOS SDK tried to retrieve pending dynamic link, + // but pending link is not available for this device/App combination. + // At this point you may display default onboarding view. + } + return YES; + } + return NO; +} +@end diff --git a/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/fiam-external-ios-testing-app/Assets.xcassets/AppIcon.appiconset/Contents.json b/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/fiam-external-ios-testing-app/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..1d060ed288 --- /dev/null +++ b/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/fiam-external-ios-testing-app/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,93 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "3x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "83.5x83.5", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/fiam-external-ios-testing-app/Base.lproj/LaunchScreen.storyboard b/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/fiam-external-ios-testing-app/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000000..acde84d5a5 --- /dev/null +++ b/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/fiam-external-ios-testing-app/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/fiam-external-ios-testing-app/Base.lproj/Main.storyboard b/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/fiam-external-ios-testing-app/Base.lproj/Main.storyboard new file mode 100644 index 0000000000..67cb7ed4c5 --- /dev/null +++ b/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/fiam-external-ios-testing-app/Base.lproj/Main.storyboard @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/fiam-external-ios-testing-app/Info.plist b/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/fiam-external-ios-testing-app/Info.plist new file mode 100644 index 0000000000..15f461a4e6 --- /dev/null +++ b/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/fiam-external-ios-testing-app/Info.plist @@ -0,0 +1,60 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + fiam-external-ios-testing + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + my-url + CFBundleURLSchemes + + com.google.InAppMessagingExampleiOS + + + + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/fiam-external-ios-testing-app/ViewController.h b/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/fiam-external-ios-testing-app/ViewController.h new file mode 100644 index 0000000000..b6115b8070 --- /dev/null +++ b/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/fiam-external-ios-testing-app/ViewController.h @@ -0,0 +1,19 @@ +// 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 + +@interface ViewController : UIViewController + +@end diff --git a/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/fiam-external-ios-testing-app/ViewController.m b/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/fiam-external-ios-testing-app/ViewController.m new file mode 100644 index 0000000000..44335d8292 --- /dev/null +++ b/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/fiam-external-ios-testing-app/ViewController.m @@ -0,0 +1,39 @@ +// 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 "ViewController.h" + +#import + +@interface ViewController () +@property(weak, nonatomic) IBOutlet UITextField *urlText; + +@end + +@implementation ViewController +- (IBAction)triggerEvent:(id)sender { + [FIRAnalytics logEventWithName:self.urlText.text parameters:@{}]; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + // Do any additional setup after loading the view, typically from a nib. +} + +- (void)didReceiveMemoryWarning { + [super didReceiveMemoryWarning]; + // Dispose of any resources that can be recreated. +} + +@end diff --git a/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/fiam-external-ios-testing-app/main.m b/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/fiam-external-ios-testing-app/main.m new file mode 100644 index 0000000000..3b3b323ef0 --- /dev/null +++ b/InAppMessaging/Example/ExternalAppExample/fiam-external-ios-testing-app/fiam-external-ios-testing-app/main.m @@ -0,0 +1,24 @@ +/* + * 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 +#import "AppDelegate.h" + +int main(int argc, char* argv[]) { + @autoreleasepool { + return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class])); + } +} diff --git a/InAppMessaging/Example/FIRIAMSDKModeManagerTests.m b/InAppMessaging/Example/FIRIAMSDKModeManagerTests.m new file mode 100644 index 0000000000..eed680b313 --- /dev/null +++ b/InAppMessaging/Example/FIRIAMSDKModeManagerTests.m @@ -0,0 +1,137 @@ +/* + * 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 +#import + +#import "FIRIAMSDKModeManager.h" + +@interface FIRIAMSDKModeManagerTests : XCTestCase +@property(nonatomic) NSUserDefaults *mockUserDefaults; +@property(nonatomic) id mockTestingModeListener; +@end + +@implementation FIRIAMSDKModeManagerTests + +- (void)setUp { + [super setUp]; + self.mockUserDefaults = OCMClassMock(NSUserDefaults.class); + self.mockTestingModeListener = OCMStrictProtocolMock(@protocol(FIRIAMTestingModeListener)); +} + +- (void)tearDown { + // Put teardown code here. This method is called after the invocation of each test method in the + // class. + [super tearDown]; +} + +- (void)testFirstRunFromInstall_ok { + // mode entry not existing from a fresh install + OCMStub([self.mockUserDefaults objectForKey:[OCMArg isEqual:kFIRIAMUserDefaultKeyForSDKMode]]) + .andReturn(nil); + FIRIAMSDKModeManager *sdkManager = + [[FIRIAMSDKModeManager alloc] initWithUserDefaults:self.mockUserDefaults + testingModeListener:self.mockTestingModeListener]; + + XCTAssertEqual(FIRIAMSDKModeNewlyInstalled, [sdkManager currentMode]); + + // verify that we setting the mode into use defaults + OCMVerify([self.mockUserDefaults + setObject:[OCMArg isEqual:[NSNumber numberWithInt:FIRIAMSDKModeNewlyInstalled]] + forKey:[OCMArg isEqual:kFIRIAMUserDefaultKeyForSDKMode]]); + + // verify that we are initializing fetch count as 0 by writing into user defaults + OCMVerify([self.mockUserDefaults + setInteger:0 + forKey:[OCMArg isEqual:kFIRIAMUserDefaultKeyForServerFetchCount]]); +} + +- (void)testGoingIntoRegularFromNewlyInstalledMode { + // mode entry not existing from a fresh install + OCMStub([self.mockUserDefaults objectForKey:[OCMArg isEqual:kFIRIAMUserDefaultKeyForSDKMode]]) + .andReturn(nil); + FIRIAMSDKModeManager *sdkManager = + [[FIRIAMSDKModeManager alloc] initWithUserDefaults:self.mockUserDefaults + testingModeListener:self.mockTestingModeListener]; + XCTAssertEqual(FIRIAMSDKModeNewlyInstalled, [sdkManager currentMode]); + + // now we register up to kFIRIAMMaxFetchInNewlyInstalledMode - 1 fetches and it still stay + // in Newly Installed mode + for (int i = 0; i < kFIRIAMMaxFetchInNewlyInstalledMode - 1; i++) { + [sdkManager registerOneMoreFetch]; + } + XCTAssertEqual(FIRIAMSDKModeNewlyInstalled, [sdkManager currentMode]); + + // now one more fetch would turn it into regular mode + [sdkManager registerOneMoreFetch]; + XCTAssertEqual(FIRIAMSDKModeRegular, [sdkManager currentMode]); +} + +- (void)testIncrementCountForFetchRegistrationFromNewlyInstalledMode { + // put sdk into regular mode + OCMStub([self.mockUserDefaults objectForKey:[OCMArg isEqual:kFIRIAMUserDefaultKeyForSDKMode]]) + .andReturn([NSNumber numberWithInt:FIRIAMSDKModeNewlyInstalled]); + + int currentFetchCount = 3; + OCMStub([self.mockUserDefaults + integerForKey:[OCMArg isEqual:kFIRIAMUserDefaultKeyForServerFetchCount]]) + .andReturn(currentFetchCount); + FIRIAMSDKModeManager *sdkManager = + [[FIRIAMSDKModeManager alloc] initWithUserDefaults:self.mockUserDefaults + testingModeListener:self.mockTestingModeListener]; + + // now we do new fetch registeration + [sdkManager registerOneMoreFetch]; + + // verify that we are writing currentFetchCount+1 into user defaults + OCMVerify([self.mockUserDefaults + setInteger:currentFetchCount + 1 + forKey:[OCMArg isEqual:kFIRIAMUserDefaultKeyForServerFetchCount]]); +} + +- (void)testNoUpdateForFetchRegistrationFromRegularMode { + // put sdk into regular mode + OCMStub([self.mockUserDefaults objectForKey:[OCMArg isEqual:kFIRIAMUserDefaultKeyForSDKMode]]) + .andReturn([NSNumber numberWithInt:FIRIAMSDKModeRegular]); + + int currentFetchCount = 3; + OCMStub([self.mockUserDefaults + integerForKey:[OCMArg isEqual:kFIRIAMUserDefaultKeyForServerFetchCount]]) + .andReturn(currentFetchCount); + FIRIAMSDKModeManager *sdkManager = + [[FIRIAMSDKModeManager alloc] initWithUserDefaults:self.mockUserDefaults + testingModeListener:self.mockTestingModeListener]; + + // now we do new fetch registeration, but no more fetch count or mode updates in user defaults + [sdkManager registerOneMoreFetch]; + XCTAssertEqual(FIRIAMSDKModeRegular, [sdkManager currentMode]); + OCMReject([self.mockUserDefaults setInteger:currentFetchCount + 1 forKey:[OCMArg any]]); +} + +- (void)testGoingIntoTestingDeviceMode { + // mode entry not existing from a fresh install + OCMStub([self.mockUserDefaults objectForKey:[OCMArg isEqual:kFIRIAMUserDefaultKeyForSDKMode]]) + .andReturn(nil); + OCMExpect([self.mockTestingModeListener testingModeSwitchedOn]); + FIRIAMSDKModeManager *sdkManager = + [[FIRIAMSDKModeManager alloc] initWithUserDefaults:self.mockUserDefaults + testingModeListener:self.mockTestingModeListener]; + + [sdkManager becomeTestingInstance]; + XCTAssertEqual(FIRIAMSDKModeTesting, [sdkManager currentMode]); + OCMVerify([self.mockTestingModeListener testingModeSwitchedOn]); +} +@end diff --git a/InAppMessaging/Example/InAppMessaging-Example-iOS.xcodeproj/project.pbxproj b/InAppMessaging/Example/InAppMessaging-Example-iOS.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..a2d174fd00 --- /dev/null +++ b/InAppMessaging/Example/InAppMessaging-Example-iOS.xcodeproj/project.pbxproj @@ -0,0 +1,828 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXBuildFile section */ + 25C6745121EEC868005A4C23 /* NSString+InterlaceStringsTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 25C6745021EEC868005A4C23 /* NSString+InterlaceStringsTests.m */; }; + 5BDD2462D9E03C4678945D2B /* Pods_InAppMessaging_Example_iOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 16E2417C62DE07F79F99BC3A /* Pods_InAppMessaging_Example_iOS.framework */; }; + AD0E370C1F7C63BC00BBF23C /* FIRIAMMsgFetcherUsingRestfulTests.m in Sources */ = {isa = PBXBuildFile; fileRef = AD0E370B1F7C63BC00BBF23C /* FIRIAMMsgFetcherUsingRestfulTests.m */; }; + AD0E37101F7DB3D500BBF23C /* FIRIAMFetchResponseParserTests.m in Sources */ = {isa = PBXBuildFile; fileRef = AD0E370F1F7DB3D500BBF23C /* FIRIAMFetchResponseParserTests.m */; }; + AD241B0F1F4F582A009A3C22 /* FIRIAMElapsedTimeTrackerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = AD241B0E1F4F582A009A3C22 /* FIRIAMElapsedTimeTrackerTests.m */; }; + AD3868531F95300E00E36BC5 /* FIRIAMMessageClientCacheTests.m in Sources */ = {isa = PBXBuildFile; fileRef = AD3868521F95300E00E36BC5 /* FIRIAMMessageClientCacheTests.m */; }; + AD39268920AB55B700FA3FDF /* JsonDataWithInvalidMessagesFromFetch.txt in Resources */ = {isa = PBXBuildFile; fileRef = AD39268820AB55B700FA3FDF /* JsonDataWithInvalidMessagesFromFetch.txt */; }; + AD39268A20AB56B900FA3FDF /* JsonDataWithInvalidMessagesFromFetch.txt in Resources */ = {isa = PBXBuildFile; fileRef = AD39268820AB55B700FA3FDF /* JsonDataWithInvalidMessagesFromFetch.txt */; }; + AD3EE82A1F4CED0B006829AE /* FIRIAMDisplayExecutorTests.m in Sources */ = {isa = PBXBuildFile; fileRef = AD3EE8291F4CED0B006829AE /* FIRIAMDisplayExecutorTests.m */; }; + AD5D25CB1FA91C9900F1B0EB /* FIRIAMActivityLoggerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = AD5D25CA1FA91C9900F1B0EB /* FIRIAMActivityLoggerTests.m */; }; + AD6A6CB11F56093700A6DFA1 /* FIRIAMBookKeeperViaUserDefaultsTests.m in Sources */ = {isa = PBXBuildFile; fileRef = AD6A6CAE1F5606CD00A6DFA1 /* FIRIAMBookKeeperViaUserDefaultsTests.m */; }; + AD764A341FE4856400378AE0 /* AutoDisplayFlowViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = AD764A2E1FE4856300378AE0 /* AutoDisplayFlowViewController.m */; }; + AD764A361FE4856400378AE0 /* LogDumpViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = AD764A301FE4856300378AE0 /* LogDumpViewController.m */; }; + AD764A371FE4856400378AE0 /* AutoDisplayMesagesTableVC.m in Sources */ = {isa = PBXBuildFile; fileRef = AD764A331FE4856300378AE0 /* AutoDisplayMesagesTableVC.m */; }; + AD811A321F13F88800BF632A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = AD811A311F13F88800BF632A /* main.m */; }; + AD811A351F13F88800BF632A /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = AD811A341F13F88800BF632A /* AppDelegate.m */; }; + AD811A3D1F13F88800BF632A /* Shared.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AD811A3C1F13F88800BF632A /* Shared.xcassets */; }; + AD81223D1F14100700BF632A /* FIRIAMMessageContentDataWithImageURLTests.m in Sources */ = {isa = PBXBuildFile; fileRef = AD8122391F140FFC00BF632A /* FIRIAMMessageContentDataWithImageURLTests.m */; }; + AD8122431F14148100BF632A /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AD81221B1F14064800BF632A /* LaunchScreen.storyboard */; }; + AD8122441F14148100BF632A /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AD8122181F14052100BF632A /* Main.storyboard */; }; + AD95D8EE1FFEFCA700780607 /* FIRIAMActionUrlFollowerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = AD95D8ED1FFEFCA700780607 /* FIRIAMActionUrlFollowerTests.m */; }; + ADA10F98202CC1B0000F4425 /* AdSupport.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = ADA10F97202CC1B0000F4425 /* AdSupport.framework */; }; + ADA10F9A202CCBFC000F4425 /* FIRIAMAnalyticsEventLoggerImplTests.m in Sources */ = {isa = PBXBuildFile; fileRef = ADA10F99202CCBFC000F4425 /* FIRIAMAnalyticsEventLoggerImplTests.m */; }; + ADA72B9320282F3B0087E131 /* FIRIAMClearcutUploaderTests.m in Sources */ = {isa = PBXBuildFile; fileRef = ADA72B9220282F3B0087E131 /* FIRIAMClearcutUploaderTests.m */; }; + ADB47ADF20A107C6002D52E9 /* TestJsonDataFromFetch.txt in Resources */ = {isa = PBXBuildFile; fileRef = ADB47ADE20A107C6002D52E9 /* TestJsonDataFromFetch.txt */; }; + ADB47AE020A107F3002D52E9 /* TestJsonDataFromFetch.txt in Resources */ = {isa = PBXBuildFile; fileRef = ADB47ADE20A107C6002D52E9 /* TestJsonDataFromFetch.txt */; }; + ADB47AE220A110F4002D52E9 /* TestJsonDataWithTestMessageFromFetch.txt in Resources */ = {isa = PBXBuildFile; fileRef = ADB47AE120A110F4002D52E9 /* TestJsonDataWithTestMessageFromFetch.txt */; }; + ADB47AE320A111D5002D52E9 /* TestJsonDataWithTestMessageFromFetch.txt in Resources */ = {isa = PBXBuildFile; fileRef = ADB47AE120A110F4002D52E9 /* TestJsonDataWithTestMessageFromFetch.txt */; }; + ADBC527A1F4CD18300A4BEF9 /* FIRIAMFetchFlowTests.m in Sources */ = {isa = PBXBuildFile; fileRef = ADBC52791F4CD18300A4BEF9 /* FIRIAMFetchFlowTests.m */; }; + ADC4298A1F8BEAFD00027599 /* UIColor+FIRIAMHexStringTests.m in Sources */ = {isa = PBXBuildFile; fileRef = ADC429891F8BEAFD00027599 /* UIColor+FIRIAMHexStringTests.m */; }; + ADC4298F1F8D3DD500027599 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = ADC4298E1F8D3DD500027599 /* GoogleService-Info.plist */; }; + ADCC091020782E2D009BFC2F /* FIRIAMSDKModeManagerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = ADCC090F20782E2D009BFC2F /* FIRIAMSDKModeManagerTests.m */; }; + ADD981962006D1C500944751 /* FIRIAMClearcutLoggerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = ADD981952006D1C500944751 /* FIRIAMClearcutLoggerTests.m */; }; + ADE5BB60200988A1001A1395 /* FIRIAMClearcutRetryLocalStorageTests.m in Sources */ = {isa = PBXBuildFile; fileRef = ADE5BB5F200988A1001A1395 /* FIRIAMClearcutRetryLocalStorageTests.m */; }; + FA7A6267360499E94F504669 /* Pods_InAppMessaging_Tests_iOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B403D5D166E193EAAB4C3B25 /* Pods_InAppMessaging_Tests_iOS.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + AD8122311F140F9700BF632A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = AD811A251F13F88800BF632A /* Project object */; + proxyType = 1; + remoteGlobalIDString = AD811A2C1F13F88800BF632A; + remoteInfo = InAppMessaging_Example_iOS; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 0AC166835CF567A8E912BD91 /* Pods-InAppMessaging_Example_iOS_Swift.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-InAppMessaging_Example_iOS_Swift.release.xcconfig"; path = "Pods/Target Support Files/Pods-InAppMessaging_Example_iOS_Swift/Pods-InAppMessaging_Example_iOS_Swift.release.xcconfig"; sourceTree = ""; }; + 16E2417C62DE07F79F99BC3A /* Pods_InAppMessaging_Example_iOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_InAppMessaging_Example_iOS.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 25C6745021EEC868005A4C23 /* NSString+InterlaceStringsTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = "NSString+InterlaceStringsTests.m"; path = "Tests/NSString+InterlaceStringsTests.m"; sourceTree = ""; }; + 4A218F594D01B3E92842DF08 /* Pods-InAppMessaging_Tests_iOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-InAppMessaging_Tests_iOS.release.xcconfig"; path = "Pods/Target Support Files/Pods-InAppMessaging_Tests_iOS/Pods-InAppMessaging_Tests_iOS.release.xcconfig"; sourceTree = ""; }; + 92F3AF0CAD88D57986578C52 /* Pods-InAppMessaging_Tests_iOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-InAppMessaging_Tests_iOS.debug.xcconfig"; path = "Pods/Target Support Files/Pods-InAppMessaging_Tests_iOS/Pods-InAppMessaging_Tests_iOS.debug.xcconfig"; sourceTree = ""; }; + 9E53A331D50419C13BE9189E /* Pods_InAppMessaging_Example_iOS_Swift.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_InAppMessaging_Example_iOS_Swift.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + AD00D6E61F26655B00DB4967 /* InAppMessaging_Example_iOS_SwiftUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessaging_Example_iOS_SwiftUITests.swift; sourceTree = ""; }; + AD00D6E81F26655B00DB4967 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + AD0E370B1F7C63BC00BBF23C /* FIRIAMMsgFetcherUsingRestfulTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = FIRIAMMsgFetcherUsingRestfulTests.m; path = Tests/FIRIAMMsgFetcherUsingRestfulTests.m; sourceTree = ""; }; + AD0E370F1F7DB3D500BBF23C /* FIRIAMFetchResponseParserTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = FIRIAMFetchResponseParserTests.m; path = Tests/FIRIAMFetchResponseParserTests.m; sourceTree = ""; }; + AD1469821FEC7DD5002051BF /* InAppMessaging_Example_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = InAppMessaging_Example_iOS.entitlements; sourceTree = ""; }; + AD241B0E1F4F582A009A3C22 /* FIRIAMElapsedTimeTrackerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = FIRIAMElapsedTimeTrackerTests.m; path = Tests/FIRIAMElapsedTimeTrackerTests.m; sourceTree = ""; }; + AD3868521F95300E00E36BC5 /* FIRIAMMessageClientCacheTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = FIRIAMMessageClientCacheTests.m; path = Tests/FIRIAMMessageClientCacheTests.m; sourceTree = ""; }; + AD39268820AB55B700FA3FDF /* JsonDataWithInvalidMessagesFromFetch.txt */ = {isa = PBXFileReference; lastKnownFileType = text; name = JsonDataWithInvalidMessagesFromFetch.txt; path = Tests/JsonDataWithInvalidMessagesFromFetch.txt; sourceTree = ""; }; + AD3EE8291F4CED0B006829AE /* FIRIAMDisplayExecutorTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = FIRIAMDisplayExecutorTests.m; path = Tests/FIRIAMDisplayExecutorTests.m; sourceTree = ""; }; + AD40FB071F38CCB700AB8C14 /* SnapshotHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SnapshotHelper.swift; path = ../App/InAppMessaging_Example_iOS_Swift/SnapshotHelper.swift; sourceTree = ""; }; + AD5D25CA1FA91C9900F1B0EB /* FIRIAMActivityLoggerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = FIRIAMActivityLoggerTests.m; path = Tests/FIRIAMActivityLoggerTests.m; sourceTree = ""; }; + AD6A6CAE1F5606CD00A6DFA1 /* FIRIAMBookKeeperViaUserDefaultsTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = FIRIAMBookKeeperViaUserDefaultsTests.m; path = Tests/FIRIAMBookKeeperViaUserDefaultsTests.m; sourceTree = ""; }; + AD764A2C1FE4856300378AE0 /* AutoDisplayMesagesTableVC.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AutoDisplayMesagesTableVC.h; path = "App/InAppMessaging-Example-iOS/AutoDisplayMesagesTableVC.h"; sourceTree = ""; }; + AD764A2D1FE4856300378AE0 /* AutoDisplayFlowViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AutoDisplayFlowViewController.h; path = "App/InAppMessaging-Example-iOS/AutoDisplayFlowViewController.h"; sourceTree = ""; }; + AD764A2E1FE4856300378AE0 /* AutoDisplayFlowViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = AutoDisplayFlowViewController.m; path = "App/InAppMessaging-Example-iOS/AutoDisplayFlowViewController.m"; sourceTree = ""; }; + AD764A301FE4856300378AE0 /* LogDumpViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = LogDumpViewController.m; path = "App/InAppMessaging-Example-iOS/LogDumpViewController.m"; sourceTree = ""; }; + AD764A311FE4856300378AE0 /* LogDumpViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = LogDumpViewController.h; path = "App/InAppMessaging-Example-iOS/LogDumpViewController.h"; sourceTree = ""; }; + AD764A331FE4856300378AE0 /* AutoDisplayMesagesTableVC.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = AutoDisplayMesagesTableVC.m; path = "App/InAppMessaging-Example-iOS/AutoDisplayMesagesTableVC.m"; sourceTree = ""; }; + AD811A2D1F13F88800BF632A /* InAppMessaging_Example_iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = InAppMessaging_Example_iOS.app; sourceTree = BUILT_PRODUCTS_DIR; }; + AD811A311F13F88800BF632A /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = main.m; path = "App/InAppMessaging-Example-iOS/main.m"; sourceTree = ""; }; + AD811A331F13F88800BF632A /* AppDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = "App/InAppMessaging-Example-iOS/AppDelegate.h"; sourceTree = ""; }; + AD811A341F13F88800BF632A /* AppDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = AppDelegate.m; path = "App/InAppMessaging-Example-iOS/AppDelegate.m"; sourceTree = ""; }; + AD811A3C1F13F88800BF632A /* Shared.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Shared.xcassets; path = ../../Example/Shared/Shared.xcassets; sourceTree = ""; }; + AD811A411F13F88800BF632A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = "App/InAppMessaging-Example-iOS/Info.plist"; sourceTree = ""; }; + AD8122191F14052100BF632A /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = "App/InAppMessaging-Example-iOS/Base.lproj/Main.storyboard"; sourceTree = ""; }; + AD81221C1F14064800BF632A /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = "App/InAppMessaging-Example-iOS/Base.lproj/LaunchScreen.storyboard"; sourceTree = ""; }; + AD81222C1F140F9700BF632A /* InAppMessaging_Tests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = InAppMessaging_Tests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + AD8122391F140FFC00BF632A /* FIRIAMMessageContentDataWithImageURLTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = FIRIAMMessageContentDataWithImageURLTests.m; path = Tests/FIRIAMMessageContentDataWithImageURLTests.m; sourceTree = ""; }; + AD8122411F1412C500BF632A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Tests/Info.plist; sourceTree = ""; }; + AD95D8ED1FFEFCA700780607 /* FIRIAMActionUrlFollowerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = FIRIAMActionUrlFollowerTests.m; path = Tests/FIRIAMActionUrlFollowerTests.m; sourceTree = ""; }; + ADA10F97202CC1B0000F4425 /* AdSupport.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AdSupport.framework; path = System/Library/Frameworks/AdSupport.framework; sourceTree = SDKROOT; }; + ADA10F99202CCBFC000F4425 /* FIRIAMAnalyticsEventLoggerImplTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = FIRIAMAnalyticsEventLoggerImplTests.m; path = Tests/FIRIAMAnalyticsEventLoggerImplTests.m; sourceTree = ""; }; + ADA72B9220282F3B0087E131 /* FIRIAMClearcutUploaderTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = FIRIAMClearcutUploaderTests.m; path = Tests/FIRIAMClearcutUploaderTests.m; sourceTree = ""; }; + ADB47ADE20A107C6002D52E9 /* TestJsonDataFromFetch.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestJsonDataFromFetch.txt; sourceTree = ""; }; + ADB47AE120A110F4002D52E9 /* TestJsonDataWithTestMessageFromFetch.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestJsonDataWithTestMessageFromFetch.txt; sourceTree = ""; }; + ADBC52791F4CD18300A4BEF9 /* FIRIAMFetchFlowTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = FIRIAMFetchFlowTests.m; path = Tests/FIRIAMFetchFlowTests.m; sourceTree = ""; }; + ADC429891F8BEAFD00027599 /* UIColor+FIRIAMHexStringTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = "UIColor+FIRIAMHexStringTests.m"; path = "Tests/UIColor+FIRIAMHexStringTests.m"; sourceTree = ""; }; + ADC4298E1F8D3DD500027599 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "App/InAppMessaging-Example-iOS/GoogleService-Info.plist"; sourceTree = ""; }; + ADCC090F20782E2D009BFC2F /* FIRIAMSDKModeManagerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FIRIAMSDKModeManagerTests.m; sourceTree = ""; }; + ADD981952006D1C500944751 /* FIRIAMClearcutLoggerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = FIRIAMClearcutLoggerTests.m; path = Tests/FIRIAMClearcutLoggerTests.m; sourceTree = ""; }; + ADE5BB5F200988A1001A1395 /* FIRIAMClearcutRetryLocalStorageTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = FIRIAMClearcutRetryLocalStorageTests.m; path = Tests/FIRIAMClearcutRetryLocalStorageTests.m; sourceTree = ""; }; + B08DD02B5CAEC2B9FF562FBC /* Pods_fiam_sample_consuming_app.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_fiam_sample_consuming_app.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + B403D5D166E193EAAB4C3B25 /* Pods_InAppMessaging_Tests_iOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_InAppMessaging_Tests_iOS.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + BF4A969BA440546630E4C931 /* Pods-InAppMessaging_Example_iOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-InAppMessaging_Example_iOS.debug.xcconfig"; path = "Pods/Target Support Files/Pods-InAppMessaging_Example_iOS/Pods-InAppMessaging_Example_iOS.debug.xcconfig"; sourceTree = ""; }; + E96B40A85DA4991275EC4934 /* Pods-InAppMessaging_Example_iOS_Swift.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-InAppMessaging_Example_iOS_Swift.debug.xcconfig"; path = "Pods/Target Support Files/Pods-InAppMessaging_Example_iOS_Swift/Pods-InAppMessaging_Example_iOS_Swift.debug.xcconfig"; sourceTree = ""; }; + F0C2B7614A637C08CBDB8FF1 /* Pods-InAppMessaging_Example_iOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-InAppMessaging_Example_iOS.release.xcconfig"; path = "Pods/Target Support Files/Pods-InAppMessaging_Example_iOS/Pods-InAppMessaging_Example_iOS.release.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + AD811A2A1F13F88800BF632A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ADA10F98202CC1B0000F4425 /* AdSupport.framework in Frameworks */, + 5BDD2462D9E03C4678945D2B /* Pods_InAppMessaging_Example_iOS.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + AD8122291F140F9700BF632A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + FA7A6267360499E94F504669 /* Pods_InAppMessaging_Tests_iOS.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 9F765C4CE4252F7162E20DB1 /* Pods */ = { + isa = PBXGroup; + children = ( + BF4A969BA440546630E4C931 /* Pods-InAppMessaging_Example_iOS.debug.xcconfig */, + F0C2B7614A637C08CBDB8FF1 /* Pods-InAppMessaging_Example_iOS.release.xcconfig */, + E96B40A85DA4991275EC4934 /* Pods-InAppMessaging_Example_iOS_Swift.debug.xcconfig */, + 0AC166835CF567A8E912BD91 /* Pods-InAppMessaging_Example_iOS_Swift.release.xcconfig */, + 92F3AF0CAD88D57986578C52 /* Pods-InAppMessaging_Tests_iOS.debug.xcconfig */, + 4A218F594D01B3E92842DF08 /* Pods-InAppMessaging_Tests_iOS.release.xcconfig */, + ); + name = Pods; + sourceTree = ""; + }; + AD00D6E51F26655B00DB4967 /* InAppMessaging_Example_iOS_SwiftUITests */ = { + isa = PBXGroup; + children = ( + AD40FB071F38CCB700AB8C14 /* SnapshotHelper.swift */, + AD00D6E61F26655B00DB4967 /* InAppMessaging_Example_iOS_SwiftUITests.swift */, + AD00D6E81F26655B00DB4967 /* Info.plist */, + ); + path = InAppMessaging_Example_iOS_SwiftUITests; + sourceTree = ""; + }; + AD531EF91F3A5D9D00E899A5 /* UI-Tests */ = { + isa = PBXGroup; + children = ( + AD00D6E51F26655B00DB4967 /* InAppMessaging_Example_iOS_SwiftUITests */, + ); + name = "UI-Tests"; + sourceTree = ""; + }; + AD531EFA1F3A5DB400E899A5 /* Unit Tests */ = { + isa = PBXGroup; + children = ( + ADE5BB5E20098880001A1395 /* Analytics */, + AD95D8EC1FFEFC6000780607 /* Runtime */, + AD6A6CAB1F56030F00A6DFA1 /* Util */, + ADBC52781F4CD12C00A4BEF9 /* Flows */, + AD8122411F1412C500BF632A /* Info.plist */, + AD8122371F140FEA00BF632A /* Data */, + AD8122361F140FE500BF632A /* UI */, + ); + name = "Unit Tests"; + sourceTree = ""; + }; + AD6A6CAB1F56030F00A6DFA1 /* Util */ = { + isa = PBXGroup; + children = ( + AD241B0E1F4F582A009A3C22 /* FIRIAMElapsedTimeTrackerTests.m */, + ADC429891F8BEAFD00027599 /* UIColor+FIRIAMHexStringTests.m */, + 25C6745021EEC868005A4C23 /* NSString+InterlaceStringsTests.m */, + ); + name = Util; + sourceTree = ""; + }; + AD811A241F13F88800BF632A = { + isa = PBXGroup; + children = ( + AD1469821FEC7DD5002051BF /* InAppMessaging_Example_iOS.entitlements */, + AD81221F1F140DE800BF632A /* Tests */, + AD811A471F13FB3A00BF632A /* App */, + AD811A2E1F13F88800BF632A /* Products */, + 9F765C4CE4252F7162E20DB1 /* Pods */, + AE2B80A4702458F0BCEF595B /* Frameworks */, + ); + sourceTree = ""; + }; + AD811A2E1F13F88800BF632A /* Products */ = { + isa = PBXGroup; + children = ( + AD811A2D1F13F88800BF632A /* InAppMessaging_Example_iOS.app */, + AD81222C1F140F9700BF632A /* InAppMessaging_Tests_iOS.xctest */, + ); + name = Products; + sourceTree = ""; + }; + AD811A471F13FB3A00BF632A /* App */ = { + isa = PBXGroup; + children = ( + AD8121DF1F13FC2D00BF632A /* iOS */, + ); + name = App; + sourceTree = ""; + }; + AD8121DF1F13FC2D00BF632A /* iOS */ = { + isa = PBXGroup; + children = ( + AD8121E01F13FC3800BF632A /* objc */, + ); + name = iOS; + sourceTree = ""; + }; + AD8121E01F13FC3800BF632A /* objc */ = { + isa = PBXGroup; + children = ( + AD764A2D1FE4856300378AE0 /* AutoDisplayFlowViewController.h */, + AD764A2E1FE4856300378AE0 /* AutoDisplayFlowViewController.m */, + AD764A2C1FE4856300378AE0 /* AutoDisplayMesagesTableVC.h */, + AD764A331FE4856300378AE0 /* AutoDisplayMesagesTableVC.m */, + AD764A311FE4856300378AE0 /* LogDumpViewController.h */, + AD764A301FE4856300378AE0 /* LogDumpViewController.m */, + ADC4298E1F8D3DD500027599 /* GoogleService-Info.plist */, + AD81221B1F14064800BF632A /* LaunchScreen.storyboard */, + AD8122181F14052100BF632A /* Main.storyboard */, + AD811A331F13F88800BF632A /* AppDelegate.h */, + AD811A341F13F88800BF632A /* AppDelegate.m */, + AD811A3C1F13F88800BF632A /* Shared.xcassets */, + AD811A411F13F88800BF632A /* Info.plist */, + AD811A311F13F88800BF632A /* main.m */, + ); + name = objc; + sourceTree = ""; + }; + AD81221F1F140DE800BF632A /* Tests */ = { + isa = PBXGroup; + children = ( + AD531EFA1F3A5DB400E899A5 /* Unit Tests */, + AD531EF91F3A5D9D00E899A5 /* UI-Tests */, + ); + name = Tests; + sourceTree = ""; + }; + AD8122361F140FE500BF632A /* UI */ = { + isa = PBXGroup; + children = ( + ); + name = UI; + sourceTree = ""; + }; + AD8122371F140FEA00BF632A /* Data */ = { + isa = PBXGroup; + children = ( + AD8122391F140FFC00BF632A /* FIRIAMMessageContentDataWithImageURLTests.m */, + AD0E370F1F7DB3D500BBF23C /* FIRIAMFetchResponseParserTests.m */, + ADB47ADE20A107C6002D52E9 /* TestJsonDataFromFetch.txt */, + ADB47AE120A110F4002D52E9 /* TestJsonDataWithTestMessageFromFetch.txt */, + AD39268820AB55B700FA3FDF /* JsonDataWithInvalidMessagesFromFetch.txt */, + ); + name = Data; + sourceTree = ""; + }; + AD95D8EC1FFEFC6000780607 /* Runtime */ = { + isa = PBXGroup; + children = ( + AD95D8ED1FFEFCA700780607 /* FIRIAMActionUrlFollowerTests.m */, + ); + name = Runtime; + sourceTree = ""; + }; + ADBC52781F4CD12C00A4BEF9 /* Flows */ = { + isa = PBXGroup; + children = ( + AD6A6CAE1F5606CD00A6DFA1 /* FIRIAMBookKeeperViaUserDefaultsTests.m */, + ADBC52791F4CD18300A4BEF9 /* FIRIAMFetchFlowTests.m */, + AD3EE8291F4CED0B006829AE /* FIRIAMDisplayExecutorTests.m */, + AD0E370B1F7C63BC00BBF23C /* FIRIAMMsgFetcherUsingRestfulTests.m */, + AD3868521F95300E00E36BC5 /* FIRIAMMessageClientCacheTests.m */, + AD5D25CA1FA91C9900F1B0EB /* FIRIAMActivityLoggerTests.m */, + ADCC090F20782E2D009BFC2F /* FIRIAMSDKModeManagerTests.m */, + ); + name = Flows; + sourceTree = ""; + }; + ADE5BB5E20098880001A1395 /* Analytics */ = { + isa = PBXGroup; + children = ( + ADD981952006D1C500944751 /* FIRIAMClearcutLoggerTests.m */, + ADE5BB5F200988A1001A1395 /* FIRIAMClearcutRetryLocalStorageTests.m */, + ADA10F99202CCBFC000F4425 /* FIRIAMAnalyticsEventLoggerImplTests.m */, + ADA72B9220282F3B0087E131 /* FIRIAMClearcutUploaderTests.m */, + ); + name = Analytics; + sourceTree = ""; + }; + AE2B80A4702458F0BCEF595B /* Frameworks */ = { + isa = PBXGroup; + children = ( + ADA10F97202CC1B0000F4425 /* AdSupport.framework */, + 16E2417C62DE07F79F99BC3A /* Pods_InAppMessaging_Example_iOS.framework */, + 9E53A331D50419C13BE9189E /* Pods_InAppMessaging_Example_iOS_Swift.framework */, + B403D5D166E193EAAB4C3B25 /* Pods_InAppMessaging_Tests_iOS.framework */, + B08DD02B5CAEC2B9FF562FBC /* Pods_fiam_sample_consuming_app.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + AD811A2C1F13F88800BF632A /* InAppMessaging_Example_iOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = AD811A441F13F88800BF632A /* Build configuration list for PBXNativeTarget "InAppMessaging_Example_iOS" */; + buildPhases = ( + B3566055E1E8BBB4417DE800 /* [CP] Check Pods Manifest.lock */, + AD811A291F13F88800BF632A /* Sources */, + AD811A2A1F13F88800BF632A /* Frameworks */, + AD811A2B1F13F88800BF632A /* Resources */, + EB73238A9A185D6764A4067A /* [CP] Embed Pods Frameworks */, + E8F0203E0D9D89F08A0D0D30 /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = InAppMessaging_Example_iOS; + productName = "InAppMessaging-Example-iOS"; + productReference = AD811A2D1F13F88800BF632A /* InAppMessaging_Example_iOS.app */; + productType = "com.apple.product-type.application"; + }; + AD81222B1F140F9700BF632A /* InAppMessaging_Tests_iOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = AD8122351F140F9700BF632A /* Build configuration list for PBXNativeTarget "InAppMessaging_Tests_iOS" */; + buildPhases = ( + 51B09CAB6AC5DD3E8DDA85E3 /* [CP] Check Pods Manifest.lock */, + AD8122281F140F9700BF632A /* Sources */, + AD8122291F140F9700BF632A /* Frameworks */, + AD81222A1F140F9700BF632A /* Resources */, + 8A096C95C7D03347A14348EE /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + AD8122321F140F9700BF632A /* PBXTargetDependency */, + ); + name = InAppMessaging_Tests_iOS; + productName = InAppMessaging_Tests_iOS; + productReference = AD81222C1F140F9700BF632A /* InAppMessaging_Tests_iOS.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + AD811A251F13F88800BF632A /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0830; + LastUpgradeCheck = 0900; + ORGANIZATIONNAME = "Yong Mao"; + TargetAttributes = { + AD811A2C1F13F88800BF632A = { + CreatedOnToolsVersion = 8.3.3; + DevelopmentTeam = EQHXZ8M8AV; + LastSwiftMigration = 0910; + ProvisioningStyle = Manual; + SystemCapabilities = { + com.apple.SafariKeychain = { + enabled = 1; + }; + }; + }; + AD81222B1F140F9700BF632A = { + CreatedOnToolsVersion = 8.3.3; + DevelopmentTeam = L8VKXC2S77; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = AD811A281F13F88800BF632A /* Build configuration list for PBXProject "InAppMessaging-Example-iOS" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = English; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = AD811A241F13F88800BF632A; + productRefGroup = AD811A2E1F13F88800BF632A /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + AD811A2C1F13F88800BF632A /* InAppMessaging_Example_iOS */, + AD81222B1F140F9700BF632A /* InAppMessaging_Tests_iOS */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + AD811A2B1F13F88800BF632A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AD8122431F14148100BF632A /* LaunchScreen.storyboard in Resources */, + AD8122441F14148100BF632A /* Main.storyboard in Resources */, + ADC4298F1F8D3DD500027599 /* GoogleService-Info.plist in Resources */, + AD39268920AB55B700FA3FDF /* JsonDataWithInvalidMessagesFromFetch.txt in Resources */, + ADB47ADF20A107C6002D52E9 /* TestJsonDataFromFetch.txt in Resources */, + AD811A3D1F13F88800BF632A /* Shared.xcassets in Resources */, + ADB47AE220A110F4002D52E9 /* TestJsonDataWithTestMessageFromFetch.txt in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + AD81222A1F140F9700BF632A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AD39268A20AB56B900FA3FDF /* JsonDataWithInvalidMessagesFromFetch.txt in Resources */, + ADB47AE320A111D5002D52E9 /* TestJsonDataWithTestMessageFromFetch.txt in Resources */, + ADB47AE020A107F3002D52E9 /* TestJsonDataFromFetch.txt in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 51B09CAB6AC5DD3E8DDA85E3 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-InAppMessaging_Tests_iOS-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 8A096C95C7D03347A14348EE /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${SRCROOT}/Pods/Target Support Files/Pods-InAppMessaging_Tests_iOS/Pods-InAppMessaging_Tests_iOS-frameworks.sh", + "${BUILT_PRODUCTS_DIR}/GoogleUtilities/GoogleUtilities.framework", + "${BUILT_PRODUCTS_DIR}/nanopb/nanopb.framework", + "${BUILT_PRODUCTS_DIR}/OCMock/OCMock.framework", + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GoogleUtilities.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/nanopb.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/OCMock.framework", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-InAppMessaging_Tests_iOS/Pods-InAppMessaging_Tests_iOS-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + B3566055E1E8BBB4417DE800 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-InAppMessaging_Example_iOS-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + }; + E8F0203E0D9D89F08A0D0D30 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 12; + files = ( + ); + inputPaths = ( + "${SRCROOT}/Pods/Target Support Files/Pods-InAppMessaging_Example_iOS/Pods-InAppMessaging_Example_iOS-resources.sh", + "${PODS_CONFIGURATION_BUILD_DIR}/FirebaseInAppMessagingDisplay/InAppMessagingDisplayResources.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/InAppMessagingDisplayResources.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-InAppMessaging_Example_iOS/Pods-InAppMessaging_Example_iOS-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + EB73238A9A185D6764A4067A /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${SRCROOT}/Pods/Target Support Files/Pods-InAppMessaging_Example_iOS/Pods-InAppMessaging_Example_iOS-frameworks.sh", + "${BUILT_PRODUCTS_DIR}/GoogleUtilities/GoogleUtilities.framework", + "${BUILT_PRODUCTS_DIR}/nanopb/nanopb.framework", + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GoogleUtilities.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/nanopb.framework", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-InAppMessaging_Example_iOS/Pods-InAppMessaging_Example_iOS-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + AD811A291F13F88800BF632A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AD764A341FE4856400378AE0 /* AutoDisplayFlowViewController.m in Sources */, + AD764A371FE4856400378AE0 /* AutoDisplayMesagesTableVC.m in Sources */, + AD811A351F13F88800BF632A /* AppDelegate.m in Sources */, + AD764A361FE4856400378AE0 /* LogDumpViewController.m in Sources */, + AD811A321F13F88800BF632A /* main.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + AD8122281F140F9700BF632A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 25C6745121EEC868005A4C23 /* NSString+InterlaceStringsTests.m in Sources */, + ADC4298A1F8BEAFD00027599 /* UIColor+FIRIAMHexStringTests.m in Sources */, + AD3EE82A1F4CED0B006829AE /* FIRIAMDisplayExecutorTests.m in Sources */, + ADA72B9320282F3B0087E131 /* FIRIAMClearcutUploaderTests.m in Sources */, + ADCC091020782E2D009BFC2F /* FIRIAMSDKModeManagerTests.m in Sources */, + ADD981962006D1C500944751 /* FIRIAMClearcutLoggerTests.m in Sources */, + ADE5BB60200988A1001A1395 /* FIRIAMClearcutRetryLocalStorageTests.m in Sources */, + AD81223D1F14100700BF632A /* FIRIAMMessageContentDataWithImageURLTests.m in Sources */, + AD95D8EE1FFEFCA700780607 /* FIRIAMActionUrlFollowerTests.m in Sources */, + ADA10F9A202CCBFC000F4425 /* FIRIAMAnalyticsEventLoggerImplTests.m in Sources */, + ADBC527A1F4CD18300A4BEF9 /* FIRIAMFetchFlowTests.m in Sources */, + AD3868531F95300E00E36BC5 /* FIRIAMMessageClientCacheTests.m in Sources */, + AD0E370C1F7C63BC00BBF23C /* FIRIAMMsgFetcherUsingRestfulTests.m in Sources */, + AD6A6CB11F56093700A6DFA1 /* FIRIAMBookKeeperViaUserDefaultsTests.m in Sources */, + AD0E37101F7DB3D500BBF23C /* FIRIAMFetchResponseParserTests.m in Sources */, + AD241B0F1F4F582A009A3C22 /* FIRIAMElapsedTimeTrackerTests.m in Sources */, + AD5D25CB1FA91C9900F1B0EB /* FIRIAMActivityLoggerTests.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + AD8122321F140F9700BF632A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = AD811A2C1F13F88800BF632A /* InAppMessaging_Example_iOS */; + targetProxy = AD8122311F140F9700BF632A /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + AD8122181F14052100BF632A /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + AD8122191F14052100BF632A /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + AD81221B1F14064800BF632A /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + AD81221C1F14064800BF632A /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + AD811A421F13F88800BF632A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 10.3; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + AD811A431F13F88800BF632A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 10.3; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + AD811A451F13F88800BF632A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = BF4A969BA440546630E4C931 /* Pods-InAppMessaging_Example_iOS.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = InAppMessaging_Example_iOS.entitlements; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_TEAM = EQHXZ8M8AV; + FRAMEWORK_SEARCH_PATHS = "$(inherited)"; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "\"${PODS_ROOT}/../../../Firebase/InAppMessaging/\"/**", + ); + INFOPLIST_FILE = "$(SRCROOT)/App/InAppMessaging-Example-iOS/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 8.4; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = com.google.experimental1.dev; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE = "3aa1288b-7c4f-4d59-8975-546306cf00ae"; + PROVISIONING_PROFILE_SPECIFIER = "Experimental App 1 Dev"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 3.0; + }; + name = Debug; + }; + AD811A461F13F88800BF632A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F0C2B7614A637C08CBDB8FF1 /* Pods-InAppMessaging_Example_iOS.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = InAppMessaging_Example_iOS.entitlements; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_TEAM = EQHXZ8M8AV; + FRAMEWORK_SEARCH_PATHS = "$(inherited)"; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "\"${PODS_ROOT}/../../../Firebase/InAppMessaging/\"/**", + ); + INFOPLIST_FILE = "$(SRCROOT)/App/InAppMessaging-Example-iOS/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 8.4; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = com.google.experimental1.dev; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE = "3aa1288b-7c4f-4d59-8975-546306cf00ae"; + PROVISIONING_PROFILE_SPECIFIER = "Experimental App 1 Dev"; + SWIFT_VERSION = 3.0; + }; + name = Release; + }; + AD8122331F140F9700BF632A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 92F3AF0CAD88D57986578C52 /* Pods-InAppMessaging_Tests_iOS.debug.xcconfig */; + buildSettings = { + DEVELOPMENT_TEAM = L8VKXC2S77; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "\"${PODS_ROOT}/../../../Firebase/InAppMessaging/\"/**", + ); + INFOPLIST_FILE = Tests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = "com.google.InAppMessaging-Tests-iOS"; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + AD8122341F140F9700BF632A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 4A218F594D01B3E92842DF08 /* Pods-InAppMessaging_Tests_iOS.release.xcconfig */; + buildSettings = { + DEVELOPMENT_TEAM = L8VKXC2S77; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "\"${PODS_ROOT}/../../../Firebase/InAppMessaging/\"/**", + ); + INFOPLIST_FILE = Tests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = "com.google.InAppMessaging-Tests-iOS"; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + AD811A281F13F88800BF632A /* Build configuration list for PBXProject "InAppMessaging-Example-iOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AD811A421F13F88800BF632A /* Debug */, + AD811A431F13F88800BF632A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + AD811A441F13F88800BF632A /* Build configuration list for PBXNativeTarget "InAppMessaging_Example_iOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AD811A451F13F88800BF632A /* Debug */, + AD811A461F13F88800BF632A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + AD8122351F140F9700BF632A /* Build configuration list for PBXNativeTarget "InAppMessaging_Tests_iOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AD8122331F140F9700BF632A /* Debug */, + AD8122341F140F9700BF632A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = AD811A251F13F88800BF632A /* Project object */; +} diff --git a/InAppMessaging/Example/InAppMessaging-Example-iOS.xcodeproj/xcshareddata/xcschemes/InAppMessaging_Example_iOS.xcscheme b/InAppMessaging/Example/InAppMessaging-Example-iOS.xcodeproj/xcshareddata/xcschemes/InAppMessaging_Example_iOS.xcscheme new file mode 100644 index 0000000000..735c3f01be --- /dev/null +++ b/InAppMessaging/Example/InAppMessaging-Example-iOS.xcodeproj/xcshareddata/xcschemes/InAppMessaging_Example_iOS.xcscheme @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/InAppMessaging/Example/InAppMessaging-Example-iOS.xcodeproj/xcshareddata/xcschemes/InAppMessaging_Tests_iOS.xcscheme b/InAppMessaging/Example/InAppMessaging-Example-iOS.xcodeproj/xcshareddata/xcschemes/InAppMessaging_Tests_iOS.xcscheme new file mode 100644 index 0000000000..d80dabb17c --- /dev/null +++ b/InAppMessaging/Example/InAppMessaging-Example-iOS.xcodeproj/xcshareddata/xcschemes/InAppMessaging_Tests_iOS.xcscheme @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/InAppMessaging/Example/InAppMessaging_Example_iOS.entitlements b/InAppMessaging/Example/InAppMessaging_Example_iOS.entitlements new file mode 100644 index 0000000000..31304199ac --- /dev/null +++ b/InAppMessaging/Example/InAppMessaging_Example_iOS.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.associated-domains + + applinks:da29k.app.goo.gl + + + diff --git a/InAppMessaging/Example/InAppMessaging_Example_iOS_SwiftUITests/InAppMessaging_Example_iOS_SwiftUITests.swift b/InAppMessaging/Example/InAppMessaging_Example_iOS_SwiftUITests/InAppMessaging_Example_iOS_SwiftUITests.swift new file mode 100644 index 0000000000..71c526137e --- /dev/null +++ b/InAppMessaging/Example/InAppMessaging_Example_iOS_SwiftUITests/InAppMessaging_Example_iOS_SwiftUITests.swift @@ -0,0 +1,680 @@ +/* + * 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 XCTest + +class InAppMessaging_Example_iOS_SwiftUITests: XCTestCase { + override func setUp() { + super.setUp() + continueAfterFailure = false + let app = XCUIApplication() + setupSnapshot(app) + app.launch() + } + + override func tearDown() { + // Put teardown code here. This method is called after the invocation of each test method in the class. + XCUIDevice.shared.orientation = .portrait + super.tearDown() + } + + func waitForElementToAppear(_ element: XCUIElement, _ timeoutInSeconds: TimeInterval = 5) { + let existsPredicate = NSPredicate(format: "exists == true") + expectation(for: existsPredicate, evaluatedWith: element, handler: nil) + waitForExpectations(timeout: timeoutInSeconds, handler: nil) + } + + func waitForElementToDisappear(_ element: XCUIElement, _ timeoutInSeconds: TimeInterval = 5) { + let existsPredicate = NSPredicate(format: "exists == false") + expectation(for: existsPredicate, evaluatedWith: element, handler: nil) + waitForExpectations(timeout: timeoutInSeconds, handler: nil) + } + + func childFrameWithinParentBound(parent: XCUIElement, child: XCUIElement) -> Bool { + return parent.frame.contains(child.frame) + } + + func isUIElementWithinUIWindow(_ uiElement: XCUIElement) -> Bool { + let app = XCUIApplication() + let window = app.windows.element(boundBy: 0) + return window.frame.contains(uiElement.frame) + } + + func isElementExistentAndHavingSize(_ uiElement: XCUIElement) -> Bool { + // on iOS 9.3 for a XCUIElement whose height or width <=0, uiElement.exists still returns true + // on iOS 10.3, for such an element uiElement.exists returns false + // this function is to handle the existence (in our semanatic visible) testing for both cases + return uiElement.exists && uiElement.frame.size.height > 0 && uiElement.frame.size.width > 0 + } + + func testNormalModalView() { + let app = XCUIApplication() + app.tabBars.buttons["Modal Messages"].tap() + + let messageCardView = app.otherElements["message-card-view"] + let closeButton = app.buttons["close-button"] + let imageView = app.images["modal-image-view"] + let actionButton = app.buttons["message-action-button"] + + let orientantions = [UIDeviceOrientation.portrait, UIDeviceOrientation.landscapeLeft] + for orientation in orientantions { + XCUIDevice.shared.orientation = orientation + + app.buttons["Regular"].tap() + + waitForElementToAppear(closeButton) + + snapshot("in-app-regular-modal-view-\(orientation.rawValue)") + + XCTAssert(isElementExistentAndHavingSize(actionButton)) + XCTAssert(isElementExistentAndHavingSize(imageView)) + XCTAssert(isElementExistentAndHavingSize(messageCardView)) + XCTAssert(isElementExistentAndHavingSize(closeButton)) + + XCTAssert(isUIElementWithinUIWindow(messageCardView)) + XCTAssert(childFrameWithinParentBound(parent: messageCardView, child: actionButton)) + + app.buttons["close-button"].tap() + waitForElementToDisappear(messageCardView) + } + } + + func testModalViewWithWideImage() { + let app = XCUIApplication() + app.tabBars.buttons["Modal Messages"].tap() + + let messageCardView = app.otherElements["message-card-view"] + let closeButton = app.buttons["close-button"] + let imageView = app.images["modal-image-view"] + let actionButton = app.buttons["message-action-button"] + + let orientantions = [UIDeviceOrientation.portrait, UIDeviceOrientation.landscapeLeft] + for orientation in orientantions { + XCUIDevice.shared.orientation = orientation + + app.buttons["Thin Image"].tap() + + waitForElementToAppear(closeButton) + + snapshot("in-app-regular-modal-view-with-wider-image-\(orientation.rawValue)") + + XCTAssert(isElementExistentAndHavingSize(actionButton)) + XCTAssert(isElementExistentAndHavingSize(imageView)) + XCTAssert(isElementExistentAndHavingSize(messageCardView)) + XCTAssert(isElementExistentAndHavingSize(closeButton)) + + XCTAssert(isUIElementWithinUIWindow(messageCardView)) + XCTAssert(childFrameWithinParentBound(parent: messageCardView, child: actionButton)) + + app.buttons["close-button"].tap() + waitForElementToDisappear(messageCardView) + } + } + + func testModalViewWithNarrowImage() { + let app = XCUIApplication() + app.tabBars.buttons["Modal Messages"].tap() + + let messageCardView = app.otherElements["message-card-view"] + let closeButton = app.buttons["close-button"] + let imageView = app.images["modal-image-view"] + let actionButton = app.buttons["message-action-button"] + + let orientantions = [UIDeviceOrientation.portrait, UIDeviceOrientation.landscapeLeft] + for orientation in orientantions { + XCUIDevice.shared.orientation = orientation + + app.buttons["Wide Image"].tap() + + waitForElementToAppear(closeButton) + + snapshot("in-app-regular-modal-view-with-narrow-image-\(orientation.rawValue)") + + XCTAssert(isElementExistentAndHavingSize(actionButton)) + XCTAssert(isElementExistentAndHavingSize(imageView)) + XCTAssert(isElementExistentAndHavingSize(messageCardView)) + XCTAssert(isElementExistentAndHavingSize(closeButton)) + + XCTAssert(isUIElementWithinUIWindow(messageCardView)) + XCTAssert(childFrameWithinParentBound(parent: messageCardView, child: actionButton)) + + app.buttons["close-button"].tap() + waitForElementToDisappear(messageCardView) + } + } + + func testNormalBannerView() { + let app = XCUIApplication() + app.tabBars.buttons["Banner Messages"].tap() + + let titleElement = app.staticTexts["banner-message-title-view"] + let imageView = app.images["banner-image-view"] + let bodyElement = app.staticTexts["banner-body-label"] + let bannerUIView = app.otherElements["banner-mode-uiview"] + + let orientantions = [UIDeviceOrientation.portrait, UIDeviceOrientation.landscapeLeft] + for orientation in orientantions { + XCUIDevice.shared.orientation = orientation + + app.buttons["Show Regular Banner View"].tap() + + waitForElementToAppear(bannerUIView) + + snapshot("in-app-regular-banner-view-\(orientation.rawValue)") + + XCTAssert(isElementExistentAndHavingSize(imageView)) + XCTAssert(isElementExistentAndHavingSize(titleElement)) + XCTAssert(isElementExistentAndHavingSize(bodyElement)) + + bannerUIView.swipeUp() + + waitForElementToDisappear(bannerUIView) + } + } + + func testBannerViewAutoDimiss() { + let app = XCUIApplication() + app.tabBars.buttons["Banner Messages"].tap() + + let titleElement = app.staticTexts["banner-message-title-view"] + let imageView = app.images["banner-image-view"] + let bodyElement = app.staticTexts["banner-body-label"] + let bannerUIView = app.otherElements["banner-mode-uiview"] + + let orientantions = [UIDeviceOrientation.portrait, UIDeviceOrientation.landscapeLeft] + for orientation in orientantions { + XCUIDevice.shared.orientation = orientation + + app.buttons["Banner View With Short Auto Dismiss"].tap() + + waitForElementToAppear(bannerUIView) + + XCTAssert(isElementExistentAndHavingSize(imageView)) + XCTAssert(isElementExistentAndHavingSize(titleElement)) + XCTAssert(isElementExistentAndHavingSize(bodyElement)) + + // without user action, the banner is dismissed quickly in this test setup + waitForElementToDisappear(bannerUIView, 15) + } + } + + func testBannerViewWithoutImage() { + let app = XCUIApplication() + app.tabBars.buttons["Banner Messages"].tap() + + let titleElement = app.staticTexts["banner-message-title-view"] + let bodyElement = app.staticTexts["banner-body-label"] + let bannerUIView = app.otherElements["banner-mode-uiview"] + + let orientantions = [UIDeviceOrientation.portrait, UIDeviceOrientation.landscapeLeft] + for orientation in orientantions { + XCUIDevice.shared.orientation = orientation + app.buttons["Without Image"].tap() + waitForElementToAppear(bannerUIView) + + snapshot("in-app-banner-view-without-image-\(orientation.rawValue)") + + XCTAssert(isElementExistentAndHavingSize(titleElement)) + XCTAssert(isElementExistentAndHavingSize(bodyElement)) + + bannerUIView.swipeUp() + waitForElementToDisappear(bannerUIView) + } + } + + func testBannerViewWithLongTitle() { + let app = XCUIApplication() + app.tabBars.buttons["Banner Messages"].tap() + + let titleElement = app.staticTexts["banner-message-title-view"] + let imageView = app.images["banner-image-view"] + let bodyElement = app.staticTexts["banner-body-label"] + let bannerUIView = app.otherElements["banner-mode-uiview"] + + let orientantions = [UIDeviceOrientation.portrait, UIDeviceOrientation.landscapeLeft] + for orientation in orientantions { + XCUIDevice.shared.orientation = orientation + app.buttons["With Long Title"].tap() + waitForElementToAppear(bannerUIView) + + snapshot("in-app-banner-view-with-long-title-\(orientation.rawValue)") + + XCTAssert(isElementExistentAndHavingSize(imageView)) + XCTAssert(isElementExistentAndHavingSize(titleElement)) + XCTAssert(isElementExistentAndHavingSize(bodyElement)) + + bannerUIView.swipeUp() + waitForElementToDisappear(bannerUIView) + } + } + + func testBannerViewWithWideImage() { + let app = XCUIApplication() + app.tabBars.buttons["Banner Messages"].tap() + + let titleElement = app.staticTexts["banner-message-title-view"] + let imageView = app.images["banner-image-view"] + let bodyElement = app.staticTexts["banner-body-label"] + let bannerUIView = app.otherElements["banner-mode-uiview"] + + let orientantions = [UIDeviceOrientation.portrait, UIDeviceOrientation.landscapeLeft] + for orientation in orientantions { + XCUIDevice.shared.orientation = orientation + app.buttons["With Wide Image"].tap() + waitForElementToAppear(bannerUIView) + + snapshot("in-app-banner-view-with-wide-image-\(orientation.rawValue)") + + XCTAssert(isElementExistentAndHavingSize(imageView)) + XCTAssert(isElementExistentAndHavingSize(titleElement)) + XCTAssert(isElementExistentAndHavingSize(bodyElement)) + + bannerUIView.swipeUp() + waitForElementToDisappear(bannerUIView) + } + } + + func testBannerViewWithThinImage() { + let app = XCUIApplication() + app.tabBars.buttons["Banner Messages"].tap() + + let titleElement = app.staticTexts["banner-message-title-view"] + let imageView = app.images["banner-image-view"] + let bodyElement = app.staticTexts["banner-body-label"] + let bannerUIView = app.otherElements["banner-mode-uiview"] + + let orientantions = [UIDeviceOrientation.portrait, UIDeviceOrientation.landscapeLeft] + for orientation in orientantions { + XCUIDevice.shared.orientation = orientation + app.buttons["With Thin Image"].tap() + waitForElementToAppear(bannerUIView) + + snapshot("in-app-banner-view-with-thing-image-\(orientation.rawValue)") + + XCTAssert(isElementExistentAndHavingSize(imageView)) + XCTAssert(isElementExistentAndHavingSize(titleElement)) + XCTAssert(isElementExistentAndHavingSize(bodyElement)) + + bannerUIView.swipeUp() + waitForElementToDisappear(bannerUIView) + } + } + + func testBannerViewWithLargeBody() { + let app = XCUIApplication() + app.tabBars.buttons["Banner Messages"].tap() + + let titleElement = app.staticTexts["banner-message-title-view"] + let imageView = app.images["banner-image-view"] + let bodyElement = app.staticTexts["banner-body-label"] + let bannerUIView = app.otherElements["banner-mode-uiview"] + + let orientantions = [UIDeviceOrientation.portrait, UIDeviceOrientation.landscapeLeft] + for orientation in orientantions { + XCUIDevice.shared.orientation = orientation + app.buttons["With Large Body Text"].tap() + waitForElementToAppear(bannerUIView) + + snapshot("in-app-banner-view-with-long-body-\(orientation.rawValue)") + + XCTAssert(isElementExistentAndHavingSize(imageView)) + XCTAssert(isElementExistentAndHavingSize(titleElement)) + XCTAssert(isElementExistentAndHavingSize(bodyElement)) + + bannerUIView.swipeUp() + waitForElementToDisappear(bannerUIView) + } + } + + func testImageOnlyView() { + let app = XCUIApplication() + app.tabBars.buttons["Image Only Messages"].tap() + + let imageView = app.images["image-view-in-image-only-view"] + let closeButton = app.buttons["close-button"] + + let orientantions = [UIDeviceOrientation.portrait, UIDeviceOrientation.landscapeLeft] + for orientation in orientantions { + XCUIDevice.shared.orientation = orientation + app.buttons["Show Regular Image Only View"].tap() + + waitForElementToAppear(closeButton) + snapshot("in-app-regular-image-only-view-\(orientation.rawValue)") + + XCTAssert(isElementExistentAndHavingSize(imageView)) + XCTAssert(isUIElementWithinUIWindow(imageView)) + + app.buttons["close-button"].tap() + waitForElementToDisappear(imageView) + } + } + + func testImageOnlyViewWithLargeImageDimension() { + let app = XCUIApplication() + app.tabBars.buttons["Image Only Messages"].tap() + + let imageView = app.images["image-view-in-image-only-view"] + let closeButton = app.buttons["close-button"] + + let orientantions = [UIDeviceOrientation.portrait, UIDeviceOrientation.landscapeLeft] + for orientation in orientantions { + XCUIDevice.shared.orientation = orientation + app.buttons["High Dimension Image"].tap() + + // wait time longer due to large image + waitForElementToAppear(closeButton, 10) + + snapshot("in-app-large-image-only-view-high-dimension-\(orientation.rawValue)") + XCTAssert(isElementExistentAndHavingSize(imageView)) + XCTAssert(isUIElementWithinUIWindow(imageView)) + + app.buttons["close-button"].tap() + waitForElementToDisappear(imageView) + } + } + + func testImageOnlyViewWithLowImageDimension() { + let app = XCUIApplication() + app.tabBars.buttons["Image Only Messages"].tap() + + let imageView = app.images["image-view-in-image-only-view"] + let closeButton = app.buttons["close-button"] + + let orientantions = [UIDeviceOrientation.portrait, UIDeviceOrientation.landscapeLeft] + for orientation in orientantions { + XCUIDevice.shared.orientation = orientation + app.buttons["Low Dimension Image"].tap() + + // wait time longer due to large image + waitForElementToAppear(closeButton, 10) + + snapshot("in-app-large-image-only-view-low-dimension-\(orientation.rawValue)") + XCTAssert(isElementExistentAndHavingSize(imageView)) + XCTAssert(isUIElementWithinUIWindow(imageView)) + + app.buttons["close-button"].tap() + waitForElementToDisappear(imageView) + } + } + + func testModalViewWithoutImage() { + let app = XCUIApplication() + app.tabBars.buttons["Modal Messages"].tap() + + let messageCardView = app.otherElements["message-card-view"] + let closeButton = app.buttons["close-button"] + let actionButton = app.buttons["message-action-button"] + let imageView = app.images["modal-image-view"] + + let orientantions = [UIDeviceOrientation.portrait, UIDeviceOrientation.landscapeLeft] + for orientation in orientantions { + XCUIDevice.shared.orientation = orientation + app.buttons["Without Image"].tap() + waitForElementToAppear(closeButton) + + snapshot("in-app-no-image-modal-view-\(orientation.rawValue)") + XCTAssert(isElementExistentAndHavingSize(actionButton)) + XCTAssert(isElementExistentAndHavingSize(closeButton)) + XCTAssertFalse(isElementExistentAndHavingSize(imageView)) + + XCTAssert(isElementExistentAndHavingSize(messageCardView)) + + XCTAssert(isUIElementWithinUIWindow(messageCardView)) + XCTAssert(childFrameWithinParentBound(parent: messageCardView, child: actionButton)) + + app.buttons["close-button"].tap() + waitForElementToDisappear(messageCardView) + } + } + + func testModalViewWithoutImageOrActionButton() { + let app = XCUIApplication() + app.tabBars.buttons["Modal Messages"].tap() + + let messageCardView = app.otherElements["message-card-view"] + let closeButton = app.buttons["close-button"] + let actionButton = app.buttons["message-action-button"] + + let orientantions = [UIDeviceOrientation.portrait, UIDeviceOrientation.landscapeLeft] + for orientation in orientantions { + XCUIDevice.shared.orientation = orientation + app.buttons["Wthout Image or Action Button"].tap() + + waitForElementToAppear(closeButton) + + snapshot("in-app-no-image-no-button-modal-view-\(orientation.rawValue)") + XCTAssertFalse(isElementExistentAndHavingSize(actionButton)) + XCTAssert(isElementExistentAndHavingSize(closeButton)) + XCTAssert(isElementExistentAndHavingSize(messageCardView)) + + XCTAssert(isUIElementWithinUIWindow(messageCardView)) + + app.buttons["close-button"].tap() + waitForElementToDisappear(messageCardView) + XCUIDevice.shared.orientation = .portrait + } + } + + func testModalViewWithoutActionButton() { + let app = XCUIApplication() + app.tabBars.buttons["Modal Messages"].tap() + + let messageCardView = app.otherElements["message-card-view"] + let closeButton = app.buttons["close-button"] + let imageView = app.images["modal-image-view"] + let actionButton = app.buttons["message-action-button"] + + let orientantions = [UIDeviceOrientation.portrait, UIDeviceOrientation.landscapeLeft] + + for orientation in orientantions { + XCUIDevice.shared.orientation = orientation + app.buttons["Without Action Button"].tap() + waitForElementToAppear(closeButton) + + snapshot("in-app-no-action-button-moal-view-\(orientation.rawValue)") + XCTAssertFalse(isElementExistentAndHavingSize(actionButton)) + XCTAssert(isElementExistentAndHavingSize(closeButton)) + XCTAssert(isElementExistentAndHavingSize(messageCardView)) + XCTAssert(isElementExistentAndHavingSize(imageView)) + + XCTAssert(isUIElementWithinUIWindow(messageCardView)) + + app.buttons["close-button"].tap() + waitForElementToDisappear(messageCardView) + } + } + + func testModalViewWithLongMessageTitle() { + let app = XCUIApplication() + app.tabBars.buttons["Modal Messages"].tap() + + let messageCardView = app.otherElements["message-card-view"] + let closeButton = app.buttons["close-button"] + let imageView = app.images["modal-image-view"] + let bodyTextview = app.textViews["message-body-textview"] + + let orientantions = [UIDeviceOrientation.portrait, UIDeviceOrientation.landscapeLeft] + + for orientation in orientantions { + XCUIDevice.shared.orientation = orientation + + app.buttons["Large Title Text"].tap() + waitForElementToAppear(closeButton) + + snapshot("in-app-long-title-modal-view-\(orientation.rawValue)") + let actionButton = app.buttons["message-action-button"] + + XCTAssert(isElementExistentAndHavingSize(actionButton)) + XCTAssert(isElementExistentAndHavingSize(closeButton)) + XCTAssert(isElementExistentAndHavingSize(bodyTextview)) + XCTAssert(isElementExistentAndHavingSize(messageCardView)) + XCTAssert(isElementExistentAndHavingSize(imageView)) + + XCTAssert(isUIElementWithinUIWindow(messageCardView)) + XCTAssert(childFrameWithinParentBound(parent: messageCardView, child: actionButton)) + XCTAssert(childFrameWithinParentBound(parent: messageCardView, child: bodyTextview)) + XCTAssert(childFrameWithinParentBound(parent: messageCardView, child: imageView)) + + app.buttons["close-button"].tap() + + waitForElementToDisappear(messageCardView) + } + } + + func testModalViewWithLongMessageBody() { + let app = XCUIApplication() + app.tabBars.buttons["Modal Messages"].tap() + + let messageCardView = app.otherElements["message-card-view"] + let closeButton = app.buttons["close-button"] + let imageView = app.images["modal-image-view"] + let bodyTextview = app.textViews["message-body-textview"] + + let orientantions = [UIDeviceOrientation.portrait, UIDeviceOrientation.landscapeLeft] + + for orientation in orientantions { + XCUIDevice.shared.orientation = orientation + + app.buttons["Large Title Text"].tap() + waitForElementToAppear(closeButton) + + snapshot("in-app-long-body-modal-view-\(orientation.rawValue)") + let actionButton = app.buttons["message-action-button"] + + XCTAssert(isElementExistentAndHavingSize(actionButton)) + XCTAssert(isElementExistentAndHavingSize(closeButton)) + XCTAssert(isElementExistentAndHavingSize(bodyTextview)) + XCTAssert(isElementExistentAndHavingSize(messageCardView)) + XCTAssert(isElementExistentAndHavingSize(imageView)) + + XCTAssert(isUIElementWithinUIWindow(messageCardView)) + XCTAssert(childFrameWithinParentBound(parent: messageCardView, child: actionButton)) + XCTAssert(childFrameWithinParentBound(parent: messageCardView, child: bodyTextview)) + XCTAssert(childFrameWithinParentBound(parent: messageCardView, child: imageView)) + + app.buttons["close-button"].tap() + + waitForElementToDisappear(messageCardView) + } + } + + func testModalViewWithLongMessageTitleAndMessageBody() { + let app = XCUIApplication() + app.tabBars.buttons["Modal Messages"].tap() + + let messageCardView = app.otherElements["message-card-view"] + let closeButton = app.buttons["close-button"] + let imageView = app.images["modal-image-view"] + let bodyTextview = app.textViews["message-body-textview"] + + let orientantions = [UIDeviceOrientation.portrait, UIDeviceOrientation.landscapeLeft] + + for orientation in orientantions { + XCUIDevice.shared.orientation = orientation + + app.buttons["With Large Title and Body"].tap() + waitForElementToAppear(closeButton) + + snapshot("in-app-long-title-and-body-modal-view-\(orientation.rawValue)") + let actionButton = app.buttons["message-action-button"] + + XCTAssert(isElementExistentAndHavingSize(actionButton)) + XCTAssert(isElementExistentAndHavingSize(closeButton)) + XCTAssert(isElementExistentAndHavingSize(bodyTextview)) + XCTAssert(isElementExistentAndHavingSize(messageCardView)) + XCTAssert(isElementExistentAndHavingSize(imageView)) + + XCTAssert(isUIElementWithinUIWindow(messageCardView)) + XCTAssert(childFrameWithinParentBound(parent: messageCardView, child: actionButton)) + XCTAssert(childFrameWithinParentBound(parent: messageCardView, child: bodyTextview)) + XCTAssert(childFrameWithinParentBound(parent: messageCardView, child: imageView)) + + app.buttons["close-button"].tap() + + waitForElementToDisappear(messageCardView) + } + } + + func testModalViewWithLongMessageTitleAndMessageBodyWithoutImage() { + let app = XCUIApplication() + app.tabBars.buttons["Modal Messages"].tap() + + let messageCardView = app.otherElements["message-card-view"] + let closeButton = app.buttons["close-button"] + let imageView = app.images["modal-image-view"] + let bodyTextview = app.textViews["message-body-textview"] + + let orientantions = [UIDeviceOrientation.portrait, UIDeviceOrientation.landscapeLeft] + + for orientation in orientantions { + XCUIDevice.shared.orientation = orientation + + app.buttons["With Large Title and Body Without Image"].tap() + waitForElementToAppear(closeButton) + + snapshot("in-app-long-title-and-body-no-image-modal-view-\(orientation.rawValue)") + let actionButton = app.buttons["message-action-button"] + + XCTAssert(isElementExistentAndHavingSize(actionButton)) + XCTAssert(isElementExistentAndHavingSize(closeButton)) + XCTAssert(isElementExistentAndHavingSize(bodyTextview)) + XCTAssert(isElementExistentAndHavingSize(messageCardView)) + XCTAssert(!isElementExistentAndHavingSize(imageView)) + + XCTAssert(isUIElementWithinUIWindow(messageCardView)) + XCTAssert(childFrameWithinParentBound(parent: messageCardView, child: actionButton)) + XCTAssert(childFrameWithinParentBound(parent: messageCardView, child: bodyTextview)) + + app.buttons["close-button"].tap() + + waitForElementToDisappear(messageCardView) + } + } + + func testModalViewWithLongMessageTitleWithoutBodyWithoutImageWithoutButton() { + let app = XCUIApplication() + app.tabBars.buttons["Modal Messages"].tap() + + let messageCardView = app.otherElements["message-card-view"] + let closeButton = app.buttons["close-button"] + let imageView = app.images["modal-image-view"] + let bodyTextview = app.textViews["message-body-textview"] + + let orientantions = [UIDeviceOrientation.portrait, UIDeviceOrientation.landscapeLeft] + + for orientation in orientantions { + XCUIDevice.shared.orientation = orientation + + app.buttons["With Large Title, No Image, No Body and No Button"].tap() + waitForElementToAppear(closeButton) + + snapshot("in-app-long-title-no-image-body-button-modal-view-\(orientation.rawValue)") + let actionButton = app.buttons["message-action-button"] + + XCTAssert(!isElementExistentAndHavingSize(actionButton)) + XCTAssert(isElementExistentAndHavingSize(closeButton)) + XCTAssert(!isElementExistentAndHavingSize(bodyTextview)) + XCTAssert(isElementExistentAndHavingSize(messageCardView)) + XCTAssert(!isElementExistentAndHavingSize(imageView)) + + XCTAssert(isUIElementWithinUIWindow(messageCardView)) + + app.buttons["close-button"].tap() + + waitForElementToDisappear(messageCardView) + } + } +} diff --git a/InAppMessaging/Example/InAppMessaging_Example_iOS_SwiftUITests/Info.plist b/InAppMessaging/Example/InAppMessaging_Example_iOS_SwiftUITests/Info.plist new file mode 100644 index 0000000000..6c6c23c43a --- /dev/null +++ b/InAppMessaging/Example/InAppMessaging_Example_iOS_SwiftUITests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/InAppMessaging/Example/Podfile b/InAppMessaging/Example/Podfile new file mode 100644 index 0000000000..13f55e3169 --- /dev/null +++ b/InAppMessaging/Example/Podfile @@ -0,0 +1,27 @@ + +use_frameworks! + +# Uncomment the next two lines for pre-release testing on public repo +source 'https://github.com/Firebase/SpecsStaging.git' +source 'https://github.com/CocoaPods/Specs.git' + +pod 'FirebaseCore', :path => '../..' + +target 'InAppMessaging_Example_iOS' do + platform :ios, '8.0' + + pod 'FirebaseInAppMessagingDisplay', :path => '../..' + pod 'FirebaseInAppMessaging', :path => '../..' + pod 'FirebaseAnalyticsInterop', :path => '../..' + pod 'FirebaseAnalytics' + pod 'FirebaseDynamicLinks', :path => '../..' +end + +target 'InAppMessaging_Tests_iOS' do + platform :ios, '8.0' + + pod 'FirebaseInAppMessaging', :path => '../..' + pod 'FirebaseInstanceID' + pod 'FirebaseAnalyticsInterop', :path => '../..' + pod 'OCMock' +end diff --git a/InAppMessaging/Example/Scanfile b/InAppMessaging/Example/Scanfile new file mode 100644 index 0000000000..43b96a958a --- /dev/null +++ b/InAppMessaging/Example/Scanfile @@ -0,0 +1,13 @@ +# For more information about this configuration visit +# https://github.com/fastlane/fastlane/tree/master/scan#scanfile + +# In general, you can use the options available +# fastlane scan --help + +# Remove the # in front of the line to enable the option + +workspace "InAppMessaging-Example-iOS.xcworkspace" +scheme "InAppMessaging_Example_iOS_Swift" +devices ["iPad Pro", "iPhone 6s", "iPhone 4s", "iPhone 7Plus"] +open_report true +clean true diff --git a/InAppMessaging/Example/Snapfile b/InAppMessaging/Example/Snapfile new file mode 100644 index 0000000000..9c3aed4cc4 --- /dev/null +++ b/InAppMessaging/Example/Snapfile @@ -0,0 +1,37 @@ +# Uncomment the lines below you want to change by removing the # in the beginning + +# A list of devices you want to take the screenshots from + devices([ +# "iPhone 4s", + "iPhone 6 Plus", + "iPhone 5s", + "iPhone 7", + "iPad Pro (12.9 inch)", +# "iPad Pro (9.7 inch)", +# "Apple TV 1080p" + ]) + + +languages([ + "en-US", +]) + +# The name of the scheme which contains the UI Tests +scheme "InAppMessaging_Example_iOS_Swift" + +# Where should the resulting screenshots be stored? +output_directory "./screenshots" + +clear_previous_screenshots true # remove the '#' to clear all previously generated screenshots before creating new ones + +# Choose which project/workspace to use +# project "./Project.xcodeproj" +# workspace "./Project.xcworkspace" + +workspace "InAppMessaging-Example-iOS.xcworkspace" + +# Arguments to pass to the app on launch. See https://github.com/fastlane/fastlane/tree/master/snapshot#launch-arguments +# launch_arguments(["-favColor red"]) + +# For more information about all available options run +# fastlane snapshot --help diff --git a/InAppMessaging/Example/TestJsonDataFromFetch.txt b/InAppMessaging/Example/TestJsonDataFromFetch.txt new file mode 100644 index 0000000000..ce305646e0 --- /dev/null +++ b/InAppMessaging/Example/TestJsonDataFromFetch.txt @@ -0,0 +1,169 @@ +{ + "messages": [ + { + "vanillaPayload": { + "campaignId": "13313766398414028800", + "campaignStartTimeMillis": "1523986039000", + "campaignEndTimeMillis": "1526986039000", + "campaignName": "first campaign" + }, + "content": { + "modal": { + "title": { + "text": "I heard you like In-App Messages", + "hexColor": "#000000" + }, + "body": { + "text": "This is message body", + "hexColor": "#000000" + }, + "imageUrl": "https://image.com/5GCaq8sWMgk", + "actionButton": { + "text": { + "text": "Learn More", + "hexColor": "#ffffff" + }, + "buttonHexColor": "#000000" + }, + "action": { + "actionUrl": "https://www.google.com" + }, + "backgroundHexColor": "#fffff8" + } + }, + "priority": { + "value": 1 + }, + "triggeringConditions": [ + { + "fiamTrigger": "ON_FOREGROUND" + }, + { + "event": { + "name": "jackpot" + } + } + ] + }, + { + "vanillaPayload": { + "campaignId": "9350598726327992320", + "campaignStartTimeMillis": "1523985333000", + "campaignEndTimeMillis": "9223372036854775807", + "campaignName": "Inception1" + }, + "content": { + "modal": { + "title": { + "text": "Test 2", + "hexColor": "#000000" + }, + "body": { + "hexColor": "#000000" + }, + "imageUrl": "https://image.com/5GCaq8sWMgk.jpg", + "actionButton": { + "text": { + "text": "Learn More", + "hexColor": "#ffffff" + }, + "buttonHexColor": "#000000" + }, + "action": { + "actionUrl": "https://www.google.com" + }, + "backgroundHexColor": "#ffffff" + } + }, + "priority": { + "value": 1 + }, + "triggeringConditions": [ + { + "fiamTrigger": "ON_FOREGROUND" + }, + { + "event": { + "name": "jackpot" + } + } + ] + }, + { + "vanillaPayload": { + "campaignId": "14819094573862617088", + "campaignStartTimeMillis": "1519934825000", + "campaignEndTimeMillis": "9223372036854775807", + "campaignName": "Top banner" + }, + "content": { + "banner": { + "title": { + "text": "Hey everybody!", + "hexColor": "#000000" + }, + "body": { + "text": "This is an in-app message! Now go to Screen 2!", + "hexColor": "#000000" + }, + "imageUrl": "https://image.com/5YYCaq8sWMgk.png", + "action": { + "actionUrl": "https://test-app.firebaseapp.com/Calculator/screen2" + }, + "backgroundHexColor": "#ffffff" + } + }, + "priority": { + "value": 1 + }, + "triggeringConditions": [ + { + "event": { + "name": "jackpot" + } + } + ] + }, + { + "vanillaPayload": { + "campaignId": "5595722537007841280", + "campaignStartTimeMillis": "1519934650000", + "campaignEndTimeMillis": "9223372036854775807", + "campaignName": "Ducks on foreground" + }, + "content": { + "modal": { + "title": { + "text": "Look, it's a duck!", + "hexColor": "#000000" + }, + "body": { + "text": "It's a very nice duck.", + "hexColor": "#000000" + }, + "imageUrl": "https://image.com/5YYCaq8sWMgkff.png", + "actionButton": { + "text": { + "text": "Go to Google.com", + "hexColor": "#ffffff" + }, + "buttonHexColor": "#000000" + }, + "action": { + "actionUrl": "https://www.google.com" + }, + "backgroundHexColor": "#ffffff" + } + }, + "priority": { + "value": 1 + }, + "triggeringConditions": [ + { + "fiamTrigger": "ON_FOREGROUND" + } + ] + } + ], + "expirationEpochTimestampMillis": "1537896430193" +} diff --git a/InAppMessaging/Example/TestJsonDataWithTestMessageFromFetch.txt b/InAppMessaging/Example/TestJsonDataWithTestMessageFromFetch.txt new file mode 100644 index 0000000000..637eb5cf68 --- /dev/null +++ b/InAppMessaging/Example/TestJsonDataWithTestMessageFromFetch.txt @@ -0,0 +1,71 @@ +{ + "messages": [ + { + "vanillaPayload": { + "campaignId": "2108810525516234752" + }, + "content": { + "modal": { + "title": { + "text": "FAST", + "hexColor": "#000000" + }, + "body": { + "hexColor": "#000000" + }, + "backgroundHexColor": "#ffffff" + } + }, + "triggeringConditions": [ + { + "fiamTrigger": "ON_FOREGROUND" + } + ], + "isTestCampaign": true + }, + { + "vanillaPayload": { + "campaignId": "13313766398414028800", + "campaignStartTimeMillis": "1523986039000", + "campaignEndTimeMillis": "9223372036854775807", + "campaignName": "copy of Inception1" + }, + "content": { + "modal": { + "title": { + "text": "I heard you like In-App Messages", + "hexColor": "#000000" + }, + "body": { + "hexColor": "#000000" + }, + "imageUrl": "https://google.com/an_image", + "actionButton": { + "text": { + "text": "Learn More", + "hexColor": "#ffffff" + }, + "buttonHexColor": "#000000" + }, + "action": { + "actionUrl": "https://www.google.com" + }, + "backgroundHexColor": "#ffffff" + } + }, + "priority": { + "value": 1 + }, + "triggeringConditions": [ + { + "fiamTrigger": "ON_FOREGROUND" + }, + { + "event": { + "name": "jackpot" + } + } + ] + } + ] +} diff --git a/InAppMessaging/Example/Tests/FIRIAMActionUrlFollowerTests.m b/InAppMessaging/Example/Tests/FIRIAMActionUrlFollowerTests.m new file mode 100644 index 0000000000..0f85028c68 --- /dev/null +++ b/InAppMessaging/Example/Tests/FIRIAMActionUrlFollowerTests.m @@ -0,0 +1,270 @@ +/* + * Copyright 2018 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 +#import + +#import "FIRIAMActionURLFollower.h" + +// since OCMock does support mocking respondsToSelector on mock object, we have to define +// different delegate classes with different coverages of certain delegate methods: +// FIRIAMActionURLFollower behavior depend on these method implementation coverages on the +// delegate + +// this delegate only implements application:continueUserActivity:restorationHandler +@interface Delegate1 : NSObject +- (BOOL)application:(UIApplication *)application + continueUserActivity:(NSUserActivity *)userActivity + restorationHandler:(void (^)(NSArray *))restorationHandler; +@end +@implementation Delegate1 +- (BOOL)application:(UIApplication *)application + continueUserActivity:(NSUserActivity *)userActivity + restorationHandler:(void (^)(NSArray *))restorationHandler { + return YES; +} +@end + +// this delegate only implements application:openURL:options which is suitable for custom url scheme +// link handling +@interface Delegate2 : NSObject +- (BOOL)application:(UIApplication *)app + openURL:(NSURL *)url + options:(NSDictionary *)options; +@end +@implementation Delegate2 +- (BOOL)application:(UIApplication *)app + openURL:(NSURL *)url + options:(NSDictionary *)options { + return YES; +} +@end + +@interface FIRIAMActionURLFollowerTests : XCTestCase +@property FIRIAMActionURLFollower *actionFollower; +@property UIApplication *mockApplication; +@property id mockAppDelegate; +@end + +@implementation FIRIAMActionURLFollowerTests + +- (void)setUp { + [super setUp]; + self.mockApplication = OCMClassMock([UIApplication class]); +} + +- (void)tearDown { + // Put teardown code here. This method is called after the invocation of each test method in the + // class. + [super tearDown]; +} + +- (void)testUniversalLinkHandlingReturnYES { + self.mockAppDelegate = OCMClassMock([Delegate1 class]); + OCMStub([self.mockApplication delegate]).andReturn(self.mockAppDelegate); + + // In this test case, app delegate's application:continueUserActivity:restorationHandler + // handles the url and returns YES + + NSURL *url = [NSURL URLWithString:@"http://test.com"]; + OCMExpect([self.mockAppDelegate application:[OCMArg isKindOfClass:[UIApplication class]] + continueUserActivity:[OCMArg checkWithBlock:^BOOL(id userActivity) { + // verifying the type and url field for the userActivity object + NSUserActivity *activity = (NSUserActivity *)userActivity; + return [activity.activityType + isEqualToString:NSUserActivityTypeBrowsingWeb] && + [activity.webpageURL isEqual:url]; + }] + restorationHandler:[OCMArg any]]) + .andReturn(YES); + + FIRIAMActionURLFollower *follower = + [[FIRIAMActionURLFollower alloc] initWithCustomURLSchemeArray:@[] + withApplication:self.mockApplication]; + + XCTestExpectation *expectation = + [self expectationWithDescription:@"Completion Callback Triggered"]; + [follower followActionURL:url + withCompletionBlock:^(BOOL success) { + XCTAssertTrue(success); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; + OCMVerifyAll((id)self.mockAppDelegate); +} + +- (void)setupOpenURLViaIOSForUIApplicationWithReturnValue:(BOOL)returnValue { + // it would fallback to either openURL:options:completionHandler: + // or openURL: on the UIApplication object to follow the url + if ([self.mockApplication respondsToSelector:@selector(openURL:options:completionHandler:)]) { + // id types is needed for calling invokeBlockWithArgs + id yesOrNo = returnValue ? @YES : @NO; + OCMStub([self.mockApplication openURL:[OCMArg any] + options:[OCMArg any] + completionHandler:([OCMArg invokeBlockWithArgs:yesOrNo, nil])]); + } else { + OCMStub([self.mockApplication openURL:[OCMArg any]]).andReturn(returnValue); + } +} + +- (void)testUniversalLinkHandlingReturnNo { + self.mockAppDelegate = OCMClassMock([Delegate1 class]); + OCMStub([self.mockApplication delegate]).andReturn(self.mockAppDelegate); + + // In this test case, app delegate's application:continueUserActivity:restorationHandler + // tries to handle the url but returns NO. We should fallback to the do iOS OpenURL for + // this case + NSURL *url = [NSURL URLWithString:@"http://test.com"]; + OCMExpect([self.mockAppDelegate application:[OCMArg isKindOfClass:[UIApplication class]] + continueUserActivity:[OCMArg any] + restorationHandler:[OCMArg any]]) + .andReturn(NO); + + [self setupOpenURLViaIOSForUIApplicationWithReturnValue:YES]; + + FIRIAMActionURLFollower *follower = + [[FIRIAMActionURLFollower alloc] initWithCustomURLSchemeArray:@[] + withApplication:self.mockApplication]; + + XCTestExpectation *expectation = + [self expectationWithDescription:@"Completion Callback Triggered"]; + [follower followActionURL:url + withCompletionBlock:^(BOOL success) { + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; + OCMVerifyAll((id)self.mockAppDelegate); +} + +- (void)testCustomSchemeHandlingReturnYES { + self.mockAppDelegate = OCMClassMock([Delegate2 class]); + OCMStub([self.mockApplication delegate]).andReturn(self.mockAppDelegate); + + // we support custom url scheme 'scheme1' and 'scheme2' in this setup + FIRIAMActionURLFollower *follower = + [[FIRIAMActionURLFollower alloc] initWithCustomURLSchemeArray:@[ @"scheme1", @"scheme2" ] + withApplication:self.mockApplication]; + + NSURL *customURL = [NSURL URLWithString:@"scheme1://test.com"]; + OCMExpect([self.mockAppDelegate application:[OCMArg isKindOfClass:[UIApplication class]] + openURL:[OCMArg checkWithBlock:^BOOL(id urlId) { + // verifying url received by the app delegate is expected + NSURL *url = (NSURL *)urlId; + return [url isEqual:customURL]; + }] + options:[OCMArg any]]) + .andReturn(YES); + + XCTestExpectation *expectation = + [self expectationWithDescription:@"Completion Callback Triggered"]; + [follower followActionURL:customURL + withCompletionBlock:^(BOOL success) { + XCTAssertTrue(success); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; + OCMVerifyAll((id)self.mockAppDelegate); +} + +- (void)testCustomSchemeHandlingReturnNO { + self.mockAppDelegate = OCMClassMock([Delegate2 class]); + OCMStub([self.mockApplication delegate]).andReturn(self.mockAppDelegate); + + // we support custom url scheme 'scheme1' and 'scheme2' in this setup + FIRIAMActionURLFollower *follower = + [[FIRIAMActionURLFollower alloc] initWithCustomURLSchemeArray:@[ @"scheme1", @"scheme2" ] + withApplication:self.mockApplication]; + + NSURL *customURL = [NSURL URLWithString:@"scheme1://test.com"]; + OCMExpect([self.mockAppDelegate application:[OCMArg isKindOfClass:[UIApplication class]] + openURL:[OCMArg checkWithBlock:^BOOL(id urlId) { + // verifying url received by the app delegate is expected + NSURL *url = (NSURL *)urlId; + return [url isEqual:customURL]; + }] + options:[OCMArg any]]) + .andReturn(NO); + + // it would fallback to Open URL with iOS System + [self setupOpenURLViaIOSForUIApplicationWithReturnValue:NO]; + XCTestExpectation *expectation = + [self expectationWithDescription:@"Completion Callback Triggered"]; + [follower followActionURL:customURL + withCompletionBlock:^(BOOL success) { + // since both custom scheme url open and fallback iOS url open returns NO, we expect + // to get a NO here + XCTAssertFalse(success); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; + OCMVerifyAll((id)self.mockAppDelegate); +} + +- (void)testCustomSchemeNotMatching { + self.mockAppDelegate = OCMClassMock([Delegate2 class]); + OCMStub([self.mockApplication delegate]).andReturn(self.mockAppDelegate); + + // we support custom url scheme 'scheme1' and 'scheme2' in this setup + FIRIAMActionURLFollower *follower = + [[FIRIAMActionURLFollower alloc] initWithCustomURLSchemeArray:@[ @"scheme1", @"scheme2" ] + withApplication:self.mockApplication]; + + NSURL *customURL = [NSURL URLWithString:@"unknown-scheme://test.com"]; + + // since custom scheme does not match, we should not expect app delegate's open URL method + // being triggered + OCMReject([self.mockAppDelegate application:[OCMArg any] + openURL:[OCMArg any] + options:[OCMArg any]]); + + XCTestExpectation *expectation = + [self expectationWithDescription:@"Completion Callback Triggered"]; + [self setupOpenURLViaIOSForUIApplicationWithReturnValue:YES]; + + [follower followActionURL:customURL + withCompletionBlock:^(BOOL success) { + XCTAssertTrue(success); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; + OCMVerifyAll((id)self.mockAppDelegate); +} + +- (void)testUniversalLinkWithoutContinueUserActivityDefined { + // Delegate2 does not define application:continueUserActivity:restorationHandler + self.mockAppDelegate = OCMClassMock([Delegate2 class]); + OCMStub([self.mockApplication delegate]).andReturn(self.mockAppDelegate); + + FIRIAMActionURLFollower *follower = + [[FIRIAMActionURLFollower alloc] initWithCustomURLSchemeArray:@[] + withApplication:self.mockApplication]; + + // so for this url, even if it's a http or https link, we should fall back to openURL with + // iOS system + NSURL *url = [NSURL URLWithString:@"http://test.com"]; + + XCTestExpectation *expectation = + [self expectationWithDescription:@"Completion Callback Triggered"]; + [self setupOpenURLViaIOSForUIApplicationWithReturnValue:YES]; + + [follower followActionURL:url + withCompletionBlock:^(BOOL success) { + XCTAssertTrue(success); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; +} +@end diff --git a/InAppMessaging/Example/Tests/FIRIAMActivityLoggerTests.m b/InAppMessaging/Example/Tests/FIRIAMActivityLoggerTests.m new file mode 100644 index 0000000000..a82da43966 --- /dev/null +++ b/InAppMessaging/Example/Tests/FIRIAMActivityLoggerTests.m @@ -0,0 +1,185 @@ +/* + * 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 + +#import "FIRIAMActivityLogger.h" +@interface FIRIAMActivityLogger () +- (void)loadFromCachePath:(NSString *)cacheFilePath; +- (BOOL)saveIntoCacheWithPath:(NSString *)cacheFilePath; +@end + +@interface FIRIAMActivityLoggerTests : XCTestCase + +@end + +@implementation FIRIAMActivityLoggerTests + +- (void)setUp { + [super setUp]; + // Put setup code here. This method is called before the invocation of each test method in the + // class. +} + +- (void)tearDown { + // Put teardown code here. This method is called after the invocation of each test method in the + // class. + [super tearDown]; +} + +- (void)testNormalFlow { + FIRIAMActivityLogger *logger = [[FIRIAMActivityLogger alloc] initWithMaxCountBeforeReduce:100 + withSizeAfterReduce:80 + verboseMode:YES + loadFromCache:NO]; + + FIRIAMActivityRecord *first = + [[FIRIAMActivityRecord alloc] initWithActivityType:FIRIAMActivityTypeRenderMessage + isSuccessful:NO + withDetail:@"log detail" + timestamp:nil]; + [logger addLogRecord:first]; + NSDate *now = [[NSDate alloc] init]; + FIRIAMActivityRecord *second = + [[FIRIAMActivityRecord alloc] initWithActivityType:FIRIAMActivityTypeCheckForFetch + isSuccessful:YES + withDetail:@"log detail2" + timestamp:now]; + [logger addLogRecord:second]; + + // now read them back + NSArray *records = [logger readRecords]; + XCTAssertEqual(2, [records count]); + + // notice that log records read out would be [second, first] in LIFO order + FIRIAMActivityRecord *firstFetched = records[0]; + XCTAssertEqualObjects(@"log detail2", firstFetched.detail); + XCTAssertEqual(YES, firstFetched.success); + XCTAssertEqual(FIRIAMActivityTypeCheckForFetch, firstFetched.activityType); + // second's timestamp should be equal to now since it's used to construct that log record + XCTAssertEqualWithAccuracy(now.timeIntervalSince1970, + firstFetched.timestamp.timeIntervalSince1970, 0.001); + + FIRIAMActivityRecord *secondFetched = records[1]; + XCTAssertEqualObjects(@"log detail", secondFetched.detail); + XCTAssertEqual(NO, secondFetched.success); + XCTAssertEqual(FIRIAMActivityTypeRenderMessage, secondFetched.activityType); + // 60 seconds is large enough buffer for the timestamp comparison + XCTAssertEqualWithAccuracy([[NSDate alloc] init].timeIntervalSince1970, + secondFetched.timestamp.timeIntervalSince1970, 60); +} + +- (void)testReduceAfterReachingMaxCount { + // expected behavior for logger regarding reducing is to come down to 1 after reaching size of 3 + FIRIAMActivityLogger *logger = [[FIRIAMActivityLogger alloc] initWithMaxCountBeforeReduce:3 + withSizeAfterReduce:1 + verboseMode:YES + loadFromCache:NO]; + + FIRIAMActivityRecord *first = + [[FIRIAMActivityRecord alloc] initWithActivityType:FIRIAMActivityTypeRenderMessage + isSuccessful:NO + withDetail:@"log detail" + timestamp:nil]; + [logger addLogRecord:first]; + FIRIAMActivityRecord *second = + [[FIRIAMActivityRecord alloc] initWithActivityType:FIRIAMActivityTypeCheckForFetch + isSuccessful:YES + withDetail:@"log detail2" + timestamp:nil]; + [logger addLogRecord:second]; + + FIRIAMActivityRecord *third = + [[FIRIAMActivityRecord alloc] initWithActivityType:FIRIAMActivityTypeCheckForFetch + isSuccessful:YES + withDetail:@"log detail3" + timestamp:nil]; + [logger addLogRecord:third]; + NSArray *records = [logger readRecords]; + XCTAssertEqual(1, [records count]); + + // and the remaining one would be the last one being inserted + XCTAssertEqualObjects(@"log detail3", records[0].detail); +} + +- (void)testNonVerboseMode { + // certain types of messages would get dropped + FIRIAMActivityLogger *logger = [[FIRIAMActivityLogger alloc] initWithMaxCountBeforeReduce:100 + withSizeAfterReduce:50 + verboseMode:NO + loadFromCache:NO]; + + // this one would be added + FIRIAMActivityRecord *next = + [[FIRIAMActivityRecord alloc] initWithActivityType:FIRIAMActivityTypeRenderMessage + isSuccessful:NO + withDetail:@"log detail" + timestamp:nil]; + [logger addLogRecord:next]; + + // this one would be dropped + next = [[FIRIAMActivityRecord alloc] initWithActivityType:FIRIAMActivityTypeCheckForOnOpenMessage + isSuccessful:NO + withDetail:@"log detail" + timestamp:nil]; + [logger addLogRecord:next]; + + // this one would be added + next = [[FIRIAMActivityRecord alloc] initWithActivityType:FIRIAMActivityTypeFetchMessage + isSuccessful:NO + withDetail:@"log detail" + timestamp:nil]; + [logger addLogRecord:next]; + NSArray *records = [logger readRecords]; + XCTAssertEqual(2, [records count]); +} + +- (void)testReadingAndWritingCache { + FIRIAMActivityLogger *logger = [[FIRIAMActivityLogger alloc] initWithMaxCountBeforeReduce:100 + withSizeAfterReduce:50 + verboseMode:YES + loadFromCache:NO]; + + FIRIAMActivityRecord *next = + [[FIRIAMActivityRecord alloc] initWithActivityType:FIRIAMActivityTypeRenderMessage + isSuccessful:NO + withDetail:@"log detail" + timestamp:nil]; + [logger addLogRecord:next]; + next = [[FIRIAMActivityRecord alloc] initWithActivityType:FIRIAMActivityTypeCheckForOnOpenMessage + isSuccessful:NO + withDetail:@"log detail2" + timestamp:nil]; + [logger addLogRecord:next]; + next = [[FIRIAMActivityRecord alloc] initWithActivityType:FIRIAMActivityTypeCheckForOnOpenMessage + isSuccessful:NO + withDetail:@"log detail3" + timestamp:nil]; + [logger addLogRecord:next]; + + NSString *cacheFilePath = [NSString stringWithFormat:@"%@/temp-cache", NSTemporaryDirectory()]; + [logger saveIntoCacheWithPath:cacheFilePath]; + + // read it back + FIRIAMActivityLogger *logger2 = [[FIRIAMActivityLogger alloc] initWithMaxCountBeforeReduce:100 + withSizeAfterReduce:50 + verboseMode:YES + loadFromCache:NO]; + [logger2 loadFromCachePath:cacheFilePath]; + + XCTAssertEqual(3, [[logger2 readRecords] count]); +} +@end diff --git a/InAppMessaging/Example/Tests/FIRIAMAnalyticsEventLoggerImplTests.m b/InAppMessaging/Example/Tests/FIRIAMAnalyticsEventLoggerImplTests.m new file mode 100644 index 0000000000..5816c65248 --- /dev/null +++ b/InAppMessaging/Example/Tests/FIRIAMAnalyticsEventLoggerImplTests.m @@ -0,0 +1,224 @@ +/* + * Copyright 2018 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 +#import + +#import "FIRIAMAnalyticsEventLoggerImpl.h" +#import "FIRIAMClearcutLogger.h" + +#import +#import +#import + +@interface FIRIAMAnalyticsEventLoggerImplTests : XCTestCase +@property(nonatomic) FIRIAMClearcutLogger *mockClearcutLogger; +@property(nonatomic) id mockTimeFetcher; +@property(nonatomic) id mockFirebaseAnalytics; +@property(nonatomic) NSUserDefaults *mockUserDefaults; + +@end + +static NSString *campaignID = @"campaign id"; +static NSString *campaignName = @"campaign name"; + +typedef void (^FIRAUserPropertiesCallback)(NSDictionary *userProperties); + +typedef void (^FakeAnalyticsLogEventHandler)(NSString *origin, + NSString *name, + NSDictionary *parameters); +typedef void (^FakeAnalyticsUserPropertyHandler)(NSString *origin, NSString *name, id value); +typedef void (^LastNotificationCallback)(NSString *); +typedef void (^FakeAnalyticsLastNotificationHandler)(NSString *origin, LastNotificationCallback); + +@interface FakeAnalytics : NSObject + +@property FakeAnalyticsLogEventHandler eventHandler; +@property FakeAnalyticsLogEventHandler userPropertyHandler; +@property FakeAnalyticsLastNotificationHandler lastNotificationHandler; + +- (instancetype)initWithEventHandler:(FakeAnalyticsLogEventHandler)eventHandler; +- (instancetype)initWithUserPropertyHandler:(FakeAnalyticsUserPropertyHandler)userPropertyHandler; +@end + +@implementation FakeAnalytics + +- (instancetype)initWithEventHandler:(FakeAnalyticsLogEventHandler)eventHandler { + self = [super init]; + if (self) { + _eventHandler = eventHandler; + } + return self; +} + +- (instancetype)initWithUserPropertyHandler:(FakeAnalyticsUserPropertyHandler)userPropertyHandler { + self = [super init]; + if (self) { + _userPropertyHandler = userPropertyHandler; + } + return self; +} + +- (void)logEventWithOrigin:(nonnull NSString *)origin + name:(nonnull NSString *)name + parameters:(nullable NSDictionary *)parameters { + if (_eventHandler) { + _eventHandler(origin, name, parameters); + } +} + +- (void)setUserPropertyWithOrigin:(nonnull NSString *)origin + name:(nonnull NSString *)name + value:(nonnull id)value { + if (_userPropertyHandler) { + _userPropertyHandler(origin, name, value); + } +} + +- (void)checkLastNotificationForOrigin:(nonnull NSString *)origin + queue:(nonnull dispatch_queue_t)queue + callback:(nonnull void (^)(NSString *_Nullable)) + currentLastNotificationProperty { + if (_lastNotificationHandler) { + _lastNotificationHandler(origin, currentLastNotificationProperty); + } +} + +// Stubs +- (void)clearConditionalUserProperty:(nonnull NSString *)userPropertyName + clearEventName:(nonnull NSString *)clearEventName + clearEventParameters:(nonnull NSDictionary *)clearEventParameters { +} + +- (nonnull NSArray *) + conditionalUserProperties:(nonnull NSString *)origin + propertyNamePrefix:(nonnull NSString *)propertyNamePrefix { + return @[]; +} + +- (NSInteger)maxUserProperties:(nonnull NSString *)origin { + return -1; +} + +- (void)setConditionalUserProperty:(nonnull FIRAConditionalUserProperty *)conditionalUserProperty { +} + +- (void)registerAnalyticsListener:(nonnull id)listener + withOrigin:(nonnull NSString *)origin { +} + +- (void)unregisterAnalyticsListenerWithOrigin:(nonnull NSString *)origin { +} +@end + +@implementation FIRIAMAnalyticsEventLoggerImplTests + +- (void)setUp { + [super setUp]; + self.mockClearcutLogger = OCMClassMock(FIRIAMClearcutLogger.class); + self.mockTimeFetcher = OCMProtocolMock(@protocol(FIRIAMTimeFetcher)); + self.mockUserDefaults = OCMClassMock(NSUserDefaults.class); +} + +- (void)tearDown { + [super tearDown]; +} + +- (void)testLogImpressionEvent { + XCTestExpectation *expectation1 = [self expectationWithDescription:@"Log to Analytics"]; + FakeAnalytics *analytics = [[FakeAnalytics alloc] + initWithEventHandler:^(NSString *origin, NSString *name, NSDictionary *parameters) { + XCTAssertEqualObjects(origin, @"fiam"); + XCTAssertEqualObjects(name, @"firebase_in_app_message_impression"); + XCTAssertEqual([parameters count], 3); + XCTAssertNotNil(parameters); + XCTAssertEqual(parameters[@"_nmid"], campaignID); + XCTAssertEqual(parameters[@"_nmn"], campaignName); + [expectation1 fulfill]; + }]; + FIRIAMAnalyticsEventLoggerImpl *logger = + [[FIRIAMAnalyticsEventLoggerImpl alloc] initWithClearcutLogger:self.mockClearcutLogger + usingTimeFetcher:self.mockTimeFetcher + usingUserDefaults:nil + analytics:analytics]; + + NSTimeInterval currentMoment = 10000; + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]).andReturn(currentMoment); + + OCMExpect([self.mockClearcutLogger + logAnalyticsEventForType:FIRIAMAnalyticsEventMessageImpression + forCampaignID:[OCMArg isEqual:campaignID] + withCampaignName:[OCMArg isEqual:campaignName] + eventTimeInMs:[OCMArg isNil] + completion:([OCMArg invokeBlockWithArgs:@YES, nil])]); + + XCTestExpectation *expectation2 = + [self expectationWithDescription:@"Completion Callback Triggered"]; + [logger logAnalyticsEventForType:FIRIAMAnalyticsEventMessageImpression + forCampaignID:campaignID + withCampaignName:campaignName + eventTimeInMs:nil + completion:^(BOOL success) { + [expectation2 fulfill]; + }]; + [self waitForExpectationsWithTimeout:1.0 handler:nil]; +} + +- (void)testLogActionEvent { + XCTestExpectation *expectation1 = [self expectationWithDescription:@"Log to Analytics"]; + FakeAnalytics *analytics = [[FakeAnalytics alloc] + initWithEventHandler:^(NSString *origin, NSString *name, NSDictionary *parameters) { + XCTAssertEqualObjects(origin, @"fiam"); + XCTAssertEqualObjects(name, @"firebase_in_app_message_action"); + XCTAssertEqual([parameters count], 3); + XCTAssertNotNil(parameters); + XCTAssertEqual(parameters[@"_nmid"], campaignID); + XCTAssertEqual(parameters[@"_nmn"], campaignName); + [expectation1 fulfill]; + }]; + + FIRIAMAnalyticsEventLoggerImpl *logger = + [[FIRIAMAnalyticsEventLoggerImpl alloc] initWithClearcutLogger:self.mockClearcutLogger + usingTimeFetcher:self.mockTimeFetcher + usingUserDefaults:self.mockUserDefaults + analytics:analytics]; + + NSTimeInterval currentMoment = 10000; + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]).andReturn(currentMoment); + + OCMExpect([self.mockClearcutLogger + logAnalyticsEventForType:FIRIAMAnalyticsEventActionURLFollow + forCampaignID:[OCMArg isEqual:campaignID] + withCampaignName:[OCMArg isEqual:campaignName] + eventTimeInMs:[OCMArg isNil] + completion:([OCMArg invokeBlockWithArgs:@YES, nil])]); + + XCTestExpectation *expectation2 = + [self expectationWithDescription:@"Completion Callback Triggered"]; + + [logger logAnalyticsEventForType:FIRIAMAnalyticsEventActionURLFollow + forCampaignID:campaignID + withCampaignName:campaignName + eventTimeInMs:nil + completion:^(BOOL success) { + [expectation2 fulfill]; + }]; + + [self waitForExpectationsWithTimeout:2.0 handler:nil]; + OCMVerifyAll((id)self.mockClearcutLogger); +} + +@end diff --git a/InAppMessaging/Example/Tests/FIRIAMBookKeeperViaUserDefaultsTests.m b/InAppMessaging/Example/Tests/FIRIAMBookKeeperViaUserDefaultsTests.m new file mode 100644 index 0000000000..85b07845eb --- /dev/null +++ b/InAppMessaging/Example/Tests/FIRIAMBookKeeperViaUserDefaultsTests.m @@ -0,0 +1,187 @@ +/* + * 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 +#import + +#import "FIRIAMBookKeeper.h" + +@interface FIRIAMBookKeeperViaUserDefaultsTests : XCTestCase +@property(nonatomic) NSUserDefaults *userDefaultsForTesting; +@end + +extern NSString *FIRIAM_UserDefaultsKeyForImpressions; +extern NSString *FIRIAM_UserDefaultsKeyForLastImpressionTimestamp; + +extern NSString *FIRIAM_ImpressionDictKeyForID; +extern NSString *FIRIAM_ImpressionDictKeyForTimestamp; + +@implementation FIRIAMBookKeeperViaUserDefaultsTests +- (void)setUp { + [super setUp]; + self.userDefaultsForTesting = + [[NSUserDefaults alloc] initWithSuiteName:@"FIRIAMBookKeeperViaUserDefaultsTests"]; +} + +- (void)tearDown { + // Put teardown code here. This method is called after the invocation of each test method in the + // class. + [super tearDown]; + [self.userDefaultsForTesting removeSuiteNamed:@"FIRIAMBookKeeperViaUserDefaultsTests"]; +} + +- (void)testRecordImpressionRecords { + FIRIAMBookKeeperViaUserDefaults *bookKeeper = + [[FIRIAMBookKeeperViaUserDefaults alloc] initWithUserDefaults:self.userDefaultsForTesting]; + [bookKeeper cleanupImpressions]; + + NSArray *impressions = [bookKeeper getImpressions]; + XCTAssertEqual(0, [impressions count]); + + double impression1_ts = 12345; + double impression2_ts = 34567; + + [bookKeeper recordNewImpressionForMessage:@"m1" withStartTimestampInSeconds:impression1_ts]; + [bookKeeper recordNewImpressionForMessage:@"m1" withStartTimestampInSeconds:impression2_ts]; + + impressions = [bookKeeper getImpressions]; + // For the same message, we only record the last impression record. + XCTAssertEqual(1, [impressions count]); + XCTAssertEqualWithAccuracy(impression2_ts, impressions[0].impressionTimeInSeconds, 0.1); + + // Verify the last display time. + XCTAssertEqualWithAccuracy(impression2_ts, [bookKeeper lastDisplayTime], 0.1); + + double impression3_ts = 45000; + + [bookKeeper recordNewImpressionForMessage:@"m2" withStartTimestampInSeconds:impression3_ts]; + impressions = [bookKeeper getImpressions]; + // Now we should see two different impression records for two different messages. + XCTAssertEqual(2, [impressions count]); + // Verify the last display time is updated again. + XCTAssertEqualWithAccuracy(impression3_ts, [bookKeeper lastDisplayTime], 0.1); +} + +- (void)testRecordFetchTimes { + FIRIAMBookKeeperViaUserDefaults *bookKeeper = + [[FIRIAMBookKeeperViaUserDefaults alloc] initWithUserDefaults:self.userDefaultsForTesting]; + [bookKeeper cleanupImpressions]; + + double fetch1_ts = 12345; + double fetch2_ts = 34567; + [bookKeeper recordNewFetchWithFetchCount:10 + withTimestampInSeconds:fetch1_ts + nextFetchWaitTime:nil]; + [bookKeeper recordNewFetchWithFetchCount:10 + withTimestampInSeconds:fetch2_ts + nextFetchWaitTime:nil]; + + XCTAssertEqualWithAccuracy(fetch2_ts, [bookKeeper lastFetchTime], 0.1); +} + +- (void)testRecordFetchTimesWithFetchWaitTime { + FIRIAMBookKeeperViaUserDefaults *bookKeeper = + [[FIRIAMBookKeeperViaUserDefaults alloc] initWithUserDefaults:self.userDefaultsForTesting]; + [bookKeeper cleanupImpressions]; + + double fetch1_ts = 12345; + NSNumber *fetchWaitTime = [NSNumber numberWithInt:30000]; + [bookKeeper recordNewFetchWithFetchCount:10 + withTimestampInSeconds:fetch1_ts + nextFetchWaitTime:fetchWaitTime]; + XCTAssertEqualWithAccuracy(fetchWaitTime.doubleValue, [bookKeeper nextFetchWaitTime], 0.1); +} + +- (void)testRecordFetchTimesWithFetchWaitTimeOverCap { + FIRIAMBookKeeperViaUserDefaults *bookKeeper = + [[FIRIAMBookKeeperViaUserDefaults alloc] initWithUserDefaults:self.userDefaultsForTesting]; + [bookKeeper cleanupImpressions]; + + double fetch1_ts = 12345; + NSNumber *fetchWaitTime = [NSNumber numberWithInt:30000]; + [bookKeeper recordNewFetchWithFetchCount:10 + withTimestampInSeconds:fetch1_ts + nextFetchWaitTime:fetchWaitTime]; + XCTAssertEqualWithAccuracy(fetchWaitTime.doubleValue, [bookKeeper nextFetchWaitTime], 0.1); + + // Second recording use a very large fetch wait time: 30000000 is to large to be accepted. + NSNumber *fetchWaitTime2 = [NSNumber numberWithInt:30000000]; + [bookKeeper recordNewFetchWithFetchCount:10 + withTimestampInSeconds:fetch1_ts + nextFetchWaitTime:fetchWaitTime2]; + // Next fetch wait time is still the same as from fetchWaitTime + XCTAssertEqualWithAccuracy(fetchWaitTime.doubleValue, [bookKeeper nextFetchWaitTime], 0.1); +} + +- (void)testFetchImpressions { + NSString *message1 = @"message1 id"; + double message1ImpressionTime = 1000.0; + + NSString *message2 = @"message2 id"; + double message2ImpressionTime = 2000.0; + + FIRIAMBookKeeperViaUserDefaults *bookKeeper = + [[FIRIAMBookKeeperViaUserDefaults alloc] initWithUserDefaults:self.userDefaultsForTesting]; + [bookKeeper cleanupImpressions]; + // Set up existing impressions. + [bookKeeper recordNewImpressionForMessage:message1 + withStartTimestampInSeconds:message1ImpressionTime]; + [bookKeeper recordNewImpressionForMessage:message2 + withStartTimestampInSeconds:message2ImpressionTime]; + + NSArray *fetchedImpressions = [bookKeeper getImpressions]; + + XCTAssertEqual(2, fetchedImpressions.count); + + FIRIAMImpressionRecord *first = fetchedImpressions[0]; + XCTAssertEqualObjects(first.messageID, message1); + XCTAssertEqualWithAccuracy((double)first.impressionTimeInSeconds, message1ImpressionTime, 0.1); + + FIRIAMImpressionRecord *second = fetchedImpressions[1]; + XCTAssertEqualObjects(second.messageID, message2); + XCTAssertEqualWithAccuracy((double)second.impressionTimeInSeconds, message2ImpressionTime, 0.1); + + NSArray *messageIDs = [bookKeeper getMessageIDsFromImpressions]; + XCTAssertEqualObjects(messageIDs[0], message1); + XCTAssertEqualObjects(messageIDs[1], message2); +} + +- (void)testClearImpressionsForMessageIDs { + FIRIAMBookKeeperViaUserDefaults *bookKeeper = + [[FIRIAMBookKeeperViaUserDefaults alloc] initWithUserDefaults:self.userDefaultsForTesting]; + [bookKeeper cleanupImpressions]; + + NSArray *impressions = [bookKeeper getImpressions]; + XCTAssertEqual(0, [impressions count]); + + double impression1_ts = 12345; + double impression2_ts = 34567; + double impression3_ts = 34567; + + [bookKeeper recordNewImpressionForMessage:@"m1" withStartTimestampInSeconds:impression1_ts]; + [bookKeeper recordNewImpressionForMessage:@"m2" withStartTimestampInSeconds:impression2_ts]; + [bookKeeper recordNewImpressionForMessage:@"m3" withStartTimestampInSeconds:impression3_ts]; + + [bookKeeper clearImpressionsWithMessageList:@[ @"m1", @"m3" ]]; + + impressions = [bookKeeper getImpressions]; + + // Only impressions about m2 remains. + XCTAssertEqual(1, [impressions count]); + XCTAssertEqualObjects(impressions[0].messageID, @"m2"); +} + +@end diff --git a/InAppMessaging/Example/Tests/FIRIAMClearcutLoggerTests.m b/InAppMessaging/Example/Tests/FIRIAMClearcutLoggerTests.m new file mode 100644 index 0000000000..dfdc70c246 --- /dev/null +++ b/InAppMessaging/Example/Tests/FIRIAMClearcutLoggerTests.m @@ -0,0 +1,144 @@ +/* + * Copyright 2018 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 +#import + +#import "FIRIAMClearcutHttpRequestSender.h" +#import "FIRIAMClearcutLogStorage.h" +#import "FIRIAMClearcutLogger.h" +#import "FIRIAMClearcutUploader.h" + +@interface FIRIAMClearcutLoggerTests : XCTestCase +@property(nonatomic) FIRIAMClientInfoFetcher *mockClientInfoFetcher; +@property(nonatomic) id mockTimeFetcher; +@property(nonatomic) FIRIAMClearcutHttpRequestSender *mockRequestSender; +@property(nonatomic) FIRIAMClearcutUploader *mockCtUploader; + +@end + +NSString *iid = @"my iid"; +NSString *osVersion = @"iOS version"; +NSString *sdkVersion = @"SDK version"; + +// we need to access the some internal things in FIRIAMClearcutLogger in our unit tests +// verifications +@interface FIRIAMClearcutLogger (UnitTestAccess) +@property(readonly, nonatomic) FIRIAMClearcutLogStorage *retryStorage; +@property(nonatomic) FIRIAMClearcutHttpRequestSender *requestSender; +@property(nonatomic) id timeFetcher; +- (void)checkAndRetryClearcutLogs; +@end +@interface FIRIAMClearcutLogStorage (UnitTestAccess) +@property(nonatomic) NSMutableArray *records; +@end + +@implementation FIRIAMClearcutLoggerTests +- (void)setUp { + [super setUp]; + + self.mockTimeFetcher = OCMProtocolMock(@protocol(FIRIAMTimeFetcher)); + self.mockClientInfoFetcher = OCMClassMock(FIRIAMClientInfoFetcher.class); + self.mockRequestSender = OCMClassMock(FIRIAMClearcutHttpRequestSender.class); + self.mockCtUploader = OCMClassMock(FIRIAMClearcutUploader.class); + + OCMStub([self.mockClientInfoFetcher + fetchFirebaseIIDDataWithProjectNumber:[OCMArg any] + withCompletion:([OCMArg invokeBlockWithArgs:iid, @"token", + [NSNull null], nil])]); + + OCMStub([self.mockClientInfoFetcher getIAMSDKVersion]).andReturn(sdkVersion); + OCMStub([self.mockClientInfoFetcher getOSVersion]).andReturn(osVersion); +} + +- (void)tearDown { + // Put teardown code here. This method is called after the invocation of each test method in the + // class. + [super tearDown]; +} + +// verify that the produced FIRIAMClearcutLogRecord record has expected content for +// the event extension json string +- (void)testEventLogBodyContent_Expected { + NSString *fbProjectNumber = @"clearcutserver"; + NSString *fbAppId = @"test Firebase app"; + + FIRIAMClearcutLogger *logger = + [[FIRIAMClearcutLogger alloc] initWithFBProjectNumber:fbProjectNumber + fbAppId:fbAppId + clientInfoFetcher:self.mockClientInfoFetcher + usingTimeFetcher:self.mockTimeFetcher + usingUploader:self.mockCtUploader]; + + NSTimeInterval eventMoment = 10000; + __block NSDictionary *capturedEventDict; + + OCMExpect([self.mockCtUploader + addNewLogRecord:[OCMArg checkWithBlock:^BOOL(FIRIAMClearcutLogRecord *newLogRecord) { + NSString *jsonString = newLogRecord.eventExtensionJsonString; + + capturedEventDict = [NSJSONSerialization + JSONObjectWithData:[jsonString dataUsingEncoding:NSUTF8StringEncoding] + options:kNilOptions + error:nil]; + return (int)newLogRecord.eventTimestampInSeconds == (int)eventMoment; + }]]); + + NSString *campaignID = @"test campaign"; + [logger logAnalyticsEventForType:FIRIAMAnalyticsEventActionURLFollow + forCampaignID:campaignID + withCampaignName:@"name" + eventTimeInMs:[NSNumber numberWithInteger:eventMoment * 1000] + completion:^(BOOL success){ + }]; + + OCMVerifyAll((id)self.mockCtUploader); + + XCTAssertEqualObjects(@"CLICK_EVENT_TYPE", capturedEventDict[@"event_type"]); + XCTAssertEqualObjects(fbProjectNumber, capturedEventDict[@"project_number"]); + XCTAssertEqualObjects(campaignID, capturedEventDict[@"campaign_id"]); + XCTAssertEqualObjects(fbAppId, capturedEventDict[@"client_app"][@"google_app_id"]); + XCTAssertEqualObjects(iid, capturedEventDict[@"client_app"][@"firebase_instance_id"]); + XCTAssertEqualObjects(sdkVersion, capturedEventDict[@"fiam_sdk_version"]); +} + +// calling logAnalyticsEventForType with event time set to nil +- (void)testNilEventTimestamp { + FIRIAMClearcutLogger *logger = + [[FIRIAMClearcutLogger alloc] initWithFBProjectNumber:@"clearcutserver" + fbAppId:@"test Firebase app" + clientInfoFetcher:self.mockClientInfoFetcher + usingTimeFetcher:self.mockTimeFetcher + usingUploader:self.mockCtUploader]; + + NSTimeInterval currentMoment = 10000; + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]).andReturn(currentMoment); + + OCMExpect([self.mockCtUploader + addNewLogRecord:[OCMArg checkWithBlock:^BOOL(FIRIAMClearcutLogRecord *newLogRecord) { + return (int)newLogRecord.eventTimestampInSeconds == (int)currentMoment; + }]]); + + [logger logAnalyticsEventForType:FIRIAMAnalyticsEventActionURLFollow + forCampaignID:@"test campaign" + withCampaignName:@"name" + eventTimeInMs:nil + completion:^(BOOL success){ + }]; + + OCMVerifyAll((id)self.mockCtUploader); +} +@end diff --git a/InAppMessaging/Example/Tests/FIRIAMClearcutRetryLocalStorageTests.m b/InAppMessaging/Example/Tests/FIRIAMClearcutRetryLocalStorageTests.m new file mode 100644 index 0000000000..95901f055a --- /dev/null +++ b/InAppMessaging/Example/Tests/FIRIAMClearcutRetryLocalStorageTests.m @@ -0,0 +1,81 @@ +/* + * Copyright 2018 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 +#import + +#import "FIRIAMClearcutLogStorage.h" +#import "FIRIAMTimeFetcher.h" + +@interface FIRIAMClearcutLogStorage (UnitTestAccess) +@property(nonatomic) NSMutableArray *records; +@end + +@interface FIRIAMClearcutLogStorageTests : XCTestCase + +@end + +@implementation FIRIAMClearcutLogStorageTests + +- (void)setUp { + [super setUp]; + // Put setup code here. This method is called before the invocation of each test method in the + // class. +} + +- (void)tearDown { + // Put teardown code here. This method is called after the invocation of each test method in the + // class. + [super tearDown]; +} + +- (void)testExpiringOldLogs { + id mockTimeFetcher = OCMProtocolMock(@protocol(FIRIAMTimeFetcher)); + NSInteger logExpiresInSeconds = 20; + + FIRIAMClearcutLogStorage *storage = + [[FIRIAMClearcutLogStorage alloc] initWithExpireAfterInSeconds:logExpiresInSeconds + withTimeFetcher:mockTimeFetcher]; + + NSInteger eventTimestamp = 1000; + // insert 10 logs with event timestamp as eventTimestamp + for (int i = 0; i < 10; i++) { + FIRIAMClearcutLogRecord *nextRecord = + [[FIRIAMClearcutLogRecord alloc] initWithExtensionJsonString:@"json string" + eventTimestampInSeconds:eventTimestamp]; + [storage pushRecords:@[ nextRecord ]]; + } + + // insert 2 logs with event timestamp as eventTimestamp + 10 + for (int i = 0; i < 2; i++) { + FIRIAMClearcutLogRecord *nextRecord = + [[FIRIAMClearcutLogRecord alloc] initWithExtensionJsonString:@"json string" + eventTimestampInSeconds:eventTimestamp + 10]; + [storage pushRecords:@[ nextRecord ]]; + } + + // with this stub, 10 out of the 12 the retry logs are going expired + OCMStub([mockTimeFetcher currentTimestampInSeconds]) + .andReturn(eventTimestamp + logExpiresInSeconds + 1); + + NSArray *results = [storage popStillValidRecordsForUpTo:6]; + // only 2 out of 12 retry logs are still valid + XCTAssertEqual(2, results.count); + + // all the messages should be gone here + XCTAssertEqual(0, storage.records.count); +} +@end diff --git a/InAppMessaging/Example/Tests/FIRIAMClearcutUploaderTests.m b/InAppMessaging/Example/Tests/FIRIAMClearcutUploaderTests.m new file mode 100644 index 0000000000..c4bc6b6725 --- /dev/null +++ b/InAppMessaging/Example/Tests/FIRIAMClearcutUploaderTests.m @@ -0,0 +1,373 @@ +/* + * Copyright 2018 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 +#import + +#import "FIRIAMClearcutHttpRequestSender.h" +#import "FIRIAMClearcutLogStorage.h" +#import "FIRIAMClearcutUploader.h" +#import "FIRIAMTimeFetcher.h" + +@interface FIRIAMClearcutUploaderTests : XCTestCase +@property(nonatomic) id mockTimeFetcher; +@property(nonatomic) FIRIAMClearcutHttpRequestSender *mockRequestSender; +@property(nonatomic) FIRIAMClearcutLogStorage *mockLogStorage; + +@property(nonatomic) FIRIAMClearcutStrategy *defaultStrategy; + +@property(nonatomic) NSUserDefaults *mockUserDefaults; +@end + +// expose certain internal things to help with unit testing +@interface FIRIAMClearcutUploader (UnitTest) +@property(nonatomic, assign) int64_t nextValidSendTimeInMills; +@end + +@implementation FIRIAMClearcutUploaderTests + +- (void)setUp { + [super setUp]; + self.mockTimeFetcher = OCMProtocolMock(@protocol(FIRIAMTimeFetcher)); + self.mockRequestSender = OCMClassMock(FIRIAMClearcutHttpRequestSender.class); + self.mockLogStorage = OCMClassMock(FIRIAMClearcutLogStorage.class); + + self.defaultStrategy = [[FIRIAMClearcutStrategy alloc] initWithMinWaitTimeInMills:1000 + maxWaitTimeInMills:2000 + failureBackoffTimeInMills:1000 + batchSendSize:10]; + + self.mockUserDefaults = OCMClassMock(NSUserDefaults.class); + OCMStub([self.mockUserDefaults integerForKey:[OCMArg any]]).andReturn(0); +} + +- (void)tearDown { + // Put teardown code here. This method is called after the invocation of each test method in the + // class. + [super tearDown]; +} + +- (void)testUploadTriggeredWhenWaitTimeConditionSatisfied { + // using a real storage in this case + FIRIAMClearcutLogStorage *logStorage = + [[FIRIAMClearcutLogStorage alloc] initWithExpireAfterInSeconds:1000 + withTimeFetcher:self.mockTimeFetcher]; + + FIRIAMClearcutUploader *uploader = + [[FIRIAMClearcutUploader alloc] initWithRequestSender:self.mockRequestSender + timeFetcher:self.mockTimeFetcher + logStorage:logStorage + usingStrategy:self.defaultStrategy + usingUserDefaults:self.mockUserDefaults]; + + // so the following setup is to say that it's now ok to do upload right away + // nextValidSendTimeInMills < currnt time + NSTimeInterval currentMoment = 10000; + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]).andReturn(currentMoment); + uploader.nextValidSendTimeInMills = (int64_t)(currentMoment - 1) * 1000; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Triggers send on sender"]; + + OCMStub([self.mockRequestSender sendClearcutHttpRequestForLogs:[OCMArg any] + withCompletion:[OCMArg any]]) + .andDo(^(NSInvocation *invocation) { + [expectation fulfill]; + }); + + FIRIAMClearcutLogRecord *newRecord = + [[FIRIAMClearcutLogRecord alloc] initWithExtensionJsonString:@"string" + eventTimestampInSeconds:currentMoment]; + + [uploader addNewLogRecord:newRecord]; + + // we expect expectation to be fulfilled right away since the upload can be carried out without + // delay + [self waitForExpectationsWithTimeout:1.0 handler:nil]; +} + +- (void)testUploadNotTriggeredWhenWaitTimeConditionNotSatisfied { + // using a real storage in this case + FIRIAMClearcutLogStorage *logStorage = + [[FIRIAMClearcutLogStorage alloc] initWithExpireAfterInSeconds:1000 + withTimeFetcher:self.mockTimeFetcher]; + + FIRIAMClearcutUploader *uploader = + [[FIRIAMClearcutUploader alloc] initWithRequestSender:self.mockRequestSender + timeFetcher:self.mockTimeFetcher + logStorage:logStorage + usingStrategy:self.defaultStrategy + usingUserDefaults:self.mockUserDefaults]; + + // so the following setup is to say that we need at least 5 seconds from now to attempt the + // the uploading + NSTimeInterval currentMoment = 10000; + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]).andReturn(currentMoment); + uploader.nextValidSendTimeInMills = (int64_t)(currentMoment + 5) * 1000; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Triggers send on sender"]; + + FIRIAMClearcutLogRecord *newRecord = + [[FIRIAMClearcutLogRecord alloc] initWithExtensionJsonString:@"string" + eventTimestampInSeconds:currentMoment]; + + __block BOOL sendingAttempted = NO; + // we don't expect sendClearcutHttpRequestForLogs:withCompletion: to be triggered + // after wait for 2.0 seconds below. We have a BOOL flag to be used for that kind verification + // checking + OCMStub([self.mockRequestSender sendClearcutHttpRequestForLogs:[OCMArg any] + withCompletion:[OCMArg any]]) + .andDo(^(NSInvocation *invocation) { + sendingAttempted = YES; + }); + [uploader addNewLogRecord:newRecord]; + + // we wait for 2 seconds and we expect nothing should happen to self.mockRequestSender right after + // 2 seconds: the upload will eventually be attempted in after 10 seconds based on the setup + // in this unit test + double delayInSeconds = 2.0; + dispatch_time_t popTime = + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delayInSeconds * NSEC_PER_SEC)); + dispatch_after(popTime, dispatch_get_main_queue(), ^(void) { + [expectation fulfill]; + }); + + // we expect expectation to be fulfilled right away since the upload can be carried out without + // delay + [self waitForExpectationsWithTimeout:10.0 handler:nil]; + XCTAssertFalse(sendingAttempted); +} + +- (void)testUploadBatchSizeIsBasedOnStrategySetting { + int batchSendSize = 5; + + // using a strategy with batch send size as 5 + FIRIAMClearcutStrategy *strategy = + [[FIRIAMClearcutStrategy alloc] initWithMinWaitTimeInMills:1000 + maxWaitTimeInMills:2000 + failureBackoffTimeInMills:1000 + batchSendSize:batchSendSize]; + + FIRIAMClearcutUploader *uploader = + [[FIRIAMClearcutUploader alloc] initWithRequestSender:self.mockRequestSender + timeFetcher:self.mockTimeFetcher + logStorage:self.mockLogStorage + usingStrategy:strategy + usingUserDefaults:self.mockUserDefaults]; + + // so the following setup is to say that next upload is going to be now + NSTimeInterval currentMoment = 10000; + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]).andReturn(currentMoment); + uploader.nextValidSendTimeInMills = (int64_t)currentMoment * 1000; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Triggers send on sender"]; + OCMExpect([self.mockLogStorage popStillValidRecordsForUpTo:batchSendSize]); + + FIRIAMClearcutLogRecord *newRecord = + [[FIRIAMClearcutLogRecord alloc] initWithExtensionJsonString:@"string" + eventTimestampInSeconds:currentMoment]; + [uploader addNewLogRecord:newRecord]; + + // we wait for 2 seconds to ensure that the next send is attempted and then verify its + // interacton with the underlying storage + double delayInSeconds = 2.0; + dispatch_time_t popTime = + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delayInSeconds * NSEC_PER_SEC)); + dispatch_after(popTime, dispatch_get_main_queue(), ^(void) { + [expectation fulfill]; + }); + + // we expect expectation to be fulfilled right away since the upload can be carried out without + // delay + [self waitForExpectationsWithTimeout:10.0 handler:nil]; + OCMVerifyAll((id)self.mockLogStorage); +} + +- (void)testRespectingWaitTimeFromRequestSender { + // using a real storage in this case + FIRIAMClearcutLogStorage *logStorage = + [[FIRIAMClearcutLogStorage alloc] initWithExpireAfterInSeconds:1000 + withTimeFetcher:self.mockTimeFetcher]; + + FIRIAMClearcutUploader *uploader = + [[FIRIAMClearcutUploader alloc] initWithRequestSender:self.mockRequestSender + timeFetcher:self.mockTimeFetcher + logStorage:logStorage + usingStrategy:self.defaultStrategy + usingUserDefaults:self.mockUserDefaults]; + + // so the following setup is to say that next upload is going to be now + NSTimeInterval currentMoment = 10000; + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]).andReturn(currentMoment); + uploader.nextValidSendTimeInMills = (int64_t)currentMoment * 1000; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Triggers send on sender"]; + + // notice that waitTime is between minWaitTimeInMills and maxWaitTimeInMills in the default + // strategy + NSNumber *waitTime = [NSNumber numberWithLongLong:1500]; + // set up request sender which triggers the callback with a wait time interval to be 1000 + // milliseconds + OCMStub( + [self.mockRequestSender + sendClearcutHttpRequestForLogs:[OCMArg any] + withCompletion:([OCMArg invokeBlockWithArgs:@YES, @NO, waitTime, nil])]) + .andDo(^(NSInvocation *invocation) { + [expectation fulfill]; + }); + + FIRIAMClearcutLogRecord *newRecord = + [[FIRIAMClearcutLogRecord alloc] initWithExtensionJsonString:@"string" + eventTimestampInSeconds:currentMoment]; + [uploader addNewLogRecord:newRecord]; + + [self waitForExpectationsWithTimeout:10.0 handler:nil]; + // verify the update to nextValidSendTimeInMills is expected + XCTAssertEqual(currentMoment * 1000 + 1500, uploader.nextValidSendTimeInMills); +} + +- (void)testWaitTimeFromRequestSenderAdjustedByMinWaitTimeInStrategy { + // using a real storage in this case + FIRIAMClearcutLogStorage *logStorage = + [[FIRIAMClearcutLogStorage alloc] initWithExpireAfterInSeconds:1000 + withTimeFetcher:self.mockTimeFetcher]; + + FIRIAMClearcutUploader *uploader = + [[FIRIAMClearcutUploader alloc] initWithRequestSender:self.mockRequestSender + timeFetcher:self.mockTimeFetcher + logStorage:logStorage + usingStrategy:self.defaultStrategy + usingUserDefaults:self.mockUserDefaults]; + + // so the following setup is to say that next upload is going to be now + NSTimeInterval currentMoment = 10000; + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]).andReturn(currentMoment); + uploader.nextValidSendTimeInMills = (int64_t)currentMoment * 1000; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Triggers send on sender"]; + + // notice that waitTime is below minWaitTimeInMills in the default strategy + NSNumber *waitTime = + [NSNumber numberWithLongLong:self.defaultStrategy.minimalWaitTimeInMills - 200]; + // set up request sender which triggers the callback with a wait time interval to be 1000 + // milliseconds + OCMStub( + [self.mockRequestSender + sendClearcutHttpRequestForLogs:[OCMArg any] + withCompletion:([OCMArg invokeBlockWithArgs:@YES, @NO, waitTime, nil])]) + .andDo(^(NSInvocation *invocation) { + [expectation fulfill]; + }); + + FIRIAMClearcutLogRecord *newRecord = + [[FIRIAMClearcutLogRecord alloc] initWithExtensionJsonString:@"string" + eventTimestampInSeconds:currentMoment]; + [uploader addNewLogRecord:newRecord]; + + [self waitForExpectationsWithTimeout:10.0 handler:nil]; + // verify the update to nextValidSendTimeInMills is expected + XCTAssertEqual(currentMoment * 1000 + self.defaultStrategy.minimalWaitTimeInMills, + uploader.nextValidSendTimeInMills); +} + +- (void)testWaitTimeFromRequestSenderAdjustedByMaxWaitTimeInStrategy { + // using a real storage in this case + FIRIAMClearcutLogStorage *logStorage = + [[FIRIAMClearcutLogStorage alloc] initWithExpireAfterInSeconds:1000 + withTimeFetcher:self.mockTimeFetcher]; + + FIRIAMClearcutUploader *uploader = + [[FIRIAMClearcutUploader alloc] initWithRequestSender:self.mockRequestSender + timeFetcher:self.mockTimeFetcher + logStorage:logStorage + usingStrategy:self.defaultStrategy + usingUserDefaults:self.mockUserDefaults]; + + // so the following setup is to say that next upload is going to be now + NSTimeInterval currentMoment = 10000; + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]).andReturn(currentMoment); + uploader.nextValidSendTimeInMills = (int64_t)currentMoment * 1000; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Triggers send on sender"]; + + // notice that waitTime is larger than maximumWaitTimeInMills in the default strategy + NSNumber *waitTime = + [NSNumber numberWithLongLong:self.defaultStrategy.maximumWaitTimeInMills + 200]; + // set up request sender which triggers the callback with a wait time interval to be 1000 + // milliseconds + OCMStub( + [self.mockRequestSender + sendClearcutHttpRequestForLogs:[OCMArg any] + withCompletion:([OCMArg invokeBlockWithArgs:@YES, @NO, waitTime, nil])]) + .andDo(^(NSInvocation *invocation) { + [expectation fulfill]; + }); + + FIRIAMClearcutLogRecord *newRecord = + [[FIRIAMClearcutLogRecord alloc] initWithExtensionJsonString:@"string" + eventTimestampInSeconds:currentMoment]; + [uploader addNewLogRecord:newRecord]; + + [self waitForExpectationsWithTimeout:10.0 handler:nil]; + // verify the update to nextValidSendTimeInMills is expected + XCTAssertEqual(currentMoment * 1000 + self.defaultStrategy.maximumWaitTimeInMills, + uploader.nextValidSendTimeInMills); +} + +- (void)testRepushLogsIfRequestSenderSaysSo { + // using a real storage in this case + FIRIAMClearcutLogStorage *logStorage = + [[FIRIAMClearcutLogStorage alloc] initWithExpireAfterInSeconds:1000 + withTimeFetcher:self.mockTimeFetcher]; + + FIRIAMClearcutUploader *uploader = + [[FIRIAMClearcutUploader alloc] initWithRequestSender:self.mockRequestSender + timeFetcher:self.mockTimeFetcher + logStorage:logStorage + usingStrategy:self.defaultStrategy + usingUserDefaults:self.mockUserDefaults]; + + // so the following setup is to say that next upload is going to be now + NSTimeInterval currentMoment = 10000; + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]).andReturn(currentMoment); + uploader.nextValidSendTimeInMills = (int64_t)currentMoment * 1000; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Triggers send on sender"]; + + // notice that waitTime is larger than maximumWaitTimeInMills in the default strategy + NSNumber *waitTime = + [NSNumber numberWithLongLong:self.defaultStrategy.maximumWaitTimeInMills + 200]; + + // Notice that it's invoking completion with falure flag and a flag to re-push those logs + OCMStub( + [self.mockRequestSender + sendClearcutHttpRequestForLogs:[OCMArg any] + withCompletion:([OCMArg invokeBlockWithArgs:@NO, @YES, waitTime, nil])]) + .andDo(^(NSInvocation *invocation) { + [expectation fulfill]; + }); + + FIRIAMClearcutLogRecord *newRecord = + [[FIRIAMClearcutLogRecord alloc] initWithExtensionJsonString:@"string" + eventTimestampInSeconds:currentMoment]; + [uploader addNewLogRecord:newRecord]; + + [self waitForExpectationsWithTimeout:10.0 handler:nil]; + + // we should still be able to fetch one log record from storage since it's re-pushed due + // to send failure + XCTAssertEqual([logStorage popStillValidRecordsForUpTo:10].count, 1); +} +@end diff --git a/InAppMessaging/Example/Tests/FIRIAMDisplayExecutorTests.m b/InAppMessaging/Example/Tests/FIRIAMDisplayExecutorTests.m new file mode 100644 index 0000000000..f71a32abe6 --- /dev/null +++ b/InAppMessaging/Example/Tests/FIRIAMDisplayExecutorTests.m @@ -0,0 +1,761 @@ +/* + * 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 +#import + +#import "FIRIAMActionURLFollower.h" +#import "FIRIAMDisplayExecutor.h" +#import "FIRIAMDisplayTriggerDefinition.h" +#import "FIRIAMMessageContentData.h" + +// A class implementing protocol FIRIAMMessageContentData to be used for unit testing +@interface FIRIAMMessageContentDataForTesting : NSObject +@property(nonatomic, readwrite, nonnull) NSString *titleText; +@property(nonatomic, readwrite, nonnull) NSString *bodyText; +@property(nonatomic, nullable) NSString *actionButtonText; +@property(nonatomic, nullable) NSURL *actionURL; +@property(nonatomic, nullable) NSURL *imageURL; +@property BOOL errorEncountered; + +- (instancetype)initWithMessageTitle:(NSString *)title + messageBody:(NSString *)body + actionButtonText:(nullable NSString *)actionButtonText + actionURL:(nullable NSURL *)actionURL + imageURL:(nullable NSURL *)imageURL + hasImageError:(BOOL)hasImageError; +@end + +@implementation FIRIAMMessageContentDataForTesting +- (instancetype)initWithMessageTitle:(NSString *)title + messageBody:(NSString *)body + actionButtonText:(nullable NSString *)actionButtonText + actionURL:(nullable NSURL *)actionURL + imageURL:(nullable NSURL *)imageURL + hasImageError:(BOOL)hasImageError { + if (self = [super init]) { + _titleText = title; + _bodyText = body; + _imageURL = imageURL; + _actionButtonText = actionButtonText; + _actionURL = actionURL; + _errorEncountered = hasImageError; + } + return self; +} + +- (void)loadImageDataWithBlock:(void (^)(NSData *_Nullable imageData, + NSError *_Nullable error))block { + if (self.errorEncountered) { + block(nil, [NSError errorWithDomain:@"image error" code:0 userInfo:nil]); + } else { + NSString *str = @"image data"; + NSData *data = [str dataUsingEncoding:NSUTF8StringEncoding]; + block(data, nil); + } +} +@end + +// Defines how the message display component triggers the delegate in unit testing +typedef NS_ENUM(NSInteger, FIRInAppMessagingDelegateInteraction) { + FIRInAppMessagingDelegateInteractionDismiss, // message display component triggers + // messageDismissedWithType: + FIRInAppMessagingDelegateInteractionClick, // message display component triggers + // messageClicked: + FIRInAppMessagingDelegateInteractionError, // message display component triggers + // displayErrorEncountered: + FIRInAppMessagingDelegateInteractionImpressionDetected, // message has finished a valid + // impression, but it's not getting + // closed by the user. +}; + +// A class implementing protocol FIRInAppMessagingDisplay to be used for unit testing +@interface FIRIAMMessageDisplayForTesting : NSObject +@property FIRInAppMessagingDelegateInteraction delegateInteraction; + +// used for interaction verificatio +@property FIRInAppMessagingDisplayMessageBase *message; +- (instancetype)initWithDelegateInteraction:(FIRInAppMessagingDelegateInteraction)interaction; +@end + +@implementation FIRIAMMessageDisplayForTesting +- (instancetype)initWithDelegateInteraction:(FIRInAppMessagingDelegateInteraction)interaction { + if (self = [super init]) { + _delegateInteraction = interaction; + } + return self; +} + +- (void)displayMessage:(FIRInAppMessagingDisplayMessageBase *)messageForDisplay + displayDelegate:(id)displayDelegate { + self.message = messageForDisplay; + + switch (self.delegateInteraction) { + case FIRInAppMessagingDelegateInteractionClick: + [displayDelegate messageClicked]; + break; + case FIRInAppMessagingDelegateInteractionDismiss: + [displayDelegate messageDismissedWithType:FIRInAppMessagingDismissTypeAuto]; + break; + case FIRInAppMessagingDelegateInteractionError: + [displayDelegate displayErrorEncountered:[NSError errorWithDomain:NSURLErrorDomain + code:0 + userInfo:nil]]; + break; + case FIRInAppMessagingDelegateInteractionImpressionDetected: + [displayDelegate impressionDetected]; + break; + } +} +@end + +@interface FIRIAMDisplayExecutorTests : XCTestCase + +@property(nonatomic) FIRIAMDisplaySetting *displaySetting; +@property FIRIAMMessageClientCache *clientMessageCache; +@property id mockBookkeeper; +@property id mockTimeFetcher; + +@property FIRIAMDisplayExecutor *displayExecutor; + +@property FIRIAMActivityLogger *mockActivityLogger; + +@property id mockAnalyticsEventLogger; + +@property FIRIAMActionURLFollower *mockActionURLFollower; + +@property id mockMessageDisplayComponent; + +// three pre-defined messages +@property FIRIAMMessageDefinition *m1, *m2, *m3, *m4; +@end + +@implementation FIRIAMDisplayExecutorTests + +- (void)setupMessageTexture { + // startTime, endTime here ensures messages with them are active + NSTimeInterval activeStartTime = 0; + NSTimeInterval activeEndTime = [[NSDate date] timeIntervalSince1970] + 10000; + + // m1 & m3 will be of contextual trigger + FIRIAMDisplayTriggerDefinition *contextualTriggerDefinition = + [[FIRIAMDisplayTriggerDefinition alloc] initWithFirebaseAnalyticEvent:@"test_event"]; + + // m2 and m4 will be of app open trigger + FIRIAMDisplayTriggerDefinition *appOpentriggerDefinition = + [[FIRIAMDisplayTriggerDefinition alloc] initForAppForegroundTrigger]; + + FIRIAMMessageContentDataForTesting *m1ContentData = [[FIRIAMMessageContentDataForTesting alloc] + initWithMessageTitle:@"m1 title" + messageBody:@"message body" + actionButtonText:nil + actionURL:[NSURL URLWithString:@"http://google.com"] + imageURL:[NSURL URLWithString:@"https://google.com/image"] + hasImageError:NO]; + + FIRIAMRenderingEffectSetting *renderSetting1 = + [FIRIAMRenderingEffectSetting getDefaultRenderingEffectSetting]; + renderSetting1.viewMode = FIRIAMRenderAsBannerView; + + FIRIAMMessageRenderData *renderData1 = + [[FIRIAMMessageRenderData alloc] initWithMessageID:@"m1" + messageName:@"name" + contentData:m1ContentData + renderingEffect:renderSetting1]; + + self.m1 = [[FIRIAMMessageDefinition alloc] initWithRenderData:renderData1 + startTime:activeStartTime + endTime:activeEndTime + triggerDefinition:@[ contextualTriggerDefinition ]]; + + FIRIAMMessageContentDataForTesting *m2ContentData = [[FIRIAMMessageContentDataForTesting alloc] + initWithMessageTitle:@"m2 title" + messageBody:@"message body" + actionButtonText:nil + actionURL:[NSURL URLWithString:@"http://google.com"] + imageURL:[NSURL URLWithString:@"https://unsplash.it/300/400"] + hasImageError:NO]; + + FIRIAMRenderingEffectSetting *renderSetting2 = + [FIRIAMRenderingEffectSetting getDefaultRenderingEffectSetting]; + renderSetting2.viewMode = FIRIAMRenderAsModalView; + + FIRIAMMessageRenderData *renderData2 = + [[FIRIAMMessageRenderData alloc] initWithMessageID:@"m2" + messageName:@"name" + contentData:m2ContentData + renderingEffect:renderSetting2]; + + self.m2 = [[FIRIAMMessageDefinition alloc] initWithRenderData:renderData2 + startTime:activeStartTime + endTime:activeEndTime + triggerDefinition:@[ appOpentriggerDefinition ]]; + + FIRIAMMessageContentDataForTesting *m3ContentData = [[FIRIAMMessageContentDataForTesting alloc] + initWithMessageTitle:@"m3 title" + messageBody:@"message body" + actionButtonText:nil + actionURL:[NSURL URLWithString:@"http://google.com"] + imageURL:[NSURL URLWithString:@"https://google.com/image"] + hasImageError:NO]; + + FIRIAMRenderingEffectSetting *renderSetting3 = + [FIRIAMRenderingEffectSetting getDefaultRenderingEffectSetting]; + renderSetting3.viewMode = FIRIAMRenderAsImageOnlyView; + + FIRIAMMessageRenderData *renderData3 = + [[FIRIAMMessageRenderData alloc] initWithMessageID:@"m3" + messageName:@"name" + contentData:m3ContentData + renderingEffect:renderSetting3]; + + self.m3 = [[FIRIAMMessageDefinition alloc] initWithRenderData:renderData3 + startTime:activeStartTime + endTime:activeEndTime + triggerDefinition:@[ contextualTriggerDefinition ]]; + + FIRIAMMessageContentDataForTesting *m4ContentData = [[FIRIAMMessageContentDataForTesting alloc] + initWithMessageTitle:@"m4 title" + messageBody:@"message body" + actionButtonText:nil + actionURL:[NSURL URLWithString:@"http://google.com"] + imageURL:[NSURL URLWithString:@"https://google.com/image"] + hasImageError:NO]; + + FIRIAMRenderingEffectSetting *renderSetting4 = + [FIRIAMRenderingEffectSetting getDefaultRenderingEffectSetting]; + renderSetting4.viewMode = FIRIAMRenderAsImageOnlyView; + + FIRIAMMessageRenderData *renderData4 = + [[FIRIAMMessageRenderData alloc] initWithMessageID:@"m4" + messageName:@"name" + contentData:m4ContentData + renderingEffect:renderSetting4]; + + self.m4 = [[FIRIAMMessageDefinition alloc] initWithRenderData:renderData4 + startTime:activeStartTime + endTime:activeEndTime + triggerDefinition:@[ appOpentriggerDefinition ]]; +} + +NSTimeInterval DISPLAY_MIN_INTERVALS = 1; + +- (void)setUp { + [super setUp]; + [self setupMessageTexture]; + + self.displaySetting = [[FIRIAMDisplaySetting alloc] init]; + self.displaySetting.displayMinIntervalInMinutes = DISPLAY_MIN_INTERVALS; + self.mockBookkeeper = OCMProtocolMock(@protocol(FIRIAMBookKeeper)); + + FIRIAMFetchResponseParser *parser = + [[FIRIAMFetchResponseParser alloc] initWithTimeFetcher:[[FIRIAMTimerWithNSDate alloc] init]]; + + self.clientMessageCache = [[FIRIAMMessageClientCache alloc] initWithBookkeeper:self.mockBookkeeper + usingResponseParser:parser]; + self.mockTimeFetcher = OCMProtocolMock(@protocol(FIRIAMTimeFetcher)); + self.mockActivityLogger = OCMClassMock([FIRIAMActivityLogger class]); + self.mockAnalyticsEventLogger = OCMProtocolMock(@protocol(FIRIAMAnalyticsEventLogger)); + + self.mockActionURLFollower = OCMClassMock([FIRIAMActionURLFollower class]); + + self.displayExecutor = + [[FIRIAMDisplayExecutor alloc] initWithSetting:self.displaySetting + messageCache:self.clientMessageCache + timeFetcher:self.mockTimeFetcher + bookKeeper:self.mockBookkeeper + actionURLFollower:self.mockActionURLFollower + activityLogger:self.mockActivityLogger + analyticsEventLogger:self.mockAnalyticsEventLogger]; + + OCMStub([self.mockBookkeeper recordNewImpressionForMessage:[OCMArg any] + withStartTimestampInSeconds:1000]); +} + +- (void)tearDown { + // Put teardown code here. This method is called after the invocation of each test method in the + // class. + [super tearDown]; +} + +- (void)testRegularMessageAvailableCase { + // This setup allows next message to be displayed from display interval perspective. + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]) + .andReturn(DISPLAY_MIN_INTERVALS * 60 + 100); + + FIRIAMMessageDisplayForTesting *display = [[FIRIAMMessageDisplayForTesting alloc] + initWithDelegateInteraction:FIRInAppMessagingDelegateInteractionClick]; + self.displayExecutor.messageDisplayComponent = display; + [self.clientMessageCache setMessageData:@[ self.m2, self.m4 ]]; + [self.displayExecutor checkAndDisplayNextAppForegroundMessage]; + NSInteger remainingMsgCount = [self.clientMessageCache allRegularMessages].count; + XCTAssertEqual(1, remainingMsgCount); + + // Verify that the message content handed to display component is expected + XCTAssertEqualObjects(self.m2.renderData.messageID, display.message.messageID); +} + +- (void)testFollowingActionURL { + // This setup allows next message to be displayed from display interval perspective. + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]) + .andReturn(DISPLAY_MIN_INTERVALS * 60 + 100); + + FIRIAMMessageDisplayForTesting *display = [[FIRIAMMessageDisplayForTesting alloc] + initWithDelegateInteraction:FIRInAppMessagingDelegateInteractionClick]; + self.displayExecutor.messageDisplayComponent = display; + [self.clientMessageCache setMessageData:@[ self.m2 ]]; + + // not expecting triggering analytics recording + OCMExpect([self.mockActionURLFollower + followActionURL:[OCMArg isEqual:self.m2.renderData.contentData.actionURL] + withCompletionBlock:[OCMArg any]]); + [self.displayExecutor checkAndDisplayNextAppForegroundMessage]; + + OCMVerifyAll((id)self.mockActionURLFollower); +} + +- (void)testFollowingActionURLForTestMessage { + // This setup allows next message to be displayed from display interval perspective. + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]) + .andReturn(DISPLAY_MIN_INTERVALS * 60 + 100); + + FIRIAMMessageDefinition *testMessage = + [[FIRIAMMessageDefinition alloc] initTestMessageWithRenderData:self.m1.renderData]; + + FIRIAMMessageDisplayForTesting *display = [[FIRIAMMessageDisplayForTesting alloc] + initWithDelegateInteraction:FIRInAppMessagingDelegateInteractionClick]; + self.displayExecutor.messageDisplayComponent = display; + [self.clientMessageCache setMessageData:@[ testMessage ]]; + + // not expecting triggering analytics recording + OCMExpect([self.mockActionURLFollower + followActionURL:[OCMArg isEqual:testMessage.renderData.contentData.actionURL] + withCompletionBlock:[OCMArg any]]); + [self.displayExecutor checkAndDisplayNextAppForegroundMessage]; + + OCMVerifyAll((id)self.mockActionURLFollower); +} + +- (void)testClientTestMessageAvailableCase { + // When test message is present in cache, even if the display time interval has not been + // reached, we still render. + + // 10 seconds is less than DISPLAY_MIN_INTERVALS minutes, so we have not reached + // minimal display time interval yet. + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]).andReturn(10); + FIRIAMMessageDefinition *testMessage = + [[FIRIAMMessageDefinition alloc] initTestMessageWithRenderData:self.m1.renderData]; + + [self.clientMessageCache setMessageData:@[ self.m2, testMessage, self.m4 ]]; + + // We have test message in the cache now. + XCTAssertTrue([self.clientMessageCache hasTestMessage]); + + FIRIAMMessageDisplayForTesting *display = [[FIRIAMMessageDisplayForTesting alloc] + initWithDelegateInteraction:FIRInAppMessagingDelegateInteractionClick]; + self.displayExecutor.messageDisplayComponent = display; + + [self.displayExecutor checkAndDisplayNextAppForegroundMessage]; + + // No more test message in the cache now. + XCTAssertFalse([self.clientMessageCache hasTestMessage]); +} + +// If a message is still being displayed, we won't try to display a second one on top of it +- (void)testNoDualDisplay { + // This setup allows next message to be displayed from display interval perspective. + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]) + .andReturn(DISPLAY_MIN_INTERVALS * 60 + 100); + + // This display component only detects a valid impression, but does not end the renderig + FIRIAMMessageDisplayForTesting *display = [[FIRIAMMessageDisplayForTesting alloc] + initWithDelegateInteraction:FIRInAppMessagingDelegateInteractionImpressionDetected]; + self.displayExecutor.messageDisplayComponent = display; + + [self.clientMessageCache setMessageData:@[ self.m2, self.m4 ]]; + [self.displayExecutor checkAndDisplayNextAppForegroundMessage]; + + // m2 is being rendered + XCTAssertEqualObjects(self.m2.renderData.messageID, display.message.messageID); + + NSInteger remainingMsgCount = [self.clientMessageCache allRegularMessages].count; + XCTAssertEqual(1, remainingMsgCount); + + // try to display again when the in-display flag is already turned on (and not turned off yet) + [self.displayExecutor checkAndDisplayNextAppForegroundMessage]; + + // Verify that the message in display component is still m2 + XCTAssertEqualObjects(self.m2.renderData.messageID, display.message.messageID); + + // message in cache remain unchanged for the second checkAndDisplayNext call + remainingMsgCount = [self.clientMessageCache allRegularMessages].count; + XCTAssertEqual(1, remainingMsgCount); +} + +// this test case contracts testNoAnalyticsTrackingOnTestMessage to cover both positive +// and negative cases +- (void)testDoesAnalyticsTrackingOnNonTestMessage { + // This setup allows next message to be displayed from display interval perspective. + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]) + .andReturn(DISPLAY_MIN_INTERVALS * 60 + 100); + + // not expecting triggering analytics recording + OCMExpect([self.mockAnalyticsEventLogger + logAnalyticsEventForType:FIRIAMAnalyticsEventMessageImpression + forCampaignID:[OCMArg isEqual:self.m2.renderData.messageID] + withCampaignName:[OCMArg any] + eventTimeInMs:[OCMArg any] + completion:[OCMArg any]]); + [self.clientMessageCache setMessageData:@[ self.m2 ]]; + + FIRIAMMessageDisplayForTesting *display = [[FIRIAMMessageDisplayForTesting alloc] + initWithDelegateInteraction:FIRInAppMessagingDelegateInteractionClick]; + self.displayExecutor.messageDisplayComponent = display; + [self.displayExecutor checkAndDisplayNextAppForegroundMessage]; + OCMVerifyAll((id)self.mockAnalyticsEventLogger); +} + +- (void)testDoesAnalyticsTrackingOnDisplayError { + // 1000 seconds is larger than DISPLAY_MIN_INTERVALS minutes + // last display time is set to 0 by default + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]).andReturn(1000); + + // not expecting triggering analytics recording + OCMExpect([self.mockAnalyticsEventLogger + logAnalyticsEventForType:FIRIAMAnalyticsEventImageFetchError + forCampaignID:[OCMArg isEqual:self.m2.renderData.messageID] + withCampaignName:[OCMArg any] + eventTimeInMs:[OCMArg any] + completion:[OCMArg any]]); + + [self.clientMessageCache setMessageData:@[ self.m2 ]]; + + FIRIAMMessageDisplayForTesting *display = [[FIRIAMMessageDisplayForTesting alloc] + initWithDelegateInteraction:FIRInAppMessagingDelegateInteractionError]; + self.displayExecutor.messageDisplayComponent = display; + + [self.displayExecutor checkAndDisplayNextAppForegroundMessage]; + OCMVerifyAll((id)self.mockAnalyticsEventLogger); +} + +- (void)testAnalyticsTrackingOnMessageDismissCase { + // This setup allows next message to be displayed from display interval perspective. + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]) + .andReturn(DISPLAY_MIN_INTERVALS * 60 + 100); + + // not expecting triggering analytics recording + OCMExpect([self.mockAnalyticsEventLogger + logAnalyticsEventForType:FIRIAMAnalyticsEventMessageDismissAuto + forCampaignID:[OCMArg isEqual:self.m2.renderData.messageID] + withCampaignName:[OCMArg any] + eventTimeInMs:[OCMArg any] + completion:[OCMArg any]]); + + // Make sure we don't log the url follow event. + OCMReject([self.mockAnalyticsEventLogger + logAnalyticsEventForType:FIRIAMAnalyticsEventActionURLFollow + forCampaignID:[OCMArg isEqual:self.m2.renderData.messageID] + withCampaignName:[OCMArg any] + eventTimeInMs:[OCMArg any] + completion:[OCMArg any]]); + + [self.clientMessageCache setMessageData:@[ self.m2 ]]; + + FIRIAMMessageDisplayForTesting *display = [[FIRIAMMessageDisplayForTesting alloc] + initWithDelegateInteraction:FIRInAppMessagingDelegateInteractionDismiss]; + self.displayExecutor.messageDisplayComponent = display; + + [self.displayExecutor checkAndDisplayNextAppForegroundMessage]; + OCMVerifyAll((id)self.mockAnalyticsEventLogger); +} + +- (void)testAnalyticsTrackingOnMessageClickCase { + // This setup allows next message to be displayed from display interval perspective. + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]) + .andReturn(DISPLAY_MIN_INTERVALS * 60 + 100); + + // We expect two analytics events for a click action: + // An impression event and an action URL follow event + OCMExpect([self.mockAnalyticsEventLogger + logAnalyticsEventForType:FIRIAMAnalyticsEventMessageImpression + forCampaignID:[OCMArg isEqual:self.m2.renderData.messageID] + withCampaignName:[OCMArg any] + eventTimeInMs:[OCMArg any] + completion:[OCMArg any]]); + + OCMExpect([self.mockAnalyticsEventLogger + logAnalyticsEventForType:FIRIAMAnalyticsEventActionURLFollow + forCampaignID:[OCMArg isEqual:self.m2.renderData.messageID] + withCampaignName:[OCMArg any] + eventTimeInMs:[OCMArg any] + completion:[OCMArg any]]); + + [self.clientMessageCache setMessageData:@[ self.m2 ]]; + + FIRIAMMessageDisplayForTesting *display = [[FIRIAMMessageDisplayForTesting alloc] + initWithDelegateInteraction:FIRInAppMessagingDelegateInteractionClick]; + self.displayExecutor.messageDisplayComponent = display; + + [self.displayExecutor checkAndDisplayNextAppForegroundMessage]; + OCMVerifyAll((id)self.mockAnalyticsEventLogger); +} + +- (void)testAnalyticsTrackingOnTestMessageClickCase { + // 1000 seconds is larger than DISPLAY_MIN_INTERVALS minutes + // last display time is set to 0 by default + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]).andReturn(1000); + + // We expect two analytics events for a click action: + // An test message impression event and a test message click event + OCMExpect([self.mockAnalyticsEventLogger + logAnalyticsEventForType:FIRIAMAnalyticsEventTestMessageImpression + forCampaignID:[OCMArg isEqual:self.m2.renderData.messageID] + withCampaignName:[OCMArg any] + eventTimeInMs:[OCMArg any] + completion:[OCMArg any]]); + + OCMExpect([self.mockAnalyticsEventLogger + logAnalyticsEventForType:FIRIAMAnalyticsEventTestMessageClick + forCampaignID:[OCMArg isEqual:self.m2.renderData.messageID] + withCampaignName:[OCMArg any] + eventTimeInMs:[OCMArg any] + completion:[OCMArg any]]); + + FIRIAMMessageDefinition *testMessage = + [[FIRIAMMessageDefinition alloc] initTestMessageWithRenderData:self.m2.renderData]; + + [self.clientMessageCache setMessageData:@[ testMessage ]]; + + FIRIAMMessageDisplayForTesting *display = [[FIRIAMMessageDisplayForTesting alloc] + initWithDelegateInteraction:FIRInAppMessagingDelegateInteractionClick]; + self.displayExecutor.messageDisplayComponent = display; + + [self.displayExecutor checkAndDisplayNextAppForegroundMessage]; + OCMVerifyAll((id)self.mockAnalyticsEventLogger); +} + +- (void)testAnalyticsTrackingOnTestMessageDismissCase { + // 1000 seconds is larger than DISPLAY_MIN_INTERVALS minutes + // last display time is set to 0 by default + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]).andReturn(1000); + + // We expect a test message impression + OCMExpect([self.mockAnalyticsEventLogger + logAnalyticsEventForType:FIRIAMAnalyticsEventTestMessageImpression + forCampaignID:[OCMArg isEqual:self.m2.renderData.messageID] + withCampaignName:[OCMArg any] + eventTimeInMs:[OCMArg any] + completion:[OCMArg any]]); + // No click event + OCMReject([self.mockAnalyticsEventLogger + logAnalyticsEventForType:FIRIAMAnalyticsEventTestMessageClick + forCampaignID:[OCMArg isEqual:self.m2.renderData.messageID] + withCampaignName:[OCMArg any] + eventTimeInMs:[OCMArg any] + completion:[OCMArg any]]); + + FIRIAMMessageDefinition *testMessage = + [[FIRIAMMessageDefinition alloc] initTestMessageWithRenderData:self.m2.renderData]; + + [self.clientMessageCache setMessageData:@[ testMessage ]]; + + FIRIAMMessageDisplayForTesting *display = [[FIRIAMMessageDisplayForTesting alloc] + initWithDelegateInteraction:FIRInAppMessagingDelegateInteractionDismiss]; + self.displayExecutor.messageDisplayComponent = display; + + [self.displayExecutor checkAndDisplayNextAppForegroundMessage]; + OCMVerifyAll((id)self.mockAnalyticsEventLogger); +} + +- (void)testAnalyticsTrackingImpressionOnValidImpressionDetectedCase { + // This setup allows next message to be displayed from display interval perspective. + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]) + .andReturn(DISPLAY_MIN_INTERVALS * 60 + 100); + + // not expecting triggering analytics recording + OCMExpect([self.mockAnalyticsEventLogger + logAnalyticsEventForType:FIRIAMAnalyticsEventMessageImpression + forCampaignID:[OCMArg isEqual:self.m2.renderData.messageID] + withCampaignName:[OCMArg any] + eventTimeInMs:[OCMArg any] + completion:[OCMArg any]]); + + [self.clientMessageCache setMessageData:@[ self.m2 ]]; + + FIRIAMMessageDisplayForTesting *display = [[FIRIAMMessageDisplayForTesting alloc] + initWithDelegateInteraction:FIRInAppMessagingDelegateInteractionImpressionDetected]; + self.displayExecutor.messageDisplayComponent = display; + + [self.displayExecutor checkAndDisplayNextAppForegroundMessage]; + OCMVerifyAll((id)self.mockAnalyticsEventLogger); +} + +- (void)testNoAnalyticsTrackingOnTestMessage { + // This setup allows next message to be displayed from display interval perspective. + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]) + .andReturn(DISPLAY_MIN_INTERVALS * 60 + 100); + + FIRIAMMessageDefinition *testMessage = + [[FIRIAMMessageDefinition alloc] initTestMessageWithRenderData:self.m1.renderData]; + + // not expecting triggering analytics recording + OCMReject([self.mockAnalyticsEventLogger + logAnalyticsEventForType:FIRIAMAnalyticsEventMessageImpression + forCampaignID:[OCMArg isEqual:self.m1.renderData.messageID] + withCampaignName:[OCMArg any] + eventTimeInMs:[OCMArg any] + completion:[OCMArg any]]); + [self.clientMessageCache setMessageData:@[ testMessage ]]; + + FIRIAMMessageDisplayForTesting *display = [[FIRIAMMessageDisplayForTesting alloc] + initWithDelegateInteraction:FIRInAppMessagingDelegateInteractionClick]; + self.displayExecutor.messageDisplayComponent = display; + [self.displayExecutor checkAndDisplayNextAppForegroundMessage]; + OCMVerifyAll((id)self.mockAnalyticsEventLogger); +} + +- (void)testNoMessageAvailableCase { + // This setup allows next message to be displayed from display interval perspective. + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]) + .andReturn(DISPLAY_MIN_INTERVALS * 60 + 100); + + FIRIAMMessageDisplayForTesting *display = [[FIRIAMMessageDisplayForTesting alloc] + initWithDelegateInteraction:FIRInAppMessagingDelegateInteractionClick]; + self.displayExecutor.messageDisplayComponent = display; + [self.clientMessageCache setMessageData:@[]]; + [self.displayExecutor checkAndDisplayNextAppForegroundMessage]; + + // No display has happened so the message stored in the display component should be nil + XCTAssertNil(display.message); + NSInteger remainingMsgCount = [self.clientMessageCache allRegularMessages].count; + XCTAssertEqual(0, remainingMsgCount); +} + +- (void)testIntervalBetweenOnAppOpenDisplays { + self.displaySetting.displayMinIntervalInMinutes = 10; + + // last display time is set to 0 by default + // 10 seconds is not long enough for satisfying the 10-min internal requirement + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]).andReturn(10); + + FIRIAMMessageDisplayForTesting *display = [[FIRIAMMessageDisplayForTesting alloc] + initWithDelegateInteraction:FIRInAppMessagingDelegateInteractionClick]; + self.displayExecutor.messageDisplayComponent = display; + + [self.clientMessageCache setMessageData:@[ self.m1 ]]; + [self.displayExecutor checkAndDisplayNextAppForegroundMessage]; + + NSInteger remainingMsgCount = [self.clientMessageCache allRegularMessages].count; + // No display has happened so the message stored in the display component should be nil + XCTAssertNil(display.message); + + // still got one in the queue + XCTAssertEqual(1, remainingMsgCount); +} + +// making sure that we match on the event names for analytics based events +- (void)testOnFirebaseAnalyticsEventDisplayMessages { + // This setup allows next message to be displayed from display interval perspective. + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]) + .andReturn(DISPLAY_MIN_INTERVALS * 60 + 100); + + FIRIAMMessageDisplayForTesting *display = [[FIRIAMMessageDisplayForTesting alloc] + initWithDelegateInteraction:FIRInAppMessagingDelegateInteractionClick]; + self.displayExecutor.messageDisplayComponent = display; + + // m1 and m3 are messages triggered by 'test_event' analytics events + [self.clientMessageCache setMessageData:@[ self.m1, self.m3 ]]; + + [self.displayExecutor checkAndDisplayNextContextualMessageForAnalyticsEvent:@"different event"]; + NSInteger remainingMsgCount = [self.clientMessageCache allRegularMessages].count; + + // No message matching event "different event", so no message is nil + XCTAssertNil(display.message); + // still got 2 in the queue + XCTAssertEqual(2, remainingMsgCount); + + // now trigger it with 'test_event' and we would expect one message to be displayed and removed + // from cache + [self.displayExecutor checkAndDisplayNextContextualMessageForAnalyticsEvent:@"test_event"]; + // Expecting the m1 being used for display + XCTAssertEqualObjects(self.m1.renderData.messageID, display.message.messageID); + + remainingMsgCount = [self.clientMessageCache allRegularMessages].count; + + // Now only one message remaining in the queue + XCTAssertEqual(1, remainingMsgCount); +} + +// no regular message rendering if suppress message display flag is turned on +- (void)testNoRenderingIfMessageDisplayIsSuppressed { + // This setup allows next message to be displayed from display interval perspective. + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]) + .andReturn(DISPLAY_MIN_INTERVALS * 60 + 100); + + [self.clientMessageCache setMessageData:@[ self.m2, self.m4 ]]; + + FIRIAMMessageDisplayForTesting *display = [[FIRIAMMessageDisplayForTesting alloc] + initWithDelegateInteraction:FIRInAppMessagingDelegateInteractionClick]; + self.displayExecutor.messageDisplayComponent = display; + self.displayExecutor.suppressMessageDisplay = YES; + [self.displayExecutor checkAndDisplayNextAppForegroundMessage]; + + // no message display has happened + XCTAssertNil(display.message); + + NSInteger remainingMsgCount = [self.clientMessageCache allRegularMessages].count; + // no message is removed from the cache + XCTAssertEqual(2, remainingMsgCount); + + // now allow message rendering again + self.displayExecutor.suppressMessageDisplay = NO; + [self.displayExecutor checkAndDisplayNextAppForegroundMessage]; + NSInteger remainingMsgCount2 = [self.clientMessageCache allRegularMessages].count; + // one message was rendered and removed from the cache + XCTAssertEqual(1, remainingMsgCount2); +} + +// No contextual message rendering if suppress message display flag is turned on +- (void)testNoContextualMsgRenderingIfMessageDisplayIsSuppressed { + // This setup allows next message to be displayed from display interval perspective. + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]) + .andReturn(DISPLAY_MIN_INTERVALS * 60 + 100); + + [self.clientMessageCache setMessageData:@[ self.m1, self.m3 ]]; + + FIRIAMMessageDisplayForTesting *display = [[FIRIAMMessageDisplayForTesting alloc] + initWithDelegateInteraction:FIRInAppMessagingDelegateInteractionClick]; + self.displayExecutor.messageDisplayComponent = display; + + self.displayExecutor.suppressMessageDisplay = YES; + [self.displayExecutor checkAndDisplayNextContextualMessageForAnalyticsEvent:@"test_event"]; + + // no message display has happened + XCTAssertNil(display.message); + + NSInteger remainingMsgCount = [self.clientMessageCache allRegularMessages].count; + // No message is removed from the cache. + XCTAssertEqual(2, remainingMsgCount); + + // now re-enable message rendering again + self.displayExecutor.suppressMessageDisplay = NO; + [self.displayExecutor checkAndDisplayNextContextualMessageForAnalyticsEvent:@"test_event"]; + + NSInteger remainingMsgCount2 = [self.clientMessageCache allRegularMessages].count; + // one message was rendered and removed from the cache + XCTAssertEqual(1, remainingMsgCount2); +} +@end diff --git a/InAppMessaging/Example/Tests/FIRIAMElapsedTimeTrackerTests.m b/InAppMessaging/Example/Tests/FIRIAMElapsedTimeTrackerTests.m new file mode 100644 index 0000000000..1e69b430d0 --- /dev/null +++ b/InAppMessaging/Example/Tests/FIRIAMElapsedTimeTrackerTests.m @@ -0,0 +1,72 @@ +/* + * 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 +#import + +#import "FIRIAMElapsedTimeTracker.h" + +@interface FIRIAMElapsedTimeTrackerTests : XCTestCase +@property id mockTimeFetcher; +@property FIRIAMElapsedTimeTracker *tracker; + +@end + +@implementation FIRIAMElapsedTimeTrackerTests + +- (void)setUp { + [super setUp]; + self.mockTimeFetcher = OCMProtocolMock(@protocol(FIRIAMTimeFetcher)); +} + +- (void)tearDown { + // Put teardown code here. This method is called after the invocation of each test method in the + // class. + [super tearDown]; +} + +- (void)testTrackingTimeWithPauses { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + + // set up the time moments to be returned + // 0 start + // 15 pause + // 20 resume + // 30 measure the total tracked time + // given the above sequence, + // at time = 30 seconds, we expect the tracked time to be 15 + (30 - 20) = 25 seconds + + NSArray *currentTimes = @[ + [NSNumber numberWithDouble:0], [NSNumber numberWithDouble:15], [NSNumber numberWithDouble:20], + [NSNumber numberWithDouble:30] + ]; + __block int nextTimeToReturn = 0; + + // start with timestamp as 0 + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]).andDo(^(NSInvocation *invocation) { + NSTimeInterval time = [currentTimes[nextTimeToReturn++] doubleValue]; + [invocation setReturnValue:&time]; + }); + + self.tracker = [[FIRIAMElapsedTimeTracker alloc] initWithTimeFetcher:_mockTimeFetcher]; + [self.tracker pause]; + [self.tracker resume]; + + NSTimeInterval trackedTime = [self.tracker trackedTimeSoFar]; + XCTAssertEqualWithAccuracy(25, trackedTime, 0.01); +} +@end diff --git a/InAppMessaging/Example/Tests/FIRIAMFetchFlowTests.m b/InAppMessaging/Example/Tests/FIRIAMFetchFlowTests.m new file mode 100644 index 0000000000..65eaffee46 --- /dev/null +++ b/InAppMessaging/Example/Tests/FIRIAMFetchFlowTests.m @@ -0,0 +1,331 @@ +/* + * 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 + +#import + +#import "FIRIAMAnalyticsEventLogger.h" +#import "FIRIAMDisplayTriggerDefinition.h" +#import "FIRIAMFetchFlow.h" +#import "FIRIAMMessageContentDataWithImageURL.h" +#import "FIRIAMSDKModeManager.h" + +@interface FIRIAMFetchFlowTests : XCTestCase +@property(nonatomic) FIRIAMFetchSetting *fetchSetting; +@property FIRIAMMessageClientCache *clientMessageCache; +@property id mockMessageFetcher; +@property id mockBookkeeper; +@property id mockTimeFetcher; +@property FIRIAMFetchFlow *flow; +@property FIRIAMActivityLogger *activityLogger; +@property FIRIAMSDKModeManager *mockSDKModeManager; + +@property id mockAnaltycisEventLogger; + +// three pre-defined messages +@property FIRIAMMessageDefinition *m1, *m2, *m3; +@end + +CGFloat FETCH_MIN_INTERVALS = 1; + +@implementation FIRIAMFetchFlowTests +- (void)setupMessageTexture { + // startTime, endTime here ensures messages with them are active + NSTimeInterval activeStartTime = 0; + NSTimeInterval activeEndTime = [[NSDate date] timeIntervalSince1970] + 10000; + + FIRIAMDisplayTriggerDefinition *triggerDefinition = + [[FIRIAMDisplayTriggerDefinition alloc] initWithFirebaseAnalyticEvent:@"test_event"]; + + FIRIAMMessageContentDataWithImageURL *m1ContentData = + [[FIRIAMMessageContentDataWithImageURL alloc] + initWithMessageTitle:@"m1 title" + messageBody:@"message body" + actionButtonText:nil + actionURL:[NSURL URLWithString:@"http://google.com"] + imageURL:[NSURL URLWithString:@"https://unsplash.it/300/300"] + usingURLSession:nil]; + + FIRIAMRenderingEffectSetting *renderSetting1 = + [FIRIAMRenderingEffectSetting getDefaultRenderingEffectSetting]; + renderSetting1.viewMode = FIRIAMRenderAsBannerView; + + FIRIAMMessageRenderData *renderData1 = + [[FIRIAMMessageRenderData alloc] initWithMessageID:@"m1" + messageName:@"name" + contentData:m1ContentData + renderingEffect:renderSetting1]; + + self.m1 = [[FIRIAMMessageDefinition alloc] initWithRenderData:renderData1 + startTime:activeStartTime + endTime:activeEndTime + triggerDefinition:@[ triggerDefinition ]]; + + FIRIAMMessageContentDataWithImageURL *m2ContentData = + [[FIRIAMMessageContentDataWithImageURL alloc] + initWithMessageTitle:@"m2 title" + messageBody:@"message body" + actionButtonText:nil + actionURL:[NSURL URLWithString:@"http://google.com"] + imageURL:[NSURL URLWithString:@"https://unsplash.it/300/400"] + usingURLSession:nil]; + + FIRIAMRenderingEffectSetting *renderSetting2 = + [FIRIAMRenderingEffectSetting getDefaultRenderingEffectSetting]; + renderSetting2.viewMode = FIRIAMRenderAsModalView; + + FIRIAMMessageRenderData *renderData2 = + [[FIRIAMMessageRenderData alloc] initWithMessageID:@"m2" + messageName:@"name" + contentData:m2ContentData + renderingEffect:renderSetting2]; + + self.m2 = [[FIRIAMMessageDefinition alloc] initWithRenderData:renderData2 + startTime:activeStartTime + endTime:activeEndTime + triggerDefinition:@[ triggerDefinition ]]; + + FIRIAMMessageContentDataWithImageURL *m3ContentData = + [[FIRIAMMessageContentDataWithImageURL alloc] + initWithMessageTitle:@"m3 title" + messageBody:@"message body" + actionButtonText:nil + actionURL:[NSURL URLWithString:@"http://google.com"] + imageURL:[NSURL URLWithString:@"https://unsplash.it/400/300"] + usingURLSession:nil]; + + FIRIAMRenderingEffectSetting *renderSetting3 = + [FIRIAMRenderingEffectSetting getDefaultRenderingEffectSetting]; + renderSetting3.viewMode = FIRIAMRenderAsImageOnlyView; + + FIRIAMMessageRenderData *renderData3 = + [[FIRIAMMessageRenderData alloc] initWithMessageID:@"m3" + messageName:@"name" + contentData:m3ContentData + renderingEffect:renderSetting3]; + + self.m3 = [[FIRIAMMessageDefinition alloc] initWithRenderData:renderData3 + startTime:activeStartTime + endTime:activeEndTime + triggerDefinition:@[ triggerDefinition ]]; +} + +- (void)setUp { + [super setUp]; + [self setupMessageTexture]; + + self.fetchSetting = [[FIRIAMFetchSetting alloc] init]; + self.fetchSetting.fetchMinIntervalInMinutes = FETCH_MIN_INTERVALS; + self.mockMessageFetcher = OCMProtocolMock(@protocol(FIRIAMMessageFetcher)); + + FIRIAMFetchResponseParser *parser = + [[FIRIAMFetchResponseParser alloc] initWithTimeFetcher:[[FIRIAMTimerWithNSDate alloc] init]]; + + self.clientMessageCache = [[FIRIAMMessageClientCache alloc] initWithBookkeeper:self.mockBookkeeper + usingResponseParser:parser]; + self.mockTimeFetcher = OCMProtocolMock(@protocol(FIRIAMTimeFetcher)); + self.mockBookkeeper = OCMProtocolMock(@protocol(FIRIAMBookKeeper)); + self.activityLogger = OCMClassMock([FIRIAMActivityLogger class]); + self.mockAnaltycisEventLogger = OCMProtocolMock(@protocol(FIRIAMAnalyticsEventLogger)); + + self.mockSDKModeManager = OCMClassMock([FIRIAMSDKModeManager class]); + + self.mockTimeFetcher = OCMProtocolMock(@protocol(FIRIAMTimeFetcher)); + + self.flow = [[FIRIAMFetchFlow alloc] initWithSetting:self.fetchSetting + messageCache:self.clientMessageCache + messageFetcher:self.mockMessageFetcher + timeFetcher:self.mockTimeFetcher + bookKeeper:self.mockBookkeeper + activityLogger:self.activityLogger + analyticsEventLogger:self.mockAnaltycisEventLogger + FIRIAMSDKModeManager:self.mockSDKModeManager]; +} + +- (void)tearDown { + // Put teardown code here. This method is called after the invocation of each test method in the + // class. + [super tearDown]; +} + +// In happy path, the fetch is allowed and we are able to fetch two messages back +- (void)testHappyPath { + OCMStub([self.mockBookkeeper lastFetchTime]).andReturn(0); + + // Set it up so that we already have impressions for m1 and m3 + FIRIAMImpressionRecord *impression1 = + [[FIRIAMImpressionRecord alloc] initWithMessageID:self.m1.renderData.messageID + impressionTimeInSeconds:1233]; + + FIRIAMImpressionRecord *impression2 = [[FIRIAMImpressionRecord alloc] initWithMessageID:@"m3" + impressionTimeInSeconds:5678]; + + NSArray *impressions = @[ impression1, impression2 ]; + OCMStub([self.mockBookkeeper getImpressions]).andReturn(impressions); + + NSArray *fetchedMessages = @[ self.m1, self.m2 ]; + + // 200 seconds is larger than fetch wait time which is 100 in this setup + OCMStub([self.mockBookkeeper nextFetchWaitTime]).andReturn(100); + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]).andReturn(200); + + OCMStub([self.mockSDKModeManager currentMode]).andReturn(FIRIAMSDKModeRegular); + + NSNumber *fetchWaitTimeFromResponse = [NSNumber numberWithInt:2000]; + + OCMStub([self.mockMessageFetcher + fetchMessagesWithImpressionList:[OCMArg any] + withCompletion:([OCMArg invokeBlockWithArgs:fetchedMessages, + fetchWaitTimeFromResponse, + [NSNull null], [NSNull null], + nil])]); + [self.flow checkAndFetch]; + + // We expect m1 and m2 to be dumped into clientMessageCache. + NSArray *foundMessages = [self.clientMessageCache allRegularMessages]; + XCTAssertEqual(2, foundMessages.count); + XCTAssertEqualObjects(foundMessages[0].renderData.messageID, self.m1.renderData.messageID); + XCTAssertEqualObjects(foundMessages[1].renderData.messageID, self.m2.renderData.messageID); + + // Verify that we record the new fetch with bookkeeper + OCMVerify([self.mockBookkeeper recordNewFetchWithFetchCount:2 + withTimestampInSeconds:200 + nextFetchWaitTime:fetchWaitTimeFromResponse]); + + // So we are sending the request with impression for m1 and m3 and getting back messages for m1 + // and m2. In here m1 is a recurring message and after the fetch, we should call + // book keeper's clearImpressionsWithMessageList: method with m1 which is an intersection + // between the request impression list and the response message id list. We are skipping + // m2 since it's not included in the impression records sent along with the request. + OCMVerify( + [self.mockBookkeeper clearImpressionsWithMessageList:@[ self.m1.renderData.messageID ]]); +} + +// No fetch is to be performed if the required fetch interval is not met +- (void)testNoFetchDueToIntervalConstraint { + OCMStub([self.mockSDKModeManager currentMode]).andReturn(FIRIAMSDKModeRegular); + + // We need to wait at least 300 seconds before making another fetch + OCMStub([self.mockBookkeeper nextFetchWaitTime]).andReturn(300); + + // And it's only been 200 seconds since last fetch, so no fetch should happen + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]).andReturn(200); + OCMStub([self.mockBookkeeper lastFetchTime]).andReturn(0); + + // We don't expect fetchMessages: for self.mockMessageFetcher to be triggred + OCMReject([self.mockMessageFetcher fetchMessagesWithImpressionList:[OCMArg any] + withCompletion:[OCMArg any]]); + [self.flow checkAndFetch]; + + NSArray *foundMessages = [self.clientMessageCache allRegularMessages]; + XCTAssertEqual(0, foundMessages.count); +} + +// Fetch always in newly installed mode +- (void)testAlwaysFetchForNewlyInstalledMode { + OCMStub([self.mockBookkeeper lastFetchTime]).andReturn(0); + OCMStub([self.mockSDKModeManager currentMode]).andReturn(FIRIAMSDKModeNewlyInstalled); + OCMStub([self.mockMessageFetcher + fetchMessagesWithImpressionList:[OCMArg any] + withCompletion:([OCMArg invokeBlockWithArgs:@[ self.m1, self.m2 ], + [NSNull null], [NSNull null], + [NSNull null], nil])]); + + // 100 seconds is less than fetch wait time which is 1000 in this setup, + // but since we are in newly installed mode, fetch would still happen + OCMStub([self.mockBookkeeper nextFetchWaitTime]).andReturn(1000); + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]).andReturn(100); + + [self.flow checkAndFetch]; + + // we expect m1 and m2 to be dumped into clientMessageCache + NSArray *foundMessages = [self.clientMessageCache allRegularMessages]; + XCTAssertEqual(2, foundMessages.count); + XCTAssertEqualObjects(foundMessages[0].renderData.messageID, self.m1.renderData.messageID); + XCTAssertEqualObjects(foundMessages[1].renderData.messageID, self.m2.renderData.messageID); + + // we expect to register a fetch with sdk manager + OCMVerify([self.mockSDKModeManager registerOneMoreFetch]); +} + +// Fetch always in testing app instance mode +- (void)testAlwaysFetchForTestingAppInstanceMode { + OCMStub([self.mockBookkeeper lastFetchTime]).andReturn(0); + OCMStub([self.mockSDKModeManager currentMode]).andReturn(FIRIAMSDKModeTesting); + OCMStub([self.mockMessageFetcher + fetchMessagesWithImpressionList:[OCMArg any] + withCompletion:([OCMArg invokeBlockWithArgs:@[ self.m1, self.m2 ], + [NSNull null], [NSNull null], + [NSNull null], nil])]); + // 100 seconds is less than fetch wait time which is 1000 in this setup, + // but since we are in testing app instance mode, fetch would still happen + OCMStub([self.mockBookkeeper nextFetchWaitTime]).andReturn(1000); + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]).andReturn(100); + + [self.flow checkAndFetch]; + + // we expect m1 and m2 to be dumped into clientMessageCache + NSArray *foundMessages = [self.clientMessageCache allRegularMessages]; + XCTAssertEqual(2, foundMessages.count); + XCTAssertEqualObjects(foundMessages[0].renderData.messageID, self.m1.renderData.messageID); + XCTAssertEqualObjects(foundMessages[1].renderData.messageID, self.m2.renderData.messageID); + + // we expect to register a fetch with sdk manager + OCMVerify([self.mockSDKModeManager registerOneMoreFetch]); +} + +- (void)testTurnIntoTestigModeOnSeeingTestMessage { + OCMStub([self.mockBookkeeper lastFetchTime]).andReturn(0); + OCMStub([self.mockSDKModeManager currentMode]).andReturn(FIRIAMSDKModeNewlyInstalled); + + FIRIAMMessageDefinition *testMessage = + [[FIRIAMMessageDefinition alloc] initTestMessageWithRenderData:self.m2.renderData]; + + OCMStub([self.mockMessageFetcher + fetchMessagesWithImpressionList:[OCMArg any] + withCompletion:([OCMArg invokeBlockWithArgs:@[ self.m1, testMessage ], + [NSNull null], [NSNull null], + [NSNull null], nil])]); + self.fetchSetting.fetchMinIntervalInMinutes = 10; // at least 600 seconds between fetches + // 100 seconds is larger than FETCH_MIN_INTERVALS minutes + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]).andReturn(100); + + [self.flow checkAndFetch]; + + // Expecting turning sdk mode into a testing instance + OCMVerify([self.mockSDKModeManager becomeTestingInstance]); +} + +- (void)testNotTurningIntoTestingModeIfAlreadyInTestingMode { + OCMStub([self.mockBookkeeper lastFetchTime]).andReturn(0); + OCMStub([self.mockSDKModeManager currentMode]).andReturn(FIRIAMSDKModeTesting); + + FIRIAMMessageDefinition *testMessage = + [[FIRIAMMessageDefinition alloc] initTestMessageWithRenderData:self.m2.renderData]; + + OCMStub([self.mockMessageFetcher + fetchMessagesWithImpressionList:[OCMArg any] + withCompletion:([OCMArg invokeBlockWithArgs:@[ self.m1, testMessage ], + [NSNull null], [NSNull null], + [NSNull null], nil])]); + self.fetchSetting.fetchMinIntervalInMinutes = 10; // at least 600 seconds between fetches + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]).andReturn(1000); + OCMReject([self.mockSDKModeManager becomeTestingInstance]); + + [self.flow checkAndFetch]; +} +@end diff --git a/InAppMessaging/Example/Tests/FIRIAMFetchResponseParserTests.m b/InAppMessaging/Example/Tests/FIRIAMFetchResponseParserTests.m new file mode 100644 index 0000000000..0365d072d8 --- /dev/null +++ b/InAppMessaging/Example/Tests/FIRIAMFetchResponseParserTests.m @@ -0,0 +1,180 @@ +/* + * 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 +#import + +#import "FIRIAMDisplayTriggerDefinition.h" +#import "FIRIAMFetchResponseParser.h" +#import "FIRIAMMessageContentDataWithImageURL.h" +#import "FIRIAMMessageDefinition.h" +#import "FIRIAMTimeFetcher.h" +#import "UIColor+FIRIAMHexString.h" + +@interface FIRIAMFetchResponseParserTests : XCTestCase +@property(nonatomic, copy) NSString *jsonResposne; +@property(nonatomic) FIRIAMFetchResponseParser *parser; +@property(nonatomic) id mockTimeFetcher; +@end + +@implementation FIRIAMFetchResponseParserTests + +- (void)setUp { + [super setUp]; + self.mockTimeFetcher = OCMProtocolMock(@protocol(FIRIAMTimeFetcher)); + self.parser = [[FIRIAMFetchResponseParser alloc] initWithTimeFetcher:self.mockTimeFetcher]; +} +- (void)tearDown { + [super tearDown]; +} + +- (void)testRegularConversion { + NSString *testJsonDataFilePath = + [[NSBundle bundleForClass:[self class]] pathForResource:@"TestJsonDataFromFetch" + ofType:@"txt"]; + + NSTimeInterval currentMoment = 100000000; + OCMStub([self.mockTimeFetcher currentTimestampInSeconds]).andReturn(currentMoment); + + self.jsonResposne = [[NSString alloc] initWithContentsOfFile:testJsonDataFilePath + encoding:NSUTF8StringEncoding + error:nil]; + + NSData *data = [self.jsonResposne dataUsingEncoding:NSUTF8StringEncoding]; + NSError *errorJson = nil; + NSDictionary *responseDict = [NSJSONSerialization JSONObjectWithData:data + options:kNilOptions + error:&errorJson]; + + NSInteger discardCount; + NSNumber *fetchWaitTime; + NSArray *results = + [self.parser parseAPIResponseDictionary:responseDict + discardedMsgCount:&discardCount + fetchWaitTimeInSeconds:&fetchWaitTime]; + + double nextFetchEpochTimeInResponse = + [responseDict[@"expirationEpochTimestampMillis"] doubleValue]; + + // fetch wait time should be (next fetch epoch time - curret moment) + XCTAssertEqualWithAccuracy([fetchWaitTime doubleValue], + nextFetchEpochTimeInResponse / 1000 - currentMoment, 0.1); + + XCTAssertEqual(4, [results count]); + XCTAssertEqual(0, discardCount); + + FIRIAMMessageDefinition *first = results[0]; + XCTAssertEqualObjects(@"13313766398414028800", first.renderData.messageID); + XCTAssertEqualObjects(@"first campaign", first.renderData.name); + XCTAssertEqualObjects(@"I heard you like In-App Messages", + first.renderData.contentData.titleText); + XCTAssertEqualObjects(@"This is message body", first.renderData.contentData.bodyText); + XCTAssertEqual(FIRIAMRenderAsModalView, first.renderData.renderingEffectSettings.viewMode); + XCTAssertEqualWithAccuracy(1523986039, first.startTime, 0.1); + XCTAssertEqualWithAccuracy(1526986039, first.endTime, 0.1); + XCTAssertNotNil(first.renderData.renderingEffectSettings.textColor); + XCTAssertEqualObjects(first.renderData.renderingEffectSettings.displayBGColor, + [UIColor firiam_colorWithHexString:@"#fffff8"]); + XCTAssertEqualObjects(first.renderData.renderingEffectSettings.btnBGColor, + [UIColor firiam_colorWithHexString:@"#000000"]); + XCTAssertEqualObjects(first.renderData.contentData.actionURL.absoluteString, + @"https://www.google.com"); + XCTAssertEqual(FIRIAMRenderTriggerOnAppForeground, first.renderTriggers[0].triggerType); + + FIRIAMMessageDefinition *second = results[1]; + XCTAssertEqualObjects(@"9350598726327992320", second.renderData.messageID); + XCTAssertEqualObjects(@"Inception1", second.renderData.name); + XCTAssertEqualObjects(@"Test 2", second.renderData.contentData.titleText); + XCTAssertNil(second.renderData.contentData.bodyText); + XCTAssertEqual(FIRIAMRenderAsModalView, second.renderData.renderingEffectSettings.viewMode); + XCTAssertEqual(2, second.renderTriggers.count); + + XCTAssertEqualObjects(second.renderData.renderingEffectSettings.displayBGColor, + [UIColor firiam_colorWithHexString:@"#ffffff"]); + + // Third message is a banner view message based on a analytics event trigger. + FIRIAMMessageDefinition *third = results[2]; + XCTAssertEqualObjects(@"14819094573862617088", third.renderData.messageID); + XCTAssertEqual(FIRIAMRenderAsBannerView, third.renderData.renderingEffectSettings.viewMode); + XCTAssertEqual(1, third.renderTriggers.count); + XCTAssertEqualObjects(@"jackpot", third.renderTriggers[0].firebaseEventName); +} + +- (void)testParsingTestMessage { + NSString *testJsonDataFilePath = [[NSBundle bundleForClass:[self class]] + pathForResource:@"TestJsonDataWithTestMessageFromFetch" + ofType:@"txt"]; + + self.jsonResposne = [[NSString alloc] initWithContentsOfFile:testJsonDataFilePath + encoding:NSUTF8StringEncoding + error:nil]; + + NSData *data = [self.jsonResposne dataUsingEncoding:NSUTF8StringEncoding]; + NSError *errorJson = nil; + NSDictionary *responseDict = [NSJSONSerialization JSONObjectWithData:data + options:kNilOptions + error:&errorJson]; + + NSInteger discardCount; + NSNumber *fetchWaitTime; + NSArray *results = + [self.parser parseAPIResponseDictionary:responseDict + discardedMsgCount:&discardCount + fetchWaitTimeInSeconds:&fetchWaitTime]; + + // In our fixture file used in this test, there is no fetch expiration time + XCTAssertNil(fetchWaitTime); + + XCTAssertEqual(2, [results count]); + XCTAssertEqual(0, discardCount); + + // First is a test message and the second one is not. + XCTAssertTrue(results[0].isTestMessage); + XCTAssertTrue(results[0].renderData.renderingEffectSettings.isTestMessage); + + XCTAssertFalse(results[1].isTestMessage); + XCTAssertFalse(results[1].renderData.renderingEffectSettings.isTestMessage); +} + +- (void)testParsingInvalidTestMessageNodes { + NSString *testJsonDataFilePath = [[NSBundle bundleForClass:[self class]] + pathForResource:@"JsonDataWithInvalidMessagesFromFetch" + ofType:@"txt"]; + + self.jsonResposne = [[NSString alloc] initWithContentsOfFile:testJsonDataFilePath + encoding:NSUTF8StringEncoding + error:nil]; + + NSData *data = [self.jsonResposne dataUsingEncoding:NSUTF8StringEncoding]; + NSError *errorJson = nil; + NSDictionary *responseDict = [NSJSONSerialization JSONObjectWithData:data + options:kNilOptions + error:&errorJson]; + + NSInteger discardCount; + NSNumber *fetchWaitTime; + NSArray *results = + [self.parser parseAPIResponseDictionary:responseDict + discardedMsgCount:&discardCount + fetchWaitTimeInSeconds:&fetchWaitTime]; + + XCTAssertEqual(0, [results count]); + + // First node missing title, second one missig triggering conditions and the third one + // contains invalid type node. + XCTAssertEqual(3, discardCount); +} + +@end diff --git a/InAppMessaging/Example/Tests/FIRIAMMessageClientCacheTests.m b/InAppMessaging/Example/Tests/FIRIAMMessageClientCacheTests.m new file mode 100644 index 0000000000..c0b95c539a --- /dev/null +++ b/InAppMessaging/Example/Tests/FIRIAMMessageClientCacheTests.m @@ -0,0 +1,378 @@ +/* + * 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 +#import + +#import "FIRIAMDisplayCheckOnAnalyticEventsFlow.h" +#import "FIRIAMDisplayTriggerDefinition.h" +#import "FIRIAMMessageClientCache.h" +#import "FIRIAMMessageContentDataWithImageURL.h" +#import "FIRIAMMessageDefinition.h" +#import "FIRIAMTimeFetcher.h" + +@interface FIRIAMMessageClientCacheTests : XCTestCase +@property id mockBookkeeper; +@property(nonatomic) FIRIAMMessageClientCache *clientCache; +@end + +@interface FIRIAMMessageClientCache () +// for the purpose of unit testing validations +@property(nonatomic) NSMutableSet *firebaseAnalyticEventsToWatch; +@end + +@implementation FIRIAMMessageClientCacheTests { + // some predefined message definitions that are handy for certain test cases + FIRIAMMessageDefinition *m1, *m2, *m3, *m4, *m5; +} + +- (void)setUp { + [super setUp]; + self.mockBookkeeper = OCMProtocolMock(@protocol(FIRIAMBookKeeper)); + + FIRIAMFetchResponseParser *parser = + [[FIRIAMFetchResponseParser alloc] initWithTimeFetcher:[[FIRIAMTimerWithNSDate alloc] init]]; + self.clientCache = [[FIRIAMMessageClientCache alloc] initWithBookkeeper:self.mockBookkeeper + usingResponseParser:parser]; + + // startTime, endTime here ensures messages with them are active + NSTimeInterval activeStartTime = 0; + NSTimeInterval activeEndTime = [[NSDate date] timeIntervalSince1970] + 10000; + // m2 & m 4 will be of contextual trigger + FIRIAMDisplayTriggerDefinition *contextualTriggerDefinition = + [[FIRIAMDisplayTriggerDefinition alloc] initWithFirebaseAnalyticEvent:@"test_event"]; + + FIRIAMDisplayTriggerDefinition *contextualTriggerDefinition2 = + [[FIRIAMDisplayTriggerDefinition alloc] initWithFirebaseAnalyticEvent:@"second_event"]; + + // m1 and m3 will be of app open trigger + FIRIAMDisplayTriggerDefinition *appOpentriggerDefinition = + [[FIRIAMDisplayTriggerDefinition alloc] initForAppForegroundTrigger]; + + FIRIAMMessageContentDataWithImageURL *msgContentData = + [[FIRIAMMessageContentDataWithImageURL alloc] + initWithMessageTitle:@"title" + messageBody:@"message body" + actionButtonText:nil + actionURL:[NSURL URLWithString:@"http://google.com"] + imageURL:[NSURL URLWithString:@"https://unsplash.it/300/300"] + usingURLSession:nil]; + + FIRIAMRenderingEffectSetting *renderSetting = + [FIRIAMRenderingEffectSetting getDefaultRenderingEffectSetting]; + renderSetting.viewMode = FIRIAMRenderAsBannerView; + + FIRIAMMessageRenderData *renderData1 = + [[FIRIAMMessageRenderData alloc] initWithMessageID:@"m1" + messageName:@"name" + contentData:msgContentData + renderingEffect:renderSetting]; + + m1 = [[FIRIAMMessageDefinition alloc] initWithRenderData:renderData1 + startTime:activeStartTime + endTime:activeEndTime + triggerDefinition:@[ appOpentriggerDefinition ]]; + + FIRIAMMessageRenderData *renderData2 = + [[FIRIAMMessageRenderData alloc] initWithMessageID:@"m2" + messageName:@"name" + contentData:msgContentData + renderingEffect:renderSetting]; + + m2 = [[FIRIAMMessageDefinition alloc] initWithRenderData:renderData2 + startTime:activeStartTime + endTime:activeEndTime + triggerDefinition:@[ contextualTriggerDefinition ]]; + + FIRIAMMessageRenderData *renderData3 = + [[FIRIAMMessageRenderData alloc] initWithMessageID:@"m3" + messageName:@"name" + contentData:msgContentData + renderingEffect:renderSetting]; + + m3 = [[FIRIAMMessageDefinition alloc] initWithRenderData:renderData3 + startTime:activeStartTime + endTime:activeEndTime + triggerDefinition:@[ appOpentriggerDefinition ]]; + + FIRIAMMessageRenderData *renderData4 = + [[FIRIAMMessageRenderData alloc] initWithMessageID:@"m4" + messageName:@"name" + contentData:msgContentData + renderingEffect:renderSetting]; + + FIRIAMMessageRenderData *renderData5 = + [[FIRIAMMessageRenderData alloc] initWithMessageID:@"m5" + messageName:@"name" + contentData:msgContentData + renderingEffect:renderSetting]; + + m4 = [[FIRIAMMessageDefinition alloc] + initWithRenderData:renderData4 + startTime:activeStartTime + endTime:activeEndTime + triggerDefinition:@[ contextualTriggerDefinition, contextualTriggerDefinition2 ]]; + + // m5 is of mixture of both app-foreground and contextual triggers + m5 = [[FIRIAMMessageDefinition alloc] + initWithRenderData:renderData5 + startTime:activeStartTime + endTime:activeEndTime + triggerDefinition:@[ contextualTriggerDefinition, appOpentriggerDefinition ]]; +} + +- (void)tearDown { + // Put teardown code here. This method is called after the invocation of each test method in the + // class. + [super tearDown]; +} + +- (void)testResetMessages { + // test setting a mixture of display-on-app open messages and Firebase Analytics based messages + // to see if the cache will keep them correctly + OCMStub([self.mockBookkeeper getImpressions]).andReturn(@[]); + [self.clientCache setMessageData:@[ m1, m2, m3, m4 ]]; + + NSArray *messages = [self.clientCache allRegularMessages]; + XCTAssertEqual(4, [messages count]); + + // m4 have two contextual events defined as triggers + XCTAssertEqual(2, [self.clientCache.firebaseAnalyticEventsToWatch count]); + XCTAssert([self.clientCache.firebaseAnalyticEventsToWatch containsObject:@"test_event"]); + XCTAssert([self.clientCache.firebaseAnalyticEventsToWatch containsObject:@"second_event"]); +} + +- (void)testResetMessagesWithImpressionsData { + // test setting a mixture of display-on-app open messages and Firebase Analytics based messages + // to see if the cache will keep them correctly + + NSArray *impressionList = @[ @"m1", @"m2" ]; + OCMStub([self.mockBookkeeper getMessageIDsFromImpressions]).andReturn(impressionList); + [self.clientCache setMessageData:@[ m1, m2, m3, m4 ]]; + + // m1 and m2 should have been filtered out + NSArray *messages = [self.clientCache allRegularMessages]; + XCTAssertEqual(2, messages.count); + + // m4 have two contextual events defined as triggers + XCTAssertEqual(2, self.clientCache.firebaseAnalyticEventsToWatch.count); + XCTAssert([self.clientCache.firebaseAnalyticEventsToWatch containsObject:@"test_event"]); + XCTAssert([self.clientCache.firebaseAnalyticEventsToWatch containsObject:@"second_event"]); +} + +- (void)testNextOnAppOpenDisplayMsg_ok { + OCMStub([self.mockBookkeeper getImpressions]).andReturn(@[]); + // m1 and m3 are messages rendered on app open + [self.clientCache setMessageData:@[ m1, m2, m3, m4 ]]; + + FIRIAMMessageDefinition *nextMsgOnAppOpen = [self.clientCache nextOnAppOpenDisplayMsg]; + XCTAssertEqual(@"m1", nextMsgOnAppOpen.renderData.messageID); + // remove m1 + [self.clientCache removeMessageWithId:@"m1"]; + + // read m2 and remove it + nextMsgOnAppOpen = [self.clientCache nextOnAppOpenDisplayMsg]; + XCTAssertEqual(@"m3", nextMsgOnAppOpen.renderData.messageID); + [self.clientCache removeMessageWithId:@"m3"]; + + // no more message for display on app open + nextMsgOnAppOpen = [self.clientCache nextOnAppOpenDisplayMsg]; + XCTAssertNil(nextMsgOnAppOpen); +} + +- (void)testNextOnFirebaseAnalyticsEventDisplayMsg_ok { + OCMStub([self.mockBookkeeper getImpressions]).andReturn(@[]); + // m2 and m4 are messages rendered on 'app open'test_event' Firebase Analytics event + [self.clientCache setMessageData:@[ m1, m2, m3, m4 ]]; + + FIRIAMMessageDefinition *nextMsgOnFIREvent = + [self.clientCache nextOnFirebaseAnalyticEventDisplayMsg:@"test_event"]; + XCTAssertEqual(@"m2", nextMsgOnFIREvent.renderData.messageID); + // remove m2 + [self.clientCache removeMessageWithId:@"m2"]; + // verify that the watch set is empty after draining all the messages + XCTAssertTrue([self.clientCache.firebaseAnalyticEventsToWatch containsObject:@"test_event"]); + + // read m4 and remove it + nextMsgOnFIREvent = [self.clientCache nextOnFirebaseAnalyticEventDisplayMsg:@"test_event"]; + XCTAssertEqual(@"m4", nextMsgOnFIREvent.renderData.messageID); + // remove m4 + [self.clientCache removeMessageWithId:@"m4"]; + + // no more message for display on Firebase Analytics event + nextMsgOnFIREvent = [self.clientCache nextOnFirebaseAnalyticEventDisplayMsg:@"test_event"]; + XCTAssertNil(nextMsgOnFIREvent); + + // verify that the watch set is empty after draining all the messages + XCTAssertEqual(0, self.clientCache.firebaseAnalyticEventsToWatch.count); +} + +- (void)testNextOnFirebaseAnalyticsEventDisplayMsgEventNameMustMatch_ok { + OCMStub([self.mockBookkeeper getImpressions]).andReturn(@[]); + // m2 and m4 are messages rendered on 'app open'test_event' Firebase Analytics event + [self.clientCache setMessageData:@[ m1, m2, m3, m4 ]]; + + FIRIAMMessageDefinition *nextMsgOnFIREvent = + [self.clientCache nextOnFirebaseAnalyticEventDisplayMsg:@"different_event"]; + XCTAssertNil(nextMsgOnFIREvent); +} + +- (void)testNextOnFirebaseAnalyticsEventDisplayMsgEventNameCanMatchAny_ok { + OCMStub([self.mockBookkeeper getImpressions]).andReturn(@[]); + // m4 are messages of multiple contextual triggers, one of which is for event + // 'second_event' + [self.clientCache setMessageData:@[ m1, m2, m3, m4 ]]; + + FIRIAMMessageDefinition *nextMsgOnFIREvent = + [self.clientCache nextOnFirebaseAnalyticEventDisplayMsg:@"second_event"]; + XCTAssertNotNil(nextMsgOnFIREvent); +} + +- (void)testMessageCanHaveMixedTypeOfTriggers_ok { + OCMStub([self.mockBookkeeper getImpressions]).andReturn(@[]); + [self.clientCache setMessageData:@[ m5 ]]; + + FIRIAMMessageDefinition *nextMsgOnFIREvent = + [self.clientCache nextOnFirebaseAnalyticEventDisplayMsg:@"test_event"]; + XCTAssertNotNil(nextMsgOnFIREvent); + + // in the meanwhile, retrieving an app-foreground message should be successful + FIRIAMMessageDefinition *nextMsgOnAppForeground = [self.clientCache nextOnAppOpenDisplayMsg]; + XCTAssertNotNil(nextMsgOnAppForeground); +} + +- (void)testNextOnFirebaseAnalyticsEventDisplayMsg_handleStartEndTimeCorrectly { + OCMStub([self.mockBookkeeper getImpressions]).andReturn(@[]); + + FIRIAMDisplayTriggerDefinition *appOpentriggerDefinition = + [[FIRIAMDisplayTriggerDefinition alloc] initForAppForegroundTrigger]; + + FIRIAMMessageContentDataWithImageURL *msgContentData = + [[FIRIAMMessageContentDataWithImageURL alloc] + initWithMessageTitle:@"title" + messageBody:@"message body" + actionButtonText:nil + actionURL:[NSURL URLWithString:@"http://google.com"] + imageURL:[NSURL URLWithString:@"https://unsplash.it/300/300"] + usingURLSession:nil]; + + FIRIAMRenderingEffectSetting *renderSetting = + [FIRIAMRenderingEffectSetting getDefaultRenderingEffectSetting]; + renderSetting.viewMode = FIRIAMRenderAsBannerView; + + FIRIAMMessageRenderData *renderData1 = + [[FIRIAMMessageRenderData alloc] initWithMessageID:@"m1" + messageName:@"name" + contentData:msgContentData + renderingEffect:renderSetting]; + + // m1 has not started yet + FIRIAMMessageDefinition *unstartedMessage = [[FIRIAMMessageDefinition alloc] + initWithRenderData:renderData1 + startTime:[[NSDate date] timeIntervalSince1970] + 10000 + endTime:[[NSDate date] timeIntervalSince1970] + 20000 + triggerDefinition:@[ appOpentriggerDefinition ]]; + + FIRIAMMessageRenderData *renderData2 = + [[FIRIAMMessageRenderData alloc] initWithMessageID:@"m2" + messageName:@"name" + contentData:msgContentData + renderingEffect:renderSetting]; + + // m2 has ended + FIRIAMMessageDefinition *endedMessage = [[FIRIAMMessageDefinition alloc] + initWithRenderData:renderData2 + startTime:[[NSDate date] timeIntervalSince1970] - 20000 + endTime:[[NSDate date] timeIntervalSince1970] - 10000 + triggerDefinition:@[ appOpentriggerDefinition ]]; + + // m3, m4 are campaigns with good start/end time + [self.clientCache setMessageData:@[ unstartedMessage, endedMessage, m3, m4 ]]; + + FIRIAMMessageDefinition *nextMsgOnAppOpen = [self.clientCache nextOnAppOpenDisplayMsg]; + FIRIAMMessageDefinition *nextMsgOnFIREvent = + [self.clientCache nextOnFirebaseAnalyticEventDisplayMsg:@"test_event"]; + XCTAssertEqual(nextMsgOnAppOpen.renderData.messageID, @"m3"); + XCTAssertEqual(nextMsgOnFIREvent.renderData.messageID, @"m4"); + + // no more on app open display message + [self.clientCache removeMessageWithId:@"m3"]; + nextMsgOnAppOpen = [self.clientCache nextOnAppOpenDisplayMsg]; + XCTAssertNil(nextMsgOnAppOpen); +} + +- (void)testCallingStartAnalyticsEventListenFlow_ok { + OCMStub([self.mockBookkeeper getImpressions]).andReturn(@[]); + + FIRIAMDisplayCheckOnAnalyticEventsFlow *mockAnalyticsEventFlow = + OCMClassMock(FIRIAMDisplayCheckOnAnalyticEventsFlow.class); + self.clientCache.analycisEventDislayCheckFlow = mockAnalyticsEventFlow; + + // m2 and m4 are messages rendered on 'test_event' Firebase Analytics event + // so we espect the analytics event listening flow to be started + OCMExpect([mockAnalyticsEventFlow start]); + [self.clientCache setMessageData:@[ m1, m2, m3, m4 ]]; + OCMVerifyAll((id)mockAnalyticsEventFlow); +} + +- (void)testCallingStopAnalyticsEventListenFlow_ok { + OCMStub([self.mockBookkeeper getImpressions]).andReturn(@[]); + + FIRIAMDisplayCheckOnAnalyticEventsFlow *mockAnalyticsEventFlow = + OCMClassMock(FIRIAMDisplayCheckOnAnalyticEventsFlow.class); + self.clientCache.analycisEventDislayCheckFlow = mockAnalyticsEventFlow; + + // m1 and m3 are messages rendered on app foreground triggers + OCMExpect([mockAnalyticsEventFlow stop]); + [self.clientCache setMessageData:@[ m1, m3 ]]; + OCMVerifyAll((id)mockAnalyticsEventFlow); +} + +- (void)testCallingStartAndThenStopAnalyticsEventListenFlow_ok { + OCMStub([self.mockBookkeeper getImpressions]).andReturn(@[]); + + FIRIAMDisplayCheckOnAnalyticEventsFlow *mockAnalyticsEventFlow = + OCMClassMock(FIRIAMDisplayCheckOnAnalyticEventsFlow.class); + self.clientCache.analycisEventDislayCheckFlow = mockAnalyticsEventFlow; + + // start is triggered on the setMessageData: call + OCMExpect([mockAnalyticsEventFlow start]); + // stop is triggered on removeMessageWithId: call since m2 is the only message + // using contextual triggers + OCMExpect([mockAnalyticsEventFlow stop]); + + [self.clientCache setMessageData:@[ m1, m2, m3 ]]; + [self.clientCache removeMessageWithId:m2.renderData.messageID]; + OCMVerifyAll((id)mockAnalyticsEventFlow); +} + +- (void)testFetchTestMessageFirstOnNextOnAppOpenDisplayMsg_ok { + OCMStub([self.mockBookkeeper getImpressions]).andReturn(@[]); + + FIRIAMMessageDefinition *testMessage = + [[FIRIAMMessageDefinition alloc] initTestMessageWithRenderData:m2.renderData]; + + // m1 and m3 are messages rendered on app open + [self.clientCache setMessageData:@[ m1, m2, testMessage, m3, m4 ]]; + + // we are fetching test message back + FIRIAMMessageDefinition *nextMsgOnAppOpen = [self.clientCache nextOnAppOpenDisplayMsg]; + XCTAssertEqual(testMessage.renderData.messageID, nextMsgOnAppOpen.renderData.messageID); + + // we still have 4 regular messages after the first fetch + XCTAssertEqual(4, self.clientCache.allRegularMessages.count); +} +@end diff --git a/InAppMessaging/Example/Tests/FIRIAMMessageContentDataWithImageURLTests.m b/InAppMessaging/Example/Tests/FIRIAMMessageContentDataWithImageURLTests.m new file mode 100644 index 0000000000..0a71613dd7 --- /dev/null +++ b/InAppMessaging/Example/Tests/FIRIAMMessageContentDataWithImageURLTests.m @@ -0,0 +1,241 @@ +/* + * 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 +#import +#import "FIRIAMMessageContentDataWithImageURL.h" + +static NSString *defaultTitle = @"Message Title"; +static NSString *defaultBody = @"Message Body"; +static NSString *defaultActionButtonText = @"Take action"; +static NSString *defaultActionURL = @"https://foo.com/bar"; +static NSString *defaultImageURL = @"http://firebase.com/iam/test.png"; + +@interface FIRIAMMessageContentDataWithImageURLTests : XCTestCase +@property NSURLSession *mockedNSURLSession; + +@property FIRIAMMessageContentDataWithImageURL *defaultContentDataWithImageURL; +@end + +@implementation FIRIAMMessageContentDataWithImageURLTests + +- (void)setUp { + [super setUp]; + + _mockedNSURLSession = OCMClassMock([NSURLSession class]); + _defaultContentDataWithImageURL = [[FIRIAMMessageContentDataWithImageURL alloc] + initWithMessageTitle:defaultTitle + messageBody:defaultBody + actionButtonText:defaultActionButtonText + actionURL:[NSURL URLWithString:defaultActionURL] + imageURL:[NSURL URLWithString:defaultImageURL] + usingURLSession:_mockedNSURLSession]; +} + +- (void)tearDown { + [super tearDown]; +} + +- (void)testReadingTitleAndBodyBackCorrectly { + XCTAssertEqualObjects(defaultTitle, self.defaultContentDataWithImageURL.titleText); + XCTAssertEqualObjects(defaultBody, self.defaultContentDataWithImageURL.bodyText); +} + +- (void)testReadingActionButtonTextCorrectly { + XCTAssertEqualObjects(defaultActionButtonText, + self.defaultContentDataWithImageURL.actionButtonText); +} + +- (void)testURLRequestUsingCorrectImageURL { + __block NSURLRequest *capturedNSURLRequest; + OCMStub([self.mockedNSURLSession + dataTaskWithRequest:[OCMArg checkWithBlock:^BOOL(NSURLRequest *request) { + capturedNSURLRequest = request; + return YES; + }] + completionHandler:[OCMArg any] // second parameter is the callback which we don't care in + // this unit testing + ]); + + [_defaultContentDataWithImageURL + loadImageDataWithBlock:^(NSData *_Nullable imageData, NSError *_Nullable error){ + }]; + + // verify that the dataTaskWithRequest:completionHandler: is triggered for NSURLSession object + OCMVerify([self.mockedNSURLSession dataTaskWithRequest:[OCMArg any] + completionHandler:[OCMArg any]]); + + XCTAssertEqualObjects([capturedNSURLRequest URL].absoluteString, defaultImageURL); +} + +- (void)testReportErrorOnNonSuccessHTTPStatusCode { + // NSURLSessionDataTask * mockedDataTask = OCMClassMock([NSURLSessionDataTask class]); + __block void (^capturedCompletionHandler)(NSData *data, NSURLResponse *response, NSError *error); + + OCMStub([self.mockedNSURLSession + dataTaskWithRequest:[OCMArg any] + completionHandler:[OCMArg checkWithBlock:^BOOL(id completionHandler) { + capturedCompletionHandler = completionHandler; + return YES; + }] // second parameter is the callback which we don't care in this unit testing + ]); + + XCTestExpectation *expectation = + [self expectationWithDescription:@"image load callback triggered."]; + [_defaultContentDataWithImageURL + loadImageDataWithBlock:^(NSData *_Nullable imageData, NSError *_Nullable error) { + XCTAssertNil(imageData); + XCTAssertNotNil(error); // we should report error due to the unsuccessful http status code + [expectation fulfill]; + }]; + + // verify that the dataTaskWithRequest:completionHandler: is triggered for NSURLSession object + OCMVerify([self.mockedNSURLSession dataTaskWithRequest:[OCMArg any] + completionHandler:[OCMArg any]]); + + // by this time we should have capturedCompletionHandler being the callback block for the + // NSURLSessionDataTask, now supply it with invalid http status code to see how the block from + // loadImageDataWithBlock: would react to it. + + NSURL *url = [[NSURL alloc] initWithString:defaultImageURL]; + + NSHTTPURLResponse *unsuccessfulHTTPResponse = [[NSHTTPURLResponse alloc] initWithURL:url + statusCode:404 + HTTPVersion:nil + headerFields:nil]; + capturedCompletionHandler(nil, unsuccessfulHTTPResponse, nil); + + [self waitForExpectationsWithTimeout:5.0 handler:nil]; +} + +- (void)testReportErrorOnGeneralNSErrorFromNSURLSession { + NSError *customError = [[NSError alloc] initWithDomain:@"Error Domain" code:100 userInfo:nil]; + __block void (^capturedCompletionHandler)(NSData *data, NSURLResponse *response, NSError *error); + + OCMStub([self.mockedNSURLSession + dataTaskWithRequest:[OCMArg any] + completionHandler:[OCMArg checkWithBlock:^BOOL(id completionHandler) { + capturedCompletionHandler = completionHandler; + return YES; + }] // second parameter is the callback which we don't care in this unit testing + ]); + + XCTestExpectation *expectation = + [self expectationWithDescription:@"image load callback triggered."]; + [_defaultContentDataWithImageURL + loadImageDataWithBlock:^(NSData *_Nullable imageData, NSError *_Nullable error) { + XCTAssertNil(imageData); + XCTAssertNotNil(error); // we should report error due to the unsuccessful http status code + XCTAssertEqualObjects(error, customError); + [expectation fulfill]; + }]; + + // verify that the dataTaskWithRequest:completionHandler: is triggered for NSURLSession object + OCMVerify([self.mockedNSURLSession dataTaskWithRequest:[OCMArg any] + completionHandler:[OCMArg any]]); + + // by this time we should have capturedCompletionHandler being the callback block for the + // NSURLSessionDataTask, now feed it with an NSError see how the block from + // loadImageDataWithBlock: would react to it. + capturedCompletionHandler(nil, nil, customError); + + [self waitForExpectationsWithTimeout:5.0 handler:nil]; +} + +- (void)testReportErrorOnNonImageContentTypeResponse { + __block void (^capturedCompletionHandler)(NSData *data, NSURLResponse *response, NSError *error); + + OCMStub([self.mockedNSURLSession + dataTaskWithRequest:[OCMArg any] + completionHandler:[OCMArg checkWithBlock:^BOOL(id completionHandler) { + capturedCompletionHandler = completionHandler; + return YES; + }] // second parameter is the callback which we don't care in this unit testing + ]); + + XCTestExpectation *expectation = + [self expectationWithDescription:@"image load callback triggered."]; + [_defaultContentDataWithImageURL + loadImageDataWithBlock:^(NSData *_Nullable imageData, NSError *_Nullable error) { + XCTAssertNil(imageData); + XCTAssertNotNil(error); // we should report error due to the http response + // content type being invalid + [expectation fulfill]; + }]; + + // verify that the dataTaskWithRequest:completionHandler: is triggered for NSURLSession object + OCMVerify([self.mockedNSURLSession dataTaskWithRequest:[OCMArg any] + completionHandler:[OCMArg any]]); + + // by this time we should have capturedCompletionHandler being the callback block for the + // NSURLSessionDataTask, now feed it with a non-image http response to see how the block from + // loadImageDataWithBlock: would react to it. + + NSURL *url = [[NSURL alloc] initWithString:defaultImageURL]; + NSHTTPURLResponse *nonImageContentTypeHTTPResponse = + [[NSHTTPURLResponse alloc] initWithURL:url + statusCode:200 + HTTPVersion:nil + headerFields:@{@"Content-Type" : @"non-image/jpeg"}]; + capturedCompletionHandler(nil, nonImageContentTypeHTTPResponse, nil); + + [self waitForExpectationsWithTimeout:5.0 handler:nil]; +} + +- (void)testGettingImageDataSuccessfully { + NSString *imageDataString = @"test image data"; + NSData *imageData = [imageDataString dataUsingEncoding:NSUTF8StringEncoding]; + + __block void (^capturedCompletionHandler)(NSData *data, NSURLResponse *response, NSError *error); + + OCMStub([self.mockedNSURLSession + dataTaskWithRequest:[OCMArg any] + completionHandler:[OCMArg checkWithBlock:^BOOL(id completionHandler) { + capturedCompletionHandler = completionHandler; + return YES; + }] // second parameter is the callback which we don't care in this unit testing + ]); + + XCTestExpectation *expectation = + [self expectationWithDescription:@"image load callback triggered."]; + [_defaultContentDataWithImageURL + loadImageDataWithBlock:^(NSData *_Nullable imageData, NSError *_Nullable error) { + XCTAssertNil(error); // no error is reported + NSString *fetchedImageDataString = [[NSString alloc] initWithData:imageData + encoding:NSUTF8StringEncoding]; + + XCTAssertEqualObjects(imageDataString, fetchedImageDataString); + + [expectation fulfill]; + }]; + + // verify that the dataTaskWithRequest:completionHandler: is triggered for NSURLSession object + OCMVerify([self.mockedNSURLSession dataTaskWithRequest:[OCMArg any] + completionHandler:[OCMArg any]]); + + NSURL *url = [[NSURL alloc] initWithString:defaultImageURL]; + NSHTTPURLResponse *successfulHTTPResponse = + [[NSHTTPURLResponse alloc] initWithURL:url + statusCode:200 + HTTPVersion:nil + headerFields:@{@"Content-Type" : @"image/jpeg"}]; + // by this time we should have capturedCompletionHandler being the callback block for the + // NSURLSessionDataTask, now feed it with image data to see how the block from + // loadImageDataWithBlock: would react to it. + capturedCompletionHandler(imageData, successfulHTTPResponse, nil); + [self waitForExpectationsWithTimeout:5.0 handler:nil]; +} +@end diff --git a/InAppMessaging/Example/Tests/FIRIAMMsgFetcherUsingRestfulTests.m b/InAppMessaging/Example/Tests/FIRIAMMsgFetcherUsingRestfulTests.m new file mode 100644 index 0000000000..efdc327fb0 --- /dev/null +++ b/InAppMessaging/Example/Tests/FIRIAMMsgFetcherUsingRestfulTests.m @@ -0,0 +1,233 @@ +/* + * 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 +#import +#import "FIRIAMFetchFlow.h" +#import "FIRIAMMessageDefinition.h" +#import "FIRIAMMsgFetcherUsingRestful.h" + +static NSString *serverHost = @"myhost"; +static NSString *projectNumber = @"My-project-number"; +static NSString *appId = @"My-app-id"; +static NSString *apiKey = @"Api-key"; + +@interface FIRIAMMsgFetcherUsingRestfulTests : XCTestCase +@property NSURLSession *mockedNSURLSession; +@property FIRIAMClientInfoFetcher *mockclientInfoFetcher; +@property FIRIAMMsgFetcherUsingRestful *fetcher; +@end + +@implementation FIRIAMMsgFetcherUsingRestfulTests + +- (void)setUp { + [super setUp]; + // Put setup code here. This method is called before the invocation of each test method in the + // class. + self.mockedNSURLSession = OCMClassMock([NSURLSession class]); + self.mockclientInfoFetcher = OCMClassMock([FIRIAMClientInfoFetcher class]); + + FIRIAMFetchResponseParser *parser = + [[FIRIAMFetchResponseParser alloc] initWithTimeFetcher:[[FIRIAMTimerWithNSDate alloc] init]]; + + self.fetcher = + [[FIRIAMMsgFetcherUsingRestful alloc] initWithHost:serverHost + HTTPProtocol:@"https" + project:projectNumber + firebaseApp:appId + APIKey:apiKey + fetchStorage:[[FIRIAMServerMsgFetchStorage alloc] init] + instanceIDFetcher:_mockclientInfoFetcher + usingURLSession:_mockedNSURLSession + responseParser:parser]; +} + +- (void)tearDown { + // Put teardown code here. This method is called after the invocation of each test method in the + // class. + [super tearDown]; +} + +- (void)testRequestConstructionWithoutImpressionData { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + + __block NSURLRequest *capturedNSURLRequest; + OCMStub([self.mockedNSURLSession + dataTaskWithRequest:[OCMArg checkWithBlock:^BOOL(NSURLRequest *request) { + capturedNSURLRequest = request; + return YES; + }] + completionHandler:[OCMArg any] // second parameter is the callback which we don't care in + // this unit testing + ]); + + NSString *iidValue = @"my iid"; + NSString *iidToken = @"my iid token"; + OCMStub([self.mockclientInfoFetcher + fetchFirebaseIIDDataWithProjectNumber:[OCMArg any] + withCompletion:([OCMArg invokeBlockWithArgs:iidValue, iidToken, + [NSNull null], nil])]); + + NSString *osVersion = @"OS Version"; + OCMStub([self.mockclientInfoFetcher getOSVersion]).andReturn(osVersion); + NSString *appVersion = @"App Version"; + OCMStub([self.mockclientInfoFetcher getAppVersion]).andReturn(appVersion); + NSString *deviceLanguage = @"Language"; + OCMStub([self.mockclientInfoFetcher getDeviceLanguageCode]).andReturn(deviceLanguage); + NSString *timezone = @"time zone"; + OCMStub([self.mockclientInfoFetcher getTimezone]).andReturn(timezone); + + [self.fetcher + fetchMessagesWithImpressionList:@[] + withCompletion:^(NSArray *_Nullable messages, + NSNumber *nextFetchWaitTime, NSInteger discardCount, + NSError *_Nullable error){ + // blank on purpose: it won't get triggered + }]; + + // verify that the dataTaskWithRequest:completionHandler: is triggered for NSURLSession object + OCMVerify([self.mockedNSURLSession dataTaskWithRequest:[OCMArg any] + completionHandler:[OCMArg any]]); + + XCTAssertEqualObjects(@"POST", capturedNSURLRequest.HTTPMethod); + + NSDictionary *requestHeaders = capturedNSURLRequest.allHTTPHeaderFields; + + // verifying some request header fields + XCTAssertEqualObjects([NSBundle mainBundle].bundleIdentifier, + requestHeaders[@"X-Ios-Bundle-Identifier"]); + + XCTAssertEqualObjects(@"application/json", requestHeaders[@"Content-Type"]); + XCTAssertEqualObjects(@"application/json", requestHeaders[@"Accept"]); + + // verify that the request contains the desired api key + NSString *s = [NSString stringWithFormat:@"key=%@", apiKey]; + XCTAssertTrue([capturedNSURLRequest.URL.absoluteString containsString:s]); + XCTAssertTrue([capturedNSURLRequest.URL.absoluteString containsString:projectNumber]); + + // verify that we the request body contains desired iid data + NSError *errorJson = nil; + NSDictionary *requestBodyDict = + [NSJSONSerialization JSONObjectWithData:capturedNSURLRequest.HTTPBody + options:kNilOptions + error:&errorJson]; + XCTAssertEqualObjects(appId, requestBodyDict[@"requesting_client_app"][@"gmp_app_id"]); + XCTAssertEqualObjects(iidValue, requestBodyDict[@"requesting_client_app"][@"app_instance_id"]); + XCTAssertEqualObjects(iidToken, + requestBodyDict[@"requesting_client_app"][@"app_instance_id_token"]); + + XCTAssertEqualObjects(osVersion, requestBodyDict[@"client_signals"][@"platform_version"]); + XCTAssertEqualObjects(appVersion, requestBodyDict[@"client_signals"][@"app_version"]); + XCTAssertEqualObjects(deviceLanguage, requestBodyDict[@"client_signals"][@"language_code"]); + XCTAssertEqualObjects(timezone, requestBodyDict[@"client_signals"][@"time_zone"]); +} + +- (void)testRequestConstructionWithImpressionData { + __block NSURLRequest *capturedNSURLRequest; + OCMStub([self.mockedNSURLSession + dataTaskWithRequest:[OCMArg checkWithBlock:^BOOL(NSURLRequest *request) { + capturedNSURLRequest = request; + return YES; + }] + completionHandler:[OCMArg any] // second parameter is the callback which we don't care in + // this unit testing + ]); + + NSString *iidValue = @"my iid"; + NSString *iidToken = @"my iid token"; + OCMStub([self.mockclientInfoFetcher + fetchFirebaseIIDDataWithProjectNumber:[OCMArg any] + withCompletion:([OCMArg invokeBlockWithArgs:iidValue, iidToken, + [NSNull null], nil])]); + + // this is to test the case that only partial client signal fields are available + NSString *osVersion = @"OS Version"; + OCMStub([self.mockclientInfoFetcher getOSVersion]).andReturn(osVersion); + NSString *appVersion = @"App Version"; + OCMStub([self.mockclientInfoFetcher getAppVersion]).andReturn(appVersion); + + long impression1Timestamp = 12345; + FIRIAMImpressionRecord *impression1 = + [[FIRIAMImpressionRecord alloc] initWithMessageID:@"impression 1" + impressionTimeInSeconds:impression1Timestamp]; + long impression2Timestamp = 45678; + FIRIAMImpressionRecord *impression2 = + [[FIRIAMImpressionRecord alloc] initWithMessageID:@"impression 2" + impressionTimeInSeconds:impression2Timestamp]; + + [self.fetcher + fetchMessagesWithImpressionList:@[ impression1, impression2 ] + withCompletion:^(NSArray *_Nullable messages, + NSNumber *_Nullable nextFetchWaitTime, + NSInteger discardCount, NSError *_Nullable error){ + // blank on purpose: it won't get triggered + }]; + + // verify that the captured nsurl request has expected body + NSError *errorJson = nil; + NSDictionary *requestBodyDict = + [NSJSONSerialization JSONObjectWithData:capturedNSURLRequest.HTTPBody + options:kNilOptions + error:&errorJson]; + + XCTAssertEqualObjects(impression1.messageID, + requestBodyDict[@"already_seen_campaigns"][0][@"campaign_id"]); + XCTAssertEqualWithAccuracy( + impression1Timestamp * 1000, + ((NSNumber *)requestBodyDict[@"already_seen_campaigns"][0][@"impression_timestamp_millis"]) + .longValue, + 0.1); + XCTAssertEqualObjects(impression2.messageID, + requestBodyDict[@"already_seen_campaigns"][1][@"campaign_id"]); + XCTAssertEqualWithAccuracy( + impression2Timestamp * 1000, + ((NSNumber *)requestBodyDict[@"already_seen_campaigns"][1][@"impression_timestamp_millis"]) + .longValue, + 0.1); + + XCTAssertEqualObjects(osVersion, requestBodyDict[@"client_signals"][@"platform_version"]); + XCTAssertEqualObjects(appVersion, requestBodyDict[@"client_signals"][@"app_version"]); + // not expexting language siganl since it's not mocked on mockclientInfoFetcher + XCTAssertNil(requestBodyDict[@"client_signals"][@"language_code"]); +} + +- (void)testBailoutOnIIDError { + // in this test, the attempt to fetch iid data failed and as a result, we expect the whole + // fetch operation attempt to fail with that error + NSError *iidError = [[NSError alloc] initWithDomain:@"Error Domain" code:100 userInfo:nil]; + OCMStub([self.mockclientInfoFetcher + fetchFirebaseIIDDataWithProjectNumber:[OCMArg any] + withCompletion:([OCMArg invokeBlockWithArgs:[NSNull null], + [NSNull null], iidError, + nil])]); + + XCTestExpectation *expectation = + [self expectationWithDescription:@"fetch callback block triggered."]; + [self.fetcher + fetchMessagesWithImpressionList:@[] + withCompletion:^(NSArray *_Nullable messages, + NSNumber *_Nullable nextFetchWaitTime, + NSInteger discardCount, NSError *_Nullable error) { + // expecting triggering the completion callback with error + XCTAssertNil(messages); + XCTAssertEqualObjects(iidError, error); + [expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:5.0 handler:nil]; +} +@end diff --git a/InAppMessaging/Example/Tests/Info.plist b/InAppMessaging/Example/Tests/Info.plist new file mode 100644 index 0000000000..6c6c23c43a --- /dev/null +++ b/InAppMessaging/Example/Tests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/InAppMessaging/Example/Tests/JsonDataWithInvalidMessagesFromFetch.txt b/InAppMessaging/Example/Tests/JsonDataWithInvalidMessagesFromFetch.txt new file mode 100644 index 0000000000..33f7492b1e --- /dev/null +++ b/InAppMessaging/Example/Tests/JsonDataWithInvalidMessagesFromFetch.txt @@ -0,0 +1,64 @@ +{ + "messages": [ + { + "vanillaPayload": { + "campaignId": "2108810525516234752" + }, + "content": { + "modal": { + "body": { + "hexColor": "#000000" + }, + "backgroundHexColor": "#ffffff" + } + }, + "triggeringConditions": [ + { + "fiamTrigger": "ON_FOREGROUND" + } + ], + "isTestCampaign": true + }, + + { + "vanillaPayload": { + "campaignId": "2108810525516234752" + }, + "content": { + "modal": { + "title": { + "text": "FAST", + "hexColor": "#000000" + }, + "body": { + "hexColor": "#000000" + }, + "backgroundHexColor": "#ffffff" + } + }, + }, + { + "vanillaPayload": { + "campaignId": "2108810525516234752" + }, + "content": { + "unknown-type": { + "title": { + "text": "FAST", + "hexColor": "#000000" + }, + "body": { + "hexColor": "#000000" + }, + "backgroundHexColor": "#ffffff" + } + }, + "triggeringConditions": [ + { + "fiamTrigger": "ON_FOREGROUND" + } + ], + "isTestCampaign": true + }, + ] +} diff --git a/InAppMessaging/Example/Tests/NSString+InterlaceStringsTests.m b/InAppMessaging/Example/Tests/NSString+InterlaceStringsTests.m new file mode 100644 index 0000000000..727effbc35 --- /dev/null +++ b/InAppMessaging/Example/Tests/NSString+InterlaceStringsTests.m @@ -0,0 +1,60 @@ +/* + * 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 + +#import "NSString+FIRInterlaceStrings.h" + +@interface NSString_InterlaceStringsTests : XCTestCase + +@end + +@implementation NSString_InterlaceStringsTests + +- (void)testEmptyStrings { + NSString *stringOne = @""; + NSString *stringTwo = @""; + XCTAssertEqualObjects(@"", [NSString fir_interlaceString:stringOne withString:stringTwo]); +} + +- (void)testSimpleExample { + NSString *stringOne = @"fe"; + NSString *stringTwo = @"rd"; + XCTAssertEqualObjects(@"fred", [NSString fir_interlaceString:stringOne withString:stringTwo]); +} + +- (void)testLongerExample { + NSString *stringOne = @"fefittn"; + NSString *stringTwo = @"rdlnsoe"; + XCTAssertEqualObjects(@"fredflintstone", [NSString fir_interlaceString:stringOne + withString:stringTwo]); +} + +- (void)testLongerFirstString { + NSString *stringOne = @"fe'lastnameisflintstone"; + NSString *stringTwo = @"rds"; + XCTAssertEqualObjects(@"fred'slastnameisflintstone", [NSString fir_interlaceString:stringOne + withString:stringTwo]); +} + +- (void)testLongerSecondString { + NSString *stringOne = @"fe'"; + NSString *stringTwo = @"rdslastnameisflintstone"; + XCTAssertEqualObjects(@"fred'slastnameisflintstone", [NSString fir_interlaceString:stringOne + withString:stringTwo]); +} + +@end diff --git a/InAppMessaging/Example/Tests/UIColor+FIRIAMHexStringTests.m b/InAppMessaging/Example/Tests/UIColor+FIRIAMHexStringTests.m new file mode 100644 index 0000000000..483d61b88b --- /dev/null +++ b/InAppMessaging/Example/Tests/UIColor+FIRIAMHexStringTests.m @@ -0,0 +1,59 @@ +/* + * 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 + +#import "UIColor+FIRIAMHexString.h" + +@interface UIColor_FIRIAMHexStringTests : XCTestCase + +@end + +@implementation UIColor_FIRIAMHexStringTests + +- (void)setUp { + [super setUp]; + // Put setup code here. This method is called before the invocation of each test method in the + // class. +} + +- (void)tearDown { + // Put teardown code here. This method is called after the invocation of each test method in the + // class. + [super tearDown]; +} + +- (void)testNilHexString { + UIColor *color = [UIColor firiam_colorWithHexString:nil]; + XCTAssertNil(color); +} + +- (void)testEmptyHexString { + UIColor *color = [UIColor firiam_colorWithHexString:@""]; + XCTAssertNil(color); +} + +- (void)testInvalidHexString { + UIColor *color = [UIColor firiam_colorWithHexString:@"#sssfsss"]; + XCTAssertNil(color); +} + +- (void)testValidHexString { + UIColor *color = [UIColor firiam_colorWithHexString:@"#00FFEE"]; + XCTAssertNotNil(color); +} + +@end diff --git a/Interop/Analytics/Public/FIRAnalyticsInterop.h b/Interop/Analytics/Public/FIRAnalyticsInterop.h index 5e30168e4f..a5143c5e1d 100644 --- a/Interop/Analytics/Public/FIRAnalyticsInterop.h +++ b/Interop/Analytics/Public/FIRAnalyticsInterop.h @@ -17,6 +17,7 @@ #import @class FIRAConditionalUserProperty; +@protocol FIRAnalyticsInteropListener; NS_ASSUME_NONNULL_BEGIN @@ -47,6 +48,13 @@ NS_ASSUME_NONNULL_BEGIN /// Sets user property. - (void)setUserPropertyWithOrigin:(NSString *)origin name:(NSString *)name value:(id)value; +/// Registers an Analytics listener for the given origin. +- (void)registerAnalyticsListener:(id)listener + withOrigin:(NSString *)origin; + +/// Unregisters an Analytics listener for the given origin. +- (void)unregisterAnalyticsListenerWithOrigin:(NSString *)origin; + @end NS_ASSUME_NONNULL_END diff --git a/Interop/Analytics/Public/FIRAnalyticsInteropListener.h b/Interop/Analytics/Public/FIRAnalyticsInteropListener.h new file mode 100644 index 0000000000..45cde55061 --- /dev/null +++ b/Interop/Analytics/Public/FIRAnalyticsInteropListener.h @@ -0,0 +1,24 @@ +/* + * 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. + */ + +/// Handles events and messages from Analytics. +@protocol FIRAnalyticsInteropListener + +/// Triggers when an Analytics event happens for the registered origin with +/// `FIRAnalyticsInterop`s `registerAnalyticsListener:withOrigin:`. +- (void)messageTriggered:(NSString *)name parameters:(NSDictionary *)parameters; + +@end \ No newline at end of file