diff --git a/CHANGELOG.md b/CHANGELOG.md index 3453ab8c..8ba01a52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,25 @@ +## 26.1.2 +* ! Minor breaking change ! Raised the minimum supported deployment target to iOS 12 and tvOS 12 (previously iOS 10 and tvOS 10) for compatibility with Xcode 26 and the iOS 26 SDK. Apps targeting iOS 10/11 or tvOS 10/11 are no longer supported. + +* Added support for building against the iOS 26 SDK with Xcode 26. +* Added a new user properties functions on `CountlyUserDetails`: + * `setProperty:value:` for setting a single predefined or custom user property. + * `setProperties:` for setting multiple predefined and custom properties in one call. +* Added `providedUserProperties` to `CountlyConfig` to set initial user properties that are applied and saved automatically right after `start`. +* Added `setMaxValueSizePicture:` to `CountlySDKLimitsConfig` to control the maximum size of picture URLs and picture paths independently of other value limits (default 4096). +* Updated resolution extraction to accommodate iOS 26 deprecations. + +* Mitigated a race condition in the request queue that could drop or duplicate requests. +* Mitigated an issue where non-queued requests were affected from request timeout settings. +* Mitigated an issue where default SDK behavior settings overrode user-provided SDK limits. +* Mitigated an issue where consent could be sent twice during initialization when enabled via SDK Behavior Settings. +* Mitigated an issue where content fetches and remote config requests could be sent while in temporary device ID mode, creating a `CLYTemporaryDeviceID` user on the server. + +* Deprecated the direct property setters on `CountlyUserDetails`: `name`, `username`, `email`, `organization`, `phone`, `gender`, `pictureURL`, `pictureLocalPath`, `birthYear`, `custom`. Use `setProperty:value:` or `setProperties:` instead. +* Deprecated `set:value:`, `set:numberValue:`, `set:boolValue:` on `CountlyUserDetails`. Use `setProperty:value:` instead. +* Deprecated `unSet:` on `CountlyUserDetails`. Use `setProperty:value:` with an empty string `@""` to clear a property on the server. +* Deprecated the `+ user` class accessor on `Countly`. Use `Countly.sharedInstance.userProfile` instead. + ## 26.1.1 * Added POST method support for contents. * Added robust resource loading checks before displaying content diff --git a/Countly-PL.podspec b/Countly-PL.podspec index 33338d45..7e969545 100644 --- a/Countly-PL.podspec +++ b/Countly-PL.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'Countly-PL' - s.version = '26.1.1' + s.version = '26.1.2' 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/Countly.h b/Countly.h index 8949e263..f35fa19f 100644 --- a/Countly.h +++ b/Countly.h @@ -602,7 +602,7 @@ NS_ASSUME_NONNULL_BEGIN * Returns @c CountlyUserDetails singleton to be used throughout the app. * @return The shared @c CountlyUserDetails object */ -+ (CountlyUserDetails *)user; ++ (CountlyUserDetails *)user DEPRECATED_MSG_ATTRIBUTE("Use -userProfile via Countly.sharedInstance instead (e.g., Countly.sharedInstance.userProfile)."); @@ -705,6 +705,8 @@ NS_ASSUME_NONNULL_BEGIN * @discussion Feedback widget interface for developer to interact with SDK. */ - (CountlyFeedbacks *) feedback; + +- (CountlyUserDetails *) userProfile; #endif diff --git a/Countly.m b/Countly.m index d07ef6fd..06873cd1 100644 --- a/Countly.m +++ b/Countly.m @@ -10,6 +10,7 @@ @interface Countly () { NSTimer* timer; BOOL isSuspended; + CountlyConfig* _startConfig; } @end @@ -101,9 +102,10 @@ - (void)startWithConfig:(CountlyConfig *)config CountlyCommon.sharedInstance.shouldIgnoreTrustCheck = config.shouldIgnoreTrustCheck; CountlyCommon.sharedInstance.loggerDelegate = config.loggerDelegate; CountlyCommon.sharedInstance.internalLogLevel = config.internalLogLevel; - + config = [self checkAndFixInternalLimitsConfig:config]; - + _startConfig = config; + if (config.disableSDKBehaviorSettingsUpdates) { [CountlyServerConfig.sharedInstance disableSDKBehaviourSettings]; } @@ -111,6 +113,7 @@ - (void)startWithConfig:(CountlyConfig *)config CountlyCommon.sharedInstance.maxKeyLength = config.sdkInternalLimits.getMaxKeyLength; CountlyCommon.sharedInstance.maxValueLength = config.sdkInternalLimits.getMaxValueSize; + CountlyCommon.sharedInstance.maxValueLengthPicture = config.sdkInternalLimits.getMaxValueSizePicture; CountlyCommon.sharedInstance.maxSegmentationValues = config.sdkInternalLimits.getMaxSegmentationValues; // For backward compatibility, deprecated values are only set incase new values are not provided using sdkInternalLimits interface @@ -165,8 +168,13 @@ - (void)startWithConfig:(CountlyConfig *)config NSDictionary* customMetricsTruncated = [config.customMetrics cly_truncated:@"Custom metric"]; CountlyDeviceInfo.sharedInstance.customMetrics = [customMetricsTruncated cly_limited:@"Custom metric"]; - - [Countly.user save]; + + if (config.providedUserProperties.count > 0) { + CLY_LOG_I(@"%s applying providedUserProperties at init [%lu]", __FUNCTION__, (unsigned long)config.providedUserProperties.count); + [Countly.sharedInstance.userProfile setProperties:config.providedUserProperties]; + } + + [Countly.sharedInstance.userProfile save]; // If something added related to server config, make sure to check CountlyServerConfig.notifySdkConfigChange [CountlyServerConfig.sharedInstance fetchServerConfig:config]; @@ -326,6 +334,8 @@ - (void)startWithConfig:(CountlyConfig *)config [self recordIndirectAttribution:config.indirectAttribution]; [CountlyHealthTracker.sharedInstance sendHealthCheck]; + + CountlyCommon.sharedInstance.hasFinishedInit = YES; } - (CountlyConfig *) checkAndFixInternalLimitsConfig:(CountlyConfig *)config @@ -731,8 +741,10 @@ - (void)setIDInternal:(NSString *)deviceID onServer:(BOOL)onServer [CountlyConnectionManager.sharedInstance proceedOnQueue]; + [CountlyServerConfig.sharedInstance fetchServerConfig:_startConfig]; + [CountlyRemoteConfigInternal.sharedInstance downloadRemoteConfigAutomatically]; - + [CountlyHealthTracker.sharedInstance sendHealthCheck]; return; @@ -1544,6 +1556,9 @@ - (void)halt:(BOOL) clearStorage [CountlyDeviceInfo.sharedInstance resetInstance]; [CountlyConnectionManager.sharedInstance resetInstance]; [CountlyServerConfig.sharedInstance resetInstance]; +#if (TARGET_OS_IOS) + [CountlyContentBuilderInternal.sharedInstance resetInstance]; +#endif [CountlyUserDetails.sharedInstance clearUserDetails]; [self resetInstance]; [CountlyCommon.sharedInstance resetInstance]; @@ -1611,4 +1626,9 @@ - (CountlyRemoteConfig *) remoteConfig { return CountlyRemoteConfig.sharedInstance; } +- (CountlyUserDetails *) userProfile +{ + return CountlyUserDetails.sharedInstance; +} + @end diff --git a/Countly.podspec b/Countly.podspec index d162fc6b..827d97fa 100644 --- a/Countly.podspec +++ b/Countly.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'Countly' - s.version = '26.1.1' + s.version = '26.1.2' 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' @@ -10,21 +10,23 @@ Pod::Spec.new do |s| s.requires_arc = true s.default_subspecs = 'Core' - s.ios.deployment_target = '10.0' + s.ios.deployment_target = '12.0' s.osx.deployment_target = '10.14' s.watchos.deployment_target = '4.0' - s.tvos.deployment_target = '10.0' + s.tvos.deployment_target = '12.0' + s.visionos.deployment_target = '1.0' 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', "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'] + core.visionos.frameworks = ['Foundation', 'UIKit', 'UserNotifications', 'CoreLocation'] end s.subspec 'NotificationService' do |ns| ns.source_files = 'CountlyNotificationService.{m,h}' - ns.ios.deployment_target = '10.0' + ns.ios.deployment_target = '12.0' ns.ios.frameworks = ['Foundation', 'UserNotifications'] end diff --git a/Countly.xcodeproj/project.pbxproj b/Countly.xcodeproj/project.pbxproj index b7bf6339..7c73122b 100644 --- a/Countly.xcodeproj/project.pbxproj +++ b/Countly.xcodeproj/project.pbxproj @@ -86,6 +86,7 @@ 96095A5F2F20105600FDE933 /* TouchDelegatingView.h in Headers */ = {isa = PBXBuildFile; fileRef = 96095A5E2F20105600FDE933 /* TouchDelegatingView.h */; }; 962485BA2D9E971800FA3C20 /* TestUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 962485B92D9E971400FA3C20 /* TestUtils.swift */; }; 96329DE02D9426F300BFD641 /* CountlyServerConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96329DDF2D9426F300BFD641 /* CountlyServerConfigTests.swift */; }; + ABCDE001000000000000ABCD /* CountlyServerConfigValidationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCDE002000000000000ABCD /* CountlyServerConfigValidationTests.swift */; }; 96329DE22D94299D00BFD641 /* ServerConfigBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96329DE12D94299D00BFD641 /* ServerConfigBuilder.swift */; }; 96329DE42D952F1500BFD641 /* MockURLProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96329DE32D952F1500BFD641 /* MockURLProtocol.swift */; }; 965A2E9C2DDDCDAC00F28F6A /* CountlyHealthTracker.m in Sources */ = {isa = PBXBuildFile; fileRef = 965A2E9B2DDDCDAC00F28F6A /* CountlyHealthTracker.m */; }; @@ -93,6 +94,7 @@ 9673567F2EC60CD400C742D8 /* TestURLProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9673567E2EC60CD400C742D8 /* TestURLProtocol.swift */; }; 968426812BF2302C007B303E /* CountlyConnectionManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 968426802BF2302C007B303E /* CountlyConnectionManagerTests.swift */; }; 96CB10A12F04B3A100D1E2F0 /* CountlyContentBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96CB10A02F04B3A100D1E2F0 /* CountlyContentBuilderTests.swift */; }; + AB10000011112222333344A1 /* CountlyScreenMetricsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB10000011112222333344A0 /* CountlyScreenMetricsTests.swift */; }; 9691B7652F1FA35500A6ADCB /* CountlyOverlayWindow.h in Headers */ = {isa = PBXBuildFile; fileRef = 9691B7642F1FA35500A6ADCB /* CountlyOverlayWindow.h */; }; 9691B7672F1FA35C00A6ADCB /* CountlyOverlayWindow.m in Sources */ = {isa = PBXBuildFile; fileRef = 9691B7662F1FA35C00A6ADCB /* CountlyOverlayWindow.m */; }; 969E5BCE2ECC4D3200AB406A /* CountlyConsentManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 969E5BCD2ECC4D2C00AB406A /* CountlyConsentManagerTests.swift */; }; @@ -210,6 +212,7 @@ 96095A5E2F20105600FDE933 /* TouchDelegatingView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = TouchDelegatingView.h; sourceTree = ""; }; 962485B92D9E971400FA3C20 /* TestUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestUtils.swift; sourceTree = ""; }; 96329DDF2D9426F300BFD641 /* CountlyServerConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyServerConfigTests.swift; sourceTree = ""; }; + ABCDE002000000000000ABCD /* CountlyServerConfigValidationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyServerConfigValidationTests.swift; sourceTree = ""; }; 96329DE12D94299D00BFD641 /* ServerConfigBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfigBuilder.swift; sourceTree = ""; }; 96329DE32D952F1500BFD641 /* MockURLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockURLProtocol.swift; sourceTree = ""; }; 965A2E9A2DDDCDAC00F28F6A /* CountlyHealthTracker.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CountlyHealthTracker.h; sourceTree = ""; }; @@ -218,6 +221,7 @@ 9673567E2EC60CD400C742D8 /* TestURLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestURLProtocol.swift; sourceTree = ""; }; 968426802BF2302C007B303E /* CountlyConnectionManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyConnectionManagerTests.swift; sourceTree = ""; }; 96CB10A02F04B3A100D1E2F0 /* CountlyContentBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyContentBuilderTests.swift; sourceTree = ""; }; + AB10000011112222333344A0 /* CountlyScreenMetricsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyScreenMetricsTests.swift; sourceTree = ""; }; 9691B7642F1FA35500A6ADCB /* CountlyOverlayWindow.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CountlyOverlayWindow.h; sourceTree = ""; }; 9691B7662F1FA35C00A6ADCB /* CountlyOverlayWindow.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CountlyOverlayWindow.m; sourceTree = ""; }; 969E5BCD2ECC4D2C00AB406A /* CountlyConsentManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyConsentManagerTests.swift; sourceTree = ""; }; @@ -268,11 +272,13 @@ 96329DE32D952F1500BFD641 /* MockURLProtocol.swift */, 96329DE12D94299D00BFD641 /* ServerConfigBuilder.swift */, 96329DDF2D9426F300BFD641 /* CountlyServerConfigTests.swift */, + ABCDE002000000000000ABCD /* CountlyServerConfigValidationTests.swift */, 1A5C4C962B35B0850032EE1F /* CountlyTests.swift */, 1A50D7042B3C5AA3009C6938 /* CountlyBaseTestCase.swift */, 1AFD79012B3EF82C00772FBD /* CountlyTests-Bridging-Header.h */, 968426802BF2302C007B303E /* CountlyConnectionManagerTests.swift */, 96CB10A02F04B3A100D1E2F0 /* CountlyContentBuilderTests.swift */, + AB10000011112222333344A0 /* CountlyScreenMetricsTests.swift */, 96E680412BFF89AC0091E105 /* CountlyCrashReporterTests.swift */, EBD1642D826B471A80175BE3 /* CountlyWebViewManagerTests.swift */, 265156CB5F59417EA14BAC2F /* CountlyWebViewManager+Tests.h */, @@ -555,8 +561,10 @@ 3979E47D2C0760E900FA1CA4 /* CountlyUserProfileTests.swift in Sources */, 968426812BF2302C007B303E /* CountlyConnectionManagerTests.swift in Sources */, 96CB10A12F04B3A100D1E2F0 /* CountlyContentBuilderTests.swift in Sources */, + AB10000011112222333344A1 /* CountlyScreenMetricsTests.swift in Sources */, 96329DE42D952F1500BFD641 /* MockURLProtocol.swift in Sources */, 96329DE02D9426F300BFD641 /* CountlyServerConfigTests.swift in Sources */, + ABCDE001000000000000ABCD /* CountlyServerConfigValidationTests.swift in Sources */, 3966DBCF2C11EE270002ED97 /* CountlyDeviceIDTests.swift in Sources */, 3964A3E72C2AF8E90091E677 /* CountlySegmentationTests.swift in Sources */, 96DA74BB2D9FB687006FA6FF /* MockFeedbackWidget.swift in Sources */, @@ -736,13 +744,13 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = ""; - SUPPORTED_PLATFORMS = "watchsimulator watchos macosx iphonesimulator iphoneos appletvsimulator appletvos"; + SUPPORTED_PLATFORMS = "watchsimulator watchos macosx iphonesimulator iphoneos appletvsimulator appletvos xros xrsimulator"; SUPPORTS_MACCATALYST = YES; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; @@ -797,12 +805,12 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = ""; - SUPPORTED_PLATFORMS = "watchsimulator watchos macosx iphonesimulator iphoneos appletvsimulator appletvos"; + SUPPORTED_PLATFORMS = "watchsimulator watchos macosx iphonesimulator iphoneos appletvsimulator appletvos xros xrsimulator"; SUPPORTS_MACCATALYST = YES; VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; @@ -823,14 +831,14 @@ DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.14; - MARKETING_VERSION = 26.1.1; + MARKETING_VERSION = 26.1.2; PRODUCT_BUNDLE_IDENTIFIER = ly.count.CountlyiOSSDK; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -855,14 +863,14 @@ DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.14; - MARKETING_VERSION = 26.1.1; + MARKETING_VERSION = 26.1.2; PRODUCT_BUNDLE_IDENTIFIER = ly.count.CountlyiOSSDK; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/CountlyCommon.h b/CountlyCommon.h index 42b4e1ad..a3ec89ab 100644 --- a/CountlyCommon.h +++ b/CountlyCommon.h @@ -80,6 +80,7 @@ extern NSString* const kCountlySDKName; @property (nonatomic, copy) NSString* SDKName; @property (nonatomic) BOOL hasStarted; +@property (nonatomic) BOOL hasFinishedInit; @property (nonatomic) BOOL enableDebug; @property (nonatomic) BOOL shouldIgnoreTrustCheck; @@ -95,6 +96,7 @@ extern NSString* const kCountlySDKName; @property (nonatomic) NSUInteger maxKeyLength; @property (nonatomic) NSUInteger maxValueLength; +@property (nonatomic) NSUInteger maxValueLengthPicture; @property (nonatomic) NSUInteger maxSegmentationValues; void CountlyInternalLog(CLYInternalLogLevel level, NSString *format, ...) NS_FORMAT_FUNCTION(2, 3); @@ -125,6 +127,8 @@ void CountlyPrint(NSString *stringToPrint); - (NSURLSession *)URLSession; +- (NSURLSession *)ImmediateURLSession; + - (CGSize)getWindowSize; @end @@ -154,6 +158,7 @@ void CountlyPrint(NSString *stringToPrint); - (NSString *)cly_valueForQueryStringKey:(NSString *)key; - (NSString *)cly_truncatedKey:(NSString *)explanation; - (NSString *)cly_truncatedValue:(NSString *)explanation; +- (NSString *)cly_truncatedPictureValue:(NSString *)explanation; @end @interface NSArray (Countly) diff --git a/CountlyCommon.m b/CountlyCommon.m index 280d26fd..13adc0a2 100644 --- a/CountlyCommon.m +++ b/CountlyCommon.m @@ -29,7 +29,7 @@ @interface CountlyCommon () #endif @end -NSString* const kCountlySDKVersion = @"26.1.1"; +NSString* const kCountlySDKVersion = @"26.1.2"; NSString* const kCountlySDKName = @"objc-native-ios"; NSString* const kCountlyErrorDomain = @"ly.count.ErrorDomain"; @@ -66,12 +66,14 @@ - (instancetype)init - (void)resetInstance { CLY_LOG_I(@"%s", __FUNCTION__); -#if (TARGET_OS_IOS || TARGET_OS_VISION ) +#if (TARGET_OS_IOS) [NSNotificationCenter.defaultCenter removeObserver:self name:UIDeviceOrientationDidChangeNotification object:nil]; #endif _hasStarted = false; + _hasFinishedInit = false; _maxKeyLength = kCountlyMaxKeyLength; _maxValueLength = kCountlyMaxValueSize; + _maxValueLengthPicture = kCountlyMaxValueSizePicture; _maxSegmentationValues = kCountlyMaxSegmentationValues; onceToken = 0; s_sharedInstance = nil; @@ -341,6 +343,22 @@ - (NSURLSession *)URLSession } } +- (NSURLSession *)ImmediateURLSession +{ + // Base on the user-provided URLSessionConfiguration so that things like + // protocolClasses (test mocks), cookie policy, etc. are preserved. + // If none was provided, fall back to default session configuration. + NSURLSessionConfiguration *userConfig = CountlyConnectionManager.sharedInstance.URLSessionConfiguration; + NSURLSessionConfiguration *immediateConfig = userConfig ? [userConfig copy] : [NSURLSessionConfiguration defaultSessionConfiguration]; + + // Immediate requests must not be constrained by the SDK's configured + // request timeout — reset to the system defaults. + immediateConfig.timeoutIntervalForRequest = 60; + immediateConfig.timeoutIntervalForResource = 7 * 24 * 60 * 60; + + return [NSURLSession sessionWithConfiguration:immediateConfig]; +} + #if (TARGET_OS_IOS) - (bool) hasTopNotch:(UIEdgeInsets)safeArea { @@ -360,7 +378,7 @@ - (CGSize)getWindowSize{ } } } else { - window = UIApplication.sharedApplication.delegate.window; + window = [[UIApplication sharedApplication].delegate window]; } if (!window) return CGSizeZero; @@ -631,6 +649,17 @@ - (NSString *)cly_truncatedKey:(NSString *)explanation return self; } +- (NSString *)cly_truncatedPictureValue:(NSString *)explanation +{ + NSUInteger limit = CountlyCommon.sharedInstance.maxValueLengthPicture; + if (self.length > limit) + { + CLY_LOG_W(@"%@ length is more than the picture limit (%ld)! So, it will be truncated: %@.", explanation, (long)limit, self); + return [self substringToIndex:limit]; + } + return self; +} + - (NSString *)cly_truncatedValue:(NSString *)explanation { if (self.length > CountlyCommon.sharedInstance.maxValueLength) @@ -721,7 +750,7 @@ - (NSMutableDictionary *) cly_filterSupportedDataTypes if ([value isKindOfClass:[NSNumber class]] || [value isKindOfClass:[NSString class]] || - ([value isKindOfClass:[NSArray class]] && (value = [value cly_filterSupportedDataTypes]))) { + ([value isKindOfClass:[NSArray class]] && (value = [(NSArray *)value cly_filterSupportedDataTypes]))) { [filteredDictionary setObject:value forKey:key]; } else { CLY_LOG_W(@"%s, Removed invalid type for key %@: %@", __FUNCTION__, key, [value class]); diff --git a/CountlyConfig.h b/CountlyConfig.h index 215586a5..f5b4f1cb 100644 --- a/CountlyConfig.h +++ b/CountlyConfig.h @@ -440,6 +440,14 @@ typedef enum : NSUInteger */ - (CountlySDKLimitsConfig *)sdkInternalLimits; +/** + * User properties to set automatically right after SDK init. + * @discussion Equivalent to calling @c [Countly.user setProperties:data] followed by @c save once the SDK has started. + * @discussion Keys matching predefined fields (name, username, email, organization, phone, gender, picture, picturePath, byear) + * are routed to the corresponding top-level user-detail field. All other keys are stored as custom user properties. + */ +@property (nonatomic, copy) NSDictionary * _Nullable providedUserProperties; + /** * For sending all requests using HTTP POST method. * @discussion If set, all requests will be sent using HTTP POST method. Otherwise; only the requests with a file upload or data size more than 2048 bytes will be sent using HTTP POST method. diff --git a/CountlyConnectionManager.m b/CountlyConnectionManager.m index 4e9d5a45..d1ef9ac8 100644 --- a/CountlyConnectionManager.m +++ b/CountlyConnectionManager.m @@ -18,6 +18,7 @@ @interface CountlyConnectionManager () @property (nonatomic, strong) NSDate *startTime; @property (nonatomic, assign) atomic_bool backoff; +@property (nonatomic, assign) atomic_bool isProcessingQueue; @property (nonatomic, strong) NSMutableDictionary *internalRequestCallbacks; @property (nonatomic, strong) NSMutableArray *queueFlushRunnables; @property (nonatomic) BOOL hasAnyRequestFailed; @@ -109,6 +110,7 @@ - (instancetype)init unsentSessionLength = 0.0; isSessionStarted = NO; atomic_init(&_backoff, NO); + atomic_init(&_isProcessingQueue, NO); _internalRequestCallbacks = [NSMutableDictionary dictionary]; _queueFlushRunnables = [NSMutableArray array]; _hasAnyRequestFailed = NO; @@ -133,6 +135,7 @@ - (void)resetInstance { [self->_queueFlushRunnables removeAllObjects]; }); _hasAnyRequestFailed = NO; + atomic_store(&_isProcessingQueue, NO); } - (void)setHost:(NSString *)host @@ -194,33 +197,37 @@ - (void)proceedOnQueue return; } - if (self.connection) + if (self.connection || atomic_exchange(&_isProcessingQueue, YES)) { CLY_LOG_D(@"Proceeding on queue is aborted: Already has a request in process!"); return; } - + if (isCrashing) { CLY_LOG_D(@"Proceeding on queue is aborted: Application is crashing!"); + atomic_store(&_isProcessingQueue, NO); return; } - + if (self.isTerminating) { CLY_LOG_D(@"Proceeding on queue is aborted: Application is terminating!"); + atomic_store(&_isProcessingQueue, NO); return; } - + if (CountlyPersistency.sharedInstance.isQueueBeingModified) { CLY_LOG_D(@"Proceeding on queue is aborted: Queue is being modified!"); + atomic_store(&_isProcessingQueue, NO); return; } - + BOOL backoffFlag = atomic_load(&_backoff) ? YES : NO; if (backoffFlag) { CLY_LOG_I(@"%s, currently backed off, skipping proceeding the queue", __FUNCTION__); + atomic_store(&_isProcessingQueue, NO); return; } @@ -263,6 +270,7 @@ - (void)proceedOnQueue // Reset start time and failure flag for future queue processing self.startTime = nil; self.hasAnyRequestFailed = NO; + atomic_store(&_isProcessingQueue, NO); return; } @@ -272,17 +280,19 @@ - (void)proceedOnQueue [CountlyPersistency.sharedInstance removeFromQueue:firstItemInQueue]; [CountlyPersistency.sharedInstance saveToFile]; - + + atomic_store(&_isProcessingQueue, NO); [self proceedOnQueue]; - + return; } - + NSString* temporaryDeviceIDQueryString = [NSString stringWithFormat:@"&%@=%@", kCountlyQSKeyDeviceID, CLYTemporaryDeviceID]; if ([firstItemInQueue containsString:temporaryDeviceIDQueryString]) { CLY_LOG_D(@"Proceeding on queue is aborted: Device ID in request is CLYTemporaryDeviceID!"); + atomic_store(&_isProcessingQueue, NO); return; } @@ -391,6 +401,11 @@ - (void)proceedOnQueue [CountlyPersistency.sharedInstance saveToFile]; + // Clear the processing flag only after the head has been removed + // from the queue. Clearing it earlier would let a concurrent + // proceedOnQueue caller re-send the same head request. + atomic_store(&self->_isProcessingQueue, NO); + if(CountlyServerConfig.sharedInstance.backoffMechanism && [self backoff:duration queryString:queryString]){ CLY_LOG_D(@"%s, backed off dropping proceeding the queue", __FUNCTION__); self.startTime = nil; @@ -420,6 +435,7 @@ - (void)proceedOnQueue [CountlyHealthTracker.sharedInstance logFailedNetworkRequestWithStatusCode:((NSHTTPURLResponse*)response).statusCode errorResponse: [data cly_stringUTF8]]; [CountlyHealthTracker.sharedInstance saveState]; self.startTime = nil; + atomic_store(&self->_isProcessingQueue, NO); } } else @@ -441,6 +457,7 @@ - (void)proceedOnQueue [CountlyPersistency.sharedInstance saveToFile]; #endif self.startTime = nil; + atomic_store(&self->_isProcessingQueue, NO); } }]; @@ -595,9 +612,9 @@ - (void)beginSession } #endif - if([Countly.user hasUnsyncedChanges]) + if ([CountlyUserDetails.sharedInstance hasUnsyncedChanges]) { - [Countly.user save]; + [CountlyUserDetails.sharedInstance save]; } isSessionStarted = YES; @@ -638,9 +655,9 @@ - (void)updateSession return; } - if([Countly.user hasUnsyncedChanges]) + if ([CountlyUserDetails.sharedInstance hasUnsyncedChanges]) { - [Countly.user save]; + [CountlyUserDetails.sharedInstance save]; } NSString* queryString = [[self queryEssentials] stringByAppendingFormat:@"&%@=%d", @@ -680,9 +697,9 @@ - (void)endSession - (void)sendEventsWithSaveIfNeeded { - if([Countly.user hasUnsyncedChanges]) + if ([CountlyUserDetails.sharedInstance hasUnsyncedChanges]) { - [Countly.user save]; + [CountlyUserDetails.sharedInstance save]; } else { diff --git a/CountlyConsentManager.m b/CountlyConsentManager.m index 6cbf8c38..ef9ca76c 100644 --- a/CountlyConsentManager.m +++ b/CountlyConsentManager.m @@ -309,7 +309,7 @@ - (void)setConsentForUserDetails:(BOOL)consentForUserDetails { CLY_LOG_D(@"Consent for UserDetails is given."); [CountlyCommon.sharedInstance recordOrientation]; - [Countly.user save]; + [CountlyUserDetails.sharedInstance save]; } else { diff --git a/CountlyContentBuilderInternal.h b/CountlyContentBuilderInternal.h index a9859281..7983a27d 100644 --- a/CountlyContentBuilderInternal.h +++ b/CountlyContentBuilderInternal.h @@ -22,6 +22,7 @@ NS_ASSUME_NONNULL_BEGIN - (void)enterContentZone:(NSArray *)tags; - (void)exitContentZone; +- (void)resetInstance; - (void)changeContent:(NSArray *)tags; - (void)refreshContentZone; - (void)refreshContentZoneJTE; diff --git a/CountlyContentBuilderInternal.m b/CountlyContentBuilderInternal.m index 634f0576..9f8c07d3 100644 --- a/CountlyContentBuilderInternal.m +++ b/CountlyContentBuilderInternal.m @@ -180,13 +180,19 @@ - (void)fetchContentsForJourneyWithMaxAttempts:(NSInteger)maxAttempts currentAtt - (void)clearContentState { [_requestTimer invalidate]; _requestTimer = nil; - + [_minuteTimer invalidate]; _minuteTimer = nil; self.currentTags = nil; [self setRequestQueueLockedThreadSafe:NO]; } +- (void)resetInstance { + CLY_LOG_I(@"%s", __FUNCTION__); + [self clearContentState]; + _isCurrentlyContentShown = NO; +} + - (void)fetchContents { [self fetchContents:nil contentId:nil]; } @@ -197,7 +203,13 @@ - (void)fetchContents:(void (^)(void))failureCallback contentId:(NSString *)cont if (!CountlyServerConfig.sharedInstance.networkingEnabled) return; - + + if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) + { + CLY_LOG_W(@"%s content can not be fetched while in temporary device ID mode", __FUNCTION__); + return; + } + if(_isCurrentlyContentShown){ CLY_LOG_I(@"%s a content is already shown, skipping" ,__FUNCTION__); return; @@ -209,7 +221,8 @@ - (void)fetchContents:(void (^)(void))failureCallback contentId:(NSString *)cont [self setRequestQueueLockedThreadSafe:YES]; - NSURLSessionTask *dataTask = [CountlyCommon.sharedInstance.URLSession dataTaskWithRequest:[self fetchContentsRequest: contentId] completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + NSURLSessionTask *dataTask = [[CountlyCommon.sharedInstance ImmediateURLSession] dataTaskWithRequest:[self fetchContentsRequest:contentId] completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + // IMMEDIATE REQUEST to find them better in search if (error) { CLY_LOG_I(@"%s fetch content details failed: [%@]", __FUNCTION__, error); [self setRequestQueueLockedThreadSafe:NO]; diff --git a/CountlyDeviceInfo.m b/CountlyDeviceInfo.m index 7af25929..6ebc99f5 100644 --- a/CountlyDeviceInfo.m +++ b/CountlyDeviceInfo.m @@ -261,11 +261,29 @@ + (NSString *)carrier + (NSString *)resolution { - CGRect bounds; - CGFloat scale; + CGRect bounds = CGRectZero; + CGFloat scale = 0; #if (TARGET_OS_IOS || TARGET_OS_TV) - bounds = UIScreen.mainScreen.bounds; - scale = UIScreen.mainScreen.scale; + UIWindow *window = nil; +#if (TARGET_OS_IOS) + if (@available(iOS 13.0, *)) { +#elif (TARGET_OS_TV) + if (@available(tvOS 13.0, *)) { +#endif + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if ([scene isKindOfClass:[UIWindowScene class]]) { + window = ((UIWindowScene *)scene).windows.firstObject; + break; + } + } + if(window){ + bounds = window.bounds; + scale = window.traitCollection.displayScale; + } + } else { + bounds = UIScreen.mainScreen.bounds; + scale = UIScreen.mainScreen.scale; + } #elif (TARGET_OS_WATCH) bounds = WKInterfaceDevice.currentDevice.screenBounds; scale = WKInterfaceDevice.currentDevice.screenScale; @@ -519,3 +537,4 @@ - (void)resetInstance } @end + diff --git a/CountlyFeedbackWidget.m b/CountlyFeedbackWidget.m index a229678a..861116e6 100644 --- a/CountlyFeedbackWidget.m +++ b/CountlyFeedbackWidget.m @@ -205,8 +205,9 @@ - (void)getWidgetData:(void (^)(NSDictionary * __nullable widgetData, NSError * return; } - NSURLSessionTask* task = [CountlyCommon.sharedInstance.URLSession dataTaskWithRequest:[self dataRequest] completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) + NSURLSessionTask* task = [CountlyCommon.sharedInstance.ImmediateURLSession dataTaskWithRequest:[self dataRequest] completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { + // IMMEDIATE REQUEST to find them better in search NSDictionary *widgetData = nil; if (!error) diff --git a/CountlyFeedbacksInternal.m b/CountlyFeedbacksInternal.m index b2a9aa80..f3215007 100644 --- a/CountlyFeedbacksInternal.m +++ b/CountlyFeedbacksInternal.m @@ -252,8 +252,9 @@ - (void)presentRatingWidgetWithID:(NSString *)widgetID closeButtonText:(NSString return; NSURLRequest* feedbackWidgetCheckRequest = [self widgetCheckURLRequest:widgetID]; - NSURLSessionTask* task = [CountlyCommon.sharedInstance.URLSession dataTaskWithRequest:feedbackWidgetCheckRequest completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) + NSURLSessionTask* task = [CountlyCommon.sharedInstance.ImmediateURLSession dataTaskWithRequest:feedbackWidgetCheckRequest completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { + // IMMEDIATE REQUEST to find them better in search NSDictionary* widgetInfo = nil; if (!error) @@ -300,7 +301,8 @@ - (void)presentRatingWidgetInternal:(NSString *)widgetID closeButtonText:(NSStri { __block CLYInternalViewController* webVC = CLYInternalViewController.new; webVC.view.backgroundColor = UIColor.whiteColor; - webVC.view.bounds = UIScreen.mainScreen.bounds; + CGSize windowSize = [CountlyCommon.sharedInstance getWindowSize]; + webVC.view.bounds = CGRectMake(0, 0, windowSize.width, windowSize.height); webVC.modalPresentationStyle = UIModalPresentationCustom; WKWebView* webView = [WKWebView.alloc initWithFrame:webVC.view.bounds]; @@ -437,8 +439,9 @@ - (void)getFeedbackWidgets:(void (^)(NSArray *feedback return; } - NSURLSessionTask* task = [CountlyCommon.sharedInstance.URLSession dataTaskWithRequest:[self feedbacksRequest] completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) + NSURLSessionTask* task = [CountlyCommon.sharedInstance.ImmediateURLSession dataTaskWithRequest:[self feedbacksRequest] completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { + // IMMEDIATE REQUEST to find them better in search NSDictionary *feedbacksResponse = nil; if (!error) diff --git a/CountlyHealthTracker.m b/CountlyHealthTracker.m index 139860e8..668f40c3 100644 --- a/CountlyHealthTracker.m +++ b/CountlyHealthTracker.m @@ -194,8 +194,9 @@ - (void)sendHealthCheck { return; } - NSURLSessionTask* task = [CountlyCommon.sharedInstance.URLSession dataTaskWithRequest:[self healthCheckRequest] completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) + NSURLSessionTask* task = [CountlyCommon.sharedInstance.ImmediateURLSession dataTaskWithRequest:[self healthCheckRequest] completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { + // IMMEDIATE REQUEST to find them better in search if (error) { CLY_LOG_W(@"%s error while sending health checks error: [%@]", __FUNCTION__, error); diff --git a/CountlyNotificationService.m b/CountlyNotificationService.m index a42a08ad..bec63764 100644 --- a/CountlyNotificationService.m +++ b/CountlyNotificationService.m @@ -87,6 +87,7 @@ + (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withConte [[NSURLSession.sharedSession downloadTaskWithURL:[NSURL URLWithString:attachmentURL] completionHandler:^(NSURL * location, NSURLResponse * response, NSError * error) { + // IMMEDIATE REQUEST to find them better in search, but uses NSURLSession.sharedSession as it should not be blocked by SDK configurations if (!error) { COUNTLY_EXT_LOG(@"Attachment download completed!"); diff --git a/CountlyPersistency.m b/CountlyPersistency.m index c50d7aa4..b14575d0 100644 --- a/CountlyPersistency.m +++ b/CountlyPersistency.m @@ -271,9 +271,9 @@ - (void)recordEvent:(CountlyEvent *)event callback:(CLYRequestCallback)callback { @synchronized (self.recordedEvents) { - if([Countly.user hasUnsyncedChanges]) + if ([CountlyUserDetails.sharedInstance hasUnsyncedChanges]) { - [Countly.user save]; + [CountlyUserDetails.sharedInstance save]; } [self.recordedEvents addObject:event]; diff --git a/CountlyRemoteConfigInternal.m b/CountlyRemoteConfigInternal.m index 5e0aee79..b9c31f79 100644 --- a/CountlyRemoteConfigInternal.m +++ b/CountlyRemoteConfigInternal.m @@ -193,12 +193,18 @@ - (void)fetchRemoteConfigForKeys:(NSArray *)keys omitKeys:(NSArray *)omitKeys i CLY_LOG_D(@"'fetchRemoteConfigForKeys' is aborted: SDK Networking is disabled from server config!"); return; } + if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) + { + CLY_LOG_W(@"'fetchRemoteConfigForKeys' is aborted: device ID is in temporary mode!"); + return; + } if (!completionHandler) return; - + NSURLRequest* request = [self remoteConfigRequestForKeys:keys omitKeys:omitKeys isLegacy:isLegacy]; - NSURLSessionTask* task = [CountlyCommon.sharedInstance.URLSession dataTaskWithRequest:request completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) + NSURLSessionTask* task = [CountlyCommon.sharedInstance.ImmediateURLSession dataTaskWithRequest:request completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { + // IMMEDIATE REQUEST to find them better in search NSDictionary* remoteConfig = nil; if (!error) @@ -492,12 +498,18 @@ - (void)testingDownloadAllVariantsInternal:(void (^)(CLYRequestResult response, CLY_LOG_D(@"'fetchVariantForKeys' is aborted: SDK Networking is disabled from server config!"); return; } + if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) + { + CLY_LOG_W(@"'fetchVariantForKeys' is aborted: device ID is in temporary mode!"); + return; + } if (!completionHandler) return; - + NSURLRequest* request = [self downloadVariantsRequest]; - NSURLSessionTask* task = [CountlyCommon.sharedInstance.URLSession dataTaskWithRequest:request completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) + NSURLSessionTask* task = [CountlyCommon.sharedInstance.ImmediateURLSession dataTaskWithRequest:request completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { + // IMMEDIATE REQUEST to find them better in search NSMutableDictionary* variants = NSMutableDictionary.new; if (!error) @@ -605,10 +617,17 @@ - (void)testingEnrollIntoVariantInternal:(NSString *)key variantName:(NSString * CLY_LOG_D(@"'enrollInRCVariant' is aborted: 'variantName' is not valid"); return; } - + + if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) + { + CLY_LOG_W(@"'enrollInRCVariant' is aborted: device ID is in temporary mode!"); + return; + } + NSURLRequest* request = [self enrollInVarianRequestForKey:key variantName:variantName]; - NSURLSessionTask* task = [CountlyCommon.sharedInstance.URLSession dataTaskWithRequest:request completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) + NSURLSessionTask* task = [CountlyCommon.sharedInstance.ImmediateURLSession dataTaskWithRequest:request completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { + // IMMEDIATE REQUEST to find them better in search NSDictionary* variants = nil; [self clearCachedRemoteConfig]; if (!error) @@ -717,13 +736,18 @@ - (void)testingDownloaExperimentInfoInternal:(void (^)(CLYRequestResult response CLY_LOG_D(@"'testingDownloaExperimentInfoInternal' is aborted: SDK Networking is disabled from server config!"); return; } + if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) + { + CLY_LOG_W(@"'testingDownloaExperimentInfoInternal' is aborted: device ID is in temporary mode!"); + return; + } if (!completionHandler) return; - + NSURLRequest* request = [self downloadExperimentInfoRequest]; - NSURLSessionTask* task = [CountlyCommon.sharedInstance.URLSession dataTaskWithRequest:request completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) + NSURLSessionTask* task = [CountlyCommon.sharedInstance.ImmediateURLSession dataTaskWithRequest:request completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { - + // IMMEDIATE REQUEST to find them better in search NSMutableDictionary * experiments = NSMutableDictionary.new; if (!error) diff --git a/CountlySDKLimitsConfig.h b/CountlySDKLimitsConfig.h index fa3d77f1..74001772 100644 --- a/CountlySDKLimitsConfig.h +++ b/CountlySDKLimitsConfig.h @@ -9,6 +9,7 @@ extern const NSUInteger kCountlyMaxKeyLength; extern const NSUInteger kCountlyMaxValueSize; +extern const NSUInteger kCountlyMaxValueSizePicture; extern const NSUInteger kCountlyMaxBreadcrumbCount; extern const NSUInteger kCountlyMaxSegmentationValues; extern const NSUInteger kCountlyMaxStackTraceLineLength; @@ -19,6 +20,7 @@ extern const NSUInteger kCountlyMaxStackTraceLinesPerThread; - (void)setMaxKeyLength:(NSUInteger)maxKeyLength; - (void)setMaxValueSize:(NSUInteger)maxValueSize; +- (void)setMaxValueSizePicture:(NSUInteger)maxValueSizePicture; - (void)setMaxBreadcrumbCount:(NSUInteger)maxBreadcrumbCount; - (void)setMaxSegmentationValues:(NSUInteger)maxSegmentationValues; - (void)setMaxStackTraceLineLength:(NSUInteger)maxStackTraceLineLength; @@ -26,6 +28,7 @@ extern const NSUInteger kCountlyMaxStackTraceLinesPerThread; - (NSUInteger)getMaxKeyLength; - (NSUInteger)getMaxValueSize; +- (NSUInteger)getMaxValueSizePicture; - (NSUInteger)getMaxBreadcrumbCount; - (NSUInteger)getMaxSegmentationValues; - (NSUInteger)getMaxStackTraceLineLength; diff --git a/CountlySDKLimitsConfig.m b/CountlySDKLimitsConfig.m index 2a085e5c..725322de 100644 --- a/CountlySDKLimitsConfig.m +++ b/CountlySDKLimitsConfig.m @@ -10,6 +10,7 @@ const NSUInteger kCountlyMaxKeyLength = 128; const NSUInteger kCountlyMaxValueSize = 256; +const NSUInteger kCountlyMaxValueSizePicture = 4096; const NSUInteger kCountlyMaxSegmentationValues = 100; const NSUInteger kCountlyMaxBreadcrumbCount = 100; const NSUInteger kCountlyMaxStackTraceLinesPerThread = 30; @@ -19,6 +20,7 @@ @interface CountlySDKLimitsConfig () { NSUInteger _maxKeyLength; NSUInteger _maxValueSize; + NSUInteger _maxValueSizePicture; NSUInteger _maxSegmentationValues; NSUInteger _maxBreadcrumbCount; NSUInteger _maxStackTraceLinesPerThread; @@ -33,12 +35,13 @@ - (instancetype)init { _maxKeyLength = kCountlyMaxKeyLength; _maxValueSize = kCountlyMaxValueSize; + _maxValueSizePicture = kCountlyMaxValueSizePicture; _maxSegmentationValues = kCountlyMaxSegmentationValues; _maxBreadcrumbCount = kCountlyMaxBreadcrumbCount; _maxStackTraceLinesPerThread = kCountlyMaxStackTraceLinesPerThread; _maxStackTraceLineLength = kCountlyMaxStackTraceLineLength; } - + return self; } @@ -52,6 +55,11 @@ - (void)setMaxValueSize:(NSUInteger)maxValueSize _maxValueSize = maxValueSize; } +- (void)setMaxValueSizePicture:(NSUInteger)maxValueSizePicture +{ + _maxValueSizePicture = maxValueSizePicture; +} + - (void)setMaxSegmentationValues:(NSUInteger)maxSegmentationValues { _maxSegmentationValues = maxSegmentationValues; @@ -82,6 +90,11 @@ - (NSUInteger)getMaxValueSize return _maxValueSize; } +- (NSUInteger)getMaxValueSizePicture +{ + return _maxValueSizePicture; +} + - (NSUInteger)getMaxSegmentationValues { return _maxSegmentationValues; diff --git a/CountlyServerConfig.m b/CountlyServerConfig.m index cbf35b34..14f7e748 100644 --- a/CountlyServerConfig.m +++ b/CountlyServerConfig.m @@ -169,13 +169,13 @@ - (void)retrieveServerConfigFromStorage:(CountlyConfig *)config if ([parsed isKindOfClass:[NSDictionary class]]) { persistentBehaviorSettings = [(NSDictionary *)parsed mutableCopy]; - [CountlyPersistency.sharedInstance storeServerConfig:persistentBehaviorSettings]; } else { CLY_LOG_W(@"%s, Failed to parse sdkBehaviorSettings or not a dictionary: %@", __FUNCTION__, error); } } [self populateServerConfig:persistentBehaviorSettings withConfig:config]; + [CountlyPersistency.sharedInstance storeServerConfig:persistentBehaviorSettings]; } - (void)mergeBehaviorSettings:(NSMutableDictionary *)baseConfig @@ -225,52 +225,108 @@ - (void)mergeBehaviorSettings:(NSMutableDictionary *)baseConfig } } -- (void)setBoolProperty:(BOOL *)property fromDictionary:(NSDictionary *)dictionary key:(NSString *)key logString:(NSMutableString *)logString +- (void)setBoolProperty:(BOOL *)property fromDictionary:(NSMutableDictionary *)dictionary key:(NSString *)key logString:(NSMutableString *)logString { - NSNumber *value = dictionary[key]; - if (value) + id value = dictionary[key]; + if (!value) + return; + + if (CFGetTypeID((__bridge CFTypeRef)value) == CFBooleanGetTypeID()) { - *property = value.boolValue; + *property = [value boolValue]; [logString appendFormat:@"%@: %@, ", key, *property ? @"YES" : @"NO"]; } + else + { + CLY_LOG_W(@"%s, Invalid type for bool key '%@', removing", __FUNCTION__, key); + [dictionary removeObjectForKey:key]; + } +} + +- (void)setIntegerProperty:(NSInteger *)property fromDictionary:(NSMutableDictionary *)dictionary key:(NSString *)key logString:(NSMutableString *)logString +{ + [self setIntegerProperty:property fromDictionary:dictionary key:key minValue:1 logString:logString]; } -- (void)setIntegerProperty:(NSInteger *)property fromDictionary:(NSDictionary *)dictionary key:(NSString *)key logString:(NSMutableString *)logString +- (void)setIntegerProperty:(NSInteger *)property fromDictionary:(NSMutableDictionary *)dictionary key:(NSString *)key minValue:(NSInteger)minValue logString:(NSMutableString *)logString { - NSNumber *value = dictionary[key]; - if (value) + id value = dictionary[key]; + if (!value) + return; + + if ([value isKindOfClass:[NSNumber class]] && CFGetTypeID((__bridge CFTypeRef)value) != CFBooleanGetTypeID()) { - *property = value.integerValue; - [logString appendFormat:@"%@: %ld, ", key, (long)*property]; + NSInteger intVal = [value integerValue]; + if (intVal >= minValue) + { + *property = intVal; + [logString appendFormat:@"%@: %ld, ", key, (long)*property]; + } + else + { + CLY_LOG_W(@"%s, Invalid value (%ld) for integer key '%@' (min: %ld), removing", __FUNCTION__, (long)intVal, key, (long)minValue); + [dictionary removeObjectForKey:key]; + } + } + else + { + CLY_LOG_W(@"%s, Invalid type for integer key '%@', removing", __FUNCTION__, key); + [dictionary removeObjectForKey:key]; } } -- (void)setDoubleProperty:(double *)property fromDictionary:(NSDictionary *)dictionary key:(NSString *)key logString:(NSMutableString *)logString +- (void)setDoubleProperty:(double *)property fromDictionary:(NSMutableDictionary *)dictionary key:(NSString *)key logString:(NSMutableString *)logString { - NSNumber *value = dictionary[key]; - if (value) + id value = dictionary[key]; + if (!value) + return; + + if ([value isKindOfClass:[NSNumber class]] && CFGetTypeID((__bridge CFTypeRef)value) != CFBooleanGetTypeID()) { - *property = value.doubleValue; - [logString appendFormat:@"%@: %lf, ", key, (double)*property]; + double dblVal = [value doubleValue]; + if (dblVal > 0.0 && dblVal < 1.0) + { + *property = dblVal; + [logString appendFormat:@"%@: %lf, ", key, (double)*property]; + } + else + { + CLY_LOG_W(@"%s, Invalid value (%lf) for double key '%@', removing", __FUNCTION__, dblVal, key); + [dictionary removeObjectForKey:key]; + } + } + else + { + CLY_LOG_W(@"%s, Invalid type for double key '%@', removing", __FUNCTION__, key); + [dictionary removeObjectForKey:key]; } } -- (void)populateServerConfig:(NSDictionary *)serverConfig withConfig:(CountlyConfig *)config +- (void)populateServerConfig:(NSMutableDictionary *)serverConfig withConfig:(CountlyConfig *)config { if(config.requestTimeoutDuration <= 0) { config.requestTimeoutDuration = 1; } - + _requestTimeoutDuration = config.requestTimeoutDuration; - + if (!serverConfig[kRConfig]) { CLY_LOG_D(@"%s, config key is missing in the server configuration omitting", __FUNCTION__); return; } - - NSDictionary *dictionary = serverConfig[kRConfig]; - + + NSMutableDictionary *dictionary; + if ([serverConfig[kRConfig] isKindOfClass:[NSDictionary class]]) + { + dictionary = [serverConfig[kRConfig] mutableCopy]; + } + else + { + CLY_LOG_D(@"%s, config is not a dictionary, omitting", __FUNCTION__); + return; + } + if (!serverConfig[kRVersion] || !serverConfig[kRTimestamp]) { CLY_LOG_D(@"%s, version or timestamp is missing in the server configuration omitting", __FUNCTION__); @@ -281,7 +337,59 @@ - (void)populateServerConfig:(NSDictionary *)serverConfig withConfig:(CountlyCon _timestamp = [serverConfig[kRTimestamp] longLongValue]; NSMutableString *logString = [NSMutableString stringWithString:@"Server Config: "]; - + + // Known keys set — used to remove unknown keys after validation + NSMutableSet *knownKeys = [NSMutableSet setWithArray:@[ + kTracking, + kNetworking, + kRSessionUpdateInterval, + kRReqQueueSize, + kREventQueueSize, + kRCrashReporting, + kRSessionTracking, + kRLogging, + kRLimitKeyLength, + kRLimitValueSize, + kRLimitSegValues, + kRLimitBreadcrumb, + kRLimitTraceLine, + kRLimitTraceLength, + kRCustomEventTracking, + kRViewTracking, + kREnterContentZone, + kRContentZoneInterval, + kRConsentRequired, + kRDropOldRequestTime, + kRServerConfigUpdateInterval, + kRLocationTracking, + kRRefreshContentZone, + kRbackoffMechanism, + kRBOMAcceptedTimeout, + kRBOMRQPercentage, + kRBOMRequestAge, + kRBOMDuration, + kRUserPropertyCacheLimit, + kREventBlacklist, + kREventWhitelist, + kRUserPropertyBlacklist, + kRUserPropertyWhitelist, + kRSegmentationBlacklist, + kRSegmentationWhitelist, + kREventSegmentationBlacklist, + kREventSegmentationWhitelist, + kRJourneyTriggerEvents + ]]; + + // Remove unknown keys + for (NSString *key in dictionary.allKeys) + { + if (![knownKeys containsObject:key]) + { + CLY_LOG_W(@"%s, Unknown config key '%@', removing", __FUNCTION__, key); + [dictionary removeObjectForKey:key]; + } + } + [self setBoolProperty:&_trackingEnabled fromDictionary:dictionary key:kTracking logString:logString]; [self setBoolProperty:&_networkingEnabled fromDictionary:dictionary key:kNetworking logString:logString]; [self setIntegerProperty:&_sessionInterval fromDictionary:dictionary key:kRSessionUpdateInterval logString:logString]; @@ -299,9 +407,9 @@ - (void)populateServerConfig:(NSDictionary *)serverConfig withConfig:(CountlyCon [self setBoolProperty:&_customEventTrackingEnabled fromDictionary:dictionary key:kRCustomEventTracking logString:logString]; [self setBoolProperty:&_viewTrackingEnabled fromDictionary:dictionary key:kRViewTracking logString:logString]; [self setBoolProperty:&_enterContentZone fromDictionary:dictionary key:kREnterContentZone logString:logString]; - [self setIntegerProperty:&_contentZoneInterval fromDictionary:dictionary key:kRContentZoneInterval logString:logString]; + [self setIntegerProperty:&_contentZoneInterval fromDictionary:dictionary key:kRContentZoneInterval minValue:16 logString:logString]; [self setBoolProperty:&_consentRequired fromDictionary:dictionary key:kRConsentRequired logString:logString]; - [self setIntegerProperty:&_dropOldRequestTime fromDictionary:dictionary key:kRDropOldRequestTime logString:logString]; + [self setIntegerProperty:&_dropOldRequestTime fromDictionary:dictionary key:kRDropOldRequestTime minValue:0 logString:logString]; [self setIntegerProperty:&_serverConfigUpdateInterval fromDictionary:dictionary key:kRServerConfigUpdateInterval logString:logString]; [self setBoolProperty:&_locationTracking fromDictionary:dictionary key:kRLocationTracking logString:logString]; [self setBoolProperty:&_refreshContentZone fromDictionary:dictionary key:kRRefreshContentZone logString:logString]; @@ -314,6 +422,9 @@ - (void)populateServerConfig:(NSDictionary *)serverConfig withConfig:(CountlyCon [self updateListingFilters:dictionary logString:logString]; + // Update the config dictionary with cleaned values + serverConfig[kRConfig] = dictionary; + if(![logString isEqualToString: @"Server Config: "]){ // means new config gotten, if that is the case notify SDK [self notifySdkConfigChange: config]; @@ -357,6 +468,7 @@ - (void)notifySdkConfigChange:(CountlyConfig *)config CountlyCommon.sharedInstance.maxKeyLength = config.sdkInternalLimits.getMaxKeyLength; CountlyCommon.sharedInstance.maxValueLength = config.sdkInternalLimits.getMaxValueSize; + CountlyCommon.sharedInstance.maxValueLengthPicture = config.sdkInternalLimits.getMaxValueSizePicture; CountlyCommon.sharedInstance.maxSegmentationValues = config.sdkInternalLimits.getMaxSegmentationValues; config.eventSendThreshold = _eventQueueSize ?: config.eventSendThreshold; @@ -372,7 +484,7 @@ - (void)notifySdkConfigChange:(CountlyConfig *)config BOOL consentDidChange = !config.requiresConsent && _consentRequired; config.requiresConsent = _consentRequired ?: config.requiresConsent; CountlyConsentManager.sharedInstance.requiresConsent = config.requiresConsent; - if(consentDidChange){ + if(consentDidChange && CountlyCommon.sharedInstance.hasFinishedInit){ [CountlyConsentManager.sharedInstance sendConsents]; if (!CountlyConsentManager.sharedInstance.consentForLocation) { @@ -459,7 +571,10 @@ - (void)fetchServerConfig:(CountlyConfig *)config } if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) + { + CLY_LOG_W(@"%s, fetch is skipped while in temporary device ID mode", __FUNCTION__); return; + } _lastFetchTimestamp = NSDate.date.timeIntervalSince1970 * 1000; @@ -496,19 +611,21 @@ - (void)fetchServerConfig:(CountlyConfig *)config { NSMutableDictionary *persistentBehaviorSettings = [CountlyPersistency.sharedInstance retrieveServerConfig]; [self mergeBehaviorSettings:persistentBehaviorSettings withConfig:serverConfigResponse]; - [CountlyPersistency.sharedInstance storeServerConfig:persistentBehaviorSettings]; [self populateServerConfig:persistentBehaviorSettings withConfig:config]; + [CountlyPersistency.sharedInstance storeServerConfig:persistentBehaviorSettings]; } }; // Set default values - NSURLSessionTask *task = [CountlyCommon.sharedInstance.URLSession dataTaskWithRequest:[self serverConfigRequest] completionHandler:handler]; + NSURLSessionTask *task = [CountlyCommon.sharedInstance.ImmediateURLSession dataTaskWithRequest:[self serverConfigRequest] completionHandler:handler]; + // IMMEDIATE REQUEST to find them better in search [task resume]; } - (NSURLRequest *)serverConfigRequest { - NSString *queryString = [NSString stringWithFormat:@"%@=%@&%@=%@&%@=%@&%@=%@&%@=%@", kCountlyQSKeyMethod, kCountlySCKeySC, kCountlyQSKeyAppKey, CountlyConnectionManager.sharedInstance.appKey.cly_URLEscaped, kCountlyQSKeyDeviceID, CountlyDeviceInfo.sharedInstance.deviceID.cly_URLEscaped, - kCountlyQSKeySDKName, CountlyCommon.sharedInstance.SDKName, kCountlyQSKeySDKVersion, CountlyCommon.sharedInstance.SDKVersion]; + NSString *queryString = [CountlyConnectionManager.sharedInstance queryEssentials]; + + queryString = [queryString stringByAppendingFormat:@"&%@=%@", kCountlyQSKeyMethod, kCountlySCKeySC]; queryString = [queryString stringByAppendingFormat:@"&%@=%@", kCountlyAppVersionKey, CountlyDeviceInfo.appVersion]; @@ -552,18 +669,18 @@ - (void)setDefaultValues { _bomRequestAge = 24; _bomDuration = 60; - _eventQueueSize = 100; - _requestQueueSize = 1000; - _sessionInterval = 60; - _limitKeyLength = 128; - _limitValueSize = 256; - _limitSegValues = 100; - _limitBreadcrumb = 100; - _limitTraceLine = 200; - _limitTraceLength = 30; + _eventQueueSize = 0; + _requestQueueSize = 0; + _sessionInterval = 0; + _limitKeyLength = 0; + _limitValueSize = 0; + _limitSegValues = 0; + _limitBreadcrumb = 0; + _limitTraceLine = 0; + _limitTraceLength = 0; _consentRequired = NO; _dropOldRequestTime = 0; - _contentZoneInterval = 30; + _contentZoneInterval = 0; _eventFilterSet = [NSSet set]; _eventFilterIsWhitelist = NO; @@ -729,40 +846,38 @@ - (NSInteger)userPropertyCacheLimit - (void)removeConflictingFilterKeys:(NSMutableDictionary *)mergedConfig newConfig:(NSDictionary *)newConfig { - // Remove listing filter keys from stored config based on new config - // If new config has any whitelist key, remove all blacklist keys from stored config - // If new config has any blacklist key, remove all whitelist keys from stored config - NSArray *whitelistKeys = @[kREventWhitelist, kRSegmentationWhitelist, kREventSegmentationWhitelist, kRUserPropertyWhitelist]; - NSArray *blacklistKeys = @[kREventBlacklist, kRSegmentationBlacklist, kREventSegmentationBlacklist, kRUserPropertyBlacklist]; - - BOOL newHasWhitelist = NO; - BOOL newHasBlacklist = NO; - for (NSString *key in whitelistKeys) + // Remove conflicting filter keys per category. + // Within each category, if new config provides a blacklist, remove stored whitelist (and vice versa). + NSArray *filterPairs = @[ + @[kREventBlacklist, kREventWhitelist], + @[kRSegmentationBlacklist, kRSegmentationWhitelist], + @[kREventSegmentationBlacklist, kREventSegmentationWhitelist], + @[kRUserPropertyBlacklist, kRUserPropertyWhitelist], + ]; + + for (NSArray *pair in filterPairs) { - if (newConfig[key]) { newHasWhitelist = YES; break; } - } - for (NSString *key in blacklistKeys) - { - if (newConfig[key]) { newHasBlacklist = YES; break; } - } + NSString *blacklistKey = pair[0]; + NSString *whitelistKey = pair[1]; - if (newHasWhitelist) - { - for (NSString *key in blacklistKeys) + // Only consider valid filter values (arrays/dicts) for conflict resolution + id blacklistVal = newConfig[blacklistKey]; + id whitelistVal = newConfig[whitelistKey]; + BOOL hasValidBlacklist = [blacklistVal isKindOfClass:NSArray.class] || [blacklistVal isKindOfClass:NSDictionary.class]; + BOOL hasValidWhitelist = [whitelistVal isKindOfClass:NSArray.class] || [whitelistVal isKindOfClass:NSDictionary.class]; + + if (hasValidBlacklist) { - [mergedConfig removeObjectForKey:key]; + [mergedConfig removeObjectForKey:whitelistKey]; } - } - if (newHasBlacklist) - { - for (NSString *key in whitelistKeys) + if (hasValidWhitelist) { - [mergedConfig removeObjectForKey:key]; + [mergedConfig removeObjectForKey:blacklistKey]; } } } -- (void)updateListingFilters:(NSDictionary *)dictionary logString:(NSMutableString *)logString +- (void)updateListingFilters:(NSMutableDictionary *)dictionary logString:(NSMutableString *)logString { // Event filter (eb/ew) - blacklist takes precedence NSArray *eb = dictionary[kREventBlacklist]; @@ -771,10 +886,17 @@ - (void)updateListingFilters:(NSDictionary *)dictionary logString:(NSMutableStri _eventFilterSet = [NSSet setWithArray:eb]; _eventFilterIsWhitelist = NO; [logString appendFormat:@"%@: %@, ", kREventBlacklist, eb]; + if (ew) + [dictionary removeObjectForKey:kREventWhitelist]; // blacklist takes precedence } else if ([ew isKindOfClass:NSArray.class]) { _eventFilterSet = [NSSet setWithArray:ew]; _eventFilterIsWhitelist = YES; [logString appendFormat:@"%@: %@, ", kREventWhitelist, ew]; + } else { + if (eb) + [dictionary removeObjectForKey:kREventBlacklist]; + if (ew) + [dictionary removeObjectForKey:kREventWhitelist]; } // User property filter (upb/upw) - blacklist takes precedence @@ -784,10 +906,17 @@ - (void)updateListingFilters:(NSDictionary *)dictionary logString:(NSMutableStri _userPropertyFilterSet = [NSSet setWithArray:upb]; _userPropertyFilterIsWhitelist = NO; [logString appendFormat:@"%@: %@, ", kRUserPropertyBlacklist, upb]; + if (upw) + [dictionary removeObjectForKey:kRUserPropertyWhitelist]; } else if ([upw isKindOfClass:NSArray.class]) { _userPropertyFilterSet = [NSSet setWithArray:upw]; _userPropertyFilterIsWhitelist = YES; [logString appendFormat:@"%@: %@, ", kRUserPropertyWhitelist, upw]; + } else { + if (upb) + [dictionary removeObjectForKey:kRUserPropertyBlacklist]; + if (upw) + [dictionary removeObjectForKey:kRUserPropertyWhitelist]; } // Segmentation filter (sb/sw) - blacklist takes precedence @@ -797,10 +926,17 @@ - (void)updateListingFilters:(NSDictionary *)dictionary logString:(NSMutableStri _segmentationFilterSet = [NSSet setWithArray:sb]; _segmentationFilterIsWhitelist = NO; [logString appendFormat:@"%@: %@, ", kRSegmentationBlacklist, sb]; + if (sw) + [dictionary removeObjectForKey:kRSegmentationWhitelist]; } else if ([sw isKindOfClass:NSArray.class]) { _segmentationFilterSet = [NSSet setWithArray:sw]; _segmentationFilterIsWhitelist = YES; [logString appendFormat:@"%@: %@, ", kRSegmentationWhitelist, sw]; + } else { + if (sb) + [dictionary removeObjectForKey:kRSegmentationBlacklist]; + if (sw) + [dictionary removeObjectForKey:kRSegmentationWhitelist]; } // Event segmentation filter (esb/esw) - blacklist takes precedence @@ -816,6 +952,8 @@ - (void)updateListingFilters:(NSDictionary *)dictionary logString:(NSMutableStri _eventSegmentationFilterMap = map.copy; _eventSegmentationFilterIsWhitelist = NO; [logString appendFormat:@"%@: %@, ", kREventSegmentationBlacklist, esb]; + if (esw) + [dictionary removeObjectForKey:kREventSegmentationWhitelist]; } else if ([esw isKindOfClass:NSDictionary.class]) { NSMutableDictionary *map = NSMutableDictionary.new; [esw enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSArray *obj, BOOL *stop) { @@ -826,6 +964,11 @@ - (void)updateListingFilters:(NSDictionary *)dictionary logString:(NSMutableStri _eventSegmentationFilterMap = map.copy; _eventSegmentationFilterIsWhitelist = YES; [logString appendFormat:@"%@: %@, ", kREventSegmentationWhitelist, esw]; + } else { + if (esb) + [dictionary removeObjectForKey:kREventSegmentationBlacklist]; + if (esw) + [dictionary removeObjectForKey:kREventSegmentationWhitelist]; } // Journey trigger events (jte) @@ -833,6 +976,9 @@ - (void)updateListingFilters:(NSDictionary *)dictionary logString:(NSMutableStri if ([jte isKindOfClass:NSArray.class]) { _journeyTriggerEvents = [NSSet setWithArray:jte]; [logString appendFormat:@"%@: %@, ", kRJourneyTriggerEvents, jte]; + } else { + if (jte) + [dictionary removeObjectForKey:kRJourneyTriggerEvents]; } } diff --git a/CountlyTests/CountlyBaseTestCase.swift b/CountlyTests/CountlyBaseTestCase.swift index c4fe7204..d032ed5b 100644 --- a/CountlyTests/CountlyBaseTestCase.swift +++ b/CountlyTests/CountlyBaseTestCase.swift @@ -34,7 +34,16 @@ class CountlyBaseTestCase: XCTestCase { } func cleanupState() { - // All cleanup logic now handled inside Countly.halt(true) + // Fully purge any state persisted by a previous test *process*. halt(true) alone does + // not reliably clear stale persisted requests when the SDK was never started in this + // process (this is the edge case the historical `testDummy` was added to work around). + // Starting first wires up persistency/connection-manager and loads the stale state, then + // halt(true) forces a full reset + empty save — so every test, including ones run in + // isolation, begins from a clean slate. + let config = createBaseConfig() + config.requiresConsent = false + config.manualSessionHandling = true + Countly.sharedInstance().start(with: config) Countly.sharedInstance().halt(true) } } diff --git a/CountlyTests/CountlyContentBuilderTests.swift b/CountlyTests/CountlyContentBuilderTests.swift index 564f7039..683a2255 100644 --- a/CountlyTests/CountlyContentBuilderTests.swift +++ b/CountlyTests/CountlyContentBuilderTests.swift @@ -232,5 +232,60 @@ class CountlyContentBuilderTests: CountlyBaseTestCase { XCTAssertEqual(fetchCount, countAfterFirstFetch, "No additional content fetches should occur after exitContentZone") } + + /** + *
+     * Reproduction + regression test for the temporary-device-ID leak.
+     *
+     * The content fetch is an immediate request (it bypasses the persisted request queue and its
+     * temporary-ID guard) and it carries device_id. While in temporary device ID mode it must not be
+     * sent, otherwise a "CLYTemporaryDeviceID" user is created on the server.
+     *
+     * 1- Init SDK with MockURLProtocol, then enter temporary device ID mode
+     * 2- Enter content zone with zero initial delay
+     * 3- Verify NO request to /o/sdk/content is made while in temporary mode (inverted expectation)
+     * 4- Exit content zone, switch to a real device ID (leaving temporary mode)
+     * 5- Re-enter content zone and verify the content fetch now fires normally
+     * 
+ */ + func test_fetchContent_isBlocked_inTemporaryDeviceIDMode() { + let noFetchInTempMode = self.expectation(description: "No content fetch while in temporary device ID mode") + noFetchInTempMode.isInverted = true + let fetchAfterExit = self.expectation(description: "Content fetch resumes after leaving temporary mode") + fetchAfterExit.assertForOverFulfill = false + + var inTemporaryMode = true + MockURLProtocol.requestHandler = { request in + if let url = request.url?.absoluteString, url.contains("/o/sdk/content") { + if inTemporaryMode { + noFetchInTempMode.fulfill() + } else { + fetchAfterExit.fulfill() + } + } + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)! + return ("{}".data(using: .utf8)!, response, nil) + } + + let config = createContentTestConfig() + Countly.sharedInstance().start(with: config) + Countly.sharedInstance().enableTemporaryDeviceIDMode() + + CountlyContentBuilderInternal.sharedInstance().contentInitialDelay = 0 + CountlyContentBuilderInternal.sharedInstance().enterContentZone([]) + + // Phase 1: nothing should hit the content endpoint while in temporary mode + wait(for: [noFetchInTempMode], timeout: 5) + + // Phase 2: leave temporary mode by assigning a real device ID, then re-enter the content zone + CountlyContentBuilderInternal.sharedInstance().exitContentZone() + inTemporaryMode = false + Countly.sharedInstance().changeDeviceIDWithoutMerge("real_user_after_temp") + + CountlyContentBuilderInternal.sharedInstance().contentInitialDelay = 0 + CountlyContentBuilderInternal.sharedInstance().enterContentZone([]) + + wait(for: [fetchAfterExit], timeout: 15) + } } #endif diff --git a/CountlyTests/CountlyScreenMetricsTests.swift b/CountlyTests/CountlyScreenMetricsTests.swift new file mode 100644 index 00000000..62f7e5a4 --- /dev/null +++ b/CountlyTests/CountlyScreenMetricsTests.swift @@ -0,0 +1,174 @@ +// +// CountlyScreenMetricsTests.swift +// CountlyTests +// +// Covers screen-metric helpers refactored on the `mainscreen_dpc` branch: +// - CountlyCommon.getWindowSize() — used for content/feedback view sizing. +// - CountlyDeviceInfo.resolution() — used for the `_resolution` metric. +// +// These tests are written to work both in a hosted XCTest bundle (where +// UIApplication has connected window scenes) and in a logic-test bundle +// (where no scene is attached). They assert *property* invariants rather +// than concrete sizes so they hold across hosts and devices. +// + +import XCTest +@testable import Countly + +#if os(iOS) +class CountlyScreenMetricsTests: CountlyBaseTestCase { + + override func setUp() { + super.setUp() + Countly.sharedInstance().halt(true) + } + + override func tearDown() { + Countly.sharedInstance().halt(true) + super.tearDown() + } + + // MARK: - getWindowSize + + /** + * Regression guard: getWindowSize must not crash and must return a + * finite, non-negative CGSize. The `mainscreen_dpc` refactor previously + * broke the build by referencing `size` after removing its declaration; + * this test exists primarily so the build/runtime path stays exercised. + */ + func test_getWindowSize_returnsFiniteNonNegativeSize() { + let size = CountlyCommon.sharedInstance().getWindowSize() + + XCTAssertFalse(size.width.isNaN, "width should not be NaN") + XCTAssertFalse(size.height.isNaN, "height should not be NaN") + XCTAssertGreaterThanOrEqual(size.width, 0, "width should be >= 0") + XCTAssertGreaterThanOrEqual(size.height, 0, "height should be >= 0") + } + + /** + * getWindowSize must be deterministic — repeated calls without any + * window-scene churn should return the same value. + */ + func test_getWindowSize_isStableAcrossCalls() { + let first = CountlyCommon.sharedInstance().getWindowSize() + let second = CountlyCommon.sharedInstance().getWindowSize() + let third = CountlyCommon.sharedInstance().getWindowSize() + + XCTAssertEqual(first.width, second.width, accuracy: 0.0001) + XCTAssertEqual(first.height, second.height, accuracy: 0.0001) + XCTAssertEqual(second.width, third.width, accuracy: 0.0001) + XCTAssertEqual(second.height, third.height, accuracy: 0.0001) + } + + /** + * If a UIWindowScene is present (hosted test), getWindowSize should not + * exceed the underlying window's bounds — safe-area adjustments may + * shrink the size but never grow it. If no scene is present, the + * function returns CGSizeZero, which satisfies the same invariant. + */ + func test_getWindowSize_doesNotExceedWindowBounds() { + let size = CountlyCommon.sharedInstance().getWindowSize() + + if let window = firstWindow() { + XCTAssertLessThanOrEqual(size.width, window.bounds.width, + "Reported width should not exceed window width") + XCTAssertLessThanOrEqual(size.height, window.bounds.height, + "Reported height should not exceed window height") + } else { + XCTAssertEqual(size.width, 0, accuracy: 0.0001, + "Expected CGSizeZero when no window scene is attached") + XCTAssertEqual(size.height, 0, accuracy: 0.0001, + "Expected CGSizeZero when no window scene is attached") + } + } + + // MARK: - resolution + + /** + * resolution() must return a "WIDTHxHEIGHT" formatted string with two + * non-negative numeric components. This is the contract consumed by the + * `_resolution` metric and by the content-builder query parameters. + */ + func test_resolution_isWellFormedString() { + guard let resolution = CountlyDeviceInfo.resolution() else { + XCTFail("resolution() returned nil on iOS") + return + } + + let parts = resolution.components(separatedBy: "x") + XCTAssertEqual(parts.count, 2, + "Expected WIDTHxHEIGHT format, got: \(resolution)") + guard parts.count == 2 else { return } + + guard let width = Double(parts[0]), let height = Double(parts[1]) else { + XCTFail("resolution() components should be numeric, got: \(resolution)") + return + } + XCTAssertGreaterThanOrEqual(width, 0, "resolution width should be >= 0 (got: \(resolution))") + XCTAssertGreaterThanOrEqual(height, 0, "resolution height should be >= 0 (got: \(resolution))") + } + + /** + * On iOS 13+ the refactor reads bounds/scale from the first + * UIWindowScene's window. When a scene is connected, the returned + * string must match `bounds.size * displayScale` for that window. + * When no scene is connected, the implementation falls through with + * zero values and emits "0x0" — locks in the iOS 13+ no-fallback + * behavior so regressions are visible. + */ + func test_resolution_matchesFirstSceneOrIsZero() { + guard let resolution = CountlyDeviceInfo.resolution() else { + XCTFail("resolution() returned nil on iOS") + return + } + + if let window = firstWindow() { + let scale = window.traitCollection.displayScale + let expected = "\(percentG(window.bounds.width * scale))x\(percentG(window.bounds.height * scale))" + XCTAssertEqual(resolution, expected, + "resolution should reflect first window scene's pixel size") + } else { + XCTAssertEqual(resolution, "0x0", + "iOS 13+ without a window scene should report 0x0 (no UIScreen fallback)") + } + } + + /** + * The metrics dictionary returned by CountlyDeviceInfo.metrics() + * must include the `_resolution` key — this is the SDK-facing + * contract that ends up in `/i` requests. + */ + func test_resolution_appearsInMetricsString() { + guard let metricsString = CountlyDeviceInfo.metrics() else { + XCTFail("metrics() returned nil") + return + } + XCTAssertTrue(metricsString.contains("_resolution"), + "metrics() should include the _resolution key, got: \(metricsString)") + } + + // MARK: - Helpers + + private func firstWindow() -> UIWindow? { + if #available(iOS 13.0, *) { + for scene in UIApplication.shared.connectedScenes { + if let windowScene = scene as? UIWindowScene, + let window = windowScene.windows.first { + return window + } + } + return nil + } else { + return UIApplication.shared.delegate?.window ?? nil + } + } + + /// Mirrors Objective-C `%g` formatting used by `CountlyDeviceInfo.resolution`. + /// `%g` uses the shorter of `%e` or `%f`, trims trailing zeros, and drops + /// the decimal point when not needed. `String(format:)` with `%g` matches + /// this behavior on Apple platforms. + private func percentG(_ value: CGFloat) -> String { + return String(format: "%g", Double(value)) + } +} +#endif diff --git a/CountlyTests/CountlyServerConfigTests.swift b/CountlyTests/CountlyServerConfigTests.swift index c717c0f7..a528acd9 100644 --- a/CountlyTests/CountlyServerConfigTests.swift +++ b/CountlyTests/CountlyServerConfigTests.swift @@ -5,6 +5,12 @@ class CountlyServerConfigTests: CountlyBaseTestCase { class CountTracker { var counts: [Int] = [0, 0, 0, 0, 0] + // When true, the mock returns 200 for stored /i requests and records them (in send + // order) in `sentRequests`, so the request queue actually drains and the content + // refresh queue-flush runnable fires. Default false preserves the 400 behavior that + // every other test relies on (requests stay in the RQ for index-based inspection). + var drainQueue = false + var sentRequests: [String] = [] } /// Local Countly instances created during tests. @@ -152,12 +158,91 @@ class CountlyServerConfigTests: CountlyBaseTestCase { * Verifies that the configuration is properly handled when received through the request generator. */ func test_serverConfig_withImmediateRequestGenerator() throws { - + try initServerConfigWithValues { config, serverConfig in config.urlSessionConfiguration = createUrlSessionConfigForResponse(serverConfig) } } - + + /** + * Regression test for SDK Behavior Settings (server config) deferral in temporary device ID mode. + * + * The server config fetch is an immediate request carrying device_id. When the SDK starts in + * temporary device ID mode it must not be sent (it would create a CLYTemporaryDeviceID user), but + * it must not be lost either: it should be deferred and run once a real device ID is assigned. + * + * 1- Start the SDK already in temporary device ID mode, with a mock session that counts method=sc requests + * 2- Verify NO server config request is sent while in temporary mode (inverted expectation) + * 3- Assign a real device ID (leaving temporary mode) + * 4- Verify the deferred server config request now runs + */ + func test_serverConfigFetch_isDeferred_untilLeavingTemporaryDeviceIDMode() { + let realDeviceID = "real_user_after_temp" + + // A distinctive server config so we can prove the re-fetched response is actually applied + // through the cached config (i.e. the cached config is valid and the re-fetch works end-to-end). + let builder = ServerConfigBuilder() + .networking(true) + .sessionUpdateInterval(120) + .limitKeyLength(89) + .limitValueSize(43) + let serverConfigJson = builder.build() + + let noFetchInTempMode = self.expectation(description: "No server config fetch while in temporary device ID mode") + noFetchInTempMode.isInverted = true + let fetchAfterExit = self.expectation(description: "Deferred server config fetch runs after leaving temporary mode") + fetchAfterExit.assertForOverFulfill = false + + var inTemporaryMode = true + var capturedSCRequestURL: String? + MockURLProtocol.requestHandler = { request in + let urlString = request.url?.absoluteString ?? "" + if urlString.contains("method=sc") { + if inTemporaryMode { + noFetchInTempMode.fulfill() + } else { + capturedSCRequestURL = urlString + fetchAfterExit.fulfill() + } + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (serverConfigJson.data(using: .utf8), response, nil) + } + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return ("{}".data(using: .utf8), response, nil) + } + + let config = TestUtils.createBaseConfig() + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [MockURLProtocol.self] + config.urlSessionConfiguration = sessionConfig + config.enableTemporaryDeviceIDMode() + + let countly = createLocalCountly() + countly.start(with: config) + + // Phase 1: nothing should hit the server config endpoint while in temporary mode + wait(for: [noFetchInTempMode], timeout: 4) + + // Phase 2: leaving temporary mode should run the deferred fetch using the cached config + inTemporaryMode = false + countly.changeDeviceIDWithoutMerge(realDeviceID) + + wait(for: [fetchAfterExit], timeout: 10) + + // The re-fetch must carry the real device ID (the cached config / ordering must not leak the + // temporary device ID, which is what created the CLYTemporaryDeviceID user in the first place). + XCTAssertNotNil(capturedSCRequestURL, "A server config request should have been made after leaving temporary mode") + XCTAssertTrue(capturedSCRequestURL?.contains("device_id=\(realDeviceID)") ?? false, "SBS re-fetch must use the real device ID") + XCTAssertFalse(capturedSCRequestURL?.contains("CLYTemporaryDeviceID") ?? true, "SBS re-fetch must not leak the temporary device ID") + // The re-fetch must use the cached config's app key. + XCTAssertTrue(capturedSCRequestURL?.contains("app_key=\(TestUtils.commonAppKey)") ?? false, "SBS re-fetch must use the cached config app key") + + // The re-fetched response must actually be applied to the SDK. This proves the cached config is + // valid and the re-fetch path works end-to-end (not just that a request was sent). + TestUtils.sleep(2, { builder.validateAgainst() }) + } + + /** * Tests that all features work correctly with default server configuration. * Verifies that all SDK features (sessions, events, views, crashes, etc.) function as expected @@ -259,24 +344,33 @@ class CountlyServerConfigTests: CountlyBaseTestCase { func test_sessionsDisabled_allFeatures() throws { let sc = ServerConfigBuilder() sc.sessionTracking(false) - let tracker = setupTestAllFeatures(sc.buildJson()) - + let tracker = setupTestAllFeatures(sc.buildJson(), drainQueue: true) + XCTAssertEqual(0, TestUtils.getCurrentRQ()?.count) XCTAssertEqual(0, TestUtils.getCurrentEQ()?.count) - + let stackTrace = flowAllFeatures() - + immediateFlowAllFeatures() + XCTAssertEqual(0, TestUtils.getCurrentEQ()?.count) + feedbackFlowAllFeatures() + waitForQueueToDrain(tracker, expected: 8) + XCTAssertEqual(0, TestUtils.getCurrentEQ()?.count) + + let sent = tracker.sentRequests + XCTAssertEqual(8, sent.count, "Sent request count mismatch") + + // Sessions disabled => no begin_session; the crash request is first. TestUtils.validateRequest([:], 0, { request in let crash = request["crash"] as? [String: Any] XCTAssertTrue(crash != nil) - }) - try TestUtils.validateEventInRQ("test_event", [:], 1, 7, 0, 2) - try TestUtils.validateEventInRQ("[CLY]_view", ["name": "test_view", "segment": "iOS", "visit": "1"], 1, 7, 1, 2) + }, from: sent) + try TestUtils.validateEventInRQ("test_event", [:], 1, 7, 0, 2, from: sent) + try TestUtils.validateEventInRQ("[CLY]_view", ["name": "test_view", "segment": "iOS", "visit": "1"], 1, 7, 1, 2, from: sent) TestUtils.validateRequest([:], 2, { request in let userDetails = request["user_details"] as! [String: Any] XCTAssertTrue(TestUtils.compareDictionaries(userDetails["custom"] as! [String: Any], ["test_property": "test_value"])) - }) - TestUtils.validateRequest(["location": "33.689500,139.691700"], 3) + }, from: sent) + TestUtils.validateRequest(["location": "33.689500,139.691700"], 3, from: sent) TestUtils.validateRequest([:], 4, { request in let apm = request["apm"] as! [String: Any] XCTAssertEqual("test_trace", apm["name"] as! String) @@ -286,16 +380,9 @@ class CountlyServerConfigTests: CountlyBaseTestCase { "response_code": 400, "request_payload_size": 2000, "response_payload_size": 2000])) - }) - TestUtils.validateRequest(["attribution_data": "test_data"], 5) - TestUtils.validateRequest(["key": "value"], 6) - - XCTAssertEqual(7, TestUtils.getCurrentRQ()?.count) - immediateFlowAllFeatures() - - XCTAssertEqual(0, TestUtils.getCurrentEQ()?.count) - feedbackFlowAllFeatures() - XCTAssertEqual(0, TestUtils.getCurrentEQ()?.count) + }, from: sent) + TestUtils.validateRequest(["attribution_data": "test_data"], 5, from: sent) + TestUtils.validateRequest(["key": "value"], 6, from: sent) try TestUtils.validateEventInRQ("[CLY]_star_rating", [ "platform": "iOS", @@ -305,16 +392,14 @@ class CountlyServerConfigTests: CountlyBaseTestCase { "contactMe": "1", "email": "test", "comment": "test" - ], 7, 8, 0, 2) - + ], 7, 8, 0, 2, from: sent) + try TestUtils.validateEventInRQ("[CLY]_nps", [ "app_version": CountlyDeviceInfo.appVersion()!, "widget_id": "test", "closed": "1", "platform": "iOS" - ], 7, 8, 1, 2) - - XCTAssertEqual(8, TestUtils.getCurrentRQ()?.count) + ], 7, 8, 1, 2, from: sent) validateCounts(tracker.counts, hc: 1, fc: 1, rc: 1, cc: 2, sc: 1) } @@ -500,28 +585,29 @@ class CountlyServerConfigTests: CountlyBaseTestCase { func test_eventsDisabled_allFeatures() throws { let sc = ServerConfigBuilder() .customEventTracking(false) - let tracker = setupTestAllFeatures(sc.buildJson()) - + let tracker = setupTestAllFeatures(sc.buildJson(), drainQueue: true) + XCTAssertEqual(0, TestUtils.getCurrentRQ()?.count) XCTAssertEqual(0, TestUtils.getCurrentEQ()?.count) - + let stackTrace = flowAllFeatures() immediateFlowAllFeatures() + waitForQueueToDrain(tracker, expected: 8) + let sent = tracker.sentRequests - - XCTAssertTrue(TestUtils.getCurrentRQ()![0].contains("begin_session")) + XCTAssertTrue(sent[0].contains("begin_session")) // Events should not be tracked - XCTAssertFalse(containsEventWithKey(TestUtils.getCurrentRQ()!, "test_event")) - + XCTAssertFalse(containsEventWithKey(sent, "test_event")) + // But other features should work - try TestUtils.validateEventInRQ("[CLY]_view", ["name": "test_view", "segment": "iOS", "visit": "1", "start": "1"], 2, 7, 0, 1) + try TestUtils.validateEventInRQ("[CLY]_view", ["name": "test_view", "segment": "iOS", "visit": "1", "start": "1"], 2, 7, 0, 1, from: sent) TestUtils.validateRequest([:], 3, { request in let userDetails = request["user_details"] as! [String: Any] XCTAssertTrue(TestUtils.compareDictionaries(userDetails["custom"] as! [String: Any], ["test_property": "test_value"])) - }) - - XCTAssertEqual(8, TestUtils.getCurrentRQ()?.count) - XCTAssertFalse(containsEventWithKey(TestUtils.getCurrentRQ()!, "test_event")) + }, from: sent) + + XCTAssertEqual(8, sent.count) + XCTAssertFalse(containsEventWithKey(sent, "test_event")) XCTAssertTrue(TestUtils.getCurrentEQ()!.isEmpty) validateCounts(tracker.counts, hc: 1, fc: 1, rc: 1, cc: 2, sc: 1) } @@ -535,21 +621,23 @@ class CountlyServerConfigTests: CountlyBaseTestCase { func test_crashesDisabled_allFeatures() throws { let sc = ServerConfigBuilder() .crashReporting(false) - let tracker = setupTestAllFeatures(sc.buildJson()) - + let tracker = setupTestAllFeatures(sc.buildJson(), drainQueue: true) + XCTAssertEqual(0, TestUtils.getCurrentRQ()?.count) - + let stackTrace = flowAllFeatures() immediateFlowAllFeatures() + waitForQueueToDrain(tracker, expected: 7) + let sent = tracker.sentRequests // Verify that crash is not recorded - for request in TestUtils.getCurrentRQ()! { + for request in sent { XCTAssertFalse(request.contains("crash=")) } - + // But other features should work - XCTAssertTrue(TestUtils.getCurrentRQ()![0].contains("begin_session")) - try TestUtils.validateEventInRQ("test_event", [:], 2, 7, 0, 2) - + XCTAssertTrue(sent[0].contains("begin_session")) + try TestUtils.validateEventInRQ("test_event", [:], 2, 7, 0, 2, from: sent) + validateCounts(tracker.counts, hc: 1, fc: 1, rc: 1, cc: 2, sc: 1) } @@ -562,33 +650,35 @@ class CountlyServerConfigTests: CountlyBaseTestCase { .sessionTracking(false) .customEventTracking(false) .viewTracking(false) - let tracker = setupTestAllFeatures(sc.buildJson()) - + let tracker = setupTestAllFeatures(sc.buildJson(), drainQueue: true) + XCTAssertEqual(0, TestUtils.getCurrentRQ()?.count) - + let stackTrace = flowAllFeatures() immediateFlowAllFeatures() + waitForQueueToDrain(tracker, expected: 6) + let sent = tracker.sentRequests // Verify no sessions, events, or views are recorded - for request in TestUtils.getCurrentRQ()! { + for request in sent { XCTAssertFalse(request.contains("begin_session")) - XCTAssertFalse(containsEventWithKey(TestUtils.getCurrentRQ()!, "test_event")) - XCTAssertFalse(containsEventWithKey(TestUtils.getCurrentRQ()!, "[CLY]_view")) + XCTAssertFalse(containsEventWithKey(sent, "test_event")) + XCTAssertFalse(containsEventWithKey(sent, "[CLY]_view")) } - - XCTAssertEqual(6, TestUtils.getCurrentRQ()?.count) - + + XCTAssertEqual(6, sent.count) + TestUtils.validateRequest([:], 0, { request in XCTAssertTrue(request.contains(where: { (key: String, value: Any) in return key.elementsEqual("crash") })) - }) + }, from: sent) TestUtils.validateRequest([:], 1, { request in let userDetails = request["user_details"] as! [String: Any] XCTAssertTrue(TestUtils.compareDictionaries(userDetails["custom"] as! [String: Any], ["test_property": "test_value"])) - }) - TestUtils.validateRequest(["location": "33.689500,139.691700"], 2) + }, from: sent) + TestUtils.validateRequest(["location": "33.689500,139.691700"], 2, from: sent) + - validateCounts(tracker.counts, hc: 1, fc: 1, rc: 1, cc: 2, sc: 1) } @@ -804,9 +894,44 @@ class CountlyServerConfigTests: CountlyBaseTestCase { } - private func setupTestAllFeatures(_ serverConfig: [String: Any]) -> CountTracker { + /// Returns the query string of a captured request: the POST body (read from the body + /// stream, since URLProtocol nils out `httpBody`) when present, otherwise the URL query. + /// `parseQueryString` expects a bare `app_key=...&...` string, which is exactly what the + /// stored /i request body contains. + static func capturedQueryString(_ request: URLRequest) -> String { + if let body = request.httpBody, let s = String(data: body, encoding: .utf8), !s.isEmpty { + return s + } + if let stream = request.httpBodyStream { + stream.open() + defer { stream.close() } + let bufferSize = 8192 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { buffer.deallocate() } + var data = Data() + while stream.hasBytesAvailable { + let read = stream.read(buffer, maxLength: bufferSize) + if read > 0 { data.append(buffer, count: read) } else { break } + } + if let s = String(data: data, encoding: .utf8), !s.isEmpty { return s } + } + return request.url?.query ?? "" + } + + /// Spins until the stored request queue has fully drained (so the captured `sentRequests` + /// are complete and the content-refresh queue-flush runnable has run). Used by the + /// drainQueue-mode tests. + private func waitForQueueToDrain(_ tracker: CountTracker, expected: Int) { + for _ in 0..<12 { + if tracker.sentRequests.count >= expected && (TestUtils.getCurrentRQ()?.isEmpty ?? true) { break } + TestUtils.sleep(0.5) {} + } + } + + private func setupTestAllFeatures(_ serverConfig: [String: Any], drainQueue: Bool = false) -> CountTracker { let tracker = CountTracker() - + tracker.drainQueue = drainQueue + // Define the mock behavior MockURLProtocol.requestHandler = { request in let requestString = request.url?.absoluteString ?? "" @@ -828,8 +953,14 @@ class CountlyServerConfigTests: CountlyBaseTestCase { } catch { //ignored } + } else if tracker.drainQueue { + // Stored /i request: record its query string (POST body / GET query) in send + // order and return success so the request queue drains, letting the + // content-refresh queue-flush runnable run. + tracker.sentRequests.append(CountlyServerConfigTests.capturedQueryString(request)) + responseString = "{\"result\":\"Success\"}".data(using: .utf8) } - + if(responseString != nil){ return (responseString, HTTPURLResponse(url: request.url!, statusCode: 200, @@ -923,27 +1054,44 @@ class CountlyServerConfigTests: CountlyBaseTestCase { let sc = ServerConfigBuilder() consumer(sc) - let tracker = setupTestAllFeatures(sc.buildJson()) - + let tracker = setupTestAllFeatures(sc.buildJson(), drainQueue: true) + XCTAssertEqual(0, TestUtils.getCurrentRQ()?.count) XCTAssertEqual(0, TestUtils.getCurrentEQ()?.count) - + + // Record the whole flow first. Under drainQueue the stored /i requests are answered + // with 200 and removed as they go, so we validate against the ordered `sentRequests` + // the mock saw (same FIFO order as the live RQ) rather than the live RQ, which empties + // asynchronously. Draining the queue is also what lets refreshContentZone's queue-flush + // runnable fire, producing the second content request (cc == 2). let stackTrace = flowAllFeatures() - XCTAssertEqual(8, TestUtils.getCurrentRQ()?.count) + immediateFlowAllFeatures() + XCTAssertEqual(0, TestUtils.getCurrentEQ()?.count) + feedbackFlowAllFeatures() - XCTAssertTrue(TestUtils.getCurrentRQ()![0].contains("begin_session")) + // Wait until the queue has fully drained (all 9 stored requests sent). + for _ in 0..<10 { + if tracker.sentRequests.count >= 9 && (TestUtils.getCurrentRQ()?.isEmpty ?? true) { break } + TestUtils.sleep(0.5) {} + } + XCTAssertEqual(0, TestUtils.getCurrentEQ()?.count) + + let sent = tracker.sentRequests + XCTAssertEqual(9, sent.count, "Sent request count mismatch") + + XCTAssertTrue(sent[0].contains("begin_session")) TestUtils.validateRequest([:], 1, { request in let crash = request["crash"] as? [String: Any] XCTAssertTrue(crash != nil) - }) + }, from: sent) //try TestUtils.validateEventInRQ("[CLY]_orientation", ["mode": "portrait"], 2, 8, 0, 3) - try TestUtils.validateEventInRQ("test_event", [:], 2, 8, 0, 2) // 1, 3 - try TestUtils.validateEventInRQ("[CLY]_view", ["name": "test_view", "segment": "iOS", "visit": "1", "start": "1"], 2, 8, 1, 2) // 2, 3 + try TestUtils.validateEventInRQ("test_event", [:], 2, 8, 0, 2, from: sent) // 1, 3 + try TestUtils.validateEventInRQ("[CLY]_view", ["name": "test_view", "segment": "iOS", "visit": "1", "start": "1"], 2, 8, 1, 2, from: sent) // 2, 3 TestUtils.validateRequest([:], 3, { request in let userDetails = request["user_details"] as! [String: Any] XCTAssertTrue(TestUtils.compareDictionaries(userDetails["custom"] as! [String: Any], ["test_property": "test_value"])) - }) - TestUtils.validateRequest(["location": "33.689500,139.691700"], 4) + }, from: sent) + TestUtils.validateRequest(["location": "33.689500,139.691700"], 4, from: sent) TestUtils.validateRequest([:], 5, { request in let apm = request["apm"] as! [String: Any] XCTAssertEqual("test_trace", apm["name"] as! String) @@ -953,17 +1101,10 @@ class CountlyServerConfigTests: CountlyBaseTestCase { "response_code": 400, "request_payload_size": 2000, "response_payload_size": 2000])) - }) - TestUtils.validateRequest(["attribution_data": "test_data"], 6) - TestUtils.validateRequest(["key": "value"], 7) - - immediateFlowAllFeatures() - XCTAssertEqual(0, TestUtils.getCurrentEQ()?.count) - feedbackFlowAllFeatures() + }, from: sent) + TestUtils.validateRequest(["attribution_data": "test_data"], 6, from: sent) + TestUtils.validateRequest(["key": "value"], 7, from: sent) - XCTAssertEqual(0, TestUtils.getCurrentEQ()?.count) - XCTAssertEqual(9, TestUtils.getCurrentRQ()?.count) - try TestUtils.validateEventInRQ("[CLY]_star_rating", [ "platform": "iOS", "app_version": CountlyDeviceInfo.appVersion()!, @@ -972,17 +1113,15 @@ class CountlyServerConfigTests: CountlyBaseTestCase { "contactMe": 1, "email": "test", "comment": "test" - ], 8, 9, 0, 2) - + ], 8, 9, 0, 2, from: sent) + try TestUtils.validateEventInRQ("[CLY]_nps", [ "app_version": CountlyDeviceInfo.appVersion()!, "widget_id": "test", "closed": "1", "platform": "iOS" - ], 8, 9, 1, 2) - - XCTAssertEqual(9, TestUtils.getCurrentRQ()?.count) - + ], 8, 9, 1, 2, from: sent) + validateCounts(tracker.counts, hc: hc, fc: fc, rc: rc, cc: cc, sc: scc) } diff --git a/CountlyTests/CountlyServerConfigValidationTests.swift b/CountlyTests/CountlyServerConfigValidationTests.swift new file mode 100644 index 00000000..64bd0219 --- /dev/null +++ b/CountlyTests/CountlyServerConfigValidationTests.swift @@ -0,0 +1,468 @@ +import XCTest +@testable import Countly + +/// Tests for the server-config validation, persistence cleanup, +/// per-category filter conflict resolution, and consent-during-init +/// behaviors introduced alongside the session_immediates changes. +class CountlyServerConfigValidationTests: CountlyBaseTestCase { + + private let persistencyKey = "kCountlyServerConfigPersistencyKey" + private var localCountlyInstances: [Countly] = [] + + override func setUp() { + super.setUp() + TestUtils.cleanup() + localCountlyInstances = [] + UserDefaults.standard.removeObject(forKey: persistencyKey) + UserDefaults.standard.synchronize() + } + + override func tearDown() { + for instance in localCountlyInstances { + instance.halt(true) + } + localCountlyInstances = [] + if Countly.sharedInstance() != nil { + Countly.sharedInstance().halt(true) + } + UserDefaults.standard.removeObject(forKey: persistencyKey) + UserDefaults.standard.synchronize() + super.tearDown() + } + + private func createLocalCountly() -> Countly { + let instance = Countly() + localCountlyInstances.append(instance) + return instance + } + + private func setStoredServerConfig(_ payload: [String: Any]) { + UserDefaults.standard.set(payload, forKey: persistencyKey) + UserDefaults.standard.synchronize() + } + + private func storedServerConfig() -> [String: Any] { + return UserDefaults.standard.object(forKey: persistencyKey) as? [String: Any] ?? [:] + } + + private func storedConfigDictionary() -> [String: Any] { + return storedServerConfig()["c"] as? [String: Any] ?? [:] + } + + private func makeServerConfig(_ config: [String: Any]) -> [String: Any] { + return [ + "v": 1, + "t": Int(Date().timeIntervalSince1970), + "c": config + ] + } + + // MARK: - Type validation + + /// Invalid types for boolean keys must be stripped from the persisted config + /// and must not change the corresponding SDK property. + func test_validation_boolKeyWithInvalidType_isStrippedAndDefaultsKept() { + setStoredServerConfig(makeServerConfig([ + "tracking": "not a bool", // wrong type + "networking": 42, // wrong type (integer, not bool) + "crt": true // valid bool + ])) + + let cfg = TestUtils.createBaseConfig() + cfg.enableServerConfiguration = true + cfg.manualSessionHandling = true + let countly = createLocalCountly() + countly.start(with: cfg) + + // Valid bool was applied; invalid ones fell back to default (YES) + XCTAssertTrue(CountlyServerConfig.sharedInstance().trackingEnabled()) + XCTAssertTrue(CountlyServerConfig.sharedInstance().networkingEnabled()) + XCTAssertTrue(CountlyServerConfig.sharedInstance().crashReportingEnabled()) + + let persisted = storedConfigDictionary() + XCTAssertNil(persisted["tracking"], "Invalid bool type should be stripped from persistence") + XCTAssertNil(persisted["networking"], "Invalid bool type should be stripped from persistence") + XCTAssertEqual(persisted["crt"] as? Bool, true) + } + + /// Invalid types for integer keys must be stripped from the persisted config. + func test_validation_integerKeyWithInvalidType_isStripped() { + setStoredServerConfig(makeServerConfig([ + "eqs": "not a number", // wrong type + "rqs": 500 // valid + ])) + + let cfg = TestUtils.createBaseConfig() + cfg.enableServerConfiguration = true + cfg.manualSessionHandling = true + let countly = createLocalCountly() + countly.start(with: cfg) + + XCTAssertEqual(500, CountlyServerConfig.sharedInstance().requestQueueSize()) + + let persisted = storedConfigDictionary() + XCTAssertNil(persisted["eqs"], "Invalid integer type should be stripped") + XCTAssertEqual(persisted["rqs"] as? Int, 500) + } + + /// Integer values below the configured minimum must be stripped. + /// `czi` (contentZoneInterval) requires minValue=16. + func test_validation_integerBelowMin_isStripped_contentZoneInterval() { + setStoredServerConfig(makeServerConfig([ + "czi": 5 // below minValue=16 + ])) + + let cfg = TestUtils.createBaseConfig() + cfg.enableServerConfiguration = true + cfg.manualSessionHandling = true + let countly = createLocalCountly() + countly.start(with: cfg) + + let persisted = storedConfigDictionary() + XCTAssertNil(persisted["czi"], "czi below min should be stripped from persistence") + } + + /// Integer values at/above the minimum should be accepted and persisted. + func test_validation_integerAtMin_isAccepted_contentZoneInterval() { + setStoredServerConfig(makeServerConfig([ + "czi": 16 + ])) + + let cfg = TestUtils.createBaseConfig() + cfg.enableServerConfiguration = true + cfg.manualSessionHandling = true + let countly = createLocalCountly() + countly.start(with: cfg) + + let persisted = storedConfigDictionary() + XCTAssertEqual(persisted["czi"] as? Int, 16) + } + + /// `dort` (dropOldRequestTime) has minValue=0 — zero is valid and must remain. + func test_validation_integerZero_isAccepted_dropOldRequestTime() { + setStoredServerConfig(makeServerConfig([ + "dort": 0 + ])) + + let cfg = TestUtils.createBaseConfig() + cfg.enableServerConfiguration = true + cfg.manualSessionHandling = true + let countly = createLocalCountly() + countly.start(with: cfg) + + let persisted = storedConfigDictionary() + XCTAssertEqual(persisted["dort"] as? Int, 0) + } + + /// Unknown keys in the config object must be stripped from persistence. + func test_validation_unknownKeys_areStripped() { + setStoredServerConfig(makeServerConfig([ + "tracking": true, // known + "totally_unknown_key": "abc", // unknown + "another_unknown": 123 // unknown + ])) + + let cfg = TestUtils.createBaseConfig() + cfg.enableServerConfiguration = true + cfg.manualSessionHandling = true + let countly = createLocalCountly() + countly.start(with: cfg) + + let persisted = storedConfigDictionary() + XCTAssertEqual(persisted["tracking"] as? Bool, true) + XCTAssertNil(persisted["totally_unknown_key"]) + XCTAssertNil(persisted["another_unknown"]) + } + + // MARK: - User-provided SDK limits preservation + + /// When server config returns valid keys but does not include a particular + /// limit, the user-provided SDK limit must not be overridden by a stale default. + func test_userLimits_preserved_whenServerConfigOmitsThem() { + // Server config provides a valid SC payload that excludes eventQueueSize + // and contentZoneInterval, but includes another valid key so that + // notifySdkConfigChange is invoked. + setStoredServerConfig(makeServerConfig([ + "tracking": true + ])) + + let cfg = TestUtils.createBaseConfig() + cfg.enableServerConfiguration = true + cfg.manualSessionHandling = true + cfg.eventSendThreshold = 7 // user-provided + cfg.storedRequestsLimit = 555 // user-provided + let countly = createLocalCountly() + countly.start(with: cfg) + + XCTAssertEqual(7, CountlyPersistency.sharedInstance().eventSendThreshold, + "User-provided eventSendThreshold must not be overridden when server config omits eqs") + XCTAssertEqual(555, CountlyPersistency.sharedInstance().storedRequestsLimit, + "User-provided storedRequestsLimit must not be overridden when server config omits rqs") + } + + // MARK: - Per-category filter conflict resolution + + /// Drives the SDK's server-config fetch through a mocked URL session so + /// that `mergeBehaviorSettings` runs with a controlled response payload. + private func mockURLSessionConfig(returning configDict: [String: Any]) -> URLSessionConfiguration { + let response = makeServerConfig(configDict) + let data = try! JSONSerialization.data(withJSONObject: response) + MockURLProtocol.requestHandler = { request in + let url = request.url?.absoluteString ?? "" + if url.contains("method=sc") { + return (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil), nil) + } + return ("{\"result\":\"fail\"}".data(using: .utf8), + HTTPURLResponse(url: request.url!, statusCode: 400, httpVersion: nil, headerFields: nil), nil) + } + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + return config + } + + /// When a new merged config provides a whitelist for one category, the + /// stored blacklist for that same category must be removed — but the + /// filter for an unrelated category must be preserved. + func test_perCategoryFilterConflict_doesNotClearUnrelatedCategories() { + // Seed persisted config with both an event blacklist and a user + // property blacklist already present. + setStoredServerConfig(makeServerConfig([ + "eb": ["blocked_event"], + "upb": ["blocked_prop"] + ])) + + // Mock a server response that only introduces an event whitelist (`ew`). + let cfg = TestUtils.createBaseConfig() + cfg.manualSessionHandling = true + cfg.urlSessionConfiguration = mockURLSessionConfig(returning: [ + "ew": ["allowed_event"] + ]) + + let countly = createLocalCountly() + countly.start(with: cfg) + + TestUtils.sleep(2) { + let persisted = self.storedConfigDictionary() + XCTAssertNil(persisted["eb"], "eb must be removed because new config provided ew") + XCTAssertEqual(persisted["ew"] as? [String], ["allowed_event"]) + XCTAssertEqual(persisted["upb"] as? [String], ["blocked_prop"], + "upb must be preserved — the new config did not touch the user-property category") + } + } + + /// Conflict resolution must work independently per category — providing + /// both a segmentation blacklist and a user-property whitelist must + /// remove only their same-category counterparts. + func test_perCategoryFilterConflict_independentAcrossCategories() { + setStoredServerConfig(makeServerConfig([ + "sw": ["s_keep"], + "upb": ["u_blocked"] + ])) + + let cfg = TestUtils.createBaseConfig() + cfg.manualSessionHandling = true + cfg.urlSessionConfiguration = mockURLSessionConfig(returning: [ + "sb": ["s_blocked"], + "upw": ["u_allowed"] + ]) + + let countly = createLocalCountly() + countly.start(with: cfg) + + TestUtils.sleep(2) { + let persisted = self.storedConfigDictionary() + XCTAssertNil(persisted["sw"], "sw must be removed because new config provided sb") + XCTAssertEqual(persisted["sb"] as? [String], ["s_blocked"]) + XCTAssertNil(persisted["upb"], "upb must be removed because new config provided upw") + XCTAssertEqual(persisted["upw"] as? [String], ["u_allowed"]) + } + } + + // MARK: - Consent during init + + /// When server config requires consent and the user config does not, + /// consent must be sent exactly once during init (not twice). + func test_consent_sentOnceDuringInit_whenServerConfigEnablesConsent() { + setStoredServerConfig(makeServerConfig([ + "cr": true + ])) + + let cfg = TestUtils.createBaseConfig() + cfg.enableServerConfiguration = true + cfg.manualSessionHandling = true + // Do NOT set requiresConsent on the user config — only the server config requires it. + let countly = createLocalCountly() + countly.start(with: cfg) + + TestUtils.sleep(1) { + let rq = TestUtils.getCurrentRQ() ?? [] + let consentRequests = rq.filter { $0.contains("consent=") } + XCTAssertEqual(1, consentRequests.count, + "Consent should be queued exactly once during init, got requests: \(rq)") + } + } + + // MARK: - ImmediateURLSession + + /// The new ImmediateURLSession must propagate any HTTP additional headers + /// configured on the URLSessionConfiguration. + func test_immediateURLSession_propagatesAdditionalHeaders() { + let cfg = TestUtils.createBaseConfig() + let sessionConfig = URLSessionConfiguration.default + sessionConfig.httpAdditionalHeaders = ["X-Immediate-Test": "value1"] + cfg.urlSessionConfiguration = sessionConfig + cfg.manualSessionHandling = true + + Countly.sharedInstance().start(with: cfg) + + let immediate = CountlyCommon.sharedInstance().immediateURLSession() + let headers = immediate.configuration.httpAdditionalHeaders as? [String: String] + XCTAssertEqual(headers?["X-Immediate-Test"], "value1", + "ImmediateURLSession must inherit configured HTTPAdditionalHeaders") + } + + /// ImmediateURLSession must also propagate `protocolClasses` so that + /// callers (e.g. tests using a mock protocol) and pinned certs continue + /// to work for non-queued requests. + func test_immediateURLSession_propagatesProtocolClasses() { + // Provide a noop handler so background traffic during SDK init does + // not trigger MockURLProtocol's "handler not set" XCTFail. + MockURLProtocol.requestHandler = { request in + return ( + "{\"result\":\"ok\"}".data(using: .utf8), + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil), + nil + ) + } + + let cfg = TestUtils.createBaseConfig() + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [MockURLProtocol.self] + cfg.urlSessionConfiguration = sessionConfig + cfg.manualSessionHandling = true + + Countly.sharedInstance().start(with: cfg) + + let immediate = CountlyCommon.sharedInstance().immediateURLSession() + let names = (immediate.configuration.protocolClasses ?? []).map { String(describing: $0) } + XCTAssertTrue(names.contains("MockURLProtocol"), + "ImmediateURLSession must propagate user-provided protocolClasses, got: \(names)") + } + + /// ImmediateURLSession must not be constrained by the SDK's configured + /// request timeout. The timeout on the returned session should match the + /// system default (60s), regardless of whatever the queued URLSession was + /// set to. + func test_immediateURLSession_doesNotInheritSdkRequestTimeout() { + let cfg = TestUtils.createBaseConfig() + cfg.manualSessionHandling = true + cfg.requestTimeoutDuration = 5 // SDK timeout small enough to detect + + Countly.sharedInstance().start(with: cfg) + + let immediate = CountlyCommon.sharedInstance().immediateURLSession() + XCTAssertEqual(60.0, immediate.configuration.timeoutIntervalForRequest, accuracy: 0.001, + "ImmediateURLSession should not adopt the SDK's request timeout — that's the bug being mitigated") + } + + // MARK: - Server config persistence is the cleaned dictionary + + /// After populate, the persisted config must reflect the cleaned version + /// of the dictionary (no invalid types, no unknown keys, no out-of-range + /// values). Stale entries from a previous launch must not leak through. + func test_serverConfig_persistedDictionaryReflectsCleaning() { + setStoredServerConfig(makeServerConfig([ + "tracking": true, // kept + "networking": "not a bool", // stripped (type) + "czi": 5, // stripped (below min 16) + "unknown_x": [1, 2, 3] // stripped (unknown) + ])) + + let cfg = TestUtils.createBaseConfig() + cfg.enableServerConfiguration = true + cfg.manualSessionHandling = true + let countly = createLocalCountly() + countly.start(with: cfg) + + let persisted = storedConfigDictionary() + XCTAssertEqual(persisted["tracking"] as? Bool, true) + XCTAssertNil(persisted["networking"]) + XCTAssertNil(persisted["czi"]) + XCTAssertNil(persisted["unknown_x"]) + } + + // MARK: - Consent gated by hasFinishedInit + + /// hasFinishedInit must be NO before start completes and YES afterwards. + /// This is what gates the early consent-send path in populateServerConfig. + func test_hasFinishedInit_flipsToTrueAfterStartCompletes() { + XCTAssertFalse(CountlyCommon.sharedInstance().hasFinishedInit, + "hasFinishedInit must start NO before init") + + let cfg = TestUtils.createBaseConfig() + cfg.manualSessionHandling = true + Countly.sharedInstance().start(with: cfg) + + XCTAssertTrue(CountlyCommon.sharedInstance().hasFinishedInit, + "hasFinishedInit must be YES after start completes") + + Countly.sharedInstance().halt(true) + XCTAssertFalse(CountlyCommon.sharedInstance().hasFinishedInit, + "hasFinishedInit must reset to NO on halt") + } + + // MARK: - Connection manager queue-concurrency guard + + /// Calling `proceedOnQueue` rapidly when a request is in flight must not + /// double-send the same request. The atomic `isProcessingQueue` flag + /// guards against re-entrancy. + /// + /// This test counts outbound hits for a uniquely-marked request and + /// asserts at most one is sent even when proceedOnQueue is invoked + /// concurrently from many threads. + func test_proceedOnQueue_doesNotDoubleSendHeadRequest() { + let lock = NSLock() + var markerHits = 0 + MockURLProtocol.requestHandler = { request in + let url = request.url?.absoluteString ?? "" + if url.contains("unique_marker=x") { + lock.lock() + markerHits += 1 + lock.unlock() + } + return ( + "{\"result\":\"ok\"}".data(using: .utf8), + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil), + nil + ) + } + + let cfg = TestUtils.createBaseConfig() + cfg.manualSessionHandling = true + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [MockURLProtocol.self] + cfg.urlSessionConfiguration = sessionConfig + + Countly.sharedInstance().start(with: cfg) + + // Queue a single uniquely-marked request and pound proceedOnQueue from many threads. + Countly.sharedInstance().addDirectRequest(["unique_marker": "x"]) + let group = DispatchGroup() + for _ in 0..<32 { + group.enter() + DispatchQueue.global().async { + CountlyConnectionManager.sharedInstance().proceedOnQueue() + group.leave() + } + } + group.wait() + + TestUtils.sleep(2) { + lock.lock() + let hits = markerHits + lock.unlock() + XCTAssertEqual(1, hits, + "The marker request must be sent exactly once even with 32 concurrent proceedOnQueue calls; got \(hits)") + } + } +} diff --git a/CountlyTests/CountlyUserProfileTests.swift b/CountlyTests/CountlyUserProfileTests.swift index 3b4d1dd6..2ee08eaa 100644 --- a/CountlyTests/CountlyUserProfileTests.swift +++ b/CountlyTests/CountlyUserProfileTests.swift @@ -25,6 +25,15 @@ class CountlyUserProfileTests: CountlyBaseTestCase { super.setUp() // Initialize or reset necessary objects here Countly.sharedInstance().halt(true) + + // sdkInternalLimits() returns a file-scope static singleton, not a + // per-config instance — so a test setting setMaxValueSize/setMaxKeyLength + // mutates state visible to every later test. Reset to defaults here. + let limits = CountlyConfig().sdkInternalLimits() + limits.setMaxKeyLength(128) + limits.setMaxValueSize(256) + limits.setMaxValueSizePicture(4096) + limits.setMaxSegmentationValues(100) } override func tearDown() { @@ -496,6 +505,8 @@ class CountlyUserProfileTests: CountlyBaseTestCase { } func getUserDataMap()-> [String: Any]{ + // Single-call $push/$pull/$addToSet serialize as the bare value; + // accumulation across multiple calls upgrades to array. let userProperties = ["a12345": "My Property", "b12345": ["$inc": 1], "c12345": ["$inc": 10], @@ -515,7 +526,518 @@ class CountlyUserProfileTests: CountlyBaseTestCase { Countly.user().set("a12345", value: "3"); Countly.user().set("a12345", value: "4"); } - + + // MARK: - New API tests (Android `ModuleUserProfileTests` parity) + + /// Android parity: `setAndSaveValues` (line 40) + /// `setProperties` with named + custom keys, then `save`, produces a single + /// user_details request with named fields at top level and custom keys nested. + func test_setAndSaveValues_namedAndCustomBundled() { + let config = createBaseConfig() + config.requiresConsent = false + config.manualSessionHandling = true + Countly.sharedInstance().start(with: config) + + let userProperties: [String: Any] = [ + "name": "Test Test", + "username": "test", + "email": "test@gmail.com", + "organization": "Tester", + "phone": "+1234567890", + "gender": "M", + "picture": "http://domain.com/test.png", + "byear": 2000, + "key1": "value1", + "key2": "value2" + ] + + Countly.user().setProperties(userProperties) + Countly.user().save() + + guard let rq = TestUtils.getCurrentRQ() else { + XCTFail("RQ is nil"); return + } + guard let userDetailsRequest = rq.first(where: { $0.contains("user_details=") }) else { + XCTFail("No user_details request in RQ"); return + } + + let parsed = TestUtils.parseQueryString(userDetailsRequest) + guard let userDetails = parsed["user_details"] as? [String: Any] else { + XCTFail("user_details missing or not a dict"); return + } + + XCTAssertEqual("Test Test", userDetails["name"] as? String) + XCTAssertEqual("test", userDetails["username"] as? String) + XCTAssertEqual("test@gmail.com", userDetails["email"] as? String) + XCTAssertEqual("Tester", userDetails["organization"] as? String) + XCTAssertEqual("+1234567890", userDetails["phone"] as? String) + XCTAssertEqual("M", userDetails["gender"] as? String) + XCTAssertEqual("http://domain.com/test.png", userDetails["picture"] as? String) + XCTAssertEqual(2000, userDetails["byear"] as? Int) + + guard let custom = userDetails["custom"] as? [String: Any] else { + XCTFail("custom dict missing"); return + } + XCTAssertEqual("value1", custom["key1"] as? String) + XCTAssertEqual("value2", custom["key2"] as? String) + } + + /// Android parity: `testClear` (line 289) + /// After `clear`, `hasUnsyncedChanges` returns false and `save` produces no + /// user_details request. + func test_clear_dropsAllPendingChanges() { + let config = createBaseConfig() + config.requiresConsent = false + config.manualSessionHandling = true + Countly.sharedInstance().start(with: config) + + Countly.user().setProperties([ + "name": "Test", + "email": "test@example.com", + "key1": "value1" + ]) + XCTAssertTrue(Countly.user().hasUnsyncedChanges()) + + // `Countly.user().clear()` is ambiguous from Swift due to a duplicated + // `clear` import (Countly module + bridging-header view of + // CountlyUserDetails.h). Resolving via runtime selector lookup. + _ = Countly.user().perform(NSSelectorFromString("clear")) + XCTAssertFalse(Countly.user().hasUnsyncedChanges()) + + let rqBefore = TestUtils.getCurrentRQ()?.count ?? 0 + Countly.user().save() + let rqAfter = TestUtils.getCurrentRQ()?.count ?? 0 + XCTAssertEqual(rqBefore, rqAfter, "save() after clear() must not enqueue a request") + } + + /// Android parity: `testCustomModifiers` (line 272) + /// Multiple `pushUnique` calls on the same key accumulate into an array + /// (the array-merge fix). Scalar values like `$inc` and `$mul` stay as numbers. + func test_customModifiers_addToSetAccumulates() { + let config = createBaseConfig() + config.requiresConsent = false + config.manualSessionHandling = true + Countly.sharedInstance().start(with: config) + + Countly.user().increment(by: "key_inc", value: 1) + Countly.user().multiply("key_mul", value: 2) + Countly.user().pushUnique("key_set", value: "test1") + Countly.user().pushUnique("key_set", value: "test2") + Countly.user().save() + + guard let rq = TestUtils.getCurrentRQ(), + let request = rq.first(where: { $0.contains("user_details=") }) else { + XCTFail("No user_details request"); return + } + + let parsed = TestUtils.parseQueryString(request) + guard let userDetails = parsed["user_details"] as? [String: Any], + let custom = userDetails["custom"] as? [String: Any] else { + XCTFail("custom missing"); return + } + + XCTAssertEqual(1, (custom["key_inc"] as? [String: Any])?["$inc"] as? Int) + XCTAssertEqual(2, (custom["key_mul"] as? [String: Any])?["$mul"] as? Int) + + guard let setEntry = custom["key_set"] as? [String: Any], + let setArray = setEntry["$addToSet"] as? [Any] else { + XCTFail("key_set should hold an $addToSet array"); return + } + XCTAssertEqual(2, setArray.count) + XCTAssertEqual("test1", setArray[0] as? String) + XCTAssertEqual("test2", setArray[1] as? String) + } + + /// Android parity: `testCustomData` (line 243) + /// `setProperty` with a custom key lands in the custom dict. + func test_setProperty_customKeyLandsInCustom() { + let config = createBaseConfig() + config.requiresConsent = false + config.manualSessionHandling = true + Countly.sharedInstance().start(with: config) + + Countly.user().setProperties(["key1": "value1", "key2": "value2"]) + Countly.user().setProperty("key_prop", value: "value_prop") + Countly.user().save() + + guard let rq = TestUtils.getCurrentRQ(), + let request = rq.first(where: { $0.contains("user_details=") }) else { + XCTFail("No user_details request"); return + } + + let parsed = TestUtils.parseQueryString(request) + guard let userDetails = parsed["user_details"] as? [String: Any], + let custom = userDetails["custom"] as? [String: Any] else { + XCTFail("custom missing"); return + } + XCTAssertEqual("value1", custom["key1"] as? String) + XCTAssertEqual("value2", custom["key2"] as? String) + XCTAssertEqual("value_prop", custom["key_prop"] as? String) + } + + /// Android parity: `internalLimit_testCustomData` (line 441) + /// With maxKeyLength=10, custom keys longer than 10 are truncated and merge. + /// Note (Android difference): Android does NOT truncate predefined keys; + /// iOS doesn't pass predefined names through key truncation either since the + /// named-field switch matches the full constant. + func test_internalLimit_truncatesCustomKeys() { + let config = createBaseConfig() + config.requiresConsent = false + config.manualSessionHandling = true + config.sdkInternalLimits().setMaxKeyLength(10) + Countly.sharedInstance().start(with: config) + + Countly.user().setProperties([ + "hair_color_id": 4567, + "hair_color_tone": "bold" + ]) + Countly.user().setProperty("hair_color", value: "black") + Countly.user().setProperty("hair_skin_tone", value: "yellow") + Countly.user().save() + + guard let rq = TestUtils.getCurrentRQ(), + let request = rq.first(where: { $0.contains("user_details=") }) else { + XCTFail("No user_details request"); return + } + + let parsed = TestUtils.parseQueryString(request) + guard let userDetails = parsed["user_details"] as? [String: Any], + let custom = userDetails["custom"] as? [String: Any] else { + XCTFail("custom missing"); return + } + XCTAssertEqual("black", custom["hair_color"] as? String, + "hair_color_id (truncated to hair_color), then hair_color_tone (also truncated to hair_color), then hair_color literal — last write wins") + XCTAssertEqual("yellow", custom["hair_skin_"] as? String, + "hair_skin_tone truncated to hair_skin_") + } + + /// Android parity: `internalLimit_testCustomModifiers` (line 468) + /// With maxKeyLength=10, two `push` calls on different long keys whose + /// truncated form collides should accumulate into the same array. + /// This exercises the truncated-key merge fix and the array-merge fix together. + func test_internalLimit_pushKeysCollideAndAccumulate() { + let config = createBaseConfig() + config.requiresConsent = false + config.manualSessionHandling = true + config.sdkInternalLimits().setMaxKeyLength(10) + Countly.sharedInstance().start(with: config) + + Countly.user().push("key_push_reminder", value: "test1") + Countly.user().push("key_push_rock", value: "test3") + Countly.user().save() + + guard let rq = TestUtils.getCurrentRQ(), + let request = rq.first(where: { $0.contains("user_details=") }) else { + XCTFail("No user_details request"); return + } + + let parsed = TestUtils.parseQueryString(request) + guard let userDetails = parsed["user_details"] as? [String: Any], + let custom = userDetails["custom"] as? [String: Any] else { + XCTFail("custom missing"); return + } + + guard let entry = custom["key_push_r"] as? [String: Any], + let pushArray = entry["$push"] as? [Any] else { + XCTFail("Expected key_push_r with $push array"); return + } + XCTAssertEqual(2, pushArray.count) + XCTAssertEqual("test1", pushArray[0] as? String) + XCTAssertEqual("test3", pushArray[1] as? String) + } + + /// Android parity: `internalLimit_setProperties_maxValueSizePicture` (line 591) + /// Picture URL uses the picture-specific limit (4096), independent of maxValueSize. + func test_internalLimit_pictureUsesItsOwnLimit() { + let config = createBaseConfig() + config.requiresConsent = false + config.manualSessionHandling = true + config.sdkInternalLimits().setMaxValueSize(2) + Countly.sharedInstance().start(with: config) + + let longURL = String(repeating: "a", count: 6000) + Countly.user().setProperty("picture", value: longURL) + Countly.user().save() + + guard let rq = TestUtils.getCurrentRQ(), + let request = rq.first(where: { $0.contains("user_details=") }) else { + XCTFail("No user_details request"); return + } + + let parsed = TestUtils.parseQueryString(request) + guard let userDetails = parsed["user_details"] as? [String: Any] else { + XCTFail("user_details missing"); return + } + let pic = userDetails["picture"] as? String + XCTAssertEqual(4096, pic?.count, "picture should be truncated to 4096, not maxValueSize=2") + } + + /// Android parity: `setUserProperties_null` (line 695) + /// NSNull values are skipped with a warning; nothing reaches the wire. + func test_setProperties_nullValuesSkipped() { + let config = createBaseConfig() + config.requiresConsent = false + config.manualSessionHandling = true + Countly.sharedInstance().start(with: config) + + let rqBefore = TestUtils.getCurrentRQ()?.count ?? 0 + Countly.user().setProperties(["null_key": NSNull()]) + Countly.user().save() + let rqAfter = TestUtils.getCurrentRQ()?.count ?? 0 + XCTAssertEqual(rqBefore, rqAfter, "NSNull-only setProperties + save should not enqueue a user_details request") + } + + // MARK: - iOS-specific Android-parity tests (gaps B, C, A, F, E) + + /// Gap B: empty string for a named string field is sent to the server + /// as-is — the server clears a field on "" but ignores null, so the + /// Android-style "" → null conversion would defeat the clear. + func test_emptyStringForNamedField_isSentAsIs() { + let config = createBaseConfig() + config.requiresConsent = false + config.manualSessionHandling = true + Countly.sharedInstance().start(with: config) + + Countly.user().setProperties(["name": "Joe"]) + Countly.user().save() + Countly.user().setProperty("name", value: "") + Countly.user().save() + + guard let rq = TestUtils.getCurrentRQ() else { + XCTFail("RQ is nil"); return + } + let userDetailsRequests = rq.filter { $0.contains("user_details=") } + XCTAssertGreaterThanOrEqual(userDetailsRequests.count, 2) + + let lastClearRequest = userDetailsRequests.last! + let parsed = TestUtils.parseQueryString(lastClearRequest) + guard let userDetails = parsed["user_details"] as? [String: Any] else { + XCTFail("user_details missing"); return + } + + // The empty string must survive serialization untouched (clear-on-server). + XCTAssertEqual("", userDetails["name"] as? String, + "Empty string should be sent as-is. Got: \(String(describing: userDetails["name"]))") + } + + /// Gap C parity: a negative byear is accepted and serialized as null on + /// the wire (Android behavior). It counts as a property change, so it + /// flushes pending events and dirties the cache like any other value. + func test_negativeBirthYear_serializesAsNull() { + purgePersistedState() + + let config = createBaseConfig() + config.requiresConsent = false + config.manualSessionHandling = true + Countly.sharedInstance().start(with: config) + + // A negative byear is a real property change: pending events flush. + Countly.sharedInstance().recordEvent("pendingEvent") + XCTAssertEqual(1, TestUtils.getCurrentEQ()?.count ?? -1) + XCTAssertEqual(0, TestUtils.getCurrentRQ()?.count ?? -1) + + Countly.user().setProperty("byear", value: -1) + XCTAssertEqual(0, TestUtils.getCurrentEQ()?.count ?? -1, + "byear change should flush pending events") + XCTAssertEqual(1, TestUtils.getCurrentRQ()?.count ?? -1) + + Countly.user().save() + XCTAssertEqual(2, TestUtils.getCurrentRQ()?.count ?? -1) + + guard let rq = TestUtils.getCurrentRQ(), rq.count == 2 else { + XCTFail("Expected exactly 2 requests, got: \(TestUtils.getCurrentRQ() ?? [])"); return + } + validateEvents(request: rq[0], keysToCheck: ["pendingEvent"]) + + let parsed = TestUtils.parseQueryString(rq[1]) + guard let userDetails = parsed["user_details"] as? [String: Any] else { + XCTFail("user_details missing in request: \(rq[1])"); return + } + XCTAssertTrue(userDetails["byear"] is NSNull, + "Negative byear should serialize as null. Got: \(String(describing: userDetails["byear"]))") + } + + /// Gap A parity: `picturePath` pointing at a non-existent file is dropped + /// with a warning; pictureLocalPath remains nil. + func test_picturePath_nonExistentFile_isDropped() { + let config = createBaseConfig() + config.requiresConsent = false + config.manualSessionHandling = true + Countly.sharedInstance().start(with: config) + + Countly.user().setProperty("picturePath", value: "/definitely/not/a/real/file.jpg") + Countly.user().save() + + // No request should fire because pictureLocalPath got nilled out and + // no other user-detail change happened. + guard let rq = TestUtils.getCurrentRQ() else { + XCTFail("RQ is nil"); return + } + let userDetailsRequests = rq.filter { $0.contains("user_details=") } + XCTAssertEqual(0, userDetailsRequests.count, + "Non-existent picturePath should not produce a user_details request") + } + + /// Gap E parity: changing a user property triggers an event-queue flush + /// (Android `onUserPropertiesChanged`). Walks through an interleaved + /// set-property / record-event sequence and validates EQ and RQ sizes + /// after every single call, then the exact request order and contents. + func test_propertyChange_flushesPendingEvents() { + purgePersistedState() + + let config = createBaseConfig() + config.requiresConsent = false + config.manualSessionHandling = true + Countly.sharedInstance().start(with: config) + + // Clean start: nothing queued anywhere. + XCTAssertEqual(0, TestUtils.getCurrentEQ()?.count ?? -1) + XCTAssertEqual(0, TestUtils.getCurrentRQ()?.count ?? -1) + + // Events recorded with a clean property cache stay in the EQ. + Countly.sharedInstance().recordEvent("eventA") + XCTAssertEqual(1, TestUtils.getCurrentEQ()?.count ?? -1) + XCTAssertEqual(0, TestUtils.getCurrentRQ()?.count ?? -1) + + Countly.sharedInstance().recordEvent("eventB") + XCTAssertEqual(2, TestUtils.getCurrentEQ()?.count ?? -1) + XCTAssertEqual(0, TestUtils.getCurrentRQ()?.count ?? -1) + + // Property change drains the EQ into a single events request; the + // property itself only goes into the cache (no user_details yet). + Countly.user().setProperty("level", value: 42) + XCTAssertEqual(0, TestUtils.getCurrentEQ()?.count ?? -1, + "Auto-flush should drain the event queue when a user property changes") + XCTAssertEqual(1, TestUtils.getCurrentRQ()?.count ?? -1, + "Drained events should land in the RQ as one events request") + + // Recording an event with a dirty property cache saves the cache + // first (user_details request), then queues the event in the EQ. + Countly.sharedInstance().recordEvent("eventC") + XCTAssertEqual(1, TestUtils.getCurrentEQ()?.count ?? -1) + XCTAssertEqual(2, TestUtils.getCurrentRQ()?.count ?? -1, + "Recording an event with pending property changes should enqueue a user_details request first") + + // Second property change flushes eventC the same way. + Countly.user().setProperty("level", value: 43) + XCTAssertEqual(0, TestUtils.getCurrentEQ()?.count ?? -1) + XCTAssertEqual(3, TestUtils.getCurrentRQ()?.count ?? -1) + + // Explicit save drains the cache into a final user_details request. + Countly.user().save() + XCTAssertEqual(0, TestUtils.getCurrentEQ()?.count ?? -1) + XCTAssertEqual(4, TestUtils.getCurrentRQ()?.count ?? -1) + + // Saving again with a clean cache must not enqueue anything. + Countly.user().save() + XCTAssertEqual(0, TestUtils.getCurrentEQ()?.count ?? -1) + XCTAssertEqual(4, TestUtils.getCurrentRQ()?.count ?? -1, + "save() with no unsynced changes should be a no-op") + + // Strict order: events always precede the user_details request that + // carries the property state they were recorded under. + guard let rq = TestUtils.getCurrentRQ(), rq.count == 4 else { + XCTFail("Expected exactly 4 requests, got: \(TestUtils.getCurrentRQ() ?? [])"); return + } + validateEvents(request: rq[0], keysToCheck: ["eventA", "eventB"]) + assertOnlyCustomProperty(rq[1], key: "level", expected: 42) + validateEvents(request: rq[2], keysToCheck: ["eventC"]) + assertOnlyCustomProperty(rq[3], key: "level", expected: 43) + } + + /// Gap D parity: `providedUserProperties` from CountlyConfig is applied + /// at SDK start and saved automatically, leaving a clean property cache. + /// Then interleaves event recording and a property overwrite, validating + /// EQ and RQ sizes after every call plus exact request order and contents. + func test_providedUserPropertiesAtInit_appliedAndSaved() { + purgePersistedState() + + let config = createBaseConfig() + config.requiresConsent = false + config.manualSessionHandling = true + config.providedUserProperties = [ + "email": "init@example.com", + "favorite_color": "blue" + ] + Countly.sharedInstance().start(with: config) + + // Init properties are saved automatically at start: exactly one + // user_details request, empty EQ, no leftover cache. + XCTAssertEqual(0, TestUtils.getCurrentEQ()?.count ?? -1) + XCTAssertEqual(1, TestUtils.getCurrentRQ()?.count ?? -1, + "providedUserProperties should produce exactly one user_details request at init") + + // Cache was cleared by the init save: this event must stay in the + // EQ without triggering another user_details request. + Countly.sharedInstance().recordEvent("postInitEvent") + XCTAssertEqual(1, TestUtils.getCurrentEQ()?.count ?? -1) + XCTAssertEqual(1, TestUtils.getCurrentRQ()?.count ?? -1, + "Recording an event after the init save should not enqueue a new user_details request") + + // Overwriting an init property flushes the pending event into the + // RQ; the new value only goes into the cache. + Countly.user().setProperty("favorite_color", value: "red") + XCTAssertEqual(0, TestUtils.getCurrentEQ()?.count ?? -1, + "Property change should drain the pending event") + XCTAssertEqual(2, TestUtils.getCurrentRQ()?.count ?? -1) + + // Recording another event saves the dirty cache first, then queues + // the event. + Countly.sharedInstance().recordEvent("secondEvent") + XCTAssertEqual(1, TestUtils.getCurrentEQ()?.count ?? -1) + XCTAssertEqual(3, TestUtils.getCurrentRQ()?.count ?? -1) + + guard let rq = TestUtils.getCurrentRQ(), rq.count == 3 else { + XCTFail("Expected exactly 3 requests, got: \(TestUtils.getCurrentRQ() ?? [])"); return + } + + // Request 0: init save — named field at top level, custom key nested. + let parsed = TestUtils.parseQueryString(rq[0]) + guard let userDetails = parsed["user_details"] as? [String: Any] else { + XCTFail("user_details missing in init request: \(rq[0])"); return + } + XCTAssertEqual("init@example.com", userDetails["email"] as? String) + guard let custom = userDetails["custom"] as? [String: Any] else { + XCTFail("custom missing in init user_details: \(userDetails)"); return + } + XCTAssertEqual(1, custom.count, "Init custom payload should hold only favorite_color") + XCTAssertEqual("blue", custom["favorite_color"] as? String) + + // Request 1: the event recorded under favorite_color=blue. + validateEvents(request: rq[1], keysToCheck: ["postInitEvent"]) + + // Request 2: the overwritten property value. + assertOnlyCustomProperty(rq[2], key: "favorite_color", expected: "red") + + // The second event is still pending in the EQ. + XCTAssertEqual("secondEvent", TestUtils.getCurrentEQ()?.first?.key) + } + + /// A previous (possibly crashed) run can leave persisted requests and a + /// generated device ID on disk; setUp's halt(true) is a no-op before the + /// first start in a process. Start + halt(true) wipes storage so the + /// exact queue-size assertions begin from a truly clean slate. + private func purgePersistedState() { + let purgeConfig = createBaseConfig() + purgeConfig.requiresConsent = false + purgeConfig.manualSessionHandling = true + Countly.sharedInstance().start(with: purgeConfig) + Countly.sharedInstance().halt(true) + } + + /// Asserts the request carries a user_details payload whose custom + /// dictionary contains exactly one entry: `key` with `expected` value. + private func assertOnlyCustomProperty(_ request: String, key: String, expected: Any) { + let parsed = TestUtils.parseQueryString(request) + guard let userDetails = parsed["user_details"] as? [String: Any] else { + XCTFail("user_details missing in request: \(request)"); return + } + guard let custom = userDetails["custom"] as? [String: Any] else { + XCTFail("custom missing in user_details: \(userDetails)"); return + } + XCTAssertEqual(1, custom.count, "Expected exactly one custom property, got: \(custom)") + XCTAssertEqual("\(expected)", "\(custom[key] ?? "nil")", "Custom property '\(key)' mismatch") + } } diff --git a/CountlyTests/ServerConfigBuilder.swift b/CountlyTests/ServerConfigBuilder.swift index ce8dcc51..dd8394a3 100644 --- a/CountlyTests/ServerConfigBuilder.swift +++ b/CountlyTests/ServerConfigBuilder.swift @@ -271,7 +271,11 @@ class ServerConfigBuilder { requestQueueSize(1000) eventQueueSize(100) logging(false) - sessionUpdateInterval(60) + // CountlyServerConfig.sessionInterval defaults to 0 when no SBS is applied; 0 is the + // sentinel meaning "fall back to config.updateSessionPeriod (60s)" (see CountlyServerConfig + // populateConfig: `config.updateSessionPeriod = _sessionInterval ?: ...`). So the default + // of the server-config field itself is 0, not 60. + sessionUpdateInterval(0) contentZoneInterval(30) consentRequired(false) dropOldRequestTime(0) diff --git a/CountlyTests/TestUtils.swift b/CountlyTests/TestUtils.swift index 895bc424..0fba71b2 100644 --- a/CountlyTests/TestUtils.swift +++ b/CountlyTests/TestUtils.swift @@ -13,7 +13,7 @@ class TestUtils { static let commonDeviceId: String = "deviceId" static let commonAppKey: String = "appkey" static let host: String = "https://YOUR_SERVER" - static let SDK_VERSION = "26.1.1" + static let SDK_VERSION = "26.1.2" static let SDK_NAME = "objc-native-ios" static func cleanup() { @@ -46,12 +46,12 @@ class TestUtils { } } - static func validateRequest(_ params: [String: Any], _ idx: Int) { - validateRequest(params, idx, { request in }) + static func validateRequest(_ params: [String: Any], _ idx: Int, from source: [String]? = nil) { + validateRequest(params, idx, { request in }, from: source) } - static func validateRequest(_ params: [String: Any], _ idx: Int, _ customValidator: ([String: Any]) -> Void) { - guard let rq = getCurrentRQ() else { + static func validateRequest(_ params: [String: Any], _ idx: Int, _ customValidator: ([String: Any]) -> Void, from source: [String]? = nil) { + guard let rq = source ?? getCurrentRQ() else { XCTFail("Request queue is nil.") return } @@ -60,7 +60,7 @@ class TestUtils { return } - let requestStr = getCurrentRQ()![idx] + let requestStr = rq[idx] let request = parseQueryString(requestStr) validateRequiredParams(request) @@ -91,9 +91,9 @@ class TestUtils { static func validateEventInRQ( _ eventName: String, _ segmentation: [String: Any], _ idx: Int, _ rqCount: Int, _ eventIdx: Int, - _ eventCount: Int + _ eventCount: Int, from source: [String]? = nil ) throws { - let requestStr = getCurrentRQ()![idx] + let requestStr = (source ?? getCurrentRQ())![idx] let request = parseQueryString(requestStr) validateRequiredParams(request) @@ -286,6 +286,13 @@ class TestUtils { return result } + /// Best-effort array coercion that handles both Swift `[T]` and bridged NSArray. + static func asArray(_ value: Any) -> [Any]? { + if let arr = value as? [Any] { return arr } + if let nsArr = value as? NSArray { return nsArr as? [Any] } + return nil + } + static func compareDictionaries(_ dict1: [String: Any], _ dict2: [String: Any]) -> Bool { guard dict1.count == dict2.count else { return false @@ -300,6 +307,13 @@ class TestUtils { if !compareDictionaries(nestedDict1, nestedDict2) { return false } + } else if let arr1 = TestUtils.asArray(value), let arr2 = TestUtils.asArray(otherValue) { + // Element-by-element string comparison so Swift `[String]` matches + // NSArray bridged from JSON-parsed request payloads. + if arr1.count != arr2.count { return false } + for (a, b) in zip(arr1, arr2) { + if "\(a)" != "\(b)" { return false } + } } else if "\(value)" != "\(otherValue)" { return false } diff --git a/CountlyUserDetails.h b/CountlyUserDetails.h index 35f6ef32..5b2d0536 100644 --- a/CountlyUserDetails.h +++ b/CountlyUserDetails.h @@ -49,35 +49,35 @@ extern NSString* const kCountlyLocalPicturePath; * @discussion It can be set to an @c NSString, or @c NSNull for clearing it on server. * It will be sent to server when @c recordUserDetails method is called. */ -@property (nonatomic, copy) id _Nullable name; +@property (nonatomic, copy) id _Nullable name DEPRECATED_MSG_ATTRIBUTE("Use -setProperty:value: with key 'name' instead. Pass @\"\" to clear the field on server."); /** * Default @c username property for user's username in User Profiles. * @discussion It can be set to an @c NSString, or @c NSNull for clearing it on server. * It will be sent to server when @c recordUserDetails method is called. */ -@property (nonatomic, copy) id _Nullable username; +@property (nonatomic, copy) id _Nullable username DEPRECATED_MSG_ATTRIBUTE("Use -setProperty:value: with key 'username' instead."); /** * Default @c email property for user's e-mail in User Profiles. * @discussion It can be set to an @c NSString, or @c NSNull for clearing it on server. * It will be sent to server when @c recordUserDetails method is called. */ -@property (nonatomic, copy) id _Nullable email; +@property (nonatomic, copy) id _Nullable email DEPRECATED_MSG_ATTRIBUTE("Use -setProperty:value: with key 'email' instead."); /** * Default @c organization property for user's organization/company in User Profiles. * @discussion It can be set to an @c NSString, or @c NSNull for clearing it on server. * It will be sent to server when @c recordUserDetails method is called. */ -@property (nonatomic, copy) id _Nullable organization; +@property (nonatomic, copy) id _Nullable organization DEPRECATED_MSG_ATTRIBUTE("Use -setProperty:value: with key 'organization' instead."); /** * Default @c phone property for user's phone number in User Profiles. * @discussion It can be set to an @c NSString, or @c NSNull for clearing it on server. * It will be sent to server when @c recordUserDetails method is called. */ -@property (nonatomic, copy) id _Nullable phone; +@property (nonatomic, copy) id _Nullable phone DEPRECATED_MSG_ATTRIBUTE("Use -setProperty:value: with key 'phone' instead."); /** * Default @c gender property for user's gender in User Profiles. @@ -85,7 +85,7 @@ extern NSString* const kCountlyLocalPicturePath; * It will be sent to server when @c recordUserDetails method is called. * @discussion If it is set to case-insensitive @c m or @c f, it is displayed as @c Male or @c Female. Otherwise it will displayed as @c Unknown. */ -@property (nonatomic, copy) id _Nullable gender; +@property (nonatomic, copy) id _Nullable gender DEPRECATED_MSG_ATTRIBUTE("Use -setProperty:value: with key 'gender' instead."); /** * Default @c pictureURL property for user's profile photo in User Profiles. @@ -93,7 +93,7 @@ extern NSString* const kCountlyLocalPicturePath; * It will be sent to server when @c recordUserDetails method is called. * @discussion It should be a publicly accessible URL string to user's profile photo, so server can download it. */ -@property (nonatomic, copy) id _Nullable pictureURL; +@property (nonatomic, copy) id _Nullable pictureURL DEPRECATED_MSG_ATTRIBUTE("Use -setProperty:value: with key 'picture' instead."); /** * Default @c pictureLocalPath property for user's profile photo in User Profiles. @@ -102,14 +102,14 @@ extern NSString* const kCountlyLocalPicturePath; * @discussion It should be a valid local path string to user's profile photo on the device, so it can be uploaded to server. * If @c pictureURL is also set at the same time, @c pictureLocalPath will be ignored and @c pictureURL will be used. */ -@property (nonatomic, copy) id _Nullable pictureLocalPath; +@property (nonatomic, copy) id _Nullable pictureLocalPath DEPRECATED_MSG_ATTRIBUTE("Use -setProperty:value: with key 'picturePath' instead."); /** * Default @c birthYear property for user's birth year in User Profiles. * @discussion It can be set to an @c NSNumber, or @c NSNull for clearing it on server. * It will be sent to server when @c recordUserDetails method is called. */ -@property (nonatomic, copy) id _Nullable birthYear; +@property (nonatomic, copy) id _Nullable birthYear DEPRECATED_MSG_ATTRIBUTE("Use -setProperty:value: with key 'byear' instead."); /** * @c custom property for user's custom information as key-value pairs in User Profiles. @@ -117,12 +117,12 @@ extern NSString* const kCountlyLocalPicturePath; * It will be sent to server when @c recordUserDetails method is called. * @discussion Key-value pairs in @c custom property can also be modified using custom property modifier methods. */ -@property (nonatomic, copy) id _Nullable custom; +@property (nonatomic, copy) id _Nullable custom DEPRECATED_MSG_ATTRIBUTE("Use -setProperties: instead to set multiple custom properties at once."); /** * Returns @c CountlyUserDetails singleton to be used throughout the app. * @return The shared @c CountlyUserDetails object - * @discussion @c Countly.user convenience accessor can also be used. + * @discussion @c Countly.sharedInstance.userProfile convenience accessor can also be used and is the recommended way. */ + (instancetype)sharedInstance; @@ -134,7 +134,7 @@ extern NSString* const kCountlyLocalPicturePath; * @param key Key for custom property key-value pair * @param value String value for custom property key-value pair */ -- (void)set:(NSString *)key value:(NSString *)value; +- (void)set:(NSString *)key value:(NSString *)value DEPRECATED_MSG_ATTRIBUTE("Use -setProperty:value: instead. This method always treats the key as a custom property; -setProperty: routes predefined keys (name, email, etc.) to top-level fields."); /** * Custom user details property modifier for setting a key-value pair where the value is a number. @@ -142,7 +142,7 @@ extern NSString* const kCountlyLocalPicturePath; * @param key Key for custom property key-value pair * @param value Number value for custom property key-value pair */ -- (void)set:(NSString *)key numberValue:(NSNumber *)value; +- (void)set:(NSString *)key numberValue:(NSNumber *)value DEPRECATED_MSG_ATTRIBUTE("Use -setProperty:value: instead."); /** * Custom user details property modifier for setting a key-value pair where the value is a boolean. @@ -150,7 +150,7 @@ extern NSString* const kCountlyLocalPicturePath; * @param key Key for custom property key-value pair * @param value Boolean value for custom property key-value pair */ -- (void)set:(NSString *)key boolValue:(BOOL)value; +- (void)set:(NSString *)key boolValue:(BOOL)value DEPRECATED_MSG_ATTRIBUTE("Use -setProperty:value: with @(boolValue) instead."); /** * Custom user details property modifier for setting a key-value pair if not set before. @@ -181,7 +181,7 @@ extern NSString* const kCountlyLocalPicturePath; * @discussion When called, this modifier is added to a non-persistent queue and sent to server only when @c save method is called. * @param key Key for custom property key-value pair */ -- (void)unSet:(NSString *)key; +- (void)unSet:(NSString *)key DEPRECATED_MSG_ATTRIBUTE("Use -setProperty:value: with an empty string @\"\" to clear a property on the server."); /** * Custom user details property modifier for incrementing a key-value pair's value by 1. @@ -324,7 +324,39 @@ extern NSString* const kCountlyLocalPicturePath; */ - (void)save; -- (BOOL) hasUnsyncedChanges; +#pragma mark - User Properties + +/** + * Sets a single user property by key. + * @discussion The key may be either a predefined property (@c name, @c username, @c email, @c organization, @c phone, @c gender, @c picture, @c picturePath, @c byear) or a custom property. Predefined keys are routed to the matching top-level user-detail field; all other keys are stored as custom user properties. + * @discussion The change is queued and sent to server when @c save is called. Pending events in the event queue are flushed first so they reach the server in the right order. + * @discussion Pass an empty string @c @"" to clear a predefined string field on the server. Pass a negative @c NSNumber for @c byear to clear it. + * @param key The user property key. Must not be @c nil. + * @param value The user property value. Supported types are @c NSString, @c NSNumber, and @c NSArray (of supported scalar types). Must not be @c nil or @c NSNull. + */ +- (void)setProperty:(NSString *)key value:(id)value; + +/** + * Sets multiple user properties in one call. + * @discussion Equivalent to calling @c setProperty:value: for each key-value pair, but bundled into a single batch. Each entry is routed individually — predefined keys go to top-level fields, custom keys are stored under the @c custom dictionary. + * @discussion The change is queued and sent to server when @c save is called. Pending events in the event queue are flushed first so they reach the server in the right order. + * @discussion Pass an empty string @c @"" as a value to clear the corresponding predefined string field on the server. Entries with @c nil or @c NSNull values are skipped with a warning. + * @param data Dictionary of user properties. Must not be @c nil. Empty dictionaries are ignored. + */ +- (void)setProperties:(NSDictionary *)data; + +/** + * Clears all queued user property changes without sending them to the server. + * @discussion Drops all named-field updates and pending custom property modifications accumulated since the last successful @c save call. No request is made. + * @discussion Use this to discard a batch of pending changes before they are sent — for example, if user input is cancelled before submission. + */ +- (void)clear; + +/** + * Indicates whether there are queued user property changes that have not been sent to the server yet. + * @return @c YES if a subsequent @c save call would produce a user-details request; @c NO otherwise. + */ +- (BOOL)hasUnsyncedChanges; NS_ASSUME_NONNULL_END diff --git a/CountlyUserDetails.m b/CountlyUserDetails.m index 2bb75188..6f89f1da 100644 --- a/CountlyUserDetails.m +++ b/CountlyUserDetails.m @@ -7,7 +7,10 @@ #import "CountlyCommon.h" @interface CountlyUserDetails () -@property (nonatomic) NSMutableDictionary* modifications; +@property (nonatomic) NSMutableDictionary* customMods; +@property (nonatomic) NSMutableDictionary* customProperties; + +- (BOOL)isValidDataType:(id) value; @end NSString* const kCountlyLocalPicturePath = @"kCountlyLocalPicturePath"; @@ -21,6 +24,7 @@ @interface CountlyUserDetails () NSString* const kCountlyUDKeyPicture = @"picture"; NSString* const kCountlyUDKeyBirthyear = @"byear"; NSString* const kCountlyUDKeyCustom = @"custom"; +NSString* const kCountlyUDKeyPicturePath = @"picturePath"; NSString* const kCountlyUDKeyModifierSetOnce = @"$setOnce"; NSString* const kCountlyUDKeyModifierIncrement = @"$inc"; @@ -31,6 +35,20 @@ @interface CountlyUserDetails () NSString* const kCountlyUDKeyModifierAddToSet = @"$addToSet"; NSString* const kCountlyUDKeyModifierPull = @"$pull"; +static NSString* const kCountlyUDNamedFields[] = { + kCountlyUDKeyName, + kCountlyUDKeyUsername, + kCountlyUDKeyEmail, + kCountlyUDKeyOrganization, + kCountlyUDKeyPhone, + kCountlyUDKeyGender, + kCountlyUDKeyPicture, + kCountlyUDKeyPicturePath, + kCountlyUDKeyBirthyear +}; + +static const NSUInteger kCountlyUDNamedFieldsCount = sizeof(kCountlyUDNamedFields) / sizeof(kCountlyUDNamedFields[0]); + @implementation CountlyUserDetails + (instancetype)sharedInstance @@ -45,7 +63,8 @@ - (instancetype)init { if (self = [super init]) { - self.modifications = NSMutableDictionary.new; + self.customMods = NSMutableDictionary.new; + self.customProperties = NSMutableDictionary.new; } return self; @@ -54,54 +73,45 @@ - (instancetype)init - (NSString *)serializedUserDetails { NSMutableDictionary* userDictionary = NSMutableDictionary.new; - if (self.name) - userDictionary[kCountlyUDKeyName] = - ![self.name isKindOfClass:NSString.class] ? self.name : - [(NSString *)self.name cly_truncatedValue:@"User details name"]; - - if (self.username) - userDictionary[kCountlyUDKeyUsername] = - ![self.username isKindOfClass:NSString.class] ? self.username : - [(NSString *)self.username cly_truncatedValue:@"User details username"]; - - if (self.email) - userDictionary[kCountlyUDKeyEmail] = - ![self.email isKindOfClass:NSString.class] ? self.email : - [(NSString *)self.email cly_truncatedValue:@"User details email"]; - - if (self.organization) - userDictionary[kCountlyUDKeyOrganization] = - ![self.organization isKindOfClass:NSString.class] ? self.organization : - [(NSString *)self.organization cly_truncatedValue:@"User details organization"]; - - if (self.phone) - userDictionary[kCountlyUDKeyPhone] = - ![self.phone isKindOfClass:NSString.class] ? self.phone : - [(NSString *)self.phone cly_truncatedValue:@"User details phone"]; - - if (self.gender) - userDictionary[kCountlyUDKeyGender] = - ![self.gender isKindOfClass:NSString.class] ? self.gender : - [(NSString *)self.gender cly_truncatedValue:@"User details gender"]; - - if (self.pictureURL) - userDictionary[kCountlyUDKeyPicture] = self.pictureURL; - - if (self.birthYear) - userDictionary[kCountlyUDKeyBirthyear] = self.birthYear; + [self serializeStringField:self.name key:kCountlyUDKeyName explanation:@"User details name" into:userDictionary picture:NO]; + [self serializeStringField:self.username key:kCountlyUDKeyUsername explanation:@"User details username" into:userDictionary picture:NO]; + [self serializeStringField:self.email key:kCountlyUDKeyEmail explanation:@"User details email" into:userDictionary picture:NO]; + [self serializeStringField:self.organization key:kCountlyUDKeyOrganization explanation:@"User details organization" into:userDictionary picture:NO]; + [self serializeStringField:self.phone key:kCountlyUDKeyPhone explanation:@"User details phone" into:userDictionary picture:NO]; + [self serializeStringField:self.gender key:kCountlyUDKeyGender explanation:@"User details gender" into:userDictionary picture:NO]; + [self serializeStringField:self.pictureURL key:kCountlyUDKeyPicture explanation:@"User details picture" into:userDictionary picture:YES]; + + if (self.birthYear) { + if ([self.birthYear isKindOfClass:NSNumber.class] && ((NSNumber *)self.birthYear).integerValue < 0) { + // Negative byear is sent as null on the wire (Android behavior). + userDictionary[kCountlyUDKeyBirthyear] = NSNull.null; + } else { + userDictionary[kCountlyUDKeyBirthyear] = self.birthYear; + } + } + NSMutableDictionary* customAll = NSMutableDictionary.new; + if ([self.custom isKindOfClass:NSDictionary.class]) { NSMutableDictionary *customMutable = [((NSDictionary *)self.custom) mutableCopy]; [self filterAndLimitUserProperties:customMutable]; NSDictionary* customTruncated = [customMutable cly_truncated:@"User details custom dictionary"]; - self.custom = [customTruncated cly_limited:@"User details custom dictionary"]; + [customAll addEntriesFromDictionary:[customTruncated cly_limited:@"User details custom dictionary"]]; + } + + if(self.customProperties.count > 0){ + [customAll addEntriesFromDictionary:[self.customProperties cly_limited:@"User details custom dictionary"]]; + } + + if(self.customMods.count > 0){ + [customAll addEntriesFromDictionary:self.customMods]; } - if (self.custom) - userDictionary[kCountlyUDKeyCustom] = self.custom; + if (customAll.count > 0) + userDictionary[kCountlyUDKeyCustom] = customAll; - if (userDictionary.allKeys.count) + if (userDictionary.count > 0) return [userDictionary cly_JSONify]; return nil; @@ -120,7 +130,8 @@ - (void)clearUserDetails self.birthYear = nil; self.custom = nil; - [self.modifications removeAllObjects]; + [self.customMods removeAllObjects]; + [self.customProperties removeAllObjects]; } - (BOOL)hasUnsyncedChanges @@ -146,72 +157,86 @@ - (BOOL)hasUnsyncedChanges } }]; - return userDetailsChanged || self.modifications.count > 0; + return userDetailsChanged || self.customProperties.count > 0 || self.customMods.count > 0; } #pragma mark - +// Legacy custom-only setter. Treats every key as a custom user property — never +// routes to predefined fields like `name`/`email`/etc. This preserves the +// pre-`setProperty:` wire format. Use `-setProperty:value:` for the named-aware path. - (void)set:(NSString *)key value:(NSString *)value { CLY_LOG_I(@"%s %@ %@", __FUNCTION__, key, value); - - self.modifications[key] = value.copy; + [self setCustomProperty:key value:value]; } - (void)set:(NSString *)key numberValue:(NSNumber *)value { CLY_LOG_I(@"%s %@ %@", __FUNCTION__, key, value); - - self.modifications[key] = value.copy; + [self setCustomProperty:key value:value]; } - (void)set:(NSString *)key boolValue:(BOOL)value { CLY_LOG_I(@"%s %@ %d", __FUNCTION__, key, value); - - self.modifications[key] = @(value); + [self setCustomProperty:key value:@(value)]; } -- (void)setOnce:(NSString *)key value:(NSString *)value +- (void)setCustomProperty:(NSString *)key value:(id)value { - CLY_LOG_I(@"%s %@ %@", __FUNCTION__, key, value); - - if (!value) - { - CLY_LOG_W(@"%s call will be ignored as value is nil!", __FUNCTION__); + if (key == nil || value == nil) return; + if (![CountlyServerConfig.sharedInstance shouldRecordUserProperty:key]) { + CLY_LOG_D(@"%s key [%@] is filtered out by user property filter, omitting call", __FUNCTION__, key); return; } + if (![self isValidDataType:value]) { + CLY_LOG_D(@"%s unsupported type for key [%@], type [%@], omitting call", + __FUNCTION__, key, NSStringFromClass([value class])); + return; + } + NSString* truncatedLog = [NSString stringWithFormat:@"%s",__FUNCTION__]; + if ([value isKindOfClass:[NSString class]]) { + BOOL isPicture = [key isEqualToString:kCountlyUDKeyPicture] || [key isEqualToString:kCountlyUDKeyPicturePath]; + value = isPicture ? [(NSString *)value cly_truncatedPictureValue:truncatedLog] + : [(NSString *)value cly_truncatedValue:truncatedLog]; + } + NSString *truncatedKey = [key cly_truncatedKey:truncatedLog]; + self.customProperties[truncatedKey] = value; + // No auto-flush — legacy `set:` preserves pre-existing event-flush timing. +} - self.modifications[key] = @{kCountlyUDKeyModifierSetOnce: value.copy}; +- (void)setOnce:(NSString *)key value:(NSString *)value +{ + CLY_LOG_I(@"%s %@ %@", __FUNCTION__, key, value); + [self doModification:kCountlyUDKeyModifierSetOnce key:key value:value]; } - (void)setOnce:(NSString *)key numberValue:(NSNumber *)value { CLY_LOG_I(@"%s %@ %@", __FUNCTION__, key, value); - - if (!value) - { - CLY_LOG_W(@"%s call will be ignored as value is nil!", __FUNCTION__); - return; - } - - self.modifications[key] = @{kCountlyUDKeyModifierSetOnce: value.copy}; + [self doModification:kCountlyUDKeyModifierSetOnce key:key value:value]; } - (void)setOnce:(NSString *)key boolValue:(BOOL)value; { CLY_LOG_I(@"%s %@ %d", __FUNCTION__, key, value); - - self.modifications[key] = @{kCountlyUDKeyModifierSetOnce: @(value)}; + [self doModification:kCountlyUDKeyModifierSetOnce key:key value:@(value)]; } +// Legacy custom-only unsetter. Always treats key as a custom user property; does +// not clear predefined fields like `name`/`email`. Preserves pre-`setProperty:` +// wire format. Clear named fields via direct property assignment to NSNull.null. - (void)unSet:(NSString *)key { CLY_LOG_I(@"%s %@", __FUNCTION__, key); + if (key == nil) return; - self.modifications[key] = NSNull.null; + NSString *truncatedKey = [key cly_truncatedKey:@"unSet"]; + self.customProperties[truncatedKey] = NSNull.null; + // No auto-flush — legacy `unSet:` preserves pre-existing event-flush timing. } - (void)increment:(NSString *)key @@ -224,189 +249,96 @@ - (void)increment:(NSString *)key - (void)incrementBy:(NSString *)key value:(NSNumber *)value { CLY_LOG_I(@"%s %@ %@", __FUNCTION__, key, value); - - if (!value) - { - CLY_LOG_W(@"%s call will be ignored as value is nil!", __FUNCTION__); - return; - } - - self.modifications[key] = @{kCountlyUDKeyModifierIncrement: value}; + [self doModification:kCountlyUDKeyModifierIncrement key:key value:value]; } - (void)multiply:(NSString *)key value:(NSNumber *)value { CLY_LOG_I(@"%s %@ %@", __FUNCTION__, key, value); - - if (!value) - { - CLY_LOG_W(@"%s call will be ignored as value is nil!", __FUNCTION__); - return; - } - - self.modifications[key] = @{kCountlyUDKeyModifierMultiply: value}; + [self doModification:kCountlyUDKeyModifierMultiply key:key value:value]; } - (void)max:(NSString *)key value:(NSNumber *)value { CLY_LOG_I(@"%s %@ %@", __FUNCTION__, key, value); - - if (!value) - { - CLY_LOG_W(@"%s call will be ignored as value is nil!", __FUNCTION__); - return; - } - - self.modifications[key] = @{kCountlyUDKeyModifierMax: value}; + [self doModification:kCountlyUDKeyModifierMax key:key value:value]; } - (void)min:(NSString *)key value:(NSNumber *)value { CLY_LOG_I(@"%s %@ %@", __FUNCTION__, key, value); - - if (!value) - { - CLY_LOG_W(@"%s call will be ignored as value is nil!", __FUNCTION__); - return; - } - - self.modifications[key] = @{kCountlyUDKeyModifierMin: value}; -} + [self doModification:kCountlyUDKeyModifierMin key:key value:value];} - (void)push:(NSString *)key value:(NSString *)value { CLY_LOG_I(@"%s %@ %@", __FUNCTION__, key, value); - - if (!value) - { - CLY_LOG_W(@"%s call will be ignored as value is nil!", __FUNCTION__); - return; - } - - self.modifications[key] = @{kCountlyUDKeyModifierPush: value.copy}; + [self doModification:kCountlyUDKeyModifierPush key:key value:value]; } - (void)push:(NSString *)key numberValue:(NSNumber *)value; { - if (!value) - { - CLY_LOG_W(@"%s call will be ignored as value is nil!", __FUNCTION__); - return; - } - - self.modifications[key] = @{kCountlyUDKeyModifierPush: value.copy}; + CLY_LOG_I(@"%s %@ %@", __FUNCTION__, key, value); + [self doModification:kCountlyUDKeyModifierPush key:key value:value]; } - (void)push:(NSString *)key boolValue:(BOOL)value { CLY_LOG_I(@"%s %@ %d", __FUNCTION__, key, value); - - self.modifications[key] = @{kCountlyUDKeyModifierPush: @(value)}; + [self doModification:kCountlyUDKeyModifierPush key:key value:@(value)]; } - (void)push:(NSString *)key values:(NSArray *)value { CLY_LOG_I(@"%s %@ %@", __FUNCTION__, key, value); - - if (!value) - { - CLY_LOG_W(@"%s call will be ignored as value is nil!", __FUNCTION__); - return; - } - - self.modifications[key] = @{kCountlyUDKeyModifierPush: value.copy}; + [self doModification:kCountlyUDKeyModifierPush key:key value:value]; } - (void)pushUnique:(NSString *)key value:(NSString *)value { CLY_LOG_I(@"%s %@ %@", __FUNCTION__, key, value); - - if (!value) - { - CLY_LOG_W(@"%s call will be ignored as value is nil!", __FUNCTION__); - return; - } - - self.modifications[key] = @{kCountlyUDKeyModifierAddToSet: value.copy}; + [self doModification:kCountlyUDKeyModifierAddToSet key:key value:value]; } - (void)pushUnique:(NSString *)key numberValue:(NSNumber *)value { CLY_LOG_I(@"%s %@ %@", __FUNCTION__, key, value); - - if (!value) - { - CLY_LOG_W(@"%s call will be ignored as value is nil!", __FUNCTION__); - return; - } - - self.modifications[key] = @{kCountlyUDKeyModifierAddToSet: value.copy}; + [self doModification:kCountlyUDKeyModifierAddToSet key:key value:value]; } - (void)pushUnique:(NSString *)key boolValue:(BOOL)value { CLY_LOG_I(@"%s %@ %d", __FUNCTION__, key, value); - - self.modifications[key] = @{kCountlyUDKeyModifierAddToSet: @(value)}; + [self doModification:kCountlyUDKeyModifierAddToSet key:key value:@(value)]; } - (void)pushUnique:(NSString *)key values:(NSArray *)value { CLY_LOG_I(@"%s %@ %@", __FUNCTION__, key, value); - - if (!value) - { - CLY_LOG_W(@"%s call will be ignored as value is nil!", __FUNCTION__); - return; - } - - self.modifications[key] = @{kCountlyUDKeyModifierAddToSet: value.copy}; + [self doModification:kCountlyUDKeyModifierAddToSet key:key value:value]; } - (void)pull:(NSString *)key value:(NSString *)value { CLY_LOG_I(@"%s %@ %@", __FUNCTION__, key, value); - - if (!value) - { - CLY_LOG_W(@"%s call will be ignored as value is nil!", __FUNCTION__); - return; - } - - self.modifications[key] = @{kCountlyUDKeyModifierPull: value.copy}; + [self doModification:kCountlyUDKeyModifierPull key:key value:value]; } - (void)pull:(NSString *)key numberValue:(NSNumber *)value { CLY_LOG_I(@"%s %@ %@", __FUNCTION__, key, value); - - if (!value) - { - CLY_LOG_W(@"%s call will be ignored as value is nil!", __FUNCTION__); - return; - } - - self.modifications[key] = @{kCountlyUDKeyModifierPull: value.copy}; + [self doModification:kCountlyUDKeyModifierPull key:key value:value]; } - (void)pull:(NSString *)key boolValue:(BOOL)value { CLY_LOG_I(@"%s %@ %d", __FUNCTION__, key, value); - - self.modifications[key] = @{kCountlyUDKeyModifierPull: @(value)}; + [self doModification:kCountlyUDKeyModifierPull key:key value:@(value)]; } - (void)pull:(NSString *)key values:(NSArray *)value { CLY_LOG_I(@"%s %@ %@", __FUNCTION__, key, value); - - if (!value) - { - CLY_LOG_W(@"%s call will be ignored as value is nil!", __FUNCTION__); - return; - } - - self.modifications[key] = @{kCountlyUDKeyModifierPull: value.copy}; + [self doModification:kCountlyUDKeyModifierPull key:key value:value]; } - (void)save @@ -432,15 +364,194 @@ - (void)save if (self.pictureLocalPath && !self.pictureURL) [CountlyConnectionManager.sharedInstance sendUserDetails:[@{kCountlyLocalPicturePath: self.pictureLocalPath} cly_JSONify]]; - if (self.modifications.count) + [self clearUserDetails]; +} + +- (void)doModification:(NSString *)mod key:(NSString *)key value:(id)value { + if (value == nil) { - [self filterAndLimitUserProperties:self.modifications]; - [CountlyConnectionManager.sharedInstance sendUserDetails:[@{kCountlyUDKeyCustom: [self truncateModifications]} cly_JSONify]]; + CLY_LOG_W(@"%s call will be ignored as value is nil!", __FUNCTION__); + return; + } + if (![self isValidDataType:value]) { + CLY_LOG_W(@"%s unsupported value type for key [%@] mod [%@], type [%@], omitting call", + __FUNCTION__, key, mod, NSStringFromClass([value class])); + return; } + if (![CountlyServerConfig.sharedInstance shouldRecordUserProperty:key]) { + CLY_LOG_D(@"%s key [%@] is filtered out by user property filter, omitting call", __FUNCTION__, key); + return; + } + NSString* truncatedLog = [NSString stringWithFormat:@"%s",__FUNCTION__]; - [self clearUserDetails]; + // If the value is NSString, apply truncation rules + if ([value isKindOfClass:[NSString class]]) { + value = [[value description] cly_truncatedValue:truncatedLog]; + } + + NSString* truncatedKey = [[key description] cly_truncatedKey:truncatedLog]; + if (![mod isEqualToString:@"$pull"] && + ![mod isEqualToString:@"$push"] && + ![mod isEqualToString:@"$addToSet"]) { + self.customMods[truncatedKey] = @{mod: value}; + } else if (self.customMods[truncatedKey] && self.customMods[truncatedKey][mod]) { + id existing = self.customMods[truncatedKey][mod]; + NSMutableArray *array = [existing isKindOfClass:[NSArray class]] ? [existing mutableCopy] : [NSMutableArray arrayWithObject:existing]; + if ([value isKindOfClass:[NSArray class]]) { + [array addObjectsFromArray:value]; + } else { + [array addObject:value]; + } + self.customMods[truncatedKey] = @{mod: array}; + } else { + self.customMods[truncatedKey] = @{mod: value}; + } + // Note: legacy modifier methods (setOnce/push/pull/etc.) deliberately do + // NOT auto-flush events — preserves pre-existing request-timing behavior + // for callers still on the legacy API. Auto-flush is opt-in via the new + // -setProperty:/setProperties: path. +} + +/** + * This mainly performs the filtering of provided values. + * This single call is used for both predefined properties and custom user properties. + * + * @param data Dictionary of user properties + */ +- (void)setPropertiesInternal:(NSDictionary *)data { + if (data.count == 0) { + CLY_LOG_I(@"%s no data was provided", __FUNCTION__); + return; + } + + NSString* truncatedLog = [NSString stringWithFormat:@"%s",__FUNCTION__]; + BOOL anyChange = NO; + + for (NSString *key in data) { + id value = data[key]; + + if (value == nil || value == [NSNull null]) { + CLY_LOG_W(@"%s provided value for key [%@] is 'null'", __FUNCTION__, key); + continue; + } + + if ([value isKindOfClass:[NSString class]]) { + BOOL isPicture = [key isEqualToString:kCountlyUDKeyPicture] || [key isEqualToString:kCountlyUDKeyPicturePath]; + value = isPicture ? [[value description] cly_truncatedPictureValue:truncatedLog] + : [[value description] cly_truncatedValue:truncatedLog]; + } + + BOOL isNamed = NO; + for (NSUInteger i = 0; i < kCountlyUDNamedFieldsCount; i++) { + if ([kCountlyUDNamedFields[i] isEqualToString:key]) { + isNamed = YES; + if ([self assignNamedField:key value:value]) + anyChange = YES; + break; + } + } + + if (!isNamed) { + if (![CountlyServerConfig.sharedInstance shouldRecordUserProperty:key]) { + CLY_LOG_D(@"%s key [%@] is filtered out by user property filter, omitting call", __FUNCTION__, key); + continue; + } + NSString* truncatedKey = [[key description] cly_truncatedKey:truncatedLog]; + if ([self isValidDataType:value]) { + self.customProperties[truncatedKey] = value; + anyChange = YES; + } else { + CLY_LOG_D(@"%s provided an unsupported type for key: [%@], value: [%@], type: [%@], omitting call",__FUNCTION__, + key, value, NSStringFromClass([value class])); + } + } + } + + if (anyChange) [self userPropertiesChanged]; } +- (void)serializeStringField:(id)field + key:(NSString *)key + explanation:(NSString *)explanation + into:(NSMutableDictionary *)userDictionary + picture:(BOOL)isPicture +{ + if (!field) return; + + if (![field isKindOfClass:NSString.class]) { + // NSNull — explicit clear. + userDictionary[key] = field; + return; + } + + NSString *str = (NSString *)field; + // Empty strings are sent to the server as-is: the server clears a field + // on "" but ignores null, so converting "" to null (Android behavior) + // would defeat the clear. + userDictionary[key] = isPicture ? [str cly_truncatedPictureValue:explanation] + : [str cly_truncatedValue:explanation]; +} + +// Returns YES when the field was actually assigned, NO when the value was +// rejected — so the caller only marks a change (and flushes events) for +// values that will end up in the next user-details request. +- (BOOL)assignNamedField:(NSString *)key value:(id)value { + BOOL isNull = (value == [NSNull null]); + id stringOrNull = isNull ? NSNull.null : [value description]; + + if ([key isEqualToString:kCountlyUDKeyName]) { + self.name = stringOrNull; + } else if ([key isEqualToString:kCountlyUDKeyUsername]) { + self.username = stringOrNull; + } else if ([key isEqualToString:kCountlyUDKeyEmail]) { + self.email = stringOrNull; + } else if ([key isEqualToString:kCountlyUDKeyOrganization]) { + self.organization = stringOrNull; + } else if ([key isEqualToString:kCountlyUDKeyPhone]) { + self.phone = stringOrNull; + } else if ([key isEqualToString:kCountlyUDKeyGender]) { + self.gender = stringOrNull; + } else if ([key isEqualToString:kCountlyUDKeyPicture]) { + self.pictureURL = stringOrNull; + } else if ([key isEqualToString:kCountlyUDKeyPicturePath]) { + if (isNull) { + self.pictureLocalPath = nil; + } else { + NSString *path = [value description]; + // drop the path with a warning if the file isn't readable. + if (path.length > 0 && ![[NSFileManager defaultManager] isReadableFileAtPath:path]) { + CLY_LOG_W(@"%s provided picture path file [%@] can not be opened", __FUNCTION__, path); + self.pictureLocalPath = nil; + } else { + self.pictureLocalPath = path; + } + } + } else if ([key isEqualToString:kCountlyUDKeyBirthyear]) { + if (isNull) { + self.birthYear = NSNull.null; + } else { + NSNumber *parsed = nil; + if ([value isKindOfClass:[NSNumber class]]) { + parsed = (NSNumber *)value; + } else if ([value isKindOfClass:[NSString class]]) { + NSNumberFormatter *formatter = [NSNumberFormatter new]; + parsed = [formatter numberFromString:(NSString *)value]; + if (!parsed) { + CLY_LOG_W(@"%s incorrect byear number format: %@", __FUNCTION__, value); + return NO; + } + } else { + return NO; + } + + self.birthYear = parsed; + } + } else { + return NO; + } + + return YES; +} - (void)filterAndLimitUserProperties:(NSMutableDictionary *)properties { @@ -460,46 +571,51 @@ - (void)filterAndLimitUserProperties:(NSMutableDictionary *)properties } } -- (NSDictionary *)truncateModifications +// when user properties change, flush any +// pending events first so they reach the server before the next user-details request. +- (void)userPropertiesChanged { - NSMutableDictionary* truncatedDict = self.modifications.mutableCopy; - [self.modifications enumerateKeysAndObjectsUsingBlock:^(NSString * key, id obj, BOOL * stop) - { - NSString* truncatedKey = [key cly_truncatedKey:@"User details modifications key"]; - if (![truncatedKey isEqualToString:key]) - { - truncatedDict[truncatedKey] = obj; - [truncatedDict removeObjectForKey:key]; - } - - if ([obj isKindOfClass:NSString.class]) - { - NSString* truncatedValue = [obj cly_truncatedValue:@"User details modifications value"]; - if (![truncatedValue isEqualToString:obj]) - { - truncatedDict[truncatedKey] = truncatedValue; - } - } - else if ([obj isKindOfClass:NSDictionary.class]) - { - NSMutableDictionary* truncatedValueDict = ((NSDictionary *)obj).mutableCopy; - [(NSDictionary *)obj enumerateKeysAndObjectsUsingBlock:^(NSString * key, id value, BOOL * stop) - { - if ([value isKindOfClass:NSString.class]) - { - NSString* truncatedValue = [value cly_truncatedValue:@"User details modifications value"]; - if (![truncatedValue isEqualToString:value]) - { - truncatedValueDict[key] = truncatedValue; - truncatedDict[truncatedKey] = truncatedValueDict; - } - } - }]; - } - - }]; - - return truncatedDict.copy; + if (!CountlyCommon.sharedInstance.hasStarted) + return; + [CountlyConnectionManager.sharedInstance sendEvents]; +} + +- (BOOL)isValidDataType:(id) value { + if ([value isKindOfClass:[NSNumber class]] || + [value isKindOfClass:[NSString class]] || + ([value isKindOfClass:[NSArray class]] && (value = [(NSArray *)value cly_filterSupportedDataTypes]))) { + return YES; + } + return NO; } +// Set a single user property. It can be either a custom one or one of the predefined ones. +- (void)setProperty:(NSString *)key value:(id)value { + CLY_LOG_I(@"%s %@ %@", __FUNCTION__, key, value); + + NSMutableDictionary *data = [NSMutableDictionary dictionary]; + if (key != nil && value != nil) { + data[key] = value; + } + + [self setPropertiesInternal:data]; +} + +// Provide a map of user properties to set. +// Those can be either custom user properties or predefined user properties +- (void)setProperties:(NSDictionary *)data { + CLY_LOG_I(@"%s", __FUNCTION__); + + if (data == nil) { + CLY_LOG_W(@"%s provided data can not be 'null'", __FUNCTION__); + return; + } + + [self setPropertiesInternal:data]; +} + +- (void)clear { + CLY_LOG_I(@"%s", __FUNCTION__); + [self clearUserDetails]; +} @end diff --git a/Package.swift b/Package.swift index 10bf0da8..e2d1c11f 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.3 +// swift-tools-version:5.9 import PackageDescription @@ -8,10 +8,11 @@ let package = Package( platforms: [ - .iOS(.v10), + .iOS(.v12), .macOS(.v10_14), - .tvOS(.v10), - .watchOS(.v4) + .tvOS(.v12), + .watchOS(.v4), + .visionOS(.v1) ], products: @@ -45,12 +46,12 @@ let package = Package( linkerSettings: [ .linkedFramework("Foundation"), - .linkedFramework("UIKit", .when(platforms: [.iOS, .tvOS])), + .linkedFramework("UIKit", .when(platforms: [.iOS, .tvOS, .visionOS])), .linkedFramework("WatchKit", .when(platforms: [.watchOS])), .linkedFramework("WatchConnectivity", .when(platforms: [.iOS, .watchOS])), .linkedFramework("AppKit", .when(platforms: [.macOS])), .linkedFramework("IOKit", .when(platforms: [.macOS])), - .linkedFramework("UserNotifications", .when(platforms: [.iOS, .macOS])), + .linkedFramework("UserNotifications", .when(platforms: [.iOS, .macOS, .visionOS])), .linkedFramework("CoreLocation"), .linkedFramework("WebKit", .when(platforms: [.iOS])), .linkedFramework("CoreTelephony", .when(platforms: [.iOS])),