diff --git a/CHANGELOG.md b/CHANGELOG.md index 29cf94a..ac94d90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,30 @@ ## 20.11.0 +* !! Due to cocoapods issue with Xcode 12, we have added the iOS SDK as source code instead of Pod. Due to that change you may need to remove the ios platform and add it again. * !! Consent change !! To use remote config, you now need to give "remote-config" consent * !! Push breaking changes !! Google play vulnerability issue fixed due to broadcast receiver for android push notification +* Added "onNotification" listener for push notification callbacks +* Notification Service Extension automation added +* Tweaked android push notifications to always show up as notifications * Added Surveys and NPS feedback widgets -* Added replaceAllAppKeysInQueueWithCurrentAppKey method to replace all app keys in queue with the current app key -* Added removeDifferentAppKeysFromQueue method to remove all different app keys from the queue -* Added setStarRatingDialogTexts method to set text's for different fields of star rating dialog +* Added "replaceAllAppKeysInQueueWithCurrentAppKey" method to replace all app keys in queue with the current app key +* Added "removeDifferentAppKeysFromQueue" method to remove all different app keys from the queue +* Added "setStarRatingDialogTexts" method to set text's for different fields of star rating dialog +* Added "setLoggingEnabled" call +* Added "setLocationInit" method to record Location before init, to prevent potential issues occurred when location is passed after init. +* Added "giveConsentInit" method to give Consents before init, some features needed consent before init to work properly. +* Added APM calls +* Added "isInitialised" call +* Added functionality to enable attribution +* Added "recordAttributionID" call to support changes in iOS 14 related to App Tracking Permission. +* Added call to retrieve the currently used device ID and Author. +* Updated "init" call to async +* Segmentation added in recordView method +* Renamed countly-sdk-js to countly-sdk-cordova +* Scripts added to create Cordova and Ionic Example app +* Ionic example app removed +* Fixed issues related to location tracking +* Fixed SDK version and SDK name metrics to show not the bridged SDK values but the ones from the cordova SDK * Updated underlying android SDK to 20.11.2 * Updated underlying ios SDK to 20.11.1 -## 20.4.0 +## 19.9.3 diff --git a/hooks/createService.js b/hooks/createService.js index 05e7cd4..eb8d9cc 100644 --- a/hooks/createService.js +++ b/hooks/createService.js @@ -41,8 +41,8 @@ module.exports = function(context) { }); var countlyFiles = [ - __dirname +'/../../../platforms/ios/Pods/CountlyPod/' +'CountlyNotificationService.h', - __dirname +'/../../../platforms/ios/Pods/CountlyPod/' +'CountlyNotificationService.m' + __dirname +`/../src/ios/CountlyiOS/` +'CountlyNotificationService.h', + __dirname +`/../src/ios/CountlyiOS/` +'CountlyNotificationService.m' ]; extFiles.push(countlyFiles[0]); extFiles.push(countlyFiles[1]); diff --git a/plugin.xml b/plugin.xml index 9876886..f284d7b 100644 --- a/plugin.xml +++ b/plugin.xml @@ -18,9 +18,9 @@ https://github.com/Countly/countly-sdk-cordova/issues - - - + + + @@ -42,6 +42,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -49,7 +98,6 @@ - diff --git a/src/ios/CountlyiOS/Countly.h b/src/ios/CountlyiOS/Countly.h new file mode 100644 index 0000000..b25476f --- /dev/null +++ b/src/ios/CountlyiOS/Countly.h @@ -0,0 +1,694 @@ +// Countly.h +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import +#import +#import "CountlyUserDetails.h" +#import "CountlyConfig.h" +#import "CountlyFeedbackWidget.h" +#if (TARGET_OS_IOS || TARGET_OS_OSX) +#import +#endif + +NS_ASSUME_NONNULL_BEGIN + +@interface Countly : NSObject + +#pragma mark - Core + +/** + * Returns @c Countly singleton to be used throughout the app. + * @return The shared @c Countly object + */ ++ (instancetype)sharedInstance; + +/** + * Starts Countly with given configuration and begins session. + * @param config @c CountlyConfig object that defines host, app key, optional features and other settings + */ +- (void)startWithConfig:(CountlyConfig *)config; + +/** + * Sets new app key to be used in following requests. + * @discussion Before switching to new app key, this method suspends Countly and resumes immediately after. + * @discussion Requests already queued previously will keep using the old app key. + * @discussion New app key needs to be a non-zero length string, otherwise it is ignored. + * @discussion @c recordPushNotificationToken and @c updateRemoteConfigWithCompletionHandler: methods may need to be called again after app key change. + * @param newAppKey New app key + */ +- (void)setNewAppKey:(NSString *)newAppKey; + +/** + * Sets the value of the custom HTTP header field to be sent with every request if @c customHeaderFieldName is set on initial configuration. + * @discussion If @c customHeaderFieldValue on initial configuration can not be set on app launch, this method can be used to do so later. + * @discussion Requests not started due to missing @c customHeaderFieldValue since app launch will start hereafter. + * @param customHeaderFieldValue Custom header field value + */ +- (void)setCustomHeaderFieldValue:(NSString *)customHeaderFieldValue; + +/** + * Flushes request and event queues. + * @discussion Flushes persistently stored request queue and events recorded but not converted to a request so far. + * @discussion Started timed events will not be affected. + */ +- (void)flushQueues; + +/** + * Replaces all requests with a different app key with the current app key. + * @discussion In request queue, if there are any request whose app key is different than the current app key, + * @discussion these requests' app key will be replaced with the current app key. + */ +- (void)replaceAllAppKeysInQueueWithCurrentAppKey; + +/** + * Removes all requests with a different app key in request queue. + * @discussion In request queue, if there are any request whose app key is different than the current app key, + * @discussion these requests will be removed from request queue. + */ +- (void)removeDifferentAppKeysFromQueue; + +/** + * Starts session and sends @c begin_session request with default metrics for manual session handling. + * @discussion This method needs to be called for starting a session only if @c manualSessionHandling flag is set on initial configuration. + * @discussion Otherwise; sessions will be handled automatically by default, and calling this method will have no effect. + */ +- (void)beginSession; + +/** + * Updates session and sends unsent session duration for manual session handling. + * @discussion This method needs to be called for updating a session only if @c manualSessionHandling flag is set on initial configuration. + * @discussion Otherwise; sessions will be handled automatically by default, and calling this method will have no effect. + */ +- (void)updateSession; + +/** + * Ends session and sends @c end_session request for manual session handling. + * @discussion This method needs to be called for ending a session only if @c manualSessionHandling flag is set on initial configuration. + * @discussion Otherwise; sessions will be handled automatically by default, and calling this method will have no effect. + */ +- (void)endSession; + +#if (TARGET_OS_WATCH) +/** + * Suspends Countly, adds recorded events to request queue and ends current session. + * @discussion This method needs to be called manually only on @c watchOS, on other platforms it will be called automatically. + */ +- (void)suspend; + +/** + * Resumes Countly, begins a new session after the app comes to foreground. + * @discussion This method needs to be called manually only on @c watchOS, on other platforms it will be called automatically. + */ +- (void)resume; +#endif + + + +#pragma mark - Device ID + +/** + * Returns current device ID being used for tracking. + * @discussion Device ID can be used for handling data export and/or removal requests as part of data privacy compliance. + */ +- (NSString *)deviceID; + +/** + * Returns current device ID type. + * @discussion Device ID type can be one of the following: + * @discussion @c CLYDeviceIDTypeCustom : Custom device ID set by app developer. + * @discussion @c CLYDeviceIDTypeTemporary : Temporary device ID. See @c CLYTemporaryDeviceID for details. + * @discussion @c CLYDeviceIDTypeIDFV : Default device ID type used by the SDK on iOS and tvOS. + * @discussion @c CLYDeviceIDTypeNSUUID : Default device ID type used by the SDK on watchOS and macOS. + */ +- (CLYDeviceIDType)deviceIDType; + +/** + * Sets new device ID to be persistently stored and used in following requests. + * @discussion Value passed for @c deviceID parameter has to be a non-zero length valid string, otherwise default device ID will be used instead. + * @discussion If value passed for @c deviceID parameter is exactly same to the current device ID, method call is ignored. + * @discussion When passing @c CLYTemporaryDeviceID for @c deviceID parameter, argument for @c onServer parameter does not matter. + * @discussion When setting a new device ID while the current device ID is @c CLYTemporaryDeviceID, argument for @c onServer parameter does not matter. + * @param deviceID New device ID + * @param onServer If set, data on Countly Server will be merged automatically, otherwise device will be counted as a new device + */ +- (void)setNewDeviceID:(NSString * _Nullable)deviceID onServer:(BOOL)onServer; + + + +#pragma mark - Consents + +/** + * Grants consent to given feature and starts it. + * @discussion If @c requiresConsent flag is set on initial configuration, each feature waits and ignores manual calls until explicit consent is given. + * @discussion After giving consent to a feature, it is started and kept active henceforth. + * @discussion If consent to the feature is already given before, calling this method will have no effect. + * @discussion If @c requiresConsent flag is not set on initial configuration, calling this method will have no effect. + * @param featureName Feature name to give consent to + */ +- (void)giveConsentForFeature:(CLYConsent)featureName; + +/** + * Grants consent to given features and starts them. + * @discussion This is a convenience method for grating consent for multiple features at once. + * @discussion Inner workings of @c giveConsentForFeature: method applies for this method as well. + * @param features Array of feature names to give consent to + */ +- (void)giveConsentForFeatures:(NSArray *)features; + +/** + * Grants consent to all features and starts them. + * @discussion This is a convenience method for grating consent for all features at once. + * @discussion Inner workings of @c giveConsentForFeature: method applies for this method as well. + */ +- (void)giveConsentForAllFeatures; + +/** + * Cancels consent to given feature and stops it. + * @discussion After cancelling consent to a feature, it is stopped and kept inactive henceforth. + * @discussion If consent to the feature is already cancelled before, calling this method will have no effect. + * @discussion If @c requiresConsent flag is not set on initial configuration, calling this method will have no effect. + * @param featureName Feature name to cancel consent to + */ +- (void)cancelConsentForFeature:(CLYConsent)featureName; + +/** + * Cancels consent to given features and stops them. + * @discussion This is a convenience method for cancelling consent for multiple features at once. + * @discussion Inner workings of @c cancelConsentForFeature: method applies for this method as well. + * @param features Array of feature names to cancel consent to + */ +- (void)cancelConsentForFeatures:(NSArray *)features; + +/** + * Cancels consent to all features and stops them. + * @discussion This is a convenience method for cancelling consent for all features at once. + * @discussion Inner workings of @c cancelConsentForFeature: method applies for this method as well. + */ +- (void)cancelConsentForAllFeatures; + + + +#pragma mark - Events + +/** + * Records event with given key. + * @param key Event key, a non-zero length valid string + */ +- (void)recordEvent:(NSString *)key; + +/** + * Records event with given key and count. + * @param key Event key, a non-zero length valid string + * @param count Count of event occurrences + */ +- (void)recordEvent:(NSString *)key count:(NSUInteger)count; + +/** + * Records event with given key and sum. + * @param key Event key, a non-zero length valid string + * @param sum Sum of any specific value for event + */ +- (void)recordEvent:(NSString *)key sum:(double)sum; + +/** + * Records event with given key and duration. + * @param key Event key, a non-zero length valid string + * @param duration Duration of event in seconds + */ +- (void)recordEvent:(NSString *)key duration:(NSTimeInterval)duration; + +/** + * Records event with given key, count and sum. + * @param key Event key, a non-zero length valid string + * @param count Count of event occurrences + * @param sum Sum of any specific value for event + */ +- (void)recordEvent:(NSString *)key count:(NSUInteger)count sum:(double)sum; + +/** + * Records event with given key and segmentation. + * @discussion Segmentation should be an @c NSDictionary, with keys and values are both @c NSString's only. + * @discussion Custom objects in segmentation will cause events not to be sent to Countly Server. + * @discussion Nested values in segmentation will be ignored by Countly Server event segmentation section. + * @param key Event key, a non-zero length valid string + * @param segmentation Segmentation key-value pairs of event + */ +- (void)recordEvent:(NSString *)key segmentation:(NSDictionary * _Nullable)segmentation; + +/** + * Records event with given key, segmentation and count. + * @discussion Segmentation should be an @c NSDictionary, with keys and values are both @c NSString's only. + * @discussion Custom objects in segmentation will cause events not to be sent to Countly Server. + * @discussion Nested values in segmentation will be ignored by Countly Server event segmentation section. + * @param key Event key, a non-zero length valid string + * @param segmentation Segmentation key-value pairs of event + * @param count Count of event occurrences + */ +- (void)recordEvent:(NSString *)key segmentation:(NSDictionary * _Nullable)segmentation count:(NSUInteger)count; + +/** + * Records event with given key, segmentation, count and sum. + * @discussion Segmentation should be an @c NSDictionary, with keys and values are both @c NSString's only. + * @discussion Custom objects in segmentation will cause events not to be sent to Countly Server. + * @discussion Nested values in segmentation will be ignored by Countly Server event segmentation section. + * @param key Event key, a non-zero length valid string + * @param segmentation Segmentation key-value pairs of event + * @param count Count of event occurrences + * @param sum Sum of any specific value for event + */ +- (void)recordEvent:(NSString *)key segmentation:(NSDictionary * _Nullable)segmentation count:(NSUInteger)count sum:(double)sum; + +/** + * Records event with given key, segmentation, count, sum and duration. + * @discussion Segmentation should be an @c NSDictionary, with keys and values are both @c NSString's only. + * @discussion Custom objects in segmentation will cause events not to be sent to Countly Server. + * @discussion Nested values in segmentation will be ignored by Countly Server event segmentation section. + * @param key Event key, a non-zero length valid string + * @param segmentation Segmentation key-value pairs of event + * @param count Count of event occurrences + * @param sum Sum of any specific value for event + * @param duration Duration of event in seconds + */ +- (void)recordEvent:(NSString *)key segmentation:(NSDictionary * _Nullable)segmentation count:(NSUInteger)count sum:(double)sum duration:(NSTimeInterval)duration; + +/** + * Starts a timed event with given key to be ended later. Duration of timed event will be calculated on ending. + * @discussion Trying to start an event with already started key will have no effect. + * @param key Event key, a non-zero length valid string + */ +- (void)startEvent:(NSString *)key; + +/** + * Ends a previously started timed event with given key. + * @discussion Trying to end an event with already ended (or not yet started) key will have no effect. + * @param key Event key, a non-zero length valid string + */ +- (void)endEvent:(NSString *)key; + +/** + * Ends a previously started timed event with given key, segmentation, count and sum. + * @discussion Trying to end an event with already ended (or not yet started) key will have no effect. + * @discussion Segmentation should be an @c NSDictionary, with keys and values are both @c NSString's only. + * @discussion Custom objects in segmentation will cause events not to be sent to Countly Server. + * @discussion Nested values in segmentation will be ignored by Countly Server event segmentation section. + * @param key Event key, a non-zero length valid string + * @param segmentation Segmentation key-value pairs of event + * @param count Count of event occurrences + * @param sum Sum of any specific value for event + */ +- (void)endEvent:(NSString *)key segmentation:(NSDictionary * _Nullable)segmentation count:(NSUInteger)count sum:(double)sum; + +/** + * Cancels a previously started timed event with given key. + * @discussion Trying to cancel an event with already cancelled (or ended or not yet started) key will have no effect. + * @param key Event key, a non-zero length valid string + */ +- (void)cancelEvent:(NSString *)key; + + + +#pragma mark - Push Notification +#if (TARGET_OS_IOS || TARGET_OS_OSX) +#ifndef COUNTLY_EXCLUDE_PUSHNOTIFICATIONS +/** + * Shows default system dialog that asks for user's permission to display notifications. + * @discussion A unified convenience method that handles asking for notification permission on both iOS10 and older iOS versions with badge, sound and alert notification types. + */ +- (void)askForNotificationPermission; + +/** + * Shows default system dialog that asks for user's permission to display notifications with given options and completion handler. + * @discussion A more customizable version of unified convenience method that handles asking for notification permission on both iOS10 and older iOS versions. + * @discussion Notification types the app wants to display can be specified using @c options parameter. + * @discussion Completion block has @c granted (@c BOOL) parameter which is @c YES if user granted permission, and @c error (@c NSError) parameter which is non-nil if there is an error. + * @param options Bitwise combination of notification types (badge, sound or alert) the app wants to display + * @param completionHandler A completion handler block to be executed when user answers notification permission dialog + */ +- (void)askForNotificationPermissionWithOptions:(UNAuthorizationOptions)options completionHandler:(void (^)(BOOL granted, NSError * __nullable error))completionHandler API_AVAILABLE(ios(10.0), macos(10.14)); + +/** + * Records action event for a manually presented push notification with custom action buttons. + * @discussion If a push notification with custom action buttons is handled and presented manually using custom UI, user's action needs to be recorded manually. + * @discussion With this convenience method user's action can be recorded passing push notification dictionary and clicked button index. + * @discussion Button index should be @c 0 for default action, @c 1 for the first action button and @c 2 for the second action button. + * @param userInfo Manually presented push notification dictionary + * @param buttonIndex Index of custom action button user clicked + */ +- (void)recordActionForNotification:(NSDictionary *)userInfo clickedButtonIndex:(NSInteger)buttonIndex; + +/** + * Records push notification token to Countly Server for current device ID. + * @discussion Can be used to re-send push notification token for current device ID, without waiting for the app to be restarted. + * @discussion For cases like a new user logs in and device ID changes, or a new app key is set. + * @discussion In general, push notification token is handled automatically and this method does not need to be called manually. + */ +- (void)recordPushNotificationToken; + +/** + * Clears push notification token on Countly Server for current device ID. + * @discussion Can be used to clear push notification token for current device ID, before the current user logs out and device ID changes, without waiting for the app to be restarted. + */ +- (void)clearPushNotificationToken; +#endif +#endif + + + +#pragma mark - Location + +/** + * Records user's location, city, country and IP address to be used for geo-location based push notifications and advanced user segmentation. + * @discussion By default, Countly Server uses a geo-ip database for acquiring user's location. + * @discussion If the app uses Core Location services and granted permission, a location with better accuracy can be provided using this method. + * @discussion If the app has information about user's city and/or country, these information can be provided using this method. + * @discussion If the app needs to explicitly specify the IP address due to network requirements, it can be provided using this method. + * @discussion This method overrides all location related properties specified on initial configuration or on a previous call to this method, and sends an immediate request. + * @discussion City and country code information should be provided together. If one of them is missing while the other one is present, there will be a warning logged. + * @param location User's location with latitude and longitude + * @param city User's city + * @param ISOCountryCode User's country code in ISO 3166-1 alpha-2 format + * @param IP User's explicit IP address + */ +- (void)recordLocation:(CLLocationCoordinate2D)location city:(NSString * _Nullable)city ISOCountryCode:(NSString * _Nullable)ISOCountryCode IP:(NSString * _Nullable)IP; + +/** + * Records user's location info to be used for geo-location based push notifications and advanced user segmentation. + * @discussion By default, Countly Server uses a geo-ip database for acquiring user's location. + * @discussion If the app uses Core Location services and granted permission, a location with better accuracy can be provided using this method. + * @discussion This method overrides @c location property specified on initial configuration, and sends an immediate request. + * @param location User's location with latitude and longitude + */ +- (void)recordLocation:(CLLocationCoordinate2D)location DEPRECATED_MSG_ATTRIBUTE("Use 'recordLocation:city:ISOCountryCode:IP:' method instead!"); + +/** + * Records user's city and country info to be used for geo-location based push notifications and advanced user segmentation. + * @discussion By default, Countly Server uses a geo-ip database for acquiring user's location. + * @discussion If the app has information about user's city and/or country, these information can be provided using this method. + * @discussion This method overrides @c city and @c ISOCountryCode properties specified on initial configuration, and sends an immediate request. + * @param city User's city + * @param ISOCountryCode User's ISO country code in ISO 3166-1 alpha-2 format + */ +- (void)recordCity:(NSString *)city andISOCountryCode:(NSString *)ISOCountryCode DEPRECATED_MSG_ATTRIBUTE("Use 'recordLocation:city:ISOCountryCode:IP:' method instead!"); + +/** + * Records user's IP address to be used for geo-location based push notifications and advanced user segmentation. + * @discussion By default, Countly Server uses a geo-ip database for acquiring user's location. + * @discussion If the app needs to explicitly specify the IP address due to network requirements, it can be provided using this method. + * @discussion This method overrides @c IP property specified on initial configuration, and sends an immediate request. + * @param IP User's explicit IP address + */ +- (void)recordIP:(NSString *)IP DEPRECATED_MSG_ATTRIBUTE("Use 'recordLocation:city:ISOCountryCode:IP:' method instead!"); + +/** + * Disables geo-location based push notifications by clearing all existing location info. + * @discussion Once disabled, geo-location based push notifications can be enabled again by calling @c recordLocation: or @c recordCity:andISOCountryCode: or @c recordIP: method. + */ +- (void)disableLocationInfo; + +/** + * @c isGeoLocationEnabled property is deprecated. Please use @c disableLocationInfo method instead. + * @discussion Using this property will have no effect. + */ +@property (nonatomic) BOOL isGeoLocationEnabled DEPRECATED_MSG_ATTRIBUTE("Use 'disableLocationInfo' method instead!"); + + + +#pragma mark - Crash Reporting + +/** + * Records a handled exception manually. + * @param exception Exception to be recorded + */ +- (void)recordHandledException:(NSException *)exception; + +/** + * Records a handled exception and given stack trace manually. + * @param exception Exception to be recorded + * @param stackTrace Stack trace to be recorded + */ +- (void)recordHandledException:(NSException *)exception withStackTrace:(NSArray * _Nullable)stackTrace; + +/** + * Records an unhandled exception and given stack trace manually. + * @discussion For recording non-native level fatal exceptions, where the app keeps running at native level and can recover. + * @param exception Exception to be recorded + * @param stackTrace Stack trace to be recorded + */ +- (void)recordUnhandledException:(NSException *)exception withStackTrace:(NSArray * _Nullable)stackTrace; + +/** + * Records custom logs to be delivered with crash report. + * @discussion Logs recorded by this method are stored in a non-persistent structure, and delivered to Countly Server only in case of a crash. + * @param log Custom log string to be recorded + */ +- (void)recordCrashLog:(NSString *)log; + +/** + * @c crashLog: method is deprecated. Please use @c recordCrashLog: method instead. + * @discussion Be advised, parameter type changed to plain @c NSString from string format, for better Swift compatibility. + * @discussion Calling this method will have no effect. + */ +- (void)crashLog:(NSString *)format, ... NS_FORMAT_FUNCTION(1,2) DEPRECATED_MSG_ATTRIBUTE("Use 'recordCrashLog:' method instead!"); + + + +#pragma mark - View Tracking + +/** + * Records a visited view with given name. + * @discussion Total duration of the view will be calculated on next @c recordView: call. + * @discussion If AutoViewTracking feature is enabled on initial configuration, this method does not need to be called manually. + * @param viewName Name of the view visited, a non-zero length valid string + */ +- (void)recordView:(NSString *)viewName; + +/** + * Records a visited view with given name and custom segmentation. + * @discussion This is an extended version of @c recordView: method. + * @discussion If segmentation has any of Countly reserved keys, they will be ignored: + * @discussion @c name, @c segment, @c visit, @c start, @c bounce, @c exit, @c view, @c domain, @c dur + * @discussion Segmentation should be an @c NSDictionary, with keys and values are both @c NSString's only. + * @discussion Custom objects in segmentation will cause events not to be sent to Countly Server. + * @discussion Nested values in segmentation will be ignored by Countly Server event segmentation section. + * @param viewName Name of the view visited, a non-zero length valid string + * @param segmentation Custom segmentation key-value pairs + */ +- (void)recordView:(NSString *)viewName segmentation:(NSDictionary *)segmentation; + +#if (TARGET_OS_IOS) +/** + * Adds exception for AutoViewTracking. + * @discussion @c UIViewControllers with specified title or class name will be ignored by AutoViewTracking and their appearances and disappearances will not be recorded. + * @discussion Adding an already added @c UIViewController title or subclass name again will have no effect. + * @param exception @c UIViewController title or subclass name to be added as exception + */ +- (void)addExceptionForAutoViewTracking:(NSString *)exception; + +/** + * Removes exception for AutoViewTracking. + * @discussion Removing an already removed (or not yet added) @c UIViewController title or subclass name will have no effect. + * @param exception @c UIViewController title or subclass name to be removed + */ +- (void)removeExceptionForAutoViewTracking:(NSString *)exception; + +/** + * Temporarily activates or deactivates AutoViewTracking, if AutoViewTracking feature is enabled on initial configuration. + * @discussion If AutoViewTracking feature is not enabled on initial configuration, this property has no effect. + */ +@property (nonatomic) BOOL isAutoViewTrackingActive; + +/** + * @c isAutoViewTrackingEnabled property is deprecated. Please use @c isAutoViewTrackingActive property instead. + * @discussion Using this property will have no effect. + */ +@property (nonatomic) BOOL isAutoViewTrackingEnabled DEPRECATED_MSG_ATTRIBUTE("Use 'isAutoViewTrackingActive' property instead!"); + +#endif + + + +#pragma mark - User Details + +/** + * Returns @c CountlyUserDetails singleton to be used throughout the app. + * @return The shared @c CountlyUserDetails object + */ ++ (CountlyUserDetails *)user; + +/** + * Handles switching from device ID to custom user ID for logged in users + * @discussion When a user logs in, this user can be tracked with custom user ID instead of device ID. + * @discussion This is just a convenience method that handles setting user ID as new device ID and merging existing data on Countly Server. + * @param userID Custom user ID uniquely defining the logged in user + */ +- (void)userLoggedIn:(NSString *)userID; + +/** + * Handles switching from custom user ID to device ID for logged out users + * @discussion When a user logs out, all the data can be tracked with default device ID henceforth. + * @discussion This is just a convenience method that handles resetting device ID to default one and starting a new session. + */ +- (void)userLoggedOut; + + + +#pragma mark - Feedbacks +#if (TARGET_OS_IOS) +/** + * Shows star-rating dialog manually and executes completion block after user's action. + * @discussion Completion block has a single NSInteger parameter that indicates 1 to 5 star-rating given by user. + * @discussion If user dismissed dialog without giving a rating, this value will be 0 and it will not be sent to Countly Server. + * @param completion A block object to be executed when user gives a star-rating or dismisses dialog without rating + */ +- (void)askForStarRating:(void(^)(NSInteger rating))completion; + +/** + * Presents feedback widget with given ID in a WKWebView placed in a UIViewController. + * @discussion First, the availability of the feedback widget will be checked asynchronously. + * @discussion If the feedback widget with given ID is available, it will be modally presented. + * @discussion Otherwise, @c completionHandler will be executed with an @c NSError. + * @discussion @c completionHandler will also be executed with @c nil when feedback widget is dismissed by user. + * @discussion Calls to this method will be ignored and @c completionHandler will not be executed if: + * @discussion - Consent for @c CLYConsentFeedback is not given, while @c requiresConsent flag is set on initial configuration. + * @discussion - Current device ID is @c CLYTemporaryDeviceID. + * @discussion - @c widgetID is not a non-zero length valid string. + * @discussion This is a legacy method for presenting Rating type feedback widgets only. + * @discussion Passing widget ID's of Survey or NPS type feedback widgets will not work. + * @param widgetID ID of the feedback widget created on Countly Server. + * @param completionHandler A completion handler block to be executed when feedback widget is dismissed by user or there is an error. + */ +- (void)presentFeedbackWidgetWithID:(NSString *)widgetID completionHandler:(void (^)(NSError * __nullable error))completionHandler; + +/** + * Fetches a list of available feedback widgets. + * @discussion When feedback widgets are fetched successfully, @c completionHandler will be executed with an array of @c CountlyFeedbackWidget objects. + * @discussion Otherwise, @c completionHandler will be executed with an @c NSError. + * @discussion Calls to this method will be ignored and @c completionHandler will not be executed if: + * @discussion - Consent for @c CLYConsentFeedback is not given, while @c requiresConsent flag is set on initial configuration. + * @discussion - Current device ID is @c CLYTemporaryDeviceID. + * @param completionHandler A completion handler block to be executed when list is fetched successfully or there is an error. + */ +- (void)getFeedbackWidgets:(void (^)(NSArray * __nullable feedbackWidgets, NSError * __nullable error))completionHandler; + +#endif + + + +#pragma mark - Attribution + +/** + * Records attribution ID (IDFA) for campaign attribution. + * @discussion This method overrides @c attributionID property specified on initial configuration, and sends an immediate request. + * @discussion Also, this attribution ID will be sent with all @c begin_session requests. + * @param attributionID Attribution ID (IDFA) + */ +- (void)recordAttributionID:(NSString *)attributionID; + + + +#pragma mark - Remote Config +/** + * Returns last retrieved remote config value for given key, if exists. + * @discussion If remote config is never retrieved from Countly Server before, this method will return @c nil. + * @discussion If @c key is not defined in remote config on Countly Server, this method will return @c nil. + * @discussion If Countly Server is not reachable, this method will return the last retrieved value which is stored on device. + * @param key Remote config key specified on Countly Server + */ +- (id)remoteConfigValueForKey:(NSString *)key; + +/** + * Manually updates all locally stored remote config values by fetching latest values from Countly Server, and executes completion handler. + * @discussion @c completionHandler has an @c NSError parameter that will be either @ nil or an @c NSError object, depending on result. + * @discussion Calls to this method will be ignored and @c completionHandler will not be executed if: + * @discussion - There is not any consent given, while @c requiresConsent flag is set on initial configuration. + * @discussion - Current device ID is @c CLYTemporaryDeviceID. + * @param completionHandler A completion handler block to be executed when updating of remote config is completed, either with success or failure. + */ +- (void)updateRemoteConfigWithCompletionHandler:(void (^)(NSError * __nullable error))completionHandler; + +/** + * Manually updates locally stored remote config values only for specified keys, by fetching latest values from Countly Server, and executes completion handler. + * @discussion @c completionHandler has an @c NSError parameter that will be either @ nil or an @c NSError object, depending on result. + * @discussion Calls to this method will be ignored and @c completionHandler will not be executed if: + * @discussion - There is not any consent given, while @c requiresConsent flag is set on initial configuration. + * @discussion - Current device ID is @c CLYTemporaryDeviceID. + * @param keys An array of remote config keys to update + * @param completionHandler A completion handler block to be executed when updating of remote config is completed, either with success or failure + */ +- (void)updateRemoteConfigOnlyForKeys:(NSArray *)keys completionHandler:(void (^)(NSError * __nullable error))completionHandler; + +/** + * Manually updates locally stored remote config values except for specified keys, by fetching latest values from Countly Server, and executes completion handler. + * @discussion @c completionHandler has an @c NSError parameter that will be either @ nil or an @c NSError object, depending on result. + * @discussion Calls to this method will be ignored and @c completionHandler will not be executed if: + * @discussion - There is not any consent given, while @c requiresConsent flag is set on initial configuration. + * @discussion - Current device ID is @c CLYTemporaryDeviceID. + * @param omitKeys An array of remote config keys to omit from updating + * @param completionHandler A completion handler block to be executed when updating of remote config is completed, either with success or failure + */ +- (void)updateRemoteConfigExceptForKeys:(NSArray *)omitKeys completionHandler:(void (^)(NSError * __nullable error))completionHandler; + + + +#pragma mark - Performance Monitoring + +/** + * Manually records a network trace for performance monitoring. + * @discussion A network trace is a collection of measured information about a network request. + * @discussion When a network request is completed, a network trace can be recorded manually to be analyzed in Performance Monitoring feature. + * @discussion Trace name needs to be a non-zero length string, otherwise it is ignored. + * @param traceName Trace name, a non-zero length valid string + * @param requestPayloadSize Size of the request's payload in bytes + * @param responsePayloadSize Size of the received response's payload in bytes + * @param responseStatusCode HTTP status code of the received response + * @param startTime UNIX time stamp in milliseconds for the starting time of the request + * @param endTime UNIX time stamp in milliseconds for the ending time of the request + */ +- (void)recordNetworkTrace:(NSString *)traceName requestPayloadSize:(NSInteger)requestPayloadSize responsePayloadSize:(NSInteger)responsePayloadSize responseStatusCode:(NSInteger)responseStatusCode startTime:(long long)startTime endTime:(long long)endTime; + +/** + * Starts a performance monitoring custom trace with given name to be ended later. + * @discussion Duration of custom trace will be calculated on ending. + * @discussion Trying to start a custom trace with already started name will have no effect. + * @param traceName Trace name, a non-zero length valid string + */ +- (void)startCustomTrace:(NSString *)traceName; + +/** + * Ends a previously started performance monitoring custom trace with given name and metrics. + * @discussion Trying to end a custom trace with already ended (or not yet started) name will have no effect. + * @param traceName Trace name, a non-zero length valid string + * @param metrics Metrics key-value pairs + */ +- (void)endCustomTrace:(NSString *)traceName metrics:(NSDictionary * _Nullable)metrics; + +/** + * Cancels a previously started performance monitoring custom trace with given name. + * @discussion Trying to cancel a custom trace with already cancelled (or ended or not yet started) name will have no effect. + * @param traceName Trace name, a non-zero length valid string + */ +- (void)cancelCustomTrace:(NSString *)traceName; + +/** + * Clears all previously started performance monitoring custom traces. + * @discussion All previously started performance monitoring custom traces are automatically cleaned when: + * @discussion - Consent for @c CLYConsentPerformanceMonitoring is cancelled + * @discussion - A new app key is set using @c setNewAppKey: method + */ +- (void)clearAllCustomTraces; + +/** + * Calculates and records app launch time for performance monitoring. + * @discussion This method should be called when the app is loaded and displayed its first user facing view successfully. + * @discussion e.g. @c viewDidAppear: method of the root view controller or whatever place is suitable for the app's flow. + * @discussion Time passed since the app started to launch will be automatically calculated and recorded for performance monitoring. + * @discussion App launch time can be recorded only once per app launch. So, second and following calls to this method will be ignored. + */ +- (void)appLoadingFinished; + +NS_ASSUME_NONNULL_END + +@end diff --git a/src/ios/CountlyiOS/Countly.m b/src/ios/CountlyiOS/Countly.m new file mode 100644 index 0000000..e781f2a --- /dev/null +++ b/src/ios/CountlyiOS/Countly.m @@ -0,0 +1,865 @@ +// Countly.m +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#pragma mark - Core + +#import "CountlyCommon.h" + +@interface Countly () +{ + NSTimer* timer; + BOOL isSuspended; +} +@end + +long long appLoadStartTime; + +@implementation Countly + ++ (void)load +{ + [super load]; + + appLoadStartTime = floor(NSDate.date.timeIntervalSince1970 * 1000); +} + ++ (instancetype)sharedInstance +{ + static Countly *s_sharedCountly = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{s_sharedCountly = self.new;}); + return s_sharedCountly; +} + +- (instancetype)init +{ + if (self = [super init]) + { +#if (TARGET_OS_IOS || TARGET_OS_TV) + [NSNotificationCenter.defaultCenter addObserver:self + selector:@selector(applicationDidEnterBackground:) + name:UIApplicationDidEnterBackgroundNotification + object:nil]; + [NSNotificationCenter.defaultCenter addObserver:self + selector:@selector(applicationWillEnterForeground:) + name:UIApplicationWillEnterForegroundNotification + object:nil]; + [NSNotificationCenter.defaultCenter addObserver:self + selector:@selector(applicationWillTerminate:) + name:UIApplicationWillTerminateNotification + object:nil]; +#elif (TARGET_OS_OSX) + [NSNotificationCenter.defaultCenter addObserver:self + selector:@selector(applicationWillTerminate:) + name:NSApplicationWillTerminateNotification + object:nil]; +#endif + } + + return self; +} + +#pragma mark --- + +- (void)startWithConfig:(CountlyConfig *)config +{ + if (CountlyCommon.sharedInstance.hasStarted_) + return; + + CountlyCommon.sharedInstance.hasStarted = YES; + CountlyCommon.sharedInstance.enableDebug = config.enableDebug; + CountlyCommon.sharedInstance.loggerDelegate = config.loggerDelegate; + CountlyConsentManager.sharedInstance.requiresConsent = config.requiresConsent; + + if (!config.appKey.length || [config.appKey isEqualToString:@"YOUR_APP_KEY"]) + [NSException raise:@"CountlyAppKeyNotSetException" format:@"appKey property on CountlyConfig object is not set"]; + + if (!config.host.length || [config.host isEqualToString:@"https://YOUR_COUNTLY_SERVER"]) + [NSException raise:@"CountlyHostNotSetException" format:@"host property on CountlyConfig object is not set"]; + + COUNTLY_LOG(@"Initializing with %@ SDK v%@", CountlyCommon.sharedInstance.SDKName, CountlyCommon.sharedInstance.SDKVersion); + + if (!CountlyDeviceInfo.sharedInstance.deviceID || config.resetStoredDeviceID) + { + [self storeCustomDeviceIDState:config.deviceID]; + + [CountlyDeviceInfo.sharedInstance initializeDeviceID:config.deviceID]; + } + + CountlyConnectionManager.sharedInstance.appKey = config.appKey; + CountlyConnectionManager.sharedInstance.host = [config.host hasSuffix:@"/"] ? [config.host substringToIndex:config.host.length - 1] : config.host; + CountlyConnectionManager.sharedInstance.alwaysUsePOST = config.alwaysUsePOST; + CountlyConnectionManager.sharedInstance.pinnedCertificates = config.pinnedCertificates; + CountlyConnectionManager.sharedInstance.customHeaderFieldName = config.customHeaderFieldName; + CountlyConnectionManager.sharedInstance.customHeaderFieldValue = config.customHeaderFieldValue; + CountlyConnectionManager.sharedInstance.secretSalt = config.secretSalt; + CountlyConnectionManager.sharedInstance.URLSessionConfiguration = config.URLSessionConfiguration; + + CountlyPersistency.sharedInstance.eventSendThreshold = config.eventSendThreshold; + CountlyPersistency.sharedInstance.storedRequestsLimit = MAX(1, config.storedRequestsLimit); + + CountlyCommon.sharedInstance.manualSessionHandling = config.manualSessionHandling; + + CountlyCommon.sharedInstance.enableAppleWatch = config.enableAppleWatch; + + CountlyCommon.sharedInstance.attributionID = config.attributionID; + + CountlyDeviceInfo.sharedInstance.customMetrics = config.customMetrics; + +#if (TARGET_OS_IOS) + CountlyFeedbacks.sharedInstance.message = config.starRatingMessage; + CountlyFeedbacks.sharedInstance.sessionCount = config.starRatingSessionCount; + CountlyFeedbacks.sharedInstance.disableAskingForEachAppVersion = config.starRatingDisableAskingForEachAppVersion; + CountlyFeedbacks.sharedInstance.ratingCompletionForAutoAsk = config.starRatingCompletion; + [CountlyFeedbacks.sharedInstance checkForStarRatingAutoAsk]; + + [CountlyLocationManager.sharedInstance updateLocation:config.location city:config.city ISOCountryCode:config.ISOCountryCode IP:config.IP]; +#endif + + if (!CountlyCommon.sharedInstance.manualSessionHandling) + [CountlyConnectionManager.sharedInstance beginSession]; + + //NOTE: If there is no consent for sessions, location info and attribution should be sent separately, as they cannot be sent with begin_session request. + if (!CountlyConsentManager.sharedInstance.consentForSessions) + { + [CountlyLocationManager.sharedInstance sendLocationInfo]; + [CountlyConnectionManager.sharedInstance sendAttribution]; + } + +#if (TARGET_OS_IOS || TARGET_OS_OSX) +#ifndef COUNTLY_EXCLUDE_PUSHNOTIFICATIONS + if ([config.features containsObject:CLYPushNotifications]) + { + CountlyPushNotifications.sharedInstance.isEnabledOnInitialConfig = YES; + CountlyPushNotifications.sharedInstance.pushTestMode = config.pushTestMode; + CountlyPushNotifications.sharedInstance.sendPushTokenAlways = config.sendPushTokenAlways; + CountlyPushNotifications.sharedInstance.doNotShowAlertForNotifications = config.doNotShowAlertForNotifications; + CountlyPushNotifications.sharedInstance.launchNotification = config.launchNotification; + [CountlyPushNotifications.sharedInstance startPushNotifications]; + } +#endif +#endif + + CountlyCrashReporter.sharedInstance.crashSegmentation = config.crashSegmentation; + CountlyCrashReporter.sharedInstance.crashLogLimit = MAX(1, config.crashLogLimit); + CountlyCrashReporter.sharedInstance.crashFilter = config.crashFilter; + CountlyCrashReporter.sharedInstance.shouldUsePLCrashReporter = config.shouldUsePLCrashReporter; + CountlyCrashReporter.sharedInstance.shouldUseMachSignalHandler = config.shouldUseMachSignalHandler; + CountlyCrashReporter.sharedInstance.crashOccuredOnPreviousSessionCallback = config.crashOccuredOnPreviousSessionCallback; + CountlyCrashReporter.sharedInstance.shouldSendCrashReportCallback = config.shouldSendCrashReportCallback; + if ([config.features containsObject:CLYCrashReporting]) + { + CountlyCrashReporter.sharedInstance.isEnabledOnInitialConfig = YES; + [CountlyCrashReporter.sharedInstance startCrashReporting]; + } + +#if (TARGET_OS_IOS || TARGET_OS_TV) + if ([config.features containsObject:CLYAutoViewTracking]) + { + CountlyViewTracking.sharedInstance.isEnabledOnInitialConfig = YES; + [CountlyViewTracking.sharedInstance startAutoViewTracking]; + } +#endif + + timer = [NSTimer timerWithTimeInterval:config.updateSessionPeriod target:self selector:@selector(onTimer:) userInfo:nil repeats:YES]; + [NSRunLoop.mainRunLoop addTimer:timer forMode:NSRunLoopCommonModes]; + + [CountlyCommon.sharedInstance startAppleWatchMatching]; + + CountlyRemoteConfig.sharedInstance.isEnabledOnInitialConfig = config.enableRemoteConfig; + CountlyRemoteConfig.sharedInstance.remoteConfigCompletionHandler = config.remoteConfigCompletionHandler; + [CountlyRemoteConfig.sharedInstance startRemoteConfig]; + + CountlyPerformanceMonitoring.sharedInstance.isEnabledOnInitialConfig = config.enablePerformanceMonitoring; + [CountlyPerformanceMonitoring.sharedInstance startPerformanceMonitoring]; + + [CountlyCommon.sharedInstance observeDeviceOrientationChanges]; + + [CountlyConnectionManager.sharedInstance proceedOnQueue]; + + if (config.consents) + [self giveConsentForFeatures:config.consents]; +} + +- (void)setNewAppKey:(NSString *)newAppKey +{ + if (!newAppKey.length) + return; + + [self suspend]; + + [CountlyPerformanceMonitoring.sharedInstance clearAllCustomTraces]; + + CountlyConnectionManager.sharedInstance.appKey = newAppKey; + + [self resume]; +} + +- (void)setCustomHeaderFieldValue:(NSString *)customHeaderFieldValue +{ + CountlyConnectionManager.sharedInstance.customHeaderFieldValue = customHeaderFieldValue.copy; + [CountlyConnectionManager.sharedInstance proceedOnQueue]; +} + +- (void)flushQueues +{ + [CountlyPersistency.sharedInstance flushEvents]; + [CountlyPersistency.sharedInstance flushQueue]; +} + +- (void)replaceAllAppKeysInQueueWithCurrentAppKey +{ + [CountlyPersistency.sharedInstance replaceAllAppKeysInQueueWithCurrentAppKey]; +} + +- (void)removeDifferentAppKeysFromQueue +{ + [CountlyPersistency.sharedInstance removeDifferentAppKeysFromQueue]; +} + +#pragma mark --- + +- (void)beginSession +{ + if (CountlyCommon.sharedInstance.manualSessionHandling) + [CountlyConnectionManager.sharedInstance beginSession]; +} + +- (void)updateSession +{ + if (CountlyCommon.sharedInstance.manualSessionHandling) + [CountlyConnectionManager.sharedInstance updateSession]; +} + +- (void)endSession +{ + if (CountlyCommon.sharedInstance.manualSessionHandling) + [CountlyConnectionManager.sharedInstance endSession]; +} + +#pragma mark --- + +- (void)onTimer:(NSTimer *)timer +{ + if (isSuspended) + return; + + if (!CountlyCommon.sharedInstance.manualSessionHandling) + [CountlyConnectionManager.sharedInstance updateSession]; + + [CountlyConnectionManager.sharedInstance sendEvents]; +} + +- (void)suspend +{ + if (!CountlyCommon.sharedInstance.hasStarted) + return; + + if (isSuspended) + return; + + COUNTLY_LOG(@"Suspending..."); + + isSuspended = YES; + + [CountlyConnectionManager.sharedInstance sendEvents]; + + if (!CountlyCommon.sharedInstance.manualSessionHandling) + [CountlyConnectionManager.sharedInstance endSession]; + + [CountlyViewTracking.sharedInstance pauseView]; + + [CountlyPersistency.sharedInstance saveToFile]; +} + +- (void)resume +{ + if (!CountlyCommon.sharedInstance.hasStarted) + return; + +#if (TARGET_OS_WATCH) + //NOTE: Skip first time to prevent double begin session because of applicationDidBecomeActive call on launch of watchOS apps + static BOOL isFirstCall = YES; + + if (isFirstCall) + { + isFirstCall = NO; + return; + } +#endif + + if (!CountlyCommon.sharedInstance.manualSessionHandling) + [CountlyConnectionManager.sharedInstance beginSession]; + + [CountlyViewTracking.sharedInstance resumeView]; + + isSuspended = NO; +} + +#pragma mark --- + +- (void)applicationDidEnterBackground:(NSNotification *)notification +{ + COUNTLY_LOG(@"App did enter background."); + [self suspend]; +} + +- (void)applicationWillEnterForeground:(NSNotification *)notification +{ + COUNTLY_LOG(@"App will enter foreground."); + [self resume]; +} + +- (void)applicationWillTerminate:(NSNotification *)notification +{ + COUNTLY_LOG(@"App will terminate."); + + CountlyConnectionManager.sharedInstance.isTerminating = YES; + + [CountlyViewTracking.sharedInstance endView]; + + [CountlyConnectionManager.sharedInstance sendEvents]; + + [CountlyPerformanceMonitoring.sharedInstance endBackgroundTrace]; + + [CountlyPersistency.sharedInstance saveToFileSync]; +} + +- (void)dealloc +{ + [NSNotificationCenter.defaultCenter removeObserver:self]; + + if (timer) + { + [timer invalidate]; + timer = nil; + } +} + + + +#pragma mark - Device ID + +- (NSString *)deviceID +{ + return CountlyDeviceInfo.sharedInstance.deviceID.cly_URLEscaped; +} + +- (CLYDeviceIDType)deviceIDType +{ + if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) + return CLYDeviceIDTypeTemporary; + + if ([CountlyPersistency.sharedInstance retrieveIsCustomDeviceID]) + return CLYDeviceIDTypeCustom; + +#if (TARGET_OS_IOS || TARGET_OS_TV) + return CLYDeviceIDTypeIDFV; +#else + return CLYDeviceIDTypeNSUUID; +#endif +} + +- (void)setNewDeviceID:(NSString *)deviceID onServer:(BOOL)onServer +{ + if (!CountlyCommon.sharedInstance.hasStarted) + return; + + if (!CountlyConsentManager.sharedInstance.hasAnyConsent) + return; + + [self storeCustomDeviceIDState:deviceID]; + + deviceID = [CountlyDeviceInfo.sharedInstance ensafeDeviceID:deviceID]; + + if ([deviceID isEqualToString:CountlyDeviceInfo.sharedInstance.deviceID]) + { + COUNTLY_LOG(@"Attempted to set the same device ID again. So, setting new device ID is aborted."); + return; + } + + if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) + { + COUNTLY_LOG(@"Going out of CLYTemporaryDeviceID mode and switching back to normal mode."); + + [CountlyDeviceInfo.sharedInstance initializeDeviceID:deviceID]; + + [CountlyPersistency.sharedInstance replaceAllTemporaryDeviceIDsInQueueWithDeviceID:deviceID]; + + [CountlyConnectionManager.sharedInstance proceedOnQueue]; + + [CountlyRemoteConfig.sharedInstance startRemoteConfig]; + + return; + } + + if ([deviceID isEqualToString:CLYTemporaryDeviceID] && onServer) + { + COUNTLY_LOG(@"Attempted to set device ID as CLYTemporaryDeviceID with onServer option. So, onServer value is overridden as NO."); + onServer = NO; + } + + if (onServer) + { + NSString* oldDeviceID = CountlyDeviceInfo.sharedInstance.deviceID; + + [CountlyDeviceInfo.sharedInstance initializeDeviceID:deviceID]; + + [CountlyConnectionManager.sharedInstance sendOldDeviceID:oldDeviceID]; + } + else + { + [self suspend]; + + [CountlyDeviceInfo.sharedInstance initializeDeviceID:deviceID]; + + [self resume]; + + [CountlyPersistency.sharedInstance clearAllTimedEvents]; + } + + [CountlyRemoteConfig.sharedInstance clearCachedRemoteConfig]; + [CountlyRemoteConfig.sharedInstance startRemoteConfig]; +} + +- (void)storeCustomDeviceIDState:(NSString *)deviceID +{ + BOOL isCustomDeviceID = deviceID.length && ![deviceID isEqualToString:CLYTemporaryDeviceID]; + [CountlyPersistency.sharedInstance storeIsCustomDeviceID:isCustomDeviceID]; +} + +#pragma mark - Consents +- (void)giveConsentForFeature:(NSString *)featureName +{ + if (!featureName.length) + return; + + [CountlyConsentManager.sharedInstance giveConsentForFeatures:@[featureName]]; +} + +- (void)giveConsentForFeatures:(NSArray *)features +{ + [CountlyConsentManager.sharedInstance giveConsentForFeatures:features]; +} + +- (void)giveConsentForAllFeatures +{ + [CountlyConsentManager.sharedInstance giveConsentForAllFeatures]; +} + +- (void)cancelConsentForFeature:(NSString *)featureName +{ + if (!featureName.length) + return; + + [CountlyConsentManager.sharedInstance cancelConsentForFeatures:@[featureName]]; +} + +- (void)cancelConsentForFeatures:(NSArray *)features +{ + [CountlyConsentManager.sharedInstance cancelConsentForFeatures:features]; +} + +- (void)cancelConsentForAllFeatures +{ + [CountlyConsentManager.sharedInstance cancelConsentForAllFeatures]; +} + + + +#pragma mark - Events +- (void)recordEvent:(NSString *)key +{ + [self recordEvent:key segmentation:nil count:1 sum:0 duration:0]; +} + +- (void)recordEvent:(NSString *)key count:(NSUInteger)count +{ + [self recordEvent:key segmentation:nil count:count sum:0 duration:0]; +} + +- (void)recordEvent:(NSString *)key sum:(double)sum +{ + [self recordEvent:key segmentation:nil count:1 sum:sum duration:0]; +} + +- (void)recordEvent:(NSString *)key duration:(NSTimeInterval)duration +{ + [self recordEvent:key segmentation:nil count:1 sum:0 duration:duration]; +} + +- (void)recordEvent:(NSString *)key count:(NSUInteger)count sum:(double)sum +{ + [self recordEvent:key segmentation:nil count:count sum:sum duration:0]; +} + +- (void)recordEvent:(NSString *)key segmentation:(NSDictionary *)segmentation +{ + [self recordEvent:key segmentation:segmentation count:1 sum:0 duration:0]; +} + +- (void)recordEvent:(NSString *)key segmentation:(NSDictionary *)segmentation count:(NSUInteger)count +{ + [self recordEvent:key segmentation:segmentation count:count sum:0 duration:0]; +} + +- (void)recordEvent:(NSString *)key segmentation:(NSDictionary *)segmentation count:(NSUInteger)count sum:(double)sum +{ + [self recordEvent:key segmentation:segmentation count:count sum:sum duration:0]; +} + +- (void)recordEvent:(NSString *)key segmentation:(NSDictionary *)segmentation count:(NSUInteger)count sum:(double)sum duration:(NSTimeInterval)duration +{ + if (!CountlyConsentManager.sharedInstance.consentForEvents) + return; + + [self recordEvent:key segmentation:segmentation count:count sum:sum duration:duration timestamp:CountlyCommon.sharedInstance.uniqueTimestamp]; +} + +#pragma mark - + +- (void)recordReservedEvent:(NSString *)key segmentation:(NSDictionary *)segmentation +{ + [self recordEvent:key segmentation:segmentation count:1 sum:0 duration:0 timestamp:CountlyCommon.sharedInstance.uniqueTimestamp]; +} + +- (void)recordReservedEvent:(NSString *)key segmentation:(NSDictionary *)segmentation count:(NSUInteger)count sum:(double)sum duration:(NSTimeInterval)duration timestamp:(NSTimeInterval)timestamp +{ + [self recordEvent:key segmentation:segmentation count:count sum:sum duration:duration timestamp:timestamp]; +} + +#pragma mark - + +- (void)recordEvent:(NSString *)key segmentation:(NSDictionary *)segmentation count:(NSUInteger)count sum:(double)sum duration:(NSTimeInterval)duration timestamp:(NSTimeInterval)timestamp +{ + if (key.length == 0) + return; + + CountlyEvent *event = CountlyEvent.new; + event.key = key; + event.segmentation = segmentation; + event.count = MAX(count, 1); + event.sum = sum; + event.timestamp = timestamp; + event.hourOfDay = CountlyCommon.sharedInstance.hourOfDay; + event.dayOfWeek = CountlyCommon.sharedInstance.dayOfWeek; + event.duration = duration; + + [CountlyPersistency.sharedInstance recordEvent:event]; +} + +#pragma mark --- + +- (void)startEvent:(NSString *)key +{ + if (!CountlyConsentManager.sharedInstance.consentForEvents) + return; + + CountlyEvent *event = CountlyEvent.new; + event.key = key; + event.timestamp = CountlyCommon.sharedInstance.uniqueTimestamp; + event.hourOfDay = CountlyCommon.sharedInstance.hourOfDay; + event.dayOfWeek = CountlyCommon.sharedInstance.dayOfWeek; + + [CountlyPersistency.sharedInstance recordTimedEvent:event]; +} + +- (void)endEvent:(NSString *)key +{ + [self endEvent:key segmentation:nil count:1 sum:0]; +} + +- (void)endEvent:(NSString *)key segmentation:(NSDictionary *)segmentation count:(NSUInteger)count sum:(double)sum +{ + if (!CountlyConsentManager.sharedInstance.consentForEvents) + return; + + CountlyEvent *event = [CountlyPersistency.sharedInstance timedEventForKey:key]; + + if (!event) + { + COUNTLY_LOG(@"Event with key '%@' not started yet or cancelled/ended before!", key); + return; + } + + event.segmentation = segmentation; + event.count = MAX(count, 1); + event.sum = sum; + event.duration = NSDate.date.timeIntervalSince1970 - event.timestamp; + + [CountlyPersistency.sharedInstance recordEvent:event]; +} + +- (void)cancelEvent:(NSString *)key +{ + if (!CountlyConsentManager.sharedInstance.consentForEvents) + return; + + CountlyEvent *event = [CountlyPersistency.sharedInstance timedEventForKey:key]; + + if (!event) + { + COUNTLY_LOG(@"Event with key '%@' not started yet or cancelled/ended before!", key); + return; + } + + COUNTLY_LOG(@"Event with key '%@' cancelled!", key); +} + + +#pragma mark - Push Notifications +#if (TARGET_OS_IOS || TARGET_OS_OSX) +#ifndef COUNTLY_EXCLUDE_PUSHNOTIFICATIONS + +- (void)askForNotificationPermission +{ + [CountlyPushNotifications.sharedInstance askForNotificationPermissionWithOptions:0 completionHandler:nil]; +} + +- (void)askForNotificationPermissionWithOptions:(UNAuthorizationOptions)options completionHandler:(void (^)(BOOL granted, NSError * error))completionHandler; +{ + [CountlyPushNotifications.sharedInstance askForNotificationPermissionWithOptions:options completionHandler:completionHandler]; +} + +- (void)recordActionForNotification:(NSDictionary *)userInfo clickedButtonIndex:(NSInteger)buttonIndex; +{ + [CountlyPushNotifications.sharedInstance recordActionForNotification:userInfo clickedButtonIndex:buttonIndex]; +} + +- (void)recordPushNotificationToken +{ + [CountlyPushNotifications.sharedInstance sendToken]; +} + +- (void)clearPushNotificationToken +{ + [CountlyPushNotifications.sharedInstance clearToken]; +} +#endif +#endif + + + +#pragma mark - Location + +- (void)recordLocation:(CLLocationCoordinate2D)location city:(NSString * _Nullable)city ISOCountryCode:(NSString * _Nullable)ISOCountryCode IP:(NSString * _Nullable)IP +{ + [CountlyLocationManager.sharedInstance recordLocation:location city:city ISOCountryCode:ISOCountryCode IP:IP]; +} + +- (void)recordLocation:(CLLocationCoordinate2D)location +{ + COUNTLY_LOG(@"recordLocation: method is deprecated. Please use recordLocation:city:countryCode:IP: method instead."); + + [CountlyLocationManager.sharedInstance recordLocation:location city:nil ISOCountryCode:nil IP:nil]; +} + +- (void)recordCity:(NSString *)city andISOCountryCode:(NSString *)ISOCountryCode +{ + COUNTLY_LOG(@"recordCity:andISOCountryCode: method is deprecated. Please use recordLocation:city:countryCode:IP: method instead."); + + if (!city.length && !ISOCountryCode.length) + return; + + [CountlyLocationManager.sharedInstance recordLocation:kCLLocationCoordinate2DInvalid city:city ISOCountryCode:ISOCountryCode IP:nil]; +} + +- (void)recordIP:(NSString *)IP +{ + COUNTLY_LOG(@"recordIP: method is deprecated. Please use recordLocation:city:countryCode:IP: method instead."); + + if (!IP.length) + return; + + [CountlyLocationManager.sharedInstance recordLocation:kCLLocationCoordinate2DInvalid city:nil ISOCountryCode:nil IP:IP]; +} + +- (void)disableLocationInfo +{ + [CountlyLocationManager.sharedInstance disableLocationInfo]; +} + + + +#pragma mark - Crash Reporting + +- (void)recordHandledException:(NSException *)exception +{ + [CountlyCrashReporter.sharedInstance recordException:exception withStackTrace:nil isFatal:NO]; +} + +- (void)recordHandledException:(NSException *)exception withStackTrace:(NSArray *)stackTrace +{ + [CountlyCrashReporter.sharedInstance recordException:exception withStackTrace:stackTrace isFatal:NO]; +} + +- (void)recordUnhandledException:(NSException *)exception withStackTrace:(NSArray * _Nullable)stackTrace +{ + [CountlyCrashReporter.sharedInstance recordException:exception withStackTrace:stackTrace isFatal:YES]; +} + +- (void)recordCrashLog:(NSString *)log +{ + [CountlyCrashReporter.sharedInstance log:log]; +} + +- (void)crashLog:(NSString *)format, ... +{ + +} + + + +#pragma mark - View Tracking + +- (void)recordView:(NSString *)viewName; +{ + [CountlyViewTracking.sharedInstance startView:viewName customSegmentation:nil]; +} + +- (void)recordView:(NSString *)viewName segmentation:(NSDictionary *)segmentation +{ + [CountlyViewTracking.sharedInstance startView:viewName customSegmentation:segmentation]; +} + +#if (TARGET_OS_IOS) +- (void)addExceptionForAutoViewTracking:(NSString *)exception +{ + [CountlyViewTracking.sharedInstance addExceptionForAutoViewTracking:exception.copy]; +} + +- (void)removeExceptionForAutoViewTracking:(NSString *)exception +{ + [CountlyViewTracking.sharedInstance removeExceptionForAutoViewTracking:exception.copy]; +} + +- (void)setIsAutoViewTrackingActive:(BOOL)isAutoViewTrackingActive +{ + CountlyViewTracking.sharedInstance.isAutoViewTrackingActive = isAutoViewTrackingActive; +} + +- (BOOL)isAutoViewTrackingActive +{ + return CountlyViewTracking.sharedInstance.isAutoViewTrackingActive; +} +#endif + + + +#pragma mark - User Details + ++ (CountlyUserDetails *)user +{ + return CountlyUserDetails.sharedInstance; +} + +- (void)userLoggedIn:(NSString *)userID +{ + [self setNewDeviceID:userID onServer:YES]; +} + +- (void)userLoggedOut +{ + [self setNewDeviceID:CLYDefaultDeviceID onServer:NO]; +} + + + +#pragma mark - Star Rating +#if (TARGET_OS_IOS) + +- (void)askForStarRating:(void(^)(NSInteger rating))completion +{ + [CountlyFeedbacks.sharedInstance showDialog:completion]; +} + +- (void)presentFeedbackWidgetWithID:(NSString *)widgetID completionHandler:(void (^)(NSError * error))completionHandler +{ + [CountlyFeedbacks.sharedInstance checkFeedbackWidgetWithID:widgetID completionHandler:completionHandler]; +} + +- (void)getFeedbackWidgets:(void (^)(NSArray *feedbackWidgets, NSError * error))completionHandler +{ + [CountlyFeedbacks.sharedInstance getFeedbackWidgets:completionHandler]; +} + +#endif + + + +#pragma mark - Attribution + +- (void)recordAttributionID:(NSString *)attributionID +{ + if (!CountlyConsentManager.sharedInstance.consentForAttribution) + return; + + CountlyCommon.sharedInstance.attributionID = attributionID; + + [CountlyConnectionManager.sharedInstance sendAttribution]; +} + + + +#pragma mark - Remote Config + +- (id)remoteConfigValueForKey:(NSString *)key +{ + return [CountlyRemoteConfig.sharedInstance remoteConfigValueForKey:key]; +} + +- (void)updateRemoteConfigWithCompletionHandler:(void (^)(NSError * error))completionHandler +{ + [CountlyRemoteConfig.sharedInstance updateRemoteConfigForKeys:nil omitKeys:nil completionHandler:completionHandler]; +} + +- (void)updateRemoteConfigOnlyForKeys:(NSArray *)keys completionHandler:(void (^)(NSError * error))completionHandler +{ + [CountlyRemoteConfig.sharedInstance updateRemoteConfigForKeys:keys omitKeys:nil completionHandler:completionHandler]; +} + +- (void)updateRemoteConfigExceptForKeys:(NSArray *)omitKeys completionHandler:(void (^)(NSError * error))completionHandler +{ + [CountlyRemoteConfig.sharedInstance updateRemoteConfigForKeys:nil omitKeys:omitKeys completionHandler:completionHandler]; +} + + + +#pragma mark - Performance Monitoring + +- (void)recordNetworkTrace:(NSString *)traceName requestPayloadSize:(NSInteger)requestPayloadSize responsePayloadSize:(NSInteger)responsePayloadSize responseStatusCode:(NSInteger)responseStatusCode startTime:(long long)startTime endTime:(long long)endTime +{ + [CountlyPerformanceMonitoring.sharedInstance recordNetworkTrace:traceName requestPayloadSize:requestPayloadSize responsePayloadSize:responsePayloadSize responseStatusCode:responseStatusCode startTime:startTime endTime:endTime]; +} + +- (void)startCustomTrace:(NSString *)traceName +{ + [CountlyPerformanceMonitoring.sharedInstance startCustomTrace:traceName]; +} + +- (void)endCustomTrace:(NSString *)traceName metrics:(NSDictionary * _Nullable)metrics +{ + [CountlyPerformanceMonitoring.sharedInstance endCustomTrace:traceName metrics:metrics]; +} + +- (void)cancelCustomTrace:(NSString *)traceName +{ + [CountlyPerformanceMonitoring.sharedInstance cancelCustomTrace:traceName]; +} + +- (void)clearAllCustomTraces +{ + [CountlyPerformanceMonitoring.sharedInstance clearAllCustomTraces]; +} + +- (void)appLoadingFinished +{ + long long appLoadEndTime = floor(NSDate.date.timeIntervalSince1970 * 1000); + + [CountlyPerformanceMonitoring.sharedInstance recordAppStartDurationTraceWithStartTime:appLoadStartTime endTime:appLoadEndTime]; +} + +@end diff --git a/src/ios/CountlyiOS/CountlyCommon.h b/src/ios/CountlyiOS/CountlyCommon.h new file mode 100644 index 0000000..018826f --- /dev/null +++ b/src/ios/CountlyiOS/CountlyCommon.h @@ -0,0 +1,134 @@ +// CountlyCommon.h +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import +#import "Countly.h" +#import "CountlyPersistency.h" +#import "CountlyConnectionManager.h" +#import "CountlyEvent.h" +#import "CountlyUserDetails.h" +#import "CountlyDeviceInfo.h" +#import "CountlyCrashReporter.h" +#import "CountlyConfig.h" +#import "CountlyViewTracking.h" +#import "CountlyFeedbacks.h" +#import "CountlyFeedbackWidget.h" +#import "CountlyPushNotifications.h" +#import "CountlyNotificationService.h" +#import "CountlyConsentManager.h" +#import "CountlyLocationManager.h" +#import "CountlyRemoteConfig.h" +#import "CountlyPerformanceMonitoring.h" + +#define COUNTLY_LOG(fmt, ...) CountlyInternalLog(fmt, ##__VA_ARGS__) + +#if (TARGET_OS_IOS) +#import +#import "WatchConnectivity/WatchConnectivity.h" +#endif + +#if (TARGET_OS_WATCH) +#import +#import "WatchConnectivity/WatchConnectivity.h" +#endif + +#if (TARGET_OS_TV) +#import +#endif + +#import + +extern NSString* const kCountlyErrorDomain; + +NS_ERROR_ENUM(kCountlyErrorDomain) +{ + CLYErrorFeedbackWidgetNotAvailable = 10001, + CLYErrorFeedbackWidgetNotTargetedForDevice = 10002, + CLYErrorRemoteConfigGeneralAPIError = 10011, + CLYErrorFeedbacksGeneralAPIError = 10012, +}; + +@interface CountlyCommon : NSObject + +@property (nonatomic, copy) NSString* SDKVersion; +@property (nonatomic, copy) NSString* SDKName; + +@property (nonatomic) BOOL hasStarted; +@property (nonatomic) BOOL enableDebug; +@property (nonatomic, weak) id loggerDelegate; +@property (nonatomic) BOOL enableAppleWatch; +@property (nonatomic, copy) NSString* attributionID; +@property (nonatomic) BOOL manualSessionHandling; + +void CountlyInternalLog(NSString *format, ...) NS_FORMAT_FUNCTION(1,2); +void CountlyPrint(NSString *stringToPrint); + ++ (instancetype)sharedInstance; +- (NSInteger)hourOfDay; +- (NSInteger)dayOfWeek; +- (NSInteger)timeZone; +- (NSInteger)timeSinceLaunch; +- (NSTimeInterval)uniqueTimestamp; + +- (void)startBackgroundTask; +- (void)finishBackgroundTask; + +#if (TARGET_OS_IOS || TARGET_OS_TV) +- (UIViewController *)topViewController; +- (void)tryPresentingViewController:(UIViewController *)viewController; +#endif + +- (void)startAppleWatchMatching; + +- (void)observeDeviceOrientationChanges; + +- (BOOL)hasStarted_; +@end + + +#if (TARGET_OS_IOS) +@interface CLYInternalViewController : UIViewController +@end + +@interface CLYButton : UIButton +@property (nonatomic, copy) void (^onClick)(id sender); ++ (CLYButton *)dismissAlertButton; +- (void)positionToTopRight; +- (void)positionToTopRightConsideringStatusBar; +@end +#endif + +@interface CLYDelegateInterceptor : NSObject +@property (nonatomic, weak) id originalDelegate; +@end + +@interface NSString (Countly) +- (NSString *)cly_URLEscaped; +- (NSString *)cly_SHA256; +- (NSData *)cly_dataUTF8; +- (NSString *)cly_valueForQueryStringKey:(NSString *)key; +@end + +@interface NSArray (Countly) +- (NSString *)cly_JSONify; +@end + +@interface NSDictionary (Countly) +- (NSString *)cly_JSONify; +@end + +@interface NSData (Countly) +- (NSString *)cly_stringUTF8; +@end + +@interface Countly (RecordReservedEvent) +- (void)recordReservedEvent:(NSString *)key segmentation:(NSDictionary *)segmentation; +- (void)recordReservedEvent:(NSString *)key segmentation:(NSDictionary *)segmentation count:(NSUInteger)count sum:(double)sum duration:(NSTimeInterval)duration timestamp:(NSTimeInterval)timestamp; +@end + +@interface CountlyUserDetails (ClearUserDetails) +- (void)clearUserDetails; +@end diff --git a/src/ios/CountlyiOS/CountlyCommon.m b/src/ios/CountlyiOS/CountlyCommon.m new file mode 100644 index 0000000..44bc88c --- /dev/null +++ b/src/ios/CountlyiOS/CountlyCommon.m @@ -0,0 +1,522 @@ +// CountlyCommon.m +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import "CountlyCommon.h" +#include + +NSString* const kCountlyReservedEventOrientation = @"[CLY]_orientation"; +NSString* const kCountlyOrientationKeyMode = @"mode"; + +@interface CLYWCSessionDelegateInterceptor : CLYDelegateInterceptor +@end + + +@interface CountlyCommon () +{ + NSCalendar* gregorianCalendar; + NSTimeInterval startTime; +} +@property long long lastTimestamp; + +#if (TARGET_OS_IOS) +@property (nonatomic) NSString* lastInterfaceOrientation; +#endif + +#if (TARGET_OS_IOS || TARGET_OS_TV) +@property (nonatomic) UIBackgroundTaskIdentifier bgTask; +#endif +#if (TARGET_OS_IOS || TARGET_OS_WATCH) +@property (nonatomic) CLYWCSessionDelegateInterceptor* watchDelegate; +#endif +@end + +NSString* const kCountlySDKVersion = @"20.11.1"; +NSString* const kCountlySDKName = @"objc-native-ios"; + +NSString* const kCountlyParentDeviceIDTransferKey = @"kCountlyParentDeviceIDTransferKey"; + +NSString* const kCountlyErrorDomain = @"ly.count.ErrorDomain"; + +NSString* const kCountlyInternalLogPrefix = @"[Countly] "; + + +@implementation CountlyCommon + ++ (instancetype)sharedInstance +{ + static CountlyCommon *s_sharedInstance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{s_sharedInstance = self.new;}); + return s_sharedInstance; +} + +- (instancetype)init +{ + if (self = [super init]) + { + gregorianCalendar = [NSCalendar.alloc initWithCalendarIdentifier:NSCalendarIdentifierGregorian]; + startTime = NSDate.date.timeIntervalSince1970; + + self.SDKVersion = kCountlySDKVersion; + self.SDKName = kCountlySDKName; + } + + return self; +} + + +- (BOOL)hasStarted +{ + if (!_hasStarted) + CountlyPrint(@"SDK should be started first!"); + + return _hasStarted; +} + +//NOTE: This is an equivalent of hasStarted, but without internal logging. +- (BOOL)hasStarted_ +{ + return _hasStarted; +} + +void CountlyInternalLog(NSString *format, ...) +{ + if (!CountlyCommon.sharedInstance.enableDebug && !CountlyCommon.sharedInstance.loggerDelegate) + return; + + va_list args; + va_start(args, format); + + NSString* logString = [NSString.alloc initWithFormat:format arguments:args]; + +#if DEBUG + if (CountlyCommon.sharedInstance.enableDebug) + CountlyPrint(logString); +#endif + + if (CountlyCommon.sharedInstance.loggerDelegate) + { + NSString* logStringWithPrefix = [NSString stringWithFormat:@"%@%@", kCountlyInternalLogPrefix, logString]; + [CountlyCommon.sharedInstance.loggerDelegate internalLog:logStringWithPrefix]; + } + + va_end(args); +} + +void CountlyPrint(NSString *stringToPrint) +{ + NSLog(@"%@%@", kCountlyInternalLogPrefix, stringToPrint); +} + +#pragma mark - Time/Date related methods +- (NSInteger)hourOfDay +{ + NSDateComponents* components = [gregorianCalendar components:NSCalendarUnitHour fromDate:NSDate.date]; + return components.hour; +} + +- (NSInteger)dayOfWeek +{ + NSDateComponents* components = [gregorianCalendar components:NSCalendarUnitWeekday fromDate:NSDate.date]; + return components.weekday - 1; +} + +- (NSInteger)timeZone +{ + return NSTimeZone.systemTimeZone.secondsFromGMT / 60; +} + +- (NSInteger)timeSinceLaunch +{ + return (int)NSDate.date.timeIntervalSince1970 - startTime; +} + +- (NSTimeInterval)uniqueTimestamp +{ + long long now = floor(NSDate.date.timeIntervalSince1970 * 1000); + + if (now <= self.lastTimestamp) + self.lastTimestamp++; + else + self.lastTimestamp = now; + + return (NSTimeInterval)(self.lastTimestamp / 1000.0); +} + +#pragma mark - Watch Connectivity + +- (void)startAppleWatchMatching +{ + if (!self.enableAppleWatch) + return; + + if (!CountlyConsentManager.sharedInstance.consentForAppleWatch) + return; + +#if (TARGET_OS_IOS || TARGET_OS_WATCH) + if (@available(iOS 9.0, *)) + { + if (WCSession.isSupported) + { + self.watchDelegate = [CLYWCSessionDelegateInterceptor alloc]; + self.watchDelegate.originalDelegate = WCSession.defaultSession.delegate; + WCSession.defaultSession.delegate = (id)self.watchDelegate; + [WCSession.defaultSession activateSession]; + } + } +#endif + +#if (TARGET_OS_IOS) + if (@available(iOS 9.0, *)) + { + if (WCSession.defaultSession.paired && WCSession.defaultSession.watchAppInstalled) + { + [WCSession.defaultSession transferUserInfo:@{kCountlyParentDeviceIDTransferKey: CountlyDeviceInfo.sharedInstance.deviceID}]; + COUNTLY_LOG(@"Transferring parent device ID %@ ...", CountlyDeviceInfo.sharedInstance.deviceID); + } + } +#endif +} + +#pragma mark - Orientation + +- (void)observeDeviceOrientationChanges +{ +#if (TARGET_OS_IOS) + [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(deviceOrientationDidChange:) name:UIDeviceOrientationDidChangeNotification object:nil]; +#endif +} + +- (void)deviceOrientationDidChange:(NSNotification *)notification +{ + //NOTE: Delay is needed for interface orientation change animation to complete. Otherwise old interface orientation value is returned. + [NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(recordOrientation) object:nil]; + [self performSelector:@selector(recordOrientation) withObject:nil afterDelay:0.5]; +} + +- (void)recordOrientation +{ +#if (TARGET_OS_IOS) + UIInterfaceOrientation interfaceOrientation = UIInterfaceOrientationUnknown; + if (@available(iOS 13.0, *)) + { + interfaceOrientation = UIApplication.sharedApplication.keyWindow.windowScene.interfaceOrientation; + } + else + { + interfaceOrientation = UIApplication.sharedApplication.statusBarOrientation; + } + + NSString* mode = nil; + if (UIInterfaceOrientationIsPortrait(interfaceOrientation)) + mode = @"portrait"; + else if (UIInterfaceOrientationIsLandscape(interfaceOrientation)) + mode = @"landscape"; + + if (!mode) + { + COUNTLY_LOG(@"Interface orientation is not landscape or portrait."); + return; + } + + if ([mode isEqualToString:self.lastInterfaceOrientation]) + { +// COUNTLY_LOG(@"Interface orientation is still same: %@", self.lastInterfaceOrientation); + return; + } + + COUNTLY_LOG(@"Interface orientation is now: %@", mode); + self.lastInterfaceOrientation = mode; + + if (!CountlyConsentManager.sharedInstance.consentForUserDetails) + return; + + [Countly.sharedInstance recordReservedEvent:kCountlyReservedEventOrientation segmentation:@{kCountlyOrientationKeyMode: mode}]; +#endif +} + +#pragma mark - Others + +- (void)startBackgroundTask +{ +#if (TARGET_OS_IOS || TARGET_OS_TV) + if (self.bgTask != UIBackgroundTaskInvalid) + return; + + self.bgTask = [UIApplication.sharedApplication beginBackgroundTaskWithExpirationHandler:^ + { + [UIApplication.sharedApplication endBackgroundTask:self.bgTask]; + self.bgTask = UIBackgroundTaskInvalid; + }]; +#endif +} + +- (void)finishBackgroundTask +{ +#if (TARGET_OS_IOS || TARGET_OS_TV) + if (self.bgTask != UIBackgroundTaskInvalid && !CountlyConnectionManager.sharedInstance.connection) + { + [UIApplication.sharedApplication endBackgroundTask:self.bgTask]; + self.bgTask = UIBackgroundTaskInvalid; + } +#endif +} + +#if (TARGET_OS_IOS || TARGET_OS_TV) +- (UIViewController *)topViewController +{ + UIViewController* topVC = UIApplication.sharedApplication.keyWindow.rootViewController; + + while (YES) + { + if (topVC.presentedViewController) + topVC = topVC.presentedViewController; + else if ([topVC isKindOfClass:UINavigationController.class]) + topVC = ((UINavigationController *)topVC).topViewController; + else if ([topVC isKindOfClass:UITabBarController.class]) + topVC = ((UITabBarController *)topVC).selectedViewController; + else + break; + } + + return topVC; +} + +- (void)tryPresentingViewController:(UIViewController *)viewController +{ + UIViewController* topVC = self.topViewController; + + if (topVC) + { + [topVC presentViewController:viewController animated:YES completion:nil]; + return; + } + + [self performSelector:@selector(tryPresentingViewController:) withObject:viewController afterDelay:1.0]; +} +#endif + +@end + + +#pragma mark - Internal ViewController +#if (TARGET_OS_IOS) +@implementation CLYInternalViewController : UIViewController + +@end + + +@implementation CLYButton : UIButton + +const CGFloat kCountlyDismissButtonSize = 30.0; +const CGFloat kCountlyDismissButtonMargin = 10.0; +const CGFloat kCountlyDismissButtonStandardStatusBarHeight = 20.0; + +- (instancetype)initWithFrame:(CGRect)frame +{ + if (self = [super initWithFrame:frame]) + { + [self addTarget:self action:@selector(touchUpInside:) forControlEvents:UIControlEventTouchUpInside]; + } + + return self; +} + +- (void)touchUpInside:(id)sender +{ + if (self.onClick) + self.onClick(self); +} + ++ (CLYButton *)dismissAlertButton +{ + CLYButton* dismissButton = [CLYButton buttonWithType:UIButtonTypeCustom]; + dismissButton.frame = (CGRect){CGPointZero, kCountlyDismissButtonSize, kCountlyDismissButtonSize}; + [dismissButton setTitle:@"✕" forState:UIControlStateNormal]; + [dismissButton setTitleColor:UIColor.whiteColor forState:UIControlStateNormal]; + dismissButton.backgroundColor = [UIColor.blackColor colorWithAlphaComponent:0.5]; + dismissButton.layer.cornerRadius = dismissButton.bounds.size.width * 0.5; + dismissButton.layer.borderColor = [UIColor.blackColor colorWithAlphaComponent:0.7].CGColor; + dismissButton.layer.borderWidth = 1.0; + dismissButton.autoresizingMask = UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleBottomMargin; + + return dismissButton; +} + +- (void)positionToTopRight +{ + [self positionToTopRight:NO]; +} + +- (void)positionToTopRightConsideringStatusBar +{ + [self positionToTopRight:YES]; +} + +- (void)positionToTopRight:(BOOL)shouldConsiderStatusBar +{ + CGRect rect = self.frame; + rect.origin.x = self.superview.bounds.size.width - self.bounds.size.width - kCountlyDismissButtonMargin; + rect.origin.y = kCountlyDismissButtonMargin; + + if (shouldConsiderStatusBar) + { + if (@available(iOS 11.0, *)) + { + CGFloat top = UIApplication.sharedApplication.keyWindow.safeAreaInsets.top; + if (top) + { + rect.origin.y += top; + } + else + { + rect.origin.y += kCountlyDismissButtonStandardStatusBarHeight; + } + } + else + { + rect.origin.y += kCountlyDismissButtonStandardStatusBarHeight; + } + } + + self.frame = rect; +} + +@end +#endif + + +#pragma mark - Proxy Object +@implementation CLYDelegateInterceptor + +- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel +{ + return [self.originalDelegate methodSignatureForSelector:sel]; +} + +- (void)forwardInvocation:(NSInvocation *)invocation +{ + if ([self.originalDelegate respondsToSelector:invocation.selector]) + [invocation invokeWithTarget:self.originalDelegate]; + else + [super forwardInvocation:invocation]; +} +@end + + +#pragma mark - Watch Delegate Proxy +@implementation CLYWCSessionDelegateInterceptor + +#if (TARGET_OS_WATCH) +- (void)session:(WCSession *)session didReceiveUserInfo:(NSDictionary *)userInfo +{ + COUNTLY_LOG(@"Watch received user info: \n%@", userInfo); + + NSString* parentDeviceID = userInfo[kCountlyParentDeviceIDTransferKey]; + + if (parentDeviceID && ![parentDeviceID isEqualToString:[CountlyPersistency.sharedInstance retrieveWatchParentDeviceID]]) + { + [CountlyConnectionManager.sharedInstance sendParentDeviceID:parentDeviceID]; + + COUNTLY_LOG(@"Parent device ID %@ added to queue.", parentDeviceID); + + [CountlyPersistency.sharedInstance storeWatchParentDeviceID:parentDeviceID]; + } + + if ([self.originalDelegate respondsToSelector:@selector(session:didReceiveUserInfo:)]) + { + COUNTLY_LOG(@"Forwarding WCSession user info to original delegate."); + + [self.originalDelegate session:session didReceiveUserInfo:userInfo]; + } +} +#endif +@end + + +#pragma mark - Categories +NSString* CountlyJSONFromObject(id object) +{ + if (!object) + return nil; + + if (![NSJSONSerialization isValidJSONObject:object]) + { + COUNTLY_LOG(@"Object is not valid for converting to JSON!"); + return nil; + } + + NSError *error = nil; + NSData *data = [NSJSONSerialization dataWithJSONObject:object options:0 error:&error]; + if (error) + { + COUNTLY_LOG(@"JSON can not be created: \n%@", error); + } + + return [data cly_stringUTF8]; +} + +@implementation NSString (Countly) +- (NSString *)cly_URLEscaped +{ + NSCharacterSet* charset = [NSCharacterSet characterSetWithCharactersInString:@"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~"]; + return [self stringByAddingPercentEncodingWithAllowedCharacters:charset]; +} + +- (NSString *)cly_SHA256 +{ + const char* s = [self UTF8String]; + unsigned char digest[CC_SHA256_DIGEST_LENGTH]; + CC_SHA256(s, (CC_LONG)strlen(s), digest); + + NSMutableString* hash = NSMutableString.new; + for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; i++) + [hash appendFormat:@"%02x", digest[i]]; + + return hash; +} + +- (NSData *)cly_dataUTF8 +{ + return [self dataUsingEncoding:NSUTF8StringEncoding]; +} + +- (NSString *)cly_valueForQueryStringKey:(NSString *)key +{ + NSString* tempURLString = [@"http://example.com/path?" stringByAppendingString:self]; + NSURLComponents* URLComponents = [NSURLComponents componentsWithString:tempURLString]; + for (NSURLQueryItem* queryItem in URLComponents.queryItems) + { + if ([queryItem.name isEqualToString:key]) + { + return queryItem.value; + } + } + + return nil; +} +@end + +@implementation NSArray (Countly) +- (NSString *)cly_JSONify +{ + return [CountlyJSONFromObject(self) cly_URLEscaped]; +} +@end + +@implementation NSDictionary (Countly) +- (NSString *)cly_JSONify +{ + return [CountlyJSONFromObject(self) cly_URLEscaped]; +} +@end + +@implementation NSData (Countly) +- (NSString *)cly_stringUTF8 +{ + return [NSString.alloc initWithData:self encoding:NSUTF8StringEncoding]; +} +@end diff --git a/src/ios/CountlyiOS/CountlyConfig.h b/src/ios/CountlyiOS/CountlyConfig.h new file mode 100644 index 0000000..f7b6b49 --- /dev/null +++ b/src/ios/CountlyiOS/CountlyConfig.h @@ -0,0 +1,478 @@ +// CountlyConfig.h +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +//NOTE: Countly features +typedef NSString* CLYFeature NS_EXTENSIBLE_STRING_ENUM; +#if (TARGET_OS_IOS) +#ifndef COUNTLY_EXCLUDE_PUSHNOTIFICATIONS +extern CLYFeature const CLYPushNotifications; +#endif +extern CLYFeature const CLYCrashReporting; +extern CLYFeature const CLYAutoViewTracking; +#elif (TARGET_OS_WATCH) +extern CLYFeature const CLYCrashReporting; +#elif (TARGET_OS_TV) +extern CLYFeature const CLYCrashReporting; +extern CLYFeature const CLYAutoViewTracking; +#elif (TARGET_OS_OSX) +#ifndef COUNTLY_EXCLUDE_PUSHNOTIFICATIONS +extern CLYFeature const CLYPushNotifications; +#endif +extern CLYFeature const CLYCrashReporting; +#endif + + +//NOTE: Device ID options +/** + * Can be used as device ID to switch back to default device ID, if a custom device ID is set before. + * @discussion It can be used as @c deviceID on initial configuration, or passed as an argument for @c deviceID parameter on @c setNewDeviceID:onServer: method. + * @discussion On iOS and tvOS, default device ID is Identifier For Vendor (IDFV). + * @discussion On watchOS and macOS, default device ID is a persistently stored random NSUUID string. + */ +extern NSString* const CLYDefaultDeviceID; + +/** + * Use this as device ID for keeping all requests on hold until the real device ID is set later. + * @discussion It can be used as @c deviceID on initial configuration, or passed as an argument for @c deviceID parameter on @c setNewDeviceID:onServer: method. + * @discussion As long as device ID is @c CLYTemporaryDeviceID, all requests will be on hold, but they will be persistently stored. + * @discussion Later when the real device ID is set using @c setNewDeviceID:onServer: method, all requests kept on hold so far will start with the real device ID. + * @discussion When in @c CLYTemporaryDeviceID mode, method calls for presenting feedback widgets and updating remote config will be ignored. + */ +extern NSString* const CLYTemporaryDeviceID; + +//NOTE: Device ID Types +typedef NSString* CLYDeviceIDType NS_EXTENSIBLE_STRING_ENUM; +extern CLYDeviceIDType const CLYDeviceIDTypeCustom; +extern CLYDeviceIDType const CLYDeviceIDTypeTemporary; +extern CLYDeviceIDType const CLYDeviceIDTypeIDFV; +extern CLYDeviceIDType const CLYDeviceIDTypeNSUUID; + +//NOTE: Legacy device ID options +extern NSString* const CLYIDFV DEPRECATED_MSG_ATTRIBUTE("Please use CLYDefaultDeviceID instead!"); +extern NSString* const CLYIDFA DEPRECATED_MSG_ATTRIBUTE("Please use CLYDefaultDeviceID instead!"); +extern NSString* const CLYOpenUDID DEPRECATED_MSG_ATTRIBUTE("Please use CLYDefaultDeviceID instead!"); + +//NOTE: Available consents +typedef NSString* CLYConsent NS_EXTENSIBLE_STRING_ENUM; +extern CLYConsent const CLYConsentSessions; +extern CLYConsent const CLYConsentEvents; +extern CLYConsent const CLYConsentUserDetails; +extern CLYConsent const CLYConsentCrashReporting; +extern CLYConsent const CLYConsentPushNotifications; +extern CLYConsent const CLYConsentLocation; +extern CLYConsent const CLYConsentViewTracking; +extern CLYConsent const CLYConsentAttribution; +extern CLYConsent const CLYConsentStarRating DEPRECATED_MSG_ATTRIBUTE("Please use CLYConsentFeedback instead!"); +extern CLYConsent const CLYConsentAppleWatch; +extern CLYConsent const CLYConsentPerformanceMonitoring; +extern CLYConsent const CLYConsentFeedback; +extern CLYConsent const CLYConsentRemoteConfig; + +//NOTE: Push Notification Test Modes +typedef NSString* CLYPushTestMode NS_EXTENSIBLE_STRING_ENUM; +extern CLYPushTestMode const CLYPushTestModeDevelopment; +extern CLYPushTestMode const CLYPushTestModeTestFlightOrAdHoc; + +//NOTE: Default metrics +typedef NSString* CLYMetricKey NS_EXTENSIBLE_STRING_ENUM; +extern CLYMetricKey const CLYMetricKeyDevice; +extern CLYMetricKey const CLYMetricKeyDeviceType; +extern CLYMetricKey const CLYMetricKeyOS; +extern CLYMetricKey const CLYMetricKeyOSVersion; +extern CLYMetricKey const CLYMetricKeyAppVersion; +extern CLYMetricKey const CLYMetricKeyCarrier; +extern CLYMetricKey const CLYMetricKeyResolution; +extern CLYMetricKey const CLYMetricKeyDensity; +extern CLYMetricKey const CLYMetricKeyLocale; +extern CLYMetricKey const CLYMetricKeyHasWatch; +extern CLYMetricKey const CLYMetricKeyInstalledWatchApp; + + +@protocol CountlyLoggerDelegate +@required +- (void)internalLog:(NSString *)log; +@end + + +@interface CountlyConfig : NSObject + +/** + * County Server's URL without the slash at the end. + * @discussion e.g. @c https://example.com + * @discussion Host needs to be a non-zero length string, otherwise an exception is thrown. + */ +@property (nonatomic, copy) NSString* host; + +/** + * Application's App Key found on Countly Server's "Management > Applications" section. + * @discussion Using API Key or App ID will not work. + * @discussion App key needs to be a non-zero length string, otherwise an exception is thrown. + */ +@property (nonatomic, copy) NSString* appKey; + +#pragma mark - + +/** + * For enabling SDK debugging mode which prints internal logs. + * @discussion If set, SDK will print internal logs to console for debugging. Internal logging works only for Development environment where @c DEBUG flag is set in Build Settings. + */ +@property (nonatomic) BOOL enableDebug; + +/** + * For receiving SDK's internal logs even in production builds. + * @discussion If set, SDK will forward its internal logs to this delegate object regardless of @c enableDebug initial config value. + * @discussion @c internalLog: method declared as @c required in @c CountlyLoggerDelegate protocol will be called with log @c NSString. + */ +@property (nonatomic, weak) id loggerDelegate; + +#pragma mark - + +/** + * For specifying which features Countly will start with. + * @discussion Available features for each platform: + * @discussion @b iOS: + * @discussion @c CLYPushNotifications for push notifications + * @discussion @c CLYCrashReporting for crash reporting + * @discussion @c CLYAutoViewTracking for auto view tracking + * @discussion @b watchOS: + * @discussion @c CLYCrashReporting for crash reporting + * @discussion @b tvOS: + * @discussion @c CLYCrashReporting for crash reporting + * @discussion @c CLYAutoViewTracking for auto view tracking + * @discussion @b macOS: + * @discussion @c CLYPushNotifications for push notifications + * @discussion @c CLYCrashReporting for crash reporting + */ +@property (nonatomic, copy) NSArray* features; + +#pragma mark - + +/** + * For overriding default metrics (or adding extra ones) sent with @c begin_session requests. + * @discussion Custom metrics should be an @c NSDictionary, with keys and values are both @c NSString 's only. + * @discussion For overriding default metrics, keys should be @c CLYMetricKey 's. + */ +@property (nonatomic, copy) NSDictionary* customMetrics; + +#pragma mark - + +/** + * For limiting features based on user consent. + * @discussion If set, SDK will wait for explicit consent to be given for features to work. + */ +@property (nonatomic) BOOL requiresConsent; + +/** + * For granting consents to features and starting them. + * @discussion This should be an array of feature names to give consent to. + * @discussion Just like in @c giveConsentForFeatures: method. + */ +@property (nonatomic, copy) NSArray* consents; +#pragma mark - + +/** + * @c isTestDevice property is deprecated. Please use @c pushTestMode property instead. + * @discussion Using this property will have no effect. + */ +@property (nonatomic) BOOL isTestDevice DEPRECATED_MSG_ATTRIBUTE("Use 'pushTestMode' property instead!"); + +/** + * For specifying which test mode Countly Server should use for sending push notifications. + * @discussion There are 2 test modes: + * @discussion - @c CLYPushTestModeDevelopment: For development/debug builds signed with a development provisioning profile. Countly Server will send push notifications to Sandbox APNs. + * @discussion - @c CLYPushTestModeTestFlightOrAdHoc: For TestFlight or AdHoc builds signed with a distribution provisioning profile. Countly Server will send push notifications to Production APNs. + * @discussion If set, Test Users mark should be selected on Create Push Notification screen of Countly Server to send push notifications. + * @discussion If not set (or set to @c nil ), Countly Server will use Production APNs by default. + */ +@property (nonatomic, copy) CLYPushTestMode _Nullable pushTestMode; + +/** + * For sending push tokens to Countly Server even for users who have not granted permission to display notifications. + * @discussion Push tokens from users who have not granted permission to display notifications, can be used to send silent notifications. But there will be no notification UI for users to interact. This may cause incorrect push notification interaction stats. + */ +@property (nonatomic) BOOL sendPushTokenAlways; + +/** + * For disabling automatically showing of message alerts by @c CLYPushNotifications feature. + * @discussion If set, push notifications that contain a message or a URL visit request will not show alerts automatically. Push Open event will be recorded automatically, but Push Action event needs to be recorded manually, as well as displaying the message manually. + */ +@property (nonatomic) BOOL doNotShowAlertForNotifications; + +/** + * For handling push notifications for macOS apps on launch. + * @discussion Needs to be set in @c applicationDidFinishLaunching: method of macOS apps that use @c CLYPushNotifications feature, in order to handle app launches by push notification click. + */ +@property (nonatomic, copy) NSNotification* launchNotification; + +#pragma mark - + +/** + * Location latitude and longitude can be specified as @c CLLocationCoordinate2D struct to be used for geo-location based push notifications and advanced segmentation. + * @discussion By default, Countly Server uses a geo-ip database for acquiring user's location. If the app uses Core Location services and granted permission, a location with better accuracy can be provided using this property. + * @discussion It will be sent with @c begin_session requests only. + */ +@property (nonatomic) CLLocationCoordinate2D location; + +/** + * City name can be specified as string to be used for geo-location based push notifications and advanced segmentation. + * @discussion By default, Countly Server uses a geo-ip database for acquiring user's location. If the app has information about user's city, it can be provided using this property. + * @discussion It will be sent with @c begin_session requests only. + */ +@property (nonatomic, copy) NSString* city; + +/** + * ISO country code can be specified in ISO 3166-1 alpha-2 format to be used for geo-location based push notifications and advanced segmentation. + * @discussion By default, Countly Server uses a geo-ip database for acquiring user's location. If the app has information about user's country, it can be provided using this property. + * @discussion It will be sent with @c begin_session requests only. + */ +@property (nonatomic, copy) NSString* ISOCountryCode; + +/** + * IP address can be specified as string to be used for geo-location based push notifications and advanced segmentation. + * @discussion By default, Countly Server uses a geo-ip database for acquiring user's location, and deduces the IP address from the connection. If the app needs to explicitly specify the IP address due to network requirements, it can be provided using this property. + * @discussion It will be sent with @c begin_session requests only. + */ +@property (nonatomic, copy) NSString* IP; + +#pragma mark - + +/** + * @discussion Custom device ID. + * @discussion If not set, default device ID will be used. + * @discussion On iOS and tvOS, default device ID is Identifier For Vendor (IDFV). + * @discussion On watchOS and macOS, default device ID is a persistently stored random NSUUID string. + * @discussion Once set, device ID will be stored persistently and will not change even if another device ID is set on next start, unless @c resetStoredDeviceID flag is set. + */ +@property (nonatomic, copy) NSString* deviceID; + +/** + * For resetting persistently stored device ID on SDK start. + * @discussion If set, persistently stored device ID will be reset and new device ID specified on @c deviceID property of @c CountlyConfig object will be stored and used. + * @discussion It is meant to be used for debugging purposes only while developing. + */ +@property (nonatomic) BOOL resetStoredDeviceID; + +/** + * @c forceDeviceIDInitialization property is deprecated. Please use @c resetStoredDeviceID property instead. + * @discussion Using this property will have no effect. + */ +@property (nonatomic) BOOL forceDeviceIDInitialization DEPRECATED_MSG_ATTRIBUTE("Use 'resetStoredDeviceID' property instead!"); + +/** + * @c applyZeroIDFAFixFor property is deprecated. + * @discussion As IDFA is not supported anymore, @c applyZeroIDFAFix is now inoperative. + * @discussion Using this property will have no effect. + */ +@property (nonatomic) BOOL applyZeroIDFAFix DEPRECATED_MSG_ATTRIBUTE("As IDFA is not supported anymore, 'applyZeroIDFAFix' is now inoperative!"); + +#pragma mark - + +/** + * Update session period is used for updating sessions and sending queued events to Countly Server periodically. + * @discussion If not set, it will be 60 seconds for @c iOS, @c tvOS & @c macOS, and 20 seconds for @c watchOS by default. + */ +@property (nonatomic) NSTimeInterval updateSessionPeriod; + +/** + * Event send threshold is used for sending queued events to Countly Server when number of recorded events reaches to it, without waiting for next update session defined by @c updateSessionPeriod. + * @discussion If not set, it will be 10 for @c iOS, @c tvOS & @c macOS, and 3 for @c watchOS by default. + */ +@property (nonatomic) NSUInteger eventSendThreshold; + +/** + * Stored requests limit is used for limiting the number of request to be stored on the device, in case Countly Server is not reachable. + * @discussion In case Countly Server is down or unreachable for a very long time, queued request may reach excessive numbers, and this may cause problems with requests being sent to Countly Server and being stored on the device. To prevent this, SDK will only store requests up to @c storedRequestsLimit. + * @discussion If number of stored requests reaches @c storedRequestsLimit, SDK will start to drop oldest request while appending the newest one. + * @discussion If not set, it will be 1000 by default. + */ +@property (nonatomic) NSUInteger storedRequestsLimit; + +/** + * For sending all requests using HTTP POST method. + * @discussion If set, all requests will be sent using HTTP POST method. Otherwise; only the requests with a file upload or data size more than 2048 bytes will be sent using HTTP POST method. + */ +@property (nonatomic) BOOL alwaysUsePOST; + +#pragma mark - + +/** + * For handling sessions manually. + * @discussion If set, SDK does not handle beginning, updating and ending sessions automatically. Methods @c beginSession, @c updateSession and @c endSession need to be called manually. + */ +@property (nonatomic) BOOL manualSessionHandling; + +/** + * For enabling automatic handling of Apple Watch related features for iOS apps with a watchOS counterpart app. + * @discussion If set on both iOS and watchOS app, Apple Watch related features such as parent device matching, pairing status, and watch app installing status will be handled automatically. + * @discussion This flag should not be set on independent watchOS apps. + */ +@property (nonatomic) BOOL enableAppleWatch; + +#pragma mark - + +/** + * For specifying attribution ID (IDFA) for campaign attribution. + * @discussion If set, this attribution ID will be sent with all @c begin_session requests. + */ +@property (nonatomic, copy) NSString* attributionID; + +/** + * @c enableAttribution property is deprecated. Please use @c recordAttributionID method instead. + * @discussion Using this property will have no effect. + */ +@property (nonatomic) BOOL enableAttribution DEPRECATED_MSG_ATTRIBUTE("Use 'attributionID' property instead!"); + +#pragma mark - + +/** + * For using custom crash segmentation with @c CLYCrashReporting feature. + * @discussion Crash segmentation should be an @c NSDictionary, with keys and values are both @c NSString's only. + * @discussion Custom objects in crash segmentation will cause crash report not to be sent to Countly Server. + * @discussion Nested values in crash segmentation will be ignored by Countly Server. + */ +@property (nonatomic, copy) NSDictionary* crashSegmentation; + +/** + * Crash log limit is used for limiting the number of crash logs to be stored on the device. + * @discussion If number of stored crash logs reaches @c crashLogLimit, SDK will start to drop oldest crash log while appending the newest one. + * @discussion If not set, it will be 100 by default. + */ +@property (nonatomic) NSUInteger crashLogLimit; + +/** + * Regular expression used for filtering crash reports and preventing them from being sent to Countly Server. + * @discussion If a crash's name, description or any line of stack trace matches given regular expression, it will not be sent to Countly Server. + */ +@property (nonatomic, copy) NSRegularExpression* crashFilter; + +/** + * For using PLCrashReporter instead of default crash handling mechanism. + * @discussion If set, SDK will be using PLCrashReporter (1.x.x series) dependency for creating crash reports. + * @discussion PLCrashReporter option is available only for iOS apps. + * @discussion For more information about PLCrashReporter please see: https://github.com/microsoft/plcrashreporter + */ +@property (nonatomic) BOOL shouldUsePLCrashReporter; + +/** + * For using Mach type signal handler with PLCrashReporter. + * @discussion PLCrashReporter has two different signal handling implementations with different traits: + * @discussion 1) BSD: PLCrashReporterSignalHandlerTypeBSD + * @discussion 2) Mach: PLCrashReporterSignalHandlerTypeMach + * @discussion For more information about PLCrashReporter please see: https://github.com/microsoft/plcrashreporter + * @discussion By default, BSD type will be used. + */ +@property (nonatomic) BOOL shouldUseMachSignalHandler; + +/** + * Callback block to be executed when the app is launched again following a crash which is detected by PLCrashReporter on the previous session. + * @discussion It has an @c NSDictionary parameter that represents crash report object. + * @discussion If @c shouldUsePLCrashReporter flag is not set on initial config, it will never be executed. + */ +@property (nonatomic, copy) void (^crashOccuredOnPreviousSessionCallback)(NSDictionary * crashReport); + +/** + * Callback block to decide whether the crash report detected by PLCrashReporter should be sent to Countly Server or not. + * @discussion If not set, crash report will be sent to Countly Server by default. + * @discussion If set, crash report will be sent to Countly Server only if `YES` is returned. + * @discussion It has an @c NSDictionary parameter that represents crash report object. + * @discussion If @c shouldUsePLCrashReporter flag is not set on initial config, it will never be executed. + */ +@property (nonatomic, copy) BOOL (^shouldSendCrashReportCallback)(NSDictionary * crashReport); + +#pragma mark - + +/** + * For specifying bundled certificates to be used for public key pinning. + * @discussion Certificates have to be DER encoded with one of the following extensions: @c .der @c .cer or @c .crt + * @discussion e.g. @c myserver.com.cer + */ +@property (nonatomic, copy) NSArray* pinnedCertificates; + +/** + * Name of the custom HTTP header field to be sent with every request. + * @discussion e.g. X-My-Secret-Server-Token + * @discussion If set, every request sent to Countly Server will have this custom HTTP header and its value will be @c customHeaderFieldValue property. + * @discussion If @c customHeaderFieldValue is not set when Countly is started, requests will not start until it is set using @c setCustomHeaderFieldValue: method later. + */ +@property (nonatomic, copy) NSString* customHeaderFieldName; + +/** + * Value of the custom HTTP header field to be sent with every request if @c customHeaderFieldName is set. + * @discussion If not set while @c customHeaderFieldName is set, requests will not start until it is set using @c setCustomHeaderFieldValue: method later. + */ +@property (nonatomic, copy) NSString* customHeaderFieldValue; + +/** + * Salt value to be used for parameter tampering protection. + * @discussion If set, every request sent to Countly Server will have @c checksum256 value generated by SHA256(request + secretSalt) + */ +@property (nonatomic, copy) NSString* secretSalt; + +/** + * Custom URL session configuration to be used with all requests. + * @discussion If not set, @c NSURLSessionConfiguration's @c defaultSessionConfiguration will be used by default. + */ +@property (nonatomic, copy) NSURLSessionConfiguration* URLSessionConfiguration; + +#pragma mark - + +/** + * For customizing star-rating dialog message. + * @discussion If not set, it will be displayed in English: "How would you rate the app?" or corresponding supported (@c en, @c tr, @c jp, @c zh, @c ru, @c lv, @c cz, @c bn) localized version. + */ +@property (nonatomic, copy) NSString* starRatingMessage; + +/** + * For displaying star-rating dialog depending on session count, once for each new version of the app. + * @discussion If set, when total number of sessions reaches @c starRatingSessionCount, an alert view asking for 1 to 5 star-rating will be displayed automatically, once for each new version of the app. + */ +@property (nonatomic) NSUInteger starRatingSessionCount; + +/** + * Disables automatically displaying of star-rating dialog for each new version of the app. + * @discussion If set, star-rating dialog will be displayed automatically only once for the whole life of the app. It will not be displayed for each new version. + */ +@property (nonatomic) BOOL starRatingDisableAskingForEachAppVersion; + +/** + * Completion block to be executed after star-rating dialog is shown automatically. + * @discussion Completion block has a single NSInteger parameter that indicates 1 to 5 star-rating given by user. If user dismissed dialog without giving a rating, this value will be 0 and it will not be reported to Countly Server. + */ +@property (nonatomic, copy) void (^starRatingCompletion)(NSInteger rating); + +#pragma mark - + +/** + * For enabling automatic fetching of remote config values. + * @discussion If set, Remote Config values specified on Countly Server will be fetched on beginning of sessions. + */ +@property (nonatomic) BOOL enableRemoteConfig; + +/** + * Completion block to be executed after remote config is fetched from Countly Server, on start or device ID change. + * @discussion This completion block can be used to detect updating of remote config values is completed, either with success or failure. + * @discussion It has an @c NSError parameter that will be either @ nil or an @c NSError object, depending of request result. + * @discussion If there is no error, it will be executed with an @c nil, which means latest remote config values are ready to be used. + * @discussion If Countly Server is not reachable or if there is another error, it will be executed with an @c NSError indicating the problem. + * @discussion If @c enableRemoteConfig flag is not set on initial config, it will never be executed. + */ +@property (nonatomic, copy) void (^remoteConfigCompletionHandler)(NSError * _Nullable error); + +#pragma mark - + +/** + * For enabling automatic performance monitoring. + * @discussion If set, Performance Monitoring feature will be started automatically on SDK start. + */ +@property (nonatomic) BOOL enablePerformanceMonitoring; +NS_ASSUME_NONNULL_END + +@end diff --git a/src/ios/CountlyiOS/CountlyConfig.m b/src/ios/CountlyiOS/CountlyConfig.m new file mode 100644 index 0000000..175c817 --- /dev/null +++ b/src/ios/CountlyiOS/CountlyConfig.m @@ -0,0 +1,64 @@ +// CountlyConfig.m +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import "CountlyCommon.h" + +@implementation CountlyConfig + +//NOTE: Countly features +#if (TARGET_OS_IOS) +CLYFeature const CLYPushNotifications = @"CLYPushNotifications"; +CLYFeature const CLYCrashReporting = @"CLYCrashReporting"; +CLYFeature const CLYAutoViewTracking = @"CLYAutoViewTracking"; +#elif (TARGET_OS_WATCH) +CLYFeature const CLYCrashReporting = @"CLYCrashReporting"; +#elif (TARGET_OS_TV) +CLYFeature const CLYCrashReporting = @"CLYCrashReporting"; +CLYFeature const CLYAutoViewTracking = @"CLYAutoViewTracking"; +#elif (TARGET_OS_OSX) +CLYFeature const CLYPushNotifications = @"CLYPushNotifications"; +CLYFeature const CLYCrashReporting = @"CLYCrashReporting"; +#endif + + +//NOTE: Device ID options +NSString* const CLYDefaultDeviceID = @""; //NOTE: It will be overridden to default device ID mechanism, depending on platform. +NSString* const CLYTemporaryDeviceID = @"CLYTemporaryDeviceID"; + +//NOTE: Device ID Types +CLYDeviceIDType const CLYDeviceIDTypeCustom = @"CLYDeviceIDTypeCustom"; +CLYDeviceIDType const CLYDeviceIDTypeTemporary = @"CLYDeviceIDTypeTemporary"; +CLYDeviceIDType const CLYDeviceIDTypeIDFV = @"CLYDeviceIDTypeIDFV"; +CLYDeviceIDType const CLYDeviceIDTypeNSUUID = @"CLYDeviceIDTypeNSUUID"; + +//NOTE: Legacy device ID options. They will fallback to default device ID. +NSString* const CLYIDFA = CLYDefaultDeviceID; +NSString* const CLYIDFV = CLYDefaultDeviceID; +NSString* const CLYOpenUDID = CLYDefaultDeviceID; + +- (instancetype)init +{ + if (self = [super init]) + { +#if (TARGET_OS_WATCH) + self.updateSessionPeriod = 20.0; + self.eventSendThreshold = 3; +#else + self.updateSessionPeriod = 60.0; + self.eventSendThreshold = 10; +#endif + self.storedRequestsLimit = 1000; + self.crashLogLimit = 100; + + self.location = kCLLocationCoordinate2DInvalid; + + self.URLSessionConfiguration = NSURLSessionConfiguration.defaultSessionConfiguration; + } + + return self; +} + +@end diff --git a/src/ios/CountlyiOS/CountlyConnectionManager.h b/src/ios/CountlyiOS/CountlyConnectionManager.h new file mode 100644 index 0000000..6bbab5a --- /dev/null +++ b/src/ios/CountlyiOS/CountlyConnectionManager.h @@ -0,0 +1,58 @@ +// CountlyConnectionManager.h +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import + +extern NSString* const kCountlyQSKeyAppKey; +extern NSString* const kCountlyQSKeyDeviceID; +extern NSString* const kCountlyQSKeySDKVersion; +extern NSString* const kCountlyQSKeySDKName; +extern NSString* const kCountlyQSKeyMethod; +extern NSString* const kCountlyQSKeyMetrics; + +extern NSString* const kCountlyEndpointI; +extern NSString* const kCountlyEndpointO; +extern NSString* const kCountlyEndpointSDK; +extern NSString* const kCountlyEndpointFeedback; +extern NSString* const kCountlyEndpointWidget; + +@interface CountlyConnectionManager : NSObject + +@property (nonatomic) NSString* appKey; +@property (nonatomic) NSString* host; +@property (nonatomic) NSURLSessionTask* connection; +@property (nonatomic) NSArray* pinnedCertificates; +@property (nonatomic) NSString* customHeaderFieldName; +@property (nonatomic) NSString* customHeaderFieldValue; +@property (nonatomic) NSString* secretSalt; +@property (nonatomic) BOOL alwaysUsePOST; +@property (nonatomic) NSURLSessionConfiguration* URLSessionConfiguration; + +@property (nonatomic) BOOL isTerminating; + ++ (instancetype)sharedInstance; + +- (void)beginSession; +- (void)updateSession; +- (void)endSession; + +- (void)sendEvents; +- (void)sendPushToken:(NSString *)token; +- (void)sendLocationInfo; +- (void)sendUserDetails:(NSString *)userDetails; +- (void)sendCrashReport:(NSString *)report immediately:(BOOL)immediately; +- (void)sendOldDeviceID:(NSString *)oldDeviceID; +- (void)sendParentDeviceID:(NSString *)parentDeviceID; +- (void)sendAttribution; +- (void)sendConsentChanges:(NSString *)consentChanges; +- (void)sendPerformanceMonitoringTrace:(NSString *)trace; + +- (void)proceedOnQueue; + +- (NSString *)queryEssentials; +- (NSString *)appendChecksum:(NSString *)queryString; + +@end diff --git a/src/ios/CountlyiOS/CountlyConnectionManager.m b/src/ios/CountlyiOS/CountlyConnectionManager.m new file mode 100644 index 0000000..e2b81f6 --- /dev/null +++ b/src/ios/CountlyiOS/CountlyConnectionManager.m @@ -0,0 +1,682 @@ +// CountlyConnectionManager.m +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import "CountlyCommon.h" + +@interface CountlyConnectionManager () +{ + NSTimeInterval unsentSessionLength; + NSTimeInterval lastSessionStartTime; + BOOL isCrashing; +} +@property (nonatomic) NSURLSession* URLSession; +@end + +NSString* const kCountlyQSKeyAppKey = @"app_key"; + +NSString* const kCountlyQSKeyDeviceID = @"device_id"; +NSString* const kCountlyQSKeyDeviceIDOld = @"old_device_id"; +NSString* const kCountlyQSKeyDeviceIDParent = @"parent_device_id"; + +NSString* const kCountlyQSKeyTimestamp = @"timestamp"; +NSString* const kCountlyQSKeyTimeZone = @"tz"; +NSString* const kCountlyQSKeyTimeHourOfDay = @"hour"; +NSString* const kCountlyQSKeyTimeDayOfWeek = @"dow"; + +NSString* const kCountlyQSKeySDKVersion = @"sdk_version"; +NSString* const kCountlyQSKeySDKName = @"sdk_name"; + +NSString* const kCountlyQSKeySessionBegin = @"begin_session"; +NSString* const kCountlyQSKeySessionDuration = @"session_duration"; +NSString* const kCountlyQSKeySessionEnd = @"end_session"; + +NSString* const kCountlyQSKeyPushTokenSession = @"token_session"; +NSString* const kCountlyQSKeyPushTokeniOS = @"ios_token"; +NSString* const kCountlyQSKeyPushTestMode = @"test_mode"; + +NSString* const kCountlyQSKeyLocation = @"location"; +NSString* const kCountlyQSKeyLocationCity = @"city"; +NSString* const kCountlyQSKeyLocationCountry = @"country_code"; +NSString* const kCountlyQSKeyLocationIP = @"ip_address"; + +NSString* const kCountlyQSKeyMetrics = @"metrics"; +NSString* const kCountlyQSKeyEvents = @"events"; +NSString* const kCountlyQSKeyUserDetails = @"user_details"; +NSString* const kCountlyQSKeyCrash = @"crash"; +NSString* const kCountlyQSKeyChecksum256 = @"checksum256"; +NSString* const kCountlyQSKeyAttributionID = @"aid"; +NSString* const kCountlyQSKeyIDFA = @"idfa"; +NSString* const kCountlyQSKeyConsent = @"consent"; +NSString* const kCountlyQSKeyAPM = @"apm"; + +NSString* const kCountlyQSKeyMethod = @"method"; + +NSString* const kCountlyUploadBoundary = @"0cae04a8b698d63ff6ea55d168993f21"; + +NSString* const kCountlyEndpointI = @"/i"; //NOTE: input endpoint +NSString* const kCountlyEndpointO = @"/o"; //NOTE: output endpoint +NSString* const kCountlyEndpointSDK = @"/sdk"; +NSString* const kCountlyEndpointFeedback = @"/feedback"; +NSString* const kCountlyEndpointWidget = @"/widget"; + +const NSInteger kCountlyGETRequestMaxLength = 2048; + +@implementation CountlyConnectionManager : NSObject + ++ (instancetype)sharedInstance +{ + if (!CountlyCommon.sharedInstance.hasStarted) + return nil; + + static CountlyConnectionManager *s_sharedInstance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{s_sharedInstance = self.new;}); + return s_sharedInstance; +} + +- (instancetype)init +{ + if (self = [super init]) + { + unsentSessionLength = 0.0; + } + + return self; +} + +- (void)proceedOnQueue +{ + COUNTLY_LOG(@"Proceeding on queue..."); + + if (self.connection) + { + COUNTLY_LOG(@"Proceeding on queue is aborted: Already has a request in process!"); + return; + } + + if (isCrashing) + { + COUNTLY_LOG(@"Proceeding on queue is aborted: Application is crashing!"); + return; + } + + if (self.isTerminating) + { + COUNTLY_LOG(@"Proceeding on queue is aborted: Application is terminating!"); + return; + } + + if (self.customHeaderFieldName && !self.customHeaderFieldValue) + { + COUNTLY_LOG(@"Proceeding on queue is aborted: customHeaderFieldName specified on config, but customHeaderFieldValue not set yet!"); + return; + } + + if (CountlyPersistency.sharedInstance.isQueueBeingModified) + { + COUNTLY_LOG(@"Proceeding on queue is aborted: Queue is being modified!"); + return; + } + + NSString* firstItemInQueue = [CountlyPersistency.sharedInstance firstItemInQueue]; + if (!firstItemInQueue) + { + COUNTLY_LOG(@"Queue is empty. All requests are processed."); + return; + } + + NSString* temporaryDeviceIDQueryString = [NSString stringWithFormat:@"&%@=%@", kCountlyQSKeyDeviceID, CLYTemporaryDeviceID]; + if ([firstItemInQueue containsString:temporaryDeviceIDQueryString]) + { + COUNTLY_LOG(@"Proceeding on queue is aborted: Device ID in request is CLYTemporaryDeviceID!"); + return; + } + + [CountlyCommon.sharedInstance startBackgroundTask]; + + NSString* queryString = firstItemInQueue; + + queryString = [self appendChecksum:queryString]; + + NSString* serverInputEndpoint = [self.host stringByAppendingString:kCountlyEndpointI]; + NSString* fullRequestURL = [serverInputEndpoint stringByAppendingFormat:@"?%@", queryString]; + NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:fullRequestURL]]; + + NSData* pictureUploadData = [self pictureUploadDataForQueryString:queryString]; + if (pictureUploadData) + { + NSString *contentType = [@"multipart/form-data; boundary=" stringByAppendingString:kCountlyUploadBoundary]; + [request addValue:contentType forHTTPHeaderField: @"Content-Type"]; + request.HTTPMethod = @"POST"; + request.HTTPBody = pictureUploadData; + } + else if (queryString.length > kCountlyGETRequestMaxLength || self.alwaysUsePOST) + { + request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:serverInputEndpoint]]; + request.HTTPMethod = @"POST"; + request.HTTPBody = [queryString cly_dataUTF8]; + } + + if (self.customHeaderFieldName && self.customHeaderFieldValue) + [request setValue:self.customHeaderFieldValue forHTTPHeaderField:self.customHeaderFieldName]; + + request.cachePolicy = NSURLRequestReloadIgnoringLocalCacheData; + + self.connection = [self.URLSession dataTaskWithRequest:request completionHandler:^(NSData * data, NSURLResponse * response, NSError * error) + { + self.connection = nil; + + if (!error) + { + if ([self isRequestSuccessful:response]) + { + COUNTLY_LOG(@"Request <%p> successfully completed.", request); + + [CountlyPersistency.sharedInstance removeFromQueue:firstItemInQueue]; + + [CountlyPersistency.sharedInstance saveToFile]; + + [self proceedOnQueue]; + } + else + { + COUNTLY_LOG(@"Request <%p> failed!\nServer reply: %@", request, [data cly_stringUTF8]); + } + } + else + { + COUNTLY_LOG(@"Request <%p> failed!\nError: %@", request, error); +#if (TARGET_OS_WATCH) + [CountlyPersistency.sharedInstance saveToFile]; +#endif + } + }]; + + [self.connection resume]; + + COUNTLY_LOG(@"Request <%p> started:\n[%@] %@ \n%@", (id)request, request.HTTPMethod, request.URL.absoluteString, request.HTTPBody ? ([request.HTTPBody cly_stringUTF8] ?: @"Picture uploading...") : @""); +} + +#pragma mark --- + +- (void)beginSession +{ + if (!CountlyConsentManager.sharedInstance.consentForSessions) + return; + + lastSessionStartTime = NSDate.date.timeIntervalSince1970; + unsentSessionLength = 0.0; + + NSString* queryString = [[self queryEssentials] stringByAppendingFormat:@"&%@=%@&%@=%@", + kCountlyQSKeySessionBegin, @"1", + kCountlyQSKeyMetrics, [CountlyDeviceInfo metrics]]; + + NSString* locationRelatedInfoQueryString = [self locationRelatedInfoQueryString]; + if (locationRelatedInfoQueryString) + queryString = [queryString stringByAppendingString:locationRelatedInfoQueryString]; + + NSString* attributionQueryString = [self attributionQueryString]; + if (attributionQueryString) + queryString = [queryString stringByAppendingString:attributionQueryString]; + + [CountlyPersistency.sharedInstance addToQueue:queryString]; + + [self proceedOnQueue]; +} + +- (void)updateSession +{ + if (!CountlyConsentManager.sharedInstance.consentForSessions) + return; + + NSString* queryString = [[self queryEssentials] stringByAppendingFormat:@"&%@=%d", + kCountlyQSKeySessionDuration, (int)[self sessionLengthInSeconds]]; + + [CountlyPersistency.sharedInstance addToQueue:queryString]; + + [self proceedOnQueue]; +} + +- (void)endSession +{ + if (!CountlyConsentManager.sharedInstance.consentForSessions) + return; + + NSString* queryString = [[self queryEssentials] stringByAppendingFormat:@"&%@=%@&%@=%d", + kCountlyQSKeySessionEnd, @"1", + kCountlyQSKeySessionDuration, (int)[self sessionLengthInSeconds]]; + + [CountlyPersistency.sharedInstance addToQueue:queryString]; + + [self proceedOnQueue]; +} + +#pragma mark --- + +- (void)sendEvents +{ + NSString* events = [CountlyPersistency.sharedInstance serializedRecordedEvents]; + + if (!events) + return; + + NSString* queryString = [[self queryEssentials] stringByAppendingFormat:@"&%@=%@", + kCountlyQSKeyEvents, events]; + + [CountlyPersistency.sharedInstance addToQueue:queryString]; + + [self proceedOnQueue]; +} + +#pragma mark --- + +- (void)sendPushToken:(NSString *)token +{ +#ifndef COUNTLY_EXCLUDE_PUSHNOTIFICATIONS + NSInteger testMode = 0; //NOTE: default is 0: Production - not test mode + + if ([CountlyPushNotifications.sharedInstance.pushTestMode isEqualToString:CLYPushTestModeDevelopment]) + testMode = 1; //NOTE: 1: Developement/Debug builds - standard test mode using Sandbox APNs + else if ([CountlyPushNotifications.sharedInstance.pushTestMode isEqualToString:CLYPushTestModeTestFlightOrAdHoc]) + testMode = 2; //NOTE: 2: TestFlight/AdHoc builds - special test mode using Production APNs + + NSString* queryString = [[self queryEssentials] stringByAppendingFormat:@"&%@=%@&%@=%@&%@=%ld", + kCountlyQSKeyPushTokenSession, @"1", + kCountlyQSKeyPushTokeniOS, token, + kCountlyQSKeyPushTestMode, (long)testMode]; + + [CountlyPersistency.sharedInstance addToQueue:queryString]; + + [self proceedOnQueue]; +#endif +} + +- (void)sendLocationInfo +{ + NSString* locationRelatedInfoQueryString = [self locationRelatedInfoQueryString]; + + if (!locationRelatedInfoQueryString) + return; + + NSString* queryString = [[self queryEssentials] stringByAppendingString:locationRelatedInfoQueryString]; + + [CountlyPersistency.sharedInstance addToQueue:queryString]; + + [self proceedOnQueue]; +} + +- (void)sendUserDetails:(NSString *)userDetails +{ + NSString* queryString = [[self queryEssentials] stringByAppendingFormat:@"&%@=%@", + kCountlyQSKeyUserDetails, userDetails]; + + [CountlyPersistency.sharedInstance addToQueue:queryString]; + + [self proceedOnQueue]; +} + +- (void)sendCrashReport:(NSString *)report immediately:(BOOL)immediately; +{ + if (!report) + { + COUNTLY_LOG(@"Crash report is nil. Converting to JSON may have failed due to custom objects in initial config's crashSegmentation property."); + return; + } + + NSString* queryString = [[self queryEssentials] stringByAppendingFormat:@"&%@=%@", + kCountlyQSKeyCrash, report]; + + if (!immediately) + { + [CountlyPersistency.sharedInstance addToQueue:queryString]; + [self proceedOnQueue]; + return; + } + + //NOTE: Prevent `event` and `end_session` requests from being started, after `sendEvents` and `endSession` calls below. + isCrashing = YES; + + [self sendEvents]; + + if (!CountlyCommon.sharedInstance.manualSessionHandling) + [self endSession]; + + if (self.customHeaderFieldName && !self.customHeaderFieldValue) + { + COUNTLY_LOG(@"customHeaderFieldName specified on config, but customHeaderFieldValue not set! Crash report stored to be sent later!"); + + [CountlyPersistency.sharedInstance addToQueue:queryString]; + [CountlyPersistency.sharedInstance saveToFileSync]; + return; + } + + if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) + { + COUNTLY_LOG(@"Device ID is set as CLYTemporaryDeviceID! Crash report stored to be sent later!"); + + [CountlyPersistency.sharedInstance addToQueue:queryString]; + [CountlyPersistency.sharedInstance saveToFileSync]; + return; + } + + [CountlyPersistency.sharedInstance saveToFileSync]; + + NSString* serverInputEndpoint = [self.host stringByAppendingString:kCountlyEndpointI]; + NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:serverInputEndpoint]]; + request.HTTPMethod = @"POST"; + request.HTTPBody = [[self appendChecksum:queryString] cly_dataUTF8]; + + if (self.customHeaderFieldName && self.customHeaderFieldValue) + [request setValue:self.customHeaderFieldValue forHTTPHeaderField:self.customHeaderFieldName]; + + dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); + + [[self.URLSession dataTaskWithRequest:request completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) + { + if (error || ![self isRequestSuccessful:response]) + { + COUNTLY_LOG(@"Crash Report Request <%p> failed!\n%@: %@", request, error ? @"Error" : @"Server reply", error ?: [data cly_stringUTF8]); + [CountlyPersistency.sharedInstance addToQueue:queryString]; + [CountlyPersistency.sharedInstance saveToFileSync]; + } + else + { + COUNTLY_LOG(@"Crash Report Request <%p> successfully completed.", request); + } + + dispatch_semaphore_signal(semaphore); + + }] resume]; + + COUNTLY_LOG(@"Crash Report Request <%p> started:\n[%@] %@ \n%@", (id)request, request.HTTPMethod, request.URL.absoluteString, [request.HTTPBody cly_stringUTF8]); + + dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER); +} + +- (void)sendOldDeviceID:(NSString *)oldDeviceID +{ + NSString* queryString = [[self queryEssentials] stringByAppendingFormat:@"&%@=%@", + kCountlyQSKeyDeviceIDOld, oldDeviceID.cly_URLEscaped]; + + [CountlyPersistency.sharedInstance addToQueue:queryString]; + + [self proceedOnQueue]; +} + +- (void)sendParentDeviceID:(NSString *)parentDeviceID +{ + NSString* queryString = [[self queryEssentials] stringByAppendingFormat:@"&%@=%@", + kCountlyQSKeyDeviceIDParent, parentDeviceID.cly_URLEscaped]; + + [CountlyPersistency.sharedInstance addToQueue:queryString]; + + [self proceedOnQueue]; +} + +- (void)sendAttribution +{ + NSString * attributionQueryString = [self attributionQueryString]; + if (!attributionQueryString) + return; + + NSString* queryString = [[self queryEssentials] stringByAppendingString:attributionQueryString]; + + [CountlyPersistency.sharedInstance addToQueue:queryString]; + + [self proceedOnQueue]; +} + +- (void)sendConsentChanges:(NSString *)consentChanges +{ + NSString* queryString = [[self queryEssentials] stringByAppendingFormat:@"&%@=%@", + kCountlyQSKeyConsent, consentChanges]; + + [CountlyPersistency.sharedInstance addToQueue:queryString]; + + [self proceedOnQueue]; +} + +- (void)sendPerformanceMonitoringTrace:(NSString *)trace +{ + NSString* queryString = [[self queryEssentials] stringByAppendingFormat:@"&%@=%@", + kCountlyQSKeyAPM, trace]; + + [CountlyPersistency.sharedInstance addToQueue:queryString]; + + [self proceedOnQueue]; +} + +#pragma mark --- + +- (NSString *)queryEssentials +{ + return [NSString stringWithFormat:@"%@=%@&%@=%@&%@=%lld&%@=%d&%@=%d&%@=%d&%@=%@&%@=%@", + kCountlyQSKeyAppKey, self.appKey.cly_URLEscaped, + kCountlyQSKeyDeviceID, CountlyDeviceInfo.sharedInstance.deviceID.cly_URLEscaped, + kCountlyQSKeyTimestamp, (long long)(CountlyCommon.sharedInstance.uniqueTimestamp * 1000), + kCountlyQSKeyTimeHourOfDay, (int)CountlyCommon.sharedInstance.hourOfDay, + kCountlyQSKeyTimeDayOfWeek, (int)CountlyCommon.sharedInstance.dayOfWeek, + kCountlyQSKeyTimeZone, (int)CountlyCommon.sharedInstance.timeZone, + kCountlyQSKeySDKVersion, CountlyCommon.sharedInstance.SDKVersion, + kCountlyQSKeySDKName, CountlyCommon.sharedInstance.SDKName]; +} + +- (NSString *)locationRelatedInfoQueryString +{ + if (!CountlyConsentManager.sharedInstance.consentForLocation || CountlyLocationManager.sharedInstance.isLocationInfoDisabled) + { + //NOTE: Return empty string for location. This is a server requirement to disable IP based location inferring. + return [NSString stringWithFormat:@"&%@=%@", kCountlyQSKeyLocation, @""]; + } + + NSString* location = CountlyLocationManager.sharedInstance.location.cly_URLEscaped; + NSString* city = CountlyLocationManager.sharedInstance.city.cly_URLEscaped; + NSString* ISOCountryCode = CountlyLocationManager.sharedInstance.ISOCountryCode.cly_URLEscaped; + NSString* IP = CountlyLocationManager.sharedInstance.IP.cly_URLEscaped; + + NSMutableString* locationInfoQueryString = NSMutableString.new; + + if (location) + [locationInfoQueryString appendFormat:@"&%@=%@", kCountlyQSKeyLocation, location]; + + if (city) + [locationInfoQueryString appendFormat:@"&%@=%@", kCountlyQSKeyLocationCity, city]; + + if (ISOCountryCode) + [locationInfoQueryString appendFormat:@"&%@=%@", kCountlyQSKeyLocationCountry, ISOCountryCode]; + + if (IP) + [locationInfoQueryString appendFormat:@"&%@=%@", kCountlyQSKeyLocationIP, IP]; + + if (locationInfoQueryString.length) + return locationInfoQueryString.copy; + + return nil; +} + +- (NSString *)attributionQueryString +{ + if (!CountlyConsentManager.sharedInstance.consentForAttribution) + return nil; + + if (!CountlyCommon.sharedInstance.attributionID) + return nil; + + NSDictionary* attribution = @{kCountlyQSKeyIDFA: CountlyCommon.sharedInstance.attributionID}; + + return [NSString stringWithFormat:@"&%@=%@", kCountlyQSKeyAttributionID, [attribution cly_JSONify]]; +} + +- (NSData *)pictureUploadDataForQueryString:(NSString *)queryString +{ +#if (TARGET_OS_IOS) + NSString* localPicturePath = nil; + + NSString* userDetails = [queryString cly_valueForQueryStringKey:kCountlyQSKeyUserDetails]; + NSString* unescapedUserDetails = [userDetails stringByRemovingPercentEncoding]; + if (!unescapedUserDetails) + return nil; + + NSDictionary* pathDictionary = [NSJSONSerialization JSONObjectWithData:[unescapedUserDetails cly_dataUTF8] options:0 error:nil]; + localPicturePath = pathDictionary[kCountlyLocalPicturePath]; + + if (!localPicturePath.length) + return nil; + + COUNTLY_LOG(@"Local picture path successfully extracted from query string: %@", localPicturePath); + + NSArray* allowedFileTypes = @[@"gif", @"png", @"jpg", @"jpeg"]; + NSString* fileExt = localPicturePath.pathExtension.lowercaseString; + NSInteger fileExtIndex = [allowedFileTypes indexOfObject:fileExt]; + + if (fileExtIndex == NSNotFound) + return nil; + + NSData* imageData = [NSData dataWithContentsOfURL:[NSURL URLWithString:localPicturePath]]; + + if (!imageData) + { + COUNTLY_LOG(@"Local picture data can not be read!"); + return nil; + } + + COUNTLY_LOG(@"Local picture data read successfully."); + + //NOTE: Overcome failing PNG file upload if data is directly read from disk + if (fileExtIndex == 1) + imageData = UIImagePNGRepresentation([UIImage imageWithData:imageData]); + + //NOTE: Remap content type from jpg to jpeg + if (fileExtIndex == 2) + fileExtIndex = 3; + + NSString* boundaryStart = [NSString stringWithFormat:@"--%@\r\n", kCountlyUploadBoundary]; + NSString* contentDisposition = [NSString stringWithFormat:@"Content-Disposition: form-data; name=\"pictureFile\"; filename=\"%@\"\r\n", localPicturePath.lastPathComponent]; + NSString* contentType = [NSString stringWithFormat:@"Content-Type: image/%@\r\n\r\n", allowedFileTypes[fileExtIndex]]; + NSString* boundaryEnd = [NSString stringWithFormat:@"\r\n--%@--\r\n", kCountlyUploadBoundary]; + + NSMutableData* uploadData = NSMutableData.new; + [uploadData appendData:[boundaryStart cly_dataUTF8]]; + [uploadData appendData:[contentDisposition cly_dataUTF8]]; + [uploadData appendData:[contentType cly_dataUTF8]]; + [uploadData appendData:imageData]; + [uploadData appendData:[boundaryEnd cly_dataUTF8]]; + return uploadData; +#endif + return nil; +} + +- (NSString *)appendChecksum:(NSString *)queryString +{ + if (self.secretSalt) + { + NSString* checksum = [[queryString stringByAppendingString:self.secretSalt] cly_SHA256]; + return [queryString stringByAppendingFormat:@"&%@=%@", kCountlyQSKeyChecksum256, checksum]; + } + + return queryString; +} + +- (BOOL)isRequestSuccessful:(NSURLResponse *)response +{ + if (!response) + return NO; + + NSInteger code = ((NSHTTPURLResponse*)response).statusCode; + + return (code >= 200 && code < 300); +} + +- (NSInteger)sessionLengthInSeconds +{ + NSTimeInterval currentTime = NSDate.date.timeIntervalSince1970; + unsentSessionLength += (currentTime - lastSessionStartTime); + lastSessionStartTime = currentTime; + int sessionLengthInSeconds = (int)unsentSessionLength; + unsentSessionLength -= sessionLengthInSeconds; + return sessionLengthInSeconds; +} + +#pragma mark --- + +- (NSURLSession *)URLSession +{ + if (!_URLSession) + { + if (self.pinnedCertificates) + { + COUNTLY_LOG(@"%d pinned certificate(s) specified in config.", (int)self.pinnedCertificates.count); + _URLSession = [NSURLSession sessionWithConfiguration:self.URLSessionConfiguration delegate:self delegateQueue:nil]; + } + else + { + _URLSession = [NSURLSession sessionWithConfiguration:self.URLSessionConfiguration]; + } + } + + return _URLSession; +} + +- (void)URLSession:(NSURLSession *)session didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition, NSURLCredential *))completionHandler +{ + SecTrustRef serverTrust = challenge.protectionSpace.serverTrust; + SecKeyRef serverKey = SecTrustCopyPublicKey(serverTrust); + SecPolicyRef policy = SecPolicyCreateSSL(true, (__bridge CFStringRef)challenge.protectionSpace.host); + + __block BOOL isLocalAndServerCertMatch = NO; + + for (NSString* certificate in self.pinnedCertificates ) + { + NSString* localCertPath = [NSBundle.mainBundle pathForResource:certificate ofType:nil]; + if (!localCertPath) + [NSException raise:@"CountlyCertificateNotFoundException" format:@"Bundled certificate can not be found for %@", certificate]; + NSData* localCertData = [NSData dataWithContentsOfFile:localCertPath]; + SecCertificateRef localCert = SecCertificateCreateWithData(NULL, (__bridge CFDataRef)localCertData); + SecTrustRef localTrust = NULL; + SecTrustCreateWithCertificates(localCert, policy, &localTrust); + SecKeyRef localKey = SecTrustCopyPublicKey(localTrust); + + CFRelease(localCert); + CFRelease(localTrust); + + if (serverKey != NULL && localKey != NULL && [(__bridge id)serverKey isEqual:(__bridge id)localKey]) + { + COUNTLY_LOG(@"Pinned certificate and server certificate match."); + + isLocalAndServerCertMatch = YES; + CFRelease(localKey); + break; + } + + if (localKey) CFRelease(localKey); + } + + SecTrustResultType serverTrustResult; + SecTrustEvaluate(serverTrust, &serverTrustResult); + BOOL isServerCertValid = (serverTrustResult == kSecTrustResultUnspecified || serverTrustResult == kSecTrustResultProceed); + + if (isLocalAndServerCertMatch && isServerCertValid) + { + COUNTLY_LOG(@"Pinned certificate check is successful. Proceeding with request."); + completionHandler(NSURLSessionAuthChallengeUseCredential, [NSURLCredential credentialForTrust:serverTrust]); + } + else + { + if (!isLocalAndServerCertMatch) + COUNTLY_LOG(@"Pinned certificate and server certificate does not match!"); + + if (!isServerCertValid) + COUNTLY_LOG(@"Server certificate is not valid! SecTrustEvaluate result is: %u", serverTrustResult); + + COUNTLY_LOG(@"Pinned certificate check failed! Cancelling request."); + completionHandler(NSURLSessionAuthChallengeCancelAuthenticationChallenge, NULL); + } + + if (serverKey) CFRelease(serverKey); + CFRelease(policy); +} + +@end diff --git a/src/ios/CountlyiOS/CountlyConsentManager.h b/src/ios/CountlyiOS/CountlyConsentManager.h new file mode 100644 index 0000000..439ac58 --- /dev/null +++ b/src/ios/CountlyiOS/CountlyConsentManager.h @@ -0,0 +1,33 @@ +// CountlyPersistency.h +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import + +@interface CountlyConsentManager : NSObject + +@property (nonatomic) BOOL requiresConsent; + +@property (nonatomic, readonly) BOOL consentForSessions; +@property (nonatomic, readonly) BOOL consentForEvents; +@property (nonatomic, readonly) BOOL consentForUserDetails; +@property (nonatomic, readonly) BOOL consentForCrashReporting; +@property (nonatomic, readonly) BOOL consentForPushNotifications; +@property (nonatomic, readonly) BOOL consentForLocation; +@property (nonatomic, readonly) BOOL consentForViewTracking; +@property (nonatomic, readonly) BOOL consentForAttribution; +@property (nonatomic, readonly) BOOL consentForAppleWatch; +@property (nonatomic, readonly) BOOL consentForPerformanceMonitoring; +@property (nonatomic, readonly) BOOL consentForFeedback; +@property (nonatomic, readonly) BOOL consentForRemoteConfig; + ++ (instancetype)sharedInstance; +- (void)giveConsentForFeatures:(NSArray *)features; +- (void)giveConsentForAllFeatures; +- (void)cancelConsentForFeatures:(NSArray *)features; +- (void)cancelConsentForAllFeatures; +- (BOOL)hasAnyConsent; + +@end diff --git a/src/ios/CountlyiOS/CountlyConsentManager.m b/src/ios/CountlyiOS/CountlyConsentManager.m new file mode 100644 index 0000000..69acd1e --- /dev/null +++ b/src/ios/CountlyiOS/CountlyConsentManager.m @@ -0,0 +1,591 @@ +// CountlyPersistency.m +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import "CountlyCommon.h" + +CLYConsent const CLYConsentSessions = @"sessions"; +CLYConsent const CLYConsentEvents = @"events"; +CLYConsent const CLYConsentUserDetails = @"users"; +CLYConsent const CLYConsentCrashReporting = @"crashes"; +CLYConsent const CLYConsentPushNotifications = @"push"; +CLYConsent const CLYConsentLocation = @"location"; +CLYConsent const CLYConsentViewTracking = @"views"; +CLYConsent const CLYConsentAttribution = @"attribution"; +CLYConsent const CLYConsentStarRating = @"star-rating"; +CLYConsent const CLYConsentAppleWatch = @"accessory-devices"; +CLYConsent const CLYConsentPerformanceMonitoring = @"apm"; +CLYConsent const CLYConsentFeedback = @"feedback"; +CLYConsent const CLYConsentRemoteConfig = @"remote-config"; + + +@interface CountlyConsentManager () +@property (nonatomic, strong) NSMutableDictionary* consentChanges; +@end + +@implementation CountlyConsentManager + +@synthesize consentForSessions = _consentForSessions; +@synthesize consentForEvents = _consentForEvents; +@synthesize consentForUserDetails = _consentForUserDetails; +@synthesize consentForCrashReporting = _consentForCrashReporting; +@synthesize consentForPushNotifications = _consentForPushNotifications; +@synthesize consentForLocation = _consentForLocation; +@synthesize consentForViewTracking = _consentForViewTracking; +@synthesize consentForAttribution = _consentForAttribution; +@synthesize consentForAppleWatch = _consentForAppleWatch; +@synthesize consentForPerformanceMonitoring = _consentForPerformanceMonitoring; +@synthesize consentForFeedback = _consentForFeedback; +@synthesize consentForRemoteConfig = _consentForRemoteConfig; + +#pragma mark - + ++ (instancetype)sharedInstance +{ + if (!CountlyCommon.sharedInstance.hasStarted) + return nil; + + static CountlyConsentManager* s_sharedInstance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{s_sharedInstance = self.new;}); + return s_sharedInstance; +} + + +- (instancetype)init +{ + if (self = [super init]) + { + self.consentChanges = NSMutableDictionary.new; + } + + return self; +} + + +#pragma mark - + + +- (void)giveConsentForAllFeatures +{ + [self giveConsentForFeatures:[self allFeatures]]; +} + + +- (void)giveConsentForFeatures:(NSArray *)features +{ + if (!self.requiresConsent) + return; + + if (!features.count) + return; + + //NOTE: Due to some legacy Countly Server location info problems, giving consent for location should be the first. + //NOTE: Otherwise, if location consent is given after sessions consent, begin_session request will be sent with an empty string as location. + if ([features containsObject:CLYConsentLocation] && !self.consentForLocation) + self.consentForLocation = YES; + + if ([features containsObject:CLYConsentSessions] && !self.consentForSessions) + self.consentForSessions = YES; + + if ([features containsObject:CLYConsentEvents] && !self.consentForEvents) + self.consentForEvents = YES; + + if ([features containsObject:CLYConsentUserDetails] && !self.consentForUserDetails) + self.consentForUserDetails = YES; + + if ([features containsObject:CLYConsentCrashReporting] && !self.consentForCrashReporting) + self.consentForCrashReporting = YES; + + if ([features containsObject:CLYConsentPushNotifications] && !self.consentForPushNotifications) + self.consentForPushNotifications = YES; + + if ([features containsObject:CLYConsentViewTracking] && !self.consentForViewTracking) + self.consentForViewTracking = YES; + + if ([features containsObject:CLYConsentAttribution] && !self.consentForAttribution) + self.consentForAttribution = YES; + + if ([features containsObject:CLYConsentAppleWatch] && !self.consentForAppleWatch) + self.consentForAppleWatch = YES; + + if ([features containsObject:CLYConsentPerformanceMonitoring] && !self.consentForPerformanceMonitoring) + self.consentForPerformanceMonitoring = YES; + + if ([self containsFeedbackOrStarRating:features] && !self.consentForFeedback) + self.consentForFeedback = YES; + + if ([features containsObject:CLYConsentRemoteConfig] && !self.consentForRemoteConfig) + self.consentForRemoteConfig = YES; + + [self sendConsentChanges]; +} + + +- (void)cancelConsentForAllFeatures +{ + [self cancelConsentForFeatures:[self allFeatures]]; +} + + +- (void)cancelConsentForFeatures:(NSArray *)features +{ + if (!self.requiresConsent) + return; + + if ([features containsObject:CLYConsentSessions] && self.consentForSessions) + self.consentForSessions = NO; + + if ([features containsObject:CLYConsentEvents] && self.consentForEvents) + self.consentForEvents = NO; + + if ([features containsObject:CLYConsentUserDetails] && self.consentForUserDetails) + self.consentForUserDetails = NO; + + if ([features containsObject:CLYConsentCrashReporting] && self.consentForCrashReporting) + self.consentForCrashReporting = NO; + + if ([features containsObject:CLYConsentPushNotifications] && self.consentForPushNotifications) + self.consentForPushNotifications = NO; + + if ([features containsObject:CLYConsentLocation] && self.consentForLocation) + self.consentForLocation = NO; + + if ([features containsObject:CLYConsentViewTracking] && self.consentForViewTracking) + self.consentForViewTracking = NO; + + if ([features containsObject:CLYConsentAttribution] && self.consentForAttribution) + self.consentForAttribution = NO; + + if ([features containsObject:CLYConsentAppleWatch] && self.consentForAppleWatch) + self.consentForAppleWatch = NO; + + if ([features containsObject:CLYConsentPerformanceMonitoring] && self.consentForPerformanceMonitoring) + self.consentForPerformanceMonitoring = NO; + + if ([self containsFeedbackOrStarRating:features] && self.consentForFeedback) + self.consentForFeedback = NO; + + if ([features containsObject:CLYConsentRemoteConfig] && self.consentForRemoteConfig) + self.consentForRemoteConfig = NO; + + [self sendConsentChanges]; +} + + +- (void)sendConsentChanges +{ + if (self.consentChanges.allKeys.count) + { + [CountlyConnectionManager.sharedInstance sendConsentChanges:[self.consentChanges cly_JSONify]]; + [self.consentChanges removeAllObjects]; + } +} + + +- (NSArray *)allFeatures +{ + return + @[ + CLYConsentSessions, + CLYConsentEvents, + CLYConsentUserDetails, + CLYConsentCrashReporting, + CLYConsentPushNotifications, + CLYConsentLocation, + CLYConsentViewTracking, + CLYConsentAttribution, + CLYConsentAppleWatch, + CLYConsentPerformanceMonitoring, + CLYConsentFeedback, + ]; +} + + +- (BOOL)hasAnyConsent +{ + return + self.consentForSessions || + self.consentForEvents || + self.consentForUserDetails || + self.consentForCrashReporting || + self.consentForPushNotifications || + self.consentForLocation || + self.consentForViewTracking || + self.consentForAttribution || + self.consentForAppleWatch || + self.consentForPerformanceMonitoring || + self.consentForFeedback || + self.consentForRemoteConfig; +} + +- (BOOL)containsFeedbackOrStarRating:(NSArray *)features +{ + //NOTE: StarRating consent is merged into new Feedback consent. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + return [features containsObject:CLYConsentFeedback] || [features containsObject:CLYConsentStarRating]; +#pragma GCC diagnostic pop +} + +#pragma mark - + + +- (void)setConsentForSessions:(BOOL)consentForSessions +{ + _consentForSessions = consentForSessions; + + if (consentForSessions) + { + COUNTLY_LOG(@"Consent for Session is given."); + + if (!CountlyCommon.sharedInstance.manualSessionHandling) + [CountlyConnectionManager.sharedInstance beginSession]; + } + else + { + COUNTLY_LOG(@"Consent for Session is cancelled."); + } + + self.consentChanges[CLYConsentSessions] = @(consentForSessions); +} + + +- (void)setConsentForEvents:(BOOL)consentForEvents +{ + _consentForEvents = consentForEvents; + + if (consentForEvents) + { + COUNTLY_LOG(@"Consent for Events is given."); + } + else + { + COUNTLY_LOG(@"Consent for Events is cancelled."); + + [CountlyConnectionManager.sharedInstance sendEvents]; + [CountlyPersistency.sharedInstance clearAllTimedEvents]; + } + + self.consentChanges[CLYConsentEvents] = @(consentForEvents); +} + + +- (void)setConsentForUserDetails:(BOOL)consentForUserDetails +{ + _consentForUserDetails = consentForUserDetails; + + if (consentForUserDetails) + { + COUNTLY_LOG(@"Consent for UserDetails is given."); + } + else + { + COUNTLY_LOG(@"Consent for UserDetails is cancelled."); + + [CountlyUserDetails.sharedInstance clearUserDetails]; + } + + self.consentChanges[CLYConsentUserDetails] = @(consentForUserDetails); +} + + +- (void)setConsentForCrashReporting:(BOOL)consentForCrashReporting +{ + _consentForCrashReporting = consentForCrashReporting; + + if (consentForCrashReporting) + { + COUNTLY_LOG(@"Consent for CrashReporting is given."); + + [CountlyCrashReporter.sharedInstance startCrashReporting]; + } + else + { + COUNTLY_LOG(@"Consent for CrashReporting is cancelled."); + + [CountlyCrashReporter.sharedInstance stopCrashReporting]; + } + + self.consentChanges[CLYConsentCrashReporting] = @(consentForCrashReporting); +} + + +- (void)setConsentForPushNotifications:(BOOL)consentForPushNotifications +{ + _consentForPushNotifications = consentForPushNotifications; + +#if (TARGET_OS_IOS || TARGET_OS_OSX) + if (consentForPushNotifications) + { + COUNTLY_LOG(@"Consent for PushNotifications is given."); + +#ifndef COUNTLY_EXCLUDE_PUSHNOTIFICATIONS + [CountlyPushNotifications.sharedInstance startPushNotifications]; +#endif + } + else + { + COUNTLY_LOG(@"Consent for PushNotifications is cancelled."); +#ifndef COUNTLY_EXCLUDE_PUSHNOTIFICATIONS + [CountlyPushNotifications.sharedInstance stopPushNotifications]; +#endif + } +#endif + + self.consentChanges[CLYConsentPushNotifications] = @(consentForPushNotifications); +} + + +- (void)setConsentForLocation:(BOOL)consentForLocation +{ + _consentForLocation = consentForLocation; + + if (consentForLocation) + { + COUNTLY_LOG(@"Consent for Location is given."); + + [CountlyLocationManager.sharedInstance sendLocationInfo]; + } + else + { + COUNTLY_LOG(@"Consent for Location is cancelled."); + } + + self.consentChanges[CLYConsentLocation] = @(consentForLocation); +} + + +- (void)setConsentForViewTracking:(BOOL)consentForViewTracking +{ + _consentForViewTracking = consentForViewTracking; + +#if (TARGET_OS_IOS || TARGET_OS_TV) + if (consentForViewTracking) + { + COUNTLY_LOG(@"Consent for ViewTracking is given."); + + [CountlyViewTracking.sharedInstance startAutoViewTracking]; + } + else + { + COUNTLY_LOG(@"Consent for ViewTracking is cancelled."); + + [CountlyViewTracking.sharedInstance stopAutoViewTracking]; + } +#endif + + self.consentChanges[CLYConsentViewTracking] = @(consentForViewTracking); +} + + +- (void)setConsentForAttribution:(BOOL)consentForAttribution +{ + _consentForAttribution = consentForAttribution; + + if (consentForAttribution) + { + COUNTLY_LOG(@"Consent for Attribution is given."); + + [CountlyConnectionManager.sharedInstance sendAttribution]; + } + else + { + COUNTLY_LOG(@"Consent for Attribution is cancelled."); + } + + self.consentChanges[CLYConsentAttribution] = @(consentForAttribution); +} + + +- (void)setConsentForAppleWatch:(BOOL)consentForAppleWatch +{ + _consentForAppleWatch = consentForAppleWatch; + +#if (TARGET_OS_IOS || TARGET_OS_WATCH) + if (consentForAppleWatch) + { + COUNTLY_LOG(@"Consent for AppleWatch is given."); + + [CountlyCommon.sharedInstance startAppleWatchMatching]; + } + else + { + COUNTLY_LOG(@"Consent for AppleWatch is cancelled."); + } +#endif + + self.consentChanges[CLYConsentAppleWatch] = @(consentForAppleWatch); +} + + +- (void)setConsentForPerformanceMonitoring:(BOOL)consentForPerformanceMonitoring +{ + _consentForPerformanceMonitoring = consentForPerformanceMonitoring; + +#if (TARGET_OS_IOS) + if (consentForPerformanceMonitoring) + { + COUNTLY_LOG(@"Consent for PerformanceMonitoring is given."); + + [CountlyPerformanceMonitoring.sharedInstance startPerformanceMonitoring]; + } + else + { + COUNTLY_LOG(@"Consent for PerformanceMonitoring is cancelled."); + + [CountlyPerformanceMonitoring.sharedInstance stopPerformanceMonitoring]; + } +#endif + + self.consentChanges[CLYConsentPerformanceMonitoring] = @(consentForPerformanceMonitoring); +} + +- (void)setConsentForFeedback:(BOOL)consentForFeedback +{ + _consentForFeedback = consentForFeedback; + +#if (TARGET_OS_IOS) + if (consentForFeedback) + { + COUNTLY_LOG(@"Consent for Feedback is given."); + + [CountlyFeedbacks.sharedInstance checkForStarRatingAutoAsk]; + } + else + { + COUNTLY_LOG(@"Consent for Feedback is cancelled."); + } +#endif + + self.consentChanges[CLYConsentFeedback] = @(consentForFeedback); +} + +- (void)setConsentForRemoteConfig:(BOOL)consentForRemoteConfig +{ + _consentForRemoteConfig = consentForRemoteConfig; + + if (consentForRemoteConfig) + { + COUNTLY_LOG(@"Consent for RemoteConfig is given."); + + [CountlyRemoteConfig.sharedInstance startRemoteConfig]; + } + else + { + COUNTLY_LOG(@"Consent for RemoteConfig is cancelled."); + } + + self.consentChanges[CLYConsentRemoteConfig] = @(consentForRemoteConfig); +} + +#pragma mark - + +- (BOOL)consentForSessions +{ + if (!self.requiresConsent) + return YES; + + return _consentForSessions; +} + + +- (BOOL)consentForEvents +{ + if (!self.requiresConsent) + return YES; + + return _consentForEvents; +} + + +- (BOOL)consentForUserDetails +{ + if (!self.requiresConsent) + return YES; + + return _consentForUserDetails; +} + + +- (BOOL)consentForCrashReporting +{ + if (!self.requiresConsent) + return YES; + + return _consentForCrashReporting; +} + + +- (BOOL)consentForPushNotifications +{ + if (!self.requiresConsent) + return YES; + + return _consentForPushNotifications; +} + + +- (BOOL)consentForLocation +{ + if (!self.requiresConsent) + return YES; + + return _consentForLocation; +} + + +- (BOOL)consentForViewTracking +{ + if (!self.requiresConsent) + return YES; + + return _consentForViewTracking; +} + + +- (BOOL)consentForAttribution +{ + if (!self.requiresConsent) + return YES; + + return _consentForAttribution; +} + + +- (BOOL)consentForAppleWatch +{ + if (!self.requiresConsent) + return YES; + + return _consentForAppleWatch; +} + + +- (BOOL)consentForPerformanceMonitoring +{ + if (!self.requiresConsent) + return YES; + + return _consentForPerformanceMonitoring; +} + +- (BOOL)consentForFeedback +{ + if (!self.requiresConsent) + return YES; + + return _consentForFeedback; +} + +- (BOOL)consentForRemoteConfig +{ + if (!self.requiresConsent) + return YES; + + return _consentForRemoteConfig; +} + +@end diff --git a/src/ios/CountlyiOS/CountlyCrashReporter.h b/src/ios/CountlyiOS/CountlyCrashReporter.h new file mode 100644 index 0000000..7906709 --- /dev/null +++ b/src/ios/CountlyiOS/CountlyCrashReporter.h @@ -0,0 +1,32 @@ +// CountlyCrashReporter.h +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import + +@interface CountlyCrashReporter : NSObject +@property (nonatomic) BOOL isEnabledOnInitialConfig; +@property (nonatomic) NSDictionary* crashSegmentation; +@property (nonatomic) NSUInteger crashLogLimit; +@property (nonatomic) NSRegularExpression* crashFilter; +@property (nonatomic) BOOL shouldUsePLCrashReporter; +@property (nonatomic) BOOL shouldUseMachSignalHandler; +@property (nonatomic, copy) void (^crashOccuredOnPreviousSessionCallback)(NSDictionary * crashReport); +@property (nonatomic, copy) BOOL (^shouldSendCrashReportCallback)(NSDictionary * crashReport); + ++ (instancetype)sharedInstance; +- (void)startCrashReporting; +- (void)stopCrashReporting; +- (void)recordException:(NSException *)exception withStackTrace:(NSArray *)stackTrace isFatal:(BOOL)isFatal; +- (void)log:(NSString *)log; +@end + + +#if (TARGET_OS_OSX) +#import +//NOTE: Due to some macOS innerworkings limitations, NSPrincipalClass in the app's Info.plist file needs to be set as CLYExceptionHandlingApplication. +@interface CLYExceptionHandlingApplication : NSApplication +@end +#endif diff --git a/src/ios/CountlyiOS/CountlyCrashReporter.m b/src/ios/CountlyiOS/CountlyCrashReporter.m new file mode 100644 index 0000000..e42d7b0 --- /dev/null +++ b/src/ios/CountlyiOS/CountlyCrashReporter.m @@ -0,0 +1,484 @@ +// CountlyCrashReporter.m +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import "CountlyCommon.h" +#import +#include + +#if __has_include() + #define COUNTLY_PLCRASHREPORTER_EXISTS true + #import +#else + +#endif + +NSString* const kCountlyExceptionUserInfoBacktraceKey = @"kCountlyExceptionUserInfoBacktraceKey"; +NSString* const kCountlyExceptionUserInfoSignalCodeKey = @"kCountlyExceptionUserInfoSignalCodeKey"; + +NSString* const kCountlyCRKeyBinaryImages = @"_binary_images"; +NSString* const kCountlyCRKeyOS = @"_os"; +NSString* const kCountlyCRKeyOSVersion = @"_os_version"; +NSString* const kCountlyCRKeyDevice = @"_device"; +NSString* const kCountlyCRKeyArchitecture = @"_architecture"; +NSString* const kCountlyCRKeyResolution = @"_resolution"; +NSString* const kCountlyCRKeyAppVersion = @"_app_version"; +NSString* const kCountlyCRKeyAppBuild = @"_app_build"; +NSString* const kCountlyCRKeyBuildUUID = @"_build_uuid"; +NSString* const kCountlyCRKeyExecutableName = @"_executable_name"; +NSString* const kCountlyCRKeyName = @"_name"; +NSString* const kCountlyCRKeyType = @"_type"; +NSString* const kCountlyCRKeyError = @"_error"; +NSString* const kCountlyCRKeyNonfatal = @"_nonfatal"; +NSString* const kCountlyCRKeyRAMCurrent = @"_ram_current"; +NSString* const kCountlyCRKeyRAMTotal = @"_ram_total"; +NSString* const kCountlyCRKeyDiskCurrent = @"_disk_current"; +NSString* const kCountlyCRKeyDiskTotal = @"_disk_total"; +NSString* const kCountlyCRKeyBattery = @"_bat"; +NSString* const kCountlyCRKeyOrientation = @"_orientation"; +NSString* const kCountlyCRKeyOnline = @"_online"; +NSString* const kCountlyCRKeyRoot = @"_root"; +NSString* const kCountlyCRKeyBackground = @"_background"; +NSString* const kCountlyCRKeyRun = @"_run"; +NSString* const kCountlyCRKeyCustom = @"_custom"; +NSString* const kCountlyCRKeyLogs = @"_logs"; +NSString* const kCountlyCRKeyPLCrash = @"_plcrash"; +NSString* const kCountlyCRKeyImageLoadAddress = @"la"; +NSString* const kCountlyCRKeyImageBuildUUID = @"id"; + + +@interface CountlyCrashReporter () +@property (nonatomic) NSMutableArray* customCrashLogs; +@property (nonatomic) NSDateFormatter* dateFormatter; +@property (nonatomic) NSString* buildUUID; +@property (nonatomic) NSString* executableName; +#ifdef COUNTLY_PLCRASHREPORTER_EXISTS +@property (nonatomic) PLCrashReporter* crashReporter; +#endif +@end + + +@implementation CountlyCrashReporter + ++ (instancetype)sharedInstance +{ + if (!CountlyCommon.sharedInstance.hasStarted) + return nil; + + static CountlyCrashReporter *s_sharedInstance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{s_sharedInstance = self.new;}); + return s_sharedInstance; +} + +- (instancetype)init +{ + if (self = [super init]) + { + self.crashSegmentation = nil; + self.customCrashLogs = NSMutableArray.new; + self.dateFormatter = NSDateFormatter.new; + self.dateFormatter.dateFormat = @"yyyy-MM-dd HH:mm:ss.SSS"; + } + + return self; +} + +- (void)startCrashReporting +{ + if (!self.isEnabledOnInitialConfig) + return; + + if (!CountlyConsentManager.sharedInstance.consentForCrashReporting) + return; + + if (self.shouldUsePLCrashReporter) + { +#ifdef COUNTLY_PLCRASHREPORTER_EXISTS + [self startPLCrashReporter]; +#else + [NSException raise:@"CountlyPLCrashReporterDependencyNotFoundException" format:@"PLCrashReporter dependency can not be found in Project"]; +#endif + return; + } + + NSSetUncaughtExceptionHandler(&CountlyUncaughtExceptionHandler); + +#if (TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_OSX) + signal(SIGABRT, CountlySignalHandler); + signal(SIGILL, CountlySignalHandler); + signal(SIGSEGV, CountlySignalHandler); + signal(SIGFPE, CountlySignalHandler); + signal(SIGBUS, CountlySignalHandler); + signal(SIGPIPE, CountlySignalHandler); + signal(SIGTRAP, CountlySignalHandler); +#endif +} + + +- (void)stopCrashReporting +{ + if (!self.isEnabledOnInitialConfig) + return; + + NSSetUncaughtExceptionHandler(NULL); + +#if (TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_OSX) + signal(SIGABRT, SIG_DFL); + signal(SIGILL, SIG_DFL); + signal(SIGSEGV, SIG_DFL); + signal(SIGFPE, SIG_DFL); + signal(SIGBUS, SIG_DFL); + signal(SIGPIPE, SIG_DFL); + signal(SIGTRAP, SIG_DFL); +#endif + + self.customCrashLogs = nil; +} + +#ifdef COUNTLY_PLCRASHREPORTER_EXISTS + +- (void)startPLCrashReporter +{ + PLCrashReporterSignalHandlerType type = self.shouldUseMachSignalHandler ? PLCrashReporterSignalHandlerTypeMach : PLCrashReporterSignalHandlerTypeBSD; + PLCrashReporterConfig* config = [PLCrashReporterConfig.alloc initWithSignalHandlerType:type symbolicationStrategy:PLCrashReporterSymbolicationStrategyNone]; + + self.crashReporter = [PLCrashReporter.alloc initWithConfiguration:config]; + + if (self.crashReporter.hasPendingCrashReport) + [self handlePendingCrashReport]; + else + [CountlyPersistency.sharedInstance deleteCustomCrashLogFile]; + + [self.crashReporter enableCrashReporter]; +} + +- (void)handlePendingCrashReport +{ + NSError *error; + + NSData* crashData = [self.crashReporter loadPendingCrashReportDataAndReturnError:&error]; + if (!crashData) + { + COUNTLY_LOG(@"Could not load crash report data: %@", error); + return; + } + + PLCrashReport *report = [PLCrashReport.alloc initWithData:crashData error:&error]; + if (!report) + { + COUNTLY_LOG(@"Could not initialize crash report using data %@", error); + return; + } + + NSString* reportText = [PLCrashReportTextFormatter stringValueForCrashReport:report withTextFormat:PLCrashReportTextFormatiOS]; + + NSMutableDictionary* crashReport = NSMutableDictionary.dictionary; + crashReport[kCountlyCRKeyError] = reportText; + crashReport[kCountlyCRKeyOS] = CountlyDeviceInfo.osName; + crashReport[kCountlyCRKeyAppVersion] = report.applicationInfo.applicationVersion; + crashReport[kCountlyCRKeyPLCrash] = @YES; + crashReport[kCountlyCRKeyCustom] = [CountlyPersistency.sharedInstance customCrashLogsFromFile]; + + if (self.crashOccuredOnPreviousSessionCallback) + self.crashOccuredOnPreviousSessionCallback(crashReport); + + BOOL shouldSend = YES; + if (self.shouldSendCrashReportCallback) + { + COUNTLY_LOG(@"shouldSendCrashReportCallback is set, asking it if the report should be sent or not."); + shouldSend = self.shouldSendCrashReportCallback(crashReport); + + if (shouldSend) + COUNTLY_LOG(@"shouldSendCrashReportCallback returned YES, sending the report."); + else + COUNTLY_LOG(@"shouldSendCrashReportCallback returned NO, not sending the report."); + } + + if (shouldSend) + { + [CountlyConnectionManager.sharedInstance sendCrashReport:[crashReport cly_JSONify] immediately:NO]; + } + + [CountlyPersistency.sharedInstance deleteCustomCrashLogFile]; + [self.crashReporter purgePendingCrashReport]; +} + +#endif + +- (void)recordException:(NSException *)exception withStackTrace:(NSArray *)stackTrace isFatal:(BOOL)isFatal +{ + if (!CountlyConsentManager.sharedInstance.consentForCrashReporting) + return; + + if (stackTrace) + { + NSMutableDictionary* userInfo = [NSMutableDictionary dictionaryWithDictionary:exception.userInfo]; + userInfo[kCountlyExceptionUserInfoBacktraceKey] = stackTrace; + exception = [NSException exceptionWithName:exception.name reason:exception.reason userInfo:userInfo]; + } + + CountlyExceptionHandler(exception, isFatal, false); +} + +void CountlyUncaughtExceptionHandler(NSException *exception) +{ + CountlyExceptionHandler(exception, true, true); +} + +void CountlyExceptionHandler(NSException *exception, bool isFatal, bool isAutoDetect) +{ + const NSInteger kCLYMebibit = 1048576; + + NSArray* stackTrace = exception.userInfo[kCountlyExceptionUserInfoBacktraceKey]; + if (!stackTrace) + stackTrace = exception.callStackSymbols; + + NSString* stackTraceJoined = [stackTrace componentsJoinedByString:@"\n"]; + + BOOL matchesFilter = NO; + if (CountlyCrashReporter.sharedInstance.crashFilter) + { + matchesFilter = [CountlyCrashReporter.sharedInstance isMatchingFilter:stackTraceJoined] || + [CountlyCrashReporter.sharedInstance isMatchingFilter:exception.description] || + [CountlyCrashReporter.sharedInstance isMatchingFilter:exception.name]; + } + + NSMutableDictionary* crashReport = NSMutableDictionary.dictionary; + crashReport[kCountlyCRKeyError] = stackTraceJoined; + crashReport[kCountlyCRKeyBinaryImages] = [CountlyCrashReporter.sharedInstance binaryImagesForStackTrace:stackTrace]; + crashReport[kCountlyCRKeyOS] = CountlyDeviceInfo.osName; + crashReport[kCountlyCRKeyOSVersion] = CountlyDeviceInfo.osVersion; + crashReport[kCountlyCRKeyDevice] = CountlyDeviceInfo.device; + crashReport[kCountlyCRKeyArchitecture] = CountlyDeviceInfo.architecture; + crashReport[kCountlyCRKeyResolution] = CountlyDeviceInfo.resolution; + crashReport[kCountlyCRKeyAppVersion] = CountlyDeviceInfo.appVersion; + crashReport[kCountlyCRKeyAppBuild] = CountlyDeviceInfo.appBuild; + crashReport[kCountlyCRKeyBuildUUID] = CountlyCrashReporter.sharedInstance.buildUUID ?: @""; + crashReport[kCountlyCRKeyExecutableName] = CountlyCrashReporter.sharedInstance.executableName ?: @""; + crashReport[kCountlyCRKeyName] = exception.description; + crashReport[kCountlyCRKeyType] = exception.name; + crashReport[kCountlyCRKeyNonfatal] = @(!isFatal); + crashReport[kCountlyCRKeyRAMCurrent] = @((CountlyDeviceInfo.totalRAM - CountlyDeviceInfo.freeRAM) / kCLYMebibit); + crashReport[kCountlyCRKeyRAMTotal] = @(CountlyDeviceInfo.totalRAM / kCLYMebibit); + crashReport[kCountlyCRKeyDiskCurrent] = @((CountlyDeviceInfo.totalDisk - CountlyDeviceInfo.freeDisk) / kCLYMebibit); + crashReport[kCountlyCRKeyDiskTotal] = @(CountlyDeviceInfo.totalDisk / kCLYMebibit); + crashReport[kCountlyCRKeyBattery] = @(CountlyDeviceInfo.batteryLevel); + crashReport[kCountlyCRKeyOrientation] = CountlyDeviceInfo.orientation; + crashReport[kCountlyCRKeyOnline] = @((CountlyDeviceInfo.connectionType) ? 1 : 0 ); + crashReport[kCountlyCRKeyRoot] = @(CountlyDeviceInfo.isJailbroken); + crashReport[kCountlyCRKeyBackground] = @(CountlyDeviceInfo.isInBackground); + crashReport[kCountlyCRKeyRun] = @(CountlyCommon.sharedInstance.timeSinceLaunch); + + NSMutableDictionary* custom = NSMutableDictionary.new; + if (CountlyCrashReporter.sharedInstance.crashSegmentation) + [custom addEntriesFromDictionary:CountlyCrashReporter.sharedInstance.crashSegmentation]; + + NSMutableDictionary* userInfo = exception.userInfo.mutableCopy; + [userInfo removeObjectForKey:kCountlyExceptionUserInfoBacktraceKey]; + [userInfo removeObjectForKey:kCountlyExceptionUserInfoSignalCodeKey]; + [custom addEntriesFromDictionary:userInfo]; + + if (custom.allKeys.count) + crashReport[kCountlyCRKeyCustom] = custom; + + if (CountlyCrashReporter.sharedInstance.customCrashLogs) + crashReport[kCountlyCRKeyLogs] = [CountlyCrashReporter.sharedInstance.customCrashLogs componentsJoinedByString:@"\n"]; + + //NOTE: Do not send crash report if it is matching optional regex filter. + if (!matchesFilter) + { + [CountlyConnectionManager.sharedInstance sendCrashReport:[crashReport cly_JSONify] immediately:isAutoDetect]; + } + else + { + COUNTLY_LOG(@"Crash matches filter and it will not be processed."); + } + + if (isAutoDetect) + [CountlyCrashReporter.sharedInstance stopCrashReporting]; +} + +void CountlySignalHandler(int signalCode) +{ + const NSInteger kCountlyStackFramesMax = 128; + void *stack[kCountlyStackFramesMax]; + NSInteger frameCount = backtrace(stack, kCountlyStackFramesMax); + char **lines = backtrace_symbols(stack, (int)frameCount); + + NSMutableArray *backtrace = [NSMutableArray arrayWithCapacity:frameCount]; + for (NSInteger i = 1; i < frameCount; i++) + { + if (lines[i] != NULL) + { + NSString *line = [NSString stringWithUTF8String:lines[i]]; + if (line) + [backtrace addObject:line]; + } + } + + free(lines); + + NSDictionary *userInfo = @{kCountlyExceptionUserInfoSignalCodeKey: @(signalCode), kCountlyExceptionUserInfoBacktraceKey: backtrace}; + NSString *reason = [NSString stringWithFormat:@"App terminated by SIG%@", [NSString stringWithUTF8String:sys_signame[signalCode]].uppercaseString]; + NSException *e = [NSException exceptionWithName:@"Fatal Signal" reason:reason userInfo:userInfo]; + + CountlyUncaughtExceptionHandler(e); +} + +- (void)log:(NSString *)log +{ + if (!CountlyConsentManager.sharedInstance.consentForCrashReporting) + return; + + const NSInteger kCountlyCustomCrashLogLengthLimit = 1000; + + if (log.length > kCountlyCustomCrashLogLengthLimit) + log = [log substringToIndex:kCountlyCustomCrashLogLengthLimit]; + + NSString* logWithDateTime = [NSString stringWithFormat:@"<%@> %@",[self.dateFormatter stringFromDate:NSDate.date], log]; + + if (self.shouldUsePLCrashReporter) + { + [CountlyPersistency.sharedInstance writeCustomCrashLogToFile:logWithDateTime]; + } + else + { + [self.customCrashLogs addObject:logWithDateTime]; + + if (self.customCrashLogs.count > self.crashLogLimit) + [self.customCrashLogs removeObjectAtIndex:0]; + } +} + +- (NSDictionary *)binaryImagesForStackTrace:(NSArray *)stackTrace +{ + NSMutableSet* binaryImagesInStack = NSMutableSet.new; + for (NSString* line in stackTrace) + { + //NOTE: See _BACKTRACE_FORMAT in https://opensource.apple.com/source/Libc/Libc-498/gen/backtrace.c.auto.html + NSRange rangeOfBinaryImageName = (NSRange){4, 35}; + if (line.length >= rangeOfBinaryImageName.location + rangeOfBinaryImageName.length) + { + NSString* binaryImageName = [line substringWithRange:rangeOfBinaryImageName]; + binaryImageName = [binaryImageName stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceCharacterSet]; + [binaryImagesInStack addObject:binaryImageName]; + } + } + + NSMutableDictionary* binaryImages = NSMutableDictionary.new; + + uint32_t imageCount = _dyld_image_count(); + for (uint32_t i = 0; i < imageCount; i++) + { + const char* imageNameChar = _dyld_get_image_name(i); + if (imageNameChar == NULL) + { + COUNTLY_LOG(@"Image Name can not be retrieved!"); + continue; + } + + NSString *imageName = [NSString stringWithUTF8String:imageNameChar].lastPathComponent; + + if (![binaryImagesInStack containsObject:imageName]) + { + //NOTE: Image Name is not in the stack trace, so it will be ignored! + continue; + } + + COUNTLY_LOG(@"Image Name is in the stack trace, so it will be used!\n%@", imageName); + + const struct mach_header* imageHeader = _dyld_get_image_header(i); + if (imageHeader == NULL) + { + COUNTLY_LOG(@"Image Header can not be retrieved!"); + continue; + } + + BOOL is64bit = imageHeader->magic == MH_MAGIC_64 || imageHeader->magic == MH_CIGAM_64; + uintptr_t ptr = (uintptr_t)imageHeader + (is64bit ? sizeof(struct mach_header_64) : sizeof(struct mach_header)); + NSString* imageUUID = nil; + + for (uint32_t j = 0; j < imageHeader->ncmds; j++) + { + const struct segment_command_64* segCmd = (struct segment_command_64*)ptr; + + if (segCmd->cmd == LC_UUID) + { + const uint8_t* uuid = ((const struct uuid_command*)segCmd)->uuid; + imageUUID = [NSUUID.alloc initWithUUIDBytes:uuid].UUIDString; + break; + } + ptr += segCmd->cmdsize; + } + + if (!imageUUID) + { + COUNTLY_LOG(@"Image UUID can not be retrieved!"); + continue; + } + + //NOTE: Include app's own build UUID directly in crash report object, as Countly Server needs it for fast lookup + if (imageHeader->filetype == MH_EXECUTE) + { + CountlyCrashReporter.sharedInstance.buildUUID = imageUUID; + CountlyCrashReporter.sharedInstance.executableName = imageName; + } + + NSString *imageLoadAddress = [NSString stringWithFormat:@"0x%llX", (uint64_t)imageHeader]; + + binaryImages[imageName] = @{kCountlyCRKeyImageLoadAddress: imageLoadAddress, kCountlyCRKeyImageBuildUUID: imageUUID}; + } + + return [NSDictionary dictionaryWithDictionary:binaryImages]; +} + +- (BOOL)isMatchingFilter:(NSString *)string +{ + if (!self.crashFilter) + return NO; + + NSUInteger numberOfMatches = [self.crashFilter numberOfMatchesInString:string options:0 range:(NSRange){0, string.length}]; + + if (numberOfMatches == 0) + return NO; + + return YES; +} + +@end + + + +#if (TARGET_OS_OSX) +@implementation CLYExceptionHandlingApplication + +- (void)reportException:(NSException *)exception +{ + [super reportException:exception]; + + //NOTE: Custom UncaughtExceptionHandler is called with an irrelevant stack trace, not the original crash call stack trace. + //NOTE: And system's own UncaughtExceptionHandler handles the exception by just printing it to the Console. + //NOTE: So, we intercept the exception here and record manually. + [CountlyCrashReporter.sharedInstance recordException:exception withStackTrace:nil isFatal:NO]; +} + +- (void)sendEvent:(NSEvent *)theEvent +{ + //NOTE: Exceptions caused by UI related events (which run on main thread by default) seem to not trigger reportException: method. + //NOTE: So, we execute sendEvent: in a try-catch block to catch them. + + @try + { + [super sendEvent:theEvent]; + } + @catch (NSException *exception) + { + [self reportException:exception]; + } +} + +@end +#endif diff --git a/src/ios/CountlyiOS/CountlyDeviceInfo.h b/src/ios/CountlyiOS/CountlyDeviceInfo.h new file mode 100644 index 0000000..9496e66 --- /dev/null +++ b/src/ios/CountlyiOS/CountlyDeviceInfo.h @@ -0,0 +1,45 @@ +// CountlyDeviceInfo.h +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import + +@interface CountlyDeviceInfo : NSObject + +@property (nonatomic) NSString *deviceID; +@property (nonatomic) NSDictionary* customMetrics; + ++ (instancetype)sharedInstance; +- (void)initializeDeviceID:(NSString *)deviceID; +- (NSString *)ensafeDeviceID:(NSString *)deviceID; +- (BOOL)isDeviceIDTemporary; + ++ (NSString *)device; ++ (NSString *)architecture; ++ (NSString *)osName; ++ (NSString *)osVersion; ++ (NSString *)carrier; ++ (NSString *)resolution; ++ (NSString *)density; ++ (NSString *)locale; ++ (NSString *)appVersion; ++ (NSString *)appBuild; +#if (TARGET_OS_IOS) ++ (NSInteger)hasWatch; ++ (NSInteger)installedWatchApp; +#endif + ++ (NSString *)metrics; + ++ (NSUInteger)connectionType; ++ (unsigned long long)freeRAM; ++ (unsigned long long)totalRAM; ++ (unsigned long long)freeDisk; ++ (unsigned long long)totalDisk; ++ (NSInteger)batteryLevel; ++ (NSString *)orientation; ++ (BOOL)isJailbroken; ++ (BOOL)isInBackground; +@end diff --git a/src/ios/CountlyiOS/CountlyDeviceInfo.m b/src/ios/CountlyiOS/CountlyDeviceInfo.m new file mode 100644 index 0000000..cee9bdd --- /dev/null +++ b/src/ios/CountlyiOS/CountlyDeviceInfo.m @@ -0,0 +1,462 @@ +// CountlyDeviceInfo.m +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import "CountlyCommon.h" +#import +#import +#import +#import +#include +#include + +#if (TARGET_OS_IOS) +#import +#import +#elif (TARGET_OS_OSX) +#import +#endif + +CLYMetricKey const CLYMetricKeyDevice = @"_device"; +CLYMetricKey const CLYMetricKeyDeviceType = @"_device_type"; +CLYMetricKey const CLYMetricKeyOS = @"_os"; +CLYMetricKey const CLYMetricKeyOSVersion = @"_os_version"; +CLYMetricKey const CLYMetricKeyAppVersion = @"_app_version"; +CLYMetricKey const CLYMetricKeyCarrier = @"_carrier"; +CLYMetricKey const CLYMetricKeyResolution = @"_resolution"; +CLYMetricKey const CLYMetricKeyDensity = @"_density"; +CLYMetricKey const CLYMetricKeyLocale = @"_locale"; +CLYMetricKey const CLYMetricKeyHasWatch = @"_has_watch"; +CLYMetricKey const CLYMetricKeyInstalledWatchApp = @"_installed_watch_app"; + +#if (TARGET_OS_IOS) +@interface CountlyDeviceInfo () +@property (nonatomic) CTTelephonyNetworkInfo* networkInfo; +@end +#endif + +@implementation CountlyDeviceInfo + ++ (instancetype)sharedInstance +{ + static CountlyDeviceInfo *s_sharedInstance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{s_sharedInstance = self.new;}); + return s_sharedInstance; +} + +- (instancetype)init +{ + if (self = [super init]) + { + self.deviceID = [CountlyPersistency.sharedInstance retrieveDeviceID]; +#if (TARGET_OS_IOS) + self.networkInfo = CTTelephonyNetworkInfo.new; +#endif + } + + return self; +} + +- (void)initializeDeviceID:(NSString *)deviceID +{ + self.deviceID = [self ensafeDeviceID:deviceID]; + + [CountlyPersistency.sharedInstance storeDeviceID:self.deviceID]; +} + +- (NSString *)ensafeDeviceID:(NSString *)deviceID +{ + if (deviceID.length) + return deviceID; + +#if (TARGET_OS_IOS || TARGET_OS_TV) + return UIDevice.currentDevice.identifierForVendor.UUIDString; +#else + NSString* UUID = [CountlyPersistency.sharedInstance retrieveNSUUID]; + if (!UUID) + { + UUID = NSUUID.UUID.UUIDString; + [CountlyPersistency.sharedInstance storeNSUUID:UUID]; + } + + return UUID; +#endif +} + +- (BOOL)isDeviceIDTemporary +{ + return [self.deviceID isEqualToString:CLYTemporaryDeviceID]; +} + +#pragma mark - + ++ (NSString *)device +{ +#if (TARGET_OS_OSX) + char *modelKey = "hw.model"; +#else + char *modelKey = "hw.machine"; +#endif + size_t size; + sysctlbyname(modelKey, NULL, &size, NULL, 0); + char *model = malloc(size); + sysctlbyname(modelKey, model, &size, NULL, 0); + NSString *modelString = @(model); + free(model); + return modelString; +} + ++ (NSString *)deviceType +{ +#if (TARGET_OS_IOS) + if (UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad) + return @"tablet"; + + return @"mobile"; +#elif (TARGET_OS_WATCH) + return @"wearable"; +#elif (TARGET_OS_TV) + return @"smarttv"; +#elif (TARGET_OS_OSX) + return @"desktop"; +#endif + + return nil; +} + ++ (NSString *)architecture +{ + cpu_type_t type; + size_t size = sizeof(type); + sysctlbyname("hw.cputype", &type, &size, NULL, 0); + + if (type == CPU_TYPE_ARM64) + return @"arm64"; + + if (type == CPU_TYPE_ARM) + return @"armv7"; + + if (type == CPU_TYPE_ARM64_32) + return @"arm64_32"; + + if (type == CPU_TYPE_X86) + return @"x86_64"; + + return nil; +} + ++ (NSString *)osName +{ +#if (TARGET_OS_IOS) + return @"iOS"; +#elif (TARGET_OS_WATCH) + return @"watchOS"; +#elif (TARGET_OS_TV) + return @"tvOS"; +#elif (TARGET_OS_OSX) + return @"macOS"; +#endif + + return nil; +} + ++ (NSString *)osVersion +{ +#if (TARGET_OS_IOS || TARGET_OS_TV) + return UIDevice.currentDevice.systemVersion; +#elif (TARGET_OS_WATCH) + return WKInterfaceDevice.currentDevice.systemVersion; +#elif (TARGET_OS_OSX) + return [NSDictionary dictionaryWithContentsOfFile:@"/System/Library/CoreServices/SystemVersion.plist"][@"ProductVersion"]; +#endif + + return nil; +} + ++ (NSString *)carrier +{ +#if (TARGET_OS_IOS) + return CountlyDeviceInfo.sharedInstance.networkInfo.subscriberCellularProvider.carrierName; +#endif + //NOTE: it is not possible to get carrier info on Apple Watches as CoreTelephony is not available. + return nil; +} + ++ (NSString *)resolution +{ +#if (TARGET_OS_IOS || TARGET_OS_TV) + CGRect bounds = UIScreen.mainScreen.bounds; + CGFloat scale = UIScreen.mainScreen.scale; +#elif (TARGET_OS_WATCH) + CGRect bounds = WKInterfaceDevice.currentDevice.screenBounds; + CGFloat scale = WKInterfaceDevice.currentDevice.screenScale; +#elif (TARGET_OS_OSX) + NSRect bounds = NSScreen.mainScreen.frame; + CGFloat scale = NSScreen.mainScreen.backingScaleFactor; +#else + return nil; +#endif + + return [NSString stringWithFormat:@"%gx%g", bounds.size.width * scale, bounds.size.height * scale]; +} + ++ (NSString *)density +{ +#if (TARGET_OS_IOS || TARGET_OS_TV) + CGFloat scale = UIScreen.mainScreen.scale; +#elif (TARGET_OS_WATCH) + CGFloat scale = WKInterfaceDevice.currentDevice.screenScale; +#elif (TARGET_OS_OSX) + CGFloat scale = NSScreen.mainScreen.backingScaleFactor; +#else + return nil; +#endif + + return [NSString stringWithFormat:@"@%dx", (int)scale]; +} + ++ (NSString *)locale +{ + return NSLocale.currentLocale.localeIdentifier; +} + ++ (NSString *)appVersion +{ + return [NSBundle.mainBundle objectForInfoDictionaryKey:@"CFBundleShortVersionString"]; +} + ++ (NSString *)appBuild +{ + return [NSBundle.mainBundle objectForInfoDictionaryKey:(NSString*)kCFBundleVersionKey]; +} + +#if (TARGET_OS_IOS) ++ (NSInteger)hasWatch +{ + if (@available(iOS 9.0, *)) + return (NSInteger)WCSession.defaultSession.paired; + + return 0; +} + ++ (NSInteger)installedWatchApp +{ + if (@available(iOS 9.0, *)) + return (NSInteger)WCSession.defaultSession.watchAppInstalled; + + return 0; +} +#endif + ++ (NSString *)metrics +{ + NSMutableDictionary* metricsDictionary = NSMutableDictionary.new; + metricsDictionary[CLYMetricKeyDevice] = CountlyDeviceInfo.device; + metricsDictionary[CLYMetricKeyDeviceType] = CountlyDeviceInfo.deviceType; + metricsDictionary[CLYMetricKeyOS] = CountlyDeviceInfo.osName; + metricsDictionary[CLYMetricKeyOSVersion] = CountlyDeviceInfo.osVersion; + metricsDictionary[CLYMetricKeyAppVersion] = CountlyDeviceInfo.appVersion; + + NSString *carrier = CountlyDeviceInfo.carrier; + if (carrier) + metricsDictionary[CLYMetricKeyCarrier] = carrier; + + metricsDictionary[CLYMetricKeyResolution] = CountlyDeviceInfo.resolution; + metricsDictionary[CLYMetricKeyDensity] = CountlyDeviceInfo.density; + metricsDictionary[CLYMetricKeyLocale] = CountlyDeviceInfo.locale; + +#if (TARGET_OS_IOS) + if (CountlyCommon.sharedInstance.enableAppleWatch) + { + if (CountlyConsentManager.sharedInstance.consentForAppleWatch) + { + metricsDictionary[CLYMetricKeyHasWatch] = @(CountlyDeviceInfo.hasWatch); + metricsDictionary[CLYMetricKeyInstalledWatchApp] = @(CountlyDeviceInfo.installedWatchApp); + } + } +#endif + + if (CountlyDeviceInfo.sharedInstance.customMetrics) + { + [CountlyDeviceInfo.sharedInstance.customMetrics enumerateKeysAndObjectsUsingBlock:^(NSString* key, NSString* value, BOOL* stop) + { + if ([value isKindOfClass:NSString.class]) + metricsDictionary[key] = value; + }]; + } + + return [metricsDictionary cly_JSONify]; +} + +#pragma mark - + ++ (NSUInteger)connectionType +{ + typedef enum : NSInteger + { + CLYConnectionNone, + CLYConnectionWiFi, + CLYConnectionCellNetwork, + CLYConnectionCellNetwork2G, + CLYConnectionCellNetwork3G, + CLYConnectionCellNetworkLTE + } CLYConnectionType; + + CLYConnectionType connType = CLYConnectionNone; + + @try + { + struct ifaddrs *interfaces, *i; + + if (!getifaddrs(&interfaces)) + { + i = interfaces; + + while (i != NULL) + { + if (i->ifa_addr->sa_family == AF_INET) + { + if ([[NSString stringWithUTF8String:i->ifa_name] isEqualToString:@"pdp_ip0"]) + { + connType = CLYConnectionCellNetwork; + +#if (TARGET_OS_IOS) + NSDictionary* connectionTypes = + @{ + CTRadioAccessTechnologyGPRS: @(CLYConnectionCellNetwork2G), + CTRadioAccessTechnologyEdge: @(CLYConnectionCellNetwork2G), + CTRadioAccessTechnologyCDMA1x: @(CLYConnectionCellNetwork2G), + CTRadioAccessTechnologyWCDMA: @(CLYConnectionCellNetwork3G), + CTRadioAccessTechnologyHSDPA: @(CLYConnectionCellNetwork3G), + CTRadioAccessTechnologyHSUPA: @(CLYConnectionCellNetwork3G), + CTRadioAccessTechnologyCDMAEVDORev0: @(CLYConnectionCellNetwork3G), + CTRadioAccessTechnologyCDMAEVDORevA: @(CLYConnectionCellNetwork3G), + CTRadioAccessTechnologyCDMAEVDORevB: @(CLYConnectionCellNetwork3G), + CTRadioAccessTechnologyeHRPD: @(CLYConnectionCellNetwork3G), + CTRadioAccessTechnologyLTE: @(CLYConnectionCellNetworkLTE) + }; + + NSString* radioAccessTech = CountlyDeviceInfo.sharedInstance.networkInfo.currentRadioAccessTechnology; + if (connectionTypes[radioAccessTech]) + connType = [connectionTypes[radioAccessTech] integerValue]; +#endif + } + else if ([[NSString stringWithUTF8String:i->ifa_name] isEqualToString:@"en0"]) + { + connType = CLYConnectionWiFi; + break; + } + } + + i = i->ifa_next; + } + } + + freeifaddrs(interfaces); + } + @catch (NSException *exception) + { + COUNTLY_LOG(@"Connection type can not be retrieved: \n%@", exception); + } + + return connType; +} + ++ (unsigned long long)freeRAM +{ + vm_statistics_data_t vms; + mach_msg_type_number_t ic = HOST_VM_INFO_COUNT; + kern_return_t kr = host_statistics(mach_host_self(), HOST_VM_INFO, (host_info_t)&vms, &ic); + if (kr != KERN_SUCCESS) + return -1; + + return vm_page_size * (vms.free_count); +} + ++ (unsigned long long)totalRAM +{ + return NSProcessInfo.processInfo.physicalMemory; +} + ++ (unsigned long long)freeDisk +{ + NSDictionary *homeDirectory = [NSFileManager.defaultManager attributesOfFileSystemForPath:NSHomeDirectory() error:nil]; + return [homeDirectory[NSFileSystemFreeSize] longLongValue]; +} + ++ (unsigned long long)totalDisk +{ + NSDictionary *homeDirectory = [NSFileManager.defaultManager attributesOfFileSystemForPath:NSHomeDirectory() error:nil]; + return [homeDirectory[NSFileSystemSize] longLongValue]; +} + ++ (NSInteger)batteryLevel +{ +#if (TARGET_OS_IOS) + UIDevice.currentDevice.batteryMonitoringEnabled = YES; + return abs((int)(UIDevice.currentDevice.batteryLevel * 100)); +#elif (TARGET_OS_WATCH) + if (@available(watchOS 4.0, *)) + { + return abs((int)(WKInterfaceDevice.currentDevice.batteryLevel * 100)); + } + else + { + return 100; + } +#elif (TARGET_OS_OSX) + CFTypeRef sourcesInfo = IOPSCopyPowerSourcesInfo(); + NSArray *sources = (__bridge NSArray*)IOPSCopyPowerSourcesList(sourcesInfo); + NSDictionary *source = sources.firstObject; + if (!source) + return 100; + + NSInteger currentLevel = ((NSNumber *)(source[@kIOPSCurrentCapacityKey])).integerValue; + NSInteger maxLevel = ((NSNumber *)(source[@kIOPSMaxCapacityKey])).integerValue; + return (currentLevel / (float)maxLevel) * 100; +#endif + + return 100; +} + ++ (NSString *)orientation +{ +#if (TARGET_OS_IOS) + NSArray *orientations = @[@"Unknown", @"Portrait", @"PortraitUpsideDown", @"LandscapeLeft", @"LandscapeRight", @"FaceUp", @"FaceDown"]; + UIDeviceOrientation orientation = UIDevice.currentDevice.orientation; + if (orientation >= 0 && orientation < orientations.count) + return orientations[orientation]; +#elif (TARGET_OS_WATCH) + if (@available(watchOS 3.0, *)) + { + NSArray *orientations = @[@"CrownLeft", @"CrownRight"]; + WKInterfaceDeviceCrownOrientation orientation = WKInterfaceDevice.currentDevice.crownOrientation; + if (orientation >= 0 && orientation < orientations.count) + return orientations[orientation]; + } +#endif + + return nil; +} + ++ (BOOL)isJailbroken +{ + FILE *f = fopen("/bin/bash", "r"); + BOOL isJailbroken = (f != NULL); + fclose(f); + return isJailbroken; +} + ++ (BOOL)isInBackground +{ +#if (TARGET_OS_IOS || TARGET_OS_TV) + return UIApplication.sharedApplication.applicationState == UIApplicationStateBackground; +#else + return NO; +#endif +} + +@end diff --git a/src/ios/CountlyiOS/CountlyEvent.h b/src/ios/CountlyiOS/CountlyEvent.h new file mode 100644 index 0000000..7389674 --- /dev/null +++ b/src/ios/CountlyiOS/CountlyEvent.h @@ -0,0 +1,21 @@ +// CountlyEvent.h +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import + +@interface CountlyEvent : NSObject + +@property (nonatomic, copy) NSString* key; +@property (nonatomic, copy) NSDictionary* segmentation; +@property (nonatomic) NSUInteger count; +@property (nonatomic) double sum; +@property (nonatomic) NSTimeInterval timestamp; +@property (nonatomic) NSUInteger hourOfDay; +@property (nonatomic) NSUInteger dayOfWeek; +@property (nonatomic) NSTimeInterval duration; +- (NSDictionary *)dictionaryRepresentation; + +@end diff --git a/src/ios/CountlyiOS/CountlyEvent.m b/src/ios/CountlyiOS/CountlyEvent.m new file mode 100644 index 0000000..e304b8f --- /dev/null +++ b/src/ios/CountlyiOS/CountlyEvent.m @@ -0,0 +1,65 @@ +// CountlyEvent.m +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import "CountlyCommon.h" + +@implementation CountlyEvent + +NSString* const kCountlyEventKeyKey = @"key"; +NSString* const kCountlyEventKeySegmentation = @"segmentation"; +NSString* const kCountlyEventKeyCount = @"count"; +NSString* const kCountlyEventKeySum = @"sum"; +NSString* const kCountlyEventKeyTimestamp = @"timestamp"; +NSString* const kCountlyEventKeyHourOfDay = @"hour"; +NSString* const kCountlyEventKeyDayOfWeek = @"dow"; +NSString* const kCountlyEventKeyDuration = @"dur"; + +- (NSDictionary *)dictionaryRepresentation +{ + NSMutableDictionary* eventData = NSMutableDictionary.dictionary; + eventData[kCountlyEventKeyKey] = self.key; + if (self.segmentation) + { + eventData[kCountlyEventKeySegmentation] = self.segmentation; + } + eventData[kCountlyEventKeyCount] = @(self.count); + eventData[kCountlyEventKeySum] = @(self.sum); + eventData[kCountlyEventKeyTimestamp] = @((long long)(self.timestamp * 1000)); + eventData[kCountlyEventKeyHourOfDay] = @(self.hourOfDay); + eventData[kCountlyEventKeyDayOfWeek] = @(self.dayOfWeek); + eventData[kCountlyEventKeyDuration] = @(self.duration); + return eventData; +} + +- (instancetype)initWithCoder:(NSCoder *)decoder +{ + if (self = [super init]) + { + self.key = [decoder decodeObjectForKey:NSStringFromSelector(@selector(key))]; + self.segmentation = [decoder decodeObjectForKey:NSStringFromSelector(@selector(segmentation))]; + self.count = [decoder decodeIntegerForKey:NSStringFromSelector(@selector(count))]; + self.sum = [decoder decodeDoubleForKey:NSStringFromSelector(@selector(sum))]; + self.timestamp = [decoder decodeDoubleForKey:NSStringFromSelector(@selector(timestamp))]; + self.hourOfDay = [decoder decodeIntegerForKey:NSStringFromSelector(@selector(hourOfDay))]; + self.dayOfWeek = [decoder decodeIntegerForKey:NSStringFromSelector(@selector(dayOfWeek))]; + self.duration = [decoder decodeDoubleForKey:NSStringFromSelector(@selector(duration))]; + } + + return self; +} + +- (void)encodeWithCoder:(NSCoder *)encoder +{ + [encoder encodeObject:self.key forKey:NSStringFromSelector(@selector(key))]; + [encoder encodeObject:self.segmentation forKey:NSStringFromSelector(@selector(segmentation))]; + [encoder encodeInteger:self.count forKey:NSStringFromSelector(@selector(count))]; + [encoder encodeDouble:self.sum forKey:NSStringFromSelector(@selector(sum))]; + [encoder encodeDouble:self.timestamp forKey:NSStringFromSelector(@selector(timestamp))]; + [encoder encodeInteger:self.hourOfDay forKey:NSStringFromSelector(@selector(hourOfDay))]; + [encoder encodeInteger:self.dayOfWeek forKey:NSStringFromSelector(@selector(dayOfWeek))]; + [encoder encodeDouble:self.duration forKey:NSStringFromSelector(@selector(duration))]; +} +@end diff --git a/src/ios/CountlyiOS/CountlyFeedbackWidget.h b/src/ios/CountlyiOS/CountlyFeedbackWidget.h new file mode 100644 index 0000000..aa80e53 --- /dev/null +++ b/src/ios/CountlyiOS/CountlyFeedbackWidget.h @@ -0,0 +1,28 @@ +// CountlyFeedbackWidget.h +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import + +NS_ASSUME_NONNULL_BEGIN + +typedef NSString* CLYFeedbackWidgetType NS_EXTENSIBLE_STRING_ENUM; +extern CLYFeedbackWidgetType const CLYFeedbackWidgetTypeSurvey; +extern CLYFeedbackWidgetType const CLYFeedbackWidgetTypeNPS; + + +@interface CountlyFeedbackWidget : NSObject +#if (TARGET_OS_IOS) + +@property (nonatomic, readonly) CLYFeedbackWidgetType type; +@property (nonatomic, readonly) NSString* ID; +@property (nonatomic, readonly) NSString* name; + +- (void)present; + +#endif +@end + +NS_ASSUME_NONNULL_END diff --git a/src/ios/CountlyiOS/CountlyFeedbackWidget.m b/src/ios/CountlyiOS/CountlyFeedbackWidget.m new file mode 100644 index 0000000..e88b668 --- /dev/null +++ b/src/ios/CountlyiOS/CountlyFeedbackWidget.m @@ -0,0 +1,113 @@ +// CountlyFeedbackWidget.m +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import "CountlyCommon.h" +#if (TARGET_OS_IOS) +#import +#endif + +CLYFeedbackWidgetType const CLYFeedbackWidgetTypeSurvey = @"survey"; +CLYFeedbackWidgetType const CLYFeedbackWidgetTypeNPS = @"nps"; + +NSString* const kCountlyReservedEventPrefix = @"[CLY]_"; //NOTE: This will be used with feedback type. +NSString* const kCountlyFBKeyClosed = @"closed"; + +@interface CountlyFeedbackWidget () +@property (nonatomic) CLYFeedbackWidgetType type; +@property (nonatomic) NSString* ID; +@property (nonatomic) NSString* name; +@end + + +@implementation CountlyFeedbackWidget +#if (TARGET_OS_IOS) + ++ (CountlyFeedbackWidget *)createWithDictionary:(NSDictionary *)dictionary +{ + CountlyFeedbackWidget *feedback = CountlyFeedbackWidget.new; + feedback.ID = dictionary[kCountlyFBKeyID]; + feedback.type = dictionary[@"type"]; + feedback.name = dictionary[@"name"]; + return feedback; +} + +- (void)present +{ + if (!CountlyConsentManager.sharedInstance.consentForFeedback) + return; + + __block CLYInternalViewController* webVC = CLYInternalViewController.new; + webVC.view.backgroundColor = UIColor.whiteColor; + webVC.view.bounds = UIScreen.mainScreen.bounds; + webVC.modalPresentationStyle = UIModalPresentationCustom; + + WKWebView* webView = [WKWebView.alloc initWithFrame:webVC.view.bounds]; + webView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + [webVC.view addSubview:webView]; + NSURLRequest* request = [self displayRequest]; + [webView loadRequest:request]; + + CLYButton* dismissButton = [CLYButton dismissAlertButton]; + dismissButton.onClick = ^(id sender) + { + [webVC dismissViewControllerAnimated:YES completion:^ + { + webVC = nil; + }]; + + [self recordReservedEventForDismissing]; + }; + [webVC.view addSubview:dismissButton]; + [dismissButton positionToTopRightConsideringStatusBar]; + + [CountlyCommon.sharedInstance tryPresentingViewController:webVC]; +} + +- (NSURLRequest *)displayRequest +{ + NSString* queryString = [NSString stringWithFormat:@"%@=%@&%@=%@&%@=%@&%@=%@&%@=%@&%@=%@&%@=%@", + kCountlyQSKeyAppKey, CountlyConnectionManager.sharedInstance.appKey.cly_URLEscaped, + kCountlyQSKeyDeviceID, CountlyDeviceInfo.sharedInstance.deviceID.cly_URLEscaped, + kCountlyQSKeySDKName, CountlyCommon.sharedInstance.SDKName, + kCountlyQSKeySDKVersion, CountlyCommon.sharedInstance.SDKVersion, + kCountlyFBKeyAppVersion, CountlyDeviceInfo.appVersion, + kCountlyFBKeyPlatform, CountlyDeviceInfo.osName, + kCountlyFBKeyWidgetID, self.ID]; + + queryString = [CountlyConnectionManager.sharedInstance appendChecksum:queryString]; + + NSMutableString* URL = CountlyConnectionManager.sharedInstance.host.mutableCopy; + [URL appendString:kCountlyEndpointFeedback]; + NSString* feedbackTypeEndpoint = [@"/" stringByAppendingString:self.type]; + [URL appendString:feedbackTypeEndpoint]; + [URL appendFormat:@"?%@", queryString]; + + NSURLRequest* request = [NSURLRequest requestWithURL:[NSURL URLWithString:URL]]; + return request; +} + +- (void)recordReservedEventForDismissing +{ + if (!CountlyConsentManager.sharedInstance.consentForFeedback) + return; + + NSString* eventName = [kCountlyReservedEventPrefix stringByAppendingString:self.type]; + NSMutableDictionary* segmentation = NSMutableDictionary.new; + segmentation[kCountlyFBKeyPlatform] = CountlyDeviceInfo.osName; + segmentation[kCountlyFBKeyAppVersion] = CountlyDeviceInfo.appVersion; + segmentation[kCountlyFBKeyClosed] = @1; + segmentation[kCountlyFBKeyWidgetID] = self.ID; + [Countly.sharedInstance recordReservedEvent:eventName segmentation:segmentation]; +} + +- (NSString *)description +{ + NSString *customDescription = [NSString stringWithFormat:@"\rID: %@, Type: %@ \rName: %@", self.ID, self.type, self.name]; + return [[super description] stringByAppendingString:customDescription]; +} + +#endif +@end diff --git a/src/ios/CountlyiOS/CountlyFeedbacks.h b/src/ios/CountlyiOS/CountlyFeedbacks.h new file mode 100644 index 0000000..fbf0eae --- /dev/null +++ b/src/ios/CountlyiOS/CountlyFeedbacks.h @@ -0,0 +1,32 @@ +// CountlyFeedbacks.h +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import + +@class CountlyFeedbackWidget; + +extern NSString* const kCountlyFBKeyPlatform; +extern NSString* const kCountlyFBKeyAppVersion; +extern NSString* const kCountlyFBKeyWidgetID; +extern NSString* const kCountlyFBKeyID; + +@interface CountlyFeedbacks : NSObject +#if (TARGET_OS_IOS) ++ (instancetype)sharedInstance; + +- (void)showDialog:(void(^)(NSInteger rating))completion; +- (void)checkFeedbackWidgetWithID:(NSString *)widgetID completionHandler:(void (^)(NSError * error))completionHandler; +- (void)checkForStarRatingAutoAsk; + +- (void)getFeedbackWidgets:(void (^)(NSArray *feedbackWidgets, NSError *error))completionHandler; + +@property (nonatomic) NSString* message; +@property (nonatomic) NSString* dismissButtonTitle; +@property (nonatomic) NSUInteger sessionCount; +@property (nonatomic) BOOL disableAskingForEachAppVersion; +@property (nonatomic, copy) void (^ratingCompletionForAutoAsk)(NSInteger); +#endif +@end diff --git a/src/ios/CountlyiOS/CountlyFeedbacks.m b/src/ios/CountlyiOS/CountlyFeedbacks.m new file mode 100644 index 0000000..ba1d035 --- /dev/null +++ b/src/ios/CountlyiOS/CountlyFeedbacks.m @@ -0,0 +1,466 @@ +// CountlyFeedbacks.m +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import "CountlyCommon.h" +#if (TARGET_OS_IOS) +#import +#endif + +@interface CountlyFeedbackWidget () ++ (CountlyFeedbackWidget *)createWithDictionary:(NSDictionary *)dictionary; +@end + + + +@interface CountlyFeedbacks () +#if (TARGET_OS_IOS) +@property (nonatomic) UIAlertController* alertController; +@property (nonatomic, copy) void (^ratingCompletion)(NSInteger); +#endif +@end + +NSString* const kCountlyReservedEventStarRating = @"[CLY]_star_rating"; +NSString* const kCountlyStarRatingStatusSessionCountKey = @"kCountlyStarRatingStatusSessionCountKey"; +NSString* const kCountlyStarRatingStatusHasEverAskedAutomatically = @"kCountlyStarRatingStatusHasEverAskedAutomatically"; + +NSString* const kCountlyFBKeyPlatform = @"platform"; +NSString* const kCountlyFBKeyAppVersion = @"app_version"; +NSString* const kCountlyFBKeyRating = @"rating"; +NSString* const kCountlyFBKeyWidgetID = @"widget_id"; +NSString* const kCountlyFBKeyID = @"_id"; +NSString* const kCountlyFBKeyTargetDevices = @"target_devices"; +NSString* const kCountlyFBKeyPhone = @"phone"; +NSString* const kCountlyFBKeyTablet = @"tablet"; +NSString* const kCountlyFBKeyFeedback = @"feedback"; + +const CGFloat kCountlyStarRatingButtonSize = 40.0; + +@implementation CountlyFeedbacks +#if (TARGET_OS_IOS) +{ + UIButton* btn_star[5]; +} + ++ (instancetype)sharedInstance +{ + if (!CountlyCommon.sharedInstance.hasStarted) + return nil; + + static CountlyFeedbacks* s_sharedInstance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{s_sharedInstance = self.new;}); + return s_sharedInstance; +} + +- (instancetype)init +{ + if (self = [super init]) + { + NSString* langDesignator = [NSLocale.preferredLanguages.firstObject substringToIndex:2]; + + NSDictionary* dictMessage = + @{ + @"en": @"How would you rate the app?", + @"tr": @"Uygulamayı nasıl değerlendirirsiniz?", + @"ja": @"あなたの評価を教えてください。", + @"zh": @"请告诉我你的评价。", + @"ru": @"Как бы вы оценили приложение?", + @"cz": @"Jak hodnotíte aplikaci?", + @"lv": @"Kā Jūs novērtētu šo lietotni?", + @"bn": @"আপনি কিভাবে এই এপ্লিক্যাশনটি মূল্যায়ন করবেন?", + @"hi": @"आप एप्लीकेशन का मूल्यांकन कैसे करेंगे?", + }; + + self.message = dictMessage[langDesignator]; + if (!self.message) + self.message = dictMessage[@"en"]; + } + + return self; +} + +#pragma mark - Star Rating + +- (void)showDialog:(void(^)(NSInteger rating))completion +{ + if (!CountlyConsentManager.sharedInstance.consentForFeedback) + return; + + self.ratingCompletion = completion; + + self.alertController = [UIAlertController alertControllerWithTitle:@" " message:self.message preferredStyle:UIAlertControllerStyleAlert]; + + CLYButton* dismissButton = [CLYButton dismissAlertButton]; + dismissButton.onClick = ^(id sender) + { + [self.alertController dismissViewControllerAnimated:YES completion:^ + { + [self finishWithRating:0]; + }]; + }; + [self.alertController.view addSubview:dismissButton]; + [dismissButton positionToTopRight]; + + CLYInternalViewController* cvc = CLYInternalViewController.new; + [cvc setPreferredContentSize:(CGSize){kCountlyStarRatingButtonSize * 5, kCountlyStarRatingButtonSize * 1.5}]; + [cvc.view addSubview:[self starView]]; + + @try + { + [self.alertController setValue:cvc forKey:@"contentViewController"]; + } + @catch (NSException* exception) + { + COUNTLY_LOG(@"UIAlertController's contentViewController can not be set: \n%@", exception); + } + + [CountlyCommon.sharedInstance tryPresentingViewController:self.alertController]; +} + +- (void)checkForStarRatingAutoAsk +{ + if (!self.sessionCount) + return; + + if (!CountlyConsentManager.sharedInstance.consentForFeedback) + return; + + NSMutableDictionary* status = [CountlyPersistency.sharedInstance retrieveStarRatingStatus].mutableCopy; + + if (self.disableAskingForEachAppVersion && status[kCountlyStarRatingStatusHasEverAskedAutomatically]) + return; + + NSString* keyForAppVersion = [kCountlyStarRatingStatusSessionCountKey stringByAppendingString:CountlyDeviceInfo.appVersion]; + NSInteger sessionCountSoFar = [status[keyForAppVersion] integerValue]; + sessionCountSoFar++; + + if (self.sessionCount == sessionCountSoFar) + { + COUNTLY_LOG(@"Asking for star-rating as session count reached specified limit %d ...", (int)self.sessionCount); + + [self showDialog:self.ratingCompletionForAutoAsk]; + + status[kCountlyStarRatingStatusHasEverAskedAutomatically] = @YES; + } + + status[keyForAppVersion] = @(sessionCountSoFar); + + [CountlyPersistency.sharedInstance storeStarRatingStatus:status]; +} + +- (UIView *)starView +{ + UIView* vw_star = [UIView.alloc initWithFrame:(CGRect){0, 0, kCountlyStarRatingButtonSize * 5, kCountlyStarRatingButtonSize}]; + vw_star.autoresizingMask = UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin | UIViewAutoresizingFlexibleBottomMargin; + + for (int i = 0; i < 5; i++) + { + btn_star[i] = [UIButton.alloc initWithFrame:(CGRect){i * kCountlyStarRatingButtonSize, 0, kCountlyStarRatingButtonSize, kCountlyStarRatingButtonSize}]; + btn_star[i].titleLabel.font = [UIFont fontWithName:@"Helvetica" size:28]; + [btn_star[i] setTitle:@"★" forState:UIControlStateNormal]; + [btn_star[i] setTitleColor:[self passiveStarColor] forState:UIControlStateNormal]; + [btn_star[i] addTarget:self action:@selector(onClick_star:) forControlEvents:UIControlEventTouchUpInside]; + + [vw_star addSubview:btn_star[i]]; + } + + return vw_star; +} + +- (void)setMessage:(NSString *)message +{ + if (!message) + return; + + _message = message; +} + +- (void)onClick_star:(id)sender +{ + UIColor* color = [self activeStarColor]; + NSInteger rating = 0; + + for (int i = 0; i < 5; i++) + { + [btn_star[i] setTitleColor:color forState:UIControlStateNormal]; + + if (btn_star[i] == sender) + { + color = [self passiveStarColor]; + rating = i + 1; + } + } + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.25 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^ + { + [self.alertController dismissViewControllerAnimated:YES completion:^{ [self finishWithRating:rating]; }]; + }); +} + +- (void)finishWithRating:(NSInteger)rating +{ + if (self.ratingCompletion) + self.ratingCompletion(rating); + + if (rating != 0) + { + NSMutableDictionary* segmentation = NSMutableDictionary.new; + segmentation[kCountlyFBKeyPlatform] = CountlyDeviceInfo.osName; + segmentation[kCountlyFBKeyAppVersion] = CountlyDeviceInfo.appVersion; + segmentation[kCountlyFBKeyRating] = @(rating); + + [Countly.sharedInstance recordReservedEvent:kCountlyReservedEventStarRating segmentation:segmentation]; + } + + self.alertController = nil; + self.ratingCompletion = nil; +} + +- (UIColor *)activeStarColor +{ + return [UIColor colorWithRed:253/255.0 green:148/255.0 blue:38/255.0 alpha:1]; +} + +- (UIColor *)passiveStarColor +{ + return [UIColor colorWithWhite:178/255.0 alpha:1]; +} + +#pragma mark - Feedbacks (Ratings) (Legacy Feedback Widget) + +- (void)checkFeedbackWidgetWithID:(NSString *)widgetID completionHandler:(void (^)(NSError * error))completionHandler +{ + if (!CountlyConsentManager.sharedInstance.consentForFeedback) + return; + + if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) + return; + + if (!widgetID.length) + return; + + NSURLRequest* feedbackWidgetCheckRequest = [self widgetCheckURLRequest:widgetID]; + NSURLSessionTask* task = [NSURLSession.sharedSession dataTaskWithRequest:feedbackWidgetCheckRequest completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) + { + NSDictionary* widgetInfo = nil; + + if (!error) + { + widgetInfo = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; + } + + if (!error) + { + NSMutableDictionary* userInfo = widgetInfo.mutableCopy; + + if (![widgetInfo[kCountlyFBKeyID] isEqualToString:widgetID]) + { + userInfo[NSLocalizedDescriptionKey] = [NSString stringWithFormat:@"Feedback widget with ID %@ is not available.", widgetID]; + error = [NSError errorWithDomain:kCountlyErrorDomain code:CLYErrorFeedbackWidgetNotAvailable userInfo:userInfo]; + } + else if (![self isDeviceTargetedByWidget:widgetInfo]) + { + userInfo[NSLocalizedDescriptionKey] = [NSString stringWithFormat:@"Feedback widget with ID %@ does not include this device in target devices list.", widgetID]; + error = [NSError errorWithDomain:kCountlyErrorDomain code:CLYErrorFeedbackWidgetNotTargetedForDevice userInfo:userInfo]; + } + } + + if (error) + { + dispatch_async(dispatch_get_main_queue(), ^ + { + if (completionHandler) + completionHandler(error); + }); + return; + } + + dispatch_async(dispatch_get_main_queue(), ^ + { + [self presentFeedbackWidgetWithID:widgetID completionHandler:completionHandler]; + }); + }]; + + [task resume]; +} + +- (void)presentFeedbackWidgetWithID:(NSString *)widgetID completionHandler:(void (^)(NSError * error))completionHandler +{ + __block CLYInternalViewController* webVC = CLYInternalViewController.new; + webVC.view.backgroundColor = UIColor.whiteColor; + webVC.view.bounds = UIScreen.mainScreen.bounds; + webVC.modalPresentationStyle = UIModalPresentationCustom; + + WKWebView* webView = [WKWebView.alloc initWithFrame:webVC.view.bounds]; + webView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + [webVC.view addSubview:webView]; + NSURL* widgetDisplayURL = [self widgetDisplayURL:widgetID]; + [webView loadRequest:[NSURLRequest requestWithURL:widgetDisplayURL]]; + + CLYButton* dismissButton = [CLYButton dismissAlertButton]; + dismissButton.onClick = ^(id sender) + { + [webVC dismissViewControllerAnimated:YES completion:^ + { + if (completionHandler) + completionHandler(nil); + + webVC = nil; + }]; + }; + [webVC.view addSubview:dismissButton]; + [dismissButton positionToTopRightConsideringStatusBar]; + + [CountlyCommon.sharedInstance tryPresentingViewController:webVC]; +} + +- (NSURLRequest *)widgetCheckURLRequest:(NSString *)widgetID +{ + NSString* queryString = [CountlyConnectionManager.sharedInstance queryEssentials]; + + queryString = [queryString stringByAppendingFormat:@"&%@=%@", kCountlyFBKeyWidgetID, widgetID]; + + queryString = [CountlyConnectionManager.sharedInstance appendChecksum:queryString]; + + NSString* serverOutputFeedbackWidgetEndpoint = [CountlyConnectionManager.sharedInstance.host stringByAppendingFormat:@"%@%@%@", + kCountlyEndpointO, + kCountlyEndpointFeedback, + kCountlyEndpointWidget]; + + if (CountlyConnectionManager.sharedInstance.alwaysUsePOST) + { + NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:serverOutputFeedbackWidgetEndpoint]]; + request.HTTPMethod = @"POST"; + request.HTTPBody = [queryString cly_dataUTF8]; + return request.copy; + } + else + { + NSString* withQueryString = [serverOutputFeedbackWidgetEndpoint stringByAppendingFormat:@"?%@", queryString]; + NSURLRequest* request = [NSURLRequest requestWithURL:[NSURL URLWithString:withQueryString]]; + return request; + } +} + +- (NSURL *)widgetDisplayURL:(NSString *)widgetID +{ + NSString* queryString = [CountlyConnectionManager.sharedInstance queryEssentials]; + + queryString = [queryString stringByAppendingFormat:@"&%@=%@&%@=%@", + kCountlyFBKeyWidgetID, widgetID, + kCountlyFBKeyAppVersion, CountlyDeviceInfo.appVersion]; + + queryString = [CountlyConnectionManager.sharedInstance appendChecksum:queryString]; + + NSString* URLString = [NSString stringWithFormat:@"%@%@?%@", + CountlyConnectionManager.sharedInstance.host, + kCountlyEndpointFeedback, + queryString]; + + return [NSURL URLWithString:URLString]; +} + +- (BOOL)isDeviceTargetedByWidget:(NSDictionary *)widgetInfo +{ + BOOL isTablet = UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad; + BOOL isPhone = UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPhone; + BOOL isTabletTargeted = [widgetInfo[kCountlyFBKeyTargetDevices][kCountlyFBKeyTablet] boolValue]; + BOOL isPhoneTargeted = [widgetInfo[kCountlyFBKeyTargetDevices][kCountlyFBKeyPhone] boolValue]; + + return ((isTablet && isTabletTargeted) || (isPhone && isPhoneTargeted)); +} + +#pragma mark - Feedbacks (Surveys, NPS) + +- (void)getFeedbackWidgets:(void (^)(NSArray *feedbackWidgets, NSError *error))completionHandler +{ + if (!CountlyConsentManager.sharedInstance.consentForFeedback) + return; + + if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) + return; + + NSURLSessionTask* task = [NSURLSession.sharedSession dataTaskWithRequest:[self feedbacksRequest] completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) + { + NSDictionary *feedbacksResponse = nil; + + if (!error) + { + feedbacksResponse = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; + } + + if (!error) + { + if (((NSHTTPURLResponse*)response).statusCode != 200) + { + NSMutableDictionary* userInfo = feedbacksResponse.mutableCopy; + userInfo[NSLocalizedDescriptionKey] = @"Feedbacks general API error"; + error = [NSError errorWithDomain:kCountlyErrorDomain code:CLYErrorFeedbacksGeneralAPIError userInfo:userInfo]; + } + } + + if (error) + { + dispatch_async(dispatch_get_main_queue(), ^ + { + if (completionHandler) + completionHandler(nil, error); + }); + + return; + } + + NSMutableArray* feedbacks = NSMutableArray.new; + NSArray* rawFeedbackObjects = feedbacksResponse[@"result"]; + for (NSDictionary * feedbackDict in rawFeedbackObjects) + { + CountlyFeedbackWidget *feedback = [CountlyFeedbackWidget createWithDictionary:feedbackDict]; + if (feedback) + [feedbacks addObject:feedback]; + } + + dispatch_async(dispatch_get_main_queue(), ^ + { + if (completionHandler) + completionHandler([NSArray arrayWithArray:feedbacks], nil); + }); + }]; + + [task resume]; +} + +- (NSURLRequest *)feedbacksRequest +{ + NSString* queryString = [NSString stringWithFormat:@"%@=%@&%@=%@&%@=%@&%@=%@&%@=%@", + kCountlyQSKeyMethod, kCountlyFBKeyFeedback, + kCountlyQSKeyAppKey, CountlyConnectionManager.sharedInstance.appKey.cly_URLEscaped, + kCountlyQSKeyDeviceID, CountlyDeviceInfo.sharedInstance.deviceID.cly_URLEscaped, + kCountlyQSKeySDKName, CountlyCommon.sharedInstance.SDKName, + kCountlyQSKeySDKVersion, CountlyCommon.sharedInstance.SDKVersion]; + + queryString = [CountlyConnectionManager.sharedInstance appendChecksum:queryString]; + + NSMutableString* URL = CountlyConnectionManager.sharedInstance.host.mutableCopy; + [URL appendString:kCountlyEndpointO]; + [URL appendString:kCountlyEndpointSDK]; + + if (CountlyConnectionManager.sharedInstance.alwaysUsePOST) + { + NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:URL]]; + request.HTTPMethod = @"POST"; + request.HTTPBody = [queryString cly_dataUTF8]; + return request.copy; + } + else + { + [URL appendFormat:@"?%@", queryString]; + NSURLRequest* request = [NSURLRequest requestWithURL:[NSURL URLWithString:URL]]; + return request; + } +} + +#endif +@end diff --git a/src/ios/CountlyiOS/CountlyLocationManager.h b/src/ios/CountlyiOS/CountlyLocationManager.h new file mode 100644 index 0000000..f9d450b --- /dev/null +++ b/src/ios/CountlyiOS/CountlyLocationManager.h @@ -0,0 +1,22 @@ +// CountlyLocationManager.h +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import + +@interface CountlyLocationManager : NSObject +@property (nonatomic, copy) NSString* location; +@property (nonatomic, copy) NSString* city; +@property (nonatomic, copy) NSString* ISOCountryCode; +@property (nonatomic, copy) NSString* IP; +@property (nonatomic) BOOL isLocationInfoDisabled; ++ (instancetype)sharedInstance; + +- (void)sendLocationInfo; +- (void)updateLocation:(CLLocationCoordinate2D)location city:(NSString *)city ISOCountryCode:(NSString *)ISOCountryCode IP:(NSString *)IP; +- (void)recordLocation:(CLLocationCoordinate2D)location city:(NSString *)city ISOCountryCode:(NSString *)ISOCountryCode IP:(NSString *)IP; +- (void)disableLocationInfo; + +@end diff --git a/src/ios/CountlyiOS/CountlyLocationManager.m b/src/ios/CountlyiOS/CountlyLocationManager.m new file mode 100644 index 0000000..11e4d5a --- /dev/null +++ b/src/ios/CountlyiOS/CountlyLocationManager.m @@ -0,0 +1,92 @@ +// CountlyLocationManager.m +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import "CountlyCommon.h" + + +@implementation CountlyLocationManager + ++ (instancetype)sharedInstance +{ + if (!CountlyCommon.sharedInstance.hasStarted) + return nil; + + static CountlyLocationManager* s_sharedInstance; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{s_sharedInstance = self.new;}); + return s_sharedInstance; +} + +- (instancetype)init +{ + if (self = [super init]) + { + + } + + return self; +} + +#pragma mark --- + +- (void)recordLocation:(CLLocationCoordinate2D)location city:(NSString *)city ISOCountryCode:(NSString *)ISOCountryCode IP:(NSString *)IP +{ + if (!CountlyConsentManager.sharedInstance.consentForLocation) + return; + + [self updateLocation:location city:city ISOCountryCode:ISOCountryCode IP:IP]; + + [CountlyConnectionManager.sharedInstance sendLocationInfo]; +} + +- (void)updateLocation:(CLLocationCoordinate2D)location city:(NSString *)city ISOCountryCode:(NSString *)ISOCountryCode IP:(NSString *)IP +{ + if (CLLocationCoordinate2DIsValid(location)) + self.location = [NSString stringWithFormat:@"%f,%f", location.latitude, location.longitude]; + else + self.location = nil; + + self.city = city.length ? city : nil; + self.ISOCountryCode = ISOCountryCode.length ? ISOCountryCode : nil; + self.IP = IP.length ? IP : nil; + + if (self.city && !self.ISOCountryCode) + { + COUNTLY_LOG(@"City and Country Code should be set as a pair. Country Code is missing while City is set!"); + } + else if (self.ISOCountryCode && !self.city) + { + COUNTLY_LOG(@"City and Country Code should be set as a pair. City is missing while Country Code is set!"); + } + + if ((self.location || self.city || self.ISOCountryCode || self.IP)) + self.isLocationInfoDisabled = NO; +} + +- (void)sendLocationInfo +{ + if (!CountlyConsentManager.sharedInstance.consentForLocation) + return; + + [CountlyConnectionManager.sharedInstance sendLocationInfo]; +} + +- (void)disableLocationInfo +{ + if (!CountlyConsentManager.sharedInstance.consentForLocation) + return; + + self.isLocationInfoDisabled = YES; + + self.location = nil; + self.city = nil; + self.ISOCountryCode = nil; + self.IP = nil; + + [CountlyConnectionManager.sharedInstance sendLocationInfo]; +} + +@end diff --git a/src/ios/CountlyiOS/CountlyNotificationService.h b/src/ios/CountlyiOS/CountlyNotificationService.h new file mode 100644 index 0000000..9d9c585 --- /dev/null +++ b/src/ios/CountlyiOS/CountlyNotificationService.h @@ -0,0 +1,33 @@ +// CountlyNotificationService.h +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import + +#if (TARGET_OS_IOS) +#import +#endif + +NS_ASSUME_NONNULL_BEGIN + +extern NSString* const kCountlyActionIdentifier; + +extern NSString* const kCountlyPNKeyCountlyPayload; +extern NSString* const kCountlyPNKeyNotificationID; +extern NSString* const kCountlyPNKeyButtons; +extern NSString* const kCountlyPNKeyDefaultURL; +extern NSString* const kCountlyPNKeyAttachment; +extern NSString* const kCountlyPNKeyActionButtonIndex; +extern NSString* const kCountlyPNKeyActionButtonTitle; +extern NSString* const kCountlyPNKeyActionButtonURL; + +@interface CountlyNotificationService : NSObject +#if (TARGET_OS_IOS) ++ (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent *))contentHandler API_AVAILABLE(ios(10.0)); +#endif + +NS_ASSUME_NONNULL_END + +@end diff --git a/src/ios/CountlyiOS/CountlyNotificationService.m b/src/ios/CountlyiOS/CountlyNotificationService.m new file mode 100644 index 0000000..8ebf8fd --- /dev/null +++ b/src/ios/CountlyiOS/CountlyNotificationService.m @@ -0,0 +1,131 @@ +// CountlyNotificationService.m +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import "CountlyNotificationService.h" + +#if DEBUG +#define COUNTLY_EXT_LOG(fmt, ...) NSLog([@"%@ " stringByAppendingString:fmt], @"[CountlyNSE]", ##__VA_ARGS__) +#else +#define COUNTLY_EXT_LOG(...) +#endif + +NSString* const kCountlyActionIdentifier = @"CountlyActionIdentifier"; +NSString* const kCountlyCategoryIdentifier = @"CountlyCategoryIdentifier"; + +NSString* const kCountlyPNKeyCountlyPayload = @"c"; +NSString* const kCountlyPNKeyNotificationID = @"i"; +NSString* const kCountlyPNKeyButtons = @"b"; +NSString* const kCountlyPNKeyDefaultURL = @"l"; +NSString* const kCountlyPNKeyAttachment = @"a"; +NSString* const kCountlyPNKeyActionButtonIndex = @"b"; +NSString* const kCountlyPNKeyActionButtonTitle = @"t"; +NSString* const kCountlyPNKeyActionButtonURL = @"l"; + +@implementation CountlyNotificationService +#if (TARGET_OS_IOS) ++ (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent *))contentHandler +{ + COUNTLY_EXT_LOG(@"didReceiveNotificationRequest:withContentHandler:"); + + NSDictionary* countlyPayload = request.content.userInfo[kCountlyPNKeyCountlyPayload]; + NSString* notificationID = countlyPayload[kCountlyPNKeyNotificationID]; + + if (!notificationID) + { + COUNTLY_EXT_LOG(@"Countly payload not found in notification dictionary!"); + + contentHandler(request.content); + return; + } + + COUNTLY_EXT_LOG(@"Checking for notification modifiers..."); + UNMutableNotificationContent* bestAttemptContent = request.content.mutableCopy; + + NSArray* buttons = countlyPayload[kCountlyPNKeyButtons]; + if (buttons.count) + { + COUNTLY_EXT_LOG(@"%d custom action buttons found.", (int)buttons.count); + + NSMutableArray* actions = NSMutableArray.new; + + [buttons enumerateObjectsUsingBlock:^(NSDictionary* button, NSUInteger idx, BOOL * stop) + { + NSString* actionIdentifier = [NSString stringWithFormat:@"%@%lu", kCountlyActionIdentifier, (unsigned long)idx + 1]; + UNNotificationAction* action = [UNNotificationAction actionWithIdentifier:actionIdentifier title:button[kCountlyPNKeyActionButtonTitle] options:UNNotificationActionOptionForeground]; + [actions addObject:action]; + }]; + + NSString* categoryIdentifier = [kCountlyCategoryIdentifier stringByAppendingString:notificationID]; + + UNNotificationCategory* category = [UNNotificationCategory categoryWithIdentifier:categoryIdentifier actions:actions intentIdentifiers:@[] options:UNNotificationCategoryOptionNone]; + + [UNUserNotificationCenter.currentNotificationCenter setNotificationCategories:[NSSet setWithObject:category]]; + + bestAttemptContent.categoryIdentifier = categoryIdentifier; + + COUNTLY_EXT_LOG(@"%d custom action buttons added.", (int)buttons.count); + } + + NSString* attachmentURL = countlyPayload[kCountlyPNKeyAttachment]; + if (!attachmentURL.length) + { + COUNTLY_EXT_LOG(@"No attachment specified in Countly payload."); + COUNTLY_EXT_LOG(@"Handling of notification finished."); + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.01 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^ + { + contentHandler(bestAttemptContent); + }); + + return; + } + + COUNTLY_EXT_LOG(@"Attachment specified in Countly payload: %@", attachmentURL); + + [[NSURLSession.sharedSession downloadTaskWithURL:[NSURL URLWithString:attachmentURL] completionHandler:^(NSURL * location, NSURLResponse * response, NSError * error) + { + if (!error) + { + COUNTLY_EXT_LOG(@"Attachment download completed!"); + + NSString* attachmentFileName = [NSString stringWithFormat:@"%@-%@", notificationID, response.suggestedFilename ?: response.URL.absoluteString.lastPathComponent]; + + NSString* tempPath = [NSTemporaryDirectory() stringByAppendingPathComponent:attachmentFileName]; + + if (location && tempPath) + { + [NSFileManager.defaultManager moveItemAtPath:location.path toPath:tempPath error:nil]; + + NSError* attachmentError = nil; + UNNotificationAttachment* attachment = [UNNotificationAttachment attachmentWithIdentifier:attachmentFileName URL:[NSURL fileURLWithPath:tempPath] options:nil error:&attachmentError]; + + if (attachment && !attachmentError) + { + bestAttemptContent.attachments = @[attachment]; + + COUNTLY_EXT_LOG(@"Attachment added to notification!"); + } + else + { + COUNTLY_EXT_LOG(@"Attachment creation error: %@", attachmentError); + } + } + else + { + COUNTLY_EXT_LOG(@"Attachment `location` and/or `tempPath` is nil!"); + } + } + else + { + COUNTLY_EXT_LOG(@"Attachment download error: %@", error); + } + + COUNTLY_EXT_LOG(@"Handling of notification finished."); + contentHandler(bestAttemptContent); + }] resume]; +} +#endif +@end diff --git a/src/ios/CountlyiOS/CountlyPerformanceMonitoring.h b/src/ios/CountlyiOS/CountlyPerformanceMonitoring.h new file mode 100644 index 0000000..9c7631a --- /dev/null +++ b/src/ios/CountlyiOS/CountlyPerformanceMonitoring.h @@ -0,0 +1,31 @@ +// CountlyPerformanceMonitoring.h +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import + +@interface CountlyPerformanceMonitoring : NSObject +@property (nonatomic) BOOL isEnabledOnInitialConfig; + ++ (instancetype)sharedInstance; + +- (void)startPerformanceMonitoring; +- (void)stopPerformanceMonitoring; +- (void)recordAppStartDurationTraceWithStartTime:(long long)startTime endTime:(long long)endTime; +- (void)endBackgroundTrace; + +- (void)recordNetworkTrace:(NSString *)traceName + requestPayloadSize:(NSInteger)requestPayloadSize + responsePayloadSize:(NSInteger)responsePayloadSize + responseStatusCode:(NSInteger)responseStatusCode + startTime:(long long)startTime + endTime:(long long)endTime; + +- (void)startCustomTrace:(NSString *)traceName; +- (void)endCustomTrace:(NSString *)traceName metrics:(NSDictionary *)metrics; +- (void)cancelCustomTrace:(NSString *)traceName; +- (void)clearAllCustomTraces; + +@end diff --git a/src/ios/CountlyiOS/CountlyPerformanceMonitoring.m b/src/ios/CountlyiOS/CountlyPerformanceMonitoring.m new file mode 100644 index 0000000..8ca9ae2 --- /dev/null +++ b/src/ios/CountlyiOS/CountlyPerformanceMonitoring.m @@ -0,0 +1,303 @@ +// CountlyPerformanceMonitoring.m +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import "CountlyCommon.h" + + +NSString* const kCountlyPMKeyType = @"type"; +NSString* const kCountlyPMKeyNetwork = @"network"; +NSString* const kCountlyPMKeyDevice = @"device"; +NSString* const kCountlyPMKeyName = @"name"; +NSString* const kCountlyPMKeyAPMMetrics = @"apm_metrics"; +NSString* const kCountlyPMKeyResponseTime = @"response_time"; +NSString* const kCountlyPMKeyResponsePayloadSize = @"response_payload_size"; +NSString* const kCountlyPMKeyResponseCode = @"response_code"; +NSString* const kCountlyPMKeyRequestPayloadSize = @"request_payload_size"; +NSString* const kCountlyPMKeyDuration = @"duration"; +NSString* const kCountlyPMKeyStartTime = @"stz"; +NSString* const kCountlyPMKeyEndTime = @"etz"; +NSString* const kCountlyPMKeyAppStart = @"app_start"; +NSString* const kCountlyPMKeyAppInForeground = @"app_in_foreground"; +NSString* const kCountlyPMKeyAppInBackground = @"app_in_background"; + + +@interface CountlyPerformanceMonitoring () +@property (nonatomic) NSMutableDictionary* startedCustomTraces; +@property (nonatomic) BOOL hasAlreadyRecordedAppStartDurationTrace; +@end + + +@implementation CountlyPerformanceMonitoring + ++ (instancetype)sharedInstance +{ + if (!CountlyCommon.sharedInstance.hasStarted) + return nil; + + static CountlyPerformanceMonitoring* s_sharedInstance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{s_sharedInstance = self.new;}); + return s_sharedInstance; +} + +- (instancetype)init +{ + if (self = [super init]) + { + self.startedCustomTraces = NSMutableDictionary.new; + } + + return self; +} + +#pragma mark --- + +- (void)startPerformanceMonitoring +{ + if (!self.isEnabledOnInitialConfig) + return; + + if (!CountlyConsentManager.sharedInstance.consentForPerformanceMonitoring) + return; + + COUNTLY_LOG(@"Starting performance monitoring..."); + +#if (TARGET_OS_OSX) + [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(applicationDidBecomeActive:) name:NSApplicationDidBecomeActiveNotification object:nil]; + [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(applicationWillResignActive:) name:NSApplicationWillResignActiveNotification object:nil]; +#elif (TARGET_OS_IOS || TARGET_OS_TV) + [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(applicationDidBecomeActive:) name:UIApplicationDidBecomeActiveNotification object:nil]; + [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(applicationWillResignActive:) name:UIApplicationWillResignActiveNotification object:nil]; +#endif + + if (CountlyDeviceInfo.isInBackground) + [self startBackgroundTrace]; + else + [self startForegroundTrace]; +} + +- (void)stopPerformanceMonitoring +{ +#if (TARGET_OS_OSX) + [NSNotificationCenter.defaultCenter removeObserver:self name:NSApplicationDidBecomeActiveNotification object:nil]; + [NSNotificationCenter.defaultCenter removeObserver:self name:NSApplicationWillResignActiveNotification object:nil]; +#elif (TARGET_OS_IOS || TARGET_OS_TV) + [NSNotificationCenter.defaultCenter removeObserver:self name:UIApplicationDidBecomeActiveNotification object:nil]; + [NSNotificationCenter.defaultCenter removeObserver:self name:UIApplicationWillResignActiveNotification object:nil]; +#endif + + [self clearAllCustomTraces]; +} + +#pragma mark --- + +- (void)applicationDidBecomeActive:(NSNotification *)notification +{ + COUNTLY_LOG(@"applicationDidBecomeActive: (Performance Monitoring)"); + [self startForegroundTrace]; + +} + +- (void)applicationWillResignActive:(NSNotification *)notification +{ + COUNTLY_LOG(@"applicationWillResignActive: (Performance Monitoring)"); + [self startBackgroundTrace]; +} + +- (void)startForegroundTrace +{ + [self endBackgroundTrace]; + + [self startCustomTrace:kCountlyPMKeyAppInForeground]; +} + +- (void)endForegroundTrace +{ + [self endCustomTrace:kCountlyPMKeyAppInForeground metrics:nil]; +} + +- (void)startBackgroundTrace +{ + [self endForegroundTrace]; + + [self startCustomTrace:kCountlyPMKeyAppInBackground]; +} + +- (void)endBackgroundTrace +{ + [self endCustomTrace:kCountlyPMKeyAppInBackground metrics:nil]; +} + +#pragma mark --- + +- (void)recordAppStartDurationTraceWithStartTime:(long long)startTime endTime:(long long)endTime +{ + if (!CountlyConsentManager.sharedInstance.consentForPerformanceMonitoring) + return; + + if (self.hasAlreadyRecordedAppStartDurationTrace) + { + COUNTLY_LOG(@"App start duration trace can be recorded once per app launch. So, it will not be recorded this time!"); + return; + } + + long long appStartDuration = endTime - startTime; + + COUNTLY_LOG(@"App is loaded and displayed its first view in %lld milliseconds.", appStartDuration); + + NSDictionary* metrics = + @{ + kCountlyPMKeyDuration: @(appStartDuration), + }; + + NSDictionary* trace = + @{ + kCountlyPMKeyType: kCountlyPMKeyDevice, + kCountlyPMKeyName: kCountlyPMKeyAppStart, + kCountlyPMKeyAPMMetrics: metrics, + kCountlyPMKeyStartTime: @(startTime), + kCountlyPMKeyEndTime: @(endTime), + }; + + [CountlyConnectionManager.sharedInstance sendPerformanceMonitoringTrace:[trace cly_JSONify]]; + + self.hasAlreadyRecordedAppStartDurationTrace = YES; +} + +- (void)recordNetworkTrace:(NSString *)traceName + requestPayloadSize:(NSInteger)requestPayloadSize + responsePayloadSize:(NSInteger)responsePayloadSize + responseStatusCode:(NSInteger)responseStatusCode + startTime:(long long)startTime + endTime:(long long)endTime +{ + if (!CountlyConsentManager.sharedInstance.consentForPerformanceMonitoring) + return; + + if (!traceName.length) + return; + + NSDictionary* metrics = + @{ + kCountlyPMKeyRequestPayloadSize: @(requestPayloadSize), + kCountlyPMKeyResponseTime: @(endTime - startTime), + kCountlyPMKeyResponseCode: @(responseStatusCode), + kCountlyPMKeyResponsePayloadSize: @(responsePayloadSize), + }; + + NSDictionary* trace = + @{ + kCountlyPMKeyType: kCountlyPMKeyNetwork, + kCountlyPMKeyName: traceName, + kCountlyPMKeyAPMMetrics: metrics, + kCountlyPMKeyStartTime: @(startTime), + kCountlyPMKeyEndTime: @(endTime), + }; + + [CountlyConnectionManager.sharedInstance sendPerformanceMonitoringTrace:[trace cly_JSONify]]; +} + +- (void)startCustomTrace:(NSString *)traceName +{ + if (!CountlyConsentManager.sharedInstance.consentForPerformanceMonitoring) + return; + + if (!traceName.length) + return; + + @synchronized (self.startedCustomTraces) + { + if (self.startedCustomTraces[traceName]) + { + COUNTLY_LOG(@"Custom trace with name '%@' already started!", traceName); + return; + } + + NSNumber* startTime = @((long long)(CountlyCommon.sharedInstance.uniqueTimestamp * 1000)); + self.startedCustomTraces[traceName] = startTime; + } + + COUNTLY_LOG(@"Custom trace with name '%@' just started!", traceName); +} + +- (void)endCustomTrace:(NSString *)traceName metrics:(NSDictionary *)metrics +{ + if (!CountlyConsentManager.sharedInstance.consentForPerformanceMonitoring) + return; + + if (!traceName.length) + return; + + NSNumber* startTime = nil; + + @synchronized (self.startedCustomTraces) + { + startTime = self.startedCustomTraces[traceName]; + [self.startedCustomTraces removeObjectForKey:traceName]; + } + + if (!startTime) + { + COUNTLY_LOG(@"Custom trace with name '%@' not started yet or cancelled/ended before!", traceName); + return; + } + + NSNumber* endTime = @((long long)(CountlyCommon.sharedInstance.uniqueTimestamp * 1000)); + + NSMutableDictionary* mutableMetrics = metrics.mutableCopy; + if (!mutableMetrics) + mutableMetrics = NSMutableDictionary.new; + + long long duration = endTime.longLongValue - startTime.longLongValue; + mutableMetrics[kCountlyPMKeyDuration] = @(duration); + + NSDictionary* trace = + @{ + kCountlyPMKeyType: kCountlyPMKeyDevice, + kCountlyPMKeyName: traceName, + kCountlyPMKeyAPMMetrics: mutableMetrics, + kCountlyPMKeyStartTime: startTime, + kCountlyPMKeyEndTime: endTime, + }; + + COUNTLY_LOG(@"Custom trace with name '%@' just ended with duration %lld ms.", traceName, duration); + + [CountlyConnectionManager.sharedInstance sendPerformanceMonitoringTrace:[trace cly_JSONify]]; +} + +- (void)cancelCustomTrace:(NSString *)traceName +{ + if (!CountlyConsentManager.sharedInstance.consentForPerformanceMonitoring) + return; + + if (!traceName.length) + return; + + NSNumber* startTime = nil; + + @synchronized (self.startedCustomTraces) + { + startTime = self.startedCustomTraces[traceName]; + [self.startedCustomTraces removeObjectForKey:traceName]; + } + + if (!startTime) + { + COUNTLY_LOG(@"Custom trace with name '%@' not started yet or cancelled/ended before!", traceName); + return; + } + + COUNTLY_LOG(@"Custom trace with name '%@' cancelled!", traceName); +} + +- (void)clearAllCustomTraces +{ + @synchronized (self.startedCustomTraces) + { + [self.startedCustomTraces removeAllObjects]; + } +} +@end diff --git a/src/ios/CountlyiOS/CountlyPersistency.h b/src/ios/CountlyiOS/CountlyPersistency.h new file mode 100644 index 0000000..476fa9d --- /dev/null +++ b/src/ios/CountlyiOS/CountlyPersistency.h @@ -0,0 +1,62 @@ +// CountlyPersistency.h +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import + +@class CountlyEvent; + +@interface CountlyPersistency : NSObject + ++ (instancetype)sharedInstance; + +- (void)addToQueue:(NSString *)queryString; +- (void)removeFromQueue:(NSString *)queryString; +- (NSString *)firstItemInQueue; +- (void)flushQueue; +- (void)replaceAllTemporaryDeviceIDsInQueueWithDeviceID:(NSString *)deviceID; +- (void)replaceAllAppKeysInQueueWithCurrentAppKey; +- (void)removeDifferentAppKeysFromQueue; + +- (void)recordEvent:(CountlyEvent *)event; +- (NSString *)serializedRecordedEvents; +- (void)flushEvents; + +- (void)recordTimedEvent:(CountlyEvent *)event; +- (CountlyEvent *)timedEventForKey:(NSString *)key; +- (void)clearAllTimedEvents; + +- (void)writeCustomCrashLogToFile:(NSString *)log; +- (NSString *)customCrashLogsFromFile; +- (void)deleteCustomCrashLogFile; + +- (void)saveToFile; +- (void)saveToFileSync; + +- (NSString *)retrieveDeviceID; +- (void)storeDeviceID:(NSString *)deviceID; + +- (NSString *)retrieveNSUUID; +- (void)storeNSUUID:(NSString *)UUID; + +- (NSString *)retrieveWatchParentDeviceID; +- (void)storeWatchParentDeviceID:(NSString *)deviceID; + +- (NSDictionary *)retrieveStarRatingStatus; +- (void)storeStarRatingStatus:(NSDictionary *)status; + +- (BOOL)retrieveNotificationPermission; +- (void)storeNotificationPermission:(BOOL)allowed; + +- (BOOL)retrieveIsCustomDeviceID; +- (void)storeIsCustomDeviceID:(BOOL)isCustomDeviceID; + +- (NSDictionary *)retrieveRemoteConfig; +- (void)storeRemoteConfig:(NSDictionary *)remoteConfig; + +@property (nonatomic) NSUInteger eventSendThreshold; +@property (nonatomic) NSUInteger storedRequestsLimit; +@property (nonatomic, readonly) BOOL isQueueBeingModified; +@end diff --git a/src/ios/CountlyiOS/CountlyPersistency.m b/src/ios/CountlyiOS/CountlyPersistency.m new file mode 100644 index 0000000..70681c8 --- /dev/null +++ b/src/ios/CountlyiOS/CountlyPersistency.m @@ -0,0 +1,490 @@ +// CountlyPersistency.m +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import "CountlyCommon.h" + +@interface CountlyPersistency () +@property (nonatomic) NSMutableArray* queuedRequests; +@property (nonatomic) NSMutableArray* recordedEvents; +@property (nonatomic) NSMutableDictionary* startedEvents; +@property (nonatomic) BOOL isQueueBeingModified; +@end + +@implementation CountlyPersistency +NSString* const kCountlyQueuedRequestsPersistencyKey = @"kCountlyQueuedRequestsPersistencyKey"; +NSString* const kCountlyStartedEventsPersistencyKey = @"kCountlyStartedEventsPersistencyKey"; +NSString* const kCountlyStoredDeviceIDKey = @"kCountlyStoredDeviceIDKey"; +NSString* const kCountlyStoredNSUUIDKey = @"kCountlyStoredNSUUIDKey"; +NSString* const kCountlyWatchParentDeviceIDKey = @"kCountlyWatchParentDeviceIDKey"; +NSString* const kCountlyStarRatingStatusKey = @"kCountlyStarRatingStatusKey"; +NSString* const kCountlyNotificationPermissionKey = @"kCountlyNotificationPermissionKey"; +NSString* const kCountlyIsCustomDeviceIDKey = @"kCountlyIsCustomDeviceIDKey"; +NSString* const kCountlyRemoteConfigPersistencyKey = @"kCountlyRemoteConfigPersistencyKey"; + +NSString* const kCountlyCustomCrashLogFileName = @"CountlyCustomCrash.log"; + ++ (instancetype)sharedInstance +{ + if (!CountlyCommon.sharedInstance.hasStarted) + return nil; + + static CountlyPersistency* s_sharedInstance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{s_sharedInstance = self.new;}); + return s_sharedInstance; +} + +- (instancetype)init +{ + if (self = [super init]) + { + NSData* readData = [NSData dataWithContentsOfURL:[self storageFileURL]]; + + if (readData) + { + NSDictionary* readDict = [NSKeyedUnarchiver unarchiveObjectWithData:readData]; + + self.queuedRequests = [readDict[kCountlyQueuedRequestsPersistencyKey] mutableCopy]; + } + + if (!self.queuedRequests) + self.queuedRequests = NSMutableArray.new; + + if (!self.startedEvents) + self.startedEvents = NSMutableDictionary.new; + + self.recordedEvents = NSMutableArray.new; + } + + return self; +} + +#pragma mark --- + +- (void)addToQueue:(NSString *)queryString +{ + if (!queryString.length || [queryString isEqual:NSNull.null]) + return; + + @synchronized (self) + { + [self.queuedRequests addObject:queryString]; + + if (self.queuedRequests.count > self.storedRequestsLimit && !CountlyConnectionManager.sharedInstance.connection) + [self.queuedRequests removeObjectAtIndex:0]; + } +} + +- (void)removeFromQueue:(NSString *)queryString +{ + @synchronized (self) + { + if (self.queuedRequests.count) + [self.queuedRequests removeObject:queryString inRange:(NSRange){0, 1}]; + } +} + +- (NSString *)firstItemInQueue +{ + @synchronized (self) + { + return self.queuedRequests.firstObject; + } +} + +- (void)flushQueue +{ + @synchronized (self) + { + [self.queuedRequests removeAllObjects]; + } +} + +- (void)replaceAllTemporaryDeviceIDsInQueueWithDeviceID:(NSString *)deviceID +{ + NSString* temporaryDeviceIDQueryString = [NSString stringWithFormat:@"&%@=%@", kCountlyQSKeyDeviceID, CLYTemporaryDeviceID]; + NSString* realDeviceIDQueryString = [NSString stringWithFormat:@"&%@=%@", kCountlyQSKeyDeviceID, deviceID.cly_URLEscaped]; + + @synchronized (self) + { + [self.queuedRequests.copy enumerateObjectsUsingBlock:^(NSString* queryString, NSUInteger idx, BOOL* stop) + { + if ([queryString containsString:temporaryDeviceIDQueryString]) + { + COUNTLY_LOG(@"Detected a request with temporary device ID in queue and replaced it with real device ID."); + NSString * replacedQueryString = [queryString stringByReplacingOccurrencesOfString:temporaryDeviceIDQueryString withString:realDeviceIDQueryString]; + self.queuedRequests[idx] = replacedQueryString; + } + }]; + } +} + +- (void)replaceAllAppKeysInQueueWithCurrentAppKey +{ + @synchronized (self) + { + self.isQueueBeingModified = YES; + + [self.queuedRequests.copy enumerateObjectsUsingBlock:^(NSString* queryString, NSUInteger idx, BOOL* stop) + { + NSString* appKeyInQueryString = [queryString cly_valueForQueryStringKey:kCountlyQSKeyAppKey]; + + if (![appKeyInQueryString isEqualToString:CountlyConnectionManager.sharedInstance.appKey.cly_URLEscaped]) + { + COUNTLY_LOG(@"Detected a request with a different app key (%@) in queue and replaced it with current app key.", appKeyInQueryString); + + NSString* currentAppKeyQueryString = [NSString stringWithFormat:@"%@=%@", kCountlyQSKeyAppKey, CountlyConnectionManager.sharedInstance.appKey.cly_URLEscaped]; + NSString* differentAppKeyQueryString = [NSString stringWithFormat:@"%@=%@", kCountlyQSKeyAppKey, appKeyInQueryString]; + NSString * replacedQueryString = [queryString stringByReplacingOccurrencesOfString:differentAppKeyQueryString withString:currentAppKeyQueryString]; + self.queuedRequests[idx] = replacedQueryString; + } + }]; + + self.isQueueBeingModified = NO; + } +} + +- (void)removeDifferentAppKeysFromQueue +{ + @synchronized (self) + { + self.isQueueBeingModified = YES; + + NSPredicate* predicate = [NSPredicate predicateWithBlock:^BOOL(NSString* queryString, NSDictionary * bindings) + { + NSString* appKeyInQueryString = [queryString cly_valueForQueryStringKey:kCountlyQSKeyAppKey]; + + BOOL isSameAppKey = [appKeyInQueryString isEqualToString:CountlyConnectionManager.sharedInstance.appKey.cly_URLEscaped]; + if (!isSameAppKey) + { + COUNTLY_LOG(@"Detected a request with a different app key (%@) in queue and removed it.", appKeyInQueryString); + } + + return isSameAppKey; + }]; + + [self.queuedRequests filterUsingPredicate:predicate]; + + self.isQueueBeingModified = NO; + } +} + +#pragma mark --- + +- (void)recordEvent:(CountlyEvent *)event +{ + @synchronized (self.recordedEvents) + { + [self.recordedEvents addObject:event]; + + if (self.recordedEvents.count >= self.eventSendThreshold) + [CountlyConnectionManager.sharedInstance sendEvents]; + } +} + +- (NSString *)serializedRecordedEvents +{ + NSMutableArray* tempArray = NSMutableArray.new; + + @synchronized (self.recordedEvents) + { + if (self.recordedEvents.count == 0) + return nil; + + for (CountlyEvent* event in self.recordedEvents.copy) + { + [tempArray addObject:[event dictionaryRepresentation]]; + [self.recordedEvents removeObject:event]; + } + } + + return [tempArray cly_JSONify]; +} + +- (void)flushEvents +{ + @synchronized (self.recordedEvents) + { + [self.recordedEvents removeAllObjects]; + } +} + +#pragma mark --- + +- (void)recordTimedEvent:(CountlyEvent *)event +{ + @synchronized (self.startedEvents) + { + if (self.startedEvents[event.key]) + { + COUNTLY_LOG(@"Event with key '%@' already started!", event.key); + return; + } + + self.startedEvents[event.key] = event; + } +} + +- (CountlyEvent *)timedEventForKey:(NSString *)key +{ + @synchronized (self.startedEvents) + { + CountlyEvent *event = self.startedEvents[key]; + [self.startedEvents removeObjectForKey:key]; + + return event; + } +} + +- (void)clearAllTimedEvents +{ + @synchronized (self.startedEvents) + { + [self.startedEvents removeAllObjects]; + } +} + +#pragma mark --- + +- (void)writeCustomCrashLogToFile:(NSString *)log +{ + static NSURL* crashLogFileURL = nil; + + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^ + { + crashLogFileURL = [[self storageDirectoryURL] URLByAppendingPathComponent:kCountlyCustomCrashLogFileName]; + }); + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^ + { + NSString* line = [NSString stringWithFormat:@"%@\n", log]; + NSFileHandle* fileHandle = [NSFileHandle fileHandleForWritingAtPath:crashLogFileURL.path]; + if (fileHandle) + { + [fileHandle seekToEndOfFile]; + [fileHandle writeData:[line dataUsingEncoding:NSUTF8StringEncoding]]; + [fileHandle closeFile]; + } + else + { + NSError* error = nil; + [line writeToFile:crashLogFileURL.path atomically:YES encoding:NSUTF8StringEncoding error:&error]; + if (error) + { + COUNTLY_LOG(@"Crash Log File can not be created: \n%@", error); + } + } + }); +} + +- (NSString *)customCrashLogsFromFile +{ + NSURL* crashLogFileURL = [[self storageDirectoryURL] URLByAppendingPathComponent:kCountlyCustomCrashLogFileName]; + NSData* readData = [NSData dataWithContentsOfURL:crashLogFileURL]; + + NSString* storedCustomCrashLogs = nil; + if (readData) + { + storedCustomCrashLogs = [NSString.alloc initWithData:readData encoding:NSUTF8StringEncoding]; + } + + return storedCustomCrashLogs; +} + +- (void)deleteCustomCrashLogFile +{ + NSURL* crashLogFileURL = [[self storageDirectoryURL] URLByAppendingPathComponent:kCountlyCustomCrashLogFileName]; + NSError* error = nil; + if ([NSFileManager.defaultManager fileExistsAtPath:crashLogFileURL.path]) + { + COUNTLY_LOG(@"Detected Crash Log File and deleting it."); + [NSFileManager.defaultManager removeItemAtURL:crashLogFileURL error:&error]; + if (error) + { + COUNTLY_LOG(@"Crash Log File can not be deleted: \n%@", error); + } + } +} + +#pragma mark --- + +- (NSURL *)storageDirectoryURL +{ + static NSURL* URL = nil; + + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^ + { +#if (TARGET_OS_TV) + NSSearchPathDirectory directory = NSCachesDirectory; +#else + NSSearchPathDirectory directory = NSApplicationSupportDirectory; +#endif + URL = [[NSFileManager.defaultManager URLsForDirectory:directory inDomains:NSUserDomainMask] lastObject]; + +#if (TARGET_OS_OSX) + URL = [URL URLByAppendingPathComponent:NSBundle.mainBundle.bundleIdentifier]; +#endif + NSError *error = nil; + + if (![NSFileManager.defaultManager fileExistsAtPath:URL.path]) + { + [NSFileManager.defaultManager createDirectoryAtURL:URL withIntermediateDirectories:YES attributes:nil error:&error]; + if (error) + { + COUNTLY_LOG(@"Application Support directory can not be created: \n%@", error); + } + } + }); + + return URL; +} + +- (NSURL *)storageFileURL +{ + NSString* const kCountlyPersistencyFileName = @"Countly.dat"; + + static NSURL* URL = nil; + + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^ + { + URL = [[self storageDirectoryURL] URLByAppendingPathComponent:kCountlyPersistencyFileName]; + }); + + return URL; +} + +- (void)saveToFile +{ + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^ + { + [self saveToFileSync]; + }); +} + +- (void)saveToFileSync +{ + NSData* saveData; + + @synchronized (self) + { + saveData = [NSKeyedArchiver archivedDataWithRootObject:@{kCountlyQueuedRequestsPersistencyKey: self.queuedRequests}]; + } + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wunused-variable" + + BOOL writeResult = [saveData writeToFile:[self storageFileURL].path atomically:YES]; + COUNTLY_LOG(@"Result of writing data to file: %d", writeResult); + +#pragma clang diagnostic pop + + [CountlyCommon.sharedInstance finishBackgroundTask]; +} + +#pragma mark --- + +- (NSString* )retrieveDeviceID +{ + NSString* retrievedDeviceID = [NSUserDefaults.standardUserDefaults objectForKey:kCountlyStoredDeviceIDKey]; + + if (retrievedDeviceID) + { + COUNTLY_LOG(@"Device ID successfully retrieved from UserDefaults: %@", retrievedDeviceID); + return retrievedDeviceID; + } + + COUNTLY_LOG(@"There is no stored Device ID in UserDefaults!"); + + return nil; +} + +- (void)storeDeviceID:(NSString *)deviceID +{ + [NSUserDefaults.standardUserDefaults setObject:deviceID forKey:kCountlyStoredDeviceIDKey]; + [NSUserDefaults.standardUserDefaults synchronize]; + + COUNTLY_LOG(@"Device ID successfully stored in UserDefaults: %@", deviceID); +} + +- (NSString *)retrieveNSUUID +{ + return [NSUserDefaults.standardUserDefaults objectForKey:kCountlyStoredNSUUIDKey]; +} + +- (void)storeNSUUID:(NSString *)UUID +{ + [NSUserDefaults.standardUserDefaults setObject:UUID forKey:kCountlyStoredNSUUIDKey]; + [NSUserDefaults.standardUserDefaults synchronize]; +} + +- (NSString *)retrieveWatchParentDeviceID +{ + return [NSUserDefaults.standardUserDefaults objectForKey:kCountlyWatchParentDeviceIDKey]; +} + +- (void)storeWatchParentDeviceID:(NSString *)deviceID +{ + [NSUserDefaults.standardUserDefaults setObject:deviceID forKey:kCountlyWatchParentDeviceIDKey]; + [NSUserDefaults.standardUserDefaults synchronize]; +} + +- (NSDictionary *)retrieveStarRatingStatus +{ + NSDictionary* status = [NSUserDefaults.standardUserDefaults objectForKey:kCountlyStarRatingStatusKey]; + if (!status) + status = NSDictionary.new; + + return status; +} + +- (void)storeStarRatingStatus:(NSDictionary *)status +{ + [NSUserDefaults.standardUserDefaults setObject:status forKey:kCountlyStarRatingStatusKey]; + [NSUserDefaults.standardUserDefaults synchronize]; +} + +- (BOOL)retrieveNotificationPermission +{ + return [NSUserDefaults.standardUserDefaults boolForKey:kCountlyNotificationPermissionKey]; +} + +- (void)storeNotificationPermission:(BOOL)allowed +{ + [NSUserDefaults.standardUserDefaults setBool:allowed forKey:kCountlyNotificationPermissionKey]; + [NSUserDefaults.standardUserDefaults synchronize]; +} + +- (BOOL)retrieveIsCustomDeviceID +{ + return [NSUserDefaults.standardUserDefaults boolForKey:kCountlyIsCustomDeviceIDKey]; + +} + +- (void)storeIsCustomDeviceID:(BOOL)isCustomDeviceID +{ + [NSUserDefaults.standardUserDefaults setBool:isCustomDeviceID forKey:kCountlyIsCustomDeviceIDKey]; + [NSUserDefaults.standardUserDefaults synchronize]; +} + +- (NSDictionary *)retrieveRemoteConfig +{ + NSDictionary* remoteConfig = [NSUserDefaults.standardUserDefaults objectForKey:kCountlyRemoteConfigPersistencyKey]; + if (!remoteConfig) + remoteConfig = NSDictionary.new; + + return remoteConfig; +} + +- (void)storeRemoteConfig:(NSDictionary *)remoteConfig +{ + [NSUserDefaults.standardUserDefaults setObject:remoteConfig forKey:kCountlyRemoteConfigPersistencyKey]; + [NSUserDefaults.standardUserDefaults synchronize]; +} + +@end diff --git a/src/ios/CountlyiOS/CountlyPushNotifications.h b/src/ios/CountlyiOS/CountlyPushNotifications.h new file mode 100644 index 0000000..6c28c60 --- /dev/null +++ b/src/ios/CountlyiOS/CountlyPushNotifications.h @@ -0,0 +1,28 @@ +// CountlyPushNotifications.h +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import + +@interface CountlyPushNotifications : NSObject +#ifndef COUNTLY_EXCLUDE_PUSHNOTIFICATIONS +@property (nonatomic) BOOL isEnabledOnInitialConfig; +@property (nonatomic) NSString* pushTestMode; +@property (nonatomic) BOOL sendPushTokenAlways; +@property (nonatomic) BOOL doNotShowAlertForNotifications; +@property (nonatomic) NSNotification* launchNotification; + ++ (instancetype)sharedInstance; + +#if (TARGET_OS_IOS || TARGET_OS_OSX) +- (void)startPushNotifications; +- (void)stopPushNotifications; +- (void)askForNotificationPermissionWithOptions:(NSUInteger)options completionHandler:(void (^)(BOOL granted, NSError * error))completionHandler; +- (void)recordActionForNotification:(NSDictionary *)userInfo clickedButtonIndex:(NSInteger)buttonIndex; +- (void)sendToken; +- (void)clearToken; +#endif +#endif +@end diff --git a/src/ios/CountlyiOS/CountlyPushNotifications.m b/src/ios/CountlyiOS/CountlyPushNotifications.m new file mode 100644 index 0000000..c84390c --- /dev/null +++ b/src/ios/CountlyiOS/CountlyPushNotifications.m @@ -0,0 +1,569 @@ +// CountlyPushNotifications.m +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import "CountlyCommon.h" + +NSString* const kCountlyReservedEventPushAction = @"[CLY]_push_action"; +NSString* const kCountlyTokenError = @"kCountlyTokenError"; + +//NOTE: Push Notification Test Modes +CLYPushTestMode const CLYPushTestModeDevelopment = @"CLYPushTestModeDevelopment"; +CLYPushTestMode const CLYPushTestModeTestFlightOrAdHoc = @"CLYPushTestModeTestFlightOrAdHoc"; + +#if (TARGET_OS_IOS || TARGET_OS_OSX) +@interface CountlyPushNotifications () +@property (nonatomic) NSString* token; +@property (nonatomic, copy) void (^permissionCompletion)(BOOL granted, NSError * error); +#else +@interface CountlyPushNotifications () +#endif +@end + +#if (TARGET_OS_IOS) + #define CLYApplication UIApplication +#elif (TARGET_OS_OSX) + #define CLYApplication NSApplication +#endif + +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + +@implementation CountlyPushNotifications + +#ifndef COUNTLY_EXCLUDE_PUSHNOTIFICATIONS + ++ (instancetype)sharedInstance +{ + if (!CountlyCommon.sharedInstance.hasStarted) + return nil; + + static CountlyPushNotifications* s_sharedInstance; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{s_sharedInstance = self.new;}); + return s_sharedInstance; +} + +- (instancetype)init +{ + if (self = [super init]) + { + + } + + return self; +} + +#pragma mark --- + +#if (TARGET_OS_IOS || TARGET_OS_OSX) +- (void)startPushNotifications +{ + if (!self.isEnabledOnInitialConfig) + return; + + if (!CountlyConsentManager.sharedInstance.consentForPushNotifications) + return; + + if (@available(iOS 10.0, macOS 10.14, *)) + UNUserNotificationCenter.currentNotificationCenter.delegate = self; + + [self swizzlePushNotificationMethods]; + +#if (TARGET_OS_IOS) + [UIApplication.sharedApplication registerForRemoteNotifications]; +#elif (TARGET_OS_OSX) + [NSApplication.sharedApplication registerForRemoteNotificationTypes:NSRemoteNotificationTypeBadge | NSRemoteNotificationTypeAlert | NSRemoteNotificationTypeSound]; + + if (@available(macOS 10.14, *)) + { + UNNotificationResponse* notificationResponse = self.launchNotification.userInfo[NSApplicationLaunchUserNotificationKey]; + if (notificationResponse) + [self userNotificationCenter:UNUserNotificationCenter.currentNotificationCenter didReceiveNotificationResponse:notificationResponse withCompletionHandler:^{}]; + } +#endif +} + +- (void)stopPushNotifications +{ + if (!self.isEnabledOnInitialConfig) + return; + + if (@available(iOS 10.0, macOS 10.14, *)) + { + if (UNUserNotificationCenter.currentNotificationCenter.delegate == self) + UNUserNotificationCenter.currentNotificationCenter.delegate = nil; + } + + [CLYApplication.sharedApplication unregisterForRemoteNotifications]; +} + +- (void)swizzlePushNotificationMethods +{ + static BOOL alreadySwizzled; + if (alreadySwizzled) + return; + + alreadySwizzled = YES; + + Class appDelegateClass = CLYApplication.sharedApplication.delegate.class; + NSArray* selectors = + @[ + @"application:didRegisterForRemoteNotificationsWithDeviceToken:", + @"application:didFailToRegisterForRemoteNotificationsWithError:", +#if (TARGET_OS_IOS) + @"application:didRegisterUserNotificationSettings:", + @"application:didReceiveRemoteNotification:fetchCompletionHandler:", +#elif (TARGET_OS_OSX) + @"application:didReceiveRemoteNotification:", +#endif + ]; + + for (NSString* selectorString in selectors) + { + SEL originalSelector = NSSelectorFromString(selectorString); + Method originalMethod = class_getInstanceMethod(appDelegateClass, originalSelector); + + if (originalMethod == NULL) + { + Method method = class_getInstanceMethod(self.class, originalSelector); + IMP imp = method_getImplementation(method); + const char* methodTypeEncoding = method_getTypeEncoding(method); + class_addMethod(appDelegateClass, originalSelector, imp, methodTypeEncoding); + originalMethod = class_getInstanceMethod(appDelegateClass, originalSelector); + } + + SEL countlySelector = NSSelectorFromString([@"Countly_" stringByAppendingString:selectorString]); + Method countlyMethod = class_getInstanceMethod(appDelegateClass, countlySelector); + method_exchangeImplementations(originalMethod, countlyMethod); + } +} + +- (void)askForNotificationPermissionWithOptions:(NSUInteger)options completionHandler:(void (^)(BOOL granted, NSError * error))completionHandler +{ + if (!CountlyConsentManager.sharedInstance.consentForPushNotifications) + return; + + if (@available(iOS 10.0, macOS 10.14, *)) + { + if (options == 0) + options = UNAuthorizationOptionBadge | UNAuthorizationOptionSound | UNAuthorizationOptionAlert; + + [UNUserNotificationCenter.currentNotificationCenter requestAuthorizationWithOptions:options completionHandler:^(BOOL granted, NSError* error) + { + if (completionHandler) + completionHandler(granted, error); + + [self sendToken]; + }]; + } +#if (TARGET_OS_IOS) + else + { + self.permissionCompletion = completionHandler; + + if (options == 0) + options = UIUserNotificationTypeBadge | UIUserNotificationTypeSound | UIUserNotificationTypeAlert; + + UIUserNotificationType userNotificationTypes = (UIUserNotificationType)options; + UIUserNotificationSettings* settings = [UIUserNotificationSettings settingsForTypes:userNotificationTypes categories:nil]; + [UIApplication.sharedApplication registerUserNotificationSettings:settings]; + } +#endif +} + +- (void)sendToken +{ + if (!CountlyConsentManager.sharedInstance.consentForPushNotifications) + return; + + if (!self.token) + return; + + if ([self.token isEqualToString:kCountlyTokenError]) + { + [self clearToken]; + return; + } + + if (self.sendPushTokenAlways) + { + [CountlyConnectionManager.sharedInstance sendPushToken:self.token]; + return; + } + + BOOL hasNotificationPermissionBefore = [CountlyPersistency.sharedInstance retrieveNotificationPermission]; + + if (@available(iOS 10.0, macOS 10.14, *)) + { + [UNUserNotificationCenter.currentNotificationCenter getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings* settings) + { + BOOL hasProvisionalPermission = NO; + if (@available(iOS 12.0, *)) + { + hasProvisionalPermission = settings.authorizationStatus == UNAuthorizationStatusProvisional; + } + + if (settings.authorizationStatus == UNAuthorizationStatusAuthorized || hasProvisionalPermission) + { + [CountlyConnectionManager.sharedInstance sendPushToken:self.token]; + [CountlyPersistency.sharedInstance storeNotificationPermission:YES]; + } + else if (hasNotificationPermissionBefore) + { + [self clearToken]; + [CountlyPersistency.sharedInstance storeNotificationPermission:NO]; + } + }]; + } +#if (TARGET_OS_IOS) + else + { + if (UIApplication.sharedApplication.currentUserNotificationSettings.types != UIUserNotificationTypeNone) + { + [CountlyConnectionManager.sharedInstance sendPushToken:self.token]; + [CountlyPersistency.sharedInstance storeNotificationPermission:YES]; + } + else if (hasNotificationPermissionBefore) + { + [self clearToken]; + [CountlyPersistency.sharedInstance storeNotificationPermission:NO]; + } + } +#endif +} + +- (void)clearToken +{ + [CountlyConnectionManager.sharedInstance sendPushToken:@""]; +} + +- (void)handleNotification:(NSDictionary *)notification +{ +#if (TARGET_OS_IOS || TARGET_OS_OSX) + if (!CountlyConsentManager.sharedInstance.consentForPushNotifications) + return; + + COUNTLY_LOG(@"Handling remote notification %@", notification); + + NSDictionary* countlyPayload = notification[kCountlyPNKeyCountlyPayload]; + NSString* notificationID = countlyPayload[kCountlyPNKeyNotificationID]; + + if (!notificationID) + { + COUNTLY_LOG(@"Countly payload not found in notification dictionary!"); + return; + } + + COUNTLY_LOG(@"Countly Push Notification ID: %@", notificationID); +#endif + +#if (TARGET_OS_OSX) + //NOTE: For macOS targets, just record action event. + [self recordActionEvent:notificationID buttonIndex:0]; +#endif + +#if (TARGET_OS_IOS) + if (self.doNotShowAlertForNotifications) + { + COUNTLY_LOG(@"doNotShowAlertForNotifications flag is set!"); + return; + } + + if (@available(iOS 10.0, *)) + { + //NOTE: On iOS10+ when a silent notification (content-available: 1) with `alert` key arrives, do not show alert here, as it is shown in UN framework delegate method + COUNTLY_LOG(@"A silent notification (content-available: 1) with `alert` key on iOS10+."); + return; + } + + id alert = notification[@"aps"][@"alert"]; + NSString* message = nil; + NSString* title = nil; + + if ([alert isKindOfClass:NSDictionary.class]) + { + message = alert[@"body"]; + title = alert[@"title"]; + } + else + { + message = (NSString*)alert; + title = [NSBundle.mainBundle objectForInfoDictionaryKey:@"CFBundleDisplayName"]; + } + + if (!message && !title) + { + COUNTLY_LOG(@"Title and Message are both not found in notification dictionary!"); + return; + } + + + __block UIAlertController* alertController = [UIAlertController alertControllerWithTitle:title message:message preferredStyle:UIAlertControllerStyleAlert]; + + + CLYButton* defaultButton = nil; + NSString* defaultURL = countlyPayload[kCountlyPNKeyDefaultURL]; + if (defaultURL) + { + defaultButton = [CLYButton buttonWithType:UIButtonTypeCustom]; + defaultButton.frame = alertController.view.bounds; + defaultButton.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + defaultButton.onClick = ^(id sender) + { + [self recordActionEvent:notificationID buttonIndex:0]; + + [self openURL:defaultURL]; + + [alertController dismissViewControllerAnimated:YES completion:^ + { + alertController = nil; + }]; + }; + [alertController.view addSubview:defaultButton]; + } + + + CLYButton* dismissButton = [CLYButton dismissAlertButton]; + dismissButton.onClick = ^(id sender) + { + [self recordActionEvent:notificationID buttonIndex:0]; + + [alertController dismissViewControllerAnimated:YES completion:^ + { + alertController = nil; + }]; + }; + [alertController.view addSubview:dismissButton]; + [dismissButton positionToTopRight]; + + NSArray* buttons = countlyPayload[kCountlyPNKeyButtons]; + [buttons enumerateObjectsUsingBlock:^(NSDictionary* button, NSUInteger idx, BOOL * stop) + { + //NOTE: Add space to force buttons to be laid out vertically + NSString* actionTitle = [button[kCountlyPNKeyActionButtonTitle] stringByAppendingString:@" "]; + NSString* URL = button[kCountlyPNKeyActionButtonURL]; + + UIAlertAction* visit = [UIAlertAction actionWithTitle:actionTitle style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) + { + [self recordActionEvent:notificationID buttonIndex:idx + 1]; + + [self openURL:URL]; + + alertController = nil; + }]; + + [alertController addAction:visit]; + }]; + + [CountlyCommon.sharedInstance tryPresentingViewController:alertController]; + + const CGFloat kCountlyActionButtonHeight = 44.0; + CGRect tempFrame = defaultButton.frame; + tempFrame.size.height -= buttons.count * kCountlyActionButtonHeight; + defaultButton.frame = tempFrame; +#endif +} + +- (void)openURL:(NSString *)URLString +{ + if (!URLString) + return; + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.01 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^ + { +#if (TARGET_OS_IOS) + [UIApplication.sharedApplication openURL:[NSURL URLWithString:URLString]]; +#elif (TARGET_OS_OSX) + [NSWorkspace.sharedWorkspace openURL:[NSURL URLWithString:URLString]]; +#endif + }); +} + +- (void)recordActionForNotification:(NSDictionary *)userInfo clickedButtonIndex:(NSInteger)buttonIndex +{ + if (!CountlyConsentManager.sharedInstance.consentForPushNotifications) + return; + + NSDictionary* countlyPayload = userInfo[kCountlyPNKeyCountlyPayload]; + NSString* notificationID = countlyPayload[kCountlyPNKeyNotificationID]; + + [self recordActionEvent:notificationID buttonIndex:buttonIndex]; +} + +- (void)recordActionEvent:(NSString *)notificationID buttonIndex:(NSInteger)buttonIndex +{ + if (!notificationID) + return; + + NSDictionary* segmentation = + @{ + kCountlyPNKeyNotificationID: notificationID, + kCountlyPNKeyActionButtonIndex: @(buttonIndex) + }; + + [Countly.sharedInstance recordReservedEvent:kCountlyReservedEventPushAction segmentation:segmentation]; +} + +#pragma mark --- + +- (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler API_AVAILABLE(ios(10.0), macos(10.14)) +{ + COUNTLY_LOG(@"userNotificationCenter:willPresentNotification:withCompletionHandler:"); + COUNTLY_LOG(@"%@", notification.request.content.userInfo.description); + + if (!self.doNotShowAlertForNotifications) + { + NSDictionary* countlyPayload = notification.request.content.userInfo[kCountlyPNKeyCountlyPayload]; + NSString* notificationID = countlyPayload[kCountlyPNKeyNotificationID]; + + if (notificationID) + completionHandler(UNNotificationPresentationOptionAlert); + } + + id appDelegate = (id)CLYApplication.sharedApplication.delegate; + + if ([appDelegate respondsToSelector:@selector(userNotificationCenter:willPresentNotification:withCompletionHandler:)]) + [appDelegate userNotificationCenter:center willPresentNotification:notification withCompletionHandler:completionHandler]; + else + completionHandler(UNNotificationPresentationOptionNone); +} + +- (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void (^)(void))completionHandler API_AVAILABLE(ios(10.0), macos(10.14)) +{ + COUNTLY_LOG(@"userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:"); + COUNTLY_LOG(@"%@", response.notification.request.content.userInfo.description); + + if (CountlyConsentManager.sharedInstance.consentForPushNotifications) + { + NSDictionary* countlyPayload = response.notification.request.content.userInfo[kCountlyPNKeyCountlyPayload]; + NSString* notificationID = countlyPayload[kCountlyPNKeyNotificationID]; + + if (notificationID) + { + NSInteger buttonIndex = 0; + NSString* URL = nil; + + COUNTLY_LOG(@"Action Identifier: %@", response.actionIdentifier); + + if ([response.actionIdentifier isEqualToString:UNNotificationDefaultActionIdentifier]) + { + URL = countlyPayload[kCountlyPNKeyDefaultURL]; + } + else if ([response.actionIdentifier hasPrefix:kCountlyActionIdentifier]) + { + buttonIndex = [[response.actionIdentifier stringByReplacingOccurrencesOfString:kCountlyActionIdentifier withString:@""] integerValue]; + URL = countlyPayload[kCountlyPNKeyButtons][buttonIndex - 1][kCountlyPNKeyActionButtonURL]; + } + + [self recordActionEvent:notificationID buttonIndex:buttonIndex]; + + [self openURL:URL]; + } + } + + id appDelegate = (id)CLYApplication.sharedApplication.delegate; + + if ([appDelegate respondsToSelector:@selector(userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:)]) + [appDelegate userNotificationCenter:center didReceiveNotificationResponse:response withCompletionHandler:completionHandler]; + else + completionHandler(); +} + +- (void)userNotificationCenter:(UNUserNotificationCenter *)center openSettingsForNotification:(UNNotification *)notification API_AVAILABLE(ios(12.0), macos(10.14)) +{ + if (@available(iOS 12.0, macOS 10.14, *)) + { + id appDelegate = (id)CLYApplication.sharedApplication.delegate; + + if ([appDelegate respondsToSelector:@selector(userNotificationCenter:openSettingsForNotification:)]) + [appDelegate userNotificationCenter:center openSettingsForNotification:notification]; + } +} + +#pragma mark --- + +- (void)application:(CLYApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken{} +- (void)application:(CLYApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error{} +#if (TARGET_OS_IOS) +- (void)application:(UIApplication *)application didRegisterUserNotificationSettings:(UIUserNotificationSettings *)notificationSettings{} +- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler +{ + completionHandler(UIBackgroundFetchResultNewData); +} +#elif (TARGET_OS_OSX) +- (void)application:(NSApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo{} +#endif +#endif +@end + + +@implementation NSObject (CountlyPushNotifications) +#if (TARGET_OS_IOS || TARGET_OS_OSX) +- (void)Countly_application:(CLYApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken +{ + COUNTLY_LOG(@"App didRegisterForRemoteNotificationsWithDeviceToken: %@", deviceToken); + + const char* bytes = [deviceToken bytes]; + NSMutableString *token = NSMutableString.new; + for (NSUInteger i = 0; i < deviceToken.length; i++) + [token appendFormat:@"%02hhx", bytes[i]]; + + CountlyPushNotifications.sharedInstance.token = token; + + [CountlyPushNotifications.sharedInstance sendToken]; + + [self Countly_application:application didRegisterForRemoteNotificationsWithDeviceToken:deviceToken]; +} + +- (void)Countly_application:(CLYApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error +{ + COUNTLY_LOG(@"App didFailToRegisterForRemoteNotificationsWithError: %@", error); + + CountlyPushNotifications.sharedInstance.token = kCountlyTokenError; + + [CountlyPushNotifications.sharedInstance sendToken]; + + [self Countly_application:application didFailToRegisterForRemoteNotificationsWithError:error]; +} +#endif + +#if (TARGET_OS_IOS) +- (void)Countly_application:(UIApplication *)application didRegisterUserNotificationSettings:(UIUserNotificationSettings *)notificationSettings +{ + COUNTLY_LOG(@"App didRegisterUserNotificationSettings: %@", notificationSettings); + + [CountlyPushNotifications.sharedInstance sendToken]; + + BOOL granted = UIApplication.sharedApplication.currentUserNotificationSettings.types != UIUserNotificationTypeNone; + + if (CountlyPushNotifications.sharedInstance.permissionCompletion) + CountlyPushNotifications.sharedInstance.permissionCompletion(granted, nil); + + [self Countly_application:application didRegisterUserNotificationSettings:notificationSettings]; +} + +- (void)Countly_application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler; +{ + COUNTLY_LOG(@"App didReceiveRemoteNotification:fetchCompletionHandler"); + + [CountlyPushNotifications.sharedInstance handleNotification:userInfo]; + + [self Countly_application:application didReceiveRemoteNotification:userInfo fetchCompletionHandler:completionHandler]; +} + +#elif (TARGET_OS_OSX) +- (void)Countly_application:(NSApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo +{ + COUNTLY_LOG(@"App didReceiveRemoteNotification:"); + + [CountlyPushNotifications.sharedInstance handleNotification:userInfo]; + + [self Countly_application:application didReceiveRemoteNotification:userInfo]; +} +#endif +#endif +@end +#pragma GCC diagnostic pop diff --git a/src/ios/CountlyiOS/CountlyRemoteConfig.h b/src/ios/CountlyiOS/CountlyRemoteConfig.h new file mode 100644 index 0000000..0ccbc9f --- /dev/null +++ b/src/ios/CountlyiOS/CountlyRemoteConfig.h @@ -0,0 +1,19 @@ +// CountlyLocationManager.h +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import + +@interface CountlyRemoteConfig : NSObject +@property (nonatomic) BOOL isEnabledOnInitialConfig; +@property (nonatomic, copy) void (^remoteConfigCompletionHandler)(NSError * error); + ++ (instancetype)sharedInstance; + +- (void)startRemoteConfig; +- (id)remoteConfigValueForKey:(NSString *)key; +- (void)updateRemoteConfigForKeys:(NSArray *)keys omitKeys:(NSArray *)omitKeys completionHandler:(void (^)(NSError * error))completionHandler; +- (void)clearCachedRemoteConfig; +@end diff --git a/src/ios/CountlyiOS/CountlyRemoteConfig.m b/src/ios/CountlyiOS/CountlyRemoteConfig.m new file mode 100644 index 0000000..70462b1 --- /dev/null +++ b/src/ios/CountlyiOS/CountlyRemoteConfig.m @@ -0,0 +1,217 @@ +// CountlyLocationManager.h +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import "CountlyCommon.h" + +NSString* const kCountlyRCKeyFetchRemoteConfig = @"fetch_remote_config"; +NSString* const kCountlyRCKeyKeys = @"keys"; +NSString* const kCountlyRCKeyOmitKeys = @"omit_keys"; + +@interface CountlyRemoteConfig () +@property (nonatomic) NSDictionary* cachedRemoteConfig; +@end + +@implementation CountlyRemoteConfig + ++ (instancetype)sharedInstance +{ + if (!CountlyCommon.sharedInstance.hasStarted) + return nil; + + static CountlyRemoteConfig* s_sharedInstance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{s_sharedInstance = self.new;}); + return s_sharedInstance; +} + +- (instancetype)init +{ + if (self = [super init]) + { + self.cachedRemoteConfig = [CountlyPersistency.sharedInstance retrieveRemoteConfig]; + } + + return self; +} + +#pragma mark --- + +- (void)startRemoteConfig +{ + if (!self.isEnabledOnInitialConfig) + return; + + if (!CountlyConsentManager.sharedInstance.consentForRemoteConfig) + return; + + if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) + return; + + COUNTLY_LOG(@"Fetching remote config on start..."); + + [self fetchRemoteConfigForKeys:nil omitKeys:nil completionHandler:^(NSDictionary *remoteConfig, NSError *error) + { + if (!error) + { + COUNTLY_LOG(@"Fetching remote config on start is successful. \n%@", remoteConfig); + + self.cachedRemoteConfig = remoteConfig; + [CountlyPersistency.sharedInstance storeRemoteConfig:self.cachedRemoteConfig]; + } + else + { + COUNTLY_LOG(@"Fetching remote config on start failed: %@", error); + } + + if (self.remoteConfigCompletionHandler) + self.remoteConfigCompletionHandler(error); + }]; +} + +- (void)updateRemoteConfigForKeys:(NSArray *)keys omitKeys:(NSArray *)omitKeys completionHandler:(void (^)(NSError * error))completionHandler +{ + if (!CountlyConsentManager.sharedInstance.consentForRemoteConfig) + return; + + if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) + return; + + COUNTLY_LOG(@"Fetching remote config manually..."); + + [self fetchRemoteConfigForKeys:keys omitKeys:omitKeys completionHandler:^(NSDictionary *remoteConfig, NSError *error) + { + if (!error) + { + COUNTLY_LOG(@"Fetching remote config manually is successful. \n%@", remoteConfig); + + if (!keys && !omitKeys) + { + self.cachedRemoteConfig = remoteConfig; + } + else + { + NSMutableDictionary* partiallyUpdatedRemoteConfig = self.cachedRemoteConfig.mutableCopy; + [partiallyUpdatedRemoteConfig addEntriesFromDictionary:remoteConfig]; + self.cachedRemoteConfig = [NSDictionary dictionaryWithDictionary:partiallyUpdatedRemoteConfig]; + } + + [CountlyPersistency.sharedInstance storeRemoteConfig:self.cachedRemoteConfig]; + } + else + { + COUNTLY_LOG(@"Fetching remote config manually failed: %@", error); + } + + if (completionHandler) + completionHandler(error); + }]; +} + +- (id)remoteConfigValueForKey:(NSString *)key +{ + return self.cachedRemoteConfig[key]; +} + +- (void)clearCachedRemoteConfig +{ + self.cachedRemoteConfig = nil; + [CountlyPersistency.sharedInstance storeRemoteConfig:self.cachedRemoteConfig]; +} + +#pragma mark --- + +- (void)fetchRemoteConfigForKeys:(NSArray *)keys omitKeys:(NSArray *)omitKeys completionHandler:(void (^)(NSDictionary* remoteConfig, NSError * error))completionHandler +{ + if (!completionHandler) + return; + + NSURLRequest* request = [self remoteConfigRequestForKeys:keys omitKeys:omitKeys]; + NSURLSessionTask* task = [NSURLSession.sharedSession dataTaskWithRequest:request completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) + { + NSDictionary* remoteConfig = nil; + + if (!error) + { + remoteConfig = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; + } + + if (!error) + { + if (((NSHTTPURLResponse*)response).statusCode != 200) + { + NSMutableDictionary* userInfo = remoteConfig.mutableCopy; + userInfo[NSLocalizedDescriptionKey] = @"Remote config general API error"; + error = [NSError errorWithDomain:kCountlyErrorDomain code:CLYErrorRemoteConfigGeneralAPIError userInfo:userInfo]; + } + } + + if (error) + { + COUNTLY_LOG(@"Remote Config Request <%p> failed!\nError: %@", request, error); + + dispatch_async(dispatch_get_main_queue(), ^ + { + completionHandler(nil, error); + }); + + return; + } + + COUNTLY_LOG(@"Remote Config Request <%p> successfully completed.", request); + + dispatch_async(dispatch_get_main_queue(), ^ + { + completionHandler(remoteConfig, nil); + }); + }]; + + [task resume]; + + COUNTLY_LOG(@"Remote Config Request <%p> started:\n[%@] %@", (id)request, request.HTTPMethod, request.URL.absoluteString); +} + +- (NSURLRequest *)remoteConfigRequestForKeys:(NSArray *)keys omitKeys:(NSArray *)omitKeys +{ + NSString* queryString = [CountlyConnectionManager.sharedInstance queryEssentials]; + + queryString = [queryString stringByAppendingFormat:@"&%@=%@", kCountlyQSKeyMethod, kCountlyRCKeyFetchRemoteConfig]; + + if (keys) + { + queryString = [queryString stringByAppendingFormat:@"&%@=%@", kCountlyRCKeyKeys, [keys cly_JSONify]]; + } + else if (omitKeys) + { + queryString = [queryString stringByAppendingFormat:@"&%@=%@", kCountlyRCKeyOmitKeys, [omitKeys cly_JSONify]]; + } + + if (CountlyConsentManager.sharedInstance.consentForSessions) + { + queryString = [queryString stringByAppendingFormat:@"&%@=%@", kCountlyQSKeyMetrics, [CountlyDeviceInfo metrics]]; + } + + queryString = [CountlyConnectionManager.sharedInstance appendChecksum:queryString]; + + NSString* serverOutputSDKEndpoint = [CountlyConnectionManager.sharedInstance.host stringByAppendingFormat:@"%@%@", + kCountlyEndpointO, + kCountlyEndpointSDK]; + + if (CountlyConnectionManager.sharedInstance.alwaysUsePOST) + { + NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:serverOutputSDKEndpoint]]; + request.HTTPMethod = @"POST"; + request.HTTPBody = [queryString cly_dataUTF8]; + return request.copy; + } + else + { + NSString* withQueryString = [serverOutputSDKEndpoint stringByAppendingFormat:@"?%@", queryString]; + NSURLRequest* request = [NSURLRequest requestWithURL:[NSURL URLWithString:withQueryString]]; + return request; + } +} + +@end diff --git a/src/ios/CountlyiOS/CountlyUserDetails.h b/src/ios/CountlyiOS/CountlyUserDetails.h new file mode 100644 index 0000000..37bf0f2 --- /dev/null +++ b/src/ios/CountlyiOS/CountlyUserDetails.h @@ -0,0 +1,249 @@ +// CountlyUserDetails.h +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + * A placeholder type specifier which accepts only @c NSString or @c NSNull for @c CountlyUserDetails default properties. + */ +@protocol CountlyUserDetailsNullableString +@end +@interface NSString (NSStringWithCountlyUserDetailsNullableString) +@end +@interface NSNull (NSNullWithCountlyUserDetailsNullableString) +@end + + +/** + * A placeholder type specifier which accepts only @c NSDictionary or @c NSNull for @c CountlyUserDetails default properties. + */ +@protocol CountlyUserDetailsNullableDictionary +@end +@interface NSDictionary (NSDictionaryWithCountlyUserDetailsNullableDictionary) +@end +@interface NSNull (NSNullWithCountlyUserDetailsNullableDictionary) +@end + + +/** + * A placeholder type specifier which accepts only @c NSNumber or @c NSNull for @c CountlyUserDetails default properties. + */ +@protocol CountlyUserDetailsNullableNumber +@end +@interface NSNumber (NSNumberWithCountlyUserDetailsNullableNumber) +@end +@interface NSNull (NSNullWithCountlyUserDetailsNullableNumber) +@end + +extern NSString* const kCountlyLocalPicturePath; + +@interface CountlyUserDetails : NSObject + +/** + * Default @c name property for user's name in User Profiles. + * @discussion It can be set to an @c NSString, or @c NSNull for clearing it on server. + * It will be sent to server when @c recordUserDetails method is called. + */ +@property (nonatomic, copy) id _Nullable name; + +/** + * Default @c username property for user's username in User Profiles. + * @discussion It can be set to an @c NSString, or @c NSNull for clearing it on server. + * It will be sent to server when @c recordUserDetails method is called. + */ +@property (nonatomic, copy) id _Nullable username; + +/** + * Default @c email property for user's e-mail in User Profiles. + * @discussion It can be set to an @c NSString, or @c NSNull for clearing it on server. + * It will be sent to server when @c recordUserDetails method is called. + */ +@property (nonatomic, copy) id _Nullable email; + +/** + * Default @c organization property for user's organization/company in User Profiles. + * @discussion It can be set to an @c NSString, or @c NSNull for clearing it on server. + * It will be sent to server when @c recordUserDetails method is called. + */ +@property (nonatomic, copy) id _Nullable organization; + +/** + * Default @c phone property for user's phone number in User Profiles. + * @discussion It can be set to an @c NSString, or @c NSNull for clearing it on server. + * It will be sent to server when @c recordUserDetails method is called. + */ +@property (nonatomic, copy) id _Nullable phone; + +/** + * Default @c gender property for user's gender in User Profiles. + * @discussion It can be set to an @c NSString, or @c NSNull for clearing it on server. + * It will be sent to server when @c recordUserDetails method is called. + * @discussion If it is set to case-insensitive @c m or @c f, it is displayed as @c Male or @c Female. Otherwise it will displayed as @c Unknown. + */ +@property (nonatomic, copy) id _Nullable gender; + +/** + * Default @c pictureURL property for user's profile photo in User Profiles. + * @discussion It can be set to an @c NSString, or @c NSNull for clearing it on server. + * It will be sent to server when @c recordUserDetails method is called. + * @discussion It should be a publicly accessible URL string to user's profile photo, so server can download it. + */ +@property (nonatomic, copy) id _Nullable pictureURL; + +/** + * Default @c pictureLocalPath property for user's profile photo in User Profiles. + * @discussion It can be set to an @c NSString, or @c NSNull for clearing it on server. + * It will be sent to server when @c recordUserDetails method is called. + * @discussion It should be a valid local path string to user's profile photo on the device, so it can be uploaded to server. + * If @c pictureURL is also set at the same time, @c pictureLocalPath will be ignored and @c pictureURL will be used. + */ +@property (nonatomic, copy) id _Nullable pictureLocalPath; + +/** + * Default @c birthYear property for user's birth year in User Profiles. + * @discussion It can be set to an @c NSNumber, or @c NSNull for clearing it on server. + * It will be sent to server when @c recordUserDetails method is called. + */ +@property (nonatomic, copy) id _Nullable birthYear; + +/** + * @c custom property for user's custom information as key-value pairs in User Profiles. + * @discussion It can be set to an @c NSDictionary, or @c NSNull for clearing it on server. + * It will be sent to server when @c recordUserDetails method is called. + * @discussion Key-value pairs in @c custom property can also be modified using custom property modifier methods. + */ +@property (nonatomic, copy) id _Nullable custom; + +/** + * Returns @c CountlyUserDetails singleton to be used throughout the app. + * @return The shared @c CountlyUserDetails object + * @discussion @c Countly.user convenience accessor can also be used. + */ ++ (instancetype)sharedInstance; + +#pragma mark - + +/** + * Custom user details property modifier for setting a key-value pair. + * @discussion When called, this modifier is added to a non-persistent queue and sent to server only when @c save method is called. + * @param key Key for custom property key-value pair + * @param value Value for custom property key-value pair + */ +- (void)set:(NSString *)key value:(NSString *)value; + +/** + * Custom user details property modifier for setting a key-value pair if not set before. + * @discussion When called, this modifier is added to a non-persistent queue and sent to server only when @c save method is called. + * @param key Key for custom property key-value pair + * @param value Value for custom property key-value pair + */ +- (void)setOnce:(NSString *)key value:(NSString *)value; + +/** + * Custom user details property modifier for unsetting a key-value pair. + * @discussion When called, this modifier is added to a non-persistent queue and sent to server only when @c save method is called. + * @param key Key for custom property key-value pair + */ +- (void)unSet:(NSString *)key; + +/** + * Custom user details property modifier for incrementing a key-value pair's value by 1. + * @discussion When called, this modifier is added to a non-persistent queue and sent to server only when @c save method is called. + * @param key Key for custom property key-value pair + */ +- (void)increment:(NSString *)key; + +/** + * Custom user details property modifier for incrementing a key-value pair's value by specified amount. + * @discussion When called, this modifier is added to a non-persistent queue and sent to server only when @c save method is called. + * @param key Key for custom property key-value pair + * @param value Amount of increment + */ +- (void)incrementBy:(NSString *)key value:(NSNumber *)value; + +/** + * Custom user details property modifier for multiplying a key-value pair's value by specified multiplier. + * @discussion When called, this modifier is added to a non-persistent queue and sent to server only when @c save method is called. + * @param key Key for custom property key-value pair + * @param value Multiplier + */ +- (void)multiply:(NSString *)key value:(NSNumber *)value; + +/** + * Custom user details property modifier for setting a key-value pair's value if it is less than specified value. + * @discussion When called, this modifier is added to a non-persistent queue and sent to server only when @c save method is called. + * @param key Key for custom property key-value pair + * @param value Value to be compared against current value + */ +- (void)max:(NSString *)key value:(NSNumber *)value; + +/** + * Custom user details property modifier for setting a key-value pair's value if it is more than specified value. + * @discussion When called, this modifier is added to a non-persistent queue and sent to server only when @c save method is called. + * @param key Key for custom property key-value pair + * @param value Value to be compared against current value + */ +- (void)min:(NSString *)key value:(NSNumber *)value; + +/** + * Custom user details property modifier for adding specified value to the array for specified key. + * @discussion When called, this modifier is added to a non-persistent queue and sent to server only when @c save method is called. + * @param key Key for custom property of array type + * @param value Value to be added to the array + */ +- (void)push:(NSString *)key value:(NSString *)value; + +/** + * Custom user details property modifier for adding specified values to the array for specified key. + * @discussion When called, this modifier is added to a non-persistent queue and sent to server only when @c save method is called. + * @param key Key for custom property of array type + * @param value An array of values to be added to the array + */ +- (void)push:(NSString *)key values:(NSArray *)value; + +/** + * Custom user details property modifier for adding specified value to the array for specified key, if it does not exist. + * @discussion When called, this modifier is added to a non-persistent queue and sent to server only when @c save method is called. + * @param key Key for custom property of array type + * @param value Value to be added to the array + */ +- (void)pushUnique:(NSString *)key value:(NSString *)value; + +/** + * Custom user details property modifier for adding specified values to the array for specified key, if they do not exist. + * @discussion When called, this modifier is added to a non-persistent queue and sent to server only when @c save method is called. + * @param key Key for custom property of array type + * @param value An array of values to be added to the array + */ +- (void)pushUnique:(NSString *)key values:(NSArray *)value; + +/** + * Custom user details property modifier for removing specified value from the array for specified key. + * @discussion When called, this modifier is added to a non-persistent queue and sent to server only when @c save method is called. + * @param key Key for custom property of array type + * @param value Value to be removed from the array + */ +- (void)pull:(NSString *)key value:(NSString *)value; + +/** + * Custom user details property modifier for removing specified values from the array for specified key. + * @discussion When called, this modifier is added to a non-persistent queue and sent to server only when @c save method is called. + * @param key Key for custom property of array type + * @param value An array of values to be removed from the array + */ +- (void)pull:(NSString *)key values:(NSArray *)value; + +/** + * Records user details and sends them to server. + * @discussion Once called, default user details properties and custom user details property modifiers are reset. If sending them to server fails, they are stored peristently in request queue, to be tried again later. + */ +- (void)save; + +NS_ASSUME_NONNULL_END + +@end diff --git a/src/ios/CountlyiOS/CountlyUserDetails.m b/src/ios/CountlyiOS/CountlyUserDetails.m new file mode 100644 index 0000000..a134053 --- /dev/null +++ b/src/ios/CountlyiOS/CountlyUserDetails.m @@ -0,0 +1,191 @@ +// CountlyUserDetails.m +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import "CountlyCommon.h" + +@interface CountlyUserDetails () +@property (nonatomic) NSMutableDictionary* modifications; +@end + +NSString* const kCountlyLocalPicturePath = @"kCountlyLocalPicturePath"; + +NSString* const kCountlyUDKeyName = @"name"; +NSString* const kCountlyUDKeyUsername = @"username"; +NSString* const kCountlyUDKeyEmail = @"email"; +NSString* const kCountlyUDKeyOrganization = @"organization"; +NSString* const kCountlyUDKeyPhone = @"phone"; +NSString* const kCountlyUDKeyGender = @"gender"; +NSString* const kCountlyUDKeyPicture = @"picture"; +NSString* const kCountlyUDKeyBirthyear = @"byear"; +NSString* const kCountlyUDKeyCustom = @"custom"; + +NSString* const kCountlyUDKeyModifierSetOnce = @"$setOnce"; +NSString* const kCountlyUDKeyModifierIncrement = @"$inc"; +NSString* const kCountlyUDKeyModifierMultiply = @"$mul"; +NSString* const kCountlyUDKeyModifierMax = @"$max"; +NSString* const kCountlyUDKeyModifierMin = @"$min"; +NSString* const kCountlyUDKeyModifierPush = @"$push"; +NSString* const kCountlyUDKeyModifierAddToSet = @"$addToSet"; +NSString* const kCountlyUDKeyModifierPull = @"$pull"; + +@implementation CountlyUserDetails + ++ (instancetype)sharedInstance +{ + static CountlyUserDetails *s_sharedInstance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{s_sharedInstance = self.new;}); + return s_sharedInstance; +} + +- (instancetype)init +{ + if (self = [super init]) + { + self.modifications = NSMutableDictionary.new; + } + + return self; +} + +- (NSString *)serializedUserDetails +{ + NSMutableDictionary* userDictionary = NSMutableDictionary.new; + if (self.name) + userDictionary[kCountlyUDKeyName] = self.name; + if (self.username) + userDictionary[kCountlyUDKeyUsername] = self.username; + if (self.email) + userDictionary[kCountlyUDKeyEmail] = self.email; + if (self.organization) + userDictionary[kCountlyUDKeyOrganization] = self.organization; + if (self.phone) + userDictionary[kCountlyUDKeyPhone] = self.phone; + if (self.gender) + userDictionary[kCountlyUDKeyGender] = self.gender; + if (self.pictureURL) + userDictionary[kCountlyUDKeyPicture] = self.pictureURL; + if (self.birthYear) + userDictionary[kCountlyUDKeyBirthyear] = self.birthYear; + if (self.custom) + userDictionary[kCountlyUDKeyCustom] = self.custom; + + if (userDictionary.allKeys.count) + return [userDictionary cly_JSONify]; + + return nil; +} + +- (void)clearUserDetails +{ + self.name = nil; + self.username = nil; + self.email = nil; + self.organization = nil; + self.phone = nil; + self.gender = nil; + self.pictureURL = nil; + self.pictureLocalPath = nil; + self.birthYear = nil; + self.custom = nil; + + [self.modifications removeAllObjects]; +} + +#pragma mark - + +- (void)set:(NSString *)key value:(NSString *)value +{ + self.modifications[key] = value.copy; +} + +- (void)setOnce:(NSString *)key value:(NSString *)value +{ + self.modifications[key] = @{kCountlyUDKeyModifierSetOnce: value.copy}; +} + +- (void)unSet:(NSString *)key +{ + self.modifications[key] = NSNull.null; +} + +- (void)increment:(NSString *)key +{ + [self incrementBy:key value:@1]; +} + +- (void)incrementBy:(NSString *)key value:(NSNumber *)value +{ + self.modifications[key] = @{kCountlyUDKeyModifierIncrement: value}; +} + +- (void)multiply:(NSString *)key value:(NSNumber *)value +{ + self.modifications[key] = @{kCountlyUDKeyModifierMultiply: value}; +} + +- (void)max:(NSString *)key value:(NSNumber *)value +{ + self.modifications[key] = @{kCountlyUDKeyModifierMax: value}; +} + +- (void)min:(NSString *)key value:(NSNumber *)value +{ + self.modifications[key] = @{kCountlyUDKeyModifierMin: value}; +} + +- (void)push:(NSString *)key value:(NSString *)value +{ + self.modifications[key] = @{kCountlyUDKeyModifierPush: value.copy}; +} + +- (void)push:(NSString *)key values:(NSArray *)value +{ + self.modifications[key] = @{kCountlyUDKeyModifierPush: value.copy}; +} + +- (void)pushUnique:(NSString *)key value:(NSString *)value +{ + self.modifications[key] = @{kCountlyUDKeyModifierAddToSet: value.copy}; +} + +- (void)pushUnique:(NSString *)key values:(NSArray *)value +{ + self.modifications[key] = @{kCountlyUDKeyModifierAddToSet: value.copy}; +} + +- (void)pull:(NSString *)key value:(NSString *)value +{ + self.modifications[key] = @{kCountlyUDKeyModifierPull: value.copy}; +} + +- (void)pull:(NSString *)key values:(NSArray *)value +{ + self.modifications[key] = @{kCountlyUDKeyModifierPull: value.copy}; +} + +- (void)save +{ + if (!CountlyCommon.sharedInstance.hasStarted) + return; + + if (!CountlyConsentManager.sharedInstance.consentForUserDetails) + return; + + NSString* userDetails = [self serializedUserDetails]; + if (userDetails) + [CountlyConnectionManager.sharedInstance sendUserDetails:userDetails]; + + if (self.pictureLocalPath && !self.pictureURL) + [CountlyConnectionManager.sharedInstance sendUserDetails:[@{kCountlyLocalPicturePath: self.pictureLocalPath} cly_JSONify]]; + + if (self.modifications.count) + [CountlyConnectionManager.sharedInstance sendUserDetails:[@{kCountlyUDKeyCustom: self.modifications} cly_JSONify]]; + + [self clearUserDetails]; +} + +@end diff --git a/src/ios/CountlyiOS/CountlyViewTracking.h b/src/ios/CountlyiOS/CountlyViewTracking.h new file mode 100644 index 0000000..84671c4 --- /dev/null +++ b/src/ios/CountlyiOS/CountlyViewTracking.h @@ -0,0 +1,25 @@ +// CountlyViewTracking.h +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import + +@interface CountlyViewTracking : NSObject +@property (nonatomic) BOOL isEnabledOnInitialConfig; + ++ (instancetype)sharedInstance; + +- (void)startView:(NSString *)viewName customSegmentation:(NSDictionary *)customSegmentation; +- (void)endView; +- (void)pauseView; +- (void)resumeView; +#if (TARGET_OS_IOS || TARGET_OS_TV) +- (void)startAutoViewTracking; +- (void)stopAutoViewTracking; +- (void)addExceptionForAutoViewTracking:(NSString *)exception; +- (void)removeExceptionForAutoViewTracking:(NSString *)exception; +@property (nonatomic) BOOL isAutoViewTrackingActive; +#endif +@end diff --git a/src/ios/CountlyiOS/CountlyViewTracking.m b/src/ios/CountlyiOS/CountlyViewTracking.m new file mode 100644 index 0000000..33cee4f --- /dev/null +++ b/src/ios/CountlyiOS/CountlyViewTracking.m @@ -0,0 +1,316 @@ +// CountlyViewTracking.m +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import "CountlyCommon.h" + +@interface CountlyViewTracking () +@property (nonatomic) NSString* lastView; +@property (nonatomic) NSTimeInterval lastViewStartTime; +@property (nonatomic) NSTimeInterval accumulatedTime; +@property (nonatomic) NSMutableArray* exceptionViewControllers; +@end + +NSString* const kCountlyReservedEventView = @"[CLY]_view"; + +NSString* const kCountlyVTKeyName = @"name"; +NSString* const kCountlyVTKeySegment = @"segment"; +NSString* const kCountlyVTKeyVisit = @"visit"; +NSString* const kCountlyVTKeyStart = @"start"; +NSString* const kCountlyVTKeyBounce = @"bounce"; +NSString* const kCountlyVTKeyExit = @"exit"; +NSString* const kCountlyVTKeyView = @"view"; +NSString* const kCountlyVTKeyDomain = @"domain"; +NSString* const kCountlyVTKeyDur = @"dur"; + +#if (TARGET_OS_IOS || TARGET_OS_TV) +@interface UIViewController (CountlyViewTracking) +- (void)Countly_viewDidAppear:(BOOL)animated; +@end +#endif + +@implementation CountlyViewTracking + ++ (instancetype)sharedInstance +{ + if (!CountlyCommon.sharedInstance.hasStarted) + return nil; + + static CountlyViewTracking* s_sharedInstance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{s_sharedInstance = self.new;}); + return s_sharedInstance; +} + +- (instancetype)init +{ + if (self = [super init]) + { + self.exceptionViewControllers = + @[ + @"CLYInternalViewController", + @"UINavigationController", + @"UIAlertController", + @"UIPageViewController", + @"UITabBarController", + @"UIReferenceLibraryViewController", + @"UISplitViewController", + @"UIInputViewController", + @"UISearchController", + @"UISearchContainerViewController", + @"UIApplicationRotationFollowingController", + @"MFMailComposeInternalViewController", + @"MFMailComposeInternalViewController", + @"MFMailComposePlaceholderViewController", + @"UIInputWindowController", + @"_UIFallbackPresentationViewController", + @"UIActivityViewController", + @"UIActivityGroupViewController", + @"_UIActivityGroupListViewController", + @"_UIActivityViewControllerContentController", + @"UIKeyboardCandidateRowViewController", + @"UIKeyboardCandidateGridCollectionViewController", + @"UIPrintMoreOptionsTableViewController", + @"UIPrintPanelTableViewController", + @"UIPrintPanelViewController", + @"UIPrintPaperViewController", + @"UIPrintPreviewViewController", + @"UIPrintRangeViewController", + @"UIDocumentMenuViewController", + @"UIDocumentPickerViewController", + @"UIDocumentPickerExtensionViewController", + @"UIInterfaceActionGroupViewController", + @"UISystemInputViewController", + @"UIRecentsInputViewController", + @"UICompatibilityInputViewController", + @"UIInputViewAnimationControllerViewController", + @"UISnapshotModalViewController", + @"UIMultiColumnViewController", + @"UIKeyCommandDiscoverabilityHUDViewController" + ].mutableCopy; + } + + return self; +} + +#pragma mark - + +- (void)startView:(NSString *)viewName +{ + [self startView:viewName customSegmentation:nil]; +} + +- (void)startView:(NSString *)viewName customSegmentation:(NSDictionary *)customSegmentation +{ + if (!viewName.length) + return; + + if (!CountlyConsentManager.sharedInstance.consentForViewTracking) + return; + + viewName = viewName.copy; + + [self endView]; + + COUNTLY_LOG(@"View tracking started: %@", viewName); + + NSMutableDictionary* segmentation = NSMutableDictionary.new; + segmentation[kCountlyVTKeyName] = viewName; + segmentation[kCountlyVTKeySegment] = CountlyDeviceInfo.osName; + segmentation[kCountlyVTKeyVisit] = @1; + + if (!self.lastView) + segmentation[kCountlyVTKeyStart] = @1; + + if (customSegmentation) + { + NSMutableDictionary* mutableCustomSegmentation = customSegmentation.mutableCopy; + [mutableCustomSegmentation removeObjectsForKeys:self.reservedViewTrackingSegmentationKeys]; + [segmentation addEntriesFromDictionary:mutableCustomSegmentation]; + } + + [Countly.sharedInstance recordReservedEvent:kCountlyReservedEventView segmentation:segmentation]; + + self.lastView = viewName; + self.lastViewStartTime = CountlyCommon.sharedInstance.uniqueTimestamp; +} + +- (void)endView +{ + if (!CountlyConsentManager.sharedInstance.consentForViewTracking) + return; + + if (self.lastView) + { + NSMutableDictionary* segmentation = NSMutableDictionary.new; + segmentation[kCountlyVTKeyName] = self.lastView; + segmentation[kCountlyVTKeySegment] = CountlyDeviceInfo.osName; + + NSTimeInterval duration = NSDate.date.timeIntervalSince1970 - self.lastViewStartTime + self.accumulatedTime; + self.accumulatedTime = 0; + [Countly.sharedInstance recordReservedEvent:kCountlyReservedEventView segmentation:segmentation count:1 sum:0 duration:duration timestamp:self.lastViewStartTime]; + + COUNTLY_LOG(@"View tracking ended: %@ duration: %.17g", self.lastView, duration); + } +} + +- (void)pauseView +{ + if (self.lastViewStartTime) + self.accumulatedTime = NSDate.date.timeIntervalSince1970 - self.lastViewStartTime; +} + +- (void)resumeView +{ + self.lastViewStartTime = CountlyCommon.sharedInstance.uniqueTimestamp; +} + +#pragma mark - + +#if (TARGET_OS_IOS || TARGET_OS_TV) +- (void)startAutoViewTracking +{ + if (!self.isEnabledOnInitialConfig) + return; + + if (!CountlyConsentManager.sharedInstance.consentForViewTracking) + return; + + self.isAutoViewTrackingActive = YES; + + [self swizzleViewTrackingMethods]; + + UIViewController* topVC = CountlyCommon.sharedInstance.topViewController; + NSString* viewTitle = [CountlyViewTracking.sharedInstance titleForViewController:topVC]; + [self startView:viewTitle]; +} + +- (void)swizzleViewTrackingMethods +{ + static BOOL alreadySwizzled; + if (alreadySwizzled) + return; + + alreadySwizzled = YES; + + Method O_method = class_getInstanceMethod(UIViewController.class, @selector(viewDidAppear:)); + Method C_method = class_getInstanceMethod(UIViewController.class, @selector(Countly_viewDidAppear:)); + method_exchangeImplementations(O_method, C_method); +} + +- (void)stopAutoViewTracking +{ + self.isAutoViewTrackingActive = NO; + + self.lastView = nil; + self.lastViewStartTime = 0; + self.accumulatedTime = 0; +} + +#pragma mark - + +- (void)setIsAutoViewTrackingActive:(BOOL)isAutoViewTrackingActive +{ + if (!self.isEnabledOnInitialConfig) + return; + + if (!CountlyConsentManager.sharedInstance.consentForViewTracking) + return; + + _isAutoViewTrackingActive = isAutoViewTrackingActive; +} + +#pragma mark - + +- (void)addExceptionForAutoViewTracking:(NSString *)exception +{ + if (!exception.length) + return; + + if (![self.exceptionViewControllers containsObject:exception]) + [self.exceptionViewControllers addObject:exception]; +} + +- (void)removeExceptionForAutoViewTracking:(NSString *)exception +{ + [self.exceptionViewControllers removeObject:exception]; +} + +#pragma mark - + +- (NSString*)titleForViewController:(UIViewController *)viewController +{ + if (!viewController) + return nil; + + NSString* title = viewController.title; + + if (!title) + title = [viewController.navigationItem.titleView isKindOfClass:UILabel.class] ? ((UILabel *)viewController.navigationItem.titleView).text : nil; + + if (!title) + title = NSStringFromClass(viewController.class); + + return title; +} + +#endif + +- (NSArray *)reservedViewTrackingSegmentationKeys +{ + NSArray* reservedViewTrackingSegmentationKeys = + @[ + kCountlyVTKeyName, + kCountlyVTKeySegment, + kCountlyVTKeyVisit, + kCountlyVTKeyStart, + kCountlyVTKeyBounce, + kCountlyVTKeyExit, + kCountlyVTKeyView, + kCountlyVTKeyDomain, + kCountlyVTKeyDur + ]; + + return reservedViewTrackingSegmentationKeys; +} + +@end + +#pragma mark - + +#if (TARGET_OS_IOS || TARGET_OS_TV) +@implementation UIViewController (CountlyViewTracking) +- (void)Countly_viewDidAppear:(BOOL)animated +{ + [self Countly_viewDidAppear:animated]; + + if (!CountlyViewTracking.sharedInstance.isAutoViewTrackingActive) + return; + + if (!CountlyConsentManager.sharedInstance.consentForViewTracking) + return; + + NSString* viewTitle = [CountlyViewTracking.sharedInstance titleForViewController:self]; + + if ([CountlyViewTracking.sharedInstance.lastView isEqualToString:viewTitle]) + return; + + BOOL isException = NO; + + for (NSString* exception in CountlyViewTracking.sharedInstance.exceptionViewControllers) + { + isException = [self.title isEqualToString:exception] || + [self isKindOfClass:NSClassFromString(exception)] || + [NSStringFromClass(self.class) isEqualToString:exception]; + + if (isException) + break; + } + + if (!isException) + [CountlyViewTracking.sharedInstance startView:viewTitle]; +} +@end +#endif