From fef1d7da15ae67d3b55a7eb0e0c04c946645365d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20R=C4=B1za=20Kat?= Date: Thu, 24 Oct 2024 14:30:12 +0300 Subject: [PATCH 01/34] iOS Version to 24.7.4 --- ios/src/CHANGELOG.md | 47 ++ ios/src/Countly-PL.podspec | 4 +- ios/src/Countly.h | 49 +- ios/src/Countly.m | 322 +++++++--- ios/src/Countly.podspec | 4 +- ios/src/Countly.xcodeproj/project.pbxproj | 118 +++- .../xcschemes/CountlyTests.xcscheme | 54 ++ ios/src/CountlyCommon.h | 15 +- ios/src/CountlyCommon.m | 55 +- ios/src/CountlyConfig.h | 45 +- ios/src/CountlyConfig.m | 37 +- ios/src/CountlyConnectionManager.h | 4 +- ios/src/CountlyConnectionManager.m | 69 ++- ios/src/CountlyConsentManager.h | 6 +- ios/src/CountlyConsentManager.m | 64 +- ios/src/CountlyContentBuilder.h | 31 + ios/src/CountlyContentBuilder.m | 50 ++ ios/src/CountlyContentBuilderInternal.h | 28 + ios/src/CountlyContentBuilderInternal.m | 229 +++++++ ios/src/CountlyContentConfig.h | 39 ++ ios/src/CountlyContentConfig.m | 38 ++ ios/src/CountlyCrashData.h | 30 + ios/src/CountlyCrashData.m | 74 +++ ios/src/CountlyCrashReporter.h | 2 + ios/src/CountlyCrashReporter.m | 146 +++-- ios/src/CountlyCrashesConfig.h | 16 + ios/src/CountlyCrashesConfig.m | 18 + ios/src/CountlyDeviceInfo.m | 35 +- ios/src/CountlyExperimentalConfig.h | 16 + ios/src/CountlyExperimentalConfig.m | 22 + ios/src/CountlyFeedbackWidget.h | 7 + ios/src/CountlyFeedbackWidget.m | 158 +++-- ios/src/CountlyFeedbacks.h | 37 +- ios/src/CountlyFeedbacks.m | 491 +-------------- ios/src/CountlyFeedbacksInternal.h | 41 ++ ios/src/CountlyFeedbacksInternal.m | 568 ++++++++++++++++++ ios/src/CountlyNotificationService.h | 4 +- ios/src/CountlyNotificationService.m | 2 +- ios/src/CountlyPerformanceMonitoring.m | 4 +- ios/src/CountlyPersistency.m | 27 +- ios/src/CountlyPushNotifications.h | 2 +- ios/src/CountlyPushNotifications.m | 12 +- ios/src/CountlyRemoteConfigInternal.m | 19 +- .../CountlyTests/CountlyBaseTestCase.swift | 35 ++ .../CountlyCrashReporterTests.swift | 169 ++++++ .../CountlyTests/CountlyDeviceIDTests.swift | 135 +++++ ios/src/CountlyTests/CountlyEventStruct.swift | 124 ++++ .../CountlyTests/CountlyLocationTests.swift | 259 ++++++++ .../CountlySegmentationTests.swift | 124 ++++ .../CountlyUserProfileTests.swift | 506 ++++++++++++++++ ios/src/CountlyUserDetails.h | 2 + ios/src/CountlyUserDetails.m | 32 + ios/src/CountlyViewData.h | 17 +- ios/src/CountlyViewData.m | 12 +- ios/src/CountlyViewTrackingInternal.h | 12 +- ios/src/CountlyViewTrackingInternal.m | 82 ++- ios/src/CountlyWebViewManager.h | 37 ++ ios/src/CountlyWebViewManager.m | 249 ++++++++ ios/src/PassThroughBackgroundView.h | 27 + ios/src/PassThroughBackgroundView.m | 36 ++ ios/src/include/CountlyContentBuilder.h | 1 + ios/src/include/CountlyContentConfig.h | 1 + ios/src/include/CountlyCrashData.h | 1 + ios/src/include/CountlyCrashesConfig.h | 1 + ios/src/include/CountlyExperimentalConfig.h | 1 + ios/src/include/CountlyFeedbacks.h | 1 + 66 files changed, 4038 insertions(+), 865 deletions(-) create mode 100644 ios/src/Countly.xcodeproj/xcshareddata/xcschemes/CountlyTests.xcscheme create mode 100644 ios/src/CountlyContentBuilder.h create mode 100644 ios/src/CountlyContentBuilder.m create mode 100644 ios/src/CountlyContentBuilderInternal.h create mode 100644 ios/src/CountlyContentBuilderInternal.m create mode 100644 ios/src/CountlyContentConfig.h create mode 100644 ios/src/CountlyContentConfig.m create mode 100644 ios/src/CountlyCrashData.h create mode 100644 ios/src/CountlyCrashData.m create mode 100644 ios/src/CountlyCrashesConfig.h create mode 100644 ios/src/CountlyCrashesConfig.m create mode 100644 ios/src/CountlyExperimentalConfig.h create mode 100644 ios/src/CountlyExperimentalConfig.m create mode 100644 ios/src/CountlyFeedbacksInternal.h create mode 100644 ios/src/CountlyFeedbacksInternal.m create mode 100644 ios/src/CountlyTests/CountlyCrashReporterTests.swift create mode 100644 ios/src/CountlyTests/CountlyDeviceIDTests.swift create mode 100644 ios/src/CountlyTests/CountlyEventStruct.swift create mode 100644 ios/src/CountlyTests/CountlyLocationTests.swift create mode 100644 ios/src/CountlyTests/CountlySegmentationTests.swift create mode 100644 ios/src/CountlyTests/CountlyUserProfileTests.swift create mode 100644 ios/src/CountlyWebViewManager.h create mode 100644 ios/src/CountlyWebViewManager.m create mode 100644 ios/src/PassThroughBackgroundView.h create mode 100644 ios/src/PassThroughBackgroundView.m create mode 120000 ios/src/include/CountlyContentBuilder.h create mode 120000 ios/src/include/CountlyContentConfig.h create mode 120000 ios/src/include/CountlyCrashData.h create mode 120000 ios/src/include/CountlyCrashesConfig.h create mode 120000 ios/src/include/CountlyExperimentalConfig.h create mode 120000 ios/src/include/CountlyFeedbacks.h diff --git a/ios/src/CHANGELOG.md b/ios/src/CHANGELOG.md index 64bde998..88b3b7dc 100644 --- a/ios/src/CHANGELOG.md +++ b/ios/src/CHANGELOG.md @@ -1,3 +1,50 @@ +## 24.7.4 +* Added visionOS build support +* Added `CountlyFeedbacks:` interface with new view methods (Access with `Countly.sharedInstance.feedback`): + * Method to present feedback widget (wih an optional widget selector(name, ID or tag) string and a Callback): + * `presentNPS` + * `presentSurvey` + * `presentRating` + * `getAvailableFeedbackWidgets` method to retrieve available feedback widgets with a completion handler. + +* Mitigated an issue with the feedback widget URL encoding on iOS 16 and earlier, which prevented the widget from displaying +* Mitigated an issue with content fetch URL encoding on iOS 16 and earlier, which caused the request to fail + +* Deprecated `getFeedbackWidgets` method, you should use `[feedback getAvailableFeedbackWidgets:]` method instead + +## 24.7.3 +* Added current view names to event segmentation based on the `enablePreviousNameRecording` (Experimental!) +* Updated the SDK to ensure compatibility with the latest server response models + +## 24.7.2 +* Automatic view pause/resumes are changed with stop/start for better data consistency. +* Added the config interface 'experimental' to group experimental features. +* Added a flag (enablePreviousNameRecording) to add previous event and view names as segmentation. (Experimental!) +* Added a flag (enableVisibilityTracking) to add app visibility info to views +* Added Content feature methods: + - enterContentZone, to start Content checks(Experimental!) + - exitContentZone, to stop content checks (Experimental!) + +## 24.7.1 +* Added `enableTemporaryDeviceIDMode` config and post-initialization methods to enable temporary device ID mode +* Orientation info is now also sent during initialization +* Mitigated an issue where consent information was not sent when no consent was given during initialization +* Mitigated an issue where a session could have started if the SDK was initialized on the background and automatic session tracking was enabled +* Mitigated an issue where a session did not end when session consent was removed +* Mitigated an issue where disabling location did not work + +## 24.7.0 +* Implemented automatic sending of user properties to the server without requiring an explicit call to the `save` method +* Added `setID` method for changing device ID based on the device ID type +* Enhanced segmentation values to include additional supported data types beyond `NSString` +* Fixed web view caching issue for widgets + +* Mitigated an issue where the terms and conditions URL (`tc` key) was sent without double quotes +* Mitigated an issue where remote config values are not updated after enrolling to a variant + +## 24.4.2 +* Improved crash filtering capabilities to include modifications on the crash report + ## 24.4.1 * Added support for Feedback Widget terms and conditions diff --git a/ios/src/Countly-PL.podspec b/ios/src/Countly-PL.podspec index e1590e8e..78897d6b 100644 --- a/ios/src/Countly-PL.podspec +++ b/ios/src/Countly-PL.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'Countly-PL' - s.version = '24.4.1' + s.version = '24.7.4' s.license = { :type => 'MIT', :file => 'LICENSE' } s.summary = 'Countly is an innovative, real-time, open source mobile analytics platform.' s.homepage = 'https://github.com/Countly/countly-sdk-ios' @@ -17,7 +17,7 @@ Pod::Spec.new do |s| s.subspec 'Core' do |core| core.source_files = '*.{h,m}' - core.public_header_files = 'Countly.h', 'CountlyUserDetails.h', 'CountlyConfig.h', 'CountlyFeedbackWidget.h', 'CountlyRCData.h', 'CountlyRemoteConfig.h', 'CountlyViewTracking.h', 'CountlyExperimentInformation.h', 'CountlyAPMConfig.h', 'CountlySDKLimitsConfig.h', 'Resettable.h' + core.public_header_files = 'Countly.h', 'CountlyUserDetails.h', 'CountlyConfig.h', 'CountlyFeedbackWidget.h', 'CountlyRCData.h', 'CountlyRemoteConfig.h', 'CountlyViewTracking.h', 'CountlyExperimentInformation.h', 'CountlyAPMConfig.h', 'CountlySDKLimitsConfig.h', 'Resettable.h', "CountlyCrashesConfig.h", "CountlyCrashData.h", "CountlyContentBuilder.h", "CountlyExperimentalConfig.h", "CountlyContentConfig.h", "CountlyFeedbacks.h" core.preserve_path = 'countly_dsym_uploader.sh' core.ios.frameworks = ['Foundation', 'UIKit', 'UserNotifications', 'CoreLocation', 'WebKit', 'CoreTelephony', 'WatchConnectivity'] end diff --git a/ios/src/Countly.h b/ios/src/Countly.h index f5659181..a8699d08 100644 --- a/ios/src/Countly.h +++ b/ios/src/Countly.h @@ -12,8 +12,10 @@ #import "CountlyRemoteConfig.h" #import "CountlyFeedbackWidget.h" #import "CountlyViewTracking.h" +#import "CountlyContentBuilder.h" +#import "CountlyFeedbacks.h" #import "Resettable.h" -#if (TARGET_OS_IOS || TARGET_OS_OSX) +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_OSX ) #import #endif @@ -186,6 +188,15 @@ NS_ASSUME_NONNULL_BEGIN */ - (void)changeDeviceIDWithoutMerge:(NSString * _Nullable)deviceID; +- (void)setID:(NSString *)deviceID; + +/** + * This menthod will enable temporary device ID mode + * @discussion All requests will be on hold, but they will be persistently stored. + * @discussion When in temporary device ID mode, method calls for presenting feedback widgets and updating remote config will be ignored. + */ +- (void)enableTemporaryDeviceIDMode; + #pragma mark - Consents @@ -291,7 +302,7 @@ NS_ASSUME_NONNULL_BEGIN * @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; +- (void)recordEvent:(NSString *)key segmentation:(NSDictionary * _Nullable)segmentation; /** * Records event with given key, segmentation and count. @@ -302,7 +313,7 @@ NS_ASSUME_NONNULL_BEGIN * @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; +- (void)recordEvent:(NSString *)key segmentation:(NSDictionary * _Nullable)segmentation count:(NSUInteger)count; /** * Records event with given key, segmentation, count and sum. @@ -314,7 +325,7 @@ NS_ASSUME_NONNULL_BEGIN * @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; +- (void)recordEvent:(NSString *)key segmentation:(NSDictionary * _Nullable)segmentation count:(NSUInteger)count sum:(double)sum; /** * Records event with given key, segmentation, count, sum and duration. @@ -327,7 +338,7 @@ NS_ASSUME_NONNULL_BEGIN * @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; +- (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. @@ -354,7 +365,7 @@ NS_ASSUME_NONNULL_BEGIN * @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; +- (void)endEvent:(NSString *)key segmentation:(NSDictionary * _Nullable)segmentation count:(NSUInteger)count sum:(double)sum; /** * Cancels a previously started timed event with given key. @@ -366,7 +377,7 @@ NS_ASSUME_NONNULL_BEGIN #pragma mark - Push Notification -#if (TARGET_OS_IOS || TARGET_OS_OSX) +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_OSX ) #ifndef COUNTLY_EXCLUDE_PUSHNOTIFICATIONS /** * Shows default system dialog that asks for user's permission to display notifications. @@ -463,7 +474,7 @@ NS_ASSUME_NONNULL_BEGIN * @param stackTrace Stack trace to be recorded * @param segmentation Crash segmentation to override @c crashSegmentation set on initial configuration */ -- (void)recordException:(NSException *)exception isFatal:(BOOL)isFatal stackTrace:(NSArray * _Nullable)stackTrace segmentation:(NSDictionary * _Nullable)segmentation; +- (void)recordException:(NSException *)exception isFatal:(BOOL)isFatal stackTrace:(NSArray * _Nullable)stackTrace segmentation:(NSDictionary * _Nullable)segmentation; /** * Records a Swift error with given stack trace. @@ -481,7 +492,7 @@ NS_ASSUME_NONNULL_BEGIN * @param stackTrace Stack trace to be recorded * @param segmentation Crash segmentation to override @c crashSegmentation set on initial configuration */ -- (void)recordError:(NSString *)errorName isFatal:(BOOL)isFatal stackTrace:(NSArray * _Nullable)stackTrace segmentation:(NSDictionary * _Nullable)segmentation; +- (void)recordError:(NSString *)errorName isFatal:(BOOL)isFatal stackTrace:(NSArray * _Nullable)stackTrace segmentation:(NSDictionary * _Nullable)segmentation; /** * Records a handled exception manually. @@ -540,9 +551,9 @@ NS_ASSUME_NONNULL_BEGIN * @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 DEPRECATED_MSG_ATTRIBUTE("Use '[views startView/startAutoStoppedView:]' method instead!"); +- (void)recordView:(NSString *)viewName segmentation:(NSDictionary *)segmentation DEPRECATED_MSG_ATTRIBUTE("Use '[views startView/startAutoStoppedView:]' method instead!"); -#if (TARGET_OS_IOS || TARGET_OS_TV) +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV ) /** * 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. @@ -583,7 +594,7 @@ NS_ASSUME_NONNULL_BEGIN #pragma mark - Feedbacks -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION ) /** * 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. @@ -666,8 +677,20 @@ NS_ASSUME_NONNULL_BEGIN * @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; +- (void)getFeedbackWidgets:(void (^)(NSArray * __nullable feedbackWidgets, NSError * __nullable error))completionHandler DEPRECATED_MSG_ATTRIBUTE("Use '[feedback getAvailableFeedbackWidgets:]' method instead!"); +/** + * This is an experimental feature and it can have breaking changes + * Interface variable to access content functionalities. + * @discussion Content interface for developer to interact with SDK. + */ +- (CountlyContentBuilder *_Nonnull) content; + +/** + * Interface variable to access feedback widget functionalities. + * @discussion Feedback widget interface for developer to interact with SDK. + */ +- (CountlyFeedbacks *) feedback; #endif diff --git a/ios/src/Countly.m b/ios/src/Countly.m index 01f6c2ee..11e49c80 100644 --- a/ios/src/Countly.m +++ b/ios/src/Countly.m @@ -16,7 +16,8 @@ @interface Countly () long long appLoadStartTime; // It holds the event id of previous recorded custom event. NSString* previousEventID; - +// It holds the event name of previous recorded custom event. +NSString* previousEventName; @implementation Countly #pragma mark - Core @@ -24,7 +25,7 @@ @implementation Countly + (void)load { [super load]; - + appLoadStartTime = floor(NSDate.date.timeIntervalSince1970 * 1000); } @@ -47,7 +48,7 @@ - (instancetype)init { if (self = [super init]) { -#if (TARGET_OS_IOS || TARGET_OS_TV) +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV ) [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(applicationDidEnterBackground:) name:UIApplicationDidEnterBackgroundNotification @@ -60,6 +61,13 @@ - (instancetype)init selector:@selector(applicationWillTerminate:) name:UIApplicationWillTerminateNotification object:nil]; + + [NSNotificationCenter.defaultCenter addObserver:self + selector:@selector(applicationDidBecomeActive:) name:UIApplicationDidBecomeActiveNotification + object:nil]; + [NSNotificationCenter.defaultCenter addObserver:self + selector:@selector(applicationWillResignActive:) name:UIApplicationWillResignActiveNotification + object:nil]; #elif (TARGET_OS_OSX) [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(applicationWillTerminate:) @@ -67,7 +75,7 @@ - (instancetype)init object:nil]; #endif } - + return self; } @@ -75,7 +83,7 @@ - (void)startWithConfig:(CountlyConfig *)config { if (CountlyCommon.sharedInstance.hasStarted_) return; - + CountlyCommon.sharedInstance.hasStarted = YES; CountlyCommon.sharedInstance.enableDebug = config.enableDebug; CountlyCommon.sharedInstance.shouldIgnoreTrustCheck = config.shouldIgnoreTrustCheck; @@ -103,13 +111,13 @@ - (void)startWithConfig:(CountlyConfig *)config } 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"]; - + if ([CountlyCommon.sharedInstance.SDKName isEqualToString:kCountlySDKName] && [CountlyCommon.sharedInstance.SDKVersion isEqualToString:kCountlySDKVersion]) { CLY_LOG_I(@"Initializing with %@ SDK v%@ on %@ with %@ %@", @@ -129,33 +137,33 @@ - (void)startWithConfig:(CountlyConfig *)config kCountlySDKName, kCountlySDKVersion); } - + 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; CountlyConnectionManager.sharedInstance.alwaysUsePOST = config.alwaysUsePOST; CountlyConnectionManager.sharedInstance.pinnedCertificates = config.pinnedCertificates; CountlyConnectionManager.sharedInstance.secretSalt = config.secretSalt; CountlyConnectionManager.sharedInstance.URLSessionConfiguration = config.URLSessionConfiguration; - + CountlyPersistency.sharedInstance.eventSendThreshold = config.eventSendThreshold; CountlyPersistency.sharedInstance.requestDropAgeHours = config.requestDropAgeHours; CountlyPersistency.sharedInstance.storedRequestsLimit = MAX(1, config.storedRequestsLimit); - + CountlyCommon.sharedInstance.manualSessionHandling = config.manualSessionHandling; CountlyCommon.sharedInstance.enableManualSessionControlHybridMode = config.enableManualSessionControlHybridMode; - + CountlyCommon.sharedInstance.attributionID = config.attributionID; - + NSDictionary* customMetricsTruncated = [config.customMetrics cly_truncated:@"Custom metric"]; CountlyDeviceInfo.sharedInstance.customMetrics = [customMetricsTruncated cly_limited:@"Custom metric"]; - + [Countly.user save]; CountlyCommon.sharedInstance.enableServerConfiguration = config.enableServerConfiguration; @@ -165,14 +173,15 @@ - (void)startWithConfig:(CountlyConfig *)config { [CountlyServerConfig.sharedInstance fetchServerConfig]; } - + #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]; - + CountlyFeedbacksInternal.sharedInstance.message = config.starRatingMessage; + CountlyFeedbacksInternal.sharedInstance.sessionCount = config.starRatingSessionCount; + CountlyFeedbacksInternal.sharedInstance.disableAskingForEachAppVersion = config.starRatingDisableAskingForEachAppVersion; + CountlyFeedbacksInternal.sharedInstance.ratingCompletionForAutoAsk = config.starRatingCompletion; + [CountlyFeedbacksInternal.sharedInstance checkForStarRatingAutoAsk]; +#endif + if(config.disableLocation) { [CountlyLocationManager.sharedInstance disableLocation]; @@ -181,19 +190,15 @@ - (void)startWithConfig:(CountlyConfig *)config { [CountlyLocationManager.sharedInstance updateLocation:config.location city:config.city ISOCountryCode:config.ISOCountryCode IP:config.IP]; } -#endif - + if (!CountlyCommon.sharedInstance.manualSessionHandling) [CountlyConnectionManager.sharedInstance beginSession]; - + else + [CountlyCommon.sharedInstance recordOrientation]; + //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) +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_OSX ) #ifndef COUNTLY_EXCLUDE_PUSHNOTIFICATIONS if ([config.features containsObject:CLYPushNotifications]) { @@ -206,7 +211,11 @@ - (void)startWithConfig:(CountlyConfig *)config } #endif #endif - + + if(config.crashes.crashFilterCallback) { + [CountlyCrashReporter.sharedInstance setCrashFilterCallback:config.crashes.crashFilterCallback]; + } + CountlyCrashReporter.sharedInstance.crashSegmentation = config.crashSegmentation; CountlyCrashReporter.sharedInstance.crashLogLimit = config.sdkInternalLimits.getMaxBreadcrumbCount; // For backward compatibility, deprecated values are only set incase new values are not provided using sdkInternalLimits interface @@ -225,7 +234,7 @@ - (void)startWithConfig:(CountlyConfig *)config [CountlyCrashReporter.sharedInstance startCrashReporting]; } -#if (TARGET_OS_IOS || TARGET_OS_TV) +#if (TARGET_OS_IOS || TARGET_OS_TV ) if (config.enableAutomaticViewTracking || [config.features containsObject:CLYAutoViewTracking]) { // Print deprecation flag for feature @@ -236,12 +245,19 @@ - (void)startWithConfig:(CountlyConfig *)config [CountlyViewTrackingInternal.sharedInstance addAutoViewTrackingExclutionList:config.automaticViewTrackingExclusionList]; } #endif + + if(config.experimental.enablePreviousNameRecording) { + CountlyViewTrackingInternal.sharedInstance.enablePreviousNameRecording = YES; + } + if(config.experimental.enableVisibiltyTracking) { + CountlyCommon.sharedInstance.enableVisibiltyTracking = YES; + } if (config.globalViewSegmentation) { [CountlyViewTrackingInternal.sharedInstance setGlobalViewSegmentation:config.globalViewSegmentation]; } timer = [NSTimer timerWithTimeInterval:config.updateSessionPeriod target:self selector:@selector(onTimer:) userInfo:nil repeats:YES]; [NSRunLoop.mainRunLoop addTimer:timer forMode:NSRunLoopCommonModes]; - + CountlyRemoteConfigInternal.sharedInstance.isRCAutomaticTriggersEnabled = config.enableRemoteConfigAutomaticTriggers || config.enableRemoteConfig; CountlyRemoteConfigInternal.sharedInstance.isRCValueCachingEnabled = config.enableRemoteConfigValueCaching; CountlyRemoteConfigInternal.sharedInstance.remoteConfigCompletionHandler = config.remoteConfigCompletionHandler; @@ -255,23 +271,45 @@ - (void)startWithConfig:(CountlyConfig *)config if (config.apm.getAppStartTimestampOverride) { appLoadStartTime = config.apm.getAppStartTimestampOverride; } +#if (TARGET_OS_IOS) + if(config.content.getGlobalContentCallback) { + CountlyContentBuilderInternal.sharedInstance.contentCallback = config.content.getGlobalContentCallback; + } +#endif [CountlyPerformanceMonitoring.sharedInstance startWithConfig:config.apm]; - + CountlyCommon.sharedInstance.enableOrientationTracking = config.enableOrientationTracking; [CountlyCommon.sharedInstance observeDeviceOrientationChanges]; - + [CountlyConnectionManager.sharedInstance proceedOnQueue]; - + //TODO: Should move at the top after checking the the edge cases of current implementation if (config.enableAllConsents) [self giveAllConsents]; else if (config.consents) [self giveConsentForFeatures:config.consents]; - + else if (config.requiresConsent) + [CountlyConsentManager.sharedInstance sendConsents]; + + if (!CountlyConsentManager.sharedInstance.consentForSessions) + { + //Send an empty location if location is disabled or location consent is not given, without checking for location consent. + if (!CountlyConsentManager.sharedInstance.consentForLocation || CountlyLocationManager.sharedInstance.isLocationInfoDisabled) + { + [CountlyConnectionManager.sharedInstance sendLocationInfo]; + } + else + { + [CountlyLocationManager.sharedInstance sendLocationInfo]; + } + [CountlyConnectionManager.sharedInstance sendAttribution]; + } + + if (config.campaignType && config.campaignData) [self recordDirectAttributionWithCampaignType:config.campaignType andCampaignData:config.campaignData]; - + if (config.indirectAttribution) [self recordIndirectAttribution:config.indirectAttribution]; } @@ -332,7 +370,7 @@ - (void)onTimer:(NSTimer *)timer { if (isSuspended) return; - + if (!CountlyCommon.sharedInstance.manualSessionHandling) { [CountlyConnectionManager.sharedInstance updateSession]; @@ -342,8 +380,8 @@ - (void)onTimer:(NSTimer *)timer { [CountlyConnectionManager.sharedInstance updateSession]; } - - [CountlyConnectionManager.sharedInstance sendEvents]; + + [CountlyConnectionManager.sharedInstance sendEventsWithSaveIfNeeded]; } - (void)suspend @@ -351,24 +389,24 @@ - (void)suspend #if (TARGET_OS_WATCH) CLY_LOG_I(@"%s", __FUNCTION__); #endif - + if (!CountlyCommon.sharedInstance.hasStarted) return; - + if (isSuspended) return; - + CLY_LOG_D(@"Suspending..."); - + isSuspended = YES; - - [CountlyConnectionManager.sharedInstance sendEvents]; - + + [CountlyConnectionManager.sharedInstance sendEventsWithSaveIfNeeded]; + if (!CountlyCommon.sharedInstance.manualSessionHandling) [CountlyConnectionManager.sharedInstance endSession]; - + [CountlyViewTrackingInternal.sharedInstance applicationDidEnterBackground]; - + [CountlyPersistency.sharedInstance saveToFile]; } @@ -377,29 +415,40 @@ - (void)resume #if (TARGET_OS_WATCH) CLY_LOG_I(@"%s", __FUNCTION__); #endif - + 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]; - + [CountlyViewTrackingInternal.sharedInstance applicationWillEnterForeground]; - + isSuspended = NO; } +- (void)applicationDidBecomeActive:(NSNotification *)notification +{ + CLY_LOG_D(@"App enters foreground"); + [self resume]; +} + +- (void)applicationWillResignActive:(NSNotification *)notification +{ + CLY_LOG_D(@"App enters background"); +} + - (void)applicationDidEnterBackground:(NSNotification *)notification { CLY_LOG_D(@"App did enter background."); @@ -409,28 +458,28 @@ - (void)applicationDidEnterBackground:(NSNotification *)notification - (void)applicationWillEnterForeground:(NSNotification *)notification { CLY_LOG_D(@"App will enter foreground."); - [self resume]; } - (void)applicationWillTerminate:(NSNotification *)notification { CLY_LOG_D(@"App will terminate."); - + CountlyConnectionManager.sharedInstance.isTerminating = YES; - + [CountlyViewTrackingInternal.sharedInstance applicationWillTerminate]; - - [CountlyConnectionManager.sharedInstance sendEvents]; - + + [CountlyConnectionManager.sharedInstance sendEventsWithSaveIfNeeded]; + [CountlyPerformanceMonitoring.sharedInstance endBackgroundTrace]; - + [CountlyPersistency.sharedInstance saveToFileSync]; } + - (void)dealloc { [NSNotificationCenter.defaultCenter removeObserver:self]; - + if (timer) { [timer invalidate]; @@ -444,20 +493,20 @@ - (void)dealloc - (void)setNewHost:(NSString *)newHost { CLY_LOG_I(@"%s %@", __FUNCTION__, newHost); - + if (!newHost.length) { CLY_LOG_W(@"New host is invalid!"); return; } - + CountlyConnectionManager.sharedInstance.host = newHost; } - (void)setNewURLSessionConfiguration:(NSURLSessionConfiguration *)newURLSessionConfiguration { CLY_LOG_I(@"%s %@", __FUNCTION__, newURLSessionConfiguration); - + CountlyConnectionManager.sharedInstance.URLSessionConfiguration = newURLSessionConfiguration; } @@ -470,13 +519,13 @@ - (void)setNewAppKey:(NSString *)newAppKey CLY_LOG_W(@"New app key is invalid!"); return; } - + [self suspend]; - + [CountlyPerformanceMonitoring.sharedInstance clearAllCustomTraces]; - + CountlyConnectionManager.sharedInstance.appKey = newAppKey; - + [self resume]; } @@ -487,7 +536,7 @@ - (void)setNewAppKey:(NSString *)newAppKey - (void)flushQueues { CLY_LOG_I(@"%s", __FUNCTION__); - + [CountlyPersistency.sharedInstance flushEvents]; [CountlyPersistency.sharedInstance flushQueue]; } @@ -495,21 +544,21 @@ - (void)flushQueues - (void)replaceAllAppKeysInQueueWithCurrentAppKey { CLY_LOG_I(@"%s", __FUNCTION__); - + [CountlyPersistency.sharedInstance replaceAllAppKeysInQueueWithCurrentAppKey]; } - (void)removeDifferentAppKeysFromQueue { CLY_LOG_I(@"%s", __FUNCTION__); - + [CountlyPersistency.sharedInstance removeDifferentAppKeysFromQueue]; } - (void)addDirectRequest:(NSDictionary * _Nullable)requestParameters { CLY_LOG_I(@"%s %@", __FUNCTION__, requestParameters); - + [CountlyConnectionManager.sharedInstance addDirectRequest:requestParameters]; } @@ -520,7 +569,7 @@ - (void)addDirectRequest:(NSDictionary * _Nullable)reque - (void)beginSession { CLY_LOG_I(@"%s", __FUNCTION__); - + if (CountlyCommon.sharedInstance.manualSessionHandling) [CountlyConnectionManager.sharedInstance beginSession]; } @@ -528,7 +577,7 @@ - (void)beginSession - (void)updateSession { CLY_LOG_I(@"%s", __FUNCTION__); - + if (CountlyCommon.sharedInstance.manualSessionHandling) [CountlyConnectionManager.sharedInstance updateSession]; } @@ -536,9 +585,12 @@ - (void)updateSession - (void)endSession { CLY_LOG_I(@"%s", __FUNCTION__); - + if (CountlyCommon.sharedInstance.manualSessionHandling) + { + [CountlyConnectionManager.sharedInstance sendEventsWithSaveIfNeeded]; [CountlyConnectionManager.sharedInstance endSession]; + } } @@ -549,36 +601,65 @@ - (void)endSession - (NSString *)deviceID { CLY_LOG_I(@"%s", __FUNCTION__); - + return CountlyDeviceInfo.sharedInstance.deviceID.cly_URLEscaped; } - (CLYDeviceIDType)deviceIDType { CLY_LOG_I(@"%s", __FUNCTION__); - + if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) return CLYDeviceIDTypeTemporary; - + if ([CountlyPersistency.sharedInstance retrieveIsCustomDeviceID]) return CLYDeviceIDTypeCustom; -#if (TARGET_OS_IOS || TARGET_OS_TV) +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV ) return CLYDeviceIDTypeIDFV; #else return CLYDeviceIDTypeNSUUID; #endif } +- (void)setID:(NSString *)deviceID; +{ + if (deviceID == nil || !deviceID.length) + { + CLY_LOG_W(@"Passing `nil` or empty string as devie ID is not allowed."); + return; + } + + CLYDeviceIDType deviceIDType = [Countly.sharedInstance deviceIDType]; + if([deviceIDType isEqualToString:CLYDeviceIDTypeCustom]) + { + [Countly.sharedInstance setIDInternal:deviceID onServer: NO]; + } + else + { + [Countly.sharedInstance setIDInternal:deviceID onServer: YES]; + } +} + - (void)changeDeviceIDWithMerge:(NSString * _Nullable)deviceID { - [self setNewDeviceID:deviceID onServer:YES]; + [self setIDInternal:deviceID onServer:YES]; } - (void)changeDeviceIDWithoutMerge:(NSString * _Nullable)deviceID { - [self setNewDeviceID:deviceID onServer:NO]; + [self setIDInternal:deviceID onServer:NO]; +} + +- (void)enableTemporaryDeviceIDMode +{ + [Countly.sharedInstance setIDInternal:CLYTemporaryDeviceID onServer:NO]; } - (void)setNewDeviceID:(NSString *)deviceID onServer:(BOOL)onServer +{ + [Countly.sharedInstance setIDInternal:deviceID onServer:onServer]; +} + +- (void)setIDInternal:(NSString *)deviceID onServer:(BOOL)onServer { CLY_LOG_I(@"%s %@ %d", __FUNCTION__, deviceID, onServer); @@ -823,15 +904,22 @@ - (void)recordEvent:(NSString *)key segmentation:(NSDictionary *)segmentation co // Check if the event is a reserved event BOOL isReservedEvent = [self isReservedEvent:key]; - // If the event is not reserved, assign the previous event ID to the current event's PEID property, or an empty string if previousEventID is nil. Then, update previousEventID to the current event's ID. + NSMutableDictionary *filteredSegmentations = segmentation.cly_filterSupportedDataTypes; + + // If the event is not reserved, assign the previous event ID and Name to the current event's PEID property, or an empty string if previousEventID is nil. Then, update previousEventID to the current event's ID. if (!isReservedEvent) { key = [key cly_truncatedKey:@"Event key"]; event.PEID = previousEventID ?: @""; previousEventID = event.ID; + if(CountlyViewTrackingInternal.sharedInstance.enablePreviousNameRecording) { + filteredSegmentations[kCountlyPreviousEventName] = previousEventName ?: @""; + previousEventName = key; + filteredSegmentations[kCountlyCurrentView] = CountlyViewTrackingInternal.sharedInstance.currentViewName ?: @""; + } } event.key = key; - event.segmentation = segmentation; + event.segmentation = [self processSegmentation:filteredSegmentations eventKey:key]; event.count = MAX(count, 1); event.sum = sum; event.timestamp = timestamp; @@ -842,6 +930,36 @@ - (void)recordEvent:(NSString *)key segmentation:(NSDictionary *)segmentation co [CountlyPersistency.sharedInstance recordEvent:event]; } +- (NSDictionary*) processSegmentation:(NSMutableDictionary *) segmentation eventKey:(NSString *)eventKey +{ + if(CountlyViewTrackingInternal.sharedInstance.enablePreviousNameRecording) { + if([eventKey isEqualToString:kCountlyReservedEventView]) { + segmentation[kCountlyPreviousView] = CountlyViewTrackingInternal.sharedInstance.previousViewName ?: @""; + } + } + + if(CountlyCommon.sharedInstance.enableVisibiltyTracking) { + segmentation[kCountlyVisibility] = @([self isAppInForeground]); + } + return segmentation; +} + +- (BOOL)isAppInForeground { +#if TARGET_OS_IOS || TARGET_OS_TV + UIApplicationState state = [UIApplication sharedApplication].applicationState; + return state == UIApplicationStateActive; +#elif TARGET_OS_OSX + NSApplication *app = [NSApplication sharedApplication]; + return app.isActive; +#elif TARGET_OS_WATCH + WKExtension *extension = [WKExtension sharedExtension]; + return extension.applicationState == WKApplicationStateActive; +#else + return NO; +#endif +} + + - (BOOL)isReservedEvent:(NSString *)key { NSArray* reservedEvents = @@ -917,7 +1035,7 @@ - (void)cancelEvent:(NSString *)key #pragma mark - Push Notifications -#if (TARGET_OS_IOS || TARGET_OS_OSX) +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_OSX ) #ifndef COUNTLY_EXCLUDE_PUSHNOTIFICATIONS - (void)askForNotificationPermission @@ -993,7 +1111,7 @@ - (void)recordException:(NSException *)exception isFatal:(BOOL)isFatal [CountlyCrashReporter.sharedInstance recordException:exception isFatal:isFatal stackTrace:nil segmentation:nil]; } -- (void)recordException:(NSException *)exception isFatal:(BOOL)isFatal stackTrace:(NSArray *)stackTrace segmentation:(NSDictionary *)segmentation +- (void)recordException:(NSException *)exception isFatal:(BOOL)isFatal stackTrace:(NSArray *)stackTrace segmentation:(NSDictionary *)segmentation { CLY_LOG_I(@"%s %@ %d %@ %@", __FUNCTION__, exception, isFatal, stackTrace, segmentation); @@ -1007,7 +1125,7 @@ - (void)recordError:(NSString *)errorName stackTrace:(NSArray * _Nullable)stackT [CountlyCrashReporter.sharedInstance recordError:errorName isFatal:NO stackTrace:stackTrace segmentation:nil]; } -- (void)recordError:(NSString *)errorName isFatal:(BOOL)isFatal stackTrace:(NSArray * _Nullable)stackTrace segmentation:(NSDictionary * _Nullable)segmentation +- (void)recordError:(NSString *)errorName isFatal:(BOOL)isFatal stackTrace:(NSArray * _Nullable)stackTrace segmentation:(NSDictionary *)segmentation { CLY_LOG_I(@"%s %@ %d %@ %@", __FUNCTION__, errorName, isFatal, stackTrace, segmentation); @@ -1070,7 +1188,7 @@ - (void)recordView:(NSString *)viewName segmentation:(NSDictionary *)segmentatio [CountlyViewTrackingInternal.sharedInstance startAutoStoppedView:viewName segmentation:segmentation]; } -#if (TARGET_OS_IOS || TARGET_OS_TV) +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV ) - (void)addExceptionForAutoViewTracking:(NSString *)exception { CLY_LOG_I(@"%s %@", __FUNCTION__, exception); @@ -1122,7 +1240,7 @@ - (void)askForStarRating:(void(^)(NSInteger rating))completion { CLY_LOG_I(@"%s %@", __FUNCTION__, completion); - [CountlyFeedbacks.sharedInstance showDialog:completion]; + [CountlyFeedbacksInternal.sharedInstance showDialog:completion]; } - (void)presentFeedbackWidgetWithID:(NSString *)widgetID completionHandler:(void (^)(NSError * error))completionHandler @@ -1144,21 +1262,31 @@ - (void)presentRatingWidgetWithID:(NSString *)widgetID closeButtonText:(NSString CLY_LOG_I(@"%s %@ %@ %@", __FUNCTION__, widgetID, closeButtonText, completionHandler); - [CountlyFeedbacks.sharedInstance presentRatingWidgetWithID:widgetID closeButtonText:closeButtonText completionHandler:completionHandler]; + [CountlyFeedbacksInternal.sharedInstance presentRatingWidgetWithID:widgetID closeButtonText:closeButtonText completionHandler:completionHandler]; } - (void)recordRatingWidgetWithID:(NSString *)widgetID rating:(NSInteger)rating email:(NSString * _Nullable)email comment:(NSString * _Nullable)comment userCanBeContacted:(BOOL)userCanBeContacted { CLY_LOG_I(@"%s %@ %ld %@ %@ %d", __FUNCTION__, widgetID, (long)rating, email, comment, userCanBeContacted); - [CountlyFeedbacks.sharedInstance recordRatingWidgetWithID:widgetID rating:rating email:email comment:comment userCanBeContacted:userCanBeContacted]; + [CountlyFeedbacksInternal.sharedInstance recordRatingWidgetWithID:widgetID rating:rating email:email comment:comment userCanBeContacted:userCanBeContacted]; } - (void)getFeedbackWidgets:(void (^)(NSArray *feedbackWidgets, NSError * error))completionHandler { CLY_LOG_I(@"%s %@", __FUNCTION__, completionHandler); - [CountlyFeedbacks.sharedInstance getFeedbackWidgets:completionHandler]; + [CountlyFeedbacksInternal.sharedInstance getFeedbackWidgets:completionHandler]; +} + +- (CountlyContentBuilder *) content +{ + return CountlyContentBuilder.sharedInstance; +} + +- (CountlyFeedbacks *) feedback +{ + return CountlyFeedbacks.sharedInstance; } #endif @@ -1343,8 +1471,10 @@ - (void)halt - (void)halt:(BOOL) clearStorage { CLY_LOG_I(@"%s %d", __FUNCTION__, clearStorage); + [CountlyConsentManager.sharedInstance resetInstance]; [CountlyPersistency.sharedInstance resetInstance:clearStorage]; [CountlyDeviceInfo.sharedInstance resetInstance]; + [CountlyConnectionManager.sharedInstance resetInstance]; [self resetInstance]; [CountlyCommon.sharedInstance resetInstance]; diff --git a/ios/src/Countly.podspec b/ios/src/Countly.podspec index bd9efb1d..e914373a 100644 --- a/ios/src/Countly.podspec +++ b/ios/src/Countly.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'Countly' - s.version = '24.4.1' + s.version = '24.7.4' s.license = { :type => 'MIT', :file => 'LICENSE' } s.summary = 'Countly is an innovative, real-time, open source mobile analytics platform.' s.homepage = 'https://github.com/Countly/countly-sdk-ios' @@ -17,7 +17,7 @@ Pod::Spec.new do |s| s.subspec 'Core' do |core| core.source_files = '*.{h,m}' - core.public_header_files = 'Countly.h', 'CountlyUserDetails.h', 'CountlyConfig.h', 'CountlyFeedbackWidget.h', 'CountlyRCData.h', 'CountlyRemoteConfig.h', 'CountlyViewTracking.h', 'CountlyExperimentInformation.h', 'CountlyAPMConfig.h', 'CountlySDKLimitsConfig.h', 'Resettable.h' + core.public_header_files = 'Countly.h', 'CountlyUserDetails.h', 'CountlyConfig.h', 'CountlyFeedbackWidget.h', 'CountlyRCData.h', 'CountlyRemoteConfig.h', 'CountlyViewTracking.h', 'CountlyExperimentInformation.h', 'CountlyAPMConfig.h', 'CountlySDKLimitsConfig.h', 'Resettable.h', "CountlyCrashesConfig.h", "CountlyCrashData.h", "CountlyContentBuilder.h", "CountlyExperimentalConfig.h", "CountlyContentConfig.h", "CountlyFeedbacks.h" core.preserve_path = 'countly_dsym_uploader.sh' core.ios.frameworks = ['Foundation', 'UIKit', 'UserNotifications', 'CoreLocation', 'WebKit', 'CoreTelephony', 'WatchConnectivity'] end diff --git a/ios/src/Countly.xcodeproj/project.pbxproj b/ios/src/Countly.xcodeproj/project.pbxproj index c7cddef1..d90ee9c7 100644 --- a/ios/src/Countly.xcodeproj/project.pbxproj +++ b/ios/src/Countly.xcodeproj/project.pbxproj @@ -23,11 +23,34 @@ 1ACA5DC12A309E7F001F770B /* CountlyRemoteConfigInternal.h in Headers */ = {isa = PBXBuildFile; fileRef = 1ACA5DBF2A309E7F001F770B /* CountlyRemoteConfigInternal.h */; }; 1ACA5DC22A309E7F001F770B /* CountlyRemoteConfigInternal.m in Sources */ = {isa = PBXBuildFile; fileRef = 1ACA5DC02A309E7F001F770B /* CountlyRemoteConfigInternal.m */; }; 1AFD79022B3EF82C00772FBD /* CountlyTests-Bridging-Header.h in Headers */ = {isa = PBXBuildFile; fileRef = 1AFD79012B3EF82C00772FBD /* CountlyTests-Bridging-Header.h */; }; + 39002D0B2C8B2E450049394F /* CountlyContentConfig.h in Headers */ = {isa = PBXBuildFile; fileRef = 39002D092C8B2E450049394F /* CountlyContentConfig.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 39002D0C2C8B2E450049394F /* CountlyContentConfig.m in Sources */ = {isa = PBXBuildFile; fileRef = 39002D0A2C8B2E450049394F /* CountlyContentConfig.m */; }; + 3903429D2C8051C700238C96 /* CountlyExperimentalConfig.m in Sources */ = {isa = PBXBuildFile; fileRef = 3903429C2C8051C700238C96 /* CountlyExperimentalConfig.m */; }; 3948A8572BAC2E7D002D09AA /* CountlySDKLimitsConfig.m in Sources */ = {isa = PBXBuildFile; fileRef = 3948A8552BAC2E7D002D09AA /* CountlySDKLimitsConfig.m */; }; 3948A8582BAC2E7D002D09AA /* CountlySDKLimitsConfig.h in Headers */ = {isa = PBXBuildFile; fileRef = 3948A8562BAC2E7D002D09AA /* CountlySDKLimitsConfig.h */; settings = {ATTRIBUTES = (Public, ); }; }; 39527E152B5FD27400EE5D7B /* CountlyAPMConfig.m in Sources */ = {isa = PBXBuildFile; fileRef = 39527E142B5FD27400EE5D7B /* CountlyAPMConfig.m */; }; 39527E182B5FD54C00EE5D7B /* CountlyAPMConfig.h in Headers */ = {isa = PBXBuildFile; fileRef = 39527E162B5FD28900EE5D7B /* CountlyAPMConfig.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 3961C6B52C6633C000DD38BA /* CountlyWebViewManager.h in Headers */ = {isa = PBXBuildFile; fileRef = 3961C6AF2C6633C000DD38BA /* CountlyWebViewManager.h */; }; + 3961C6B72C6633C000DD38BA /* PassThroughBackgroundView.h in Headers */ = {isa = PBXBuildFile; fileRef = 3961C6B12C6633C000DD38BA /* PassThroughBackgroundView.h */; }; + 3961C6B92C6633C000DD38BA /* PassThroughBackgroundView.m in Sources */ = {isa = PBXBuildFile; fileRef = 3961C6B32C6633C000DD38BA /* PassThroughBackgroundView.m */; }; + 3961C6BA2C6633C000DD38BA /* CountlyWebViewManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 3961C6B42C6633C000DD38BA /* CountlyWebViewManager.m */; }; + 3964A3E72C2AF8E90091E677 /* CountlySegmentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3964A3E62C2AF8E90091E677 /* CountlySegmentationTests.swift */; }; + 3966DBCF2C11EE270002ED97 /* CountlyDeviceIDTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3966DBCE2C11EE270002ED97 /* CountlyDeviceIDTests.swift */; }; + 3972EDDB2C08A38D00EB9D3E /* CountlyEventStruct.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3972EDDA2C08A38D00EB9D3E /* CountlyEventStruct.swift */; }; + 3979E47D2C0760E900FA1CA4 /* CountlyUserProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3979E47C2C0760E900FA1CA4 /* CountlyUserProfileTests.swift */; }; + 399117D12C69F73D00DC4C66 /* CountlyContentBuilder.m in Sources */ = {isa = PBXBuildFile; fileRef = 399117CD2C69F73D00DC4C66 /* CountlyContentBuilder.m */; }; + 399117D22C69F73D00DC4C66 /* CountlyContentBuilderInternal.h in Headers */ = {isa = PBXBuildFile; fileRef = 399117CE2C69F73D00DC4C66 /* CountlyContentBuilderInternal.h */; }; + 399117D32C69F73D00DC4C66 /* CountlyContentBuilderInternal.m in Sources */ = {isa = PBXBuildFile; fileRef = 399117CF2C69F73D00DC4C66 /* CountlyContentBuilderInternal.m */; }; + 399117D42C69F73D00DC4C66 /* CountlyContentBuilder.h in Headers */ = {isa = PBXBuildFile; fileRef = 399117D02C69F73D00DC4C66 /* CountlyContentBuilder.h */; settings = {ATTRIBUTES = (Public, ); }; }; 39911B672B457DBB00AC053C /* Resettable.h in Headers */ = {isa = PBXBuildFile; fileRef = 39911B662B457DB500AC053C /* Resettable.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 39924ECE2BEBD0B700139F91 /* CountlyCrashesConfig.h in Headers */ = {isa = PBXBuildFile; fileRef = 39924ECD2BEBD0B700139F91 /* CountlyCrashesConfig.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 39924ED02BEBD0D400139F91 /* CountlyCrashesConfig.m in Sources */ = {isa = PBXBuildFile; fileRef = 39924ECF2BEBD0D400139F91 /* CountlyCrashesConfig.m */; }; + 39924ED62BEBD20F00139F91 /* CountlyCrashData.m in Sources */ = {isa = PBXBuildFile; fileRef = 39924ED52BEBD20F00139F91 /* CountlyCrashData.m */; }; + 39924ED82BEBD22100139F91 /* CountlyCrashData.h in Headers */ = {isa = PBXBuildFile; fileRef = 39924ED72BEBD22100139F91 /* CountlyCrashData.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 399B46502C52813700AD384E /* CountlyLocationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 399B464F2C52813700AD384E /* CountlyLocationTests.swift */; }; + 39BDF7572CC7CA870066DE7C /* CountlyFeedbacks.h in Headers */ = {isa = PBXBuildFile; fileRef = 39BDF7562CC7CA870066DE7C /* CountlyFeedbacks.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 39BDF7592CC7CA920066DE7C /* CountlyFeedbacks.m in Sources */ = {isa = PBXBuildFile; fileRef = 39BDF7582CC7CA920066DE7C /* CountlyFeedbacks.m */; }; + 39EE1F102C8B341E0016D1BF /* CountlyExperimentalConfig.h in Headers */ = {isa = PBXBuildFile; fileRef = 3903429B2C8051B400238C96 /* CountlyExperimentalConfig.h */; settings = {ATTRIBUTES = (Public, ); }; }; 3B20A9872245225A00E3D7AE /* Countly.h in Headers */ = {isa = PBXBuildFile; fileRef = 3B20A9852245225A00E3D7AE /* Countly.h */; settings = {ATTRIBUTES = (Public, ); }; }; 3B20A9B22245228700E3D7AE /* CountlyConnectionManager.h in Headers */ = {isa = PBXBuildFile; fileRef = 3B20A98D2245228300E3D7AE /* CountlyConnectionManager.h */; }; 3B20A9B32245228700E3D7AE /* CountlyNotificationService.m in Sources */ = {isa = PBXBuildFile; fileRef = 3B20A98E2245228300E3D7AE /* CountlyNotificationService.m */; }; @@ -59,12 +82,13 @@ 3B20A9D42245228700E3D7AE /* CountlyPersistency.m in Sources */ = {isa = PBXBuildFile; fileRef = 3B20A9AF2245228600E3D7AE /* CountlyPersistency.m */; }; 3B20A9D62245228700E3D7AE /* CountlyConsentManager.h in Headers */ = {isa = PBXBuildFile; fileRef = 3B20A9B12245228700E3D7AE /* CountlyConsentManager.h */; }; 968426812BF2302C007B303E /* CountlyConnectionManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 968426802BF2302C007B303E /* CountlyConnectionManagerTests.swift */; }; + 96E680422BFF89AC0091E105 /* CountlyCrashReporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E680412BFF89AC0091E105 /* CountlyCrashReporterTests.swift */; }; D219374B248AC71C00E5798B /* CountlyPerformanceMonitoring.h in Headers */ = {isa = PBXBuildFile; fileRef = D2193749248AC71C00E5798B /* CountlyPerformanceMonitoring.h */; }; D219374C248AC71C00E5798B /* CountlyPerformanceMonitoring.m in Sources */ = {isa = PBXBuildFile; fileRef = D219374A248AC71C00E5798B /* CountlyPerformanceMonitoring.m */; }; D249BF5E254D3D180058A6C2 /* CountlyFeedbackWidget.h in Headers */ = {isa = PBXBuildFile; fileRef = D249BF5C254D3D170058A6C2 /* CountlyFeedbackWidget.h */; settings = {ATTRIBUTES = (Public, ); }; }; D249BF5F254D3D180058A6C2 /* CountlyFeedbackWidget.m in Sources */ = {isa = PBXBuildFile; fileRef = D249BF5D254D3D180058A6C2 /* CountlyFeedbackWidget.m */; }; - D2CFEF972545FBE80026B044 /* CountlyFeedbacks.h in Headers */ = {isa = PBXBuildFile; fileRef = D2CFEF952545FBE80026B044 /* CountlyFeedbacks.h */; }; - D2CFEF982545FBE80026B044 /* CountlyFeedbacks.m in Sources */ = {isa = PBXBuildFile; fileRef = D2CFEF962545FBE80026B044 /* CountlyFeedbacks.m */; }; + D2CFEF972545FBE80026B044 /* CountlyFeedbacksInternal.h in Headers */ = {isa = PBXBuildFile; fileRef = D2CFEF952545FBE80026B044 /* CountlyFeedbacksInternal.h */; }; + D2CFEF982545FBE80026B044 /* CountlyFeedbacksInternal.m in Sources */ = {isa = PBXBuildFile; fileRef = D2CFEF962545FBE80026B044 /* CountlyFeedbacksInternal.m */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -94,12 +118,35 @@ 1ACA5DBF2A309E7F001F770B /* CountlyRemoteConfigInternal.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CountlyRemoteConfigInternal.h; sourceTree = ""; }; 1ACA5DC02A309E7F001F770B /* CountlyRemoteConfigInternal.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CountlyRemoteConfigInternal.m; sourceTree = ""; }; 1AFD79012B3EF82C00772FBD /* CountlyTests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "CountlyTests-Bridging-Header.h"; sourceTree = ""; }; + 39002D092C8B2E450049394F /* CountlyContentConfig.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CountlyContentConfig.h; sourceTree = ""; }; + 39002D0A2C8B2E450049394F /* CountlyContentConfig.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CountlyContentConfig.m; sourceTree = ""; }; + 3903429B2C8051B400238C96 /* CountlyExperimentalConfig.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CountlyExperimentalConfig.h; sourceTree = ""; }; + 3903429C2C8051C700238C96 /* CountlyExperimentalConfig.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CountlyExperimentalConfig.m; sourceTree = ""; }; 3948A8552BAC2E7D002D09AA /* CountlySDKLimitsConfig.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CountlySDKLimitsConfig.m; sourceTree = ""; }; 3948A8562BAC2E7D002D09AA /* CountlySDKLimitsConfig.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CountlySDKLimitsConfig.h; sourceTree = ""; }; 39527E142B5FD27400EE5D7B /* CountlyAPMConfig.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CountlyAPMConfig.m; sourceTree = ""; }; 39527E162B5FD28900EE5D7B /* CountlyAPMConfig.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CountlyAPMConfig.h; sourceTree = ""; }; 395683372BB2988300C7A06B /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + 3961C6AF2C6633C000DD38BA /* CountlyWebViewManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CountlyWebViewManager.h; sourceTree = ""; }; + 3961C6B12C6633C000DD38BA /* PassThroughBackgroundView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = PassThroughBackgroundView.h; sourceTree = ""; }; + 3961C6B32C6633C000DD38BA /* PassThroughBackgroundView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PassThroughBackgroundView.m; sourceTree = ""; }; + 3961C6B42C6633C000DD38BA /* CountlyWebViewManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CountlyWebViewManager.m; sourceTree = ""; }; + 3964A3E62C2AF8E90091E677 /* CountlySegmentationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlySegmentationTests.swift; sourceTree = ""; }; + 3966DBCE2C11EE270002ED97 /* CountlyDeviceIDTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyDeviceIDTests.swift; sourceTree = ""; }; + 3972EDDA2C08A38D00EB9D3E /* CountlyEventStruct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyEventStruct.swift; sourceTree = ""; }; + 3979E47C2C0760E900FA1CA4 /* CountlyUserProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyUserProfileTests.swift; sourceTree = ""; }; + 399117CD2C69F73D00DC4C66 /* CountlyContentBuilder.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CountlyContentBuilder.m; sourceTree = ""; }; + 399117CE2C69F73D00DC4C66 /* CountlyContentBuilderInternal.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CountlyContentBuilderInternal.h; sourceTree = ""; }; + 399117CF2C69F73D00DC4C66 /* CountlyContentBuilderInternal.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CountlyContentBuilderInternal.m; sourceTree = ""; }; + 399117D02C69F73D00DC4C66 /* CountlyContentBuilder.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CountlyContentBuilder.h; sourceTree = ""; }; 39911B662B457DB500AC053C /* Resettable.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Resettable.h; sourceTree = ""; }; + 39924ECD2BEBD0B700139F91 /* CountlyCrashesConfig.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CountlyCrashesConfig.h; sourceTree = ""; }; + 39924ECF2BEBD0D400139F91 /* CountlyCrashesConfig.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CountlyCrashesConfig.m; sourceTree = ""; }; + 39924ED52BEBD20F00139F91 /* CountlyCrashData.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CountlyCrashData.m; sourceTree = ""; }; + 39924ED72BEBD22100139F91 /* CountlyCrashData.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CountlyCrashData.h; sourceTree = ""; }; + 399B464F2C52813700AD384E /* CountlyLocationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyLocationTests.swift; sourceTree = ""; }; + 39BDF7562CC7CA870066DE7C /* CountlyFeedbacks.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CountlyFeedbacks.h; sourceTree = ""; }; + 39BDF7582CC7CA920066DE7C /* CountlyFeedbacks.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CountlyFeedbacks.m; sourceTree = ""; }; 3B20A9822245225A00E3D7AE /* Countly.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Countly.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B20A9852245225A00E3D7AE /* Countly.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Countly.h; sourceTree = ""; }; 3B20A9862245225A00E3D7AE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -133,12 +180,13 @@ 3B20A9AF2245228600E3D7AE /* CountlyPersistency.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CountlyPersistency.m; sourceTree = ""; }; 3B20A9B12245228700E3D7AE /* CountlyConsentManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CountlyConsentManager.h; sourceTree = ""; }; 968426802BF2302C007B303E /* CountlyConnectionManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyConnectionManagerTests.swift; sourceTree = ""; }; + 96E680412BFF89AC0091E105 /* CountlyCrashReporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyCrashReporterTests.swift; sourceTree = ""; }; D2193749248AC71C00E5798B /* CountlyPerformanceMonitoring.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CountlyPerformanceMonitoring.h; sourceTree = ""; }; D219374A248AC71C00E5798B /* CountlyPerformanceMonitoring.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CountlyPerformanceMonitoring.m; sourceTree = ""; }; D249BF5C254D3D170058A6C2 /* CountlyFeedbackWidget.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CountlyFeedbackWidget.h; sourceTree = ""; }; D249BF5D254D3D180058A6C2 /* CountlyFeedbackWidget.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CountlyFeedbackWidget.m; sourceTree = ""; }; - D2CFEF952545FBE80026B044 /* CountlyFeedbacks.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CountlyFeedbacks.h; sourceTree = ""; }; - D2CFEF962545FBE80026B044 /* CountlyFeedbacks.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CountlyFeedbacks.m; sourceTree = ""; }; + D2CFEF952545FBE80026B044 /* CountlyFeedbacksInternal.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CountlyFeedbacksInternal.h; sourceTree = ""; }; + D2CFEF962545FBE80026B044 /* CountlyFeedbacksInternal.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CountlyFeedbacksInternal.m; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -167,6 +215,12 @@ 1A50D7042B3C5AA3009C6938 /* CountlyBaseTestCase.swift */, 1AFD79012B3EF82C00772FBD /* CountlyTests-Bridging-Header.h */, 968426802BF2302C007B303E /* CountlyConnectionManagerTests.swift */, + 96E680412BFF89AC0091E105 /* CountlyCrashReporterTests.swift */, + 3979E47C2C0760E900FA1CA4 /* CountlyUserProfileTests.swift */, + 3972EDDA2C08A38D00EB9D3E /* CountlyEventStruct.swift */, + 3966DBCE2C11EE270002ED97 /* CountlyDeviceIDTests.swift */, + 3964A3E62C2AF8E90091E677 /* CountlySegmentationTests.swift */, + 399B464F2C52813700AD384E /* CountlyLocationTests.swift */, ); path = CountlyTests; sourceTree = ""; @@ -174,6 +228,24 @@ 3B20A9782245225A00E3D7AE = { isa = PBXGroup; children = ( + 39BDF7562CC7CA870066DE7C /* CountlyFeedbacks.h */, + 39BDF7582CC7CA920066DE7C /* CountlyFeedbacks.m */, + 39002D092C8B2E450049394F /* CountlyContentConfig.h */, + 39002D0A2C8B2E450049394F /* CountlyContentConfig.m */, + 3961C6AF2C6633C000DD38BA /* CountlyWebViewManager.h */, + 3961C6B42C6633C000DD38BA /* CountlyWebViewManager.m */, + 3961C6B12C6633C000DD38BA /* PassThroughBackgroundView.h */, + 3961C6B32C6633C000DD38BA /* PassThroughBackgroundView.m */, + 399117D02C69F73D00DC4C66 /* CountlyContentBuilder.h */, + 399117CD2C69F73D00DC4C66 /* CountlyContentBuilder.m */, + 399117CE2C69F73D00DC4C66 /* CountlyContentBuilderInternal.h */, + 399117CF2C69F73D00DC4C66 /* CountlyContentBuilderInternal.m */, + 3903429B2C8051B400238C96 /* CountlyExperimentalConfig.h */, + 3903429C2C8051C700238C96 /* CountlyExperimentalConfig.m */, + 39924ED72BEBD22100139F91 /* CountlyCrashData.h */, + 39924ED52BEBD20F00139F91 /* CountlyCrashData.m */, + 39924ECD2BEBD0B700139F91 /* CountlyCrashesConfig.h */, + 39924ECF2BEBD0D400139F91 /* CountlyCrashesConfig.m */, 395683372BB2988300C7A06B /* PrivacyInfo.xcprivacy */, 3948A8562BAC2E7D002D09AA /* CountlySDKLimitsConfig.h */, 3948A8552BAC2E7D002D09AA /* CountlySDKLimitsConfig.m */, @@ -208,8 +280,8 @@ 3B20A9AA2245228500E3D7AE /* CountlyDeviceInfo.m */, 3B20A99E2245228400E3D7AE /* CountlyEvent.h */, 3B20A9952245228400E3D7AE /* CountlyEvent.m */, - D2CFEF952545FBE80026B044 /* CountlyFeedbacks.h */, - D2CFEF962545FBE80026B044 /* CountlyFeedbacks.m */, + D2CFEF952545FBE80026B044 /* CountlyFeedbacksInternal.h */, + D2CFEF962545FBE80026B044 /* CountlyFeedbacksInternal.m */, D249BF5C254D3D170058A6C2 /* CountlyFeedbackWidget.h */, D249BF5D254D3D180058A6C2 /* CountlyFeedbackWidget.m */, 3B20A99A2245228400E3D7AE /* CountlyLocationManager.h */, @@ -258,9 +330,12 @@ isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( + 39924ECE2BEBD0B700139F91 /* CountlyCrashesConfig.h in Headers */, 1A478D032AB314750056A5E7 /* CountlyExperimentInformation.h in Headers */, + 39BDF7572CC7CA870066DE7C /* CountlyFeedbacks.h in Headers */, 3B20A9D32245228700E3D7AE /* CountlyPushNotifications.h in Headers */, 3B20A9C42245228700E3D7AE /* CountlyUserDetails.h in Headers */, + 3961C6B72C6633C000DD38BA /* PassThroughBackgroundView.h in Headers */, 3B20A9CA2245228700E3D7AE /* CountlyConfig.h in Headers */, 3B20A9872245225A00E3D7AE /* Countly.h in Headers */, 1A423EA02A271FE0008C4757 /* CountlyRCData.h in Headers */, @@ -268,13 +343,16 @@ 1A3110652A7128ED001CB507 /* CountlyViewData.h in Headers */, 3B20A9B22245228700E3D7AE /* CountlyConnectionManager.h in Headers */, 1A3A576529ED47BD0041B7BE /* CountlyServerConfig.h in Headers */, + 3961C6B52C6633C000DD38BA /* CountlyWebViewManager.h in Headers */, 1ACA5DC12A309E7F001F770B /* CountlyRemoteConfigInternal.h in Headers */, 3B20A9CC2245228700E3D7AE /* CountlyViewTrackingInternal.h in Headers */, + 39EE1F102C8B341E0016D1BF /* CountlyExperimentalConfig.h in Headers */, 3948A8582BAC2E7D002D09AA /* CountlySDKLimitsConfig.h in Headers */, 3B20A9BF2245228700E3D7AE /* CountlyLocationManager.h in Headers */, D219374B248AC71C00E5798B /* CountlyPerformanceMonitoring.h in Headers */, 39911B672B457DBB00AC053C /* Resettable.h in Headers */, 3B20A9BC2245228700E3D7AE /* CountlyCommon.h in Headers */, + 399117D42C69F73D00DC4C66 /* CountlyContentBuilder.h in Headers */, 39527E182B5FD54C00EE5D7B /* CountlyAPMConfig.h in Headers */, 3B20A9C52245228700E3D7AE /* CountlyCrashReporter.h in Headers */, 3B20A9BE2245228700E3D7AE /* CountlyDeviceInfo.h in Headers */, @@ -284,7 +362,10 @@ 1A3110702A7141AF001CB507 /* CountlyViewTracking.h in Headers */, 3B20A9B72245228700E3D7AE /* CountlyPersistency.h in Headers */, D249BF5E254D3D180058A6C2 /* CountlyFeedbackWidget.h in Headers */, - D2CFEF972545FBE80026B044 /* CountlyFeedbacks.h in Headers */, + 39924ED82BEBD22100139F91 /* CountlyCrashData.h in Headers */, + 399117D22C69F73D00DC4C66 /* CountlyContentBuilderInternal.h in Headers */, + D2CFEF972545FBE80026B044 /* CountlyFeedbacksInternal.h in Headers */, + 39002D0B2C8B2E450049394F /* CountlyContentConfig.h in Headers */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -336,7 +417,7 @@ attributes = { LastSwiftUpdateCheck = 1410; LastUpgradeCheck = 1410; - ORGANIZATIONNAME = "Countly"; + ORGANIZATIONNAME = Countly; TargetAttributes = { 1A5C4C932B35B0850032EE1F = { CreatedOnToolsVersion = 14.1; @@ -389,8 +470,14 @@ buildActionMask = 2147483647; files = ( 1A5C4C972B35B0850032EE1F /* CountlyTests.swift in Sources */, + 399B46502C52813700AD384E /* CountlyLocationTests.swift in Sources */, 1A50D7052B3C5AA3009C6938 /* CountlyBaseTestCase.swift in Sources */, + 3979E47D2C0760E900FA1CA4 /* CountlyUserProfileTests.swift in Sources */, 968426812BF2302C007B303E /* CountlyConnectionManagerTests.swift in Sources */, + 3966DBCF2C11EE270002ED97 /* CountlyDeviceIDTests.swift in Sources */, + 3964A3E72C2AF8E90091E677 /* CountlySegmentationTests.swift in Sources */, + 3972EDDB2C08A38D00EB9D3E /* CountlyEventStruct.swift in Sources */, + 96E680422BFF89AC0091E105 /* CountlyCrashReporterTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -404,24 +491,33 @@ 3B20A9B52245228700E3D7AE /* CountlyCommon.m in Sources */, 1ACA5DC22A309E7F001F770B /* CountlyRemoteConfigInternal.m in Sources */, 3B20A9CF2245228700E3D7AE /* CountlyDeviceInfo.m in Sources */, + 39924ED02BEBD0D400139F91 /* CountlyCrashesConfig.m in Sources */, 3B20A9CE2245228700E3D7AE /* CountlyCrashReporter.m in Sources */, 3B20A9D42245228700E3D7AE /* CountlyPersistency.m in Sources */, 3B20A9B32245228700E3D7AE /* CountlyNotificationService.m in Sources */, 3948A8572BAC2E7D002D09AA /* CountlySDKLimitsConfig.m in Sources */, - D2CFEF982545FBE80026B044 /* CountlyFeedbacks.m in Sources */, + D2CFEF982545FBE80026B044 /* CountlyFeedbacksInternal.m in Sources */, 1A3110632A7128CD001CB507 /* CountlyViewData.m in Sources */, + 3961C6B92C6633C000DD38BA /* PassThroughBackgroundView.m in Sources */, + 399117D32C69F73D00DC4C66 /* CountlyContentBuilderInternal.m in Sources */, + 39BDF7592CC7CA920066DE7C /* CountlyFeedbacks.m in Sources */, 1A423E9E2A271E46008C4757 /* CountlyRCData.m in Sources */, 3B20A9CB2245228700E3D7AE /* CountlyRemoteConfig.m in Sources */, 3B20A9BD2245228700E3D7AE /* CountlyConnectionManager.m in Sources */, + 39002D0C2C8B2E450049394F /* CountlyContentConfig.m in Sources */, 3B20A9C02245228700E3D7AE /* CountlyConsentManager.m in Sources */, 39527E152B5FD27400EE5D7B /* CountlyAPMConfig.m in Sources */, 1A3110712A7141AF001CB507 /* CountlyViewTracking.m in Sources */, + 3903429D2C8051C700238C96 /* CountlyExperimentalConfig.m in Sources */, 1A3A576329ED47A20041B7BE /* CountlyServerConfig.m in Sources */, D219374C248AC71C00E5798B /* CountlyPerformanceMonitoring.m in Sources */, 3B20A9B42245228700E3D7AE /* CountlyPushNotifications.m in Sources */, 3B20A9C92245228700E3D7AE /* CountlyUserDetails.m in Sources */, + 399117D12C69F73D00DC4C66 /* CountlyContentBuilder.m in Sources */, 3B20A9D12245228700E3D7AE /* CountlyConfig.m in Sources */, + 3961C6BA2C6633C000DD38BA /* CountlyWebViewManager.m in Sources */, 1A9027FE2AB197B50044EBCF /* CountlyExperimentInformation.m in Sources */, + 39924ED62BEBD20F00139F91 /* CountlyCrashData.m in Sources */, 3B20A9C82245228700E3D7AE /* CountlyViewTrackingInternal.m in Sources */, 3B20A9D22245228700E3D7AE /* CountlyLocationManager.m in Sources */, ); @@ -638,7 +734,7 @@ "@loader_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.14; - MARKETING_VERSION = 24.4.1; + MARKETING_VERSION = 24.7.4; PRODUCT_BUNDLE_IDENTIFIER = ly.count.CountlyiOSSDK; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -670,7 +766,7 @@ "@loader_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.14; - MARKETING_VERSION = 24.4.1; + MARKETING_VERSION = 24.7.4; PRODUCT_BUNDLE_IDENTIFIER = ly.count.CountlyiOSSDK; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/ios/src/Countly.xcodeproj/xcshareddata/xcschemes/CountlyTests.xcscheme b/ios/src/Countly.xcodeproj/xcshareddata/xcschemes/CountlyTests.xcscheme new file mode 100644 index 00000000..5b4cf3d5 --- /dev/null +++ b/ios/src/Countly.xcodeproj/xcshareddata/xcschemes/CountlyTests.xcscheme @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/src/CountlyCommon.h b/ios/src/CountlyCommon.h index f5088e3a..2aaf4e66 100644 --- a/ios/src/CountlyCommon.h +++ b/ios/src/CountlyCommon.h @@ -15,7 +15,7 @@ #import "CountlyCrashReporter.h" #import "CountlyConfig.h" #import "CountlyViewTrackingInternal.h" -#import "CountlyFeedbacks.h" +#import "CountlyFeedbacksInternal.h" #import "CountlyFeedbackWidget.h" #import "CountlyPushNotifications.h" #import "CountlyNotificationService.h" @@ -28,6 +28,9 @@ #import "CountlyRemoteConfig.h" #import "CountlyViewTracking.h" #import "Resettable.h" +#import "CountlyCrashData.h" +#import "CountlyContentBuilderInternal.h" +#import "CountlyExperimentalConfig.h" #define CLY_LOG_E(fmt, ...) CountlyInternalLog(CLYInternalLogLevelError, fmt, ##__VA_ARGS__) #define CLY_LOG_W(fmt, ...) CountlyInternalLog(CLYInternalLogLevelWarning, fmt, ##__VA_ARGS__) @@ -56,6 +59,7 @@ NS_ASSUME_NONNULL_BEGIN extern NSString* const kCountlyErrorDomain; extern NSString* const kCountlyReservedEventOrientation; +extern NSString* const kCountlyVisibility; NS_ERROR_ENUM(kCountlyErrorDomain) { @@ -76,6 +80,7 @@ extern NSString* const kCountlySDKName; @property (nonatomic) BOOL hasStarted; @property (nonatomic) BOOL enableDebug; + @property (nonatomic) BOOL shouldIgnoreTrustCheck; @property (nonatomic, weak) id loggerDelegate; @property (nonatomic) CLYInternalLogLevel internalLogLevel; @@ -85,6 +90,8 @@ extern NSString* const kCountlySDKName; @property (nonatomic) BOOL enableOrientationTracking; @property (nonatomic) BOOL enableServerConfiguration; +@property (nonatomic) BOOL enableVisibiltyTracking; + @property (nonatomic) NSUInteger maxKeyLength; @property (nonatomic) NSUInteger maxValueLength; @@ -104,7 +111,7 @@ void CountlyPrint(NSString *stringToPrint); - (void)startBackgroundTask; - (void)finishBackgroundTask; -#if (TARGET_OS_IOS || TARGET_OS_TV) +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV ) - (UIViewController *)topViewController; - (void)tryPresentingViewController:(UIViewController *)viewController; - (void)tryPresentingViewController:(UIViewController *)viewController withCompletion:(void (^ __nullable) (void))completion; @@ -112,6 +119,8 @@ void CountlyPrint(NSString *stringToPrint); - (void)observeDeviceOrientationChanges; +- (void)recordOrientation; + - (BOOL)hasStarted_; @end @@ -145,12 +154,14 @@ void CountlyPrint(NSString *stringToPrint); @interface NSArray (Countly) - (NSString *)cly_JSONify; +- (NSArray *)cly_filterSupportedDataTypes; @end @interface NSDictionary (Countly) - (NSString *)cly_JSONify; - (NSDictionary *)cly_truncated:(NSString *)explanation; - (NSDictionary *)cly_limited:(NSString *)explanation; +- (NSMutableDictionary *)cly_filterSupportedDataTypes; @end @interface NSData (Countly) diff --git a/ios/src/CountlyCommon.m b/ios/src/CountlyCommon.m index f2405f25..061b64f3 100644 --- a/ios/src/CountlyCommon.m +++ b/ios/src/CountlyCommon.m @@ -10,6 +10,9 @@ NSString* const kCountlyReservedEventOrientation = @"[CLY]_orientation"; NSString* const kCountlyOrientationKeyMode = @"mode"; +NSString* const kCountlyVisibility = @"cly_v"; + + @interface CountlyCommon () { NSCalendar* gregorianCalendar; @@ -17,16 +20,16 @@ @interface CountlyCommon () } @property long long lastTimestamp; -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION ) @property (nonatomic) NSString* lastInterfaceOrientation; #endif -#if (TARGET_OS_IOS || TARGET_OS_TV) +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV ) @property (nonatomic) UIBackgroundTaskIdentifier bgTask; #endif @end -NSString* const kCountlySDKVersion = @"24.4.1"; +NSString* const kCountlySDKVersion = @"24.7.4"; NSString* const kCountlySDKName = @"objc-native-ios"; NSString* const kCountlyErrorDomain = @"ly.count.ErrorDomain"; @@ -196,7 +199,13 @@ - (void)deviceOrientationDidChange:(NSNotification *)notification - (void)recordOrientation { #if (TARGET_OS_IOS) - + if (!self.enableOrientationTracking) + return; + + if ([UIApplication sharedApplication].applicationState == UIApplicationStateBackground) { + CLY_LOG_W(@"%s App is in the background, 'Record Orientation' will be ignored", __FUNCTION__); + return; + } #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wdeprecated-declarations" UIInterfaceOrientation interfaceOrientation = UIApplication.sharedApplication.statusBarOrientation; @@ -221,10 +230,11 @@ - (void)recordOrientation } CLY_LOG_D(@"Interface orientation is now: %@", mode); - self.lastInterfaceOrientation = mode; if (!CountlyConsentManager.sharedInstance.consentForUserDetails) return; + + self.lastInterfaceOrientation = mode; [Countly.sharedInstance recordReservedEvent:kCountlyReservedEventOrientation segmentation:@{kCountlyOrientationKeyMode: mode}]; #endif @@ -234,7 +244,7 @@ - (void)recordOrientation - (void)startBackgroundTask { -#if (TARGET_OS_IOS || TARGET_OS_TV) +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV) if (self.bgTask != UIBackgroundTaskInvalid) return; @@ -248,7 +258,7 @@ - (void)startBackgroundTask - (void)finishBackgroundTask { -#if (TARGET_OS_IOS || TARGET_OS_TV) +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV) if (self.bgTask != UIBackgroundTaskInvalid && !CountlyConnectionManager.sharedInstance.connection) { [UIApplication.sharedApplication endBackgroundTask:self.bgTask]; @@ -571,6 +581,18 @@ - (NSString *)cly_JSONify { return [CountlyJSONFromObject(self) cly_URLEscaped]; } + +- (NSArray *) cly_filterSupportedDataTypes { + NSMutableArray *filteredArray = [NSMutableArray array]; + for (id obj in self) { + if ([obj isKindOfClass:[NSNumber class]] || [obj isKindOfClass:[NSString class]]) { + [filteredArray addObject:obj]; + } else { + CLY_LOG_W(@"%s, Removed invalid type from array: %@", __FUNCTION__, [obj class]); + } + } + return filteredArray.copy; +} @end @implementation NSDictionary (Countly) @@ -622,6 +644,25 @@ - (NSDictionary *)cly_limited:(NSString *)explanation return limitedDict.copy; } +- (NSMutableDictionary *) cly_filterSupportedDataTypes +{ + NSMutableDictionary *filteredDictionary = [NSMutableDictionary dictionary]; + + for (NSString *key in self) { + id value = [self objectForKey:key]; + + if ([value isKindOfClass:[NSNumber class]] || + [value isKindOfClass:[NSString class]] || + ([value isKindOfClass:[NSArray class]] && (value = [value cly_filterSupportedDataTypes]))) { + [filteredDictionary setObject:value forKey:key]; + } else { + CLY_LOG_W(@"%s, Removed invalid type for key %@: %@", __FUNCTION__, key, [value class]); + } + } + + return filteredDictionary.mutableCopy; +} + @end @implementation NSData (Countly) diff --git a/ios/src/CountlyConfig.h b/ios/src/CountlyConfig.h index bf4bc9bf..7d61542a 100644 --- a/ios/src/CountlyConfig.h +++ b/ios/src/CountlyConfig.h @@ -8,18 +8,29 @@ #import #import "CountlyRCData.h" #import "CountlyAPMConfig.h" +#import "CountlyCrashesConfig.h" #import "CountlySDKLimitsConfig.h" +#import "CountlyExperimentalConfig.h" +#import "CountlyContentConfig.h" -#if (TARGET_OS_IOS || TARGET_OS_TV) +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV ) #import #endif NS_ASSUME_NONNULL_BEGIN +typedef enum : NSUInteger +{ + WIDGET_APPEARED, + WIDGET_CLOSED, +} WidgetState; + +typedef void (^WidgetCallback)(WidgetState widgetState); + //NOTE: Countly features typedef NSString* CLYFeature NS_EXTENSIBLE_STRING_ENUM; -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION ) #ifndef COUNTLY_EXCLUDE_PUSHNOTIFICATIONS extern CLYFeature const CLYPushNotifications; #endif @@ -76,6 +87,7 @@ extern CLYConsent const CLYConsentAttribution; extern CLYConsent const CLYConsentPerformanceMonitoring; extern CLYConsent const CLYConsentFeedback; extern CLYConsent const CLYConsentRemoteConfig; +extern CLYConsent const CLYConsentContent; //NOTE: Push Notification Test Modes typedef NSString* CLYPushTestMode NS_EXTENSIBLE_STRING_ENUM; @@ -113,6 +125,7 @@ typedef void (^RCVariantCallback)(CLYRequestResult response, NSError *_Nullable typedef void (^RCDownloadCallback)(CLYRequestResult response, NSError *_Nullable error, BOOL fullValueUpdate, NSDictionary* downloadedValues); + //NOTE: Internal log levels typedef enum : NSUInteger { @@ -213,7 +226,7 @@ typedef enum : NSUInteger #pragma mark - -#if (TARGET_OS_IOS || TARGET_OS_TV) +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV) /** * For enabling automatic view tacking. * @discussion If set, views will automatically track. @@ -334,6 +347,13 @@ typedef enum : NSUInteger */ @property (nonatomic, copy) NSString* deviceID; +/** + * This menthod will enable temporary device ID mode + * @discussion All requests will be on hold, but they will be persistently stored. + * @discussion When in temporary device ID mode, method calls for presenting feedback widgets and updating remote config will be ignored. + */ +- (void)enableTemporaryDeviceIDMode; + /** * 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. @@ -518,6 +538,8 @@ typedef enum : NSUInteger */ @property (nonatomic, copy) BOOL (^shouldSendCrashReportCallback)(NSDictionary * crashReport); +- (CountlyCrashesConfig *) crashes; + #pragma mark - /** @@ -641,11 +663,26 @@ typedef enum : NSUInteger @property (nonatomic) BOOL enableOrientationTracking; /** - * This is an experimental feature + * This is an experimental feature and it can have breaking changes * For enabling fetching and application of server config values. * @discussion If set, Server Config values from Countly Server will be fetched at the beginning of a session. */ @property (nonatomic) BOOL enableServerConfiguration; + +#if (TARGET_OS_IOS) +/** + * Variable to access content configurations. + * @discussion Content configurations for developer to interact with SDK. + */ +- (CountlyContentConfig *) content; +#endif + +/** + * This is an experimental feature and it can have breaking changes + * Variable to access experimental configurations. + * @discussion Experimental configurations for developer to interact with SDK. + */ +- (CountlyExperimentalConfig *) experimental; NS_ASSUME_NONNULL_END @end diff --git a/ios/src/CountlyConfig.m b/ios/src/CountlyConfig.m index f93150e5..515b02ce 100644 --- a/ios/src/CountlyConfig.m +++ b/ios/src/CountlyConfig.m @@ -17,7 +17,7 @@ - (void)enableAPMInternal:(BOOL)enableAPM; @implementation CountlyConfig //NOTE: Countly features -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) CLYFeature const CLYPushNotifications = @"CLYPushNotifications"; CLYFeature const CLYCrashReporting = @"CLYCrashReporting"; // CLYAutoViewTracking is deprecated, Use 'config.enableAutomaticViewTracking' instead @@ -34,7 +34,13 @@ @implementation CountlyConfig #endif CountlyAPMConfig *apmConfig = nil; +CountlyCrashesConfig *crashes = nil; CountlySDKLimitsConfig *sdkLimitsConfig = nil; +CountlyExperimentalConfig *experimental = nil; + +#if (TARGET_OS_IOS) +CountlyContentConfig *content = nil; +#endif //NOTE: Device ID options NSString* const CLYDefaultDeviceID = @""; //NOTE: It will be overridden to default device ID mechanism, depending on platform. @@ -77,13 +83,17 @@ - (instancetype)init return self; } +- (void)enableTemporaryDeviceIDMode +{ + self.deviceID = CLYTemporaryDeviceID; +} + -(void)remoteConfigRegisterGlobalCallback:(RCDownloadCallback) callback { [self.remoteConfigGlobalCallbacks addObject:callback]; } - - (NSMutableArray *) getRemoteConfigGlobalCallbacks { return self.remoteConfigGlobalCallbacks; @@ -112,4 +122,27 @@ - (nonnull CountlySDKLimitsConfig *)sdkInternalLimits { return sdkLimitsConfig; } +- (nonnull CountlyCrashesConfig *)crashes { + if (crashes == nil) { + crashes = CountlyCrashesConfig.new; + } + return crashes; +} + +- (nonnull CountlyExperimentalConfig *)experimental { + if (experimental == nil) { + experimental = CountlyExperimentalConfig.new; + } + return experimental; +} + +#if (TARGET_OS_IOS) +- (nonnull CountlyContentConfig *)content { + if (content == nil) { + content = CountlyContentConfig.new; + } + return content; +} +#endif + @end diff --git a/ios/src/CountlyConnectionManager.h b/ios/src/CountlyConnectionManager.h index 5c7d7446..273621b5 100644 --- a/ios/src/CountlyConnectionManager.h +++ b/ios/src/CountlyConnectionManager.h @@ -5,6 +5,7 @@ // Please visit www.count.ly for more information. #import +#import "Resettable.h" extern NSString* const kCountlyQSKeyAppKey; extern NSString* const kCountlyQSKeyDeviceID; @@ -25,7 +26,7 @@ extern NSString* const kCountlyQSKeyTimestamp; extern const NSInteger kCountlyGETRequestMaxLength; -@interface CountlyConnectionManager : NSObject +@interface CountlyConnectionManager : NSObject @property (nonatomic) NSString* appKey; @property (nonatomic) NSString* host; @@ -43,6 +44,7 @@ extern const NSInteger kCountlyGETRequestMaxLength; - (void)updateSession; - (void)endSession; +- (void)sendEventsWithSaveIfNeeded; - (void)sendEvents; - (void)attemptToSendStoredRequests; - (void)sendPushToken:(NSString *)token; diff --git a/ios/src/CountlyConnectionManager.m b/ios/src/CountlyConnectionManager.m index 50ad12df..1368f748 100644 --- a/ios/src/CountlyConnectionManager.m +++ b/ios/src/CountlyConnectionManager.m @@ -84,13 +84,12 @@ @interface CountlyConnectionManager () @implementation CountlyConnectionManager : NSObject +static CountlyConnectionManager *s_sharedInstance = nil; +static dispatch_once_t onceToken; + (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; } @@ -106,6 +105,13 @@ - (instancetype)init return self; } +- (void)resetInstance { + CLY_LOG_I(@"%s", __FUNCTION__); + onceToken = 0; + s_sharedInstance = nil; + isSessionStarted = NO; +} + - (void)setHost:(NSString *)host { if ([host hasSuffix:@"/"]) @@ -348,6 +354,24 @@ - (void)beginSession CLY_LOG_W(@"%s A session is already running, this 'beginSession' will be ignored", __FUNCTION__); return; } + +#if TARGET_OS_IOS || TARGET_OS_TV + if (!CountlyCommon.sharedInstance.manualSessionHandling && [UIApplication sharedApplication].applicationState == UIApplicationStateBackground) { + CLY_LOG_W(@"%s App is in the background, 'beginSession' will be ignored", __FUNCTION__); + return; + } +#elif TARGET_OS_OSX + if (!CountlyCommon.sharedInstance.manualSessionHandling && ![NSApplication sharedApplication].isActive) { + CLY_LOG_W(@"%s App is not active, 'beginSession' will be ignored", __FUNCTION__); + return; + } +#elif TARGET_OS_WATCH + if (!CountlyCommon.sharedInstance.manualSessionHandling && [WKExtension sharedExtension].applicationState == WKApplicationStateBackground) { + CLY_LOG_W(@"%s App is in the background, 'beginSession' will be ignored", __FUNCTION__); + return; + } +#endif + isSessionStarted = YES; lastSessionStartTime = NSDate.date.timeIntervalSince1970; @@ -366,7 +390,9 @@ - (void)beginSession queryString = [queryString stringByAppendingString:attributionQueryString]; [CountlyPersistency.sharedInstance addToQueue:queryString]; - + + [CountlyCommon.sharedInstance recordOrientation]; + [self proceedOnQueue]; } @@ -412,17 +438,37 @@ - (void)endSession #pragma mark --- +- (void)sendEventsWithSaveIfNeeded +{ + if([Countly.user hasUnsyncedChanges]) + { + [Countly.user save]; + } + else + { + [self sendEventsInternal]; + } +} + - (void)sendEvents { - [self sendEvents:false]; + [self sendEventsInternal]; } - (void)attemptToSendStoredRequests { - [self sendEvents:true]; + [self addEventsToQueue]; + [CountlyPersistency.sharedInstance saveToFileSync]; + [self proceedOnQueue]; } -- (void)sendEvents:(BOOL) saveToFile +- (void)sendEventsInternal +{ + [self addEventsToQueue]; + [self proceedOnQueue]; +} + +- (void)addEventsToQueue { NSString* events = [CountlyPersistency.sharedInstance serializedRecordedEvents]; @@ -434,11 +480,6 @@ - (void)sendEvents:(BOOL) saveToFile [CountlyPersistency.sharedInstance addToQueue:queryString]; - if(saveToFile) { - [CountlyPersistency.sharedInstance saveToFileSync]; - } - - [self proceedOnQueue]; } #pragma mark --- @@ -515,7 +556,7 @@ - (void)sendCrashReport:(NSString *)report immediately:(BOOL)immediately; //NOTE: Prevent `event` and `end_session` requests from being started, after `sendEvents` and `endSession` calls below. isCrashing = YES; - [self sendEvents]; + [self sendEventsWithSaveIfNeeded]; if (!CountlyCommon.sharedInstance.manualSessionHandling) [self endSession]; @@ -789,7 +830,7 @@ - (NSString *)attributionQueryString - (NSMutableData *)pictureUploadDataForQueryString:(NSString *)queryString { -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) NSString* localPicturePath = nil; NSString* userDetails = [queryString cly_valueForQueryStringKey:kCountlyQSKeyUserDetails]; diff --git a/ios/src/CountlyConsentManager.h b/ios/src/CountlyConsentManager.h index 78f2a846..4bab37d0 100644 --- a/ios/src/CountlyConsentManager.h +++ b/ios/src/CountlyConsentManager.h @@ -5,8 +5,9 @@ // Please visit www.count.ly for more information. #import +#import "Resettable.h" -@interface CountlyConsentManager : NSObject +@interface CountlyConsentManager : NSObject @property (nonatomic) BOOL requiresConsent; @@ -21,6 +22,8 @@ @property (nonatomic, readonly) BOOL consentForPerformanceMonitoring; @property (nonatomic, readonly) BOOL consentForFeedback; @property (nonatomic, readonly) BOOL consentForRemoteConfig; +@property (nonatomic, readonly) BOOL consentForContent; + + (instancetype)sharedInstance; - (void)giveConsentForFeatures:(NSArray *)features; @@ -29,5 +32,6 @@ - (void)cancelConsentForAllFeatures; - (void)cancelConsentForAllFeaturesWithoutSendingConsentsRequest; - (BOOL)hasAnyConsent; +- (void)sendConsents; @end diff --git a/ios/src/CountlyConsentManager.m b/ios/src/CountlyConsentManager.m index fce1c291..71cdc2cb 100644 --- a/ios/src/CountlyConsentManager.m +++ b/ios/src/CountlyConsentManager.m @@ -17,6 +17,7 @@ CLYConsent const CLYConsentPerformanceMonitoring = @"apm"; CLYConsent const CLYConsentFeedback = @"feedback"; CLYConsent const CLYConsentRemoteConfig = @"remote-config"; +CLYConsent const CLYConsentContent = @"content"; @implementation CountlyConsentManager @@ -32,16 +33,17 @@ @implementation CountlyConsentManager @synthesize consentForPerformanceMonitoring = _consentForPerformanceMonitoring; @synthesize consentForFeedback = _consentForFeedback; @synthesize consentForRemoteConfig = _consentForRemoteConfig; +@synthesize consentForContent = _consentForContent; #pragma mark - +static CountlyConsentManager* s_sharedInstance = nil; +static dispatch_once_t onceToken; + (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; } @@ -57,6 +59,12 @@ - (instancetype)init return self; } +- (void)resetInstance { + CLY_LOG_I(@"%s", __FUNCTION__); + [self cancelConsentForAllFeatures]; + onceToken = 0; + s_sharedInstance = nil; +} #pragma mark - @@ -108,6 +116,9 @@ - (void)giveConsentForFeatures:(NSArray *)features if ([features containsObject:CLYConsentRemoteConfig] && !self.consentForRemoteConfig) self.consentForRemoteConfig = YES; + + if ([features containsObject:CLYConsentContent] && !self.consentForContent) + self.consentForContent = YES; [self sendConsents]; } @@ -137,7 +148,10 @@ - (void)cancelConsentForFeatures:(NSArray *)features shouldSkipSendingConsentsRe return; if ([features containsObject:CLYConsentSessions] && self.consentForSessions) + { + [CountlyConnectionManager.sharedInstance endSession]; self.consentForSessions = NO; + } if ([features containsObject:CLYConsentEvents] && self.consentForEvents) self.consentForEvents = NO; @@ -168,6 +182,9 @@ - (void)cancelConsentForFeatures:(NSArray *)features shouldSkipSendingConsentsRe if ([features containsObject:CLYConsentRemoteConfig] && self.consentForRemoteConfig) self.consentForRemoteConfig = NO; + + if ([features containsObject:CLYConsentContent] && self.consentForContent) + self.consentForContent = NO; if (!shouldSkipSendingConsentsRequest) [self sendConsents]; @@ -189,6 +206,7 @@ - (void)sendConsents CLYConsentPerformanceMonitoring: @(self.consentForPerformanceMonitoring), CLYConsentFeedback: @(self.consentForFeedback), CLYConsentRemoteConfig: @(self.consentForRemoteConfig), + CLYConsentContent: @(self.consentForContent), }; [CountlyConnectionManager.sharedInstance sendConsents:[consents cly_JSONify]]; @@ -210,6 +228,7 @@ - (NSArray *)allFeatures CLYConsentPerformanceMonitoring, CLYConsentFeedback, CLYConsentRemoteConfig, + CLYConsentContent ]; } @@ -227,7 +246,8 @@ - (BOOL)hasAnyConsent self.consentForAttribution || self.consentForPerformanceMonitoring || self.consentForFeedback || - self.consentForRemoteConfig; + self.consentForRemoteConfig || + self.consentForContent; } @@ -264,7 +284,7 @@ - (void)setConsentForEvents:(BOOL)consentForEvents { CLY_LOG_D(@"Consent for Events is cancelled."); - [CountlyConnectionManager.sharedInstance sendEvents]; + [CountlyConnectionManager.sharedInstance sendEventsWithSaveIfNeeded]; [CountlyPersistency.sharedInstance clearAllTimedEvents]; } } @@ -277,6 +297,7 @@ - (void)setConsentForUserDetails:(BOOL)consentForUserDetails if (consentForUserDetails) { CLY_LOG_D(@"Consent for UserDetails is given."); + [CountlyCommon.sharedInstance recordOrientation]; [Countly.user save]; } else @@ -311,7 +332,7 @@ - (void)setConsentForPushNotifications:(BOOL)consentForPushNotifications { _consentForPushNotifications = consentForPushNotifications; -#if (TARGET_OS_IOS || TARGET_OS_OSX) +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_OSX) if (consentForPushNotifications) { CLY_LOG_D(@"Consent for PushNotifications is given."); @@ -344,6 +365,8 @@ - (void)setConsentForLocation:(BOOL)consentForLocation else { CLY_LOG_D(@"Consent for Location is cancelled."); + + [CountlyConnectionManager.sharedInstance sendLocationInfo]; } } @@ -352,7 +375,7 @@ - (void)setConsentForViewTracking:(BOOL)consentForViewTracking { _consentForViewTracking = consentForViewTracking; -#if (TARGET_OS_IOS || TARGET_OS_TV) +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV) if (consentForViewTracking) { CLY_LOG_D(@"Consent for ViewTracking is given."); @@ -390,7 +413,7 @@ - (void)setConsentForPerformanceMonitoring:(BOOL)consentForPerformanceMonitoring { _consentForPerformanceMonitoring = consentForPerformanceMonitoring; -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) if (consentForPerformanceMonitoring) { CLY_LOG_D(@"Consent for PerformanceMonitoring is given."); @@ -415,7 +438,7 @@ - (void)setConsentForFeedback:(BOOL)consentForFeedback { CLY_LOG_D(@"Consent for Feedback is given."); - [CountlyFeedbacks.sharedInstance checkForStarRatingAutoAsk]; + [CountlyFeedbacksInternal.sharedInstance checkForStarRatingAutoAsk]; } else { @@ -440,6 +463,23 @@ - (void)setConsentForRemoteConfig:(BOOL)consentForRemoteConfig } } +- (void)setConsentForContent:(BOOL)consentForContent +{ + _consentForContent = consentForContent; + + if (consentForContent) + { + CLY_LOG_D(@"Consent for Content is given."); + } + else + { + CLY_LOG_D(@"Consent for Content is cancelled."); +#if (TARGET_OS_IOS) + [CountlyContentBuilderInternal.sharedInstance exitContentZone]; +#endif + } +} + #pragma mark - - (BOOL)consentForSessions @@ -538,4 +578,12 @@ - (BOOL)consentForRemoteConfig return _consentForRemoteConfig; } +- (BOOL)consentForContent +{ + if (!self.requiresConsent) + return YES; + + return _consentForContent; +} + @end diff --git a/ios/src/CountlyContentBuilder.h b/ios/src/CountlyContentBuilder.h new file mode 100644 index 00000000..e1b4441f --- /dev/null +++ b/ios/src/CountlyContentBuilder.h @@ -0,0 +1,31 @@ +// CountlyContentBuilder.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 +@interface CountlyContentBuilder: NSObject +#if (TARGET_OS_IOS) ++ (instancetype)sharedInstance; + +/** + * This is an experimental feature and it can have breaking changes + * Opt in user for the content fetching and updates + */ +- (void)enterContentZone; + +/** + * This is an experimental feature and it can have breaking changes + * Opt out user for the content fetching and updates + */ +- (void)exitContentZone; + +#endif +NS_ASSUME_NONNULL_END +@end diff --git a/ios/src/CountlyContentBuilder.m b/ios/src/CountlyContentBuilder.m new file mode 100644 index 00000000..6a456cea --- /dev/null +++ b/ios/src/CountlyContentBuilder.m @@ -0,0 +1,50 @@ +// CountlyContentBuilder.m +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import "CountlyContentBuilder.h" +#import "CountlyContentBuilderInternal.h" +#import "CountlyCommon.h" + +@implementation CountlyContentBuilder +#if (TARGET_OS_IOS) ++ (instancetype)sharedInstance +{ + if (!CountlyCommon.sharedInstance.hasStarted) + return nil; + + static CountlyContentBuilder* s_sharedInstance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{s_sharedInstance = self.new;}); + return s_sharedInstance; +} + +- (instancetype)init +{ + self = [super init]; + + return self; +} + +- (void)enterContentZone +{ + [self enterContentZone:@[]]; +} + +- (void)enterContentZone:(NSArray *)tags +{ + [CountlyContentBuilderInternal.sharedInstance enterContentZone:tags]; +} +- (void)exitContentZone +{ + [CountlyContentBuilderInternal.sharedInstance exitContentZone]; +} +- (void)changeContent:(NSArray *)tags +{ + [CountlyContentBuilder.sharedInstance changeContent:tags]; +} + +#endif +@end diff --git a/ios/src/CountlyContentBuilderInternal.h b/ios/src/CountlyContentBuilderInternal.h new file mode 100644 index 00000000..be18530d --- /dev/null +++ b/ios/src/CountlyContentBuilderInternal.h @@ -0,0 +1,28 @@ +// CountlyContent.h +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import +#if (TARGET_OS_IOS) +#import +#endif +#import "CountlyCommon.h" +NS_ASSUME_NONNULL_BEGIN +@interface CountlyContentBuilderInternal: NSObject +#if (TARGET_OS_IOS) +@property (nonatomic, strong) NSArray *currentTags; +@property (nonatomic, assign) NSTimeInterval requestInterval; +@property (nonatomic) ContentCallback contentCallback; + ++ (instancetype)sharedInstance; + +- (void)enterContentZone:(NSArray *)tags; +- (void)exitContentZone; +- (void)changeContent:(NSArray *)tags; + +#endif +NS_ASSUME_NONNULL_END +@end + diff --git a/ios/src/CountlyContentBuilderInternal.m b/ios/src/CountlyContentBuilderInternal.m new file mode 100644 index 00000000..ec0bbcb0 --- /dev/null +++ b/ios/src/CountlyContentBuilderInternal.m @@ -0,0 +1,229 @@ +// CountlyContent.m +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. +#import "CountlyContentBuilderInternal.h" +#import "CountlyWebViewManager.h" + +//TODO: improve logging, check edge cases +NSString* const kCountlyEndpointContent = @"/o/sdk/content"; +NSString* const kCountlyCBFetchContent = @"queue"; + +@implementation CountlyContentBuilderInternal { + BOOL _isRequestQueueLocked; + NSTimer *_requestTimer; + NSTimer *_minuteTimer; +} +#if (TARGET_OS_IOS) ++ (instancetype)sharedInstance { + static CountlyContentBuilderInternal *instance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + instance = [[self alloc] init]; + }); + return instance; +} + +- (instancetype)init +{ + if (self = [super init]) + { + self.requestInterval = 30.0; + _requestTimer = nil; + } + + return self; +} + +- (void)enterContentZone { + [self enterContentZone:@[]]; +} + +- (void)enterContentZone:(NSArray *)tags { + [_minuteTimer invalidate]; + _minuteTimer = nil; + + if (!CountlyConsentManager.sharedInstance.consentForContent) + return; + + if(_requestTimer != nil) { + CLY_LOG_I(@"Already entered for content zone, please exit from content zone first to start again"); + return; + } + + self.currentTags = tags; + + [self fetchContents];; + _requestTimer = [NSTimer scheduledTimerWithTimeInterval:self.requestInterval + target:self + selector:@selector(fetchContents) + userInfo:nil + repeats:YES]; +} + +- (void)exitContentZone { + [self clearContentState]; +} + +- (void)changeContent:(NSArray *)tags { + if (![tags isEqualToArray:self.currentTags]) { + [self exitContentZone]; + [self enterContentZone:tags]; + } +} + +#pragma mark - Private Methods + +- (void)clearContentState { + [_requestTimer invalidate]; + _requestTimer = nil; + + [_minuteTimer invalidate]; + _minuteTimer = nil; + self.currentTags = nil; + _isRequestQueueLocked = NO; +} + +- (void)fetchContents { + if (!CountlyConsentManager.sharedInstance.consentForContent) + return; + + if (_isRequestQueueLocked) { + return; + } + + _isRequestQueueLocked = YES; + + NSURLSessionTask *dataTask = [[NSURLSession sharedSession] dataTaskWithRequest:[self fetchContentsRequest] completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + if (error) { + CLY_LOG_I(@"Fetch content details failed: %@", error); + self->_isRequestQueueLocked = NO; + return; + } + + NSError *jsonError; + NSDictionary *jsonResponse = [NSJSONSerialization JSONObjectWithData:data options:0 error:&jsonError]; + + if (jsonError) { + CLY_LOG_I(@"Failed to parse JSON: %@", jsonError); + self->_isRequestQueueLocked = NO; + return; + } + + if (!jsonResponse) { + CLY_LOG_I(@"Received empty or null response."); + self->_isRequestQueueLocked = NO; + return; + } + + NSString *pathToHtml = jsonResponse[@"html"]; + NSDictionary *placementCoordinates = jsonResponse[@"geo"]; + if(pathToHtml) { + [self showContentWithHtmlPath:pathToHtml placementCoordinates:placementCoordinates]; + } + self->_isRequestQueueLocked = NO; + }]; + + [dataTask resume]; +} + +- (NSURLRequest *)fetchContentsRequest +{ + NSString* queryString = [CountlyConnectionManager.sharedInstance queryEssentials]; + NSString *resolutionJson = [self resolutionJson]; + queryString = [queryString stringByAppendingFormat:@"&%@=%@", + @"resolution", resolutionJson.cly_URLEscaped]; + + queryString = [CountlyConnectionManager.sharedInstance appendChecksum:queryString]; + + NSString* URLString = [NSString stringWithFormat:@"%@%@?%@", + CountlyConnectionManager.sharedInstance.host, + kCountlyEndpointContent, + queryString]; + + NSURLRequest* request = [NSURLRequest requestWithURL:[NSURL URLWithString:URLString]]; + return request; +} + +- (NSString *)resolutionJson { + //TODO: check why area is not clickable and safearea things + CGRect screenBounds = [UIScreen mainScreen].bounds; + if (@available(iOS 11.0, *)) { + CGFloat top = UIApplication.sharedApplication.keyWindow.safeAreaInsets.top; + + if (top) { + screenBounds.origin.y += top + 5; + screenBounds.size.height -= top + 5; + } else { + screenBounds.origin.y += 20.0; + screenBounds.size.height -= 20.0; + } + } else { + screenBounds.origin.y += 20.0; + screenBounds.size.height -= 20.0; + } + + CGFloat width = screenBounds.size.width; + CGFloat height = screenBounds.size.height; + + NSDictionary *resolutionDict = @{ + @"portrait": @{@"height": @(height), @"width": @(width)}, + @"landscape": @{@"height": @(width), @"width": @(height)} + }; + + NSData *jsonData = [NSJSONSerialization dataWithJSONObject:resolutionDict options:0 error:nil]; + return [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; +} + +- (void)showContentWithHtmlPath:(NSString *)urlString placementCoordinates:(NSDictionary *)placementCoordinates { + // Convert pathToHtml to NSURL + NSURL *url = [NSURL URLWithString:urlString]; + + if (!url || !url.scheme || !url.host) { + NSLog(@"The URL is not valid: %@", urlString); + return; + } + + + dispatch_async(dispatch_get_main_queue(), ^ { + // Detect screen orientation + UIInterfaceOrientation orientation = [UIApplication sharedApplication].statusBarOrientation; + BOOL isLandscape = UIInterfaceOrientationIsLandscape(orientation); + + + // Get the appropriate coordinates based on the orientation + NSDictionary *coordinates = isLandscape ? placementCoordinates[@"l"] : placementCoordinates[@"p"]; + + CGFloat x = [coordinates[@"x"] floatValue]; + CGFloat y = [coordinates[@"y"] floatValue]; + CGFloat width = [coordinates[@"w"] floatValue]; + CGFloat height = [coordinates[@"h"] floatValue]; + + CGRect frame = CGRectMake(x, y, width, height); + + // Log the URL and the frame + CLY_LOG_I(@"Showing content from URL: %@", url); + CLY_LOG_I(@"Placement frame: %@", NSStringFromCGRect(frame)); + + CountlyWebViewManager* webViewManager = CountlyWebViewManager.new; + [webViewManager createWebViewWithURL:url frame:frame appearBlock:^ + { + CLY_LOG_I(@"Webview appeared"); + [self clearContentState]; + } dismissBlock:^ + { + CLY_LOG_I(@"Webview dismissed"); + self->_minuteTimer = [NSTimer scheduledTimerWithTimeInterval:60.0 + target:self + selector:@selector(enterContentZone) + userInfo:nil + repeats:NO]; + if(self.contentCallback) { + self.contentCallback(CLOSED, NSDictionary.new); + } + }]; + }); +} +#endif +@end diff --git a/ios/src/CountlyContentConfig.h b/ios/src/CountlyContentConfig.h new file mode 100644 index 00000000..d72fac0c --- /dev/null +++ b/ios/src/CountlyContentConfig.h @@ -0,0 +1,39 @@ +// CountlyContentConfig.h +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import + +NS_ASSUME_NONNULL_BEGIN + +#if (TARGET_OS_IOS) +typedef enum : NSUInteger +{ + COMPLETED, + CLOSED, +} ContentStatus; + +typedef void (^ContentCallback)(ContentStatus contentStatus, NSDictionary* contentData); +#endif + +@interface CountlyContentConfig : NSObject + +#if (TARGET_OS_IOS) +/** + * This is an experimental feature and it can have breaking changes + * Register global completion blocks to be executed on content. + */ +- (void)setGlobalContentCallback:(ContentCallback) callback; + +/** + * This is an experimental feature and it can have breaking changes + * Get content callback + */ +- (ContentCallback) getGlobalContentCallback; +#endif + +NS_ASSUME_NONNULL_END + +@end diff --git a/ios/src/CountlyContentConfig.m b/ios/src/CountlyContentConfig.m new file mode 100644 index 00000000..3d7d63cd --- /dev/null +++ b/ios/src/CountlyContentConfig.m @@ -0,0 +1,38 @@ +// CountlyContentConfig.m +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import "CountlyCommon.h" + +@interface CountlyContentConfig () +#if (TARGET_OS_IOS) +@property (nonatomic) ContentCallback contentCallback; +#endif +@end + +@implementation CountlyContentConfig + +- (instancetype)init +{ + if (self = [super init]) + { + } + + return self; +} + +#if (TARGET_OS_IOS) +-(void)setGlobalContentCallback:(ContentCallback) callback +{ + self.contentCallback = callback; +} + +- (ContentCallback) getGlobalContentCallback +{ + return self.contentCallback; +} +#endif + +@end diff --git a/ios/src/CountlyCrashData.h b/ios/src/CountlyCrashData.h new file mode 100644 index 00000000..161991eb --- /dev/null +++ b/ios/src/CountlyCrashData.h @@ -0,0 +1,30 @@ +// CrashData.h +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import + +@interface CountlyCrashData : NSObject + +@property (nonatomic, copy, nonnull) NSString *stackTrace; +@property (nonatomic, copy, nonnull) NSString *name; +@property (nonatomic, copy, nonnull) NSString *crashDescription; +@property (nonatomic, assign) BOOL fatal; +@property (nonatomic, copy, nonnull) NSMutableArray *breadcrumbs; +@property (nonatomic, copy, nonnull) NSMutableDictionary *crashSegmentation; +@property (nonatomic, copy, nonnull) NSMutableDictionary *crashMetrics; + +@property (nonatomic, strong, nonnull) NSMutableArray *checksums; +@property (nonatomic, strong, nonnull) NSMutableArray *changedFields; + +- (instancetype _Nonnull )initWithStackTrace:(NSString *_Nonnull)stackTrace name:(NSString *_Nonnull)name description:(NSString *_Nonnull)description crashSegmentation:(NSDictionary *_Nonnull)crashSegmentation breadcrumbs:(NSArray *_Nonnull)breadcrumbs crashMetrics:(NSDictionary *_Nullable)crashMetrics fatal:(BOOL)fatal; + +- (NSString *_Nonnull)getBreadcrumbsAsString; +- (NSDictionary *_Nonnull)getCrashMetricsJSON; +- (void)calculateChangedFields; +- (NSNumber *_Nonnull)getChangedFieldsAsInt; + +@end + diff --git a/ios/src/CountlyCrashData.m b/ios/src/CountlyCrashData.m new file mode 100644 index 00000000..4391ab08 --- /dev/null +++ b/ios/src/CountlyCrashData.m @@ -0,0 +1,74 @@ +// CrashData.m + +#import "CountlyCrashData.h" +#import "CountlyCommon.h" + +@implementation CountlyCrashData + +- (instancetype)initWithStackTrace:(NSString *)stackTrace name:(NSString *)name description:(NSString *)description crashSegmentation:(NSDictionary *)crashSegmentation breadcrumbs:(NSArray *)breadcrumbs crashMetrics:(NSDictionary *)crashMetrics fatal:(BOOL)fatal { + self = [super init]; + if (self) { + _stackTrace = [stackTrace copy] ?: @""; + _name = [name copy] ?: @""; + _crashDescription = [description copy] ?: @""; + _crashSegmentation = [crashSegmentation mutableCopy] ?: @{}; + _breadcrumbs = [breadcrumbs mutableCopy] ?: @[]; + _crashMetrics = [crashMetrics mutableCopy] ?: @{}; + _fatal = fatal; + + _checksums = [NSMutableArray arrayWithCapacity:7]; + _changedFields = [NSMutableArray arrayWithCapacity:7]; + [self calculateChecksums:_checksums]; + } + return self; +} + +- (NSString *)getBreadcrumbsAsString { + NSMutableString *breadcrumbsString = [NSMutableString string]; + for (NSString *breadcrumb in self.breadcrumbs) { + [breadcrumbsString appendFormat:@"%@\n", breadcrumb]; + } + return [breadcrumbsString copy]; +} + +- (NSDictionary *)getCrashMetricsJSON { + NSMutableDictionary *crashMetrics = [NSMutableDictionary dictionary]; + for (NSString *key in self.crashMetrics) { + crashMetrics[key] = self.crashMetrics[key]; + } + return [crashMetrics copy]; +} + +- (void)calculateChangedFields { + NSMutableArray *checksumsNew = [NSMutableArray arrayWithCapacity:7]; + [self calculateChecksums:checksumsNew]; + + NSMutableArray *changedFields = [NSMutableArray arrayWithCapacity:7]; + for (int i = 0; i < checksumsNew.count; i++) { + changedFields[i] = @(![self.checksums[i] isEqualToString:checksumsNew[i]]); + } + self.changedFields = [changedFields copy]; +} + +- (NSNumber *)getChangedFieldsAsInt { + int result = 0; + for (int i = (int)self.changedFields.count - 1; i >= 0; i--) { + if (self.changedFields[i].boolValue) { + result |= (1 << ((int)self.changedFields.count - 1 - i)); + } + } + return @(result); +} + +- (void)calculateChecksums:(NSMutableArray *)checksumArrayToSet { + [checksumArrayToSet removeAllObjects]; + [checksumArrayToSet addObject:[self.name cly_SHA256]]; + [checksumArrayToSet addObject:[self.crashDescription cly_SHA256]]; + [checksumArrayToSet addObject:[self.stackTrace cly_SHA256]]; + [checksumArrayToSet addObject:[[self.crashSegmentation description] cly_SHA256]]; + [checksumArrayToSet addObject:[[self.breadcrumbs description] cly_SHA256]]; + [checksumArrayToSet addObject:[[self.crashMetrics description] cly_SHA256]]; + [checksumArrayToSet addObject:[(self.fatal ? @"true" : @"false") cly_SHA256]]; +} + +@end diff --git a/ios/src/CountlyCrashReporter.h b/ios/src/CountlyCrashReporter.h index 7e0d3fdf..72fdc06b 100644 --- a/ios/src/CountlyCrashReporter.h +++ b/ios/src/CountlyCrashReporter.h @@ -15,6 +15,8 @@ @property (nonatomic) BOOL shouldUseMachSignalHandler; @property (nonatomic, copy) void (^crashOccuredOnPreviousSessionCallback)(NSDictionary * crashReport); @property (nonatomic, copy) BOOL (^shouldSendCrashReportCallback)(NSDictionary * crashReport); +@property (nonatomic, copy) BOOL (^crashFilterCallback)(CountlyCrashData *); + + (instancetype)sharedInstance; - (void)startCrashReporting; diff --git a/ios/src/CountlyCrashReporter.m b/ios/src/CountlyCrashReporter.m index 23b56e63..682bfde1 100644 --- a/ios/src/CountlyCrashReporter.m +++ b/ios/src/CountlyCrashReporter.m @@ -48,6 +48,7 @@ NSString* const kCountlyCRKeyPLCrash = @"_plcrash"; NSString* const kCountlyCRKeyImageLoadAddress = @"la"; NSString* const kCountlyCRKeyImageBuildUUID = @"id"; +NSString* const kCountlyCRKeyOB = @"_ob"; @interface CountlyCrashReporter () @@ -107,7 +108,7 @@ - (void)startCrashReporting NSSetUncaughtExceptionHandler(&CountlyUncaughtExceptionHandler); -#if (TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_OSX) +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV || TARGET_OS_OSX) signal(SIGABRT, CountlySignalHandler); signal(SIGILL, CountlySignalHandler); signal(SIGSEGV, CountlySignalHandler); @@ -126,7 +127,7 @@ - (void)stopCrashReporting NSSetUncaughtExceptionHandler(NULL); -#if (TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_OSX) +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV || TARGET_OS_OSX) signal(SIGABRT, SIG_DFL); signal(SIGILL, SIG_DFL); signal(SIGSEGV, SIG_DFL); @@ -220,7 +221,8 @@ - (void)recordException:(NSException *)exception isFatal:(BOOL)isFatal stackTrac userInfo[kCountlyExceptionUserInfoBacktraceKey] = stackTrace; if(segmentation) { NSDictionary* truncatedSegmentation = [segmentation cly_truncated:@"Exception segmentation"]; - userInfo[kCountlyExceptionUserInfoSegmentationOverrideKey] = [truncatedSegmentation cly_limited:@"Exception segmentation"]; + NSDictionary* limitedSegmentation = [truncatedSegmentation cly_limited:@"Exception segmentation"]; + userInfo[kCountlyExceptionUserInfoSegmentationOverrideKey] = limitedSegmentation.cly_filterSupportedDataTypes; } exception = [NSException exceptionWithName:exception.name reason:exception.reason userInfo:userInfo]; } @@ -241,81 +243,72 @@ void CountlyUncaughtExceptionHandler(NSException *exception) 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); - NSInteger batteryLevel = CountlyDeviceInfo.batteryLevel; - // We will add battery level only if there is a valid value. - if (batteryLevel >= 0) - { - crashReport[kCountlyCRKeyBattery] = @(batteryLevel); + [CountlyCrashReporter.sharedInstance isMatchingFilter:exception.description] || + [CountlyCrashReporter.sharedInstance isMatchingFilter:exception.name]; } - 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]; - + NSDictionary* segmentationOverride = exception.userInfo[kCountlyExceptionUserInfoSegmentationOverrideKey]; if (segmentationOverride) [custom addEntriesFromDictionary:segmentationOverride]; - + NSMutableDictionary* userInfo = exception.userInfo.mutableCopy; [userInfo removeObjectForKey:kCountlyExceptionUserInfoBacktraceKey]; [userInfo removeObjectForKey:kCountlyExceptionUserInfoSignalCodeKey]; [userInfo removeObjectForKey:kCountlyExceptionUserInfoSegmentationOverrideKey]; [custom addEntriesFromDictionary:userInfo]; - - if (custom.allKeys.count) - crashReport[kCountlyCRKeyCustom] = custom; - - if (CountlyCrashReporter.sharedInstance.customCrashLogs) - crashReport[kCountlyCRKeyLogs] = [CountlyCrashReporter.sharedInstance.customCrashLogs componentsJoinedByString:@"\n"]; - + + CountlyCrashData* crashData = [CountlyCrashReporter.sharedInstance prepareCrashDataWithError:stackTraceJoined name:exception.name description:exception.description isFatal:isFatal customSegmentation:custom]; + BOOL filterCrash = NO; + if(CountlyCrashReporter.sharedInstance.crashFilterCallback) { + // Directly passing the callback as we are doing prviouslt with download variant + filterCrash = CountlyCrashReporter.sharedInstance.crashFilterCallback(crashData); + } + //NOTE: Do not send crash report if it is matching optional regex filter. - if (!matchesFilter) + if (matchesFilter || filterCrash) { - [CountlyConnectionManager.sharedInstance sendCrashReport:[crashReport cly_JSONify] immediately:isAutoDetect]; + CLY_LOG_D(@"Crash matches filter and it will not be processed."); } else { - CLY_LOG_D(@"Crash matches filter and it will not be processed."); + NSMutableDictionary* crashReport = [crashData.crashMetrics mutableCopy]; + crashReport[kCountlyCRKeyError] = crashData.stackTrace; + crashReport[kCountlyCRKeyBinaryImages] = [CountlyCrashReporter.sharedInstance binaryImagesForStackTrace:stackTrace]; + crashReport[kCountlyCRKeyName] = crashData.crashDescription; + crashReport[kCountlyCRKeyType] = crashData.name; + crashReport[kCountlyCRKeyNonfatal] = @(!crashData.fatal); + + [crashData calculateChangedFields]; + NSNumber *obValue = [crashData getChangedFieldsAsInt]; + if(obValue && [obValue intValue] > 0) { + crashReport[kCountlyCRKeyOB] = obValue; + } + + if (crashData.crashSegmentation) { + NSDictionary* truncatedCrashSegmentation = [crashData.crashSegmentation cly_truncated:@"Crash segmentation"]; + NSDictionary* limitedCrashSegmentation = [truncatedCrashSegmentation cly_limited:@"Crash segmentation"]; + crashReport[kCountlyCRKeyCustom] = limitedCrashSegmentation; + } + + if (crashData.breadcrumbs) { + crashReport[kCountlyCRKeyLogs] = [crashData.breadcrumbs componentsJoinedByString:@"\n"]; + } + + [CountlyConnectionManager.sharedInstance sendCrashReport:[crashReport cly_JSONify] immediately:isAutoDetect]; } if (isAutoDetect) @@ -324,7 +317,7 @@ void CountlyExceptionHandler(NSException *exception, bool isFatal, bool isAutoDe void CountlySignalHandler(int signalCode) { - const NSInteger kCountlyStackFramesMax = 128; + const unsigned int kCountlyStackFramesMax = 128; void *stack[kCountlyStackFramesMax]; NSInteger frameCount = backtrace(stack, kCountlyStackFramesMax); char **lines = backtrace_symbols(stack, (int)frameCount); @@ -482,6 +475,51 @@ -(void) setCrashSegmentation:(NSDictionary*) crashSegmen _crashSegmentation = [truncatedSegmentation cly_limited:@"Crash segmentation"]; } +- (CountlyCrashData *)prepareCrashDataWithError:(NSString *)error name:(NSString *)name description:(NSString *)description isFatal:(BOOL)isFatal customSegmentation:(NSMutableDictionary *)customSegmentation { + if(error == nil) { + CLY_LOG_W(@"Error must not be nil"); + } + + NSDictionary* truncatedSegmentation = [customSegmentation cly_truncated:@"Exception segmentation"]; + NSDictionary* limitedSegmentation = [truncatedSegmentation cly_limited:@"[CountlyCrashReporter] prepareCrashData"]; + + return [[CountlyCrashData alloc] initWithStackTrace:error name:name description:description crashSegmentation:limitedSegmentation breadcrumbs:self.customCrashLogs crashMetrics:[self getCrashMetrics] fatal:isFatal]; +} + +- (NSMutableDictionary*)getCrashMetrics +{ + const NSInteger kCLYMebibit = 1048576; + NSMutableDictionary* crashReport = NSMutableDictionary.dictionary; + + 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[kCountlyCRKeyRAMCurrent] = @((CountlyDeviceInfo.totalRAM - CountlyDeviceInfo.freeRAM) / kCLYMebibit); + crashReport[kCountlyCRKeyRAMTotal] = @(CountlyDeviceInfo.totalRAM / kCLYMebibit); + crashReport[kCountlyCRKeyDiskCurrent] = @((CountlyDeviceInfo.totalDisk - CountlyDeviceInfo.freeDisk) / kCLYMebibit); + crashReport[kCountlyCRKeyDiskTotal] = @(CountlyDeviceInfo.totalDisk / kCLYMebibit); + NSInteger batteryLevel = CountlyDeviceInfo.batteryLevel; + // We will add battery level only if there is a valid value. + if (batteryLevel >= 0) + { + crashReport[kCountlyCRKeyBattery] = @(batteryLevel); + } + crashReport[kCountlyCRKeyOrientation] = CountlyDeviceInfo.orientation; + crashReport[kCountlyCRKeyOnline] = @((CountlyDeviceInfo.connectionType) ? 1 : 0 ); + crashReport[kCountlyCRKeyRoot] = @(CountlyDeviceInfo.isJailbroken); + crashReport[kCountlyCRKeyBackground] = @(CountlyDeviceInfo.isInBackground); + crashReport[kCountlyCRKeyRun] = @(CountlyCommon.sharedInstance.timeSinceLaunch); + + return crashReport; +} + @end diff --git a/ios/src/CountlyCrashesConfig.h b/ios/src/CountlyCrashesConfig.h new file mode 100644 index 00000000..8b17bde5 --- /dev/null +++ b/ios/src/CountlyCrashesConfig.h @@ -0,0 +1,16 @@ +// ConfigCrashes.h +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + + +#import +#import "CountlyCrashData.h" + +@interface CountlyCrashesConfig : NSObject + +@property (nonatomic, copy) BOOL (^crashFilterCallback)(CountlyCrashData *); +- (void)setCrashFilterCallback:(BOOL (^)(CountlyCrashData *))crashFilterCallback; + +@end diff --git a/ios/src/CountlyCrashesConfig.m b/ios/src/CountlyCrashesConfig.m new file mode 100644 index 00000000..be57ef86 --- /dev/null +++ b/ios/src/CountlyCrashesConfig.m @@ -0,0 +1,18 @@ +// ConfigCrashes.m +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import "CountlyCrashesConfig.h" + +@implementation CountlyCrashesConfig + +@synthesize crashFilterCallback = _crashFilterCallback; + +- (void)setCrashFilterCallback:(BOOL (^)(CountlyCrashData *))crashFilterCallback { + if (_crashFilterCallback != crashFilterCallback) { + _crashFilterCallback = [crashFilterCallback copy]; + } +} +@end diff --git a/ios/src/CountlyDeviceInfo.m b/ios/src/CountlyDeviceInfo.m index 41c55321..8c140388 100644 --- a/ios/src/CountlyDeviceInfo.m +++ b/ios/src/CountlyDeviceInfo.m @@ -65,7 +65,7 @@ - (instancetype)init #endif #endif -#if (TARGET_OS_IOS || TARGET_OS_TV) +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV) self.isInBackground = (UIApplication.sharedApplication.applicationState == UIApplicationStateBackground); [NSNotificationCenter.defaultCenter addObserver:self @@ -107,7 +107,7 @@ - (NSString *)ensafeDeviceID:(NSString *)deviceID if (deviceID.length) return deviceID; -#if (TARGET_OS_IOS || TARGET_OS_TV) +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV) return UIDevice.currentDevice.identifierForVendor.UUIDString; #else NSString* UUID = [CountlyPersistency.sharedInstance retrieveNSUUID]; @@ -182,6 +182,8 @@ + (NSString *)deviceType return @"smarttv"; #elif (TARGET_OS_OSX) return @"desktop"; +#elif (TARGET_OS_VISION) + return @"vr"; #endif return nil; @@ -218,6 +220,8 @@ + (NSString *)osName return @"tvOS"; #elif (TARGET_OS_OSX) return @"macOS"; +#elif (TARGET_OS_VISION) + return @"visionOS"; #endif return nil; @@ -225,7 +229,7 @@ + (NSString *)osName + (NSString *)osVersion { -#if (TARGET_OS_IOS || TARGET_OS_TV) +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV) return UIDevice.currentDevice.systemVersion; #elif (TARGET_OS_WATCH) return WKInterfaceDevice.currentDevice.systemVersion; @@ -260,34 +264,35 @@ + (NSString *)carrier + (NSString *)resolution { + CGRect bounds; + CGFloat scale; #if (TARGET_OS_IOS || TARGET_OS_TV) - CGRect bounds = UIScreen.mainScreen.bounds; - CGFloat scale = UIScreen.mainScreen.scale; + bounds = UIScreen.mainScreen.bounds; + scale = UIScreen.mainScreen.scale; #elif (TARGET_OS_WATCH) - CGRect bounds = WKInterfaceDevice.currentDevice.screenBounds; - CGFloat scale = WKInterfaceDevice.currentDevice.screenScale; + bounds = WKInterfaceDevice.currentDevice.screenBounds; + scale = WKInterfaceDevice.currentDevice.screenScale; #elif (TARGET_OS_OSX) - NSRect bounds = NSScreen.mainScreen.frame; - CGFloat scale = NSScreen.mainScreen.backingScaleFactor; + bounds = NSScreen.mainScreen.frame; + scale = NSScreen.mainScreen.backingScaleFactor; #else return nil; #endif - return [NSString stringWithFormat:@"%gx%g", bounds.size.width * scale, bounds.size.height * scale]; } + (NSString *)density { + CGFloat scale; #if (TARGET_OS_IOS || TARGET_OS_TV) - CGFloat scale = UIScreen.mainScreen.scale; + scale = UIScreen.mainScreen.scale; #elif (TARGET_OS_WATCH) - CGFloat scale = WKInterfaceDevice.currentDevice.screenScale; + scale = WKInterfaceDevice.currentDevice.screenScale; #elif (TARGET_OS_OSX) - CGFloat scale = NSScreen.mainScreen.backingScaleFactor; + scale = NSScreen.mainScreen.backingScaleFactor; #else return nil; #endif - return [NSString stringWithFormat:@"@%dx", (int)scale]; } @@ -409,7 +414,7 @@ + (unsigned long long)totalDisk // If it is not possible to retrieve a valid value then it will return a -1. + (NSInteger)batteryLevel { -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) // If battey state is "unknown" that means that battery monitoring is not enabled. // In that case we will not able to retrieve a battery level. if (UIDevice.currentDevice.batteryState == UIDeviceBatteryStateUnknown) diff --git a/ios/src/CountlyExperimentalConfig.h b/ios/src/CountlyExperimentalConfig.h new file mode 100644 index 00000000..7e438310 --- /dev/null +++ b/ios/src/CountlyExperimentalConfig.h @@ -0,0 +1,16 @@ +// CountlyExperimentalConfig.h +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import + +extern NSString* const kCountlySCKeySC; + +@interface CountlyExperimentalConfig : NSObject + +@property (nonatomic) BOOL enablePreviousNameRecording; +@property (nonatomic) BOOL enableVisibiltyTracking; + +@end diff --git a/ios/src/CountlyExperimentalConfig.m b/ios/src/CountlyExperimentalConfig.m new file mode 100644 index 00000000..e01f3f23 --- /dev/null +++ b/ios/src/CountlyExperimentalConfig.m @@ -0,0 +1,22 @@ +// CountlyExperimentalConfig.m +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import "CountlyCommon.h" + +@implementation CountlyExperimentalConfig + +- (instancetype)init +{ + if (self = [super init]) + { + self.enableVisibiltyTracking = NO; + self.enablePreviousNameRecording = NO; + } + + return self; +} + +@end diff --git a/ios/src/CountlyFeedbackWidget.h b/ios/src/CountlyFeedbackWidget.h index 73a91582..5ae8f0fe 100644 --- a/ios/src/CountlyFeedbackWidget.h +++ b/ios/src/CountlyFeedbackWidget.h @@ -5,6 +5,7 @@ // Please visit www.count.ly for more information. #import +#import "CountlyConfig.h" NS_ASSUME_NONNULL_BEGIN @@ -41,6 +42,12 @@ extern NSString* const kCountlyReservedEventRating; */ - (void)presentWithAppearBlock:(void(^ __nullable)(void))appearBlock andDismissBlock:(void(^ __nullable)(void))dismissBlock; +/** + * Modally presents the feedback widget above the top visible view controller and executes given blocks. + * @discussion Calls to this method will be ignored if consent for @c CLYConsentFeedback is not given while @c requiresConsent flag is set on initial configuration. + * @param widgetCallback Block to be executed when widget is displayed/dismissed + */ +- (void)presentWithCallback:(WidgetCallback) widgetCallback; /** * Fetches feedback widget's data to be used for manually presenting it. * @discussion When feedback widget's data is fetched successfully, @c completionHandler will be executed with an @c NSDictionary diff --git a/ios/src/CountlyFeedbackWidget.m b/ios/src/CountlyFeedbackWidget.m index 6dadbb3f..7631dd69 100644 --- a/ios/src/CountlyFeedbackWidget.m +++ b/ios/src/CountlyFeedbackWidget.m @@ -45,49 +45,64 @@ + (CountlyFeedbackWidget *)createWithDictionary:(NSDictionary *)dictionary - (void)present { CLY_LOG_I(@"%s", __FUNCTION__); - + [self presentWithAppearBlock:nil andDismissBlock:nil]; } -- (void)presentWithAppearBlock:(void(^ __nullable)(void))appearBlock andDismissBlock:(void(^ __nullable)(void))dismissBlock; +- (void)presentWithAppearBlock:(void(^ __nullable)(void))appearBlock andDismissBlock:(void(^ __nullable)(void))dismissBlock { CLY_LOG_I(@"%s %@ %@", __FUNCTION__, appearBlock, dismissBlock); + [self presentWithCallback:^(WidgetState widgetState) { + if(appearBlock && widgetState == WIDGET_APPEARED) { + appearBlock(); + } + + if(dismissBlock && widgetState == WIDGET_CLOSED) { + dismissBlock(); + } + }]; +} +- (void)presentWithCallback:(WidgetCallback) widgetCallback; +{ + CLY_LOG_I(@"%s %@", __FUNCTION__, widgetCallback); if (!CountlyConsentManager.sharedInstance.consentForFeedback) return; - __block CLYInternalViewController* webVC = CLYInternalViewController.new; webVC.view.backgroundColor = [UIColor.blackColor colorWithAlphaComponent:0.4]; webVC.modalPresentationStyle = UIModalPresentationCustom; - - WKWebView* webView = [WKWebView.alloc initWithFrame:webVC.view.bounds]; + // Configure WKWebView with non-persistent data store + WKWebViewConfiguration *configuration = [[WKWebViewConfiguration alloc] init]; + configuration.websiteDataStore = [WKWebsiteDataStore nonPersistentDataStore]; + WKWebView* webView = [[WKWebView alloc] initWithFrame:webVC.view.bounds configuration:configuration]; webView.layer.shadowColor = UIColor.blackColor.CGColor; webView.layer.shadowOpacity = 0.5; - webView.layer.shadowOffset = (CGSize){0.0f, 5.0f}; + webView.layer.shadowOffset = CGSizeMake(0.0f, 5.0f); webView.layer.masksToBounds = NO; webView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; [webVC.view addSubview:webView]; webVC.webView = webView; NSURLRequest* request = [self displayRequest]; [webView loadRequest:request]; - CLYButton* dismissButton = [CLYButton dismissAlertButton]; dismissButton.onClick = ^(id sender) { [webVC dismissViewControllerAnimated:YES completion:^ { - if (dismissBlock) - dismissBlock(); - + CLY_LOG_D(@"Feedback widget dismissed. Widget ID: %@, Name: %@", self.ID, self.name); + if (widgetCallback) + widgetCallback(WIDGET_CLOSED); webVC = nil; }]; - [self recordReservedEventForDismissing]; }; [webView addSubview:dismissButton]; [dismissButton positionToTopRight]; - - [CountlyCommon.sharedInstance tryPresentingViewController:webVC withCompletion:appearBlock]; + [CountlyCommon.sharedInstance tryPresentingViewController:webVC withCompletion:^{ + CLY_LOG_D(@"Feedback widget presented. Widget ID: %@, Name: %@", self.ID, self.name); + if(widgetCallback) + widgetCallback(WIDGET_APPEARED); + }]; } - (void)getWidgetData:(void (^)(NSDictionary * __nullable widgetData, NSError * __nullable error))completionHandler @@ -99,16 +114,16 @@ - (void)getWidgetData:(void (^)(NSDictionary * __nullable widgetData, NSError * CLY_LOG_D(@"'getWidgetData' is aborted: SDK Networking is disabled from server config!"); return; } - + NSURLSessionTask* task = [NSURLSession.sharedSession dataTaskWithRequest:[self dataRequest] completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { NSDictionary *widgetData = nil; - + if (!error) { widgetData = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; } - + if (!error) { if (((NSHTTPURLResponse*)response).statusCode != 200) @@ -118,23 +133,23 @@ - (void)getWidgetData:(void (^)(NSDictionary * __nullable widgetData, NSError * error = [NSError errorWithDomain:kCountlyErrorDomain code:CLYErrorFeedbacksGeneralAPIError userInfo:userInfo]; } } - + self.data = widgetData; - + dispatch_async(dispatch_get_main_queue(), ^ { if (completionHandler) completionHandler(widgetData, error); }); }]; - + [task resume]; } - (void)recordResult:(NSDictionary * __nullable)result { CLY_LOG_I(@"%s %@", __FUNCTION__, result); - + if (!result) [self recordReservedEventForDismissing]; else @@ -144,25 +159,25 @@ - (void)recordResult:(NSDictionary * __nullable)result - (NSURLRequest *)dataRequest { NSString* queryString = [NSString stringWithFormat:@"%@=%@&%@=%@&%@=%@&%@=%@&%@=%@&%@=%@", - kCountlyQSKeySDKName, CountlyCommon.sharedInstance.SDKName, - kCountlyQSKeySDKVersion, CountlyCommon.sharedInstance.SDKVersion, - kCountlyFBKeyAppVersion, CountlyDeviceInfo.appVersion, - kCountlyFBKeyPlatform, CountlyDeviceInfo.osName, - kCountlyFBKeyShown, @"1", - kCountlyFBKeyWidgetID, self.ID]; + kCountlyQSKeySDKName, CountlyCommon.sharedInstance.SDKName, + kCountlyQSKeySDKVersion, CountlyCommon.sharedInstance.SDKVersion, + kCountlyFBKeyAppVersion, CountlyDeviceInfo.appVersion, + kCountlyFBKeyPlatform, CountlyDeviceInfo.osName, + kCountlyFBKeyShown, @"1", + kCountlyFBKeyWidgetID, self.ID]; queryString = [queryString stringByAppendingFormat:@"&%@=%@", kCountlyAppVersionKey, CountlyDeviceInfo.appVersion]; - + queryString = [CountlyConnectionManager.sharedInstance appendChecksum:queryString]; - + NSMutableString* URL = CountlyConnectionManager.sharedInstance.host.mutableCopy; [URL appendString:kCountlyEndpointO]; [URL appendString:kCountlyEndpointSurveys]; NSString* feedbackTypeEndpoint = [@"/" stringByAppendingString:self.type]; [URL appendString:feedbackTypeEndpoint]; [URL appendString:kCountlyEndpointWidget]; - + if (queryString.length > kCountlyGETRequestMaxLength || CountlyConnectionManager.sharedInstance.alwaysUsePOST) { NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:URL]]; @@ -178,47 +193,59 @@ - (NSURLRequest *)dataRequest } } -- (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]; +- (NSURLRequest *)displayRequest { + // Create the base URL with endpoint and feedback type + NSMutableString *URL = [NSMutableString stringWithFormat:@"%@%@/%@", + CountlyConnectionManager.sharedInstance.host, + kCountlyEndpointFeedback, + self.type]; - queryString = [queryString stringByAppendingFormat:@"&%@=%@", - kCountlyAppVersionKey, CountlyDeviceInfo.appVersion]; - + // Create a dictionary for query parameters + NSDictionary *queryParams = @{ + 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, + kCountlyAppVersionKey: CountlyDeviceInfo.appVersion, + }; + + // Create the query string + NSMutableArray *queryItems = [NSMutableArray array]; + [queryParams enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { + [queryItems addObject:[NSString stringWithFormat:@"%@=%@", key, obj]]; + }]; + + NSString *queryString = [queryItems componentsJoinedByString:@"&"]; + + // Append checksum to the query string queryString = [CountlyConnectionManager.sharedInstance appendChecksum:queryString]; - - NSMutableString* URL = CountlyConnectionManager.sharedInstance.host.mutableCopy; - [URL appendString:kCountlyEndpointFeedback]; - NSString* feedbackTypeEndpoint = [@"/" stringByAppendingString:self.type]; - [URL appendString:feedbackTypeEndpoint]; + + // Add the query string to the URL [URL appendFormat:@"?%@", queryString]; - // customParams is an NSDictionary containing the custom key-value pairs + // Create custom parameters NSDictionary *customParams = @{@"tc": @"1"}; - // Build custom parameter string - NSMutableString *customString = [NSMutableString stringWithString:@"&custom="]; - [customString appendString:@"{"]; - [customParams enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { - [customString appendFormat:@"%@:%@,", key, obj]; - }]; - [customString deleteCharactersInRange:NSMakeRange(customString.length - 1, 1)]; // Remove the last comma - [customString appendString:@"}"]; - - // Append custom parameter - [URL appendString:customString]; + // Create JSON data from custom parameters + NSError *error; + NSData *jsonData = [NSJSONSerialization dataWithJSONObject:customParams options:0 error:&error]; + + if (!jsonData) { + NSLog(@"Failed to serialize JSON: %@", error); + } else { + NSString *customString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; + // Append the custom parameter to the URL + [URL appendFormat:@"&custom=%@", customString.cly_URLEscaped]; + } - NSURLRequest* request = [NSURLRequest requestWithURL:[NSURL URLWithString:URL]]; - return request; + // Create and return the NSURLRequest + return [NSURLRequest requestWithURL:[NSURL URLWithString:URL]]; } + - (void)recordReservedEventForDismissing { [self recordReservedEventWithSegmentation:@{kCountlyFBKeyClosed: @1}]; @@ -228,7 +255,7 @@ - (void)recordReservedEventWithSegmentation:(NSDictionary *)segm { if (!CountlyConsentManager.sharedInstance.consentForFeedback) return; - + NSString* eventName = nil; if ([self.type isEqualToString:CLYFeedbackWidgetTypeSurvey]) eventName = kCountlyReservedEventSurvey; @@ -236,19 +263,19 @@ - (void)recordReservedEventWithSegmentation:(NSDictionary *)segm eventName = kCountlyReservedEventNPS; else if ([self.type isEqualToString:CLYFeedbackWidgetTypeRating]) eventName = kCountlyReservedEventRating; - + if (!eventName) { CLY_LOG_W(@"Unsupported feedback widget type! Event will not be recorded!"); return; } - + NSMutableDictionary* segmentation = segm.mutableCopy; segmentation[kCountlyFBKeyPlatform] = CountlyDeviceInfo.osName; segmentation[kCountlyFBKeyAppVersion] = CountlyDeviceInfo.appVersion; segmentation[kCountlyFBKeyWidgetID] = self.ID; [Countly.sharedInstance recordReservedEvent:eventName segmentation:segmentation]; - + [CountlyConnectionManager.sharedInstance sendEvents]; } @@ -260,3 +287,4 @@ - (NSString *)description #endif @end + diff --git a/ios/src/CountlyFeedbacks.h b/ios/src/CountlyFeedbacks.h index dd2595e4..446d1e55 100644 --- a/ios/src/CountlyFeedbacks.h +++ b/ios/src/CountlyFeedbacks.h @@ -1,35 +1,30 @@ -// CountlyFeedbacks.h +// CountlyFeedbacks.h // // This code is provided under the MIT License. // // Please visit www.count.ly for more information. +// #import +#import "CountlyConfig.h" +#import "CountlyFeedbackWidget.h" -@class CountlyFeedbackWidget; - -extern NSString* const kCountlyFBKeyPlatform; -extern NSString* const kCountlyFBKeyAppVersion; -extern NSString* const kCountlyFBKeyWidgetID; -extern NSString* const kCountlyFBKeyID; - -extern NSString* const kCountlyReservedEventStarRating; - -@interface CountlyFeedbacks : NSObject +@interface CountlyFeedbacks: NSObject #if (TARGET_OS_IOS) + (instancetype)sharedInstance; -- (void)showDialog:(void(^)(NSInteger rating))completion; -- (void)presentRatingWidgetWithID:(NSString *)widgetID closeButtonText:(NSString *)closeButtonText completionHandler:(void (^)(NSError * error))completionHandler; -- (void)recordRatingWidgetWithID:(NSString *)widgetID rating:(NSInteger)rating email:(NSString *)email comment:(NSString *)comment userCanBeContacted:(BOOL)userCanBeContacted; -- (void)checkForStarRatingAutoAsk; +- (void) presentNPS; +- (void) presentNPS:(NSString *)nameIDorTag; +- (void) presentNPS:(NSString *)nameIDorTag widgetCallback:(WidgetCallback) widgetCallback; + +- (void) presentSurvey; +- (void) presentSurvey:(NSString *)nameIDorTag; +- (void) presentSurvey:(NSString *)nameIDorTag widgetCallback:(WidgetCallback) widgetCallback; -- (void)getFeedbackWidgets:(void (^)(NSArray *feedbackWidgets, NSError *error))completionHandler; +- (void) presentRating; +- (void) presentRating:(NSString *)nameIDorTag; +- (void) presentRating:(NSString *)nameIDorTag widgetCallback:(WidgetCallback) widgetCallback; -@property (nonatomic) NSString* message; -@property (nonatomic) NSString* dismissButtonTitle; -@property (nonatomic) NSUInteger sessionCount; -@property (nonatomic) BOOL disableAskingForEachAppVersion; -@property (nonatomic, copy) void (^ratingCompletionForAutoAsk)(NSInteger); +- (void)getAvailableFeedbackWidgets:(void (^)(NSArray *feedbackWidgets, NSError * error))completionHandler; #endif @end diff --git a/ios/src/CountlyFeedbacks.m b/ios/src/CountlyFeedbacks.m index 44fff2ba..5b566c0d 100644 --- a/ios/src/CountlyFeedbacks.m +++ b/ios/src/CountlyFeedbacks.m @@ -1,57 +1,20 @@ -// CountlyFeedbacks.m +// CountlyFeedbacks.m // // This code is provided under the MIT License. // // Please visit www.count.ly for more information. +// +#import "CountlyFeedbacks.h" #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"; -NSString* const kCountlyFBKeyEmail = @"email"; -NSString* const kCountlyFBKeyComment = @"comment"; -NSString* const kCountlyFBKeyContactMe = @"contactMe"; - -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;}); @@ -60,452 +23,56 @@ + (instancetype)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"]; - } - + self = [super init]; + return self; } -#pragma mark - Star Rating - -- (void)showDialog:(void(^)(NSInteger rating))completion +- (void)enterContentZone:(NSArray *)tags { - 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) - { - CLY_LOG_W(@"%s, UIAlertController's contentViewController can not be set, got exception %@", __FUNCTION__, exception); - } - - [CountlyCommon.sharedInstance tryPresentingViewController:self.alertController]; + [CountlyContentBuilderInternal.sharedInstance enterContentZone:tags]; } -- (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) - { - CLY_LOG_D(@"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]; +- (void)presentNPS { + [self presentNPS:nil widgetCallback:nil]; } -- (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)presentNPS:(NSString *)nameIDorTag { + [self presentNPS:nameIDorTag widgetCallback:nil]; } -- (void)setMessage:(NSString *)message -{ - if (!message) - return; - - _message = message; +- (void) presentNPS:(NSString *)nameIDorTag widgetCallback:(WidgetCallback) widgetCallback { + [CountlyFeedbacksInternal.sharedInstance presentNPS:nameIDorTag widgetCallback:widgetCallback]; } -- (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)presentSurvey { + [self presentSurvey:nil widgetCallback:nil]; } -- (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; +- (void)presentSurvey:(NSString *)nameIDorTag { + [self presentSurvey:nameIDorTag widgetCallback:nil]; } -- (UIColor *)activeStarColor -{ - return [UIColor colorWithRed:253/255.0 green:148/255.0 blue:38/255.0 alpha:1]; +- (void) presentSurvey:(NSString *)nameIDorTag widgetCallback:(WidgetCallback) widgetCallback { + [CountlyFeedbacksInternal.sharedInstance presentSurvey:nameIDorTag widgetCallback:widgetCallback]; } -- (UIColor *)passiveStarColor -{ - return [UIColor colorWithWhite:178/255.0 alpha:1]; +- (void)presentRating { + [self presentRating:nil widgetCallback:nil]; } -#pragma mark - Feedbacks (Ratings) (Legacy Feedback Widget) - -- (void)presentRatingWidgetWithID:(NSString *)widgetID closeButtonText:(NSString *)closeButtonText completionHandler:(void (^)(NSError * error))completionHandler -{ - if (!CountlyServerConfig.sharedInstance.networkingEnabled) - { - CLY_LOG_D(@"'presentRatingWidgetWithID' is aborted: SDK Networking is disabled from server config!"); - return; - } - - 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 presentRatingWidgetInternal:widgetID closeButtonText:closeButtonText completionHandler:completionHandler]; - }); - }]; - - [task resume]; -} - -- (void)presentRatingWidgetInternal:(NSString *)widgetID closeButtonText:(NSString *)closeButtonText 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:closeButtonText]; - dismissButton.onClick = ^(id sender) - { - [webVC dismissViewControllerAnimated:YES completion:^ - { - if (completionHandler) - completionHandler(nil); - - webVC = nil; - }]; - }; - [webVC.view addSubview:dismissButton]; - [dismissButton positionToTopRightConsideringStatusBar]; - - [CountlyCommon.sharedInstance tryPresentingViewController:webVC]; +- (void)presentRating:(NSString *)nameIDorTag { + [self presentRating:nameIDorTag widgetCallback:nil]; } -- (NSURLRequest *)widgetCheckURLRequest:(NSString *)widgetID -{ - NSString* queryString = [CountlyConnectionManager.sharedInstance queryEssentials]; - - queryString = [queryString stringByAppendingFormat:@"&%@=%@", kCountlyFBKeyWidgetID, widgetID]; - - queryString = [queryString stringByAppendingFormat:@"&%@=%@", - kCountlyAppVersionKey, CountlyDeviceInfo.appVersion]; - - queryString = [CountlyConnectionManager.sharedInstance appendChecksum:queryString]; - - NSString* serverOutputFeedbackWidgetEndpoint = [CountlyConnectionManager.sharedInstance.host stringByAppendingFormat:@"%@%@%@", - kCountlyEndpointO, - kCountlyEndpointFeedback, - kCountlyEndpointWidget]; - - if (queryString.length > kCountlyGETRequestMaxLength || 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; - } +- (void) presentRating:(NSString *)nameIDorTag widgetCallback:(WidgetCallback) widgetCallback { + [CountlyFeedbacksInternal.sharedInstance presentRating:nameIDorTag widgetCallback:widgetCallback]; } -- (NSURL *)widgetDisplayURL:(NSString *)widgetID +- (void)getAvailableFeedbackWidgets:(void (^)(NSArray *feedbackWidgets, NSError * error))completionHandler { - NSString* queryString = [CountlyConnectionManager.sharedInstance queryEssentials]; - - queryString = [queryString stringByAppendingFormat:@"&%@=%@&%@=%@", - kCountlyFBKeyWidgetID, widgetID, - kCountlyFBKeyAppVersion, CountlyDeviceInfo.appVersion]; - - queryString = [queryString stringByAppendingFormat:@"&%@=%@", - kCountlyAppVersionKey, 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 = UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPad; - BOOL isPhone = UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPhone; - BOOL isTabletTargeted = [widgetInfo[kCountlyFBKeyTargetDevices][kCountlyFBKeyTablet] boolValue]; - BOOL isPhoneTargeted = [widgetInfo[kCountlyFBKeyTargetDevices][kCountlyFBKeyPhone] boolValue]; - - return ((isTablet && isTabletTargeted) || (isPhone && isPhoneTargeted)); + CLY_LOG_I(@"%s %@", __FUNCTION__, completionHandler); + [CountlyFeedbacksInternal.sharedInstance getFeedbackWidgets:completionHandler]; } - -- (void)recordRatingWidgetWithID:(NSString *)widgetID rating:(NSInteger)rating email:(NSString *)email comment:(NSString *)comment userCanBeContacted:(BOOL)userCanBeContacted -{ - if (!CountlyConsentManager.sharedInstance.consentForFeedback) - return; - - if (!widgetID.length) - return; - - NSMutableDictionary* segmentation = NSMutableDictionary.new; - segmentation[kCountlyFBKeyPlatform] = CountlyDeviceInfo.osName; - segmentation[kCountlyFBKeyAppVersion] = CountlyDeviceInfo.appVersion; - segmentation[kCountlyFBKeyRating] = @(rating); - segmentation[kCountlyFBKeyWidgetID] = widgetID; - segmentation[kCountlyFBKeyEmail] = email; - segmentation[kCountlyFBKeyComment] = comment; - segmentation[kCountlyFBKeyContactMe] = @(userCanBeContacted); - - [Countly.sharedInstance recordReservedEvent:kCountlyReservedEventStarRating segmentation:segmentation]; -} - - -#pragma mark - Feedbacks (Surveys, NPS) - -- (void)getFeedbackWidgets:(void (^)(NSArray *feedbackWidgets, NSError *error))completionHandler -{ - if (!CountlyServerConfig.sharedInstance.networkingEnabled) - { - CLY_LOG_D(@"'getFeedbackWidgets' is aborted: SDK Networking is disabled from server config!"); - return; - } - - 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 = [queryString stringByAppendingFormat:@"&%@=%@", - kCountlyAppVersionKey, CountlyDeviceInfo.appVersion]; - - queryString = [CountlyConnectionManager.sharedInstance appendChecksum:queryString]; - - NSMutableString* URL = CountlyConnectionManager.sharedInstance.host.mutableCopy; - [URL appendString:kCountlyEndpointO]; - [URL appendString:kCountlyEndpointSDK]; - - if (queryString.length > kCountlyGETRequestMaxLength || 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/ios/src/CountlyFeedbacksInternal.h b/ios/src/CountlyFeedbacksInternal.h new file mode 100644 index 00000000..85518ebe --- /dev/null +++ b/ios/src/CountlyFeedbacksInternal.h @@ -0,0 +1,41 @@ +// CountlyFeedbacksInternal.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; + +extern NSString* const kCountlyReservedEventStarRating; + +@interface CountlyFeedbacksInternal : NSObject +#if (TARGET_OS_IOS) ++ (instancetype)sharedInstance; + +- (void)showDialog:(void(^)(NSInteger rating))completion; +- (void)presentRatingWidgetWithID:(NSString *)widgetID closeButtonText:(NSString *)closeButtonText completionHandler:(void (^)(NSError * error))completionHandler; +- (void)recordRatingWidgetWithID:(NSString *)widgetID rating:(NSInteger)rating email:(NSString *)email comment:(NSString *)comment userCanBeContacted:(BOOL)userCanBeContacted; +- (void)checkForStarRatingAutoAsk; + +- (void)getFeedbackWidgets:(void (^)(NSArray *feedbackWidgets, NSError *error))completionHandler; + +- (void) presentNPS:(NSString *)nameIDorTag widgetCallback:(WidgetCallback) wigetCallback; + +- (void) presentSurvey:(NSString *)nameIDorTag widgetCallback:(WidgetCallback) wigetCallback; + +- (void) presentRating:(NSString *)nameIDorTag widgetCallback:(WidgetCallback) wigetCallback; + +@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/ios/src/CountlyFeedbacksInternal.m b/ios/src/CountlyFeedbacksInternal.m new file mode 100644 index 00000000..4a7bb9e2 --- /dev/null +++ b/ios/src/CountlyFeedbacksInternal.m @@ -0,0 +1,568 @@ +// 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 CountlyFeedbacksInternal () +#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"; +NSString* const kCountlyFBKeyEmail = @"email"; +NSString* const kCountlyFBKeyComment = @"comment"; +NSString* const kCountlyFBKeyContactMe = @"contactMe"; + +const CGFloat kCountlyStarRatingButtonSize = 40.0; + +@implementation CountlyFeedbacksInternal +#if (TARGET_OS_IOS) +{ + UIButton* btn_star[5]; +} + ++ (instancetype)sharedInstance +{ + if (!CountlyCommon.sharedInstance.hasStarted) + return nil; + + static CountlyFeedbacksInternal* 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) + { + CLY_LOG_W(@"%s, UIAlertController's contentViewController can not be set, got exception %@", __FUNCTION__, 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) + { + CLY_LOG_D(@"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)presentRatingWidgetWithID:(NSString *)widgetID closeButtonText:(NSString *)closeButtonText completionHandler:(void (^)(NSError * error))completionHandler +{ + if (!CountlyServerConfig.sharedInstance.networkingEnabled) + { + CLY_LOG_D(@"'presentRatingWidgetWithID' is aborted: SDK Networking is disabled from server config!"); + return; + } + + 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 presentRatingWidgetInternal:widgetID closeButtonText:closeButtonText completionHandler:completionHandler]; + }); + }]; + + [task resume]; +} + +- (void)presentRatingWidgetInternal:(NSString *)widgetID closeButtonText:(NSString *)closeButtonText 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:closeButtonText]; + 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 = [queryString stringByAppendingFormat:@"&%@=%@", + kCountlyAppVersionKey, CountlyDeviceInfo.appVersion]; + + queryString = [CountlyConnectionManager.sharedInstance appendChecksum:queryString]; + + NSString* serverOutputFeedbackWidgetEndpoint = [CountlyConnectionManager.sharedInstance.host stringByAppendingFormat:@"%@%@%@", + kCountlyEndpointO, + kCountlyEndpointFeedback, + kCountlyEndpointWidget]; + + if (queryString.length > kCountlyGETRequestMaxLength || 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 = [queryString stringByAppendingFormat:@"&%@=%@", + kCountlyAppVersionKey, 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 = UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPad; + BOOL isPhone = UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPhone; + BOOL isTabletTargeted = [widgetInfo[kCountlyFBKeyTargetDevices][kCountlyFBKeyTablet] boolValue]; + BOOL isPhoneTargeted = [widgetInfo[kCountlyFBKeyTargetDevices][kCountlyFBKeyPhone] boolValue]; + + return ((isTablet && isTabletTargeted) || (isPhone && isPhoneTargeted)); +} + +- (void)recordRatingWidgetWithID:(NSString *)widgetID rating:(NSInteger)rating email:(NSString *)email comment:(NSString *)comment userCanBeContacted:(BOOL)userCanBeContacted +{ + if (!CountlyConsentManager.sharedInstance.consentForFeedback) + return; + + if (!widgetID.length) + return; + + NSMutableDictionary* segmentation = NSMutableDictionary.new; + segmentation[kCountlyFBKeyPlatform] = CountlyDeviceInfo.osName; + segmentation[kCountlyFBKeyAppVersion] = CountlyDeviceInfo.appVersion; + segmentation[kCountlyFBKeyRating] = @(rating); + segmentation[kCountlyFBKeyWidgetID] = widgetID; + segmentation[kCountlyFBKeyEmail] = email; + segmentation[kCountlyFBKeyComment] = comment; + segmentation[kCountlyFBKeyContactMe] = @(userCanBeContacted); + + [Countly.sharedInstance recordReservedEvent:kCountlyReservedEventStarRating segmentation:segmentation]; +} + + +#pragma mark - Feedbacks (Surveys, NPS) + +- (void)getFeedbackWidgets:(void (^)(NSArray *feedbackWidgets, NSError *error))completionHandler +{ + if (!CountlyServerConfig.sharedInstance.networkingEnabled) + { + CLY_LOG_D(@"'getFeedbackWidgets' is aborted: SDK Networking is disabled from server config!"); + return; + } + + 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 = [queryString stringByAppendingFormat:@"&%@=%@", + kCountlyAppVersionKey, CountlyDeviceInfo.appVersion]; + + queryString = [CountlyConnectionManager.sharedInstance appendChecksum:queryString]; + + NSMutableString* URL = CountlyConnectionManager.sharedInstance.host.mutableCopy; + [URL appendString:kCountlyEndpointO]; + [URL appendString:kCountlyEndpointSDK]; + + if (queryString.length > kCountlyGETRequestMaxLength || 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; + } +} + +- (void) presentNPS:(NSString *)nameIDorTag widgetCallback:(WidgetCallback) widgetCallback { + CLY_LOG_D(@"Presenting NPS widget with nameIDorTag: %@ and WidgetCallback: %@", nameIDorTag, widgetCallback); + [self presentFeedbackWidget:CLYFeedbackWidgetTypeNPS nameIDorTag:nameIDorTag widgetCallback:widgetCallback]; +} + +- (void) presentSurvey:(NSString *)nameIDorTag widgetCallback:(WidgetCallback) widgetCallback { + CLY_LOG_D(@"Presenting Survey widget with nameIDorTag: %@ and WidgetCallback: %@", nameIDorTag, widgetCallback); + [self presentFeedbackWidget:CLYFeedbackWidgetTypeSurvey nameIDorTag:nameIDorTag widgetCallback:widgetCallback]; +} + +- (void) presentRating:(NSString *)nameIDorTag widgetCallback:(WidgetCallback) widgetCallback { + CLY_LOG_D(@"Presenting Rating widget with nameIDorTag: %@ and WidgetCallback: %@", nameIDorTag, widgetCallback); + [self presentFeedbackWidget:CLYFeedbackWidgetTypeRating nameIDorTag:nameIDorTag widgetCallback:widgetCallback]; +} + + +-(void)presentFeedbackWidget:(CLYFeedbackWidgetType)widgetType nameIDorTag:(NSString *)nameIDorTag widgetCallback:(WidgetCallback) widgetCallback { + [Countly.sharedInstance getFeedbackWidgets:^(NSArray *feedbackWidgets, NSError *error) { + if (error) { + CLY_LOG_D(@"Getting widgets list failed. Error: %@", error); + return; + } + + CLY_LOG_D(@"Successfully retrieved feedback widgets. Total widgets count: %lu", (unsigned long)feedbackWidgets.count); + + NSPredicate *typePredicate = [NSPredicate predicateWithFormat:@"type == %@", widgetType]; + NSArray *filteredWidgets = [feedbackWidgets filteredArrayUsingPredicate:typePredicate]; + + CLY_LOG_D(@"Filtered widgets count for type '%@': %lu", widgetType, (unsigned long)filteredWidgets.count); + + CountlyFeedbackWidget *widgetToPresent = nil; + + if (nameIDorTag && nameIDorTag.length > 0) { + for (CountlyFeedbackWidget *feedbackWidget in filteredWidgets) { + if ([nameIDorTag isEqualToString:feedbackWidget.name] || + [nameIDorTag isEqualToString:feedbackWidget.ID] || + [feedbackWidget.tags containsObject:nameIDorTag]) { + widgetToPresent = feedbackWidget; + CLY_LOG_D(@"Exact match found for nameIDorTag '%@'. Widget ID: %@, Name: %@", nameIDorTag, feedbackWidget.ID, feedbackWidget.name); + break; + } + } + } + + if (!widgetToPresent && filteredWidgets.count > 0) { + widgetToPresent = filteredWidgets.firstObject; + CLY_LOG_D(@"No exact match found for nameIDorTag '%@'. Falling back to the first widget of type '%@'. Widget ID: %@, Name: %@", nameIDorTag, widgetType, widgetToPresent.ID, widgetToPresent.name); + } + + if (widgetToPresent) { + [widgetToPresent presentWithCallback:widgetCallback]; + } else { + CLY_LOG_D(@"No feedback widget found for the specified type: %@", widgetType); + } + }]; +} + +#endif +@end diff --git a/ios/src/CountlyNotificationService.h b/ios/src/CountlyNotificationService.h index 9d9c585e..66e5ec5c 100644 --- a/ios/src/CountlyNotificationService.h +++ b/ios/src/CountlyNotificationService.h @@ -6,7 +6,7 @@ #import -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) #import #endif @@ -24,7 +24,7 @@ extern NSString* const kCountlyPNKeyActionButtonTitle; extern NSString* const kCountlyPNKeyActionButtonURL; @interface CountlyNotificationService : NSObject -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) + (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent *))contentHandler API_AVAILABLE(ios(10.0)); #endif diff --git a/ios/src/CountlyNotificationService.m b/ios/src/CountlyNotificationService.m index 8ebf8fda..a42a08ad 100644 --- a/ios/src/CountlyNotificationService.m +++ b/ios/src/CountlyNotificationService.m @@ -25,7 +25,7 @@ NSString* const kCountlyPNKeyActionButtonURL = @"l"; @implementation CountlyNotificationService -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) + (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent *))contentHandler { COUNTLY_EXT_LOG(@"didReceiveNotificationRequest:withContentHandler:"); diff --git a/ios/src/CountlyPerformanceMonitoring.m b/ios/src/CountlyPerformanceMonitoring.m index 3deb4c88..b174b9ba 100644 --- a/ios/src/CountlyPerformanceMonitoring.m +++ b/ios/src/CountlyPerformanceMonitoring.m @@ -84,7 +84,7 @@ - (void)startPerformanceMonitoring #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) +#elif (TARGET_OS_IOS || TARGET_OS_VISION || 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 @@ -100,7 +100,7 @@ - (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) +#elif (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV) [NSNotificationCenter.defaultCenter removeObserver:self name:UIApplicationDidBecomeActiveNotification object:nil]; [NSNotificationCenter.defaultCenter removeObserver:self name:UIApplicationWillResignActiveNotification object:nil]; #endif diff --git a/ios/src/CountlyPersistency.m b/ios/src/CountlyPersistency.m index d8bfa012..5edff471 100644 --- a/ios/src/CountlyPersistency.m +++ b/ios/src/CountlyPersistency.m @@ -266,32 +266,43 @@ - (void)recordEvent:(CountlyEvent *)event { @synchronized (self.recordedEvents) { + if([Countly.user hasUnsyncedChanges]) + { + [Countly.user save]; + } + [self.recordedEvents addObject:event]; - + if (self.recordedEvents.count >= self.eventSendThreshold) + { [CountlyConnectionManager.sharedInstance sendEvents]; + } } } - (NSString *)serializedRecordedEvents { - NSMutableArray* tempArray = NSMutableArray.new; - + NSMutableArray *tempArray = NSMutableArray.new; + @synchronized (self.recordedEvents) { if (self.recordedEvents.count == 0) return nil; - - for (CountlyEvent* event in self.recordedEvents.copy) + + NSArray *eventsCopy = self.recordedEvents.copy; + + for (CountlyEvent *event in eventsCopy) { [tempArray addObject:[event dictionaryRepresentation]]; - [self.recordedEvents removeObject:event]; } + + [self.recordedEvents removeObjectsInArray:eventsCopy]; } - + return [tempArray cly_JSONify]; } + - (void)flushEvents { @synchronized (self.recordedEvents) @@ -303,7 +314,7 @@ - (void)flushEvents - (void)resetInstance:(BOOL) clearStorage { CLY_LOG_I(@"%s Clear Storage: %d", __FUNCTION__, clearStorage); - [CountlyConnectionManager.sharedInstance sendEvents]; + [CountlyConnectionManager.sharedInstance sendEventsWithSaveIfNeeded]; [self flushEvents]; [self clearAllTimedEvents]; [self flushQueue]; diff --git a/ios/src/CountlyPushNotifications.h b/ios/src/CountlyPushNotifications.h index 07293a74..dd92bd0d 100644 --- a/ios/src/CountlyPushNotifications.h +++ b/ios/src/CountlyPushNotifications.h @@ -18,7 +18,7 @@ extern NSString* const kCountlyReservedEventPushAction; + (instancetype)sharedInstance; -#if (TARGET_OS_IOS || TARGET_OS_OSX) +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_OSX) - (void)startPushNotifications; - (void)stopPushNotifications; - (void)askForNotificationPermissionWithOptions:(NSUInteger)options completionHandler:(void (^)(BOOL granted, NSError * error))completionHandler; diff --git a/ios/src/CountlyPushNotifications.m b/ios/src/CountlyPushNotifications.m index 1e4fbbe6..4b2cc319 100644 --- a/ios/src/CountlyPushNotifications.m +++ b/ios/src/CountlyPushNotifications.m @@ -17,7 +17,7 @@ CLYPushTestMode const CLYPushTestModeDevelopment = @"CLYPushTestModeDevelopment"; CLYPushTestMode const CLYPushTestModeTestFlightOrAdHoc = @"CLYPushTestModeTestFlightOrAdHoc"; -#if (TARGET_OS_IOS || TARGET_OS_OSX) +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_OSX) @interface CountlyPushNotifications () @property (nonatomic) NSString* token; #else @@ -25,7 +25,7 @@ @interface CountlyPushNotifications () #endif @end -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) #define CLYApplication UIApplication #elif (TARGET_OS_OSX) #define CLYApplication NSApplication @@ -58,7 +58,7 @@ - (instancetype)init #pragma mark --- -#if (TARGET_OS_IOS || TARGET_OS_OSX) +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_OSX) - (void)startPushNotifications { if (!self.isEnabledOnInitialConfig) @@ -198,7 +198,7 @@ - (void)openURL:(NSString *)URLString dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.01 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^ { -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) [UIApplication.sharedApplication openURL:[NSURL URLWithString:URLString] options:@{} completionHandler:nil]; #elif (TARGET_OS_OSX) [NSWorkspace.sharedWorkspace openURL:[NSURL URLWithString:URLString]]; @@ -223,7 +223,7 @@ - (void)recordActionEvent:(NSString *)notificationID buttonIndex:(NSInteger)butt return; NSString* platform = @"unknown"; -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) platform = kCountlyPNKeyiOS; #elif (TARGET_OS_OSX) platform = kCountlyPNKeymacOS; @@ -338,7 +338,7 @@ - (void)application:(CLYApplication *)application didFailToRegisterForRemoteNoti @implementation NSObject (CountlyPushNotifications) -#if (TARGET_OS_IOS || TARGET_OS_OSX) +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_OSX) - (void)Countly_application:(CLYApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken { CLY_LOG_D(@"App didRegisterForRemoteNotificationsWithDeviceToken: %@", deviceToken); diff --git a/ios/src/CountlyRemoteConfigInternal.m b/ios/src/CountlyRemoteConfigInternal.m index 8f375bb7..dd2ed7a8 100644 --- a/ios/src/CountlyRemoteConfigInternal.m +++ b/ios/src/CountlyRemoteConfigInternal.m @@ -166,6 +166,7 @@ - (id)remoteConfigValueForKey:(NSString *)key - (void)clearCachedRemoteConfig { + CLY_LOG_D(@"'clearCachedRemoteConfig' will cache or erase all remote config values."); if (!self.isRCValueCachingEnabled) { [self clearAll]; @@ -178,6 +179,7 @@ - (void)clearCachedRemoteConfig -(void)clearAll { + CLY_LOG_D(@"'clearAll' will erase all remote config values."); self.cachedRemoteConfig = NSMutableDictionary.new; [CountlyPersistency.sharedInstance storeRemoteConfig:self.cachedRemoteConfig]; } @@ -420,6 +422,7 @@ - (NSDictionary *) createRCMeta:(NSDictionary *) remoteConfig - (void)updateMetaStateToCache { + CLY_LOG_D(@"'updateMetaStateToCache' will cache all remote config values."); [self.cachedRemoteConfig enumerateKeysAndObjectsUsingBlock:^(NSString * key, CountlyRCData * countlyRCMeta, BOOL * stop) { countlyRCMeta.isCurrentUsersData = NO; @@ -605,16 +608,12 @@ - (void)testingEnrollIntoVariantInternal:(NSString *)key variantName:(NSString * NSURLRequest* request = [self enrollInVarianRequestForKey:key variantName:variantName]; NSURLSessionTask* task = [NSURLSession.sharedSession dataTaskWithRequest:request completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) - { + { NSDictionary* variants = nil; - + [self clearCachedRemoteConfig]; if (!error) { variants = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; - } - - if (!error) - { if (((NSHTTPURLResponse*)response).statusCode != 200) { NSMutableDictionary* userInfo = variants.mutableCopy; @@ -635,14 +634,10 @@ - (void)testingEnrollIntoVariantInternal:(NSString *)key variantName:(NSString * return; } + CLY_LOG_D(@"Enroll RC Variant Request <%p> successfully completed.", request); - [self updateRemoteConfigForKeys:nil omitKeys:nil completionHandler:^(NSError *updateRCError) { - dispatch_async(dispatch_get_main_queue(), ^ - { - completionHandler(CLYResponseSuccess, nil); - }); - }]; + [self downloadRemoteConfigAutomatically]; }]; diff --git a/ios/src/CountlyTests/CountlyBaseTestCase.swift b/ios/src/CountlyTests/CountlyBaseTestCase.swift index 12052126..90a5f60e 100644 --- a/ios/src/CountlyTests/CountlyBaseTestCase.swift +++ b/ios/src/CountlyTests/CountlyBaseTestCase.swift @@ -37,6 +37,41 @@ class CountlyBaseTestCase: XCTestCase { Countly.sharedInstance().halt(true) } + func parseQueryString(_ queryString: String) -> [String: Any] { + var result: [String: Any] = [:] + + // Split the query string by '&' to get individual key-value pairs + let pairs = queryString.split(separator: "&") + + for pair in pairs { + // Split each pair by '=' to separate the key and value + let components = pair.split(separator: "=", maxSplits: 1) + + if components.count == 2 { + let key = String(components[0]) + let value = String(components[1]) + + // If the value is a JSON string (starts and ends with '%7B' and '%7D' respectively after URL decoding), decode it + if let decodedValue = value.removingPercentEncoding, decodedValue.hasPrefix("{"), decodedValue.hasSuffix("}") { + if let jsonData = decodedValue.data(using: .utf8) { + do { + let jsonObject = try JSONSerialization.jsonObject(with: jsonData, options: []) + result[key] = jsonObject + continue + } catch { + print("Error decoding JSON for key \(key): \(error)") + } + } + } + + // Otherwise, simply assign the value to the key in the result dictionary + result[key] = value.removingPercentEncoding ?? value + } + } + + return result + } + } diff --git a/ios/src/CountlyTests/CountlyCrashReporterTests.swift b/ios/src/CountlyTests/CountlyCrashReporterTests.swift new file mode 100644 index 00000000..482021ba --- /dev/null +++ b/ios/src/CountlyTests/CountlyCrashReporterTests.swift @@ -0,0 +1,169 @@ +// +// CountlyCrashReporterTests.swift +// CountlyTests +// +// Created by Arif Burak Demiray on 23.05.2024. +// Copyright © 2024 Countly. All rights reserved. +// + +import Foundation + +import XCTest +@testable import Countly + +class CountlyCrashReporterTests: CountlyBaseTestCase { + + func testRecordHandledException_globalCrashFilter() throws { + let cConfig = createBaseConfig() + cConfig.manualSessionHandling = true + cConfig.crashSegmentation = [ + "secret": "Minato", + "int": String(Int.max), + "double": String(Double.greatestFiniteMagnitude), + "bool": String(true), + "long": String(Int64.max), + "float": String(1.1), + ] + + let crashFilterBlock: (CountlyCrashData?) -> Bool = { crash in + if (crash!.crashDescription.contains("Secret")) { + return true + } + + crash?.crashSegmentation.removeObject(forKey: "secret") + crash?.fatal = true + crash!.crashMetrics["secret"] = "Minato" + crash!.crashMetrics.removeObject(forKey: "_ram_total") + + let keys = crash!.crashSegmentation.allKeys as? [String] + return keys!.contains("sphinx_no_1") + } + + cConfig.crashes().crashFilterCallback = crashFilterBlock + + let countly = Countly() + countly.start(with: cConfig) + + // First Exception + let exception1 = NSException(name: NSExceptionName(rawValue: "secret"), reason: "Secret message") + countly.record(exception1, isFatal: false, stackTrace: nil, segmentation: nil) + XCTAssertEqual(0, CountlyPersistency.sharedInstance().remainingRequestCount()) + + // Second Exception + let exception2 = NSException(name: NSExceptionName(rawValue: "some"), reason: "Some message") + countly.record(exception2, isFatal: false, stackTrace: nil, segmentation: ["sphinx_no_1": "secret"]) + XCTAssertEqual(0, CountlyPersistency.sharedInstance().remainingRequestCount()) + + // Third Exception + countly.recordCrashLog("Breadcrumb_1") + countly.recordCrashLog("Breadcrumb_2") + let exception3 = NSException(name: NSExceptionName(rawValue: "some_other"), reason: "Some other message") + countly.record(exception3, isFatal: false, stackTrace: nil, segmentation: ["sphinx_no": "324"]) + XCTAssertEqual(1, CountlyPersistency.sharedInstance().remainingRequestCount()) + + try validateCrash( + extractStackTrace(exception3), + breadcrumbs: "Breadcrumb_1\nBreadcrumb_2", + isFatal: true, + changedBits: 11, + customSegmentation: [ + "int": String(Int.max), + "double": String(Double.greatestFiniteMagnitude), + "bool": String(true), + "float": String(1.1), + "long": String(Int64.max), + "sphinx_no": "324" + ], + idx: 0, + customMetrics: ["secret": "Minato"], + metricsToExclude: ["_ram_total"] + ) + } + + func validateCrash(_ stackTrace: String?, breadcrumbs: String, isFatal: Bool, changedBits: Int, customSegmentation: [String: Any], idx: Int, customMetrics: [String: Any], metricsToExclude: [String]) throws { + + if let queuedRequests = CountlyPersistency.sharedInstance().value(forKey: "queuedRequests") as? [String] { + let request = parseQueryString(queuedRequests[idx]) + //TestUtils.validateRequiredParams(RQ[idx]) + + let crash = request["crash"] as! [String: Any] + + var paramCount = 0 //try validateCrashMetrics(crash: crash, customMetrics: customMetrics, metricsToExclude: metricsToExclude) + + paramCount += 2 // for nonFatal and ob + XCTAssertEqual(!isFatal, crash["_nonfatal"] as? Bool) + XCTAssertEqual(changedBits, crash["_ob"] as? Int) + + if !customSegmentation.isEmpty { + paramCount += 1 + let custom = crash["_custom"] as? [String: Any] + + for (key, value) in customSegmentation { + XCTAssertEqual(value as? NSObject, custom![key] as? NSObject) + } + XCTAssertEqual(custom?.count, customSegmentation.count) + } + + if !breadcrumbs.isEmpty { + paramCount += 1 + XCTAssertEqual(breadcrumbs, crash["_logs"] as? String) + } + + //XCTAssertEqual(paramCount, crash.count) + } + + } + + func validateCrashMetrics(crash: [String: Any], customMetrics: [String: Any], metricsToExclude: [String]) throws -> Int { + var metricCount = 20 - metricsToExclude.count + + try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_device", expectedValue: "C", crash: crash) + try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_os", expectedValue: "A", crash: crash) + try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_os_version", expectedValue: "B", crash: crash) + try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_resolution", expectedValue: "E", crash: crash) + try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_app_version", expectedValue: "Countly.DEFAULT_APP_VERSION", crash: crash) + try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_manufacturer", expectedValue: "D", crash: crash) + try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_cpu", expectedValue: "N", crash: crash) + try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_opengl", expectedValue: "O", crash: crash) + try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_root", expectedValue: "T", crash: crash) + try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_has_hinge", expectedValue: "Z", crash: crash) + try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_ram_total", expectedValue: "48", crash: crash) + try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_disk_total", expectedValue: "45", crash: crash) + try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_ram_current", expectedValue: "12", crash: crash) + try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_disk_current", expectedValue: "23", crash: crash) + try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_run", expectedValue: "88", crash: crash) + try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_background", expectedValue: "true", crash: crash) + try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_muted", expectedValue: "V", crash: crash) + try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_orientation", expectedValue: "S", crash: crash) + try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_online", expectedValue: "U", crash: crash) + try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_bat", expectedValue: "6", crash: crash) + + + for (key, value) in customMetrics { + guard let crashValue = crash[key], "\(crashValue)" == "\(value)" else { + throw ValidationError.assertionFailed + } + } + metricCount += customMetrics.count + + return metricCount + } + + enum ValidationError: Error { + case jsonError + case assertionFailed + } + + func assertMetricIfNotExcluded(metricsToExclude: [String], key: String, expectedValue: String, crash: [String: Any]) throws { + if !metricsToExclude.contains(key) { + XCTAssertTrue(expectedValue == crash[key] as? String) + }else{ + XCTAssertNil(crash[key]) + } + } + + func extractStackTrace(_ exception: NSException) -> String? { + return exception.callStackSymbols.joined(separator: "\n") + } + +} diff --git a/ios/src/CountlyTests/CountlyDeviceIDTests.swift b/ios/src/CountlyTests/CountlyDeviceIDTests.swift new file mode 100644 index 00000000..208e9685 --- /dev/null +++ b/ios/src/CountlyTests/CountlyDeviceIDTests.swift @@ -0,0 +1,135 @@ +// +// CountlyDeviceIDTests.swift +// CountlyTests +// +// Created by Muhammad Junaid Akram on 06/06/2024. +// Copyright © 2024 Countly. All rights reserved. +// + +import Foundation +import XCTest +@testable import Countly + +class CountlyDeviceIDTests: CountlyBaseTestCase { + + var testDeviceID: String = "test1234" + // Run this test first if you are facing cache not clear or instances are not reset properly + // This is a dummy test to cover the edge case clear the cache when SDK is not initialized + func testDummy() { + } + + + // "setID" with custom device id + // - validate that device id is developer supplied + // - set same id and validate that it is not set + + func test_setID_sameCustom() { + let config = createBaseConfig() + config.requiresConsent = false; + config.deviceID = testDeviceID; + Countly.sharedInstance().start(with: config); + + validateDeveloperSuppliedID(deviceID: testDeviceID) + + Countly.sharedInstance().setID(testDeviceID) + + validateDeveloperSuppliedID(deviceID: testDeviceID) + } + + // "setID" with custom device id + // - validate that device id is developer supplied + // - set same id and validate that it is not set + + func test_setID_custom() { + let config = createBaseConfig() + config.requiresConsent = false; + config.deviceID = testDeviceID; + Countly.sharedInstance().start(with: config); + + validateDeveloperSuppliedID(deviceID: testDeviceID) + + let newId = "New_ID" + Countly.sharedInstance().setID(newId) + + validateDeveloperSuppliedID(deviceID: newId) + } + + // "setID" + // - Validate that device id is generated by the sdk + // - Set a new id and validate that it is set + // - Set null and validate that it is not changed + // - Set empty and validate that it is not changed + // - Set the same id and validate that it is not changed + + func test_setID() { + let config = createBaseConfig() + config.requiresConsent = false; + Countly.sharedInstance().start(with: config); + validateSdkGeneratedID() // validate ID exists and is SDK generated + + let newId = "New_ID" + Countly.sharedInstance().setID(newId) + XCTAssertEqual(2, CountlyPersistency.sharedInstance().remainingRequestCount()) + guard let queuedRequests = CountlyPersistency.sharedInstance().value(forKey: "queuedRequests") as? [String] else { + fatalError("Failed to get queuedRequests from CountlyPersistency") + } + validateSetIdOnServerRequest(request: queuedRequests[1], newDeviceId: newId) + validateDeveloperSuppliedID(deviceID: newId) + + Countly.sharedInstance().setID("") + validateDeveloperSuppliedID(deviceID: newId) + + Countly.sharedInstance().setID("") + validateDeveloperSuppliedID(deviceID: newId) + + let sdkDeviceID = Countly.sharedInstance().deviceID() + + Countly.sharedInstance().setID(sdkDeviceID) + validateDeveloperSuppliedID(deviceID: newId) + } + + + func validateDeveloperSuppliedID(deviceID: String) { + let sdkDeviceID = Countly.sharedInstance().deviceID() + XCTAssertTrue(Countly.sharedInstance().deviceIDType() == CLYDeviceIDType.custom, "Countly deviced id type should be Custom when device id is provided during init.") + XCTAssertTrue(sdkDeviceID == deviceID, "Countly device id not match with provided device id.") + } + + func validateSdkGeneratedID() { + let sdkDeviceID = Countly.sharedInstance().deviceID() + XCTAssertTrue(Countly.sharedInstance().deviceIDType() == CLYDeviceIDType.IDFV, "Countly deviced id type should be IDFV when no device id is provided during init.") + XCTAssertTrue(sdkDeviceID == getIDFV(), "Countly device id not match with provided device id.") + } + + func validateSetIdOnServerRequest(request: String, newDeviceId: String) { + let parsedRequest = parseQueryString(request) + + let sdkDeviceID = Countly.sharedInstance().deviceID() + + let oldDeviceID = parsedRequest["old_device_id"] as! String; + let deviceIDInRequest = parsedRequest["device_id"] as! String; + + XCTAssertTrue(Countly.sharedInstance().deviceIDType() == CLYDeviceIDType.custom, "Countly deviced id type should be Custom.") + XCTAssertTrue(oldDeviceID == getIDFV()) + XCTAssertTrue(newDeviceId == deviceIDInRequest) + XCTAssertTrue(sdkDeviceID == deviceIDInRequest) + } + + func getIDFV() -> String { +#if (os(iOS) || os(tvOS)) + return UIDevice.current.identifierForVendor?.uuidString ?? "" +#else + var UUID = CountlyPersistency.sharedInstance().retrieveNSUUID() + if UUID == nil { + UUID = UUID().uuidString + CountlyPersistency.sharedInstance().storeNSUUID(UUID) + } + + return UUID ?? "" +#endif + } + +} + + + diff --git a/ios/src/CountlyTests/CountlyEventStruct.swift b/ios/src/CountlyTests/CountlyEventStruct.swift new file mode 100644 index 00000000..15086096 --- /dev/null +++ b/ios/src/CountlyTests/CountlyEventStruct.swift @@ -0,0 +1,124 @@ +// +// CountlyEventStruct.swift +// CountlyTests +// +// Created by Muhammad Junaid Akram on 30/05/2024. +// Copyright © 2024 Countly. All rights reserved. +// + +import Foundation + +// Helper struct to decode Any values in the segmentation dictionary +struct AnyCodable: Codable { + let value: Any + + init(_ value: Any) { + self.value = value + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if let intValue = try? container.decode(Int.self) { + value = intValue + } else if let doubleValue = try? container.decode(Double.self) { + value = doubleValue + } else if let boolValue = try? container.decode(Bool.self) { + value = boolValue + } else if let stringValue = try? container.decode(String.self) { + value = stringValue + } else if let nestedDictionary = try? container.decode([String: AnyCodable].self) { + value = nestedDictionary.mapValues { $0.value } + } else if let nestedArray = try? container.decode([AnyCodable].self) { + value = nestedArray.map { $0.value } + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported type") + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + if let intValue = value as? Int { + try container.encode(intValue) + } else if let doubleValue = value as? Double { + try container.encode(doubleValue) + } else if let boolValue = value as? Bool { + try container.encode(boolValue) + } else if let stringValue = value as? String { + try container.encode(stringValue) + } else if let nestedDictionary = value as? [String: Any] { + try container.encode(nestedDictionary.mapValues { AnyCodable($0) }) + } else if let nestedArray = value as? [Any] { + try container.encode(nestedArray.map { AnyCodable($0) }) + } else { + throw EncodingError.invalidValue(value, EncodingError.Context(codingPath: container.codingPath, debugDescription: "Unsupported type")) + } + } +} + +// Define a struct that matches the CountlyEvent class properties +struct CountlyEventStruct: Codable { + let key: String + let ID: String + let CVID: String + let PVID: String? + let PEID: String + let segmentation: [String: Any]? + let count: UInt + let sum: Double + let timestamp: TimeInterval + let hourOfDay: UInt + let dayOfWeek: UInt + let duration: TimeInterval + + enum CodingKeys: String, CodingKey { + case key, ID = "id", CVID = "cvid", PVID = "pvid", PEID = "peid", segmentation, count, sum, timestamp, hourOfDay = "hour", dayOfWeek = "dow", duration = "dur" + } + + // Custom decoding for the segmentation dictionary + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + key = try container.decode(String.self, forKey: .key) + ID = try container.decode(String.self, forKey: .ID) + CVID = try container.decode(String.self, forKey: .CVID) + PVID = try container.decodeIfPresent(String.self, forKey: .PVID) + PEID = try container.decode(String.self, forKey: .PEID) + count = try container.decode(UInt.self, forKey: .count) + sum = try container.decode(Double.self, forKey: .sum) + timestamp = try container.decode(TimeInterval.self, forKey: .timestamp) + hourOfDay = try container.decode(UInt.self, forKey: .hourOfDay) + dayOfWeek = try container.decode(UInt.self, forKey: .dayOfWeek) + duration = try container.decode(TimeInterval.self, forKey: .duration) + + do { + let segmentationData = try container.decodeIfPresent([String: AnyCodable].self, forKey: .segmentation) + segmentation = segmentationData?.mapValues { $0.value } + } catch { + print("Error decoding segmentation: \(error.localizedDescription)") + segmentation = nil + } + } + + // Custom encoding for the segmentation dictionary + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(key, forKey: .key) + try container.encode(ID, forKey: .ID) + try container.encode(CVID, forKey: .CVID) + try container.encode(PVID, forKey: .PVID) + try container.encode(PEID, forKey: .PEID) + try container.encode(count, forKey: .count) + try container.encode(sum, forKey: .sum) + try container.encode(timestamp, forKey: .timestamp) + try container.encode(hourOfDay, forKey: .hourOfDay) + try container.encode(dayOfWeek, forKey: .dayOfWeek) + try container.encode(duration, forKey: .duration) + + if let segmentation = segmentation { + let segmentationData = segmentation.mapValues { AnyCodable($0) } + try container.encode(segmentationData, forKey: .segmentation) + } + } +} + diff --git a/ios/src/CountlyTests/CountlyLocationTests.swift b/ios/src/CountlyTests/CountlyLocationTests.swift new file mode 100644 index 00000000..55eb1178 --- /dev/null +++ b/ios/src/CountlyTests/CountlyLocationTests.swift @@ -0,0 +1,259 @@ +// +// CountlyLocationTests.swift +// CountlyTests +// +// Created by Muhammad Junaid Akram on 25/07/2024. +// Copyright © 2024 Countly. All rights reserved. +// + +import XCTest +@testable import Countly + +// M:Manual Sessions enabled +// A:Automatic sessions enabled +// H:Hybrid Sessions enabled +// CR:Consent Required +// CNR:Consent not Required +// CG:Consent given (All) +// CNG:Consent not given (All) +// CGS:Consent given for session +// CGL:Consent givent for location +// LD:Location Disable +// L: Location Provided + +class CountlyLocationTests: CountlyBaseTestCase { + + func testDummy() { + } + + func testLocationInit_CNR_A() throws { + let config = createBaseConfig() + Countly.sharedInstance().start(with: config); + + // get request queue + guard let queuedRequests = CountlyPersistency.sharedInstance().value(forKey: "queuedRequests") as? [String] else { + fatalError("Failed to get queuedRequests from CountlyPersistency") + } + XCTAssertTrue(queuedRequests[0].contains("begin_session=1"), "Begin session failed.") + XCTAssertFalse(queuedRequests[0].contains("location="), "Location should not be send in this scenario") + } + + func testLocationInit_CR_CNG_A() throws { + let config = createBaseConfig() + config.requiresConsent = true + Countly.sharedInstance().start(with: config); + + // get request queue + guard let queuedRequests = CountlyPersistency.sharedInstance().value(forKey: "queuedRequests") as? [String] else { + fatalError("Failed to get queuedRequests from CountlyPersistency") + } + + XCTAssertFalse(queuedRequests[0].contains("begin_session=1"), "Begin session should not start session consent is not given.") + XCTAssertTrue(queuedRequests[1].contains("location="), "Individual location request should send in this scenario") + } + + func testLocationInit_CR_CG_A() throws { + let config = createBaseConfig() + config.requiresConsent = true + config.enableAllConsents = true + Countly.sharedInstance().start(with: config); + + // get request queue + guard let queuedRequests = CountlyPersistency.sharedInstance().value(forKey: "queuedRequests") as? [String] else { + fatalError("Failed to get queuedRequests from CountlyPersistency") + } + + XCTAssertTrue(queuedRequests[0].contains("begin_session=1"), "Begin session failed.") + XCTAssertFalse(queuedRequests[0].contains("location="), "Location should not be send in this scenario") + } + + func testLocationInit_CR_CGS_A() throws { + let config = createBaseConfig() + config.requiresConsent = true + config.consents = [CLYConsent.sessions]; + Countly.sharedInstance().start(with: config); + + // get request queue + guard let queuedRequests = CountlyPersistency.sharedInstance().value(forKey: "queuedRequests") as? [String] else { + fatalError("Failed to get queuedRequests from CountlyPersistency") + } + + XCTAssertTrue(queuedRequests[0].contains("begin_session=1"), "Begin session failed.") + XCTAssertTrue(queuedRequests[0].contains("location="), "Location should send in this scenario") + } + + func testLocationInit_CR_CGL_A() throws { + let config = createBaseConfig() + config.requiresConsent = true + config.consents = [CLYConsent.location]; + Countly.sharedInstance().start(with: config); + + // get request queue + guard let queuedRequests = CountlyPersistency.sharedInstance().value(forKey: "queuedRequests") as? [String] else { + fatalError("Failed to get queuedRequests from CountlyPersistency") + } + + XCTAssertTrue(queuedRequests[0].contains("consent="), "Only consent request should send in this scenario") + } + + func testLocationInit_CR_CGLS_A() throws { + let config = createBaseConfig() + config.requiresConsent = true + config.consents = [CLYConsent.location, CLYConsent.sessions]; + Countly.sharedInstance().start(with: config); + + // get request queue + guard let queuedRequests = CountlyPersistency.sharedInstance().value(forKey: "queuedRequests") as? [String] else { + fatalError("Failed to get queuedRequests from CountlyPersistency") + } + + XCTAssertTrue(queuedRequests[0].contains("begin_session=1"), "Begin session failed.") + XCTAssertFalse(queuedRequests[0].contains("location="), "Location should not be send in this scenario") + } + + func testLocationInit_CNR_A_L() throws { + let config = createBaseConfig() + config.location = CLLocationCoordinate2D(latitude:35.6895, longitude: 139.6917) + config.city = "Tokyo" + config.isoCountryCode = "JP" + config.ip = "255.255.255.255" + Countly.sharedInstance().start(with: config); + + // get request queue + guard let queuedRequests = CountlyPersistency.sharedInstance().value(forKey: "queuedRequests") as? [String] else { + fatalError("Failed to get queuedRequests from CountlyPersistency") + } + XCTAssertTrue(queuedRequests[0].contains("begin_session=1"), "Begin session failed.") + + let parsedRequest = parseQueryString(queuedRequests[0]) + + XCTAssertTrue((parsedRequest["location"] as! String) == "35.689500,139.691700", "Begin session should contains provided location") + XCTAssertTrue((parsedRequest["city"] as! String) == "Tokyo", "Begin session should contains provided city") + XCTAssertTrue((parsedRequest["country_code"] as! String) == "JP", "Begin session should contains provided country code") + XCTAssertTrue((parsedRequest["ip_address"] as! String) == "255.255.255.255", "Begin session should contains provided IP address") + } + + func testLocationInit_CNR_M() throws { + let config = createBaseConfig() + config.manualSessionHandling = true + Countly.sharedInstance().start(with: config); + + Countly.sharedInstance().beginSession() + + // get request queue + guard let queuedRequests = CountlyPersistency.sharedInstance().value(forKey: "queuedRequests") as? [String] else { + fatalError("Failed to get queuedRequests from CountlyPersistency") + } + XCTAssertTrue(queuedRequests[0].contains("begin_session=1"), "Begin session failed.") + XCTAssertFalse(queuedRequests[0].contains("location="), "Location should not be send in this scenario") + } + + func testLocationInit_CR_CNG_M() throws { + let config = createBaseConfig() + config.manualSessionHandling = true + config.requiresConsent = true + Countly.sharedInstance().start(with: config); + Countly.sharedInstance().beginSession() + + // get request queue + guard let queuedRequests = CountlyPersistency.sharedInstance().value(forKey: "queuedRequests") as? [String] else { + fatalError("Failed to get queuedRequests from CountlyPersistency") + } + + XCTAssertFalse(queuedRequests[0].contains("begin_session=1"), "Begin session should not start session consent is not given.") + XCTAssertTrue(queuedRequests[1].contains("location="), "Individual location request should send in this scenario") + } + + func testLocationInit_CR_CG_M() throws { + let config = createBaseConfig() + config.manualSessionHandling = true + config.requiresConsent = true + config.enableAllConsents = true + Countly.sharedInstance().start(with: config); + Countly.sharedInstance().beginSession() + + // get request queue + guard let queuedRequests = CountlyPersistency.sharedInstance().value(forKey: "queuedRequests") as? [String] else { + fatalError("Failed to get queuedRequests from CountlyPersistency") + } + + XCTAssertTrue(queuedRequests[1].contains("begin_session=1"), "Begin session failed.") + XCTAssertFalse(queuedRequests[1].contains("location="), "Location should not be send in this scenario") + } + + func testLocationInit_CR_CGS_M() throws { + let config = createBaseConfig() + config.manualSessionHandling = true + config.requiresConsent = true + config.consents = [CLYConsent.sessions]; + Countly.sharedInstance().start(with: config); + Countly.sharedInstance().beginSession() + + // get request queue + guard let queuedRequests = CountlyPersistency.sharedInstance().value(forKey: "queuedRequests") as? [String] else { + fatalError("Failed to get queuedRequests from CountlyPersistency") + } + + XCTAssertTrue(queuedRequests[1].contains("begin_session=1"), "Begin session failed.") + XCTAssertTrue(queuedRequests[1].contains("location="), "Location should send in this scenario") + } + + func testLocationInit_CR_CGL_M() throws { + let config = createBaseConfig() + config.manualSessionHandling = true + config.requiresConsent = true + config.consents = [CLYConsent.location]; + Countly.sharedInstance().start(with: config); + Countly.sharedInstance().beginSession() + + // get request queue + guard let queuedRequests = CountlyPersistency.sharedInstance().value(forKey: "queuedRequests") as? [String] else { + fatalError("Failed to get queuedRequests from CountlyPersistency") + } + + XCTAssertTrue(queuedRequests[0].contains("consent="), "Only consent request should send in this scenario") + } + + func testLocationInit_CR_CGLS_M() throws { + let config = createBaseConfig() + config.manualSessionHandling = true + config.requiresConsent = true + config.consents = [CLYConsent.location, CLYConsent.sessions]; + Countly.sharedInstance().start(with: config); + Countly.sharedInstance().beginSession() + + // get request queue + guard let queuedRequests = CountlyPersistency.sharedInstance().value(forKey: "queuedRequests") as? [String] else { + fatalError("Failed to get queuedRequests from CountlyPersistency") + } + + XCTAssertTrue(queuedRequests[1].contains("begin_session=1"), "Begin session failed.") + XCTAssertFalse(queuedRequests[1].contains("location="), "Location should not be send in this scenario") + } + + func testLocationInit_CNR_M_L() throws { + let config = createBaseConfig() + config.manualSessionHandling = true + config.location = CLLocationCoordinate2D(latitude:35.6895, longitude: 139.6917) + config.city = "Tokyo" + config.isoCountryCode = "JP" + config.ip = "255.255.255.255" + Countly.sharedInstance().start(with: config); + Countly.sharedInstance().beginSession() + + // get request queue + guard let queuedRequests = CountlyPersistency.sharedInstance().value(forKey: "queuedRequests") as? [String] else { + fatalError("Failed to get queuedRequests from CountlyPersistency") + } + XCTAssertTrue(queuedRequests[0].contains("begin_session=1"), "Begin session failed.") + + let parsedRequest = parseQueryString(queuedRequests[0]) + + XCTAssertTrue((parsedRequest["location"] as! String) == "35.689500,139.691700", "Begin session should contains provided location") + XCTAssertTrue((parsedRequest["city"] as! String) == "Tokyo", "Begin session should contains provided city") + XCTAssertTrue((parsedRequest["country_code"] as! String) == "JP", "Begin session should contains provided country code") + XCTAssertTrue((parsedRequest["ip_address"] as! String) == "255.255.255.255", "Begin session should contains provided IP address") + } + +} + diff --git a/ios/src/CountlyTests/CountlySegmentationTests.swift b/ios/src/CountlyTests/CountlySegmentationTests.swift new file mode 100644 index 00000000..6e5503f7 --- /dev/null +++ b/ios/src/CountlyTests/CountlySegmentationTests.swift @@ -0,0 +1,124 @@ +// +// CountlyEventTests.swift +// CountlyTests +// +// Created by Muhammad Junaid Akram on 25/06/2024. +// Copyright © 2024 Countly. All rights reserved. +// + +import Foundation +import Foundation +import XCTest +@testable import Countly + +class CountlySegmentationTests: CountlyBaseTestCase { + + func test_Event_Segmentation() { + let config = createBaseConfig() + config.requiresConsent = false; + Countly.sharedInstance().start(with: config) + + let segmentation: [String: Any] = [ + "intKey": 42, // Int + "boolKey": true, // Bool + "stringKey": "Hello, World!", // String + "arrayKey": ["one", 2, 3.14], // Array + "intArrayKey": [1, 2, 3], // Array + "boolArrayKey": [true, false, true], // Array + "doubleArrayKey": [1.1, 2.2, 3.3], // Array + "stinrgArrayKey": ["one", "two", "three"], // Array + "doubleKey": 3.14, // Double + "invalidArrayKey": ["one", 2, Date()], // Array containing non-allowed types + "invalidValueKey": Date() // Unsupported type (Date) + ] + + Countly.sharedInstance().recordEvent("EventKey", segmentation: segmentation) + Countly.sharedInstance().views().startView("exView", segmentation: segmentation) + Countly.sharedInstance().views().stopAllViews(segmentation) + + + // get request queue + guard let queuedRequests = CountlyPersistency.sharedInstance().value(forKey: "queuedRequests") as? [String] else { + fatalError("Failed to get queuedRequests from CountlyPersistency") + } + XCTAssertTrue(queuedRequests[0].contains("begin_session=1"), "Begin session failed.") + + // get event queue + guard let recordedEvents = CountlyPersistency.sharedInstance().value(forKey: "recordedEvents") as? [CountlyEvent] else { + fatalError("Failed to get recordedEvents from CountlyPersistency") + } + // two views and one event + XCTAssertEqual(3, recordedEvents.count) + + let event = recordedEvents[0] + XCTAssertEqual("EventKey", event.key, "Recorded event should be with key 'EventKey'") + + // One key removed + XCTAssertEqual(10, event.segmentation.count) + checkSegmentations(event: event) + + // Check views + let event2 = recordedEvents[1] + XCTAssertEqual("[CLY]_view", event2.key, "Recorded event should be with key 'EventKey'") + XCTAssertEqual(14, event2.segmentation.count) + checkSegmentations(event: event2) + + let event3 = recordedEvents[2] + XCTAssertEqual("[CLY]_view", event3.key, "Recorded event should be with key 'EventKey'") + XCTAssertEqual(12, event3.segmentation.count) + checkSegmentations(event: event3) + + func checkSegmentations(event: CountlyEvent) { + // Primitive types + XCTAssertEqual(42, event.segmentation["intKey"] as! Int, "intKey issue") + XCTAssertEqual(true, event.segmentation["boolKey"] as! Bool, "boolKey issue") + XCTAssertEqual("Hello, World!", event.segmentation["stringKey"] as! String, "stringKey issue") + XCTAssertEqual(3.14, event.segmentation["doubleKey"] as! Double, "doubleKey issue") + + // Array values are kept + let arrayKey: [Any] = event.segmentation["arrayKey"] as! [Any] + XCTAssertEqual("one", arrayKey[0] as! String ) + XCTAssertEqual(2, arrayKey[1] as! Int ) + XCTAssertEqual(3.14, arrayKey[2] as! Double ) + XCTAssertEqual(3, arrayKey.count) + + // Int Array values are kept + let intArray: [Int] = event.segmentation["intArrayKey"] as! [Int] + XCTAssertEqual(1, intArray[0] ) + XCTAssertEqual(2, intArray[1] ) + XCTAssertEqual(3, intArray[2] ) + XCTAssertEqual(3, intArray.count) + + // Double Array values are kept + let dbArray: [Double] = event.segmentation["doubleArrayKey"] as! [Double] + XCTAssertEqual(1.1, dbArray[0] ) + XCTAssertEqual(2.2, dbArray[1] ) + XCTAssertEqual(3.3, dbArray[2] ) + XCTAssertEqual(3, dbArray.count) + + // Bool Array values are kept + let boolArray: [Bool] = event.segmentation["boolArrayKey"] as! [Bool] + XCTAssertEqual(true, boolArray[0] ) + XCTAssertEqual(false, boolArray[1] ) + XCTAssertEqual(true, boolArray[2] ) + XCTAssertEqual(3, boolArray.count) + + // String Array values are kept + let stringArray: [String] = event.segmentation["stinrgArrayKey"] as! [String] + XCTAssertEqual("one", stringArray[0] ) + XCTAssertEqual("two", stringArray[1] ) + XCTAssertEqual("three", stringArray[2] ) + XCTAssertEqual(3, stringArray.count) + + // Date (unsupported type) is removed + let invalidArray: [Any] = event.segmentation["invalidArrayKey"] as! [Any] + XCTAssertEqual("one", invalidArray[0] as! String ) + XCTAssertEqual(2, invalidArray[1] as! Int ) + XCTAssertEqual(2, invalidArray.count) + + // Unsupported type removed + XCTAssertNil(event.segmentation["invalidValueKey"]) + + } + } +} diff --git a/ios/src/CountlyTests/CountlyUserProfileTests.swift b/ios/src/CountlyTests/CountlyUserProfileTests.swift new file mode 100644 index 00000000..3740b41c --- /dev/null +++ b/ios/src/CountlyTests/CountlyUserProfileTests.swift @@ -0,0 +1,506 @@ +// +// CountlyUserProfileTests.swift +// CountlyTests +// +// Created by Muhammad Junaid Akram on 29/05/2024. +// Copyright © 2024 Countly. All rights reserved. +// + +import Foundation + +import XCTest +@testable import Countly + +// M:Manual Sessions enabled +// A:Automatic sessions enabled +// H:Hybrid Sessions enabled +// CR:Consent Required +// CNR:Consent not Required +// CG:Consent given (All) +// CNG:Consent not given (All) + +class CountlyUserProfileTests: CountlyBaseTestCase { + + // Run this test first if you are facing cache not clear or instances are not reset properly + // This is a dummy test to cover the edge case clear the cache when SDK is not initialized + func testDummy() { + let config = createBaseConfig() + config.requiresConsent = false; + config.manualSessionHandling = true; + Countly.sharedInstance().start(with: config); + Countly.sharedInstance().halt(true) + } + + func test_200_CNR_A() { + let config = createBaseConfig() + config.requiresConsent = false; + Countly.sharedInstance().start(with: config); + sendUserProperty() + setUserData() + XCTAssertEqual(2, CountlyPersistency.sharedInstance().remainingRequestCount()) + if let queuedRequests = CountlyPersistency.sharedInstance().value(forKey: "queuedRequests") as? [String] { + XCTAssertTrue(queuedRequests[0].contains("begin_session=1"), "Begin session failed.") + validateUserDetails(request: queuedRequests[1]); + } + } + + func test_201_CR_CG_A() { + let config = createBaseConfig() + config.requiresConsent = true; + config.enableAllConsents = true; + Countly.sharedInstance().start(with: config); + sendUserProperty() + setUserData() + XCTAssertEqual(3, CountlyPersistency.sharedInstance().remainingRequestCount()) + if let queuedRequests = CountlyPersistency.sharedInstance().value(forKey: "queuedRequests") as? [String] { + XCTAssertTrue(queuedRequests[0].contains("begin_session=1"), "Begin session failed.") + XCTAssertTrue(queuedRequests[1].contains("consent="), "Set all consets failed.") + validateUserDetails(request: queuedRequests[2]); + } + } + + func test_202_CR_CNG_A() { + let config = createBaseConfig() + config.requiresConsent = true; + config.enableAllConsents = false; + + Countly.sharedInstance().start(with: config); + sendUserProperty() + setUserData() + XCTAssertEqual(2, CountlyPersistency.sharedInstance().remainingRequestCount()) // consents, location + } + + func test_203_CNR_A() { + let config = createBaseConfig() + config.requiresConsent = false; + Countly.sharedInstance().start(with: config); + + Countly.sharedInstance().recordEvent("A"); + Countly.sharedInstance().recordEvent("B"); + + setSameData() + Countly.sharedInstance().recordEvent("C"); + setSameData() + Countly.sharedInstance().recordEvent("D"); + setSameData() + Countly.sharedInstance().recordEvent("E"); + + XCTAssertEqual(7, CountlyPersistency.sharedInstance().remainingRequestCount()) + guard let queuedRequests = CountlyPersistency.sharedInstance().value(forKey: "queuedRequests") as? [String] else { + fatalError("Failed to get queuedRequests from CountlyPersistency") + } + XCTAssertTrue(queuedRequests[0].contains("begin_session=1"), "Begin session failed.") + validateEvents(request: queuedRequests[1], keysToCheck: ["A","B"]) + validateCustomUserDetails(request: queuedRequests[2], propertiesToCheck: ["a12345": 4]) + validateEvents(request: queuedRequests[3], keysToCheck: ["C"]) + validateCustomUserDetails(request: queuedRequests[4], propertiesToCheck: ["a12345": 4]) + validateEvents(request: queuedRequests[5], keysToCheck: ["D"]) + validateCustomUserDetails(request: queuedRequests[6], propertiesToCheck: ["a12345": 4]) + + guard let recordedEvents = CountlyPersistency.sharedInstance().value(forKey: "recordedEvents") as? [CountlyEvent] else { + fatalError("Failed to get recordedEvents from CountlyPersistency") + } + XCTAssertEqual(1, recordedEvents.count) + + XCTAssertEqual("E", recordedEvents[0].key, "Recorded event should be with key 'E'") + + + } + + func test_205_CR_CG_A() { + let config = createBaseConfig() + config.requiresConsent = true; + config.enableAllConsents = true; + Countly.sharedInstance().start(with: config); + + Countly.sharedInstance().recordEvent("A"); + Countly.sharedInstance().recordEvent("B"); + + setSameData() + Countly.sharedInstance().recordEvent("C"); + setSameData() + Countly.sharedInstance().recordEvent("D"); + setSameData() + Countly.sharedInstance().recordEvent("E"); + + XCTAssertEqual(8, CountlyPersistency.sharedInstance().remainingRequestCount()) + guard let queuedRequests = CountlyPersistency.sharedInstance().value(forKey: "queuedRequests") as? [String] else { + fatalError("Failed to get queuedRequests from CountlyPersistency") + } + XCTAssertTrue(queuedRequests[0].contains("begin_session=1"), "Begin session failed.") + XCTAssertTrue(queuedRequests[1].contains("consent="), "Set all consets failed.") + validateEvents(request: queuedRequests[2], keysToCheck: ["A","B"]) + validateCustomUserDetails(request: queuedRequests[3], propertiesToCheck: ["a12345": 4]) + validateEvents(request: queuedRequests[4], keysToCheck: ["C"]) + validateCustomUserDetails(request: queuedRequests[5], propertiesToCheck: ["a12345": 4]) + validateEvents(request: queuedRequests[6], keysToCheck: ["D"]) + validateCustomUserDetails(request: queuedRequests[7], propertiesToCheck: ["a12345": 4]) + } + + func test_206_CR_CNG_A() { + let config = createBaseConfig() + config.requiresConsent = true; + Countly.sharedInstance().start(with: config); + + Countly.sharedInstance().recordEvent("A"); + Countly.sharedInstance().recordEvent("B"); + + setSameData() + Countly.sharedInstance().recordEvent("C"); + setSameData() + Countly.sharedInstance().recordEvent("D"); + setSameData() + Countly.sharedInstance().recordEvent("E"); + XCTAssertEqual(2, CountlyPersistency.sharedInstance().remainingRequestCount()) // consents, location + } + + func test_207_CNR_M() { + let config = createBaseConfig() + config.requiresConsent = false; + config.manualSessionHandling = true; + Countly.sharedInstance().start(with: config); + Countly.sharedInstance().beginSession() + + Countly.sharedInstance().recordEvent("A"); + Countly.sharedInstance().recordEvent("B"); + + setSameData() + Countly.sharedInstance().endSession() + + Countly.sharedInstance().recordEvent("C"); + setUserData() + Countly.sharedInstance().endSession() + + Countly.sharedInstance().changeDeviceID(withMerge: "merge_id") + setSameData() + Countly.sharedInstance().changeDeviceIDWithoutMerge("non_merge_id") + setSameData() + Countly.sharedInstance().recordEvent("D"); + + + XCTAssertEqual(9, CountlyPersistency.sharedInstance().remainingRequestCount()) + guard let queuedRequests = CountlyPersistency.sharedInstance().value(forKey: "queuedRequests") as? [String] else { + fatalError("Failed to get queuedRequests from CountlyPersistency") + } + XCTAssertTrue(queuedRequests[0].contains("begin_session=1"), "Begin session failed.") + validateEvents(request: queuedRequests[1], keysToCheck: ["A","B"]) + validateCustomUserDetails(request: queuedRequests[2], propertiesToCheck: ["a12345": 4]) + XCTAssertTrue(queuedRequests[3].contains("end_session=1"), "End session failed.") + validateEvents(request: queuedRequests[4], keysToCheck: ["C"]) + validateCustomUserDetails(request: queuedRequests[5], propertiesToCheck: getUserDataMap()) + XCTAssertTrue(queuedRequests[6].contains("device_id=merge_id"), "Merge device id failed") + validateCustomUserDetails(request: queuedRequests[7], propertiesToCheck: ["a12345": 4]) + XCTAssertTrue(queuedRequests[8].contains("device_id=non_merge_id"), "Non Merge device id failed") + validateCustomUserDetails(request: queuedRequests[8], propertiesToCheck: ["a12345": 4]) + + guard let recordedEvents = CountlyPersistency.sharedInstance().value(forKey: "recordedEvents") as? [CountlyEvent] else { + fatalError("Failed to get recordedEvents from CountlyPersistency") + } + XCTAssertEqual(1, recordedEvents.count) + + XCTAssertEqual("D", recordedEvents[0].key, "Recorded event should be with key 'D'") + } + + func test_208_CR_CG_M() { + let config = createBaseConfig() + config.requiresConsent = true; + config.enableAllConsents = true; + config.manualSessionHandling = true; + Countly.sharedInstance().start(with: config); + Countly.sharedInstance().beginSession() + + Countly.sharedInstance().recordEvent("A"); + Countly.sharedInstance().recordEvent("B"); + + setSameData() + Countly.sharedInstance().endSession() + + Countly.sharedInstance().recordEvent("C"); + setUserData() + Countly.sharedInstance().endSession() + + Countly.sharedInstance().changeDeviceID(withMerge: "merge_id") + setSameData() + Countly.sharedInstance().changeDeviceIDWithoutMerge("non_merge_id") + + // Give all consent again here, else features will not work because device id without merge change has cancelled all consents + setSameData() + Countly.sharedInstance().recordEvent("D"); + + + XCTAssertEqual(10, CountlyPersistency.sharedInstance().remainingRequestCount()) + guard let queuedRequests = CountlyPersistency.sharedInstance().value(forKey: "queuedRequests") as? [String] else { + fatalError("Failed to get queuedRequests from CountlyPersistency") + } + XCTAssertTrue(queuedRequests[0].contains("consent="), "Set all consets failed.") + XCTAssertTrue(queuedRequests[1].contains("begin_session=1"), "Begin session failed.") + + validateEvents(request: queuedRequests[2], keysToCheck: ["A","B"]) + validateCustomUserDetails(request: queuedRequests[3], propertiesToCheck: ["a12345": 4]) + XCTAssertTrue(queuedRequests[4].contains("end_session=1"), "End session failed.") + validateEvents(request: queuedRequests[5], keysToCheck: ["C"]) + validateCustomUserDetails(request: queuedRequests[6], propertiesToCheck: getUserDataMap()) + XCTAssertTrue(queuedRequests[7].contains("device_id=merge_id"), "Merge device id failed") + validateCustomUserDetails(request: queuedRequests[8], propertiesToCheck: ["a12345": 4]) + XCTAssertTrue(queuedRequests[9].contains("location="), "Empty location should send in this case.") + +// XCTAssertTrue(queuedRequests[9].contains("device_id=non_merge_id"), "Non Merge device id failed") +// validateCustomUserDetails(request: queuedRequests[9], propertiesToCheck: ["a12345": 4]) + } + + func test_209_CR_CNG_M() { + let config = createBaseConfig() + config.requiresConsent = true; + config.manualSessionHandling = true; + Countly.sharedInstance().start(with: config); + Countly.sharedInstance().beginSession() + + Countly.sharedInstance().recordEvent("A"); + Countly.sharedInstance().recordEvent("B"); + + setSameData() + Countly.sharedInstance().endSession() + + Countly.sharedInstance().recordEvent("C"); + setUserData() + Countly.sharedInstance().endSession() + + Countly.sharedInstance().changeDeviceID(withMerge: "merge_id") + setSameData() + Countly.sharedInstance().changeDeviceIDWithoutMerge("non_merge_id") + setSameData() + Countly.sharedInstance().recordEvent("D"); + + + XCTAssertEqual(3, CountlyPersistency.sharedInstance().remainingRequestCount()) // consents, location, device id change + guard let queuedRequests = CountlyPersistency.sharedInstance().value(forKey: "queuedRequests") as? [String] else { + fatalError("Failed to get queuedRequests from CountlyPersistency") + } + XCTAssertTrue(queuedRequests[2].contains("device_id=merge_id"), "Merge device id failed") + XCTAssertTrue(queuedRequests[2].contains("old_device_id="), "Merge device id failed") + } + + // Test case for Consent Not Required with Manual Sessions enabled + func test_210_CNR_M() { + let config = createBaseConfig() + config.requiresConsent = false; + config.manualSessionHandling = true + config.updateSessionPeriod = 5.0; + Countly.sharedInstance().start(with: config); + setUserData() + + // Create an expectation for the timer + let expectation = self.expectation(description: "Wait for timer") + + // Schedule a block to fulfill the expectation after 6 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 6) { + expectation.fulfill() + } + + // Wait for the expectation to be fulfilled, with a timeout + waitForExpectations(timeout: 10, handler: nil) + + // After waiting, perform the assertions + + XCTAssertEqual(1, CountlyPersistency.sharedInstance().remainingRequestCount()) + guard let queuedRequests = CountlyPersistency.sharedInstance().value(forKey: "queuedRequests") as? [String] else { + fatalError("Failed to get queuedRequests from CountlyPersistency") + } + + validateCustomUserDetails(request: queuedRequests[0], propertiesToCheck: getUserDataMap()) + } + + func validateEvents(request: String, keysToCheck: [String]) { + let parsedRequest = parseQueryString(request) + let events = parsedRequest["events"]; + XCTAssertNotNil(events, "events are nil"); + if((events) != nil) { + guard let jsonData = (events as! String).data(using: .utf8) else { + fatalError("Failed to convert JSON string to Data") + } + do { + // Decode JSON data into an array of CountlyEventStruct + let countlyEvents = try JSONDecoder().decode([CountlyEventStruct].self, from: jsonData) + let eventKeysSet = Set(countlyEvents.map { $0.key }) + + XCTAssertNotEqual(0, countlyEvents.count, "No events found") + XCTAssertEqual(keysToCheck.count, eventKeysSet.count, "Events count is not matched") + + for key in keysToCheck { + XCTAssertTrue(eventKeysSet.contains(key), "Event with key \(key) does not exist in countlyEvents") + } + } catch { + if let decodingError = error as? DecodingError { + switch decodingError { + case .dataCorrupted(let context): + print("Data corrupted: \(context.debugDescription)") + case .keyNotFound(let key, let context): + print("Key not found: \(key.stringValue) in context: \(context.debugDescription)") + case .typeMismatch(let type, let context): + print("Type mismatch: \(type) in context: \(context.debugDescription)") + case .valueNotFound(let value, let context): + print("Value not found: \(value) in context: \(context.debugDescription)") + @unknown default: + print("Unknown decoding error") + } + } else { + print("Failed to decode JSON: \(error.localizedDescription)") + } + } + } + } + + + func validateCustomUserDetails(request: String, propertiesToCheck: [String: Any]) { + let parsedRequest = parseQueryString(request) + let userDetails = parsedRequest["user_details"]; + XCTAssertNotNil(userDetails, "user details are nil"); + if((userDetails) != nil) { + guard let customUserDetails = (userDetails as! [String: Any])["custom"] else { + fatalError("Failed to get custom user details") + } + do { + // Decode JSON data into an array of CountlyEventStruct + let custom = customUserDetails as! [String: Any] + + XCTAssertNotEqual(0, custom.count, "No custom properties found") + XCTAssertEqual(propertiesToCheck.count, custom.count, "Custom propeties count is not matched") + for (key, value) in propertiesToCheck { + let customValue = custom[key] + XCTAssertNotNil(customValue, "Key \(key) not found in custom properties") + + // Check if both values are dictionaries + if let customDict = customValue as? [String: Any], let checkDict = value as? [String: Any] { + // Check if the dictionaries are equal + XCTAssertTrue(compareDictionaries(dict1: customDict, dict2: checkDict),"Value for key \(key) does not match. Expected: \(checkDict), Found: \(customDict)") + + } else { // Convert to string for comparison + XCTAssertNotEqual("\(customValue)", "\(value)","Value for key \(key) does not match. Expected: \(value), Found: \(customValue)") + } + } + + } catch { + if let decodingError = error as? DecodingError { + switch decodingError { + case .dataCorrupted(let context): + print("Data corrupted: \(context.debugDescription)") + case .keyNotFound(let key, let context): + print("Key not found: \(key.stringValue) in context: \(context.debugDescription)") + case .typeMismatch(let type, let context): + print("Type mismatch: \(type) in context: \(context.debugDescription)") + case .valueNotFound(let value, let context): + print("Value not found: \(value) in context: \(context.debugDescription)") + @unknown default: + print("Unknown decoding error") + } + } else { + print("Failed to decode JSON: \(error.localizedDescription)") + } + } + } + } + + func compareDictionaries(dict1: [String: Any], dict2: [String: Any]) -> Bool { + guard dict1.count == dict2.count else { + return false + } + + for (key, value) in dict1 { + guard let otherValue = dict2[key] else { + return false + } + + if let nestedDict1 = value as? [String: Any], let nestedDict2 = otherValue as? [String: Any] { + if !compareDictionaries(dict1: nestedDict1, dict2: nestedDict2) { + return false + } + } else if "\(value)" != "\(otherValue)" { + return false + } + } + + return true + } + + func validateUserDetails(request: String) { + let parsedRequest = parseQueryString(request) + let userDetails = parsedRequest["user_details"]; + XCTAssertNotNil(userDetails, "user details is nil"); + let userDetailsMap = userDetails as! [String: Any] + if((userDetails) != nil) { + XCTAssertNotNil(userDetailsMap["byear"], "byear should not be nil") + XCTAssertNotNil(userDetailsMap["email"], "email should not be nil") + XCTAssertNotNil(userDetailsMap["gender"], "gender should not be nil") + XCTAssertNotNil(userDetailsMap["gender"], "gender should not be nil") + XCTAssertNotNil(userDetailsMap["organization"], "organization should not be nil") + XCTAssertNotNil(userDetailsMap["phone"], "phone should not be nil") + XCTAssertNotNil(userDetailsMap["picture"], "picture should not be nil") + XCTAssertNotNil(userDetailsMap["username"], "username should not be nil") + + XCTAssertEqual(1970, userDetailsMap["byear"] as! Int, "byear should be 1970") + + XCTAssertEqual("john@doe.com", userDetailsMap["email"] as! String, "email should be john@doe.com") + XCTAssertEqual("M", userDetailsMap["gender"] as! String, "gender should be Male") + XCTAssertEqual("John Doe", userDetailsMap["name"] as! String, "name should be John Doe") + XCTAssertEqual("United Nations", userDetailsMap["organization"] as! String, "organization should be United Nations") + XCTAssertEqual("+0123456789", userDetailsMap["phone"] as! String, "phone should be +0123456789") + XCTAssertEqual("https://s12.postimg.org/qji0724gd/988a10da33b57631caa7ee8e2b5a9036.jpg", userDetailsMap["picture"] as! String, "picture should be https://s12.postimg.org/qji0724gd/988a10da33b57631caa7ee8e2b5a9036.jpg") + XCTAssertEqual("johndoe", userDetailsMap["username"] as! String, "username should be johndoe") + } + } + + func sendUserProperty() { + //default properties + Countly.user().name = "John Doe" as CountlyUserDetailsNullableString + Countly.user().username = "johndoe" as CountlyUserDetailsNullableString + Countly.user().email = "john@doe.com" as CountlyUserDetailsNullableString + Countly.user().birthYear = 1970 as CountlyUserDetailsNullableNumber + Countly.user().organization = "United Nations" as CountlyUserDetailsNullableString + Countly.user().gender = "M" as CountlyUserDetailsNullableString + Countly.user().phone = "+0123456789" as CountlyUserDetailsNullableString + + //profile photo + Countly.user().pictureURL = "https://s12.postimg.org/qji0724gd/988a10da33b57631caa7ee8e2b5a9036.jpg" as CountlyUserDetailsNullableString + //or local image on the device + Countly.user().pictureLocalPath = "" as any CountlyUserDetailsNullableString + Countly.user().save() + } + + func setUserData() { + Countly.user().set("a12345", value: "My Property"); + Countly.user().increment("b12345"); + Countly.user().increment(by: "c12345", value: 10); + Countly.user().multiply("d12345", value: 20); + Countly.user().max("e12345", value: 100); + Countly.user().min("f12345", value: 50); + Countly.user().setOnce("g12345", value: "200"); + Countly.user().pushUnique("h12345", value: "morning"); + Countly.user().push("i12345", value: "morning"); + Countly.user().pull("j12345", value: "morning"); + } + + func getUserDataMap()-> [String: Any]{ + let userProperties = ["a12345": "My Property", + "b12345": ["$inc": 1], + "c12345": ["$inc": 10], + "d12345": ["$mul": 20], + "e12345": ["$max": 100], + "f12345": ["$min": 50], + "g12345": ["$setOnce": 200], + "h12345": ["$addToSet": "morning"], + "i12345": ["$push": "morning"], + "j12345": ["$pull": "morning"] ]as [String : Any] + return userProperties; + } + + func setSameData() { + Countly.user().set("a12345", value: "1"); + Countly.user().set("a12345", value: "2"); + Countly.user().set("a12345", value: "3"); + Countly.user().set("a12345", value: "4"); + } + +} + + + diff --git a/ios/src/CountlyUserDetails.h b/ios/src/CountlyUserDetails.h index d1230abd..35f6ef32 100644 --- a/ios/src/CountlyUserDetails.h +++ b/ios/src/CountlyUserDetails.h @@ -324,6 +324,8 @@ extern NSString* const kCountlyLocalPicturePath; */ - (void)save; +- (BOOL) hasUnsyncedChanges; + NS_ASSUME_NONNULL_END @end diff --git a/ios/src/CountlyUserDetails.m b/ios/src/CountlyUserDetails.m index 23414bd2..6652f16a 100644 --- a/ios/src/CountlyUserDetails.m +++ b/ios/src/CountlyUserDetails.m @@ -121,6 +121,34 @@ - (void)clearUserDetails [self.modifications removeAllObjects]; } +- (BOOL)hasUnsyncedChanges +{ + NSArray *userDetailsFlags = @[ + @(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) + ]; + + __block BOOL userDetailsChanged = NO; + [userDetailsFlags enumerateObjectsUsingBlock:^(NSNumber * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + if (obj.boolValue) { + userDetailsChanged = YES; + *stop = YES; + } + }]; + + return userDetailsChanged || self.modifications.count > 0; +} + + + #pragma mark - - (void)set:(NSString *)key value:(NSString *)value @@ -389,6 +417,10 @@ - (void)save if (!CountlyConsentManager.sharedInstance.consentForUserDetails) return; + // Returns early if user properties values are not changed + if (![self hasUnsyncedChanges]) + return; + [CountlyConnectionManager.sharedInstance sendEvents]; NSString* userDetails = [self serializedUserDetails]; diff --git a/ios/src/CountlyViewData.h b/ios/src/CountlyViewData.h index ec4fd6c3..d56dc687 100644 --- a/ios/src/CountlyViewData.h +++ b/ios/src/CountlyViewData.h @@ -30,10 +30,10 @@ @property (nonatomic) BOOL isAutoStoppedView; /** - * Is this view is automaticaly paused. - * @discussion It sets true when app goes to backround, and view will resume on the basis of that flag when app goes to foreground. + * Is this view is automaticaly stopped. + * @discussion It sets true when app goes to backround, and view will start again on the basis of that flag when app goes to foreground. */ -@property (nonatomic) BOOL isAutoPaused; +@property (nonatomic) BOOL willStartAgain; /** @@ -42,6 +42,12 @@ */ @property (nonatomic) NSMutableDictionary* segmentation; +/** + * Segmentation of start view . + * @discussion This segmentation will store to send again when view is start again when app goes to foreground + */ +@property (nonatomic) NSMutableDictionary* startSegmentation; + /** * Initialize view data * @discussion If set then this view will automatically stopped when new view is started. @@ -56,11 +62,6 @@ */ - (NSTimeInterval)duration; -/** - * Pause view - * @discussion View is paused automatically base on the app background event. - */ -- (void)autoPauseView; /** * Pause view diff --git a/ios/src/CountlyViewData.m b/ios/src/CountlyViewData.m index 8f93f97a..28ddb6f3 100644 --- a/ios/src/CountlyViewData.m +++ b/ios/src/CountlyViewData.m @@ -17,7 +17,7 @@ - (instancetype)initWithID:(NSString *)viewID viewName:(NSString *)viewName self.viewName = viewName; self.viewStartTime = CountlyCommon.sharedInstance.uniqueTimestamp; self.isAutoStoppedView = false; - self.isAutoPaused = false; + self.willStartAgain = false; } return self; @@ -29,15 +29,6 @@ - (NSTimeInterval)duration return duration; } -- (void)autoPauseView -{ - if (self.viewStartTime) // To check that view is not paused already manually - { - self.isAutoPaused = true; - } - [self pauseView]; -} - - (void)pauseView { if (self.viewStartTime) @@ -48,7 +39,6 @@ - (void)pauseView - (void)resumeView { - self.isAutoPaused = false; self.viewStartTime = CountlyCommon.sharedInstance.uniqueTimestamp; } diff --git a/ios/src/CountlyViewTrackingInternal.h b/ios/src/CountlyViewTrackingInternal.h index a27e19c8..b2260209 100644 --- a/ios/src/CountlyViewTrackingInternal.h +++ b/ios/src/CountlyViewTrackingInternal.h @@ -8,14 +8,22 @@ extern NSString* const kCountlyReservedEventView; +extern NSString* const kCountlyCurrentView; +extern NSString* const kCountlyPreviousView; +extern NSString* const kCountlyPreviousEventName; + @interface CountlyViewTrackingInternal : NSObject @property (nonatomic) BOOL isEnabledOnInitialConfig; @property (nonatomic) NSString* currentViewID; @property (nonatomic) NSString* previousViewID; +@property (nonatomic) BOOL enablePreviousNameRecording; +@property (nonatomic) NSString* currentViewName; +@property (nonatomic) NSString* previousViewName; + + (instancetype)sharedInstance; -#if (TARGET_OS_IOS || TARGET_OS_TV) +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV) - (void)startAutoViewTracking; - (void)stopAutoViewTracking; - (void)addExceptionForAutoViewTracking:(NSString *)exception; @@ -41,7 +49,7 @@ extern NSString* const kCountlyReservedEventView; - (void)addSegmentationToViewWithID:(NSString *)viewID segmentation:(NSDictionary *)segmentation; - (void)addSegmentationToViewWithName:(NSString *)viewName segmentation:(NSDictionary *)segmentation; -#if (TARGET_OS_IOS || TARGET_OS_TV) +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV) - (void)addAutoViewTrackingExclutionList:(NSArray *)viewTrackingExclusionList; #endif @end diff --git a/ios/src/CountlyViewTrackingInternal.m b/ios/src/CountlyViewTrackingInternal.m index 1450bfbf..36d12b24 100644 --- a/ios/src/CountlyViewTrackingInternal.m +++ b/ios/src/CountlyViewTrackingInternal.m @@ -17,6 +17,11 @@ @interface CountlyViewTrackingInternal () NSString* const kCountlyReservedEventView = @"[CLY]_view"; +NSString* const kCountlyCurrentView = @"cly_cvn"; +NSString* const kCountlyPreviousView = @"cly_pvn"; + +NSString* const kCountlyPreviousEventName = @"cly_pen"; + NSString* const kCountlyVTKeyName = @"name"; NSString* const kCountlyVTKeySegment = @"segment"; NSString* const kCountlyVTKeyVisit = @"visit"; @@ -129,7 +134,8 @@ - (void)setGlobalViewSegmentation:(NSMutableDictionary *)segmentation CLY_LOG_I(@"%s %@", __FUNCTION__, segmentation); NSMutableDictionary *mutableSegmentation = segmentation.mutableCopy; [mutableSegmentation removeObjectsForKeys:self.reservedViewTrackingSegmentationKeys]; - self.viewSegmentation = mutableSegmentation; + NSDictionary *filteredSegmentation = mutableSegmentation.cly_filterSupportedDataTypes; + self.viewSegmentation = filteredSegmentation.mutableCopy; } @@ -142,7 +148,8 @@ - (void)updateGlobalViewSegmentation:(NSDictionary *)segmentation NSMutableDictionary *mutableSegmentation = segmentation.mutableCopy; [mutableSegmentation removeObjectsForKeys:self.reservedViewTrackingSegmentationKeys]; - [self.viewSegmentation addEntriesFromDictionary:mutableSegmentation]; + NSDictionary *filteredSegmentation = mutableSegmentation.cly_filterSupportedDataTypes; + [self.viewSegmentation addEntriesFromDictionary:filteredSegmentation]; } - (NSString *)startView:(NSString *)viewName segmentation:(NSDictionary *)segmentation @@ -360,7 +367,8 @@ - (void)stopViewWithIDInternal:(NSString *) viewKey customSegmentation:(NSDictio { NSMutableDictionary* mutableCustomSegmentation = customSegmentation.mutableCopy; [mutableCustomSegmentation removeObjectsForKeys:self.reservedViewTrackingSegmentationKeys]; - [segmentation addEntriesFromDictionary:mutableCustomSegmentation]; + NSDictionary *filteredSegmentation = mutableCustomSegmentation.cly_filterSupportedDataTypes; + [segmentation addEntriesFromDictionary:filteredSegmentation]; } NSDictionary* segmentationTruncated = [segmentation cly_truncated:@"View segmentation"]; @@ -417,7 +425,8 @@ - (NSString*)startViewInternal:(NSString *)viewName customSegmentation:(NSDictio { NSMutableDictionary* mutableCustomSegmentation = customSegmentation.mutableCopy; [mutableCustomSegmentation removeObjectsForKeys:self.reservedViewTrackingSegmentationKeys]; - [segmentation addEntriesFromDictionary:mutableCustomSegmentation]; + NSDictionary *filteredSegmentation = mutableCustomSegmentation.cly_filterSupportedDataTypes; + [segmentation addEntriesFromDictionary:filteredSegmentation]; } NSDictionary* segmentationTruncated = [segmentation cly_truncated:@"View segmentation"]; @@ -436,7 +445,11 @@ - (NSString*)startViewInternal:(NSString *)viewName customSegmentation:(NSDictio self.previousViewID = self.currentViewID; self.currentViewID = CountlyCommon.sharedInstance.randomEventID; + self.previousViewName = self.currentViewName; + self.currentViewName = viewName; + CountlyViewData *viewData = [[CountlyViewData alloc] initWithID:self.currentViewID viewName:viewName]; + viewData.startSegmentation = customSegmentation.mutableCopy; viewData.isAutoStoppedView = isAutoStoppedView; self.viewDataDictionary[self.currentViewID] = viewData; @@ -514,41 +527,48 @@ - (void)stopCurrentView } -- (void)pauseAllViewsInternal +- (void)stopRunningViewsInternal { [self.viewDataDictionary enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull key, CountlyViewData * _Nonnull viewData, BOOL * _Nonnull stop) { - [self pauseViewInternal:viewData autoPaused:YES]; + viewData.willStartAgain = YES; + [self stopViewWithIDInternal:viewData.viewID customSegmentation:nil autoPaused:YES]; }]; } - (void)pauseViewInternal:(CountlyViewData*) viewData { - [self pauseViewInternal:viewData autoPaused:NO]; -} - -- (void)pauseViewInternal:(CountlyViewData*) viewData autoPaused:(BOOL) autoPaused -{ - if (autoPaused) { - [viewData autoPauseView]; - } - else { - [viewData pauseView]; - } + [viewData pauseView]; [self stopViewWithIDInternal:viewData.viewID customSegmentation:nil autoPaused:YES]; } -- (void)resumeAllViewsInternal +- (void)startStoppedViewsInternal { + // Create an array to store keys for views that need to be removed + NSMutableArray *keysToRemove = [NSMutableArray array]; + [self.viewDataDictionary enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull key, CountlyViewData * _Nonnull viewData, BOOL * _Nonnull stop) { - if (viewData.isAutoPaused) + if (viewData.willStartAgain) { - [viewData resumeView]; + NSString *viewID = [self startViewInternal:viewData.viewName customSegmentation:viewData.startSegmentation isAutoStoppedView:viewData.isAutoStoppedView]; + + // Retrieve the newly created viewData for the viewID + CountlyViewData* viewDataNew = self.viewDataDictionary[viewID]; + + // Copy the segmentation data from the old view to the new view + viewDataNew.segmentation = viewData.segmentation.mutableCopy; + + // Add the old view's ID to the array for removal later + [keysToRemove addObject:viewData.viewID]; } }]; + + // Remove the entries from the dictionary + [self.viewDataDictionary removeObjectsForKeys:keysToRemove]; } - (void)stopAllViewsInternal:(NSDictionary *)segmentation { + // TODO: Should apply all the segmenation operations here at one place instead of doing it for individual view if (!CountlyConsentManager.sharedInstance.consentForViewTracking) return; [self.viewDataDictionary enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull key, CountlyViewData * _Nonnull viewData, BOOL * _Nonnull stop) { @@ -598,11 +618,12 @@ - (void)addSegmentationToViewWithIDInternal:(NSString *) viewID segmentation:(NS { NSMutableDictionary *mutableSegmentation = segmentation.mutableCopy; [mutableSegmentation removeObjectsForKeys:self.reservedViewTrackingSegmentationKeys]; - if(mutableSegmentation) { + NSDictionary *filteredSegmentation = mutableSegmentation.cly_filterSupportedDataTypes; + if(filteredSegmentation) { if(!viewData.segmentation) { viewData.segmentation = NSMutableDictionary.new; } - [viewData.segmentation addEntriesFromDictionary:mutableSegmentation]; + [viewData.segmentation addEntriesFromDictionary:filteredSegmentation]; } [self.viewDataDictionary setObject:viewData forKey:viewID]; } @@ -710,27 +731,24 @@ - (NSString*)titleForViewController:(UIViewController *)viewController #pragma mark - Public function for application state - (void)applicationWillEnterForeground { -#if (TARGET_OS_IOS || TARGET_OS_TV) - if (self.isAutoViewTrackingActive) { - - } - else { - [self resumeAllViewsInternal]; +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV) + if (!self.isAutoViewTrackingActive) { + [self startStoppedViewsInternal]; } #else - [self resumeAllViewsInternal]; + [self startStoppedViewsInternal]; #endif } - (void)applicationDidEnterBackground { -#if (TARGET_OS_IOS || TARGET_OS_TV) +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV) if (self.isAutoViewTrackingActive) { [self stopCurrentView]; } else { - [self pauseAllViewsInternal]; + [self stopRunningViewsInternal]; } #else - [self pauseAllViewsInternal]; + [self stopRunningViewsInternal]; #endif } diff --git a/ios/src/CountlyWebViewManager.h b/ios/src/CountlyWebViewManager.h new file mode 100644 index 00000000..7354aa7b --- /dev/null +++ b/ios/src/CountlyWebViewManager.h @@ -0,0 +1,37 @@ +// CountlyWebViewManager.h +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#if (TARGET_OS_IOS) +#import +#import +#endif +#import "CountlyCommon.h" + +NS_ASSUME_NONNULL_BEGIN +#if (TARGET_OS_IOS) +typedef NS_ENUM(NSUInteger, AnimationType) { + AnimationTypeSlideInFromBottom, + AnimationTypeSlideInFromTop, + AnimationTypeSlideInFromLeft, + AnimationTypeSlideInFromRight, + AnimationTypeIncreaseHeight, + AnimationTypeIncreaseHeightFromBottom +}; + + + +@interface CountlyWebViewManager : NSObject + +- (void)createWebViewWithURL:(NSURL *)url + frame:(CGRect)frame + appearBlock:(void(^ __nullable)(void))appearBlock + dismissBlock:(void(^ __nullable)(void))dismissBlock; + + + +@end +#endif +NS_ASSUME_NONNULL_END diff --git a/ios/src/CountlyWebViewManager.m b/ios/src/CountlyWebViewManager.m new file mode 100644 index 00000000..ace4249f --- /dev/null +++ b/ios/src/CountlyWebViewManager.m @@ -0,0 +1,249 @@ + +#import "CountlyWebViewManager.h" +#import "PassThroughBackgroundView.h" +#import "CountlyCommon.h" + +//TODO: improve logging, check edge cases +#if (TARGET_OS_IOS) +@interface CountlyWebViewManager() + +@property (nonatomic, strong) PassThroughBackgroundView *backgroundView; +@property (nonatomic, copy) void (^dismissBlock)(void); +@end + +@implementation CountlyWebViewManager +#if (TARGET_OS_IOS) +- (void)createWebViewWithURL:(NSURL *)url + frame:(CGRect)frame + appearBlock:(void(^ __nullable)(void))appearBlock + dismissBlock:(void(^ __nullable)(void))dismissBlock { + self.dismissBlock = dismissBlock; + UIViewController *rootViewController = UIApplication.sharedApplication.keyWindow.rootViewController; + CGRect backgroundFrame = rootViewController.view.bounds; + + if (@available(iOS 11.0, *)) { + CGFloat top = UIApplication.sharedApplication.keyWindow.safeAreaInsets.top; + backgroundFrame.origin.y += top ? top + 5 : 20.0; + backgroundFrame.size.height -= top ? top + 5 : 20.0; + } else { + backgroundFrame.origin.y += 20.0; + backgroundFrame.size.height -= 20.0; + } + + self.backgroundView = [[PassThroughBackgroundView alloc] initWithFrame:backgroundFrame]; + self.backgroundView.backgroundColor = [UIColor clearColor]; + [rootViewController.view addSubview:self.backgroundView]; + + WKWebViewConfiguration *configuration = [[WKWebViewConfiguration alloc] init]; + configuration.websiteDataStore = [WKWebsiteDataStore nonPersistentDataStore]; + WKWebView *webView = [[WKWebView alloc] initWithFrame:frame configuration:configuration]; + + [self configureWebView:webView]; + + NSURLRequest *request = [NSURLRequest requestWithURL:url]; + [webView loadRequest:request]; + + CLYButton *dismissButton = [CLYButton dismissAlertButton:@"X"]; + [self configureDismissButton:dismissButton forWebView:webView]; + + self.backgroundView.webView = webView; + self.backgroundView.dismissButton = dismissButton; + [webView evaluateJavaScript:@"document.readyState" completionHandler:^(id _Nullable result, NSError * _Nullable error) { + if ([result isKindOfClass:[NSString class]] && [(NSString *)result isEqualToString:@"complete"]) { + NSLog(@"Web view has finished loading"); + self.backgroundView.hidden = NO; + if (appearBlock) { + appearBlock(); + } + } + }]; +} + +- (void)configureWebView:(WKWebView *)webView { + webView.layer.shadowColor = UIColor.blackColor.CGColor; + webView.layer.shadowOpacity = 0.5; + webView.layer.shadowOffset = CGSizeMake(0.0f, 5.0f); + webView.layer.masksToBounds = NO; + webView.opaque = NO; + webView.scrollView.bounces = NO; + webView.navigationDelegate = self; + + [self.backgroundView addSubview:webView]; +} + +- (void)configureDismissButton:(CLYButton *)dismissButton forWebView:(WKWebView *)webView { + dismissButton.onClick = ^(id sender) { + if (self.dismissBlock) { + dispatch_async(dispatch_get_main_queue(), ^{ + self.dismissBlock(); + [self.backgroundView removeFromSuperview]; + }); + } + }; + + [self.backgroundView addSubview:dismissButton]; + [dismissButton positionToTopRight]; + [self.backgroundView bringSubviewToFront:webView]; + [webView bringSubviewToFront:dismissButton]; + + self.backgroundView.dismissButton = dismissButton; + dismissButton.hidden = YES; +} + +- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler { + NSString *url = navigationAction.request.URL.absoluteString; + + if ([url hasPrefix:@"https://countly_action_event"] && [url containsString:@"cly_x_action_event=1"]) { + NSDictionary *queryParameters = [self parseQueryString:url]; + NSString *action = queryParameters[@"action"]; + + if ([action isEqualToString:@"event"]) { + NSString *eventsJson = queryParameters[@"event"]; + [self recordEventsWithJSONString:eventsJson]; + } else if ([action isEqualToString:@"link"]) { + NSString *link = queryParameters[@"link"]; + [self openExternalLink:link]; + } else if ([action isEqualToString:@"resize_me"]) { + NSString *resize = queryParameters[@"resize_me"]; + [self resizeWebViewWithJSONString:resize]; + } + + if ([queryParameters[@"close"] boolValue]) { + [self closeWebView]; + } + + decisionHandler(WKNavigationActionPolicyCancel); + } else { + decisionHandler(WKNavigationActionPolicyAllow); + } +} + +- (void)webView:(WKWebView *)webView didStartProvisionalNavigation:(WKNavigation *)navigation { + CLY_LOG_I(@"%s Web view has started loading", __FUNCTION__); +} + +- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation { + CLY_LOG_I(@"%s Web view has finished loading", __FUNCTION__); +} + +- (void)animateView:(UIView *)view withAnimationType:(AnimationType)animationType { + NSTimeInterval animationDuration = 0; + CGAffineTransform initialTransform = CGAffineTransformIdentity; + + switch (animationType) { + case AnimationTypeSlideInFromBottom: + initialTransform = CGAffineTransformMakeTranslation(0, view.superview.frame.size.height); + break; + case AnimationTypeSlideInFromTop: + initialTransform = CGAffineTransformMakeTranslation(0, -view.superview.frame.size.height); + break; + case AnimationTypeSlideInFromLeft: + initialTransform = CGAffineTransformMakeTranslation(-view.superview.frame.size.width, 0); + break; + case AnimationTypeSlideInFromRight: + initialTransform = CGAffineTransformMakeTranslation(view.superview.frame.size.width, 0); + break; + case AnimationTypeIncreaseHeight: { + CGRect originalFrame = view.frame; + view.frame = CGRectMake(originalFrame.origin.x, originalFrame.origin.y, originalFrame.size.width, 0); + [UIView animateWithDuration:animationDuration animations:^{ + view.frame = originalFrame; + }]; + return; + } + default: + return; + } + + view.transform = initialTransform; + [UIView animateWithDuration:animationDuration animations:^{ + view.transform = CGAffineTransformIdentity; + }]; +} + +- (NSDictionary *)parseQueryString:(NSString *)url { + NSMutableDictionary *queryDict = [NSMutableDictionary dictionary]; + NSArray *urlComponents = [url componentsSeparatedByString:@"?"]; + + if (urlComponents.count > 1) { + NSArray *queryItems = [urlComponents[1] componentsSeparatedByString:@"&"]; + + for (NSString *item in queryItems) { + NSArray *keyValue = [item componentsSeparatedByString:@"="]; + if (keyValue.count == 2) { + NSString *key = keyValue[0]; + NSString *value = keyValue[1]; + queryDict[key] = value; + } + } + } + + return queryDict; +} + +- (void)recordEventsWithJSONString:(NSString *)jsonString { + NSData *data = [jsonString dataUsingEncoding:NSUTF8StringEncoding]; + NSArray *events = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + + for (NSDictionary *event in events) { + NSString *key = event[@"key"]; + NSDictionary *segmentation = event[@"sg"]; + if(!segmentation) { + CLY_LOG_I(@"Skipping the event due to missing segmentation"); + continue; + } + + [Countly.sharedInstance recordEvent:key segmentation:segmentation]; + } +} + +- (void)openExternalLink:(NSString *)urlString { + NSURL *url = [NSURL URLWithString:urlString]; + if (url) { + [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:^(BOOL success) { + if (success) { + CLY_LOG_I(@"URL [%@] opened in external browser", urlString); + } else { + CLY_LOG_I(@"Unable to open URL [%@] in external browser", urlString); + } + }]; + } +} + +- (void)resizeWebViewWithJSONString:(NSString *)jsonString { + NSData *data = [jsonString dataUsingEncoding:NSUTF8StringEncoding]; + NSDictionary *resizeDict = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + + NSDictionary *portraitDimensions = resizeDict[@"p"]; + NSDictionary *landscapeDimensions = resizeDict[@"l"]; + + UIInterfaceOrientation orientation = [UIApplication sharedApplication].statusBarOrientation; + BOOL isLandscape = UIInterfaceOrientationIsLandscape(orientation); + + NSDictionary *dimensions = isLandscape ? landscapeDimensions : portraitDimensions; + + CGFloat width = [dimensions[@"w"] floatValue]; + CGFloat height = [dimensions[@"h"] floatValue]; + + [UIView animateWithDuration:0.3 animations:^{ + CGRect frame = self.backgroundView.webView.frame; + frame.size.width = width; + frame.size.height = height; + self.backgroundView.webView.frame = frame; + } completion:^(BOOL finished) { + CLY_LOG_I(@"Resized web view to width: %f, height: %f", width, height); + }]; +} + + +- (void)closeWebView { + dispatch_async(dispatch_get_main_queue(), ^{ + if (self.dismissBlock) { + self.dismissBlock(); + } + [self.backgroundView removeFromSuperview]; + }); +} +#endif +@end +#endif diff --git a/ios/src/PassThroughBackgroundView.h b/ios/src/PassThroughBackgroundView.h new file mode 100644 index 00000000..c0b9b79f --- /dev/null +++ b/ios/src/PassThroughBackgroundView.h @@ -0,0 +1,27 @@ +// PassThroughBackgroundView.h +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + + +#if (TARGET_OS_IOS) +#import +#import +#endif + +#import "CountlyCommon.h" + +NS_ASSUME_NONNULL_BEGIN +#if (TARGET_OS_IOS) +@interface PassThroughBackgroundView : UIView + + +@property (nonatomic, strong) WKWebView *webView; +@property (nonatomic, strong) CLYButton *dismissButton; + + + +@end +#endif +NS_ASSUME_NONNULL_END diff --git a/ios/src/PassThroughBackgroundView.m b/ios/src/PassThroughBackgroundView.m new file mode 100644 index 00000000..d6bd8328 --- /dev/null +++ b/ios/src/PassThroughBackgroundView.m @@ -0,0 +1,36 @@ +// PassThroughBackgroundView.m +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import "PassThroughBackgroundView.h" + +#if (TARGET_OS_IOS) +@implementation PassThroughBackgroundView + +@synthesize webView; + +- (instancetype)initWithFrame:(CGRect)frame { + + self = [super initWithFrame:frame]; + if (self) { + } + return self; +} + +- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event { + + if (self.webView && CGRectContainsPoint(self.webView.frame, point)) { + return YES; + } + if (self.dismissButton && CGRectContainsPoint(self.dismissButton.frame, point)) { + return YES; + } + + return NO; +} + + +@end +#endif diff --git a/ios/src/include/CountlyContentBuilder.h b/ios/src/include/CountlyContentBuilder.h new file mode 120000 index 00000000..eac7b004 --- /dev/null +++ b/ios/src/include/CountlyContentBuilder.h @@ -0,0 +1 @@ +../CountlyContentBuilder.h \ No newline at end of file diff --git a/ios/src/include/CountlyContentConfig.h b/ios/src/include/CountlyContentConfig.h new file mode 120000 index 00000000..6a8fafd0 --- /dev/null +++ b/ios/src/include/CountlyContentConfig.h @@ -0,0 +1 @@ +../CountlyContentConfig.h \ No newline at end of file diff --git a/ios/src/include/CountlyCrashData.h b/ios/src/include/CountlyCrashData.h new file mode 120000 index 00000000..0aeaa981 --- /dev/null +++ b/ios/src/include/CountlyCrashData.h @@ -0,0 +1 @@ +../CountlyCrashData.h \ No newline at end of file diff --git a/ios/src/include/CountlyCrashesConfig.h b/ios/src/include/CountlyCrashesConfig.h new file mode 120000 index 00000000..8fcc3be9 --- /dev/null +++ b/ios/src/include/CountlyCrashesConfig.h @@ -0,0 +1 @@ +../CountlyCrashesConfig.h \ No newline at end of file diff --git a/ios/src/include/CountlyExperimentalConfig.h b/ios/src/include/CountlyExperimentalConfig.h new file mode 120000 index 00000000..77ddc337 --- /dev/null +++ b/ios/src/include/CountlyExperimentalConfig.h @@ -0,0 +1 @@ +../CountlyExperimentalConfig.h \ No newline at end of file diff --git a/ios/src/include/CountlyFeedbacks.h b/ios/src/include/CountlyFeedbacks.h new file mode 120000 index 00000000..b7e5847b --- /dev/null +++ b/ios/src/include/CountlyFeedbacks.h @@ -0,0 +1 @@ +../CountlyFeedbacks.h \ No newline at end of file From 039a36b9617b410b68207221b34f0f2f559d5d1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20R=C4=B1za=20Kat?= Date: Thu, 24 Oct 2024 14:33:56 +0300 Subject: [PATCH 02/34] Update Android Version to 24.7.4 --- android/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/build.gradle b/android/build.gradle index 54ccdcec..735895fe 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -41,7 +41,7 @@ repositories { dependencies { implementation "com.facebook.react:react-native:${safeExtGet('reactNativeVersion', '+')}" - implementation 'ly.count.android:sdk:24.4.1' + implementation 'ly.count.android:sdk:24.7.4' // Import the BoM for the Firebase platform // The BoM version of 28.4.2 is the newest release that will target firebase-messaging version 22 From 2db9233820b852b652560b1e4804b2407370e51a Mon Sep 17 00:00:00 2001 From: turtledreams <62231246+turtledreams@users.noreply.github.com> Date: Mon, 28 Oct 2024 19:15:00 +0900 Subject: [PATCH 03/34] update --- example/README.md | 26 ++++++++++++++++++++++++-- example/create_app.py | 2 +- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/example/README.md b/example/README.md index 1e31673d..1a00bf76 100644 --- a/example/README.md +++ b/example/README.md @@ -1,7 +1,9 @@ # Creating the Sample Countly RN App To run a React Native application you have to set up your environment correctly. -Please refer to the React Native [documentation](https://reactnative.dev/docs/environment-setup) to check the latest information on this topic. +Please refer to the React Native [documentation](https://reactnative.dev/docs/set-up-your-environment)* to check the latest information on this topic. + +(Incase there is a change in documentation links you should check the React Native [offical site](https://reactnative.dev/)) ## Automatic App Creation @@ -19,11 +21,12 @@ npx react-native run-android ``` ## Manual App Creation +For more information you can check [here](https://reactnative.dev/docs/getting-started-without-a-framework). If you want to set up the app manually instead, then you should run: ```bash -npx react-native@latest init AwesomeProject +npx @react-native-community/cli@latest init AwesomeProject ``` Then copy the contents of CountlyRNExample into the AwesomeProject and let it replace the App.tsx there. @@ -43,6 +46,25 @@ Finally you can run: npx react-native run-android # or npx react-native run-ios ``` +## Debugging +For possible java issues you can try some of the following options: +- changing the IDE settings. +- changing the JAVA_HOME environment variable. +- changing `org.gradle.java.home` in `gradle.properties`. + +Currently Java 17 and bigger is needed. + +For a ninja issue about path length you might want to download and point to a specific ninja version: +```java +// under app level build.gradle's defaultConfig + externalNativeBuild { + cmake { + arguments "-DCMAKE_MAKE_PROGRAM=your_path\ninja.exe", "-DCMAKE_OBJECT_PATH_MAX=1024" + } + } +``` + +For an issue with the recent version of React Native (0.76) about safe area context you can check this [thread](https://github.com/th3rdwave/react-native-safe-area-context/issues/539#issuecomment-2436529368). ## iOS Push Notification Documentation diff --git a/example/create_app.py b/example/create_app.py index 87bee1bb..43df0236 100644 --- a/example/create_app.py +++ b/example/create_app.py @@ -19,7 +19,7 @@ def setup_react_native_app(): print("Setting up React Native app...") # Set up React Native app - os.system("npx react-native@latest init AwesomeProject") + os.system("npx @react-native-community/cli@latest init AwesomeProject") print("Copying contents of CountlyRNExample to AwesomeProject...") From 36149c6882dbd001bce3c9d797f79d4f3cdec12a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20R=C4=B1za=20Kat?= Date: Mon, 28 Oct 2024 18:24:07 +0300 Subject: [PATCH 04/34] Updated Init Line in Example App --- example/README.md | 4 +++- example/create_app.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/example/README.md b/example/README.md index 1a00bf76..26772eb5 100644 --- a/example/README.md +++ b/example/README.md @@ -26,7 +26,9 @@ For more information you can check [here](https://reactnative.dev/docs/getting-s If you want to set up the app manually instead, then you should run: ```bash -npx @react-native-community/cli@latest init AwesomeProject +npx @react-native-community/cli@latest init AwesomeProject --version 0.74.0 +# Version here may vary but make sure to use a stabile version of the react-native +# Latest versions can experience issues because of unstability ``` Then copy the contents of CountlyRNExample into the AwesomeProject and let it replace the App.tsx there. diff --git a/example/create_app.py b/example/create_app.py index 43df0236..cc06a5c4 100644 --- a/example/create_app.py +++ b/example/create_app.py @@ -19,7 +19,9 @@ def setup_react_native_app(): print("Setting up React Native app...") # Set up React Native app - os.system("npx @react-native-community/cli@latest init AwesomeProject") + # Latest version of the react-native can experience issues because of unstability + # Because of that it's preferred to use a stabile version of react-native here + os.system("npx @react-native-community/cli@latest init AwesomeProject --version 0.74.0") print("Copying contents of CountlyRNExample to AwesomeProject...") From 4414aaa29fad0c0a31f29ca869b5358470902482 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20R=C4=B1za=20Kat?= Date: Wed, 30 Oct 2024 14:42:52 +0300 Subject: [PATCH 05/34] Android Part is Added --- Countly.d.ts | 15 ++++++++++ Countly.js | 30 +++++++++++++++++++ .../android/sdk/react/CountlyReactNative.java | 13 +++++++- 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/Countly.d.ts b/Countly.d.ts index dd8d12c3..479000d7 100644 --- a/Countly.d.ts +++ b/Countly.d.ts @@ -176,6 +176,21 @@ declare module "countly-sdk-react-native-bridge" { export function cancelEvent(eventName: string): void; } + /** + * Countly Content Module + */ + namespace contents { + /** + * Opt in user for the content fetching and updates + */ + export function enterContentZone(): void; + + /** + * Opt out user from the content fetching and updates + */ + export function exitContentZone(): void; + } + /** * Initialize Countly * diff --git a/Countly.js b/Countly.js index da830e2e..e86a458b 100644 --- a/Countly.js +++ b/Countly.js @@ -32,6 +32,8 @@ let _isCrashReportingEnabled = false; Countly.userData = {}; // userData interface Countly.userDataBulk = {}; // userDataBulk interface +Countly.contents = {}; // content interface + let _isPushInitialized = false; /* @@ -2178,4 +2180,32 @@ Countly.setCustomMetrics = async function (customMetric) { } }; +/** + * Opt in user for the content fetching and updates + * + * NOTE: This is an EXPERIMENTAL feature, and it can have breaking changes + */ +Countly.contents.enterContentZone = function() { + if (!_state.isInitialized) { + const message = "'init' must be called before 'enterContentZone'"; + L.e(`enterContentZone, ${message}`); + return message; + } + CountlyReactNative.enterContentZone(); +}; + +/** + * Opt out user from the content fetching and updates + * + * NOTE: This is an EXPERIMENTAL feature, and it can have breaking changes + */ +Countly.contents.exitContentZone = function() { + if (!_state.isInitialized) { + const message = "'init' must be called before 'exitContentZone'"; + L.e(`exitContentZone, ${message}`); + return message; + } + CountlyReactNative.exitContentZone(); +}; + export default Countly; diff --git a/android/src/main/java/ly/count/android/sdk/react/CountlyReactNative.java b/android/src/main/java/ly/count/android/sdk/react/CountlyReactNative.java index cf13ff23..5ccfda9a 100644 --- a/android/src/main/java/ly/count/android/sdk/react/CountlyReactNative.java +++ b/android/src/main/java/ly/count/android/sdk/react/CountlyReactNative.java @@ -125,7 +125,8 @@ public class CountlyReactNative extends ReactContextBaseJavaModule implements Li Countly.CountlyFeatureNames.starRating, Countly.CountlyFeatureNames.apm, Countly.CountlyFeatureNames.feedback, - Countly.CountlyFeatureNames.remoteConfig + Countly.CountlyFeatureNames.remoteConfig, + Countly.CountlyFeatureNames.content )); public CountlyReactNative(ReactApplicationContext reactContext) { @@ -1599,6 +1600,16 @@ public void appLoadingFinished() { Countly.sharedInstance().apm().setAppIsLoaded(); } + @ReactMethod + public void enterContentZone() { + Countly.sharedInstance().contents().enterContentZone(); + } + + @ReactMethod + public void exitContentZone() { + Countly.sharedInstance().contents().exitContentZone(); + } + @ReactMethod public void setCustomMetrics(ReadableArray args) { Map customMetric = new HashMap<>(); From 6d07831256882b529511a126f7c4409b345a8ac1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20R=C4=B1za=20Kat?= Date: Wed, 30 Oct 2024 16:55:01 +0300 Subject: [PATCH 06/34] iOS Content Added --- Countly.d.ts | 2 +- Countly.js | 6 +++--- ios/src/CountlyReactNative.h | 3 +++ ios/src/CountlyReactNative.m | 12 ++++++++++++ 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/Countly.d.ts b/Countly.d.ts index 479000d7..8de656e9 100644 --- a/Countly.d.ts +++ b/Countly.d.ts @@ -179,7 +179,7 @@ declare module "countly-sdk-react-native-bridge" { /** * Countly Content Module */ - namespace contents { + namespace content { /** * Opt in user for the content fetching and updates */ diff --git a/Countly.js b/Countly.js index e86a458b..e086abff 100644 --- a/Countly.js +++ b/Countly.js @@ -32,7 +32,7 @@ let _isCrashReportingEnabled = false; Countly.userData = {}; // userData interface Countly.userDataBulk = {}; // userDataBulk interface -Countly.contents = {}; // content interface +Countly.content = {}; // content interface let _isPushInitialized = false; @@ -2185,7 +2185,7 @@ Countly.setCustomMetrics = async function (customMetric) { * * NOTE: This is an EXPERIMENTAL feature, and it can have breaking changes */ -Countly.contents.enterContentZone = function() { +Countly.content.enterContentZone = function() { if (!_state.isInitialized) { const message = "'init' must be called before 'enterContentZone'"; L.e(`enterContentZone, ${message}`); @@ -2199,7 +2199,7 @@ Countly.contents.enterContentZone = function() { * * NOTE: This is an EXPERIMENTAL feature, and it can have breaking changes */ -Countly.contents.exitContentZone = function() { +Countly.content.exitContentZone = function() { if (!_state.isInitialized) { const message = "'init' must be called before 'exitContentZone'"; L.e(`exitContentZone, ${message}`); diff --git a/ios/src/CountlyReactNative.h b/ios/src/CountlyReactNative.h index 31345097..8064e7e8 100644 --- a/ios/src/CountlyReactNative.h +++ b/ios/src/CountlyReactNative.h @@ -53,6 +53,9 @@ typedef void (^Result)(id _Nullable result); - (void)appLoadingFinished; - (void)disablePushNotifications; +- (void)enterContentZone; +- (void)exitContentZone; + #ifndef COUNTLY_EXCLUDE_PUSHNOTIFICATIONS - (void)notificationCallback:(NSString *_Nullable)notificationJson; + (void)startObservingNotifications; diff --git a/ios/src/CountlyReactNative.m b/ios/src/CountlyReactNative.m index a600a614..a1ad4d75 100644 --- a/ios/src/CountlyReactNative.m +++ b/ios/src/CountlyReactNative.m @@ -1285,6 +1285,18 @@ - (CountlyFeedbackWidget *)getFeedbackWidget:(NSString *)widgetId { }); } +RCT_EXPORT_METHOD(enterContentZone) { + dispatch_async(dispatch_get_main_queue(), ^{ + [Countly.sharedInstance.content enterContentZone]; + }); +} + +RCT_EXPORT_METHOD(exitContentZone) { + dispatch_async(dispatch_get_main_queue(), ^{ + [Countly.sharedInstance.content exitContentZone]; + }); +} + - (void)addCountlyFeature:(CLYFeature)feature { if (countlyFeatures == nil) { countlyFeatures = [[NSMutableArray alloc] init]; From 6251d5784c087d7e7530592e2df04bcb96542a50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20R=C4=B1za=20Kat?= Date: Wed, 30 Oct 2024 17:13:16 +0300 Subject: [PATCH 07/34] Changelog Entry --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c697b90..acc82819 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## XX.X.X +* Added Content feature methods: + * enterContentZone, to start Content checks (Experimental!) + * exitContentZone, to stop Content checks (Experimental!) + ## 24.4.1 * Added support for Feedback Widget terms and conditions * Added six new configuration options under the 'sdkInternalLimits' interface of 'CountlyConfig': From 01837a6770cc8dda3e689c10fd0250fe1c4cd5c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20R=C4=B1za=20Kat?= Date: Thu, 31 Oct 2024 12:32:37 +0300 Subject: [PATCH 08/34] Update CHANGELOG.md --- CHANGELOG.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index acc82819..6e50453e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,38 @@ ## XX.X.X +* ! Minor breaking change ! User properties will now be automatically saved under the following conditions: + * When an event is recorded + * During an internal timer tick + * Upon flushing the event queue * Added Content feature methods: * enterContentZone, to start Content checks (Experimental!) * exitContentZone, to stop Content checks (Experimental!) +* Mitigated an issue where a session could have started while the app was in the background when the device ID was changed (non-merge). + +* Android Specific Changes: + * ! Minor breaking change ! Unsupported types for user properties will now be omitted, they won't be converted to strings. + * Disabled caching for webviews. + * Added support for array, List and JSONArray to all user given segmentations. They will support only mutable and ummutable versions of the primitive types. Which are: + * String, Integer, int, Boolean, bool, Float, float, Double, double, Long, long + * Keep in mind that float array will be converted to the double array by the JSONArray + * Mitigated an issue in the upload plugin that prevented the upload of a symbol file + * Resolved a problem where revoked consents were sent after changes without merging. + * Fixed a bug that caused the device ID to be incorrectly set after changes with merging. + * Mitigated an issue where on consent revoke, remote config values were cleared, not anymore. + +* iOS Specific Changes: + * Added visionOS build support + * Mitigated an issue with the feedback widget URL encoding on iOS 16 and earlier, which prevented the widget from displaying + * Mitigated an issue with content fetch URL encoding on iOS 16 and earlier, which caused the request to fail + * Improved crash filtering capabilities to include modifications on the crash report + * Mitigated an issue where the terms and conditions URL (`tc` key) was sent without double quotes + * Enhanced segmentation values to include additional supported data types beyond `NSString` + * Orientation info is now also sent during initialization + * Mitigated an issue where consent information was not sent when no consent was given during initialization + * Mitigated an issue where a session did not end when session consent was removed + * Updated the SDK to ensure compatibility with the latest server response models + +* Updated the underlying Android SDK version to 24.7.4 +* Updated the underlying iOS SDK version to 24.7.4 ## 24.4.1 * Added support for Feedback Widget terms and conditions From c2fb99ad77b8f9a091be1f669fda0b9eff5456cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20R=C4=B1za=20Kat?= Date: Thu, 31 Oct 2024 13:05:37 +0300 Subject: [PATCH 09/34] Update CHANGELOG.md --- CHANGELOG.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e50453e..c122b1bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,6 @@ * Android Specific Changes: * ! Minor breaking change ! Unsupported types for user properties will now be omitted, they won't be converted to strings. * Disabled caching for webviews. - * Added support for array, List and JSONArray to all user given segmentations. They will support only mutable and ummutable versions of the primitive types. Which are: - * String, Integer, int, Boolean, bool, Float, float, Double, double, Long, long - * Keep in mind that float array will be converted to the double array by the JSONArray * Mitigated an issue in the upload plugin that prevented the upload of a symbol file * Resolved a problem where revoked consents were sent after changes without merging. * Fixed a bug that caused the device ID to be incorrectly set after changes with merging. @@ -23,9 +20,7 @@ * Added visionOS build support * Mitigated an issue with the feedback widget URL encoding on iOS 16 and earlier, which prevented the widget from displaying * Mitigated an issue with content fetch URL encoding on iOS 16 and earlier, which caused the request to fail - * Improved crash filtering capabilities to include modifications on the crash report * Mitigated an issue where the terms and conditions URL (`tc` key) was sent without double quotes - * Enhanced segmentation values to include additional supported data types beyond `NSString` * Orientation info is now also sent during initialization * Mitigated an issue where consent information was not sent when no consent was given during initialization * Mitigated an issue where a session did not end when session consent was removed From 28dddc30f7b734894ca178ac877c487e6327b0e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20R=C4=B1za=20Kat?= Date: Thu, 31 Oct 2024 13:28:07 +0300 Subject: [PATCH 10/34] Update Countly.js --- Countly.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Countly.js b/Countly.js index e086abff..ae5c103f 100644 --- a/Countly.js +++ b/Countly.js @@ -2186,10 +2186,11 @@ Countly.setCustomMetrics = async function (customMetric) { * NOTE: This is an EXPERIMENTAL feature, and it can have breaking changes */ Countly.content.enterContentZone = function() { + L.i("enterContentZone, opting for content fetching."); if (!_state.isInitialized) { const message = "'init' must be called before 'enterContentZone'"; L.e(`enterContentZone, ${message}`); - return message; + return; } CountlyReactNative.enterContentZone(); }; @@ -2200,10 +2201,11 @@ Countly.content.enterContentZone = function() { * NOTE: This is an EXPERIMENTAL feature, and it can have breaking changes */ Countly.content.exitContentZone = function() { + L.i("exitContentZone, opting out from content fetching."); if (!_state.isInitialized) { const message = "'init' must be called before 'exitContentZone'"; L.e(`exitContentZone, ${message}`); - return message; + return; } CountlyReactNative.exitContentZone(); }; From d6a4ec498576b37c858f2024684f0717e6df8235 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20R=C4=B1za=20Kat?= Date: Thu, 31 Oct 2024 13:30:47 +0300 Subject: [PATCH 11/34] Added Content Calls in Example App --- example/CountlyRNExample/Others.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/example/CountlyRNExample/Others.tsx b/example/CountlyRNExample/Others.tsx index 5c98e702..2966c88f 100644 --- a/example/CountlyRNExample/Others.tsx +++ b/example/CountlyRNExample/Others.tsx @@ -50,6 +50,14 @@ const recordIndirectAttribution = () => { Countly.recordIndirectAttribution(attributionValues); }; +const enterContentZone = () => { + Countly.contents.enterContentZone(); +} + +const exitContentZone = () => { + Countly.contents.exitContentZone(); +} + function OthersScreen({ navigation }) { return ( @@ -60,6 +68,8 @@ function OthersScreen({ navigation }) { + + ); From ce51d8860fd459811d365e3b2e475f7ffd1a11b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20R=C4=B1za=20Kat?= Date: Thu, 31 Oct 2024 16:24:01 +0300 Subject: [PATCH 12/34] Example Naming Fix --- example/CountlyRNExample/Others.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/example/CountlyRNExample/Others.tsx b/example/CountlyRNExample/Others.tsx index 2966c88f..e7a52052 100644 --- a/example/CountlyRNExample/Others.tsx +++ b/example/CountlyRNExample/Others.tsx @@ -51,11 +51,11 @@ const recordIndirectAttribution = () => { }; const enterContentZone = () => { - Countly.contents.enterContentZone(); + Countly.content.enterContentZone(); } const exitContentZone = () => { - Countly.contents.exitContentZone(); + Countly.content.exitContentZone(); } function OthersScreen({ navigation }) { From 374a86dd5f0b8d6df428ff7ec947880369afedff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20R=C4=B1za=20Kat?= Date: Thu, 31 Oct 2024 17:17:15 +0300 Subject: [PATCH 13/34] Device Id Interface Added --- CHANGELOG.md | 4 ++ Countly.d.ts | 37 ++++++++++++- Countly.js | 9 +++- DeviceId.js | 75 +++++++++++++++++++++++++++ example/CountlyRNExample/DeviceID.tsx | 4 +- 5 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 DeviceId.js diff --git a/CHANGELOG.md b/CHANGELOG.md index c122b1bc..e7bcb3a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ * enterContentZone, to start Content checks (Experimental!) * exitContentZone, to stop Content checks (Experimental!) * Mitigated an issue where a session could have started while the app was in the background when the device ID was changed (non-merge). +* Deprecated following SDK calls: + * Countly.getCurrentDeviceId (replaced with: Countly.deviceId.getCurrentDeviceId) + * Countly.getDeviceIDType (replaced with: Countly.deviceId.getDeviceIDType) + * Countly.changeDeviceId (replaced with: Countly.deviceId.changeDeviceId) * Android Specific Changes: * ! Minor breaking change ! Unsupported types for user properties will now be omitted, they won't be converted to strings. diff --git a/Countly.d.ts b/Countly.d.ts index 8de656e9..7bbcadbc 100644 --- a/Countly.d.ts +++ b/Countly.d.ts @@ -402,7 +402,8 @@ declare module "countly-sdk-react-native-bridge" { export function disableLocation(): string | void; /** - * + * @deprecated use 'Countly.deviceId.getCurrentDeviceId' instead of 'Countly.getCurrentDeviceId' + * * Get currently used device Id. * Should be called after Countly init * @@ -411,6 +412,8 @@ declare module "countly-sdk-react-native-bridge" { export function getCurrentDeviceId(): Promise | string; /** + * @deprecated use 'Countly.deviceId.getDeviceIDType' instead of 'Countly.getDeviceIDType' + * * Get currently used device Id type. * Should be called after Countly init * @@ -419,6 +422,8 @@ declare module "countly-sdk-react-native-bridge" { export function getDeviceIDType(): Promise | null; /** + * @deprecated use 'Countly.deviceId.changeDeviceId' instead of 'Countly.changeDeviceId' + * * Change the current device id * * @param {string} newDeviceID id new device id @@ -427,6 +432,36 @@ declare module "countly-sdk-react-native-bridge" { */ export function changeDeviceId(newDeviceID: string, onServer: boolean): string | void; + namespace deviceId { + /** + * + * Get currently used device Id. + * Should be called after Countly init + * + * @return {string} device id or error message + */ + export function getCurrentDeviceId(): Promise | string; + + /** + * + * Get currently used device Id type. + * Should be called after Countly init + * + * @return {DeviceIdType | null} deviceIdType or null + */ + export function getDeviceIDType(): Promise | null; + + /** + * + * Change the current device id + * + * @param {string} newDeviceID id new device id + * @param {boolean} onServer merge device id + * @return {string | void} error message or void + */ + export function changeDeviceId(newDeviceID: string, onServer: boolean): string | void; + } + /** * * Set to "true" if you want HTTP POST to be used for all requests diff --git a/Countly.js b/Countly.js index ae5c103f..ceb1aaec 100644 --- a/Countly.js +++ b/Countly.js @@ -10,6 +10,7 @@ import CountlyConfig from "./CountlyConfig.js"; import CountlyState from "./CountlyState.js"; import Feedback from "./Feedback.js"; import Event from "./Event.js"; +import DeviceId from "./DeviceId.js"; import * as L from "./Logger.js"; import * as Utils from "./Utils.js"; import * as Validate from "./Validators.js"; @@ -26,6 +27,7 @@ CountlyState.eventEmitter = eventEmitter; Countly.feedback = new Feedback(CountlyState); Countly.events = new Event(CountlyState); +Countly.deviceId = new DeviceId(CountlyState); let _isCrashReportingEnabled = false; @@ -458,7 +460,8 @@ Countly.disableLocation = function () { }; /** - * + * @deprecated use 'Countly.deviceId.getCurrentDeviceId' instead of 'Countly.getCurrentDeviceId' + * * Get currently used device Id. * Should be called after Countly init * @@ -476,6 +479,8 @@ Countly.getCurrentDeviceId = async function () { }; /** + * @deprecated use 'Countly.deviceId.getDeviceIDType' instead of 'Countly.getDeviceIDType' + * * Get currently used device Id type. * Should be called after Countly init * @@ -492,6 +497,8 @@ Countly.getDeviceIDType = async function () { }; /** + * @deprecated use 'Countly.deviceId.changeDeviceId' instead of 'Countly.changeDeviceId' + * * Change the current device id * * @param {string} newDeviceID id new device id diff --git a/DeviceId.js b/DeviceId.js new file mode 100644 index 00000000..879870b8 --- /dev/null +++ b/DeviceId.js @@ -0,0 +1,75 @@ +import * as L from "./Logger.js"; +import * as Validate from "./Validators.js"; +import * as Utils from "./Utils.js"; + +class DeviceId { + #state; + + constructor(state) { + this.#state = state; + } + + /** + * + * Get currently used device Id. + * Should be called after Countly init + * + * @return {string} device id or error message + */ + getCurrentDeviceId = async function () { + if (!this.#state.isInitialized) { + const message = "'init' must be called before 'getCurrentDeviceId'"; + L.e(`getCurrentDeviceId, ${message}`); + return message; + } + L.d("getCurrentDeviceId, Getting current device id"); + const result = await this.#state.CountlyReactNative.getCurrentDeviceId(); + return result; + }; + + /** + * Get currently used device Id type. + * Should be called after Countly init + * + * @return {DeviceIdType | null} deviceIdType or null + */ + getDeviceIDType = async function () { + if (!this.#state.isInitialized) { + L.e("getDeviceIDType, 'init' must be called before 'getDeviceIDType'"); + return null; + } + L.d("getDeviceIDType, Getting device id type"); + const result = await this.#state.CountlyReactNative.getDeviceIDType(); + return Utils.intToDeviceIDType(result); + }; + + /** + * Change the current device id + * + * @param {string} newDeviceID id new device id + * @param {boolean} onServer merge device id + * @return {string | void} error message or void + */ + changeDeviceId = function (newDeviceID, onServer) { + if (!this.#state.isInitialized) { + const msg = "'init' must be called before 'changeDeviceId'"; + L.e(`changeDeviceId, ${msg}`); + return msg; + } + const message = Validate.String(newDeviceID, "newDeviceID", "changeDeviceId"); + if (message) { + return message; + } + + L.d(`changeDeviceId, Changing to new device id: [${newDeviceID}], with merge: [${onServer}]`); + if (!onServer) { + onServer = "0"; + } else { + onServer = "1"; + } + newDeviceID = newDeviceID.toString(); + this.#state.CountlyReactNative.changeDeviceId([newDeviceID, onServer]); + }; +} + +export default DeviceId; \ No newline at end of file diff --git a/example/CountlyRNExample/DeviceID.tsx b/example/CountlyRNExample/DeviceID.tsx index 6cd735d8..23e9e660 100644 --- a/example/CountlyRNExample/DeviceID.tsx +++ b/example/CountlyRNExample/DeviceID.tsx @@ -6,11 +6,11 @@ import CountlyButton from "./CountlyButton"; import { lightOrange } from "./Constants"; const temporaryDeviceIdMode = () => { - Countly.changeDeviceId(Countly.TemporaryDeviceIDString, true); + Countly.deviceId.changeDeviceId(Countly.TemporaryDeviceIDString, true); }; const changeDeviceId = () => { - Countly.changeDeviceId("02d56d66-6a39-482d-aff0-d14e4d5e5fda", true); + Countly.deviceId.changeDeviceId("02d56d66-6a39-482d-aff0-d14e4d5e5fda", true); }; function DeviceIDScreen({ navigation }) { From 18dccd18044887bab334efaa581109d1ac43d64b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20R=C4=B1za=20Kat?= Date: Thu, 31 Oct 2024 18:28:20 +0300 Subject: [PATCH 14/34] setID call added --- Countly.d.ts | 8 ++++++++ DeviceId.js | 19 +++++++++++++++++++ .../android/sdk/react/CountlyReactNative.java | 5 +++++ example/CountlyRNExample/DeviceID.tsx | 5 +++++ ios/src/CountlyReactNative.h | 2 ++ ios/src/CountlyReactNative.m | 6 ++++++ 6 files changed, 45 insertions(+) diff --git a/Countly.d.ts b/Countly.d.ts index 7bbcadbc..607b2839 100644 --- a/Countly.d.ts +++ b/Countly.d.ts @@ -460,6 +460,14 @@ declare module "countly-sdk-react-native-bridge" { * @return {string | void} error message or void */ export function changeDeviceId(newDeviceID: string, onServer: boolean): string | void; + + /** + * Sets device ID according to the device ID Type. + * If previous ID was Developer Supplied sets it without merge, otherwise with merge. + * + * @param {string} newDeviceID device id to set + */ + export function setId(newDeviceID: string): void; } /** diff --git a/DeviceId.js b/DeviceId.js index 879870b8..12c6ba91 100644 --- a/DeviceId.js +++ b/DeviceId.js @@ -70,6 +70,25 @@ class DeviceId { newDeviceID = newDeviceID.toString(); this.#state.CountlyReactNative.changeDeviceId([newDeviceID, onServer]); }; + + /** + * Sets device ID according to the device ID Type. + * If previous ID was Developer Supplied sets it without merge, otherwise with merge. + * + * @param {string} newDeviceID device id to set + */ + setId = function(newDeviceID) { + if (!this.#state.isInitialized) { + const msg = "'init' must be called before 'setId'"; + L.e(`setId, ${msg}`); + return msg; + } + + L.d(`setId, Setting device id as: [${newDeviceID}]`); + + newDeviceID = newDeviceID.toString(); + this.#state.CountlyReactNative.setId([newDeviceID]); + }; } export default DeviceId; \ No newline at end of file diff --git a/android/src/main/java/ly/count/android/sdk/react/CountlyReactNative.java b/android/src/main/java/ly/count/android/sdk/react/CountlyReactNative.java index 5ccfda9a..353652cc 100644 --- a/android/src/main/java/ly/count/android/sdk/react/CountlyReactNative.java +++ b/android/src/main/java/ly/count/android/sdk/react/CountlyReactNative.java @@ -1610,6 +1610,11 @@ public void exitContentZone() { Countly.sharedInstance().contents().exitContentZone(); } + @ReactMethod + public void setId(String newDeviceID) { + Countly.sharedInstance().deviceId().setID(newDeviceID); + } + @ReactMethod public void setCustomMetrics(ReadableArray args) { Map customMetric = new HashMap<>(); diff --git a/example/CountlyRNExample/DeviceID.tsx b/example/CountlyRNExample/DeviceID.tsx index 23e9e660..67f4bea1 100644 --- a/example/CountlyRNExample/DeviceID.tsx +++ b/example/CountlyRNExample/DeviceID.tsx @@ -13,12 +13,17 @@ const changeDeviceId = () => { Countly.deviceId.changeDeviceId("02d56d66-6a39-482d-aff0-d14e4d5e5fda", true); }; +const setDeviceId = () => { + Countly.deviceId.setId("TestingDeviceIDValue"); +}; + function DeviceIDScreen({ navigation }) { return ( + ); diff --git a/ios/src/CountlyReactNative.h b/ios/src/CountlyReactNative.h index 8064e7e8..92545f35 100644 --- a/ios/src/CountlyReactNative.h +++ b/ios/src/CountlyReactNative.h @@ -56,6 +56,8 @@ typedef void (^Result)(id _Nullable result); - (void)enterContentZone; - (void)exitContentZone; +- (void)setId; + #ifndef COUNTLY_EXCLUDE_PUSHNOTIFICATIONS - (void)notificationCallback:(NSString *_Nullable)notificationJson; + (void)startObservingNotifications; diff --git a/ios/src/CountlyReactNative.m b/ios/src/CountlyReactNative.m index a1ad4d75..1aa2b03f 100644 --- a/ios/src/CountlyReactNative.m +++ b/ios/src/CountlyReactNative.m @@ -257,6 +257,12 @@ - (void) populateConfig:(id) json { } } +RCT_EXPORT_METHOD(setId : (NSString *)newDeviceID) { + dispatch_async(dispatch_get_main_queue(), ^{ + [Countly.sharedInstance setID:newDeviceID]; + }); +} + RCT_EXPORT_METHOD(recordEvent : (NSDictionary *)arguments) { dispatch_async(dispatch_get_main_queue(), ^{ NSString *eventName = [arguments objectForKey:@"n"]; From aeb64b88db90cd8e520f0e4352d1b156f068be15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20R=C4=B1za=20Kat?= Date: Fri, 1 Nov 2024 15:53:25 +0300 Subject: [PATCH 15/34] Renamed Calls --- Countly.d.ts | 8 ++++---- Countly.js | 4 ++-- DeviceId.js | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Countly.d.ts b/Countly.d.ts index 7bbcadbc..d8ead0c7 100644 --- a/Countly.d.ts +++ b/Countly.d.ts @@ -402,7 +402,7 @@ declare module "countly-sdk-react-native-bridge" { export function disableLocation(): string | void; /** - * @deprecated use 'Countly.deviceId.getCurrentDeviceId' instead of 'Countly.getCurrentDeviceId' + * @deprecated use 'Countly.deviceId.getID' instead of 'Countly.getCurrentDeviceId' * * Get currently used device Id. * Should be called after Countly init @@ -412,7 +412,7 @@ declare module "countly-sdk-react-native-bridge" { export function getCurrentDeviceId(): Promise | string; /** - * @deprecated use 'Countly.deviceId.getDeviceIDType' instead of 'Countly.getDeviceIDType' + * @deprecated use 'Countly.deviceId.getType' instead of 'Countly.getDeviceIDType' * * Get currently used device Id type. * Should be called after Countly init @@ -440,7 +440,7 @@ declare module "countly-sdk-react-native-bridge" { * * @return {string} device id or error message */ - export function getCurrentDeviceId(): Promise | string; + export function getID(): Promise | string; /** * @@ -449,7 +449,7 @@ declare module "countly-sdk-react-native-bridge" { * * @return {DeviceIdType | null} deviceIdType or null */ - export function getDeviceIDType(): Promise | null; + export function getType(): Promise | null; /** * diff --git a/Countly.js b/Countly.js index ceb1aaec..c09acac4 100644 --- a/Countly.js +++ b/Countly.js @@ -460,7 +460,7 @@ Countly.disableLocation = function () { }; /** - * @deprecated use 'Countly.deviceId.getCurrentDeviceId' instead of 'Countly.getCurrentDeviceId' + * @deprecated use 'Countly.deviceId.getID' instead of 'Countly.getCurrentDeviceId' * * Get currently used device Id. * Should be called after Countly init @@ -479,7 +479,7 @@ Countly.getCurrentDeviceId = async function () { }; /** - * @deprecated use 'Countly.deviceId.getDeviceIDType' instead of 'Countly.getDeviceIDType' + * @deprecated use 'Countly.deviceId.getType' instead of 'Countly.getDeviceIDType' * * Get currently used device Id type. * Should be called after Countly init diff --git a/DeviceId.js b/DeviceId.js index 879870b8..36b3e632 100644 --- a/DeviceId.js +++ b/DeviceId.js @@ -16,7 +16,7 @@ class DeviceId { * * @return {string} device id or error message */ - getCurrentDeviceId = async function () { + getID = async function () { if (!this.#state.isInitialized) { const message = "'init' must be called before 'getCurrentDeviceId'"; L.e(`getCurrentDeviceId, ${message}`); @@ -33,7 +33,7 @@ class DeviceId { * * @return {DeviceIdType | null} deviceIdType or null */ - getDeviceIDType = async function () { + getType = async function () { if (!this.#state.isInitialized) { L.e("getDeviceIDType, 'init' must be called before 'getDeviceIDType'"); return null; From eb27479096cf0774b74ec6d531d19baf999fe272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20R=C4=B1za=20Kat?= Date: Fri, 1 Nov 2024 17:15:33 +0300 Subject: [PATCH 16/34] Requested Changes --- CHANGELOG.md | 2 +- Countly.d.ts | 12 +-------- Countly.js | 2 +- DeviceId.js | 39 ++++----------------------- example/CountlyRNExample/DeviceID.tsx | 4 +-- 5 files changed, 10 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7bcb3a9..1eb1d19d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ * Deprecated following SDK calls: * Countly.getCurrentDeviceId (replaced with: Countly.deviceId.getCurrentDeviceId) * Countly.getDeviceIDType (replaced with: Countly.deviceId.getDeviceIDType) - * Countly.changeDeviceId (replaced with: Countly.deviceId.changeDeviceId) + * Countly.changeDeviceId * Android Specific Changes: * ! Minor breaking change ! Unsupported types for user properties will now be omitted, they won't be converted to strings. diff --git a/Countly.d.ts b/Countly.d.ts index d8ead0c7..ad885fc0 100644 --- a/Countly.d.ts +++ b/Countly.d.ts @@ -422,7 +422,7 @@ declare module "countly-sdk-react-native-bridge" { export function getDeviceIDType(): Promise | null; /** - * @deprecated use 'Countly.deviceId.changeDeviceId' instead of 'Countly.changeDeviceId' + * @deprecated * * Change the current device id * @@ -450,16 +450,6 @@ declare module "countly-sdk-react-native-bridge" { * @return {DeviceIdType | null} deviceIdType or null */ export function getType(): Promise | null; - - /** - * - * Change the current device id - * - * @param {string} newDeviceID id new device id - * @param {boolean} onServer merge device id - * @return {string | void} error message or void - */ - export function changeDeviceId(newDeviceID: string, onServer: boolean): string | void; } /** diff --git a/Countly.js b/Countly.js index c09acac4..6817c1f6 100644 --- a/Countly.js +++ b/Countly.js @@ -497,7 +497,7 @@ Countly.getDeviceIDType = async function () { }; /** - * @deprecated use 'Countly.deviceId.changeDeviceId' instead of 'Countly.changeDeviceId' + * @deprecated * * Change the current device id * diff --git a/DeviceId.js b/DeviceId.js index 36b3e632..e3913298 100644 --- a/DeviceId.js +++ b/DeviceId.js @@ -18,11 +18,10 @@ class DeviceId { */ getID = async function () { if (!this.#state.isInitialized) { - const message = "'init' must be called before 'getCurrentDeviceId'"; - L.e(`getCurrentDeviceId, ${message}`); - return message; + L.e("getID, 'init' must be called before 'getID'"); + return null; } - L.d("getCurrentDeviceId, Getting current device id"); + L.d("getID, Getting current device id"); const result = await this.#state.CountlyReactNative.getCurrentDeviceId(); return result; }; @@ -35,41 +34,13 @@ class DeviceId { */ getType = async function () { if (!this.#state.isInitialized) { - L.e("getDeviceIDType, 'init' must be called before 'getDeviceIDType'"); + L.e("getType, 'init' must be called before 'getType'"); return null; } - L.d("getDeviceIDType, Getting device id type"); + L.d("getType, Getting device id type"); const result = await this.#state.CountlyReactNative.getDeviceIDType(); return Utils.intToDeviceIDType(result); }; - - /** - * Change the current device id - * - * @param {string} newDeviceID id new device id - * @param {boolean} onServer merge device id - * @return {string | void} error message or void - */ - changeDeviceId = function (newDeviceID, onServer) { - if (!this.#state.isInitialized) { - const msg = "'init' must be called before 'changeDeviceId'"; - L.e(`changeDeviceId, ${msg}`); - return msg; - } - const message = Validate.String(newDeviceID, "newDeviceID", "changeDeviceId"); - if (message) { - return message; - } - - L.d(`changeDeviceId, Changing to new device id: [${newDeviceID}], with merge: [${onServer}]`); - if (!onServer) { - onServer = "0"; - } else { - onServer = "1"; - } - newDeviceID = newDeviceID.toString(); - this.#state.CountlyReactNative.changeDeviceId([newDeviceID, onServer]); - }; } export default DeviceId; \ No newline at end of file diff --git a/example/CountlyRNExample/DeviceID.tsx b/example/CountlyRNExample/DeviceID.tsx index 23e9e660..6cd735d8 100644 --- a/example/CountlyRNExample/DeviceID.tsx +++ b/example/CountlyRNExample/DeviceID.tsx @@ -6,11 +6,11 @@ import CountlyButton from "./CountlyButton"; import { lightOrange } from "./Constants"; const temporaryDeviceIdMode = () => { - Countly.deviceId.changeDeviceId(Countly.TemporaryDeviceIDString, true); + Countly.changeDeviceId(Countly.TemporaryDeviceIDString, true); }; const changeDeviceId = () => { - Countly.deviceId.changeDeviceId("02d56d66-6a39-482d-aff0-d14e4d5e5fda", true); + Countly.changeDeviceId("02d56d66-6a39-482d-aff0-d14e4d5e5fda", true); }; function DeviceIDScreen({ navigation }) { From 1a29b973adf1ee2f348ce685521b561db61902b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20R=C4=B1za=20Kat?= Date: Fri, 1 Nov 2024 17:34:23 +0300 Subject: [PATCH 17/34] setID Updates --- CHANGELOG.md | 11 ++++++----- Countly.d.ts | 4 ++-- DeviceId.js | 14 +++++--------- .../android/sdk/react/CountlyReactNative.java | 2 +- example/CountlyRNExample/DeviceID.tsx | 2 +- ios/src/CountlyReactNative.h | 2 +- ios/src/CountlyReactNative.m | 2 +- 7 files changed, 17 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1eb1d19d..82d8f7be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,14 @@ * During an internal timer tick * Upon flushing the event queue * Added Content feature methods: - * enterContentZone, to start Content checks (Experimental!) - * exitContentZone, to stop Content checks (Experimental!) + * `enterContentZone`, to start Content checks (Experimental!) + * `exitContentZone`, to stop Content checks (Experimental!) +* Added `Countly.deviceId.setID` method for changing device ID based on the device ID type * Mitigated an issue where a session could have started while the app was in the background when the device ID was changed (non-merge). * Deprecated following SDK calls: - * Countly.getCurrentDeviceId (replaced with: Countly.deviceId.getCurrentDeviceId) - * Countly.getDeviceIDType (replaced with: Countly.deviceId.getDeviceIDType) - * Countly.changeDeviceId + * `Countly.getCurrentDeviceId` (replaced with: `Countly.deviceId.getID`) + * `Countly.getDeviceIDType` (replaced with: `Countly.deviceId.getType`) + * `Countly.changeDeviceId` (replaced with: `Countly.deviceId.setID`) * Android Specific Changes: * ! Minor breaking change ! Unsupported types for user properties will now be omitted, they won't be converted to strings. diff --git a/Countly.d.ts b/Countly.d.ts index d282877e..62e50dac 100644 --- a/Countly.d.ts +++ b/Countly.d.ts @@ -422,7 +422,7 @@ declare module "countly-sdk-react-native-bridge" { export function getDeviceIDType(): Promise | null; /** - * @deprecated + * @deprecated use 'Countly.deviceId.setID' instead of 'Countly.changeDeviceId' * * Change the current device id * @@ -457,7 +457,7 @@ declare module "countly-sdk-react-native-bridge" { * * @param {string} newDeviceID device id to set */ - export function setId(newDeviceID: string): void; + export function setID(newDeviceID: string): void; } /** diff --git a/DeviceId.js b/DeviceId.js index 8f267816..baf1c8a8 100644 --- a/DeviceId.js +++ b/DeviceId.js @@ -1,5 +1,4 @@ import * as L from "./Logger.js"; -import * as Validate from "./Validators.js"; import * as Utils from "./Utils.js"; class DeviceId { @@ -48,17 +47,14 @@ class DeviceId { * * @param {string} newDeviceID device id to set */ - setId = function(newDeviceID) { + setID = function(newDeviceID) { if (!this.#state.isInitialized) { - const msg = "'init' must be called before 'setId'"; - L.e(`setId, ${msg}`); - return msg; + L.e("setID, 'init' must be called before 'setID'"); + return; } - - L.d(`setId, Setting device id as: [${newDeviceID}]`); - + L.d(`setID, Setting device id as: [${newDeviceID}]`); newDeviceID = newDeviceID.toString(); - this.#state.CountlyReactNative.setId([newDeviceID]); + this.#state.CountlyReactNative.setID(newDeviceID); }; } diff --git a/android/src/main/java/ly/count/android/sdk/react/CountlyReactNative.java b/android/src/main/java/ly/count/android/sdk/react/CountlyReactNative.java index 353652cc..d3207f0d 100644 --- a/android/src/main/java/ly/count/android/sdk/react/CountlyReactNative.java +++ b/android/src/main/java/ly/count/android/sdk/react/CountlyReactNative.java @@ -1611,7 +1611,7 @@ public void exitContentZone() { } @ReactMethod - public void setId(String newDeviceID) { + public void setID(String newDeviceID) { Countly.sharedInstance().deviceId().setID(newDeviceID); } diff --git a/example/CountlyRNExample/DeviceID.tsx b/example/CountlyRNExample/DeviceID.tsx index 53c7418e..5fded2ec 100644 --- a/example/CountlyRNExample/DeviceID.tsx +++ b/example/CountlyRNExample/DeviceID.tsx @@ -14,7 +14,7 @@ const changeDeviceId = () => { }; const setDeviceId = () => { - Countly.deviceId.setId("TestingDeviceIDValue"); + Countly.deviceId.setID("TestingDeviceIDValue"); }; function DeviceIDScreen({ navigation }) { diff --git a/ios/src/CountlyReactNative.h b/ios/src/CountlyReactNative.h index 92545f35..560a00c7 100644 --- a/ios/src/CountlyReactNative.h +++ b/ios/src/CountlyReactNative.h @@ -56,7 +56,7 @@ typedef void (^Result)(id _Nullable result); - (void)enterContentZone; - (void)exitContentZone; -- (void)setId; +- (void)setID; #ifndef COUNTLY_EXCLUDE_PUSHNOTIFICATIONS - (void)notificationCallback:(NSString *_Nullable)notificationJson; diff --git a/ios/src/CountlyReactNative.m b/ios/src/CountlyReactNative.m index 1aa2b03f..923dd17d 100644 --- a/ios/src/CountlyReactNative.m +++ b/ios/src/CountlyReactNative.m @@ -257,7 +257,7 @@ - (void) populateConfig:(id) json { } } -RCT_EXPORT_METHOD(setId : (NSString *)newDeviceID) { +RCT_EXPORT_METHOD(setID : (NSString *)newDeviceID) { dispatch_async(dispatch_get_main_queue(), ^{ [Countly.sharedInstance setID:newDeviceID]; }); From 44523b45334093f39b1391e029bb5d15eac4209b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20R=C4=B1za=20Kat?= Date: Fri, 1 Nov 2024 17:35:34 +0300 Subject: [PATCH 18/34] Update CHANGELOG.md --- CHANGELOG.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1eb1d19d..6fabfe9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,14 +4,13 @@ * During an internal timer tick * Upon flushing the event queue * Added Content feature methods: - * enterContentZone, to start Content checks (Experimental!) - * exitContentZone, to stop Content checks (Experimental!) + * `enterContentZone`, to start Content checks (Experimental!) + * `exitContentZone`, to stop Content checks (Experimental!) * Mitigated an issue where a session could have started while the app was in the background when the device ID was changed (non-merge). * Deprecated following SDK calls: - * Countly.getCurrentDeviceId (replaced with: Countly.deviceId.getCurrentDeviceId) - * Countly.getDeviceIDType (replaced with: Countly.deviceId.getDeviceIDType) - * Countly.changeDeviceId - + * `Countly.getCurrentDeviceId` (replaced with: `Countly.deviceId.getID`) + * `Countly.getDeviceIDType` (replaced with: `Countly.deviceId.getType`) + * `Countly.changeDeviceId` * Android Specific Changes: * ! Minor breaking change ! Unsupported types for user properties will now be omitted, they won't be converted to strings. * Disabled caching for webviews. From 36129a12ef96263c2e401d13d8a77daaa05285d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20R=C4=B1za=20Kat?= Date: Mon, 4 Nov 2024 13:14:00 +0300 Subject: [PATCH 19/34] Updated Comments --- Countly.d.ts | 2 +- DeviceId.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Countly.d.ts b/Countly.d.ts index 62e50dac..59bcb46a 100644 --- a/Countly.d.ts +++ b/Countly.d.ts @@ -438,7 +438,7 @@ declare module "countly-sdk-react-native-bridge" { * Get currently used device Id. * Should be called after Countly init * - * @return {string} device id or error message + * @return {string} device id or null */ export function getID(): Promise | string; diff --git a/DeviceId.js b/DeviceId.js index baf1c8a8..bf472e87 100644 --- a/DeviceId.js +++ b/DeviceId.js @@ -13,7 +13,7 @@ class DeviceId { * Get currently used device Id. * Should be called after Countly init * - * @return {string} device id or error message + * @return {string} device id or null */ getID = async function () { if (!this.#state.isInitialized) { From b7fc14d9c03a416d01bd7938343c5d7c7dcfadd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20R=C4=B1za=20Kat?= Date: Mon, 4 Nov 2024 13:14:59 +0300 Subject: [PATCH 20/34] Updated Comments --- Countly.d.ts | 2 +- DeviceId.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Countly.d.ts b/Countly.d.ts index ad885fc0..66981b14 100644 --- a/Countly.d.ts +++ b/Countly.d.ts @@ -438,7 +438,7 @@ declare module "countly-sdk-react-native-bridge" { * Get currently used device Id. * Should be called after Countly init * - * @return {string} device id or error message + * @return {string} device id or null */ export function getID(): Promise | string; diff --git a/DeviceId.js b/DeviceId.js index e3913298..1e35d7eb 100644 --- a/DeviceId.js +++ b/DeviceId.js @@ -14,7 +14,7 @@ class DeviceId { * Get currently used device Id. * Should be called after Countly init * - * @return {string} device id or error message + * @return {string} device id or null */ getID = async function () { if (!this.#state.isInitialized) { From 97d38264915a724e3bd84db4fba5291f57ba76a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20R=C4=B1za=20Kat?= Date: Mon, 4 Nov 2024 15:32:59 +0300 Subject: [PATCH 21/34] return type updated --- Countly.d.ts | 2 +- DeviceId.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Countly.d.ts b/Countly.d.ts index 66981b14..f1504089 100644 --- a/Countly.d.ts +++ b/Countly.d.ts @@ -438,7 +438,7 @@ declare module "countly-sdk-react-native-bridge" { * Get currently used device Id. * Should be called after Countly init * - * @return {string} device id or null + * @returns {string | null} device id or null */ export function getID(): Promise | string; diff --git a/DeviceId.js b/DeviceId.js index 1e35d7eb..2ebbfb58 100644 --- a/DeviceId.js +++ b/DeviceId.js @@ -14,7 +14,7 @@ class DeviceId { * Get currently used device Id. * Should be called after Countly init * - * @return {string} device id or null + * @returns {string | null} device id or null */ getID = async function () { if (!this.#state.isInitialized) { From 5e44d38d8661aa2cf9a75028c26d5aa62250cc73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20R=C4=B1za=20Kat?= Date: Mon, 4 Nov 2024 16:02:01 +0300 Subject: [PATCH 22/34] setID Early Return Added --- DeviceId.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/DeviceId.js b/DeviceId.js index dce6a9c4..b81d1cfc 100644 --- a/DeviceId.js +++ b/DeviceId.js @@ -52,8 +52,12 @@ class DeviceId { L.e("setID, 'init' must be called before 'setID'"); return; } + // Check if newDeviceID is not a string + if (typeof newDeviceID !== 'string') { + L.w("setID, provided device ID is not a string."); + return; + } L.d(`setID, Setting device id as: [${newDeviceID}]`); - newDeviceID = newDeviceID.toString(); this.#state.CountlyReactNative.setID(newDeviceID); }; } From f85f2240969e8172c373a34a14ecd1afe62d091c Mon Sep 17 00:00:00 2001 From: turtledreams <62231246+turtledreams@users.noreply.github.com> Date: Mon, 4 Nov 2024 22:40:43 +0900 Subject: [PATCH 23/34] update --- DeviceId.js | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/DeviceId.js b/DeviceId.js index b81d1cfc..c0731857 100644 --- a/DeviceId.js +++ b/DeviceId.js @@ -10,33 +10,33 @@ class DeviceId { /** * - * Get currently used device Id. + * Get currently used device ID. * Should be called after Countly init * - * @returns {string | null} device id or null + * @returns {string | null} device ID or null */ getID = async function () { if (!this.#state.isInitialized) { - L.e("getID, 'init' must be called before 'getID'"); + L.w("getID, 'init' must be called before 'getID'"); return null; } - L.d("getID, Getting current device id"); + L.d("getID, Getting current device ID"); const result = await this.#state.CountlyReactNative.getCurrentDeviceId(); return result; }; /** - * Get currently used device Id type. + * Get currently used device ID type. * Should be called after Countly init * * @return {DeviceIdType | null} deviceIdType or null */ getType = async function () { if (!this.#state.isInitialized) { - L.e("getType, 'init' must be called before 'getType'"); + L.w("getType, 'init' must be called before 'getType'"); return null; } - L.d("getType, Getting device id type"); + L.d("getType, Getting device ID type"); const result = await this.#state.CountlyReactNative.getDeviceIDType(); return Utils.intToDeviceIDType(result); }; @@ -45,16 +45,16 @@ class DeviceId { * Sets device ID according to the device ID Type. * If previous ID was Developer Supplied sets it without merge, otherwise with merge. * - * @param {string} newDeviceID device id to set + * @param {string} newDeviceID - device ID to set */ - setID = function(newDeviceID) { + setID = function (newDeviceID) { if (!this.#state.isInitialized) { - L.e("setID, 'init' must be called before 'setID'"); + L.w("setID, 'init' must be called before 'setID'"); return; } // Check if newDeviceID is not a string - if (typeof newDeviceID !== 'string') { - L.w("setID, provided device ID is not a string."); + if (!newDeviceID || typeof newDeviceID !== "string" || newDeviceID.length === 0) { + L.w("setID, provided device ID is not a valid string:[" + newDeviceID + "]"); return; } L.d(`setID, Setting device id as: [${newDeviceID}]`); From a76478b254529bdb2be4b86ffaf126d7872a8f7f Mon Sep 17 00:00:00 2001 From: turtledreams <62231246+turtledreams@users.noreply.github.com> Date: Mon, 4 Nov 2024 22:49:08 +0900 Subject: [PATCH 24/34] update --- Countly.d.ts | 8 ++++---- Countly.js | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Countly.d.ts b/Countly.d.ts index e39480ef..de3ad085 100644 --- a/Countly.d.ts +++ b/Countly.d.ts @@ -435,16 +435,16 @@ declare module "countly-sdk-react-native-bridge" { namespace deviceId { /** * - * Get currently used device Id. + * Get currently used device ID. * Should be called after Countly init * - * @returns {string | null} device id or null + * @returns {string | null} device ID or null */ export function getID(): Promise | string; /** * - * Get currently used device Id type. + * Get currently used device ID type. * Should be called after Countly init * * @return {DeviceIdType | null} deviceIdType or null @@ -455,7 +455,7 @@ declare module "countly-sdk-react-native-bridge" { * Sets device ID according to the device ID Type. * If previous ID was Developer Supplied sets it without merge, otherwise with merge. * - * @param {string} newDeviceID device id to set + * @param {string} newDeviceID device ID to set */ export function setID(newDeviceID: string): void; } diff --git a/Countly.js b/Countly.js index 6817c1f6..a3072885 100644 --- a/Countly.js +++ b/Countly.js @@ -497,9 +497,9 @@ Countly.getDeviceIDType = async function () { }; /** - * @deprecated + * @deprecated use 'Countly.deviceId.setID' instead of 'Countly.changeDeviceId' for setting device ID. * - * Change the current device id + * Change the current device ID * * @param {string} newDeviceID id new device id * @param {boolean} onServer merge device id From c09d22e39d3ffa7dd52f49ec7f5055a6628df9bc Mon Sep 17 00:00:00 2001 From: turtledreams <62231246+turtledreams@users.noreply.github.com> Date: Mon, 20 Jan 2025 15:01:42 +0900 Subject: [PATCH 25/34] 25.0.1 --- CHANGELOG.md | 37 +- Countly.d.ts | 44 +- CountlyConfig.js | 33 +- CountlyReactNative.podspec | 2 +- Feedback.js | 72 +- Utils.js | 52 +- Validators.js | 12 - android/build.gradle | 8 +- .../android/sdk/react/CountlyReactNative.java | 46 +- example/CountlyRNExample/Events.tsx | 22 + example/CountlyRNExample/Feedback.tsx | 47 +- example/create_app.py | 5 +- ios/src/CHANGELOG.md | 23 + ios/src/Countly-PL.podspec | 2 +- ios/src/Countly.m | 32 +- ios/src/Countly.podspec | 2 +- ios/src/Countly.xcodeproj/project.pbxproj | 8 +- ios/src/CountlyCommon.h | 2 + ios/src/CountlyCommon.m | 14 +- ios/src/CountlyConnectionManager.h | 2 + ios/src/CountlyConnectionManager.m | 6 + ios/src/CountlyContentBuilderInternal.m | 5 + ios/src/CountlyFeedbackWidget.m | 2 +- ios/src/CountlyFeedbacksInternal.m | 4 +- ios/src/CountlyReactNative.m | 19 +- ios/src/CountlyRemoteConfigInternal.m | 8 +- ios/src/CountlyServerConfig.m | 2 +- .../CountlyTests/CountlyBaseTestCase.swift | 2 +- ios/src/CountlyTests/CountlyEventStruct.swift | 8 +- ios/src/CountlyTests/CountlyViewTests.swift | 993 ++++++++++++++++++ ios/src/CountlyViewData.h | 2 +- ios/src/CountlyViewData.m | 7 +- ios/src/CountlyViewTrackingInternal.h | 1 + ios/src/CountlyViewTrackingInternal.m | 40 +- ios/src/CountlyWebViewManager.m | 93 +- .../countly_config_experimental.js | 39 + package.json | 2 +- 37 files changed, 1525 insertions(+), 173 deletions(-) create mode 100644 ios/src/CountlyTests/CountlyViewTests.swift create mode 100644 lib/configuration_interfaces/countly_config_experimental.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 549677d2..83c7334e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,37 +1,52 @@ -## XX.X.X -* ! Minor breaking change ! User properties will now be automatically saved under the following conditions: - * When an event is recorded - * During an internal timer tick - * Upon flushing the event queue +## 25.1.0 +* ! Minor breaking change ! `Countly.userDataBulk.save()` method is now optional. SDK will save the cached data with internal triggers regularly. + * Added Content feature methods: * `enterContentZone`, to start Content checks (Experimental!) * `exitContentZone`, to stop Content checks (Experimental!) +* Added feedback widget convenience methods to display the first available widget or the one meets the criteria: + * `Countly.feedback.showNPS(nameIDorTag?: string, widgetClosedCallback?: WidgetCallback): void` + * `Countly.feedback.showSurvey(nameIDorTag?: string, widgetClosedCallback?: WidgetCallback): void` + * `Countly.feedback.showRating(nameIDorTag?: string, widgetClosedCallback?: WidgetCallback): void` +* Added config interface `experimental` that provides experimental config options: + * `.experimental.enablePreviousNameRecording()` for reporting previous event/view names + * `.experimental.enableVisibilityTracking()` for reporting app visibility with events * Added `Countly.deviceId.setID` method for changing device ID based on the device ID type -* Mitigated an issue where a session could have started while the app was in the background when the device ID was changed (non-merge). +* Added support for Arrays of primitive types in event segmentation + * Deprecated following SDK calls: * `Countly.getCurrentDeviceId` (replaced with: `Countly.deviceId.getID`) * `Countly.getDeviceIDType` (replaced with: `Countly.deviceId.getType`) * `Countly.changeDeviceId` (replaced with: `Countly.deviceId.setID`) + +* Mitigated an issue where a session could have started while the app was in the background when the device ID was changed (non-merge). +* Mitigated an issue where intent redirection checks were disabled by default +* Mitigated an issue crash tracking was not enabled with init config +* Mitigated an issue where app start time tracking was on by default + * Android Specific Changes: * ! Minor breaking change ! Unsupported types for user properties will now be omitted, they won't be converted to strings. * Disabled caching for webviews. * Mitigated an issue in the upload plugin that prevented the upload of a symbol file * Resolved a problem where revoked consents were sent after changes without merging. - * Fixed a bug that caused the device ID to be incorrectly set after changes with merging. + * Mitigated an issue that caused the device ID to be incorrectly set after changes with merging. * Mitigated an issue where on consent revoke, remote config values were cleared, not anymore. * iOS Specific Changes: + * Orientation info is now also sent during initialization * Added visionOS build support + * Updated the SDK to ensure compatibility with the latest server response models + * Improved view tracking capabilities * Mitigated an issue with the feedback widget URL encoding on iOS 16 and earlier, which prevented the widget from displaying * Mitigated an issue with content fetch URL encoding on iOS 16 and earlier, which caused the request to fail * Mitigated an issue where the terms and conditions URL (`tc` key) was sent without double quotes - * Orientation info is now also sent during initialization * Mitigated an issue where consent information was not sent when no consent was given during initialization * Mitigated an issue where a session did not end when session consent was removed - * Updated the SDK to ensure compatibility with the latest server response models + * Mitigated an issue where pausing a view resulted in a '0' view duration. + * Mitigated an issue where the user provided URLSessionConfiguration was not applied to direct requests -* Updated the underlying Android SDK version to 24.7.4 -* Updated the underlying iOS SDK version to 24.7.4 +* Updated the underlying Android SDK version to 24.7.8 +* Updated the underlying iOS SDK version to 24.7.9 ## 24.4.1 * Added support for Feedback Widget terms and conditions diff --git a/Countly.d.ts b/Countly.d.ts index de3ad085..e6bd1677 100644 --- a/Countly.d.ts +++ b/Countly.d.ts @@ -1,5 +1,5 @@ interface Segmentation { - [key: string]: number | string | boolean; + [key: string]: number | string | boolean | (number | string | boolean)[]; } interface CountlyEventOptions { @@ -89,6 +89,29 @@ declare module "countly-sdk-react-native-bridge" { * Countly Feedback Module */ namespace feedback { + + /** + * Shows the first available NPS widget that meets the criteria. + * @param {String} [nameIDorTag] - name, id, or tag of the widget to show (optional) + * @param {callback} [widgetClosedCallback] - called when the widget is closed (optional) + */ + export function showNPS(nameIDorTag?: string, widgetClosedCallback?: WidgetCallback): void; + + /** + * Shows the first available survey widget that meets the criteria. + * @param {String} [nameIDorTag] - name, id, or tag of the widget to show (optional) + * @param {callback} [widgetClosedCallback] - called when the widget is closed (optional) + */ + export function showSurvey(nameIDorTag?: string, widgetClosedCallback?: WidgetCallback): void; + + /** + * Shows the first available rating widget that meets the criteria. + * @param {String} [nameIDorTag] - name, id, or tag of the widget to show (optional) + * @param {callback} [widgetClosedCallback] - called when the widget is closed (optional) + */ + export function showRating(nameIDorTag?: string, widgetClosedCallback?: WidgetCallback): void; + + /** * Get a list of available feedback widgets as an array of objects. * @param {FeedbackWidgetCallback} [onFinished] - returns (retrievedWidgets, error). This parameter is optional. @@ -106,7 +129,7 @@ declare module "countly-sdk-react-native-bridge" { * * @return {ErrorObject} object {error: string or null} */ - export function presentFeedbackWidget(feedbackWidget: FeedbackWidget, closeButtonText: string, widgetShownCallback: callback, widgetClosedCallback: callback): ErrorObject; + export function presentFeedbackWidget(feedbackWidget: FeedbackWidget, closeButtonText: string, widgetShownCallback: WidgetCallback, widgetClosedCallback: WidgetCallback): ErrorObject; /** * Get a feedback widget's data as an object. @@ -1145,6 +1168,18 @@ declare module "countly-sdk-react-native-bridge" { } declare module "countly-sdk-react-native-bridge/CountlyConfig" { + interface experimental { + /** + * Enables previous name recording for views and events + */ + enablePreviousNameRecording(): this; + + /** + * Enables app visibility tracking with events. + */ + enableVisibilityTracking(): this; + } + /** * * This class holds APM specific configurations to be used with @@ -1237,6 +1272,11 @@ declare module "countly-sdk-react-native-bridge/CountlyConfig" { */ sdkInternalLimits: CountlyConfigSDKInternalLimits; + /** + * getter for experimental features + */ + experimental: experimental; + /** * Method to set the server url * diff --git a/CountlyConfig.js b/CountlyConfig.js index 5ef6c516..4f247b36 100644 --- a/CountlyConfig.js +++ b/CountlyConfig.js @@ -1,6 +1,7 @@ import { initialize } from "./Logger.js"; import CountlyConfigApm from "./lib/configuration_interfaces/countly_config_apm.js"; import CountlyConfigSDKInternalLimits from "./lib/configuration_interfaces/countly_config_limits.js"; +import CountlyConfigExp from "./lib/configuration_interfaces/countly_config_experimental.js"; /** * Countly SDK React Native Bridge * https://github.com/Countly/countly-sdk-react-native-bridge @@ -15,11 +16,15 @@ import CountlyConfigSDKInternalLimits from "./lib/configuration_interfaces/count * @param {String} appKey application key */ class CountlyConfig { + #crashReporting = false; + #apmLegacy = false; + #disableIntentRedirectionCheck = false; constructor(serverURL, appKey) { this.serverURL = serverURL; this.appKey = appKey; this._countlyConfigApmInstance = new CountlyConfigApm(); this._countlyConfigSDKLimitsInstance = new CountlyConfigSDKInternalLimits(); + this._countlyConfigExpInstance = new CountlyConfigExp(); } /** @@ -29,10 +34,32 @@ class CountlyConfig { return this._countlyConfigApmInstance; } + /** + * Getter to get the SDK internal limits + */ get sdkInternalLimits() { return this._countlyConfigSDKLimitsInstance; } + /** + * Getter to get the experimental configurations + */ + get experimental() { + return this._countlyConfigExpInstance; + } + + get _crashReporting() { + return this.#crashReporting; + } + + get _apmLegacy() { + return this.#apmLegacy; + } + + get _disableIntentRedirectionCheck() { + return this.#disableIntentRedirectionCheck; + } + /** * Method to set the server url * @@ -79,7 +106,7 @@ class CountlyConfig { * Method to enable crash reporting to report unhandled crashes to Countly */ enableCrashReporting() { - this.crashReporting = true; + this.#crashReporting = true; return this; } @@ -142,7 +169,7 @@ class CountlyConfig { * Method to enable application performance monitoring which includes the recording of app start time. */ enableApm() { - this.enableApm = true; + this.#apmLegacy = true; return this; } @@ -151,7 +178,7 @@ class CountlyConfig { * This method should be used to disable them. */ disableAdditionalIntentRedirectionChecks() { - this.disableAdditionalIntentRedirectionChecks = true; + this.#disableIntentRedirectionCheck = true; return this; } diff --git a/CountlyReactNative.podspec b/CountlyReactNative.podspec index 1688a4dd..b58808b6 100644 --- a/CountlyReactNative.podspec +++ b/CountlyReactNative.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'CountlyReactNative' - s.version = '24.4.1' + s.version = '25.1.0' s.license = { :type => 'COMMUNITY', :text => <<-LICENSE diff --git a/Feedback.js b/Feedback.js index c2fa20ca..3e2e33e1 100644 --- a/Feedback.js +++ b/Feedback.js @@ -7,6 +7,76 @@ class Feedback { this.#state = state; } + /** + * Shows the first available NPS widget that meets the criteria. + * @param {String} [nameIDorTag] - name, id, or tag of the widget to show (optional) + * @param {callback} [callback] - called when the widget is closed (optional) + */ + showNPS(nameIDorTag, callback) { + L.i(`showNPS, Will show NPS widget with name, id, or tag: [${nameIDorTag}], callback provided: [${typeof callback === "function"}]`); + this.#showInternalFeedback("nps", nameIDorTag, callback); + } + /** + * Shows the first available Survey widget that meets the criteria. + * @param {String} [nameIDorTag] - name, id, or tag of the widget to show (optional) + * @param {callback} [callback] - called when the widget is closed (optional) + */ + showSurvey(nameIDorTag, callback) { + L.i(`showSurvey, Will show Survey widget with name, id, or tag: [${nameIDorTag}], callback provided: [${typeof callback === "function"}]`); + this.#showInternalFeedback("survey", nameIDorTag, callback); + } + + /** + * Shows the first available Rating widget that meets the criteria. + * @param {String} [nameIDorTag] - name, id, or tag of the widget to show (optional) + * @param {callback} [callback] - called when the widget is closed (optional) + */ + showRating(nameIDorTag, callback) { + L.i(`showRating, Will show Rating widget with name, id, or tag: [${nameIDorTag}], callback provided: [${typeof callback === "function"}]`); + this.#showInternalFeedback("rating", nameIDorTag, callback); + } + + #showInternalFeedback(widgetType, nameIDorTag, callback) { + if (!this.#state.isInitialized) { + L.e(`showInternalFeedback, 'init' must be called before 'showInternalFeedback'`); + return; + } + if (typeof nameIDorTag !== "string") { + L.d(`showInternalFeedback, unsupported data type of nameIDorTag or its not given : [${typeof nameIDorTag}]`); + } + this.getAvailableFeedbackWidgets((retrievedWidgets, error) => { + if (error) { + L.e(`showInternalFeedback, ${error}`); + return; + } + if (!retrievedWidgets || retrievedWidgets.length === 0) { + L.d(`showInternalFeedback, no feedback widgets found`); + return; + } + L.d(`showInternalFeedback, Found [${retrievedWidgets.length}] feedback widgets`); + let widget = retrievedWidgets.find(w => w.type === widgetType); + try { + if (nameIDorTag && typeof nameIDorTag === 'string') { + const matchedWidget = retrievedWidgets.find(w => + w.type === widgetType && (w.name === nameIDorTag || w.id === nameIDorTag || w.tags.includes(nameIDorTag)) + ); + if (matchedWidget) { + widget = matchedWidget; + L.v(`showInternalFeedback, Found ${widgetType} widget by name, id, or tag: [${JSON.stringify(matchedWidget)}]`); + } + } + } catch (error) { + L.e(`showInternalFeedback, Error while finding widget: ${error}`); + } + + if (!widget) { + L.d(`showInternalFeedback, No ${widgetType} widget found.`); + return; + } + this.presentFeedbackWidget(widget, null, null, callback); + }); + } + /** * Get a list of available feedback widgets as an array of objects. * @param {callback} [onFinished] - returns (retrievedWidgets, error). This parameter is optional. @@ -19,7 +89,7 @@ class Feedback { return { error: message, data: null }; } - L.d("getAvailableFeedbackWidgets, getAvailableFeedbackWidgets"); + L.d("getAvailableFeedbackWidgets, fetching available feedback widgets"); let result = null; let error = null; try { diff --git a/Utils.js b/Utils.js index 37c7592a..fffdfb97 100644 --- a/Utils.js +++ b/Utils.js @@ -52,87 +52,121 @@ function configToJson(config) { if (config.loggingEnabled) { json.loggingEnabled = config.loggingEnabled; + L.i(`init configuration, Enabled logging in ${__DEV__ ? "development" : "production"} mode`) } - if (config.crashReporting) { - json.crashReporting = config.crashReporting; + if (config._crashReporting) { + json.crashReporting = true; + L.i(`init configuration, Enabled crash reporting`) } if (config.shouldRequireConsent) { json.shouldRequireConsent = config.shouldRequireConsent; + L.i(`init configuration, Require consent`) } if (config.consents) { json.consents = config.consents; + L.i(`init configuration, Consents: ${JSON.stringify(config.consents)}`) } if (config.locationCountryCode) { json.locationCountryCode = config.locationCountryCode; + L.i(`init configuration, Location country code: ${config.locationCountryCode}`) } if (config.locationCity) { json.locationCity = config.locationCity; + L.i(`init configuration, Location city: ${config.locationCity}`) } if (config.locationGpsCoordinates) { json.locationGpsCoordinates = config.locationGpsCoordinates; + L.i(`init configuration, Location gps coordinates: ${config.locationGpsCoordinates}`) } if (config.locationIpAddress) { json.locationIpAddress = config.locationIpAddress; + L.i(`init configuration, Location ip address: ${config.locationIpAddress}`) } if (config.tamperingProtectionSalt) { json.tamperingProtectionSalt = config.tamperingProtectionSalt; + L.i(`init configuration, Tampering protection salt: ${config.tamperingProtectionSalt}`) } // APM ------------------------------------------------ if (config.apm.enableForegroundBackground) { json.enableForegroundBackground = config.apm.enableForegroundBackground; + L.i(`init configuration, APM enabled foreground background`) } if (config.apm.enableManualAppLoaded) { json.enableManualAppLoaded = config.apm.enableManualAppLoaded; + L.i(`init configuration, APM enabled manual app loaded`) } if (config.apm.startTSOverride) { json.startTSOverride = config.apm.startTSOverride; + L.i(`init configuration, APM start timestamp override: ${config.apm.startTSOverride}`) } if (config.apm.trackAppStartTime) { json.trackAppStartTime = config.apm.trackAppStartTime; + L.i(`init configuration, APM track app start time`) } // Legacy APM - if (config.enableApm) { - json.enableApm = config.enableApm; + if (config._apmLegacy) { + json.enableApm = true; + L.i(`init configuration, APM start time recording enabled`) } // APM END -------------------------------------------- - if (config.disableAdditionalIntentRedirectionChecks) { - json["disableAdditionalIntentRedirectionChecks"] = config.disableAdditionalIntentRedirectionChecks; + if (config.experimental.enablePreviousNameRecording) { + json.enablePreviousNameRecording = true; + L.i(`init configuration, Enabled previous name recording`) + } + if (config.experimental.enableVisibilityTracking) { + json.enableVisibilityTracking = true; + L.i(`init configuration, Enabled visibility tracking`) + } + if (config._disableIntentRedirectionCheck) { + json.disableAdditionalIntentRedirectionChecks = true; + L.i(`init configuration, Disabled additional intent redirection checks`) } const pushNotification = {}; if (config.tokenType) { pushNotification.tokenType = config.tokenType; + L.i(`init configuration, Token type: ${config.tokenType}`) } if (config.channelName) { pushNotification.channelName = config.channelName; + L.i(`init configuration, Channel name: ${config.channelName}`) } if (config.channelDescription) { pushNotification.channelDescription = config.channelDescription; + L.i(`init configuration, Channel description: ${config.channelDescription}`) } if (config.accentColor) { pushNotification.accentColor = config.accentColor; + L.i(`init configuration, Accent color: ${config.accentColor}`) } json.pushNotification = pushNotification; if (config.allowedIntentClassNames) { json.allowedIntentClassNames = config.allowedIntentClassNames; + L.i(`init configuration, Allowed intent class names: ${config.allowedIntentClassNames}`) } if (config.allowedIntentClassNames) { json.allowedIntentPackageNames = config.allowedIntentPackageNames; + L.i(`init configuration, Allowed intent package names: ${config.allowedIntentPackageNames}`) } if (config.starRatingTextTitle) { json.starRatingTextTitle = config.starRatingTextTitle; + L.i(`init configuration, Star rating text title: ${config.starRatingTextTitle}`) } if (config.starRatingTextMessage) { json.starRatingTextMessage = config.starRatingTextMessage; + L.i(`init configuration, Star rating text message: ${config.starRatingTextMessage}`) } if (config.starRatingTextDismiss) { json.starRatingTextDismiss = config.starRatingTextDismiss; + L.i(`init configuration, Star rating text dismiss: ${config.starRatingTextDismiss}`) } if (config.campaignType) { json.campaignType = config.campaignType; json.campaignData = config.campaignData; + L.i(`init configuration, Campaign type: ${config.campaignType}, Campaign data: ${config.campaignData}`) } if (config.attributionValues) { json.attributionValues = config.attributionValues; + L.i(`init configuration, Attribution values: ${config.attributionValues}`) } // Limits ----------------------------------------------- if (config.sdkInternalLimits.maxKeyLength) { @@ -140,6 +174,7 @@ function configToJson(config) { L.w(`configToJson, Provided value for maxKeyLength is invalid!`) } else { json.maxKeyLength = config.sdkInternalLimits.maxKeyLength; + L.i(`init configuration, Max key length: ${config.sdkInternalLimits.maxKeyLength}`) } } if (config.sdkInternalLimits.maxValueSize) { @@ -147,6 +182,7 @@ function configToJson(config) { L.w(`configToJson, Provided value for maxValueSize is invalid!`) } else { json.maxValueSize = config.sdkInternalLimits.maxValueSize; + L.i(`init configuration, Max value size: ${config.sdkInternalLimits.maxValueSize}`) } } if (config.sdkInternalLimits.maxSegmentationValues) { @@ -154,6 +190,7 @@ function configToJson(config) { L.w(`configToJson, Provided value for maxSegmentationValues is invalid!`) } else { json.maxSegmentationValues = config.sdkInternalLimits.maxSegmentationValues; + L.i(`init configuration, Max segmentation values: ${config.sdkInternalLimits.maxSegmentationValues}`) } } if (config.sdkInternalLimits.maxBreadcrumbCount) { @@ -161,6 +198,7 @@ function configToJson(config) { L.w(`configToJson, Provided value for maxBreadcrumbCount is invalid!`) } else { json.maxBreadcrumbCount = config.sdkInternalLimits.maxBreadcrumbCount; + L.i(`init configuration, Max breadcrumb count: ${config.sdkInternalLimits.maxBreadcrumbCount}`) } } if (config.sdkInternalLimits.maxStackTraceLinesPerThread) { @@ -168,6 +206,7 @@ function configToJson(config) { L.w(`configToJson, Provided value for maxStackTraceLinesPerThread is invalid!`) } else { json.maxStackTraceLinesPerThread = config.sdkInternalLimits.maxStackTraceLinesPerThread; + L.i(`init configuration, Max stack trace lines per thread: ${config.sdkInternalLimits.maxStackTraceLinesPerThread}`) } } if (config.sdkInternalLimits.maxStackTraceLineLength) { @@ -175,6 +214,7 @@ function configToJson(config) { L.w(`configToJson, Provided value for maxStackTraceLineLength is invalid!`) } else { json.maxStackTraceLineLength = config.sdkInternalLimits.maxStackTraceLineLength; + L.i(`init configuration, Max stack trace line length: ${config.sdkInternalLimits.maxStackTraceLineLength}`) } } // Limits End -------------------------------------------- diff --git a/Validators.js b/Validators.js index 9f09e424..867bc11e 100644 --- a/Validators.js +++ b/Validators.js @@ -134,18 +134,6 @@ function areEventParametersValid(functionName, eventName, segmentation, eventCou return false; } - // validate segmentation values - if (segmentation) { - for (const key in segmentation) { - const value = segmentation[key]; - const valueType = typeof value; - if (value && valueType !== "string" && valueType !== "number" && valueType !== "boolean") { - L.w(`${functionName}, segmentation value: [${value}] for the key: [${key}] must be a number, string or boolean!`); - return false; - } - } - } - if (eventCount && (typeof eventCount !== "number" || eventCount < 0)) { L.w(`${functionName}, provided eventCount: [${eventCount}]. It must be a positive number!`); return false; diff --git a/android/build.gradle b/android/build.gradle index 735895fe..22eeb26c 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -16,11 +16,11 @@ def safeExtGet(prop, fallback) { } android { - compileSdkVersion safeExtGet('compileSdkVersion', 31) + compileSdkVersion safeExtGet('compileSdkVersion', 35) defaultConfig { - minSdkVersion safeExtGet('minSdkVersion', 17) - targetSdkVersion safeExtGet('targetSdkVersion', 31) + minSdkVersion safeExtGet('minSdkVersion', 21) + targetSdkVersion safeExtGet('targetSdkVersion', 35) versionCode 2 versionName "1.1" @@ -41,7 +41,7 @@ repositories { dependencies { implementation "com.facebook.react:react-native:${safeExtGet('reactNativeVersion', '+')}" - implementation 'ly.count.android:sdk:24.7.4' + implementation 'ly.count.android:sdk:24.7.8' // Import the BoM for the Firebase platform // The BoM version of 28.4.2 is the newest release that will target firebase-messaging version 22 diff --git a/android/src/main/java/ly/count/android/sdk/react/CountlyReactNative.java b/android/src/main/java/ly/count/android/sdk/react/CountlyReactNative.java index d3207f0d..b31e56a6 100644 --- a/android/src/main/java/ly/count/android/sdk/react/CountlyReactNative.java +++ b/android/src/main/java/ly/count/android/sdk/react/CountlyReactNative.java @@ -89,7 +89,7 @@ public String toString() { public class CountlyReactNative extends ReactContextBaseJavaModule implements LifecycleEventListener { public static final String TAG = "CountlyRNPlugin"; - private String COUNTLY_RN_SDK_VERSION_STRING = "24.4.1"; + private String COUNTLY_RN_SDK_VERSION_STRING = "25.1.0"; private String COUNTLY_RN_SDK_NAME = "js-rnb-android"; private static final CountlyConfig config = new CountlyConfig(); @@ -236,6 +236,12 @@ private void populateConfig(JSONObject _config) { config.setRecordAppStartTime(_config.getBoolean("enableApm")); } // APM END -------------------------------------------- + if (_config.has("enablePreviousNameRecording")) { + config.experimental.enablePreviousNameRecording(); + } + if (_config.has("enableVisibilityTracking")) { + config.experimental.enableVisibilityTracking(); + } // Limits ----------------------------------------------- if(_config.has("maxKeyLength")) { config.sdkInternalLimits.setMaxKeyLength(_config.getInt("maxKeyLength")); @@ -789,7 +795,6 @@ public void setUserData(ReadableArray args, Promise promise) { } Countly.sharedInstance().userProfile().setProperties(userDataMap); - Countly.sharedInstance().userProfile().save(); promise.resolve("Success"); } @@ -952,7 +957,6 @@ public void userData_setProperty(ReadableArray args, Promise promise) { String keyName = args.getString(0); String keyValue = args.getString(1); Countly.sharedInstance().userProfile().setProperty(keyName, keyValue); - Countly.sharedInstance().userProfile().save(); promise.resolve("Success"); } @@ -961,7 +965,6 @@ public void userData_increment(ReadableArray args, Promise promise) { Countly.sharedInstance(); String keyName = args.getString(0); Countly.sharedInstance().userProfile().increment(keyName); - Countly.sharedInstance().userProfile().save(); promise.resolve("Success"); } @@ -971,7 +974,6 @@ public void userData_incrementBy(ReadableArray args, Promise promise) { String keyName = args.getString(0); int keyIncrement = Integer.parseInt(args.getString(1)); Countly.sharedInstance().userProfile().incrementBy(keyName, keyIncrement); - Countly.sharedInstance().userProfile().save(); promise.resolve("Success"); } @@ -981,7 +983,6 @@ public void userData_multiply(ReadableArray args, Promise promise) { String keyName = args.getString(0); int multiplyValue = Integer.parseInt(args.getString(1)); Countly.sharedInstance().userProfile().multiply(keyName, multiplyValue); - Countly.sharedInstance().userProfile().save(); promise.resolve("Success"); } @@ -991,7 +992,6 @@ public void userData_saveMax(ReadableArray args, Promise promise) { String keyName = args.getString(0); int maxScore = Integer.parseInt(args.getString(1)); Countly.sharedInstance().userProfile().saveMax(keyName, maxScore); - Countly.sharedInstance().userProfile().save(); promise.resolve("Success"); } @@ -1001,7 +1001,6 @@ public void userData_saveMin(ReadableArray args, Promise promise) { String keyName = args.getString(0); int minScore = Integer.parseInt(args.getString(1)); Countly.sharedInstance().userProfile().saveMin(keyName, minScore); - Countly.sharedInstance().userProfile().save(); promise.resolve("Success"); } @@ -1011,7 +1010,6 @@ public void userData_setOnce(ReadableArray args, Promise promise) { String keyName = args.getString(0); String minScore = args.getString(1); Countly.sharedInstance().userProfile().setOnce(keyName, minScore); - Countly.sharedInstance().userProfile().save(); promise.resolve("Success"); } @@ -1021,7 +1019,6 @@ public void userData_pushUniqueValue(ReadableArray args, Promise promise) { String keyName = args.getString(0); String keyValue = args.getString(1); Countly.sharedInstance().userProfile().pushUnique(keyName, keyValue); - Countly.sharedInstance().userProfile().save(); promise.resolve("Success"); } @@ -1031,7 +1028,6 @@ public void userData_pushValue(ReadableArray args, Promise promise) { String keyName = args.getString(0); String keyValue = args.getString(1); Countly.sharedInstance().userProfile().push(keyName, keyValue); - Countly.sharedInstance().userProfile().save(); promise.resolve("Success"); } @@ -1041,7 +1037,6 @@ public void userData_pullValue(ReadableArray args, Promise promise) { String keyName = args.getString(0); String keyValue = args.getString(1); Countly.sharedInstance().userProfile().pull(keyName, keyValue); - Countly.sharedInstance().userProfile().save(); promise.resolve("Success"); } @@ -1685,6 +1680,33 @@ public Map convertToEventMap(ReadableArray segments) { segmentation.put(key, doubleValue); } break; + case Array: + ReadableArray array = segments.getArray(i + 1); + List list = new ArrayList<>(); + for (int j = 0; j < array.size(); j++) { + ReadableType arrayValueType = array.getType(j); + switch (arrayValueType) { + case String: + list.add(array.getString(j)); + break; + case Number: + double arrayDoubleValue = array.getDouble(j); + int arrayIntValue = (int) arrayDoubleValue; // casting to int will remove the decimal part + if (arrayDoubleValue == arrayIntValue) { + list.add(arrayIntValue); + } else { + list.add(arrayDoubleValue); + } + break; + case Boolean: + list.add(array.getBoolean(j)); + break; + default: + break; + } + } + segmentation.put(key, list); + break; default: // Skip other types break; diff --git a/example/CountlyRNExample/Events.tsx b/example/CountlyRNExample/Events.tsx index e6276732..39e2b3e4 100644 --- a/example/CountlyRNExample/Events.tsx +++ b/example/CountlyRNExample/Events.tsx @@ -16,6 +16,28 @@ const eventWithSegment = () => { // example for event with segment Countly.events.recordEvent("Event With Segment", { Country: "Paris", Age: 28 }, 1, undefined); Countly.events.recordEvent("Event With Segment", { Country: "France", Age: 38 }, 1, undefined); + const segment: Segmentation = { + stringList: ['value1', 'value2', 'value3'], + intList: [1, 2, 3], + doubleList: [1.1, 2.2, 3.3], + boolList: [true, false, true], + mixedList: ['value1', 2, 3.3, true], + mapList: [ // currently this is not supported + { key1: 'value1', key2: 2 }, + { key1: 'value2', key2: 3 }, + { key1: 'value3', key2: 4 } + ], + nestedList: [ // currently this is not supported + ['value1', 'value2'], + ['value3', 'value4'], + ['value5', 'value6'] + ], + normalString: 'normalString', + normalInt: 1, + normalDouble: 1.1, + normalBool: true + }; + Countly.events.recordEvent("Event With Segment With Mixed Types", segment, 1, undefined); }; const eventWithSumAndSegment = () => { // example for event with segment and sum diff --git a/example/CountlyRNExample/Feedback.tsx b/example/CountlyRNExample/Feedback.tsx index 7c91ce73..28a7d174 100644 --- a/example/CountlyRNExample/Feedback.tsx +++ b/example/CountlyRNExample/Feedback.tsx @@ -104,51 +104,17 @@ const presentRatingWidgetUsingEditBox = function () { }; const showSurvey = () => { - Countly.getFeedbackWidgets((retrivedWidgets, error) => { - if (error != null) { - console.log(`showSurvey Error : ${error}`); - } else { - console.log(`showSurvey Success : ${retrivedWidgets.length}`); - const surveyWidget = retrivedWidgets.find((x) => x.type === "survey"); - if (surveyWidget) { - Countly.presentFeedbackWidgetObject( - surveyWidget, - "Close", - function () { - console.log("showSurvey presentFeedbackWidgetObject : " + "Widgetshown"); - }, - function () { - console.log("showSurvey presentFeedbackWidgetObject : " + "Widgetclosed"); - } - ); - } - } - }); + Countly.feedback.showSurvey(undefined, () => {console.log(`Survey shown`);}); }; const showNPS = () => { - Countly.getFeedbackWidgets((retrivedWidgets, error) => { - if (error != null) { - console.log(`showNPS Error : ${error}`); - } else { - console.log(`showNPS Success : ${retrivedWidgets.length}`); - const npsWidget = retrivedWidgets.find((x) => x.type === "nps"); - if (npsWidget) { - Countly.presentFeedbackWidgetObject( - npsWidget, - "Close", - function () { - console.log("showNPS presentFeedbackWidgetObject : " + "Widgetshown"); - }, - function () { - console.log("showNPS presentFeedbackWidgetObject : " + "Widgetclosed"); - } - ); - } - } - }); + Countly.feedback.showNPS(undefined, () => {console.log(`NPS shown`);}); }; +const showRating = () => { + Countly.feedback.showRating(undefined, () => {console.log(`Rating shown`);}); +} + const styles = StyleSheet.create({ inputRoundedBorder: { margin: 5, @@ -267,6 +233,7 @@ function FeedbackScreen({ navigation }) { + diff --git a/example/create_app.py b/example/create_app.py index cc06a5c4..0e3c2197 100644 --- a/example/create_app.py +++ b/example/create_app.py @@ -19,9 +19,8 @@ def setup_react_native_app(): print("Setting up React Native app...") # Set up React Native app - # Latest version of the react-native can experience issues because of unstability - # Because of that it's preferred to use a stabile version of react-native here - os.system("npx @react-native-community/cli@latest init AwesomeProject --version 0.74.0") + # Latest version of the react-native can experience issues because of turbo modules + os.system("npx @react-native-community/cli@latest init AwesomeProject") print("Copying contents of CountlyRNExample to AwesomeProject...") diff --git a/ios/src/CHANGELOG.md b/ios/src/CHANGELOG.md index 88b3b7dc..ec54172d 100644 --- a/ios/src/CHANGELOG.md +++ b/ios/src/CHANGELOG.md @@ -1,3 +1,26 @@ +## 24.7.9 +* Improved view tracking capabilities + +## 24.7.8 +* Added support for localization of content blocks. + +* Mitigated an issue where visibility could have been wrongly assigned if a view was closed while going to background. (Experimental!) +* Mitigated an issue where the user provided URLSessionConfiguration was not applied to direct requests +* Mitigated an issue where a concurrent modification error could have happen when starting multiple stopped views +* Mitigated an issue that parsing internal content event segmentation. + +## 24.7.7 +* Changed the visibility tracking segmentation values to binary + +## 24.7.6 +* Mitigated an issue with experimental visibility tracking and previous name recording, ensuring they’re included even when no segmentation is provided in event or view recording. + +## 24.7.5 +* Mitigated an issue with content action json parsing due to json encoding +* Mitigated an issue where pausing a view resulted in a '0' view duration. +* Mitigated an issue where an internal timer was not reset when going to foreground for `autoStoppedViews` +* Mitigated an issue for `autoStoppedViews` could have not started when multiple views were open at the same time while going to foreground + ## 24.7.4 * Added visionOS build support * Added `CountlyFeedbacks:` interface with new view methods (Access with `Countly.sharedInstance.feedback`): diff --git a/ios/src/Countly-PL.podspec b/ios/src/Countly-PL.podspec index 78897d6b..2d85adbd 100644 --- a/ios/src/Countly-PL.podspec +++ b/ios/src/Countly-PL.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'Countly-PL' - s.version = '24.7.4' + s.version = '24.7.9' s.license = { :type => 'MIT', :file => 'LICENSE' } s.summary = 'Countly is an innovative, real-time, open source mobile analytics platform.' s.homepage = 'https://github.com/Countly/countly-sdk-ios' diff --git a/ios/src/Countly.m b/ios/src/Countly.m index 11e49c80..6c51a817 100644 --- a/ios/src/Countly.m +++ b/ios/src/Countly.m @@ -400,13 +400,13 @@ - (void)suspend isSuspended = YES; + [CountlyViewTrackingInternal.sharedInstance applicationDidEnterBackground]; + [CountlyConnectionManager.sharedInstance sendEventsWithSaveIfNeeded]; if (!CountlyCommon.sharedInstance.manualSessionHandling) [CountlyConnectionManager.sharedInstance endSession]; - [CountlyViewTrackingInternal.sharedInstance applicationDidEnterBackground]; - [CountlyPersistency.sharedInstance saveToFile]; } @@ -905,6 +905,8 @@ - (void)recordEvent:(NSString *)key segmentation:(NSDictionary *)segmentation co BOOL isReservedEvent = [self isReservedEvent:key]; NSMutableDictionary *filteredSegmentations = segmentation.cly_filterSupportedDataTypes; + if(filteredSegmentations == nil) + filteredSegmentations = NSMutableDictionary.new; // If the event is not reserved, assign the previous event ID and Name to the current event's PEID property, or an empty string if previousEventID is nil. Then, update previousEventID to the current event's ID. if (!isReservedEvent) @@ -930,18 +932,26 @@ - (void)recordEvent:(NSString *)key segmentation:(NSDictionary *)segmentation co [CountlyPersistency.sharedInstance recordEvent:event]; } -- (NSDictionary*) processSegmentation:(NSMutableDictionary *) segmentation eventKey:(NSString *)eventKey -{ - if(CountlyViewTrackingInternal.sharedInstance.enablePreviousNameRecording) { - if([eventKey isEqualToString:kCountlyReservedEventView]) { - segmentation[kCountlyPreviousView] = CountlyViewTrackingInternal.sharedInstance.previousViewName ?: @""; - } +- (NSDictionary *)processSegmentation:(NSMutableDictionary *)segmentation eventKey:(NSString *)eventKey { + BOOL isViewEvent = [eventKey isEqualToString:kCountlyReservedEventView]; + + // Add previous view name if enabled and the event is a view event + if (isViewEvent && CountlyViewTrackingInternal.sharedInstance.enablePreviousNameRecording) { + segmentation[kCountlyPreviousView] = CountlyViewTrackingInternal.sharedInstance.previousViewName ?: @""; } - if(CountlyCommon.sharedInstance.enableVisibiltyTracking) { - segmentation[kCountlyVisibility] = @([self isAppInForeground]); + // Add visibility tracking information if enabled + if (CountlyCommon.sharedInstance.enableVisibiltyTracking) { + BOOL isViewStart = [segmentation[kCountlyVTKeyVisit] isEqual:@1]; + + // Add visibility if it's not a view event or it's a view start event + if (!isViewEvent || isViewStart) { + segmentation[kCountlyVisibility] = @([self isAppInForeground] ? 1 : 0); + } } - return segmentation; + + // Return segmentation dictionary if not empty, otherwise return nil + return segmentation.count > 0 ? segmentation : nil; } - (BOOL)isAppInForeground { diff --git a/ios/src/Countly.podspec b/ios/src/Countly.podspec index e914373a..41dd3402 100644 --- a/ios/src/Countly.podspec +++ b/ios/src/Countly.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'Countly' - s.version = '24.7.4' + s.version = '24.7.9' s.license = { :type => 'MIT', :file => 'LICENSE' } s.summary = 'Countly is an innovative, real-time, open source mobile analytics platform.' s.homepage = 'https://github.com/Countly/countly-sdk-ios' diff --git a/ios/src/Countly.xcodeproj/project.pbxproj b/ios/src/Countly.xcodeproj/project.pbxproj index d90ee9c7..071f99d1 100644 --- a/ios/src/Countly.xcodeproj/project.pbxproj +++ b/ios/src/Countly.xcodeproj/project.pbxproj @@ -36,6 +36,7 @@ 3961C6BA2C6633C000DD38BA /* CountlyWebViewManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 3961C6B42C6633C000DD38BA /* CountlyWebViewManager.m */; }; 3964A3E72C2AF8E90091E677 /* CountlySegmentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3964A3E62C2AF8E90091E677 /* CountlySegmentationTests.swift */; }; 3966DBCF2C11EE270002ED97 /* CountlyDeviceIDTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3966DBCE2C11EE270002ED97 /* CountlyDeviceIDTests.swift */; }; + 3969D0232CB80848000F8A32 /* CountlyViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3969D0222CB80848000F8A32 /* CountlyViewTests.swift */; }; 3972EDDB2C08A38D00EB9D3E /* CountlyEventStruct.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3972EDDA2C08A38D00EB9D3E /* CountlyEventStruct.swift */; }; 3979E47D2C0760E900FA1CA4 /* CountlyUserProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3979E47C2C0760E900FA1CA4 /* CountlyUserProfileTests.swift */; }; 399117D12C69F73D00DC4C66 /* CountlyContentBuilder.m in Sources */ = {isa = PBXBuildFile; fileRef = 399117CD2C69F73D00DC4C66 /* CountlyContentBuilder.m */; }; @@ -133,6 +134,7 @@ 3961C6B42C6633C000DD38BA /* CountlyWebViewManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CountlyWebViewManager.m; sourceTree = ""; }; 3964A3E62C2AF8E90091E677 /* CountlySegmentationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlySegmentationTests.swift; sourceTree = ""; }; 3966DBCE2C11EE270002ED97 /* CountlyDeviceIDTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyDeviceIDTests.swift; sourceTree = ""; }; + 3969D0222CB80848000F8A32 /* CountlyViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyViewTests.swift; sourceTree = ""; }; 3972EDDA2C08A38D00EB9D3E /* CountlyEventStruct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyEventStruct.swift; sourceTree = ""; }; 3979E47C2C0760E900FA1CA4 /* CountlyUserProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyUserProfileTests.swift; sourceTree = ""; }; 399117CD2C69F73D00DC4C66 /* CountlyContentBuilder.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CountlyContentBuilder.m; sourceTree = ""; }; @@ -221,6 +223,7 @@ 3966DBCE2C11EE270002ED97 /* CountlyDeviceIDTests.swift */, 3964A3E62C2AF8E90091E677 /* CountlySegmentationTests.swift */, 399B464F2C52813700AD384E /* CountlyLocationTests.swift */, + 3969D0222CB80848000F8A32 /* CountlyViewTests.swift */, ); path = CountlyTests; sourceTree = ""; @@ -470,6 +473,7 @@ buildActionMask = 2147483647; files = ( 1A5C4C972B35B0850032EE1F /* CountlyTests.swift in Sources */, + 3969D0232CB80848000F8A32 /* CountlyViewTests.swift in Sources */, 399B46502C52813700AD384E /* CountlyLocationTests.swift in Sources */, 1A50D7052B3C5AA3009C6938 /* CountlyBaseTestCase.swift in Sources */, 3979E47D2C0760E900FA1CA4 /* CountlyUserProfileTests.swift in Sources */, @@ -734,7 +738,7 @@ "@loader_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.14; - MARKETING_VERSION = 24.7.4; + MARKETING_VERSION = 24.7.9; PRODUCT_BUNDLE_IDENTIFIER = ly.count.CountlyiOSSDK; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -766,7 +770,7 @@ "@loader_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.14; - MARKETING_VERSION = 24.7.4; + MARKETING_VERSION = 24.7.9; PRODUCT_BUNDLE_IDENTIFIER = ly.count.CountlyiOSSDK; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/ios/src/CountlyCommon.h b/ios/src/CountlyCommon.h index 2aaf4e66..08ad66f5 100644 --- a/ios/src/CountlyCommon.h +++ b/ios/src/CountlyCommon.h @@ -122,6 +122,8 @@ void CountlyPrint(NSString *stringToPrint); - (void)recordOrientation; - (BOOL)hasStarted_; + +- (NSURLSession *)URLSession; @end diff --git a/ios/src/CountlyCommon.m b/ios/src/CountlyCommon.m index 061b64f3..000094f4 100644 --- a/ios/src/CountlyCommon.m +++ b/ios/src/CountlyCommon.m @@ -29,7 +29,7 @@ @interface CountlyCommon () #endif @end -NSString* const kCountlySDKVersion = @"24.7.4"; +NSString* const kCountlySDKVersion = @"24.7.9"; NSString* const kCountlySDKName = @"objc-native-ios"; NSString* const kCountlyErrorDomain = @"ly.count.ErrorDomain"; @@ -317,6 +317,18 @@ - (void)tryPresentingViewController:(UIViewController *)viewController withCompl } #endif +- (NSURLSession *)URLSession +{ + if (CountlyConnectionManager.sharedInstance.URLSessionConfiguration) + { + return [NSURLSession sessionWithConfiguration:CountlyConnectionManager.sharedInstance.URLSessionConfiguration]; + } + else + { + return NSURLSession.sharedSession; + } +} + @end diff --git a/ios/src/CountlyConnectionManager.h b/ios/src/CountlyConnectionManager.h index 273621b5..53cff0c9 100644 --- a/ios/src/CountlyConnectionManager.h +++ b/ios/src/CountlyConnectionManager.h @@ -69,4 +69,6 @@ extern const NSInteger kCountlyGETRequestMaxLength; - (NSString *)queryEssentials; - (NSString *)appendChecksum:(NSString *)queryString; +- (BOOL)isSessionStarted; + @end diff --git a/ios/src/CountlyConnectionManager.m b/ios/src/CountlyConnectionManager.m index 1368f748..4bc1f207 100644 --- a/ios/src/CountlyConnectionManager.m +++ b/ios/src/CountlyConnectionManager.m @@ -16,6 +16,7 @@ @interface CountlyConnectionManager () @property (nonatomic) NSURLSession* URLSession; @property (nonatomic, strong) NSDate *startTime; + @end NSString* const kCountlyQSKeyAppKey = @"app_key"; @@ -105,6 +106,11 @@ - (instancetype)init return self; } + +- (BOOL)isSessionStarted { + return isSessionStarted; +} + - (void)resetInstance { CLY_LOG_I(@"%s", __FUNCTION__); onceToken = 0; diff --git a/ios/src/CountlyContentBuilderInternal.m b/ios/src/CountlyContentBuilderInternal.m index ec0bbcb0..196edb8a 100644 --- a/ios/src/CountlyContentBuilderInternal.m +++ b/ios/src/CountlyContentBuilderInternal.m @@ -137,6 +137,11 @@ - (NSURLRequest *)fetchContentsRequest queryString = [CountlyConnectionManager.sharedInstance appendChecksum:queryString]; + NSArray *components = [CountlyDeviceInfo.locale componentsSeparatedByCharactersInSet:[NSCharacterSet characterSetWithCharactersInString:@"_-"]]; + + queryString = [queryString stringByAppendingFormat:@"&%@=%@", + @"la", components.firstObject]; + NSString* URLString = [NSString stringWithFormat:@"%@%@?%@", CountlyConnectionManager.sharedInstance.host, kCountlyEndpointContent, diff --git a/ios/src/CountlyFeedbackWidget.m b/ios/src/CountlyFeedbackWidget.m index 7631dd69..258f265a 100644 --- a/ios/src/CountlyFeedbackWidget.m +++ b/ios/src/CountlyFeedbackWidget.m @@ -115,7 +115,7 @@ - (void)getWidgetData:(void (^)(NSDictionary * __nullable widgetData, NSError * return; } - NSURLSessionTask* task = [NSURLSession.sharedSession dataTaskWithRequest:[self dataRequest] completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) + NSURLSessionTask* task = [CountlyCommon.sharedInstance.URLSession dataTaskWithRequest:[self dataRequest] completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { NSDictionary *widgetData = nil; diff --git a/ios/src/CountlyFeedbacksInternal.m b/ios/src/CountlyFeedbacksInternal.m index 4a7bb9e2..6b320811 100644 --- a/ios/src/CountlyFeedbacksInternal.m +++ b/ios/src/CountlyFeedbacksInternal.m @@ -252,7 +252,7 @@ - (void)presentRatingWidgetWithID:(NSString *)widgetID closeButtonText:(NSString return; NSURLRequest* feedbackWidgetCheckRequest = [self widgetCheckURLRequest:widgetID]; - NSURLSessionTask* task = [NSURLSession.sharedSession dataTaskWithRequest:feedbackWidgetCheckRequest completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) + NSURLSessionTask* task = [CountlyCommon.sharedInstance.URLSession dataTaskWithRequest:feedbackWidgetCheckRequest completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { NSDictionary* widgetInfo = nil; @@ -425,7 +425,7 @@ - (void)getFeedbackWidgets:(void (^)(NSArray *feedback if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) return; - NSURLSessionTask* task = [NSURLSession.sharedSession dataTaskWithRequest:[self feedbacksRequest] completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) + NSURLSessionTask* task = [CountlyCommon.sharedInstance.URLSession dataTaskWithRequest:[self feedbacksRequest] completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { NSDictionary *feedbacksResponse = nil; diff --git a/ios/src/CountlyReactNative.m b/ios/src/CountlyReactNative.m index 923dd17d..cd811837 100644 --- a/ios/src/CountlyReactNative.m +++ b/ios/src/CountlyReactNative.m @@ -24,7 +24,7 @@ @interface CountlyFeedbackWidget () + (CountlyFeedbackWidget *)createWithDictionary:(NSDictionary *)dictionary; @end -NSString *const kCountlyReactNativeSDKVersion = @"24.4.1"; +NSString *const kCountlyReactNativeSDKVersion = @"25.1.0"; NSString *const kCountlyReactNativeSDKName = @"js-rnb-ios"; CLYPushTestMode const CLYPushTestModeProduction = @"CLYPushTestModeProduction"; @@ -196,6 +196,12 @@ - (void) populateConfig:(id) json { config.enablePerformanceMonitoring = YES; } // APM END -------------------------------------------- + if (json[@"enablePreviousNameRecording"]) { + config.experimental.enablePreviousNameRecording = YES; + } + if (json[@"enableVisibilityTracking"]) { + config.experimental.enableVisibiltyTracking = YES; + } if (json[@"crashReporting"]) { [self addCountlyFeature:CLYCrashReporting]; @@ -315,7 +321,6 @@ - (void) populateConfig:(id) json { dispatch_async(dispatch_get_main_queue(), ^{ NSDictionary *userData = [arguments objectAtIndex:0]; [self setUserDataIntenral:userData]; - [Countly.user save]; resolve(@"Success"); }); } @@ -667,7 +672,6 @@ - (CLLocationCoordinate2D)getCoordinate:(NSString *)gpsCoordinate { NSString *keyValue = [arguments objectAtIndex:1]; [Countly.user set:keyName value:keyValue]; - [Countly.user save]; resolve(@"Success"); }); } @@ -677,7 +681,6 @@ - (CLLocationCoordinate2D)getCoordinate:(NSString *)gpsCoordinate { NSString *keyName = [arguments objectAtIndex:0]; [Countly.user increment:keyName]; - [Countly.user save]; resolve(@"Success"); }); } @@ -689,7 +692,6 @@ - (CLLocationCoordinate2D)getCoordinate:(NSString *)gpsCoordinate { int keyValueInteger = [keyValue intValue]; [Countly.user incrementBy:keyName value:[NSNumber numberWithInt:keyValueInteger]]; - [Countly.user save]; resolve(@"Success"); }); } @@ -701,7 +703,6 @@ - (CLLocationCoordinate2D)getCoordinate:(NSString *)gpsCoordinate { int keyValueInteger = [keyValue intValue]; [Countly.user multiply:keyName value:[NSNumber numberWithInt:keyValueInteger]]; - [Countly.user save]; resolve(@"Success"); }); } @@ -713,7 +714,6 @@ - (CLLocationCoordinate2D)getCoordinate:(NSString *)gpsCoordinate { int keyValueInteger = [keyValue intValue]; [Countly.user max:keyName value:[NSNumber numberWithInt:keyValueInteger]]; - [Countly.user save]; resolve(@"Success"); }); } @@ -725,7 +725,6 @@ - (CLLocationCoordinate2D)getCoordinate:(NSString *)gpsCoordinate { int keyValueInteger = [keyValue intValue]; [Countly.user min:keyName value:[NSNumber numberWithInt:keyValueInteger]]; - [Countly.user save]; resolve(@"Success"); }); } @@ -736,7 +735,6 @@ - (CLLocationCoordinate2D)getCoordinate:(NSString *)gpsCoordinate { NSString *keyValue = [arguments objectAtIndex:1]; [Countly.user setOnce:keyName value:keyValue]; - [Countly.user save]; resolve(@"Success"); }); } @@ -747,7 +745,6 @@ - (CLLocationCoordinate2D)getCoordinate:(NSString *)gpsCoordinate { NSString *keyValue = [arguments objectAtIndex:1]; [Countly.user pushUnique:keyName value:keyValue]; - [Countly.user save]; resolve(@"Success"); }); } @@ -758,7 +755,6 @@ - (CLLocationCoordinate2D)getCoordinate:(NSString *)gpsCoordinate { NSString *keyValue = [arguments objectAtIndex:1]; [Countly.user push:keyName value:keyValue]; - [Countly.user save]; resolve(@"Success"); }); } @@ -769,7 +765,6 @@ - (CLLocationCoordinate2D)getCoordinate:(NSString *)gpsCoordinate { NSString *keyValue = [arguments objectAtIndex:1]; [Countly.user pull:keyName value:keyValue]; - [Countly.user save]; resolve(@"Success"); }); } diff --git a/ios/src/CountlyRemoteConfigInternal.m b/ios/src/CountlyRemoteConfigInternal.m index dd2ed7a8..5e0aee79 100644 --- a/ios/src/CountlyRemoteConfigInternal.m +++ b/ios/src/CountlyRemoteConfigInternal.m @@ -197,7 +197,7 @@ - (void)fetchRemoteConfigForKeys:(NSArray *)keys omitKeys:(NSArray *)omitKeys i return; NSURLRequest* request = [self remoteConfigRequestForKeys:keys omitKeys:omitKeys isLegacy:isLegacy]; - NSURLSessionTask* task = [NSURLSession.sharedSession dataTaskWithRequest:request completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) + NSURLSessionTask* task = [CountlyCommon.sharedInstance.URLSession dataTaskWithRequest:request completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { NSDictionary* remoteConfig = nil; @@ -496,7 +496,7 @@ - (void)testingDownloadAllVariantsInternal:(void (^)(CLYRequestResult response, return; NSURLRequest* request = [self downloadVariantsRequest]; - NSURLSessionTask* task = [NSURLSession.sharedSession dataTaskWithRequest:request completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) + NSURLSessionTask* task = [CountlyCommon.sharedInstance.URLSession dataTaskWithRequest:request completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { NSMutableDictionary* variants = NSMutableDictionary.new; @@ -607,7 +607,7 @@ - (void)testingEnrollIntoVariantInternal:(NSString *)key variantName:(NSString * } NSURLRequest* request = [self enrollInVarianRequestForKey:key variantName:variantName]; - NSURLSessionTask* task = [NSURLSession.sharedSession dataTaskWithRequest:request completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) + NSURLSessionTask* task = [CountlyCommon.sharedInstance.URLSession dataTaskWithRequest:request completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { NSDictionary* variants = nil; [self clearCachedRemoteConfig]; @@ -721,7 +721,7 @@ - (void)testingDownloaExperimentInfoInternal:(void (^)(CLYRequestResult response return; NSURLRequest* request = [self downloadExperimentInfoRequest]; - NSURLSessionTask* task = [NSURLSession.sharedSession dataTaskWithRequest:request completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) + NSURLSessionTask* task = [CountlyCommon.sharedInstance.URLSession dataTaskWithRequest:request completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { NSMutableDictionary * experiments = NSMutableDictionary.new; diff --git a/ios/src/CountlyServerConfig.m b/ios/src/CountlyServerConfig.m index 300f14e7..c8c93623 100644 --- a/ios/src/CountlyServerConfig.m +++ b/ios/src/CountlyServerConfig.m @@ -84,7 +84,7 @@ - (void)fetchServerConfig if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) return; - NSURLSessionTask* task = [NSURLSession.sharedSession dataTaskWithRequest:[self serverConfigRequest] completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) + NSURLSessionTask* task = [CountlyCommon.sharedInstance.URLSession dataTaskWithRequest:[self serverConfigRequest] completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { NSDictionary *serverConfigResponse = nil; diff --git a/ios/src/CountlyTests/CountlyBaseTestCase.swift b/ios/src/CountlyTests/CountlyBaseTestCase.swift index 90a5f60e..d786fa86 100644 --- a/ios/src/CountlyTests/CountlyBaseTestCase.swift +++ b/ios/src/CountlyTests/CountlyBaseTestCase.swift @@ -13,7 +13,7 @@ class CountlyBaseTestCase: XCTestCase { var countly: Countly! var deviceID: String = "" let appKey: String = "appkey" - var host: String = "https://test.count.ly/" + var host: String = "https://testing.count.ly/" override func setUpWithError() throws { // Put setup code here. This method is called before the invocation of each test method in the class. diff --git a/ios/src/CountlyTests/CountlyEventStruct.swift b/ios/src/CountlyTests/CountlyEventStruct.swift index 15086096..921d89f6 100644 --- a/ios/src/CountlyTests/CountlyEventStruct.swift +++ b/ios/src/CountlyTests/CountlyEventStruct.swift @@ -61,9 +61,9 @@ struct AnyCodable: Codable { struct CountlyEventStruct: Codable { let key: String let ID: String - let CVID: String + let CVID: String? let PVID: String? - let PEID: String + let PEID: String? let segmentation: [String: Any]? let count: UInt let sum: Double @@ -81,9 +81,9 @@ struct CountlyEventStruct: Codable { let container = try decoder.container(keyedBy: CodingKeys.self) key = try container.decode(String.self, forKey: .key) ID = try container.decode(String.self, forKey: .ID) - CVID = try container.decode(String.self, forKey: .CVID) + CVID = try container.decodeIfPresent(String.self, forKey: .CVID) PVID = try container.decodeIfPresent(String.self, forKey: .PVID) - PEID = try container.decode(String.self, forKey: .PEID) + PEID = try container.decodeIfPresent(String.self, forKey: .PEID) count = try container.decode(UInt.self, forKey: .count) sum = try container.decode(Double.self, forKey: .sum) timestamp = try container.decode(TimeInterval.self, forKey: .timestamp) diff --git a/ios/src/CountlyTests/CountlyViewTests.swift b/ios/src/CountlyTests/CountlyViewTests.swift new file mode 100644 index 00000000..387a6ea4 --- /dev/null +++ b/ios/src/CountlyTests/CountlyViewTests.swift @@ -0,0 +1,993 @@ +// +// CountlyViewTrackingTests.swift +// CountlyTests +// +// Copyright © 2024 Countly. All rights reserved. +// + +import XCTest +@testable import Countly + +class CountlyViewTrackingTests: CountlyViewBaseTest { + + // Run this test first if you are facing cache not clear or instances are not reset properly + // This is a dummy test to cover the edge case clear the cache when SDK is not initialized + func testDummy() { + let config = createBaseConfig() + config.requiresConsent = false; + config.manualSessionHandling = true; + Countly.sharedInstance().start(with: config); + Countly.sharedInstance().halt(true) + } + + func testStartAndStopView() throws { + let config = createBaseConfig() + Countly.sharedInstance().start(with: config) + + // Start the first view with "View1" and set an expectation to stop after 3 seconds + let viewID = Countly.sharedInstance().views().startView("View1") + XCTAssertNotNil(viewID, "View should be started successfully.") + + let expectation = XCTestExpectation(description: "First view should be stopped after 3 seconds.") + + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + Countly.sharedInstance().views().stopView(withName: "View1") + expectation.fulfill() + } + + // Start the second view with "View1" and set an expectation to stop after 5 seconds + let viewID1 = Countly.sharedInstance().views().startView("View1") + XCTAssertNotNil(viewID1, "View should be started successfully.") + + let expectation1 = XCTestExpectation(description: "Second view should be stopped after 5 seconds.") + + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { + Countly.sharedInstance().views().stopView(withName: "View1") + expectation1.fulfill() + } + + // Wait for both expectations to be fulfilled within 10 seconds + wait(for: [expectation, expectation1], timeout: 10.0) + + // Verify recorded events + let startedEventsCount = ["View1": 2] // Expecting 2 start events for "View1" + let endedEventsDurations = ["View1": [3, 5]] // Expecting 2 stop events with durations 3 and 5 seconds + + // Call validateRecordedEvents to check if the events match expectations + validateRecordedViews(startedEventsCount: startedEventsCount, endedEventsDurations: endedEventsDurations) + } + + + func testStartAndStopViewWithSegmentation() throws { + let config = createBaseConfig() + Countly.sharedInstance().start(with: config) + + // Start the view with segmentation + let viewID = Countly.sharedInstance().views().startView("View1", segmentation: ["key": "value"]) + XCTAssertNotNil(viewID, "View should be started successfully with segmentation.") + + let expectation = XCTestExpectation(description: "View should be stopped after 4 seconds.") + + // Stop the view after 4 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 4) { + Countly.sharedInstance().views().stopView(withName: "View1") + expectation.fulfill() // Fulfill expectation once view is stopped + } + + // Wait for the stop operation to complete within the timeout + wait(for: [expectation], timeout: 5.0) + + // Verify recorded events + let startedEventsCount = ["View1": 1] // Expecting 1 start events for "View1" + let endedEventsDurations = ["View1": [4]] // Expecting 1 stop events with durations 4 seconds + + // Call validateRecordedEvents to check if the events match expectations + validateRecordedViews(startedEventsCount: startedEventsCount, endedEventsDurations: endedEventsDurations) + + validateRecordedEventSegmentations(forEventID: viewID ?? "", expectedSegmentations: ["name": "View1", "visit": 1, "key": "value", "segment": "iOS"]) + } + + func testStartViewAndStopViewWithID() throws { + let config = createBaseConfig() + Countly.sharedInstance().start(with: config) + + guard let viewID = Countly.sharedInstance().views().startView("View1") else { + XCTFail("View should be started successfully, but viewID is nil.") + return + } + + let expectation = XCTestExpectation(description: "View should be stopped after 3 seconds.") + + // Stop the view after 3 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + Countly.sharedInstance().views().stopView(withID: viewID) + expectation.fulfill() // Fulfill expectation once view is stopped + } + + // Wait for the expectation to be fulfilled within 5 seconds + wait(for: [expectation], timeout: 5.0) + + // Verify recorded events + let startedEventsCount = ["View1": 1] // Expecting 1 start events for "View1" + let endedEventsDurations = ["View1": [3]] // Expecting 1 stop events with durations 3 seconds + + // Call validateRecordedEvents to check if the events match expectations + validateRecordedViews(startedEventsCount: startedEventsCount, endedEventsDurations: endedEventsDurations) + } + + func testStartAndStopMultipleViewsIncludingAutoStoppedViews() throws { + let config = createBaseConfig() + Countly.sharedInstance().start(with: config) + + // Ensure views are started successfully + guard let viewID1 = Countly.sharedInstance().views().startView("View1") else { + XCTFail("View1 should be started successfully.") + return + } + + Countly.sharedInstance().views().startAutoStoppedView("View2") + + let expectation = XCTestExpectation(description: "Views should be stopped after 5 seconds.") + + // Stop the views after 5 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { + Countly.sharedInstance().views().startView("View3") + + Countly.sharedInstance().views().stopView(withID: viewID1) + expectation.fulfill() + } + + // Wait for the stop operation to complete + wait(for: [expectation], timeout: 7.0) // Increased timeout to ensure sufficient time + + // Check recorded events for both views + // Verify recorded events + let startedEventsCount = ["View1": 1, + "View2" : 1, + "View3" : 1] + + let endedEventsDurations = ["View1": [5], + "View2": [5]] + + // Call validateRecordedEvents to check if the events match expectations + validateRecordedViews(startedEventsCount: startedEventsCount, endedEventsDurations: endedEventsDurations) + } + + func testPauseAndResumeViewsForMultipleViews() throws { + let config = createBaseConfig() + Countly.sharedInstance().start(with: config) + + // Start views + guard let viewID1 = Countly.sharedInstance().views().startView("View1") else { + XCTFail("View1 should be started successfully.") + return + } + + guard let viewID2 = Countly.sharedInstance().views().startAutoStoppedView("View2") else { + XCTFail("View2 should be started successfully.") + return + } + + XCTAssertNotNil(viewID1, "View1 should be started successfully.") + XCTAssertNotNil(viewID2, "View2 should be started successfully.") + + // Create expectations + let pauseExpectation = XCTestExpectation(description: "Pause View1 after 4 seconds.") + let resumeExpectation = XCTestExpectation(description: "Resume View1 after pausing for 3 seconds.") + let stopExpectation = XCTestExpectation(description: "Stop both views after resuming View1 for 4 seconds.") + + // Pause View1 after 4 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 4) { + Countly.sharedInstance().views().pauseView(withID: viewID1) + pauseExpectation.fulfill() + } + + // Resume View1 after an additional 3 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 7) { + Countly.sharedInstance().views().resumeView(withID: viewID1) + resumeExpectation.fulfill() + } + + // Stop both views after 5 more seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 12) { + Countly.sharedInstance().views().stopView(withName: "View1") + Countly.sharedInstance().views().stopView(withID: viewID2) + stopExpectation.fulfill() + } + + // Wait for expectations to be fulfilled + wait(for: [pauseExpectation, resumeExpectation, stopExpectation], timeout: 15.0) + + // Check recorded events for both views + // Verify recorded events + let startedEventsCount = ["View1": 1, + "View2" : 1] + + let endedEventsDurations = ["View1": [4, 5], + "View2": [12]] + + // Call validateRecordedEvents to check if the events match expectations + validateRecordedViews(startedEventsCount: startedEventsCount, endedEventsDurations: endedEventsDurations) + } + + func testMultiplePauseAndResumeCyclesOnSameView() throws { + let config = createBaseConfig() + Countly.sharedInstance().start(with: config) + + // Start view and assert it's started successfully + guard let viewID = Countly.sharedInstance().views().startView("View1") else { + XCTFail("View1 should be started successfully.") + return + } + XCTAssertNotNil(viewID, "View should be started successfully.") + + // Create expectations + let pauseExpectation = XCTestExpectation(description: "Pause View1 after 4 seconds.") + let resumeExpectation = XCTestExpectation(description: "Resume View1 after 3 seconds of pause.") + + let pauseExpectation1 = XCTestExpectation(description: "Pause View1 after 3 seconds.") + let resumeExpectation1 = XCTestExpectation(description: "Resume View1 after 5 seconds of pause.") + + let stopExpectation = XCTestExpectation(description: "Stop View1 after another 5 seconds of resuming.") + + // Pause the view after 4 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 4) { + Countly.sharedInstance().views().pauseView(withID: viewID) + pauseExpectation.fulfill() + } + + // Resume the view after 3 more seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 7) { // 4 + 3 seconds + Countly.sharedInstance().views().resumeView(withID: viewID) + resumeExpectation.fulfill() + } + + // Pause the view after 4 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 10) { + Countly.sharedInstance().views().pauseView(withID: viewID) + pauseExpectation1.fulfill() + } + + // Resume the view after 3 more seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 15) { // 4 + 3 seconds + Countly.sharedInstance().views().resumeView(withID: viewID) + resumeExpectation1.fulfill() + } + + // Stop the view after another 4 seconds of resuming + DispatchQueue.main.asyncAfter(deadline: .now() + 20) { // 4 + 3 + 4 seconds + Countly.sharedInstance().views().stopView(withName: "View1") + stopExpectation.fulfill() + } + + Countly.sharedInstance().views().startView("View1") + Countly.sharedInstance().views().stopView(withName: "View1") + + // Wait for all expectations to be fulfilled + wait(for: [pauseExpectation, resumeExpectation,pauseExpectation1, resumeExpectation1, stopExpectation], timeout: 35.0) + + // Verify recorded events + let startedEventsCount = ["View1": 2] + + let endedEventsDurations = ["View1": [4, 3, 5, 0]] + + // Call validateRecordedEvents to check if the events match expectations + validateRecordedViews(startedEventsCount: startedEventsCount, endedEventsDurations: endedEventsDurations) + } + + func testStartViewWhileAutoViewTrackingEnabled() throws { + let config = createBaseConfig() + config.enableAutomaticViewTracking = true // Enable auto view tracking + Countly.sharedInstance().start(with: config) + + // Start a manual view tracking call + let viewID = Countly.sharedInstance().views().startView("View1") + Countly.sharedInstance().views().stopView(withName: "View1") + Countly.sharedInstance().views().stopView(withID: viewID) + // Assert that manual view tracking returns nil when auto tracking is enabled + XCTAssertNil(viewID, "Manual view tracking should be ignored when auto view tracking is enabled.") + // Verify recorded events + let startedEventsCount: [String: Int] = [:] + + let endedEventsDurations : [String: [Int]] = [:] + + // Call validateRecordedEvents to check if the events match expectations + validateRecordedViews(startedEventsCount: startedEventsCount, endedEventsDurations: endedEventsDurations) + } + + func testStartAndStopAutoStoppedViewWithSegmentation() throws { + let config = createBaseConfig() + Countly.sharedInstance().start(with: config) + + // Start the auto-stopped view with segmentation + guard let viewID = Countly.sharedInstance().views().startAutoStoppedView("View1", segmentation: ["key": "value"]) else { + XCTFail("Auto-stopped view should be started successfully with segmentation.") + return + } + + XCTAssertNotNil(viewID, "Auto-stopped view should be started successfully with segmentation.") + + // Create an expectation for stopping the view after 4 seconds + let stopExpectation = XCTestExpectation(description: "Wait for 4 seconds before stopping the auto-stopped view.") + + // Stop the view after 4 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 4) { + Countly.sharedInstance().views().stopView(withID: viewID) + stopExpectation.fulfill() + } + + // Wait for the stop expectation + wait(for: [stopExpectation], timeout: 6.0) // Allow a small buffer beyond the 4-second delay + + let startedEventsCount = ["View1": 1] + + let endedEventsDurations = ["View1": [4]] + + // Call validateRecordedEvents to check if the events match expectations + validateRecordedViews(startedEventsCount: startedEventsCount, endedEventsDurations: endedEventsDurations) + } + + func testStartAutoStoppedViewAndInitiateAnother() throws { + let config = createBaseConfig() + Countly.sharedInstance().start(with: config) + + let viewID1 = Countly.sharedInstance().views().startAutoStoppedView("View1") + XCTAssertNotNil(viewID1, "View1 should be started successfully.") + + var viewID2 = "" + let startExpectation = XCTestExpectation(description: "Start second view after 4 seconds") + let stopExpectation = XCTestExpectation(description: "Stop both views after 3 seconds") + + // Start second view after 4 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 4) { + viewID2 = Countly.sharedInstance().views().startAutoStoppedView("View2") + XCTAssertNotNil(viewID2, "View2 should be started successfully.") + + // Fulfill startExpectation after starting View2 + startExpectation.fulfill() + + // Stop both views after 3 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + Countly.sharedInstance().views().stopView(withID: viewID2) + + // Fulfill stopExpectation after stopping both views + stopExpectation.fulfill() + } + } + + // Wait for both expectations + wait(for: [startExpectation, stopExpectation], timeout: 10.0) + + let startedEventsCount = ["View1": 1, + "View2": 1] + + let endedEventsDurations = ["View1": [4], + "View2": [3]] + + // Call validateRecordedEvents to check if the events match expectations + validateRecordedViews(startedEventsCount: startedEventsCount, endedEventsDurations: endedEventsDurations) + } + + func testStartRegularViewPauseAndResumeMultipleTimesThenStop() throws { + let config = createBaseConfig() + Countly.sharedInstance().start(with: config) + + // Start the view + let viewID = Countly.sharedInstance().views().startView("View1") + XCTAssertNotNil(viewID, "View should be started successfully.") + var viewID2 = ""; + // Create expectations + let pauseExpectation = XCTestExpectation(description: "Pause the view after 3 seconds") + let resumeExpectation = XCTestExpectation(description: "Resume the view after another 4 seconds") + let stopExpectation = XCTestExpectation(description: "Stop the view after 5 seconds") + + // Pause the view after 3 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + Countly.sharedInstance().views().pauseView(withID: viewID) + viewID2 = Countly.sharedInstance().views().startView("View2") + pauseExpectation.fulfill() + } + + // Resume the view after another 3 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 7) { + Countly.sharedInstance().views().resumeView(withID: viewID) + Countly.sharedInstance().views().pauseView(withID: viewID2) + resumeExpectation.fulfill() + } + + // Stop the view after 4 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 12) { + Countly.sharedInstance().views().stopView(withName: "View1") + Countly.sharedInstance().views().resumeView(withID: viewID2) + stopExpectation.fulfill() + } + + // Wait for all expectations to be fulfilled + wait(for: [pauseExpectation, resumeExpectation, stopExpectation], timeout: 20) + + let startedEventsCount = ["View1": 1, + "View2": 1] + + let endedEventsDurations = ["View1": [3, 5], + "View2": [4]] + + // Call validateRecordedEvents to check if the events match expectations + validateRecordedViews(startedEventsCount: startedEventsCount, endedEventsDurations: endedEventsDurations) + } + + func testStopAllViewsWithSpecificSegmentation() throws { + let config = createBaseConfig() + Countly.sharedInstance().start(with: config) + + // Start multiple views + let viewID1 = Countly.sharedInstance().views().startView("View1") + let viewID2 = Countly.sharedInstance().views().startView("View2") + + XCTAssertNotNil(viewID1, "View1 should be started successfully.") + XCTAssertNotNil(viewID2, "View2 should be started successfully.") + + // Create expectation for stopping all views + let stopAllViewsExpectation = XCTestExpectation(description: "Wait for 4 seconds before stopping all views.") + + DispatchQueue.main.asyncAfter(deadline: .now() + 4) { + Countly.sharedInstance().views().stopAllViews(["key": "value"]) + stopAllViewsExpectation.fulfill() + } + + // Wait for the expectation to be fulfilled + wait(for: [stopAllViewsExpectation], timeout: 6.0) + + let startedEventsCount = ["View1": 1, + "View2": 1] + + let endedEventsDurations = ["View1": [4], + "View2": [4]] + + // Call validateRecordedEvents to check if the events match expectations + validateRecordedViews(startedEventsCount: startedEventsCount, endedEventsDurations: endedEventsDurations) + //TODO: check segmentations also + } + + func testUpdateSegmentationMultipleTimesOnTheSameView() throws { + let config = createBaseConfig() + Countly.sharedInstance().start(with: config) + + let viewID = Countly.sharedInstance().views().startView("View1", segmentation: ["startKey": "startValue"]) + XCTAssertNotNil(viewID, "View should be started successfully.") + + // Create expectations + let waitForStart = XCTestExpectation(description: "Wait for 4 seconds before adding segmentation.") + let waitForSecondSegmentation = XCTestExpectation(description: "Wait for 4 seconds before adding second segmentation.") + let waitForStop = XCTestExpectation(description: "Wait for 3 seconds before stopping the view.") + + // Add first segmentation + DispatchQueue.main.asyncAfter(deadline: .now() + 4) { + Countly.sharedInstance().views().addSegmentationToView(withName: "View1", segmentation: ["key1": "value1"]) + waitForStart.fulfill() + + // Add second segmentation + DispatchQueue.main.asyncAfter(deadline: .now() + 4) { + Countly.sharedInstance().views().addSegmentationToView(withName: "View1", segmentation: ["key2": "value2"]) + waitForSecondSegmentation.fulfill() + + // Stop the view + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + Countly.sharedInstance().views().stopView(withName: "View1") + waitForStop.fulfill() + } + } + } + + // Wait for all expectations to be fulfilled + wait(for: [waitForStart, waitForSecondSegmentation, waitForStop], timeout: 12.0) + + validateRecordedEventSegmentations(forEventID: viewID ?? "", expectedSegmentations: ["name": "View1", "visit": 1, "startKey": "startValue", "segment": "iOS"]) + validateRecordedEventSegmentations(forEventID: viewID ?? "", expectedSegmentations: ["name": "View1", "key1": "value1", "key2": "value2", "segment": "iOS"]) + } + + func testStartViewWithConsentNotGiven() throws { + let config = createBaseConfig() + config.requiresConsent = true + Countly.sharedInstance().start(with: config) + + + let beforeEventCount = getRecordedViews().count; + + let viewID = Countly.sharedInstance().views().startView("View1") + XCTAssertNil(viewID, "Event should not be recorded when consent is not given.") + + Countly.sharedInstance().views().stopView(withName: "View1") // This should also not affect recorded events + + let viewID2 = Countly.sharedInstance().views().startView("View2") + Countly.sharedInstance().views().stopView(withID: viewID2) + //TODO: Add all the public methods + + + let afterEventCount = getRecordedViews().count + + XCTAssertEqual(beforeEventCount, afterEventCount, "Stopping a non-started view should not record any new event.") + + } + + func testSetAndUpdateGlobalViewSegmentationWithViewInteractions() throws { + let config = createBaseConfig() + Countly.sharedInstance().start(with: config) + + // Start the first view + Countly.sharedInstance().views().startView("View1") + //TODO: validate that view or remove it + + // Create expectations for various events + let stopView1Expectation = XCTestExpectation(description: "Expect View1 to be stopped after 4 seconds.") + let startView2Expectation = XCTestExpectation(description: "Expect View2 to start after 3 seconds.") + let stopView2Expectation = XCTestExpectation(description: "Expect View2 to be stopped after 4 seconds.") + var viewID2 = "" + // Stop View1 after 4 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 4) { + Countly.sharedInstance().views().stopView(withName: "View1") + stopView1Expectation.fulfill() // Fulfill View1 stop expectation + + // Set global view segmentation + Countly.sharedInstance().views().setGlobalViewSegmentation(["key": "value"]) + + // Start View2 after 3 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + viewID2 = Countly.sharedInstance().views().startView("View2") + //TODO: also start with segmentation to check the precedence of user provided and global segmentation + startView2Expectation.fulfill() // Fulfill View2 start expectation + + // Update global view segmentation + Countly.sharedInstance().views().updateGlobalViewSegmentation(["key": "newValue"]) + + // Stop View2 after 4 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 4) { + Countly.sharedInstance().views().stopView(withName: "View2") + stopView2Expectation.fulfill() // Fulfill View2 stop expectation + } + } + } + + // Wait for all expectations to be fulfilled + wait(for: [stopView1Expectation, startView2Expectation, stopView2Expectation], timeout: 12.0) + + validateRecordedEventSegmentations(forEventID: viewID2, expectedSegmentations: ["visit": 1, "key": "value", "name": "View2", "segment": "iOS"]) + validateRecordedEventSegmentations(forEventID: viewID2, expectedSegmentations: ["key": "newValue", "name": "View2", "segment": "iOS"]) + } + +} + +class CountlyViewForegroundBackgroundTests: CountlyViewBaseTest { + func testStartMultipleViewsMoveAppToBackgroundAndReturnToForeground() throws { + let config = createBaseConfig() + Countly.sharedInstance().start(with: config) + + // Start the views + Countly.sharedInstance().views().startView("V1") + Countly.sharedInstance().views().startAutoStoppedView("A1") + + // Create expectations for various events + let waitForStart = XCTestExpectation(description: "Wait for 3 seconds before backgrounding app.") + let waitForBackground = XCTestExpectation(description: "Wait for 4 seconds in background.") + let waitForForeground = XCTestExpectation(description: "Wait for 3 seconds after foregrounding.") + let waitBGStartView = XCTestExpectation(description: "Wait for 1 seconds after background.") + let waitFGStartView = XCTestExpectation(description: "Wait for 1 seconds after background.") + + // Start the timer for moving the app to the background + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + // Simulate app going to background + NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + waitForStart.fulfill() // Fulfill the start expectation + + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + Countly.sharedInstance().views().startView("BGV1") + Countly.sharedInstance().views().startAutoStoppedView("BGA1") + waitBGStartView.fulfill() // Fulfill the foreground expectation + } + + + // Wait in background for 4 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 4) { + // Simulate app returning to foreground + NotificationCenter.default.post(name: UIApplication.didBecomeActiveNotification, object: nil) + waitForBackground.fulfill() // Fulfill the background expectation + + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + Countly.sharedInstance().views().startView("FGV1") + Countly.sharedInstance().views().startAutoStoppedView("FGA1") + waitFGStartView.fulfill() // Fulfill the foreground expectation + } + // Wait after foreground for 3 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { + // Stop the views after returning to foreground + Countly.sharedInstance().views().stopAllViews(nil); + waitForForeground.fulfill() // Fulfill the foreground expectation + } + } + } + + // Wait for all expectations to be fulfilled + wait(for: [waitForStart, waitForBackground, waitForForeground], timeout: 20) + + let startedQueuedEventsCount = ["V1": 1, + "A1": 1] + + let endedQueuedEventsDurations = ["V1": [3], + "A1": [3]] + + // Call validateRecordedEvents to check if the events match expectations + validateQueuedViews(startedEventsCount: startedQueuedEventsCount, endedEventsDurations: endedQueuedEventsDurations) + + let startedEventsCount = ["BGV1": 1, + "BGA1": 1, + "V1": 1, + "A1": 1, + "FGV1": 1, + "FGA1": 1] + + let endedEventsDurations = ["BGA1": [3], + "A1": [1], + "V1": [5], + "BGV1": [8], + "FGV1": [4], + "FGA1": [4]] + + // Call validateRecordedEvents to check if the events match expectations + validateRecordedViews(startedEventsCount: startedEventsCount, endedEventsDurations: endedEventsDurations) + } + + func testStartViewBackgroundAppResumeViewWhenReturningToForeground() throws { + let config = createBaseConfig() + Countly.sharedInstance().start(with: config) + + // Start the view + Countly.sharedInstance().views().startView("View1") + + // Create expectations for various events + let waitForStart = XCTestExpectation(description: "Wait for 3 seconds before backgrounding app.") + let waitForBackground = XCTestExpectation(description: "Wait for 4 seconds in background.") + let waitForForeground = XCTestExpectation(description: "Wait for 3 seconds after foregrounding.") + + // Start the timer for moving the app to the background + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { + // Simulate app going to background + NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + waitForStart.fulfill() // Fulfill the start expectation + + // Wait in background for 4 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 4) { + // Simulate app returning to foreground + NotificationCenter.default.post(name: UIApplication.didBecomeActiveNotification, object: nil) + waitForBackground.fulfill() // Fulfill the background expectation + + // Wait after foreground for 3 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + // Stop the view after returning to foreground + Countly.sharedInstance().views().stopView(withName: "View1") + waitForForeground.fulfill() // Fulfill the foreground expectation + } + } + } + + // Wait for all expectations to be fulfilled + wait(for: [waitForStart, waitForBackground, waitForForeground], timeout: 20.0) + + let startedQueuedEventsCount = ["View1": 1] + + let endedQueuedEventsDurations = ["View1": [5]] + + // Call validateRecordedEvents to check if the events match expectations + validateQueuedViews(startedEventsCount: startedQueuedEventsCount, endedEventsDurations: endedQueuedEventsDurations) + + let startedEventsCount = ["View1": 1] + + let endedEventsDurations = ["View1": [3]] + + // Call validateRecordedEvents to check if the events match expectations + validateRecordedViews(startedEventsCount: startedEventsCount, endedEventsDurations: endedEventsDurations) + } + + func testAttemptToStopANonStartedView() throws { + let config = createBaseConfig() + Countly.sharedInstance().start(with: config) + + // Attempt to stop a non-started view + let beforeEventCount = getRecordedViews().count; + Countly.sharedInstance().views().stopView(withName: "ViewNotStarted") + let afterEventCount = getRecordedViews().count + + XCTAssertEqual(beforeEventCount, afterEventCount, "Stopping a non-started view should not record any new event.") + } + + func testBackgroundAndForegroundTriggers() throws { + let config = createBaseConfig() + Countly.sharedInstance().start(with: config) + + Countly.sharedInstance().views().startView("View1") + + // Create expectations for various events + let waitForStart = XCTestExpectation(description: "Wait for 3 seconds before backgrounding app.") + let waitForBackground = XCTestExpectation(description: "Wait for 4 seconds in background.") + let waitForForeground = XCTestExpectation(description: "Wait for 3 seconds after foregrounding.") + + // Start the timer for moving the app to the background + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + // Simulate app going to background + NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + waitForStart.fulfill() // Fulfill the start expectation + + // Wait in background for 4 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 4) { + // Simulate app returning to foreground + NotificationCenter.default.post(name: UIApplication.didBecomeActiveNotification, object: nil) + waitForBackground.fulfill() // Fulfill the background expectation + + // Wait after foreground for 3 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + waitForForeground.fulfill() // Fulfill the foreground expectation + } + } + } + + // Wait for all expectations to be fulfilled + wait(for: [waitForStart, waitForBackground, waitForForeground], timeout: 15.0) + + let startedQueuedEventsCount = ["View1": 1] + + let endedQueuedEventsDurations = ["View1": [3]] + + // Call validateRecordedEvents to check if the events match expectations + validateQueuedViews(startedEventsCount: startedQueuedEventsCount, endedEventsDurations: endedQueuedEventsDurations) + + let startedEventsCount = ["View1": 1] + + let endedEventsDurations: [String: [Int]] = [:] + + // Call validateRecordedEvents to check if the events match expectations + validateRecordedViews(startedEventsCount: startedEventsCount, endedEventsDurations: endedEventsDurations) + } +} + +class CountlyViewBaseTest: CountlyBaseTestCase { + + // Helper methods to validate results + + func validateRecordedViews(startedEventsCount: [String: Int], endedEventsDurations: [String: [Int]]) { + // Access recorded events + guard let recordedEvents = CountlyPersistency.sharedInstance().value(forKey: "recordedEvents") as? [CountlyEvent] else { + fatalError("Failed to get recordedEvents from CountlyPersistency") + } + + // XCTAssertNotEqual(recordedEvents.count, 0, "No recorded events found") + + // Track occurrences for started and ended events + var actualStartedEventsCount: [String: Int] = [:] + var actualEndedEventsDurations: [String: [Int]] = [:] + + // Iterate through recorded events to populate actual counts and durations + for event in recordedEvents { + // Check for start events with "visit": "1" + if event.key == kCountlyReservedEventView + { + if let eventKey = event.segmentation?["name"] as? String { + if let visit = event.segmentation?["visit"], visit as! Int == 1 { + actualStartedEventsCount[eventKey, default: 0] += 1 + } + else{ + actualEndedEventsDurations[eventKey, default: []].append(Int(event.duration)) + } + } + } + } + + // Validate started events count + for (key, expectedCount) in startedEventsCount { + let actualCount = actualStartedEventsCount[key] ?? 0 + XCTAssertEqual(actualCount, expectedCount, "Started events count for key \(key) does not match expected count \(expectedCount)") + } + + // Validate ended events durations + for (key, expectedDurations) in endedEventsDurations { + let actualDurations = actualEndedEventsDurations[key] ?? [] + + // First, ensure the counts match + XCTAssertEqual(actualDurations.count, expectedDurations.count, "Ended events count for key \(key) does not match expected count \(expectedDurations.count)") + + // Create a mutable copy of actualDurations to modify + var mutableActualDurations = actualDurations + + // Check each duration matches + for (index, expectedDuration) in expectedDurations.enumerated() { + // Check if the expected duration exists in the actual durations + XCTAssertTrue(mutableActualDurations.contains(expectedDuration), "Duration at index \(index) for key \(key) does not match expected duration \(expectedDuration)") + + // Remove the expectedDuration from mutableActualDurations + if let foundIndex = mutableActualDurations.firstIndex(of: expectedDuration) { + mutableActualDurations.remove(at: foundIndex) + } + } + + // Optionally, check if all expected durations have been matched + XCTAssertTrue(mutableActualDurations.isEmpty, "Not all actual durations were matched with expected durations for key \(key)") + } + + } + + func validateQueuedViews(startedEventsCount: [String: Int], endedEventsDurations: [String: [Int]]) { + guard let queuedRequests = CountlyPersistency.sharedInstance().value(forKey: "queuedRequests") as? [String] else { + fatalError("Failed to get queuedRequests from CountlyPersistency") + } + + // Filter out requests containing "events=" + let eventRequests = queuedRequests.filter { $0.contains("events=") } + + // Initialize dictionaries to track actual counts and durations for verification + var actualStartedEventsCount: [String: Int] = [:] + var actualEndedEventsDurations: [String: [Int]] = [:] + + // Loop through each event request to process events + for request in eventRequests { + // Parse the query parameters + let parsedRequest = parseQueryString(request) + + // Check if "events" parameter exists and parse it + if let eventsJSON = parsedRequest["events"] as? String, + let jsonData = eventsJSON.data(using: .utf8) { + do { + // Decode JSON data into an array of events + let events = try JSONDecoder().decode([CountlyEventStruct].self, from: jsonData) + + // Process each event to check if it’s a start or stop event + for event in events { + if event.key == kCountlyReservedEventView { + let eventKey = event.segmentation?["name"] as? String ?? "" + + // Check for start events with "visit": "1" + if let visit = event.segmentation?["visit"] as? Int, visit == 1 { + actualStartedEventsCount[eventKey, default: 0] += 1 + } + // Check for stop events with "dur" for duration + else { + actualEndedEventsDurations[eventKey, default: []].append(Int(event.duration)) + } + } + } + } catch { + print("Failed to decode events JSON: \(error.localizedDescription)") + } + } + } + + // Validate started events count + for (key, expectedCount) in startedEventsCount { + let actualCount = actualStartedEventsCount[key] ?? 0 + XCTAssertEqual(actualCount, expectedCount, "Started events count for key \(key) does not match expected count \(expectedCount)") + } + + // Validate ended events durations + for (key, expectedDurations) in endedEventsDurations { + let actualDurations = actualEndedEventsDurations[key] ?? [] + XCTAssertEqual(actualDurations.count, expectedDurations.count, "Ended events count for key \(key) does not match expected count \(expectedDurations.count)") + + // Check each duration matches + for (index, expectedDuration) in expectedDurations.enumerated() { + XCTAssertEqual(actualDurations[index], expectedDuration, "Duration at index \(index) for key \(key) does not match expected duration \(expectedDuration)") + } + } + } + + func getRecordedViews() -> [CountlyEvent] { + // Access recorded events + guard let recordedEvents = CountlyPersistency.sharedInstance().value(forKey: "recordedEvents") as? [CountlyEvent] else { + fatalError("Failed to get recordedEvents from CountlyPersistency") + } + + // Filter and return events with the key `kCountlyReservedEventView` + return recordedEvents.filter { $0.key == kCountlyReservedEventView } + } + + func getQueuedViews() -> [CountlyEventStruct] { + guard let queuedRequests = CountlyPersistency.sharedInstance().value(forKey: "queuedRequests") as? [String] else { + fatalError("Failed to get queuedRequests from CountlyPersistency") + } + + // Filter out requests containing "events=" + let eventRequests = queuedRequests.filter { $0.contains("events=") } + var queuedViews: [CountlyEventStruct] = [] + + // Process each event request to extract and filter events + for request in eventRequests { + // Parse the query parameters + let parsedRequest = parseQueryString(request) + + // Check if "events" parameter exists and parse it + if let eventsJSON = parsedRequest["events"] as? String, + let jsonData = eventsJSON.data(using: .utf8) { + do { + // Decode JSON data into an array of events + let events = try JSONDecoder().decode([CountlyEventStruct].self, from: jsonData) + + // Filter and add events with the key `kCountlyReservedEventView` + queuedViews.append(contentsOf: events.filter { $0.key == kCountlyReservedEventView }) + } catch { + print("Failed to decode events JSON: \(error.localizedDescription)") + } + } + } + + return queuedViews + } + + + func validateRecordedEventSegmentations(forEventID eventID: String, expectedSegmentations: [String: Any]) { + // Get recorded views filtered by key + let recordedViews = getRecordedViews() + + // Determine if "visit" is specified in expectedSegmentations + let requiresVisit = expectedSegmentations["visit"] as? Int == 1 + + // Filter events based on the presence and value of "visit" + let filteredEvents = recordedViews.filter { event in + event.id == eventID && + (requiresVisit ? (event.segmentation?["visit"] as? Int == 1) : (event.segmentation?["visit"] == nil)) + } + + // Ensure there are events with the specified ID and segmentation criteria + XCTAssertFalse(filteredEvents.isEmpty, "No recorded events found with ID \(eventID) matching expected segmentation criteria") + + // Validate segmentations for each filtered event + for event in filteredEvents { + guard let eventSegmentations = event.segmentation as? [String: Any] else { + XCTFail("Event segmentation is missing or invalid for event with ID \(eventID)") + continue + } + + // Validate each expected segmentation + for (key, expectedValue) in expectedSegmentations { + if let actualValue = eventSegmentations[key] { + XCTAssertEqual("\(actualValue)", "\(expectedValue)", "Segmentation mismatch for key \(key) in recorded event with ID \(eventID): expected \(expectedValue), found \(actualValue)") + } else { + XCTFail("Segmentation key \(key) missing in recorded event with ID \(eventID)") + } + } + } + } + + func validateQueuedEventSegmentations(forEventID eventID: String, expectedSegmentations: [String: Any]) { + // Get queued views filtered by key + let queuedViews = getQueuedViews() + + // Determine if "visit" is specified in expectedSegmentations + let requiresVisit = expectedSegmentations["visit"] as? Int == 1 + + // Filter events based on the presence and value of "visit" + let filteredEvents = queuedViews.filter { event in + event.ID == eventID && + (requiresVisit ? (event.segmentation?["visit"] as? Int == 1) : (event.segmentation?["visit"] == nil)) + } + + // Ensure there are events with the specified ID and segmentation criteria + XCTAssertFalse(filteredEvents.isEmpty, "No queued events found with ID \(eventID) matching expected segmentation criteria") + + // Validate segmentations for each filtered event + for event in filteredEvents { + guard let eventSegmentations = event.segmentation as? [String: Any] else { + XCTFail("Event segmentation is missing or invalid for event with ID \(eventID)") + continue + } + + // Validate each expected segmentation + for (key, expectedValue) in expectedSegmentations { + if let actualValue = eventSegmentations[key] { + XCTAssertEqual("\(actualValue)", "\(expectedValue)", "Segmentation mismatch for key \(key) in queued event with ID \(eventID): expected \(expectedValue), found \(actualValue)") + } else { + XCTFail("Segmentation key \(key) missing in queued event with ID \(eventID)") + } + } + } + } + + +} + + + diff --git a/ios/src/CountlyViewData.h b/ios/src/CountlyViewData.h index d56dc687..e2f40f18 100644 --- a/ios/src/CountlyViewData.h +++ b/ios/src/CountlyViewData.h @@ -60,7 +60,7 @@ * Duration of the view * @discussion it returns the duration of view in foreground after view started. */ -- (NSTimeInterval)duration; +- (NSInteger)duration; /** diff --git a/ios/src/CountlyViewData.m b/ios/src/CountlyViewData.m index 28ddb6f3..ff8b58d0 100644 --- a/ios/src/CountlyViewData.m +++ b/ios/src/CountlyViewData.m @@ -23,17 +23,18 @@ - (instancetype)initWithID:(NSString *)viewID viewName:(NSString *)viewName return self; } -- (NSTimeInterval)duration +- (NSInteger)duration { NSTimeInterval duration = NSDate.date.timeIntervalSince1970 - self.viewStartTime; - return duration; + return (NSInteger)round(duration); // Rounds to the nearest integer, to fix long value converted to 0 on server side. } - (void)pauseView { if (self.viewStartTime) { - self.viewStartTime = 0; + // For safe side we have set the value to current time stamp instead of 0 when pausing the view, as setting it to 0 could result in an invalid duration value. + self.viewStartTime = CountlyCommon.sharedInstance.uniqueTimestamp; } } diff --git a/ios/src/CountlyViewTrackingInternal.h b/ios/src/CountlyViewTrackingInternal.h index b2260209..b21e2e1e 100644 --- a/ios/src/CountlyViewTrackingInternal.h +++ b/ios/src/CountlyViewTrackingInternal.h @@ -11,6 +11,7 @@ extern NSString* const kCountlyReservedEventView; extern NSString* const kCountlyCurrentView; extern NSString* const kCountlyPreviousView; extern NSString* const kCountlyPreviousEventName; +extern NSString* const kCountlyVTKeyVisit; @interface CountlyViewTrackingInternal : NSObject @property (nonatomic) BOOL isEnabledOnInitialConfig; diff --git a/ios/src/CountlyViewTrackingInternal.m b/ios/src/CountlyViewTrackingInternal.m index 36d12b24..53f9b319 100644 --- a/ios/src/CountlyViewTrackingInternal.m +++ b/ios/src/CountlyViewTrackingInternal.m @@ -10,7 +10,7 @@ @interface CountlyViewTrackingInternal () #if (TARGET_OS_IOS || TARGET_OS_TV) @property (nonatomic) NSMutableSet* automaticViewTrackingExclusionList; #endif -@property (nonatomic) NSMutableDictionary * viewDataDictionary; +@property (nonatomic, strong) NSMutableDictionary * viewDataDictionary; @property (nonatomic) NSMutableDictionary* viewSegmentation; @property (nonatomic) BOOL isFirstView; @end @@ -377,10 +377,10 @@ - (void)stopViewWithIDInternal:(NSString *) viewKey customSegmentation:(NSDictio segmentation[kCountlyVTKeyName] = viewData.viewName; segmentation[kCountlyVTKeySegment] = CountlyDeviceInfo.osName; - NSTimeInterval duration = viewData.duration; + NSInteger duration = viewData.duration; [Countly.sharedInstance recordReservedEvent:kCountlyReservedEventView segmentation:segmentation count:1 sum:0 duration:duration ID:viewData.viewID timestamp:CountlyCommon.sharedInstance.uniqueTimestamp]; - CLY_LOG_D(@"%s View tracking ended: %@ duration: %.17g", __FUNCTION__, viewData.viewName, duration); + CLY_LOG_D(@"%s View tracking ended: %@ duration: %ld", __FUNCTION__, viewData.viewName, (long)duration); if (!autoPaused) { [self.viewDataDictionary removeObjectForKey:viewKey]; } @@ -436,7 +436,7 @@ - (NSString*)startViewInternal:(NSString *)viewName customSegmentation:(NSDictio segmentation[kCountlyVTKeySegment] = CountlyDeviceInfo.osName; segmentation[kCountlyVTKeyVisit] = @1; - if (self.isFirstView) + if (self.isFirstView && [CountlyConnectionManager.sharedInstance isSessionStarted]) { self.isFirstView = NO; segmentation[kCountlyVTKeyStart] = @1; @@ -511,7 +511,7 @@ -(CountlyViewData* ) currentView - (void)stopAutoStoppedView { CountlyViewData* currentView = self.currentView; - if (currentView && currentView.isAutoStoppedView) + if (currentView && currentView.isAutoStoppedView && !currentView.willStartAgain) { [self stopViewWithIDInternal:self.currentView.viewID customSegmentation:nil]; } @@ -537,35 +537,41 @@ - (void)stopRunningViewsInternal - (void)pauseViewInternal:(CountlyViewData*) viewData { - [viewData pauseView]; [self stopViewWithIDInternal:viewData.viewID customSegmentation:nil autoPaused:YES]; + [viewData pauseView]; } - (void)startStoppedViewsInternal { // Create an array to store keys for views that need to be removed NSMutableArray *keysToRemove = [NSMutableArray array]; - + NSMutableArray *keysToStart = [NSMutableArray array]; + + // Collect keys without modifying the dictionary [self.viewDataDictionary enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull key, CountlyViewData * _Nonnull viewData, BOOL * _Nonnull stop) { if (viewData.willStartAgain) { - NSString *viewID = [self startViewInternal:viewData.viewName customSegmentation:viewData.startSegmentation isAutoStoppedView:viewData.isAutoStoppedView]; - - // Retrieve the newly created viewData for the viewID - CountlyViewData* viewDataNew = self.viewDataDictionary[viewID]; - - // Copy the segmentation data from the old view to the new view - viewDataNew.segmentation = viewData.segmentation.mutableCopy; - - // Add the old view's ID to the array for removal later + [keysToStart addObject:key]; [keysToRemove addObject:viewData.viewID]; } }]; + // Start the collected views after enumeration + for (NSString *key in keysToStart) + { + CountlyViewData *viewData = self.viewDataDictionary[key]; + NSString *viewID = [self startViewInternal:viewData.viewName customSegmentation:viewData.startSegmentation isAutoStoppedView:viewData.isAutoStoppedView]; + + // Retrieve and update the newly created viewData + CountlyViewData *viewDataNew = self.viewDataDictionary[viewID]; + viewDataNew.segmentation = viewData.segmentation.mutableCopy; + } + // Remove the entries from the dictionary [self.viewDataDictionary removeObjectsForKeys:keysToRemove]; } + - (void)stopAllViewsInternal:(NSDictionary *)segmentation { // TODO: Should apply all the segmenation operations here at one place instead of doing it for individual view @@ -759,7 +765,7 @@ - (void)applicationWillTerminate { - (void)resetFirstView { - self.isFirstView = NO; + self.isFirstView = YES; } diff --git a/ios/src/CountlyWebViewManager.m b/ios/src/CountlyWebViewManager.m index ace4249f..14a954ff 100644 --- a/ios/src/CountlyWebViewManager.m +++ b/ios/src/CountlyWebViewManager.m @@ -96,16 +96,23 @@ - (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigati if ([url hasPrefix:@"https://countly_action_event"] && [url containsString:@"cly_x_action_event=1"]) { NSDictionary *queryParameters = [self parseQueryString:url]; NSString *action = queryParameters[@"action"]; - - if ([action isEqualToString:@"event"]) { - NSString *eventsJson = queryParameters[@"event"]; - [self recordEventsWithJSONString:eventsJson]; - } else if ([action isEqualToString:@"link"]) { - NSString *link = queryParameters[@"link"]; - [self openExternalLink:link]; - } else if ([action isEqualToString:@"resize_me"]) { - NSString *resize = queryParameters[@"resize_me"]; - [self resizeWebViewWithJSONString:resize]; + if(action) { + if ([action isEqualToString:@"event"]) { + NSString *eventsJson = queryParameters[@"event"]; + if(eventsJson) { + [self recordEventsWithJSONString:eventsJson]; + } + } else if ([action isEqualToString:@"link"]) { + NSString *link = queryParameters[@"link"]; + if(link) { + [self openExternalLink:link]; + } + } else if ([action isEqualToString:@"resize_me"]) { + NSString *resize = queryParameters[@"resize_me"]; + if(resize) { + [self resizeWebViewWithJSONString:resize]; + } + } } if ([queryParameters[@"close"] boolValue]) { @@ -182,12 +189,34 @@ - (NSDictionary *)parseQueryString:(NSString *)url { } - (void)recordEventsWithJSONString:(NSString *)jsonString { - NSData *data = [jsonString dataUsingEncoding:NSUTF8StringEncoding]; - NSArray *events = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + // Decode the URL-encoded JSON string + NSString *decodedString = [jsonString stringByRemovingPercentEncoding]; + + // Convert the decoded string to NSData + NSData *data = [decodedString dataUsingEncoding:NSUTF8StringEncoding]; + + // Parse the JSON data + NSError *error = nil; + NSArray *events = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; + + if (error) { + NSLog(@"Error parsing JSON: %@", error); + } else { + NSLog(@"Parsed JSON: %@", events); + } + for (NSDictionary *event in events) { NSString *key = event[@"key"]; - NSDictionary *segmentation = event[@"sg"]; + NSDictionary *segmentation = event[@"segmentation"]; + NSDictionary *sg = event[@"sg"]; + if(!key) { + CLY_LOG_I(@"Skipping the event due to key is empty or nil"); + continue; + } + if(sg) { + segmentation = sg; + } if(!segmentation) { CLY_LOG_I(@"Skipping the event due to missing segmentation"); continue; @@ -211,22 +240,55 @@ - (void)openExternalLink:(NSString *)urlString { } - (void)resizeWebViewWithJSONString:(NSString *)jsonString { - NSData *data = [jsonString dataUsingEncoding:NSUTF8StringEncoding]; - NSDictionary *resizeDict = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + // Decode the URL-encoded JSON string + NSString *decodedString = [jsonString stringByRemovingPercentEncoding]; + + // Convert the decoded string to NSData + NSData *data = [decodedString dataUsingEncoding:NSUTF8StringEncoding]; + + // Parse the JSON data + NSError *error = nil; + NSDictionary *resizeDict = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; + + if (!resizeDict) { + CLY_LOG_I(@"Resize dictionary should not be empty or nil. Error: %@", error); + return; + } + + // Ensure resizeDict is a dictionary + if (![resizeDict isKindOfClass:[NSDictionary class]]) { + CLY_LOG_I(@"Resize dictionary should be of type NSDictionary"); + return; + } + + // Retrieve portrait and landscape dimensions NSDictionary *portraitDimensions = resizeDict[@"p"]; NSDictionary *landscapeDimensions = resizeDict[@"l"]; + if (!portraitDimensions && !landscapeDimensions) { + CLY_LOG_I(@"Resize dimensions should not be empty or nil"); + return; + } + + // Determine the current orientation UIInterfaceOrientation orientation = [UIApplication sharedApplication].statusBarOrientation; BOOL isLandscape = UIInterfaceOrientationIsLandscape(orientation); + // Select the appropriate dimensions based on orientation NSDictionary *dimensions = isLandscape ? landscapeDimensions : portraitDimensions; + // Get the dimension values + CGFloat x = [dimensions[@"x"] floatValue]; + CGFloat y = [dimensions[@"y"] floatValue]; CGFloat width = [dimensions[@"w"] floatValue]; CGFloat height = [dimensions[@"h"] floatValue]; + // Animate the resizing of the web view [UIView animateWithDuration:0.3 animations:^{ CGRect frame = self.backgroundView.webView.frame; + frame.origin.x = x; + frame.origin.y = y; frame.size.width = width; frame.size.height = height; self.backgroundView.webView.frame = frame; @@ -236,6 +298,7 @@ - (void)resizeWebViewWithJSONString:(NSString *)jsonString { } + - (void)closeWebView { dispatch_async(dispatch_get_main_queue(), ^{ if (self.dismissBlock) { diff --git a/lib/configuration_interfaces/countly_config_experimental.js b/lib/configuration_interfaces/countly_config_experimental.js new file mode 100644 index 00000000..a1442d64 --- /dev/null +++ b/lib/configuration_interfaces/countly_config_experimental.js @@ -0,0 +1,39 @@ +/** + * + * This class holds experimental configurations to be used with CountlyConfig + * class and serves as an interface. + */ +class CountlyConfigExp { + constructor() { + this._enablePreviousNameRecording = false; + this._enableVisibilityTracking = false; + } + + get enablePreviousNameRecording() { + return this._enablePreviousNameRecording; + } + + get enableVisibilityTracking() { + return this._enableVisibilityTracking; + } + + /** + * Enables the reporting of previous view/event names. + * @returns CountlyConfigExp + */ + enablePreviousNameRecording() { + this._enablePreviousNameRecording = true; + return this; + } + + /** + * Enables the tracking of app visibility with events. + * @returns CountlyConfigExp + */ + enableVisibilityTracking() { + this._enableVisibilityTracking = true; + return this; + } +} + +export default CountlyConfigExp; diff --git a/package.json b/package.json index a0469c9c..706e3e19 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "countly-sdk-react-native-bridge", - "version": "24.4.1", + "version": "25.1.0", "author": "Countly (https://count.ly/)", "bugs": { "url": "https://github.com/Countly/countly-sdk-react-native-bridge/issues" From 0f6590886ad15c2b3e3e4fd8c2dbde6af07e68aa Mon Sep 17 00:00:00 2001 From: turtledreams <62231246+turtledreams@users.noreply.github.com> Date: Mon, 20 Jan 2025 15:29:02 +0900 Subject: [PATCH 26/34] config --- example/CountlyRNExample/Configuration.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/example/CountlyRNExample/Configuration.tsx b/example/CountlyRNExample/Configuration.tsx index 38231af7..01450d67 100644 --- a/example/CountlyRNExample/Configuration.tsx +++ b/example/CountlyRNExample/Configuration.tsx @@ -15,7 +15,6 @@ const countlyConfig = new CountlyConfig(COUNTLY_SERVER_KEY, COUNTLY_APP_KEY).set // .giveConsent(['location', 'sessions', 'attribution', 'push', 'events', 'views', 'crashes', 'users', 'push', 'star-rating', 'apm', 'feedback', 'remote-config']) // give consent for specific features before init. // .setLocation('TR', 'Istanbul', '41.0082,28.9784', '10.2.33.12') // Set user initial location. // .enableParameterTamperingProtection('salt') // Set the optional salt to be used for calculating the checksum of requested data which will be sent with each request -// .pinnedCertificates("count.ly.cer") // It will ensure that connection is made with one of the public keys specified // .setHttpPostForced(false) // Set to "true" if you want HTTP POST to be used for all requests // .pushTokenType(Countly.messagingMode.DEVELOPMENT, 'ChannelName', 'ChannelDescription') // Set messaging mode for push notifications // .configureIntentRedirectionCheck(['MainActivity'], ['com.countly.demo']) @@ -39,4 +38,9 @@ const countlyConfig = new CountlyConfig(COUNTLY_SERVER_KEY, COUNTLY_APP_KEY).set // .setMaxStackTraceLineLength() // .setMaxStackTraceLinesPerThread(); +// Countly Experimental features ============================== +// countlyConfig.experimental +// .enablePreviousNameRecording() +// .enableVisibilityTracking(); + export default countlyConfig; From 9cd0f75bfef762e971164d254dc9e736cfc8b365 Mon Sep 17 00:00:00 2001 From: turtledreams <62231246+turtledreams@users.noreply.github.com> Date: Mon, 3 Feb 2025 20:11:27 +0900 Subject: [PATCH 27/34] fixes --- CHANGELOG.md | 5 ++ Countly.d.ts | 19 +++++ Countly.js | 11 +++ CountlyConfig.js | 9 +++ Utils.js | 8 ++ android/build.gradle | 2 +- .../android/sdk/react/CountlyReactNative.java | 23 ++++++ example/CountlyRNExample/Configuration.tsx | 5 ++ ios/src/CHANGELOG.md | 7 ++ ios/src/Countly-PL.podspec | 2 +- ios/src/Countly.m | 3 + ios/src/Countly.podspec | 2 +- ios/src/Countly.xcodeproj/project.pbxproj | 4 +- ios/src/CountlyCommon.m | 2 +- ios/src/CountlyContentBuilderInternal.h | 2 +- ios/src/CountlyContentBuilderInternal.m | 61 ++++++++++----- ios/src/CountlyContentConfig.h | 14 ++++ ios/src/CountlyContentConfig.m | 14 ++++ ios/src/CountlyCrashReporter.m | 4 +- ios/src/CountlyWebViewManager.m | 11 --- ios/src/PassThroughBackgroundView.m | 78 ++++++++++++++++++- .../countly_config_content.js | 43 ++++++++++ 22 files changed, 286 insertions(+), 43 deletions(-) create mode 100644 lib/configuration_interfaces/countly_config_content.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 83c7334e..06f3f6a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 25.1.1 + +* Updated the underlying Android SDK version to 25.1.1 +* Updated the underlying iOS SDK version to 25.1.0 + ## 25.1.0 * ! Minor breaking change ! `Countly.userDataBulk.save()` method is now optional. SDK will save the cached data with internal triggers regularly. diff --git a/Countly.d.ts b/Countly.d.ts index e6bd1677..2608a90a 100644 --- a/Countly.d.ts +++ b/Countly.d.ts @@ -1180,6 +1180,20 @@ declare module "countly-sdk-react-native-bridge/CountlyConfig" { enableVisibilityTracking(): this; } + interface content { + /** + * + * @param zoneTimerInterval - the interval in seconds to check for new content + */ + setZoneTimerInterval(zoneTimerInterval: number): this; + + /** + * + * @param callback - callback to be called when new content is available + */ + setGlobalContentCallback(callback: Function): this; + } + /** * * This class holds APM specific configurations to be used with @@ -1277,6 +1291,11 @@ declare module "countly-sdk-react-native-bridge/CountlyConfig" { */ experimental: experimental; + /** + * getter for content features + */ + content: content; + /** * Method to set the server url * diff --git a/Countly.js b/Countly.js index a3072885..afc83ae2 100644 --- a/Countly.js +++ b/Countly.js @@ -91,6 +91,17 @@ Countly.initWithConfig = async function (countlyConfig) { return; } L.d("initWithConfig, Initializing Countly"); + if (countlyConfig.content.contentCallback) { + eventEmitter.addListener("globalContentCallback", (data) => { + L.d(`init configuration, Global content callback called with data: ${data}`); + try { + data = JSON.parse(data); + countlyConfig.content.contentCallback(data.status, data.data); + } catch (error) { + L.e(`init configuration, Error parsing global content callback data: ${error}`); + } + }); + } const args = []; const argsMap = Utils.configToJson(countlyConfig); const argsString = JSON.stringify(argsMap); diff --git a/CountlyConfig.js b/CountlyConfig.js index 4f247b36..dda43b25 100644 --- a/CountlyConfig.js +++ b/CountlyConfig.js @@ -2,6 +2,7 @@ import { initialize } from "./Logger.js"; import CountlyConfigApm from "./lib/configuration_interfaces/countly_config_apm.js"; import CountlyConfigSDKInternalLimits from "./lib/configuration_interfaces/countly_config_limits.js"; import CountlyConfigExp from "./lib/configuration_interfaces/countly_config_experimental.js"; +import CountlyConfigContent from "./lib/configuration_interfaces/countly_config_content.js"; /** * Countly SDK React Native Bridge * https://github.com/Countly/countly-sdk-react-native-bridge @@ -25,6 +26,7 @@ class CountlyConfig { this._countlyConfigApmInstance = new CountlyConfigApm(); this._countlyConfigSDKLimitsInstance = new CountlyConfigSDKInternalLimits(); this._countlyConfigExpInstance = new CountlyConfigExp(); + this._countlyConfigContentInstance = new CountlyConfigContent(); } /** @@ -48,6 +50,13 @@ class CountlyConfig { return this._countlyConfigExpInstance; } + /** + * Getter to get the content specific configurations + */ + get content() { + return this._countlyConfigContentInstance; + } + get _crashReporting() { return this.#crashReporting; } diff --git a/Utils.js b/Utils.js index fffdfb97..4c41c8da 100644 --- a/Utils.js +++ b/Utils.js @@ -117,6 +117,14 @@ function configToJson(config) { json.enableVisibilityTracking = true; L.i(`init configuration, Enabled visibility tracking`) } + if (config.content.timerInterval) { + json.setZoneTimerInterval = config.content.timerInterval; + L.i(`init configuration, Set zone timer interval to ${config.content.timerInterval}`) + } + if (config.content.contentCallback) { + json.setGlobalContentCallback = true; + L.i(`init configuration, Set global content callback`) + } if (config._disableIntentRedirectionCheck) { json.disableAdditionalIntentRedirectionChecks = true; L.i(`init configuration, Disabled additional intent redirection checks`) diff --git a/android/build.gradle b/android/build.gradle index 22eeb26c..6816c6a4 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -41,7 +41,7 @@ repositories { dependencies { implementation "com.facebook.react:react-native:${safeExtGet('reactNativeVersion', '+')}" - implementation 'ly.count.android:sdk:24.7.8' + implementation 'ly.count.android:sdk:25.1.1' // Import the BoM for the Firebase platform // The BoM version of 28.4.2 is the newest release that will target firebase-messaging version 22 diff --git a/android/src/main/java/ly/count/android/sdk/react/CountlyReactNative.java b/android/src/main/java/ly/count/android/sdk/react/CountlyReactNative.java index b31e56a6..57f9663c 100644 --- a/android/src/main/java/ly/count/android/sdk/react/CountlyReactNative.java +++ b/android/src/main/java/ly/count/android/sdk/react/CountlyReactNative.java @@ -27,6 +27,8 @@ import ly.count.android.sdk.RCDownloadCallback; import ly.count.android.sdk.RemoteConfigCallback; import ly.count.android.sdk.FeedbackRatingCallback; +import ly.count.android.sdk.ContentCallback; +import ly.count.android.sdk.ContentStatus; import java.io.BufferedReader; import java.io.IOException; @@ -242,6 +244,27 @@ private void populateConfig(JSONObject _config) { if (_config.has("enableVisibilityTracking")) { config.experimental.enableVisibilityTracking(); } + if (_config.has("setZoneTimerInterval")) { + config.content.setZoneTimerInterval(_config.getInt("setZoneTimerInterval")); + } + if (_config.has("setGlobalContentCallback")) { + config.content.setGlobalContentCallback(new ContentCallback() { + @Override + public void onContentCallback(ContentStatus contentStatus,Map contentData) { + JSONObject contentMap = new JSONObject(); + try { + contentMap.put("status", contentStatus.toString()); + contentMap.put("data", new JSONObject(contentData)); + } catch (JSONException e) { + log("onContentCallback, JSON exception: ", e, LogLevel.ERROR); + } + + ((ReactApplicationContext) _reactContext) + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) + .emit("globalContentCallback", contentMap.toString()); + } + }); + } // Limits ----------------------------------------------- if(_config.has("maxKeyLength")) { config.sdkInternalLimits.setMaxKeyLength(_config.getInt("maxKeyLength")); diff --git a/example/CountlyRNExample/Configuration.tsx b/example/CountlyRNExample/Configuration.tsx index 01450d67..421c70ee 100644 --- a/example/CountlyRNExample/Configuration.tsx +++ b/example/CountlyRNExample/Configuration.tsx @@ -43,4 +43,9 @@ const countlyConfig = new CountlyConfig(COUNTLY_SERVER_KEY, COUNTLY_APP_KEY).set // .enablePreviousNameRecording() // .enableVisibilityTracking(); +// countlyConfig.content.setZoneTimerInterval(120); +// countlyConfig.content.setGlobalContentCallback((status: string, data: object) => { +// console.log("Global content callback", status, data); +// }); + export default countlyConfig; diff --git a/ios/src/CHANGELOG.md b/ios/src/CHANGELOG.md index ec54172d..e5ec6216 100644 --- a/ios/src/CHANGELOG.md +++ b/ios/src/CHANGELOG.md @@ -1,3 +1,10 @@ +## 25.1.0 +* Added dynamic resizing functionality for the content zone +* Added a config option to content (setZoneTimerInterval) to set content zone timer. (Experimental!) + +* Improved management of content zone size for better responsiveness +* Fixed an issue where the build UUID and executable name were missing from crash reports + ## 24.7.9 * Improved view tracking capabilities diff --git a/ios/src/Countly-PL.podspec b/ios/src/Countly-PL.podspec index 2d85adbd..efb75925 100644 --- a/ios/src/Countly-PL.podspec +++ b/ios/src/Countly-PL.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'Countly-PL' - s.version = '24.7.9' + s.version = '25.1.0' s.license = { :type => 'MIT', :file => 'LICENSE' } s.summary = 'Countly is an innovative, real-time, open source mobile analytics platform.' s.homepage = 'https://github.com/Countly/countly-sdk-ios' diff --git a/ios/src/Countly.m b/ios/src/Countly.m index 6c51a817..a41cf2a4 100644 --- a/ios/src/Countly.m +++ b/ios/src/Countly.m @@ -275,6 +275,9 @@ - (void)startWithConfig:(CountlyConfig *)config if(config.content.getGlobalContentCallback) { CountlyContentBuilderInternal.sharedInstance.contentCallback = config.content.getGlobalContentCallback; } + if(config.content.getZoneTimerInterval){ + CountlyContentBuilderInternal.sharedInstance.zoneTimerInterval = config.content.getZoneTimerInterval; + } #endif [CountlyPerformanceMonitoring.sharedInstance startWithConfig:config.apm]; diff --git a/ios/src/Countly.podspec b/ios/src/Countly.podspec index 41dd3402..5a4d58d7 100644 --- a/ios/src/Countly.podspec +++ b/ios/src/Countly.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'Countly' - s.version = '24.7.9' + s.version = '25.1.0' s.license = { :type => 'MIT', :file => 'LICENSE' } s.summary = 'Countly is an innovative, real-time, open source mobile analytics platform.' s.homepage = 'https://github.com/Countly/countly-sdk-ios' diff --git a/ios/src/Countly.xcodeproj/project.pbxproj b/ios/src/Countly.xcodeproj/project.pbxproj index 071f99d1..8fa635bc 100644 --- a/ios/src/Countly.xcodeproj/project.pbxproj +++ b/ios/src/Countly.xcodeproj/project.pbxproj @@ -738,7 +738,7 @@ "@loader_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.14; - MARKETING_VERSION = 24.7.9; + MARKETING_VERSION = 25.1.0; PRODUCT_BUNDLE_IDENTIFIER = ly.count.CountlyiOSSDK; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -770,7 +770,7 @@ "@loader_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.14; - MARKETING_VERSION = 24.7.9; + MARKETING_VERSION = 25.1.0; PRODUCT_BUNDLE_IDENTIFIER = ly.count.CountlyiOSSDK; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/ios/src/CountlyCommon.m b/ios/src/CountlyCommon.m index 000094f4..1cdd9fd4 100644 --- a/ios/src/CountlyCommon.m +++ b/ios/src/CountlyCommon.m @@ -29,7 +29,7 @@ @interface CountlyCommon () #endif @end -NSString* const kCountlySDKVersion = @"24.7.9"; +NSString* const kCountlySDKVersion = @"25.1.0"; NSString* const kCountlySDKName = @"objc-native-ios"; NSString* const kCountlyErrorDomain = @"ly.count.ErrorDomain"; diff --git a/ios/src/CountlyContentBuilderInternal.h b/ios/src/CountlyContentBuilderInternal.h index be18530d..696d688a 100644 --- a/ios/src/CountlyContentBuilderInternal.h +++ b/ios/src/CountlyContentBuilderInternal.h @@ -13,7 +13,7 @@ NS_ASSUME_NONNULL_BEGIN @interface CountlyContentBuilderInternal: NSObject #if (TARGET_OS_IOS) @property (nonatomic, strong) NSArray *currentTags; -@property (nonatomic, assign) NSTimeInterval requestInterval; +@property (nonatomic, assign) NSTimeInterval zoneTimerInterval; @property (nonatomic) ContentCallback contentCallback; + (instancetype)sharedInstance; diff --git a/ios/src/CountlyContentBuilderInternal.m b/ios/src/CountlyContentBuilderInternal.m index 196edb8a..47cac04c 100644 --- a/ios/src/CountlyContentBuilderInternal.m +++ b/ios/src/CountlyContentBuilderInternal.m @@ -29,7 +29,7 @@ - (instancetype)init { if (self = [super init]) { - self.requestInterval = 30.0; + self.zoneTimerInterval = 30.0; _requestTimer = nil; } @@ -55,7 +55,7 @@ - (void)enterContentZone:(NSArray *)tags { self.currentTags = tags; [self fetchContents];; - _requestTimer = [NSTimer scheduledTimerWithTimeInterval:self.requestInterval + _requestTimer = [NSTimer scheduledTimerWithTimeInterval:self.zoneTimerInterval target:self selector:@selector(fetchContents) userInfo:nil @@ -151,30 +151,49 @@ - (NSURLRequest *)fetchContentsRequest return request; } -- (NSString *)resolutionJson { - //TODO: check why area is not clickable and safearea things - CGRect screenBounds = [UIScreen mainScreen].bounds; - if (@available(iOS 11.0, *)) { - CGFloat top = UIApplication.sharedApplication.keyWindow.safeAreaInsets.top; - - if (top) { - screenBounds.origin.y += top + 5; - screenBounds.size.height -= top + 5; - } else { - screenBounds.origin.y += 20.0; - screenBounds.size.height -= 20.0; +- (CGSize)getWindowSize { + CGSize size = CGSizeZero; + + // Attempt to retrieve the size from the connected scenes (for modern apps) + if (@available(iOS 13.0, *)) { + NSSet *scenes = [[UIApplication sharedApplication] connectedScenes]; + for (UIScene *scene in scenes) { + if ([scene isKindOfClass:[UIWindowScene class]]) { + UIWindowScene *windowScene = (UIWindowScene *)scene; + UIWindow *window = windowScene.windows.firstObject; + if (window) { + size = window.bounds.size; + return size; // Return immediately if we find a valid size + } + } + } + } + + // Fallback for legacy apps using AppDelegate + id appDelegate = [[UIApplication sharedApplication] delegate]; + if ([appDelegate respondsToSelector:@selector(window)]) { + UIWindow *legacyWindow = [appDelegate performSelector:@selector(window)]; + if (legacyWindow) { + size = legacyWindow.bounds.size; } - } else { - screenBounds.origin.y += 20.0; - screenBounds.size.height -= 20.0; } + + return size; +} + +- (NSString *)resolutionJson { + //TODO: check why area is not clickable and safearea things + CGSize size = [self getWindowSize]; - CGFloat width = screenBounds.size.width; - CGFloat height = screenBounds.size.height; + UIInterfaceOrientation orientation = [UIApplication sharedApplication].statusBarOrientation; + BOOL isLandscape = UIInterfaceOrientationIsLandscape(orientation); + + CGFloat lHpW = isLandscape ? size.height : size.width; + CGFloat lWpH = isLandscape ? size.width : size.height; NSDictionary *resolutionDict = @{ - @"portrait": @{@"height": @(height), @"width": @(width)}, - @"landscape": @{@"height": @(width), @"width": @(height)} + @"portrait": @{@"height": @(lWpH), @"width": @(lHpW)}, + @"landscape": @{@"height": @(lHpW), @"width": @(lWpH)} }; NSData *jsonData = [NSJSONSerialization dataWithJSONObject:resolutionDict options:0 error:nil]; diff --git a/ios/src/CountlyContentConfig.h b/ios/src/CountlyContentConfig.h index d72fac0c..ed17787b 100644 --- a/ios/src/CountlyContentConfig.h +++ b/ios/src/CountlyContentConfig.h @@ -32,6 +32,20 @@ typedef void (^ContentCallback)(ContentStatus contentStatus, NSDictionary 15) { + self.zoneTimerInterval = zoneTimerIntervalSeconds; + } +} + +- (NSUInteger) getZoneTimerInterval +{ + return self.zoneTimerInterval; +} #endif @end diff --git a/ios/src/CountlyCrashReporter.m b/ios/src/CountlyCrashReporter.m index 682bfde1..3b0d2f48 100644 --- a/ios/src/CountlyCrashReporter.m +++ b/ios/src/CountlyCrashReporter.m @@ -288,6 +288,8 @@ void CountlyExceptionHandler(NSException *exception, bool isFatal, bool isAutoDe NSMutableDictionary* crashReport = [crashData.crashMetrics mutableCopy]; crashReport[kCountlyCRKeyError] = crashData.stackTrace; crashReport[kCountlyCRKeyBinaryImages] = [CountlyCrashReporter.sharedInstance binaryImagesForStackTrace:stackTrace]; + crashReport[kCountlyCRKeyBuildUUID] = CountlyCrashReporter.sharedInstance.buildUUID ?: @""; + crashReport[kCountlyCRKeyExecutableName] = CountlyCrashReporter.sharedInstance.executableName ?: @""; crashReport[kCountlyCRKeyName] = crashData.crashDescription; crashReport[kCountlyCRKeyType] = crashData.name; crashReport[kCountlyCRKeyNonfatal] = @(!crashData.fatal); @@ -498,8 +500,6 @@ - (NSMutableDictionary*)getCrashMetrics crashReport[kCountlyCRKeyResolution] = CountlyDeviceInfo.resolution; crashReport[kCountlyCRKeyAppVersion] = CountlyDeviceInfo.appVersion; crashReport[kCountlyCRKeyAppBuild] = CountlyDeviceInfo.appBuild; - crashReport[kCountlyCRKeyBuildUUID] = CountlyCrashReporter.sharedInstance.buildUUID ?: @""; - crashReport[kCountlyCRKeyExecutableName] = CountlyCrashReporter.sharedInstance.executableName ?: @""; crashReport[kCountlyCRKeyRAMCurrent] = @((CountlyDeviceInfo.totalRAM - CountlyDeviceInfo.freeRAM) / kCLYMebibit); crashReport[kCountlyCRKeyRAMTotal] = @(CountlyDeviceInfo.totalRAM / kCLYMebibit); diff --git a/ios/src/CountlyWebViewManager.m b/ios/src/CountlyWebViewManager.m index 14a954ff..5ce14d88 100644 --- a/ios/src/CountlyWebViewManager.m +++ b/ios/src/CountlyWebViewManager.m @@ -21,15 +21,6 @@ - (void)createWebViewWithURL:(NSURL *)url UIViewController *rootViewController = UIApplication.sharedApplication.keyWindow.rootViewController; CGRect backgroundFrame = rootViewController.view.bounds; - if (@available(iOS 11.0, *)) { - CGFloat top = UIApplication.sharedApplication.keyWindow.safeAreaInsets.top; - backgroundFrame.origin.y += top ? top + 5 : 20.0; - backgroundFrame.size.height -= top ? top + 5 : 20.0; - } else { - backgroundFrame.origin.y += 20.0; - backgroundFrame.size.height -= 20.0; - } - self.backgroundView = [[PassThroughBackgroundView alloc] initWithFrame:backgroundFrame]; self.backgroundView.backgroundColor = [UIColor clearColor]; [rootViewController.view addSubview:self.backgroundView]; @@ -297,8 +288,6 @@ - (void)resizeWebViewWithJSONString:(NSString *)jsonString { }]; } - - - (void)closeWebView { dispatch_async(dispatch_get_main_queue(), ^{ if (self.dismissBlock) { diff --git a/ios/src/PassThroughBackgroundView.m b/ios/src/PassThroughBackgroundView.m index d6bd8328..adb776a9 100644 --- a/ios/src/PassThroughBackgroundView.m +++ b/ios/src/PassThroughBackgroundView.m @@ -14,8 +14,11 @@ @implementation PassThroughBackgroundView - (instancetype)initWithFrame:(CGRect)frame { self = [super initWithFrame:frame]; - if (self) { - } +#if (TARGET_OS_IOS) + [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(handleScreenChange) name:UIDeviceOrientationDidChangeNotification object:nil]; + [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(handleScreenChange) name:UIScreenModeDidChangeNotification object:nil]; + [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(handleScreenChange) name:UIApplicationDidBecomeActiveNotification object:nil]; +#endif return self; } @@ -31,6 +34,77 @@ - (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event { return NO; } +- (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection { + [super traitCollectionDidChange:previousTraitCollection]; + + if (self.traitCollection.horizontalSizeClass != previousTraitCollection.horizontalSizeClass) { + [self adjustWebViewForTraitCollection:self.traitCollection]; + } +} + +- (void)adjustWebViewForTraitCollection:(UITraitCollection *)traitCollection { + if (traitCollection.horizontalSizeClass == UIUserInterfaceSizeClassCompact) { + [self handleScreenChange]; + } +} + +CGSize getWindowSize(void) { + CGSize size = CGSizeZero; + + // Attempt to retrieve the size from the connected scenes (for modern apps) + if (@available(iOS 13.0, *)) { + NSSet *scenes = [[UIApplication sharedApplication] connectedScenes]; + for (UIScene *scene in scenes) { + if ([scene isKindOfClass:[UIWindowScene class]]) { + UIWindowScene *windowScene = (UIWindowScene *)scene; + UIWindow *window = windowScene.windows.firstObject; + if (window) { + size = window.bounds.size; + return size; // Return immediately if we find a valid size + } + } + } + } + + // Fallback for legacy apps using AppDelegate + id appDelegate = [[UIApplication sharedApplication] delegate]; + if ([appDelegate respondsToSelector:@selector(window)]) { + UIWindow *legacyWindow = [appDelegate performSelector:@selector(window)]; + if (legacyWindow) { + size = legacyWindow.bounds.size; + } + } + + return size; +} + +- (void)handleScreenChange { + // Execute after a short delay to ensure properties are updated + dispatch_async(dispatch_get_main_queue(), ^{ + [self updateWindowSize]; + }); +} + +- (void)updateWindowSize { + CGSize size = getWindowSize(); + CGFloat width = size.width; + CGFloat height = size.height; + + NSString *postMessage = [NSString stringWithFormat: + @"javascript:window.postMessage({type: 'resize', width: %f, height: %f}, '*');", + width, + height]; + [self.webView evaluateJavaScript:postMessage completionHandler:^(id result, NSError *err) { + if (err != nil) { + CLY_LOG_E(@"[PassThroughBackgroundView] updateWindowSize, %@", err); + } + }]; +} + +// Always remove observers when the view is deallocated +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} @end #endif diff --git a/lib/configuration_interfaces/countly_config_content.js b/lib/configuration_interfaces/countly_config_content.js new file mode 100644 index 00000000..9279503b --- /dev/null +++ b/lib/configuration_interfaces/countly_config_content.js @@ -0,0 +1,43 @@ +import { i } from "../../Logger"; + +/** + * + * This class holds content feature configurations to be used with CountlyConfig + * class and serves as an interface. + */ +class CountlyConfigContent { + constructor() { + this._intervalLimit = 0; + this._globalContentCallback = null; + } + + get timerInterval() { + return this._intervalLimit; + } + + get contentCallback() { + return this._globalContentCallback; + } + + /** + * Set the interval limit for zone timers. (minimum 15, default 30, seconds) + * @param {Number} interval - interval limit for zone timers + * @returns CountlyConfigContent + */ + setZoneTimerInterval(interval) { + this._intervalLimit = interval; + return this; + } + + /** + * Set the global content callback + * @param {Function} callback - callback function to be called for global content + * @returns CountlyConfigContent + */ + setGlobalContentCallback(callback) { + this._globalContentCallback = callback; + return this; + } +} + +export default CountlyConfigContent; From 5d21e8ec2befe4efa46f89413b6e9220a46f70bd Mon Sep 17 00:00:00 2001 From: turtledreams Date: Wed, 5 Feb 2025 00:43:11 +0900 Subject: [PATCH 28/34] ios --- ios/src/CountlyContentConfig.m | 8 ++++---- ios/src/CountlyReactNative.m | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/ios/src/CountlyContentConfig.m b/ios/src/CountlyContentConfig.m index 1f3c0a3e..71e10c3a 100644 --- a/ios/src/CountlyContentConfig.m +++ b/ios/src/CountlyContentConfig.m @@ -27,25 +27,25 @@ - (instancetype)init #if (TARGET_OS_IOS) -(void)setGlobalContentCallback:(ContentCallback) callback { - self.contentCallback = callback; + _contentCallback = callback; } - (ContentCallback) getGlobalContentCallback { - return self.contentCallback; + return _contentCallback; } -(void)setZoneTimerInterval:(NSUInteger)zoneTimerIntervalSeconds { if (zoneTimerIntervalSeconds > 15) { - self.zoneTimerInterval = zoneTimerIntervalSeconds; + _zoneTimerInterval = zoneTimerIntervalSeconds; } } - (NSUInteger) getZoneTimerInterval { - return self.zoneTimerInterval; + return _zoneTimerInterval; } #endif diff --git a/ios/src/CountlyReactNative.m b/ios/src/CountlyReactNative.m index cd811837..fb9073a8 100644 --- a/ios/src/CountlyReactNative.m +++ b/ios/src/CountlyReactNative.m @@ -174,6 +174,22 @@ - (void) populateConfig:(id) json { [config.sdkInternalLimits setMaxStackTraceLinesPerThread:[maxStackTraceLinesPerThread intValue]]; } // Limits End ------------------------------------------- + NSNumber *setZoneTimerInterval = json[@"setZoneTimerInterval"]; + if (setZoneTimerInterval) { + config.content.zoneTimerInterval = [setZoneTimerInterval intValue]; + } + if(json[@"setGlobalContentCallback"]) { + [config.content setGlobalContentCallback:^(ContentStatus contentStatus, NSDictionary * _Nonnull contentData) { + NSMutableDictionary *contentDataDict = [[NSMutableDictionary alloc] init]; + [contentDataDict setObject:[NSNumber numberWithInt:contentStatus] forKey:@"status"]; + [contentDataDict setObject:contentData forKey:@"data"]; + NSError *error; + NSData *contentDataJson = [NSJSONSerialization dataWithJSONObject:contentDataDict options:0 error:&error]; + NSString *contentDataString = [[NSString alloc] initWithData:contentDataJson encoding:NSUTF8StringEncoding]; + + [self sendEventWithName:@"globalContentCallback" body:contentDataString]; + }]; + } // APM ------------------------------------------------ NSNumber *enableForegroundBackground = json[@"enableForegroundBackground"]; if (enableForegroundBackground) { From 7293b06726b79348d1ee5bc03aa7eadb4698bee9 Mon Sep 17 00:00:00 2001 From: turtledreams <62231246+turtledreams@users.noreply.github.com> Date: Wed, 5 Feb 2025 00:46:25 +0900 Subject: [PATCH 29/34] ios 25.1.1 --- ios/src/CHANGELOG.md | 3 +++ ios/src/Countly-PL.podspec | 2 +- ios/src/Countly.podspec | 2 +- ios/src/Countly.xcodeproj/project.pbxproj | 4 ++-- ios/src/CountlyCommon.m | 2 +- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/ios/src/CHANGELOG.md b/ios/src/CHANGELOG.md index e5ec6216..8f7df5e5 100644 --- a/ios/src/CHANGELOG.md +++ b/ios/src/CHANGELOG.md @@ -1,3 +1,6 @@ +## 25.1.1 +* Mitigated an issue while setting zone timer interval for content. + ## 25.1.0 * Added dynamic resizing functionality for the content zone * Added a config option to content (setZoneTimerInterval) to set content zone timer. (Experimental!) diff --git a/ios/src/Countly-PL.podspec b/ios/src/Countly-PL.podspec index efb75925..78d58679 100644 --- a/ios/src/Countly-PL.podspec +++ b/ios/src/Countly-PL.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'Countly-PL' - s.version = '25.1.0' + s.version = '25.1.1' s.license = { :type => 'MIT', :file => 'LICENSE' } s.summary = 'Countly is an innovative, real-time, open source mobile analytics platform.' s.homepage = 'https://github.com/Countly/countly-sdk-ios' diff --git a/ios/src/Countly.podspec b/ios/src/Countly.podspec index 5a4d58d7..95740084 100644 --- a/ios/src/Countly.podspec +++ b/ios/src/Countly.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'Countly' - s.version = '25.1.0' + s.version = '25.1.1' s.license = { :type => 'MIT', :file => 'LICENSE' } s.summary = 'Countly is an innovative, real-time, open source mobile analytics platform.' s.homepage = 'https://github.com/Countly/countly-sdk-ios' diff --git a/ios/src/Countly.xcodeproj/project.pbxproj b/ios/src/Countly.xcodeproj/project.pbxproj index 8fa635bc..7d735fd6 100644 --- a/ios/src/Countly.xcodeproj/project.pbxproj +++ b/ios/src/Countly.xcodeproj/project.pbxproj @@ -738,7 +738,7 @@ "@loader_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.14; - MARKETING_VERSION = 25.1.0; + MARKETING_VERSION = 25.1.1; PRODUCT_BUNDLE_IDENTIFIER = ly.count.CountlyiOSSDK; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -770,7 +770,7 @@ "@loader_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.14; - MARKETING_VERSION = 25.1.0; + MARKETING_VERSION = 25.1.1; PRODUCT_BUNDLE_IDENTIFIER = ly.count.CountlyiOSSDK; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/ios/src/CountlyCommon.m b/ios/src/CountlyCommon.m index 1cdd9fd4..71597b10 100644 --- a/ios/src/CountlyCommon.m +++ b/ios/src/CountlyCommon.m @@ -29,7 +29,7 @@ @interface CountlyCommon () #endif @end -NSString* const kCountlySDKVersion = @"25.1.0"; +NSString* const kCountlySDKVersion = @"25.1.1"; NSString* const kCountlySDKName = @"objc-native-ios"; NSString* const kCountlyErrorDomain = @"ly.count.ErrorDomain"; From e9c5248c79939ff1f3a63a1c40b09225f203691b Mon Sep 17 00:00:00 2001 From: turtledreams <62231246+turtledreams@users.noreply.github.com> Date: Wed, 5 Feb 2025 00:48:12 +0900 Subject: [PATCH 30/34] content --- ios/src/CountlyReactNative.m | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ios/src/CountlyReactNative.m b/ios/src/CountlyReactNative.m index fb9073a8..87a35e9f 100644 --- a/ios/src/CountlyReactNative.m +++ b/ios/src/CountlyReactNative.m @@ -174,9 +174,9 @@ - (void) populateConfig:(id) json { [config.sdkInternalLimits setMaxStackTraceLinesPerThread:[maxStackTraceLinesPerThread intValue]]; } // Limits End ------------------------------------------- - NSNumber *setZoneTimerInterval = json[@"setZoneTimerInterval"]; - if (setZoneTimerInterval) { - config.content.zoneTimerInterval = [setZoneTimerInterval intValue]; + NSNumber *timerInt = json[@"setZoneTimerInterval"]; + if (timerInt) { + [config.content setZoneTimerInterval:[timerInt intValue]]; } if(json[@"setGlobalContentCallback"]) { [config.content setGlobalContentCallback:^(ContentStatus contentStatus, NSDictionary * _Nonnull contentData) { From 25b002911450d9b891c22d8aaef09d11153c9c02 Mon Sep 17 00:00:00 2001 From: turtledreams <62231246+turtledreams@users.noreply.github.com> Date: Wed, 5 Feb 2025 00:48:48 +0900 Subject: [PATCH 31/34] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06f3f6a0..b0dce2a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ ## 25.1.1 * Updated the underlying Android SDK version to 25.1.1 -* Updated the underlying iOS SDK version to 25.1.0 +* Updated the underlying iOS SDK version to 25.1.1 ## 25.1.0 * ! Minor breaking change ! `Countly.userDataBulk.save()` method is now optional. SDK will save the cached data with internal triggers regularly. From 4d4574aefda0de7462208d6bb51f81599ed297a2 Mon Sep 17 00:00:00 2001 From: turtledreams Date: Wed, 5 Feb 2025 01:04:38 +0900 Subject: [PATCH 32/34] ios content name --- ios/src/CountlyReactNative.m | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ios/src/CountlyReactNative.m b/ios/src/CountlyReactNative.m index 87a35e9f..3d4e00b5 100644 --- a/ios/src/CountlyReactNative.m +++ b/ios/src/CountlyReactNative.m @@ -49,6 +49,7 @@ + (CountlyFeedbackWidget *)createWithDictionary:(NSDictionary *)dictionary; NSString *const widgetClosedCallbackName = @"widgetClosedCallback"; NSString *const ratingWidgetCallbackName = @"ratingWidgetCallback"; NSString *const pushNotificationCallbackName = @"pushNotificationCallback"; +NSString *const contentCallbackName = @"globalContentCallback"; @implementation CountlyReactNative NSString *const kCountlyNotificationPersistencyKey = @"kCountlyNotificationPersistencyKey"; @@ -70,7 +71,7 @@ + (BOOL)requiresMainQueueSetup } - (NSArray *)supportedEvents { - return @[ pushNotificationCallbackName, ratingWidgetCallbackName, widgetShownCallbackName, widgetClosedCallbackName ]; + return @[ pushNotificationCallbackName, ratingWidgetCallbackName, widgetShownCallbackName, widgetClosedCallbackName, contentCallbackName ]; } RCT_EXPORT_MODULE(); @@ -187,7 +188,7 @@ - (void) populateConfig:(id) json { NSData *contentDataJson = [NSJSONSerialization dataWithJSONObject:contentDataDict options:0 error:&error]; NSString *contentDataString = [[NSString alloc] initWithData:contentDataJson encoding:NSUTF8StringEncoding]; - [self sendEventWithName:@"globalContentCallback" body:contentDataString]; + [self sendEventWithName:contentCallbackName body:contentDataString]; }]; } // APM ------------------------------------------------ From 11f5912e61e2b2ac803d21a58b80463442b6f040 Mon Sep 17 00:00:00 2001 From: turtledreams <62231246+turtledreams@users.noreply.github.com> Date: Wed, 5 Feb 2025 23:12:17 +0900 Subject: [PATCH 33/34] Changelog --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0dce2a4..fece5b5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,17 @@ ## 25.1.1 +* Improved content size management of content blocks. +* Added init time config options: + * `.content.setZoneTimerInterval` to set the frequency of content update calls in seconds. + * `.content.setGlobalContentCallback` to provide a callback that is called when a content is closed. + +* Android Specific Changes: + * Improved the custom CertificateTrustManager to handle domain-specific configurations by supporting hostname-aware checkServerTrusted calls. + * Mitigated an issue where after closing a content, they were not being fetched again. + * Mitigated an issue where, the action bar was overlapping with the content display. + +* iOS Specific Changes: + * Added dynamic resizing functionality for the content zone + * Fixed an issue where the build UUID and executable name were missing from crash reports * Updated the underlying Android SDK version to 25.1.1 * Updated the underlying iOS SDK version to 25.1.1 From 0ab4e98e1cb504841329926c9f3d8c2dc94bc762 Mon Sep 17 00:00:00 2001 From: turtledreams <62231246+turtledreams@users.noreply.github.com> Date: Wed, 5 Feb 2025 23:23:31 +0900 Subject: [PATCH 34/34] Version update 5.1.1 --- CountlyReactNative.podspec | 2 +- .../java/ly/count/android/sdk/react/CountlyReactNative.java | 2 +- ios/src/CountlyReactNative.m | 2 +- package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CountlyReactNative.podspec b/CountlyReactNative.podspec index b58808b6..0e94415a 100644 --- a/CountlyReactNative.podspec +++ b/CountlyReactNative.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'CountlyReactNative' - s.version = '25.1.0' + s.version = '25.1.1' s.license = { :type => 'COMMUNITY', :text => <<-LICENSE diff --git a/android/src/main/java/ly/count/android/sdk/react/CountlyReactNative.java b/android/src/main/java/ly/count/android/sdk/react/CountlyReactNative.java index 57f9663c..0109d4ee 100644 --- a/android/src/main/java/ly/count/android/sdk/react/CountlyReactNative.java +++ b/android/src/main/java/ly/count/android/sdk/react/CountlyReactNative.java @@ -91,7 +91,7 @@ public String toString() { public class CountlyReactNative extends ReactContextBaseJavaModule implements LifecycleEventListener { public static final String TAG = "CountlyRNPlugin"; - private String COUNTLY_RN_SDK_VERSION_STRING = "25.1.0"; + private String COUNTLY_RN_SDK_VERSION_STRING = "25.1.1"; private String COUNTLY_RN_SDK_NAME = "js-rnb-android"; private static final CountlyConfig config = new CountlyConfig(); diff --git a/ios/src/CountlyReactNative.m b/ios/src/CountlyReactNative.m index 3d4e00b5..f17ffeb5 100644 --- a/ios/src/CountlyReactNative.m +++ b/ios/src/CountlyReactNative.m @@ -24,7 +24,7 @@ @interface CountlyFeedbackWidget () + (CountlyFeedbackWidget *)createWithDictionary:(NSDictionary *)dictionary; @end -NSString *const kCountlyReactNativeSDKVersion = @"25.1.0"; +NSString *const kCountlyReactNativeSDKVersion = @"25.1.1"; NSString *const kCountlyReactNativeSDKName = @"js-rnb-ios"; CLYPushTestMode const CLYPushTestModeProduction = @"CLYPushTestModeProduction"; diff --git a/package.json b/package.json index 706e3e19..3ac82665 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "countly-sdk-react-native-bridge", - "version": "25.1.0", + "version": "25.1.1", "author": "Countly (https://count.ly/)", "bugs": { "url": "https://github.com/Countly/countly-sdk-react-native-bridge/issues"