From d1c5784722a802584c06e152b4c36aa18b699b61 Mon Sep 17 00:00:00 2001 From: turtledreams Date: Thu, 22 Oct 2026 18:46:07 +0900 Subject: [PATCH] ios np update 26.8 --- .gitignore | 1 + CHANGELOG.md | 11 +- CountlyReactNative.podspec | 13 +- ios/Podfile | 2 +- ios/src/CHANGELOG.md | 111 ++ ios/src/Countly-PL.podspec | 13 +- ios/src/Countly.h | 179 +- ios/src/Countly.m | 759 ++++---- ios/src/Countly.podspec | 15 +- ios/src/Countly.xcodeproj/project.pbxproj | 153 +- .../xcshareddata/xcschemes/Countly.xcscheme | 1 + .../xcschemes/CountlyTests.xcscheme | 1 + ios/src/CountlyAPMConfig.m | 7 - ios/src/CountlyCommon.h | 103 +- ios/src/CountlyCommon.m | 1260 +++++++++++- ios/src/CountlyConfig.h | 110 +- ios/src/CountlyConfig.m | 24 +- ios/src/CountlyConnectionManager.h | 9 +- ios/src/CountlyConnectionManager.m | 400 ++-- ios/src/CountlyConnectionTest.h | 56 + ios/src/CountlyConnectionTest.m | 498 +++++ ios/src/CountlyConsentManager.m | 107 +- ios/src/CountlyContentBuilder.h | 15 +- ios/src/CountlyContentBuilder.m | 15 +- ios/src/CountlyContentBuilderInternal.h | 51 +- ios/src/CountlyContentBuilderInternal.m | 452 ++++- ios/src/CountlyContentConfig.h | 61 +- ios/src/CountlyContentConfig.m | 69 +- ios/src/CountlyCrashReporter.h | 5 +- ios/src/CountlyCrashReporter.m | 487 ++++- ios/src/CountlyDeviceInfo.h | 14 + ios/src/CountlyDeviceInfo.m | 100 +- ios/src/CountlyEvent.m | 7 +- ios/src/CountlyFeedbackWidget.h | 2 +- ios/src/CountlyFeedbackWidget.m | 101 +- ios/src/CountlyFeedbacks.h | 2 +- ios/src/CountlyFeedbacks.m | 8 +- ios/src/CountlyFeedbacksInternal.h | 8 +- ios/src/CountlyFeedbacksInternal.m | 102 +- ios/src/CountlyHealthTracker.m | 103 +- ios/src/CountlyLocationManager.h | 3 +- ios/src/CountlyLocationManager.m | 51 +- ios/src/CountlyNotificationService.m | 1 + ios/src/CountlyOverlayWindow.h | 4 +- ios/src/CountlyOverlayWindow.m | 5 +- ios/src/CountlyPerformanceMonitoring.m | 90 +- ios/src/CountlyPersistency.h | 14 +- ios/src/CountlyPersistency.m | 244 ++- ios/src/CountlyPushNotifications.m | 69 +- ios/src/CountlyReactNative.m | 179 +- ios/src/CountlyRemoteConfig.m | 35 +- ios/src/CountlyRemoteConfigInternal.h | 6 +- ios/src/CountlyRemoteConfigInternal.m | 487 ++--- ios/src/CountlySDKLimitsConfig.h | 3 + ios/src/CountlySDKLimitsConfig.m | 15 +- ios/src/CountlyServerConfig.h | 11 + ios/src/CountlyServerConfig.m | 1054 ++++++++--- ios/src/CountlyTests.xctestplan | 27 + .../CountlyTests/CountlyBaseTestCase.swift | 38 +- .../CountlyCallbackBaseTestCase.swift | 163 ++ .../CountlyConnectionManagerTests.swift | 442 +++++ .../CountlyConnectionTestTests.swift | 566 ++++++ .../CountlyConsentManagerTests.swift | 272 +++ .../CountlyContentBuilderInternal+Tests.h | 17 + .../CountlyContentBuilderTests.swift | 742 ++++++++ .../CountlyCrashReporterTests.swift | 361 +++- .../CountlyTests/CountlyDeviceIDTests.swift | 48 +- .../CountlyHealthTrackerTests.swift | 221 ++- .../CountlyTests/CountlyLocationTests.swift | 79 +- .../CountlyTests/CountlyPlatformTests.swift | 549 ++++++ .../CountlyQueueFlushRunnablesTests.swift | 330 ++++ .../CountlyRequestCallbackTests.swift | 400 ++++ .../CountlyTests/CountlySDKLogsTests.swift | 1345 +++++++++++++ .../CountlyScreenMetricsTests.swift | 174 ++ .../CountlyServerConfigTests.swift | 1686 +++++++++++++++-- .../CountlyServerConfigValidationTests.swift | 976 ++++++++++ .../CountlyTests-Bridging-Header.h | 8 + ios/src/CountlyTests/CountlyTests.swift | 516 ++++- ios/src/CountlyTests/CountlyThemeTests.swift | 116 ++ .../CountlyUserProfileTests.swift | 601 +++++- ios/src/CountlyTests/CountlyViewTests.swift | 354 +++- .../CountlyWebViewManager+Tests.h | 34 + .../CountlyWebViewManagerTests.swift | 1019 ++++++++++ ios/src/CountlyTests/EventThreadTests.swift | 131 ++ ios/src/CountlyTests/MockFeedbackWidget.swift | 3 + ios/src/CountlyTests/MockURLProtocol.swift | 11 + .../CountlyTests/ServerConfigBuilder.swift | 341 +++- ios/src/CountlyTests/TestAppActivation.swift | 118 ++ ios/src/CountlyTests/TestPlatform.swift | 145 ++ ios/src/CountlyTests/TestURLProtocol.swift | 70 + ios/src/CountlyTests/TestUtils.swift | 319 ++-- ios/src/CountlyUserDetails.h | 64 +- ios/src/CountlyUserDetails.m | 665 ++++--- ios/src/CountlyViewTrackingInternal.h | 12 +- ios/src/CountlyViewTrackingInternal.m | 387 +++- ios/src/CountlyWebViewController.h | 8 +- ios/src/CountlyWebViewController.m | 87 +- ios/src/CountlyWebViewManager.h | 7 +- ios/src/CountlyWebViewManager.m | 649 ++++++- ios/src/Package.swift | 22 +- ios/src/PassThroughBackgroundView.h | 18 +- ios/src/PassThroughBackgroundView.m | 24 +- ios/src/PrivacyInfo.xcprivacy | 12 + ios/src/TouchDelegatingView.h | 4 +- ios/src/TouchDelegatingView.m | 2 +- 105 files changed, 18976 insertions(+), 2913 deletions(-) create mode 100644 ios/src/CountlyConnectionTest.h create mode 100644 ios/src/CountlyConnectionTest.m create mode 100644 ios/src/CountlyTests.xctestplan create mode 100644 ios/src/CountlyTests/CountlyCallbackBaseTestCase.swift create mode 100644 ios/src/CountlyTests/CountlyConnectionTestTests.swift create mode 100644 ios/src/CountlyTests/CountlyConsentManagerTests.swift create mode 100644 ios/src/CountlyTests/CountlyContentBuilderInternal+Tests.h create mode 100644 ios/src/CountlyTests/CountlyContentBuilderTests.swift create mode 100644 ios/src/CountlyTests/CountlyPlatformTests.swift create mode 100644 ios/src/CountlyTests/CountlyQueueFlushRunnablesTests.swift create mode 100644 ios/src/CountlyTests/CountlyRequestCallbackTests.swift create mode 100644 ios/src/CountlyTests/CountlySDKLogsTests.swift create mode 100644 ios/src/CountlyTests/CountlyScreenMetricsTests.swift create mode 100644 ios/src/CountlyTests/CountlyServerConfigValidationTests.swift create mode 100644 ios/src/CountlyTests/CountlyThemeTests.swift create mode 100644 ios/src/CountlyTests/CountlyWebViewManager+Tests.h create mode 100644 ios/src/CountlyTests/CountlyWebViewManagerTests.swift create mode 100644 ios/src/CountlyTests/EventThreadTests.swift create mode 100644 ios/src/CountlyTests/TestAppActivation.swift create mode 100644 ios/src/CountlyTests/TestPlatform.swift create mode 100644 ios/src/CountlyTests/TestURLProtocol.swift diff --git a/.gitignore b/.gitignore index b4eb8888..bbcd08c9 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ example/AwesomeProject/* example/ExpoProject/* hs_err_pid*.log replay_pid*.log +ios/src/.build/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 532b6ee8..c683eb6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ * ! Minor breaking change ! The package now ships only the files the SDK needs at runtime; the example app, the tests and the Xcode project for manual linking are no longer part of it. It declares `react-native` as a peer dependency and needs React Native 0.62 or newer. * ! Minor breaking change ! The pod now makes only the bridge header and the iOS SDK's public headers public; other headers can no longer be imported from it. * ! Minor breaking change ! The `NotificationService` subspec no longer depends on React, and `pod 'CountlyReactNative'` installs only the `Core` subspec. -* ! Minor breaking change ! With React Native 0.71 and newer, the pod's iOS deployment target now follows React Native's minimum iOS version. +* ! Minor breaking change ! The pod now needs iOS 15 (tvOS 15, macOS 12, watchOS 9), the minimum of the bundled iOS SDK 26.8.0, instead of iOS 10. With React Native 0.71 and newer it uses React Native's minimum iOS version when that is higher. An app whose Podfile platform is below iOS 15, for example with React Native 0.75 or older, has to raise it; an app that has to support older iOS versions can stay on 26.1.0. * Added a `{ success, error }` result to `Countly.remoteConfig.update`, `updateForKeysOnly` and `updateExceptKeys`. * Added `Countly.registerForNotificationOpened(listener)`, called when the user taps a push in Android and iOS: @@ -74,6 +74,15 @@ * `Countly.getRemoteConfigValueForKey` and `Countly.getRemoteConfigValueForKeyP`, use `Countly.remoteConfig.getValue` * `Countly.remoteConfigClearValues`, use `Countly.remoteConfig.clearValues` +* iOS specific changes: + * ! Minor breaking change ! A SIGPIPE the app neither ignores nor handles itself now ends the app after the crash is reported, as it would without the SDK. Before, the SDK reported it, kept the app running and turned automatic crash reporting off. An app that has to survive a broken pipe should ignore or handle SIGPIPE before `Countly.initWithConfig`. + * Added support for server requested SDK log gathering and connection tests, and for SDK behavior settings that control automatic session tracking, automatic view tracking, automatic crash reporting and Journey Trigger Views, also for the running session. + * Mitigated an issue where `Countly.userData.setProperty` and `Countly.userDataBulk.setProperty` with a predefined key (name, username, email, organization, phone, gender, picture, picturePath, byear) recorded a custom property of that name; they now set that field, as on Android. + * `Countly.setUserData`, `Countly.userDataBulk.setUserProperties`, `Countly.userData.setProperty` and `Countly.userDataBulk.setProperty` now use the iOS SDK's user property API: pending events are sent before the user details, and `null` values and nested objects are skipped with a log line instead of being sent, as on Android. + * The iOS SDK's default log level is now debug instead of verbose. + +* Updated the underlying iOS SDK version to 26.8.0 + ## 26.1.0-np * Added a new `Countly.sessions` interface for manual session control: * `beginSession()` diff --git a/CountlyReactNative.podspec b/CountlyReactNative.podspec index cd304135..05793bda 100644 --- a/CountlyReactNative.podspec +++ b/CountlyReactNative.podspec @@ -8,11 +8,14 @@ Pod::Spec.new do |s| s.author = {'Countly' => 'hello@count.ly'} s.source = { :git => 'https://github.com/Countly/countly-sdk-react-native-bridge.git', :tag => s.version.to_s } s.requires_arc = true - # The privacy bundle target inherits this; React Native 0.71+ defines its own minimum, which current Xcode accepts, unlike 10.0. - s.ios.deployment_target = defined?(min_ios_version_supported) ? min_ios_version_supported : '10.0' - s.osx.deployment_target = '10.14' - s.watchos.deployment_target = '4.0' - s.tvos.deployment_target = '10.0' + # The bundled iOS SDK needs iOS 15, tvOS 15, macOS 12 and watchOS 9. React Native 0.71+ defines its own iOS minimum, + # which is used when it is higher; the privacy bundle target inherits this. + countly_min_ios = '15.0' + rn_min_ios = defined?(min_ios_version_supported) ? min_ios_version_supported.to_s : countly_min_ios + s.ios.deployment_target = Gem::Version.new(rn_min_ios) > Gem::Version.new(countly_min_ios) ? rn_min_ios : countly_min_ios + s.osx.deployment_target = '12.0' + s.watchos.deployment_target = '9.0' + s.tvos.deployment_target = '15.0' s.static_framework = true s.default_subspecs = 'Core' diff --git a/ios/Podfile b/ios/Podfile index 32d7d266..df003e66 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,4 +1,4 @@ -platform :ios, '10.0' +platform :ios, '15.0' target 'countly-sdk-react-native-bridge' do use_frameworks! diff --git a/ios/src/CHANGELOG.md b/ios/src/CHANGELOG.md index 3453ab8c..92f4ae7c 100644 --- a/ios/src/CHANGELOG.md +++ b/ios/src/CHANGELOG.md @@ -1,3 +1,114 @@ +## 26.8.0 +* ! Minor breaking change ! Raised the minimum supported deployment targets to iOS 15, tvOS 15, watchOS 9 and macOS 12 (previously iOS 12, tvOS 12, watchOS 4 and macOS 10.14, and iOS 10 and tvOS 10 for "Countly-PL") for compatibility with Xcode 27. Apps targeting older versions are no longer supported and can keep using 26.1.4. +* ! Minor breaking change ! "changeDeviceIDWithMerge:" and "changeDeviceIDWithoutMerge:" now ignore nil, an empty string and "CLYDefaultDeviceID" instead of switching back to the default device ID. +* ! Minor breaking change ! A SIGPIPE the app neither ignores nor handles itself now ends the app after it is reported, as it would without the SDK. Previously the SDK reported it, kept the app running and turned automatic crash reporting off. An app that has to survive a broken pipe should ignore or handle SIGPIPE before starting the SDK. + +* Added support for server requested SDK log gathering and connection tests. When consent is required, a gathered log line is uploaded only after the consent of the feature that wrote it is given, and a line no single feature wrote waits for both the events and the user details consent. +* Added support for SDK behavior settings that control automatic session tracking, automatic view tracking, automatic crash reporting and Journey Trigger Views, also for the running session. Automatic crash reporting can be turned on this way also for an app that does not list "CLYCrashReporting" in "features", and crash consent still applies. +* Added "userProfile" access on watchOS, tvOS and macOS. +* Added a new method "setGlobalContentSegmentation" on "CountlyContentBuilder" that sets a segmentation recorded with every content event and with the events and answers of NPS, survey and rating feedback widgets presented or reported through "CountlyFeedbackWidget", leaving out the keys widgets record as answers. The legacy "presentRatingWidgetWithID:" and "recordRatingWidgetWithID:" calls do not carry it. (Experimental!) +* Improved internal logging and changed the default log level from verbose to debug. +* Improved crash handling. The SDK's handlers now pass crashes on to the handlers they replaced, leave crash tools installed later, signals the app ignores and a SIGPIPE the app handles itself alone, and are removed by "halt". +* Improved state cleanup on "halt", which now also resets events, view tracking, remote config, location and the content state on visionOS, and puts back into the configuration given at start the values SDK behavior settings changed in it. + +* Mitigated an issue where SDK behavior settings were ignored when the server response contained additional fields. +* Mitigated an issue where refreshing SDK behavior settings after the app returned from the background reset the configuration given at start until the next launch. +* Mitigated an issue where a setting removed from the SDK behavior settings in SDK Manager did not go back to the value given at start. +* Mitigated an issue where SDK behavior settings sent as false or 0, such as "log" or "dort", did not override the value given at start. The consent requirement stays the exception: SDK behavior settings can turn it on, but never turn off a requirement set with "requiresConsent". +* Mitigated an issue where the app could freeze instead of terminating on a crash after crash reporting was turned off at runtime or after "halt". +* Mitigated an issue where the app could freeze instead of terminating on a crash that happened inside the SDK's request handling or while the SDK held an internal lock. The SDK now waits at most 5 seconds for the crash report and sends it on the next start when it did not go out in time. +* Mitigated possible crashes, repeated on every start, when the server or a proxy answered a request with a JSON list instead of an object, when SDK behavior settings carried a null version, timestamp or list entry, and when the A/B testing helpers received an error answer. +* Mitigated a crash, repeated on every start, after "pictureLocalPath" was set to NSNull. As documented, NSNull now clears the picture on the server. +* Mitigated an issue where an event with a NaN or infinite sum, duration or segmentation value dropped every other event recorded with it. The invalid value is now left out. +* Mitigated an issue where "disableBackoffMechanism" took effect only while SDK behavior settings kept changing. +* Mitigated an issue where remote config values downloaded for the previous device ID were stored for the next one when the device ID changed during the download. +* Mitigated an issue where automatic view tracking did not end the previous view when the next one appeared, so views stayed open and reported no duration, or a very long one. This also covers the screen already on top when automatic view tracking starts, which now also honors the exclusion list. +* Mitigated an issue where automatic view tracking recorded the SDK's own content and feedback widget screen as a view. +* Mitigated an issue where events recorded while the app was inactive, for example under a system alert or in the app switcher, were reported as not visible on iOS, tvOS, visionOS and watchOS. +* Mitigated an issue where a feedback widget that failed to load was recorded as closed by the user, which could keep it hidden from that user. +* Mitigated an issue where "halt" in consent mode queued a request that withdrew every consent and unregistered the app from push notifications, and where "halt:" without clearing the storage dropped the requests queued since the last save. +* Mitigated an issue where the Journey Trigger content refresh and the "ecz" SDK behavior setting had no effect on visionOS. +* Mitigated an issue where the SDK's privacy manifest was not included in the Swift package, the CocoaPods pods or the framework. It now also declares the diagnostic data that SDK log gathering collects. +* Mitigated an issue where, with PLCrashReporter, crashes captured while crash consent or automatic crash reporting was off, or after "halt", were sent later. They are now discarded without calling "crashOccuredOnPreviousSessionCallback", and both PLCrashReporter callbacks run on the main thread. +* Mitigated an issue where certificate pinning was not applied to immediate requests such as SDK behavior settings, remote config, feedback widgets, content and health checks. +* Mitigated an issue where an auto stopped view running when the app went to the background could stop right after the app returned to the foreground. +* Mitigated an issue where health check counters of the previous app run were lost when a warning was logged before the SDK started. +* Mitigated an issue where a warning was logged when the app became active while a session was already running. +* Mitigated an issue where a local profile picture path was sent on macOS, tvOS and watchOS, where uploading a local picture is not supported. +* Mitigated a possible crash when SDK behavior settings updated filters or Journey Trigger lists while data was being recorded. +* Mitigated an issue where reserved events could be identified incorrectly on Intel based Macs. +* Mitigated an issue where a request queued while the request queue was finishing was sent only when the queue was triggered again, for example by the next session update, which could delay the content of Journey Trigger Events and Views. +* Mitigated an issue where, with location tracking turned off in the SDK behavior settings, the SDK queued a request without the app key at start. The server rejected it, and every request queued after it, the session begin included, was held back. +* Mitigated an issue where headers added with "addCustomNetworkRequestHeaders" were written into the "URLSessionConfiguration" of the configuration given at start, so a start with that configuration after "halt" still sent them. +* Mitigated a possible crash in "halt" while the health check state was being reset. +* Mitigated an issue where, when the SDK behavior settings turned the consent requirement on while the SDK was finishing its start, the consent state and the empty location were not sent. +* Fixed a bug where "halt" deleted every value the app itself stored in the standard user defaults. It now removes only the SDK's own values. +* Fixed a crash on visionOS when view tracking consent was given or cancelled. +* Fixed a crash on watchOS and macOS when calling "halt". +* Fixed macOS not starting a session when the app was not active at launch. +* Fixed visibility reporting and background session suppression on visionOS. +* Fixed view IDs returned by "startView" no longer resolving after the app was backgrounded and foregrounded, which made "stopViewWithID" fail and left the view reporting a new visit and a duration on every later foreground. + +* Removed the following deprecated calls from "Countly": + * "setNewDeviceID:onServer:", use "changeDeviceIDWithMerge:" or "changeDeviceIDWithoutMerge:" instead + * "giveConsentForAllFeatures", use "giveAllConsents" instead + * "recordHandledException:", use "recordException:" instead + * "recordHandledException:withStackTrace:" and "recordUnhandledException:withStackTrace:", use "recordException:isFatal:stackTrace:segmentation:" instead + * "recordView:" and "recordView:segmentation:", use "[views startAutoStoppedView:]" and "[views startAutoStoppedView:segmentation:]" instead + * "addExceptionForAutoViewTracking:", use the "automaticViewTrackingExclusionList" config option instead + * "removeExceptionForAutoViewTracking:" and "isAutoViewTrackingActive", which have no replacement + * "presentFeedbackWidgetWithID:completionHandler:", use "presentRatingWidgetWithID:completionHandler:" instead + * "getFeedbackWidgets:", use "[feedback getAvailableFeedbackWidgets:]" instead + * "recordAttributionID:", use "recordIndirectAttribution:" with the "CLYAttributionKeyIDFA" key, or "recordDirectAttributionWithCampaignType:andCampaignData:" instead + * "remoteConfigValueForKey:", use "[remoteConfig getValue:]" instead + * "updateRemoteConfigWithCompletionHandler:", use "[remoteConfig downloadKeys:]" instead + * "updateRemoteConfigOnlyForKeys:completionHandler:", use "[remoteConfig downloadSpecificKeys:completionHandler:]" instead + * "updateRemoteConfigExceptForKeys:completionHandler:", use "[remoteConfig downloadOmittingKeys:completionHandler:]" instead +* Removed the following deprecated config options from "CountlyConfig": + * "maxKeyLength", "maxValueLength", "maxSegmentationValues" and "crashLogLimit", use "setMaxKeyLength:", "setMaxValueSize:", "setMaxSegmentationValues:" and "setMaxBreadcrumbCount:" of "sdkInternalLimits" instead + * "attributionID", use "indirectAttribution" with the "CLYAttributionKeyIDFA" key, or "campaignType" and "campaignData" instead + * "enableRemoteConfig", use "enableRemoteConfigAutomaticTriggers" instead + * "remoteConfigCompletionHandler", use "remoteConfigRegisterGlobalCallback:" instead + * "enablePerformanceMonitoring", use "enableAppStartTimeTracking", "enableForegroundBackgroundTracking" and "enableManualAppLoadedTrigger" of "apm" instead + * "enableServerConfiguration", which had no effect +* Removed the deprecated feature constant "CLYAutoViewTracking", use the "enableAutomaticViewTracking" config option instead + +## 26.1.4 +* Added support for reporting the app's current theme (light or dark) when presenting feedback widgets, rating widgets, and content, so they are displayed in matching conditions. +* Added visionOS support for feedback widgets (NPS, Survey, and Rating) and content. +* Added a content configuration option "disableRotation" on "CountlyContentConfig" that pins displayed content to the portrait layout. +* Calling "exitContentZone" now also closes content that is currently displayed, instead of only stopping content retrieval. + +* Mitigated an issue where feedback widgets could be closed automatically after about a minute even while the user was still interacting with them. + +## 26.1.3 +* Added a content configuration option to reload the content web view when its load stalls, enabled via "enableContentReloadOnStall" on "CountlyContentConfig", with a configurable stall timeout "setContentReloadOnStallTimeout:" in milliseconds (default 1000). +* Added a content configuration option to disable pinch zoom, disabled via "disableZoom". +* Added a content configuration option to provide a handler for links opened from the content web view, so the app can route its own deep links instead of the SDK opening the system browser, set via "setContentURLHandler:". +* Improved link handling for content and feedback widgets, so links that carry their own query parameters, such as deep links, are parsed correctly. + +## 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/ios/src/Countly-PL.podspec b/ios/src/Countly-PL.podspec index 33338d45..58ec8d71 100644 --- a/ios/src/Countly-PL.podspec +++ b/ios/src/Countly-PL.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'Countly-PL' - s.version = '26.1.1' + s.version = '26.8.0' s.license = { :type => 'MIT', :file => 'LICENSE' } s.summary = 'Countly is an innovative, real-time, open source mobile analytics platform.' s.homepage = 'https://github.com/Countly/countly-sdk-ios' @@ -10,21 +10,22 @@ Pod::Spec.new do |s| s.requires_arc = true s.default_subspecs = 'Core' - s.ios.deployment_target = '10.0' - s.osx.deployment_target = '10.14' - s.watchos.deployment_target = '4.0' - s.tvos.deployment_target = '10.0' + s.ios.deployment_target = '15.0' + s.osx.deployment_target = '12.0' + s.watchos.deployment_target = '9.0' + s.tvos.deployment_target = '15.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.resource_bundles = { 'Countly-PL' => ['PrivacyInfo.xcprivacy'] } core.ios.frameworks = ['Foundation', 'UIKit', 'UserNotifications', 'CoreLocation', 'WebKit', 'CoreTelephony', 'WatchConnectivity'] end s.subspec 'NotificationService' do |ns| ns.source_files = 'CountlyNotificationService.{m,h}' - ns.ios.deployment_target = '10.0' + ns.ios.deployment_target = '15.0' ns.ios.frameworks = ['Foundation', 'UserNotifications'] end diff --git a/ios/src/Countly.h b/ios/src/Countly.h index 8949e263..ce8a6a3a 100644 --- a/ios/src/Countly.h +++ b/ios/src/Countly.h @@ -45,7 +45,7 @@ NS_ASSUME_NONNULL_BEGIN * Sets a new host to be used in requests. * @discussion Requests already queued previously will also be using the new host. * @discussion The new host needs to be a non-zero length string, otherwise it is ignored. - * @discussion @c recordPushNotificationToken and @c updateRemoteConfigWithCompletionHandler: methods may need to be called after the host change. + * @discussion @c recordPushNotificationToken and @c [remoteConfig downloadKeys:] methods may need to be called after the host change. * @param newHost The new host */ - (void)setNewHost:(NSString *)newHost; @@ -55,7 +55,7 @@ NS_ASSUME_NONNULL_BEGIN * @discussion Before switching to the new app key, this method suspends Countly and resumes it immediately after. * @discussion The requests already queued prior to this method call will keep using the old app key. * @discussion The new app key needs to be a non-zero length string, otherwise it is ignored. - * @discussion @c recordPushNotificationToken and @c updateRemoteConfigWithCompletionHandler: methods may need to be called again after the app key change. + * @discussion @c recordPushNotificationToken and @c [remoteConfig downloadKeys:] methods may need to be called again after the app key change. * @param newAppKey The new app key */ - (void)setNewAppKey:(NSString *)newAppKey; @@ -175,18 +175,7 @@ NS_ASSUME_NONNULL_BEGIN /** * Sets new device ID to be persistently stored and used in following requests. - * @discussion Value passed for @c deviceID parameter has to be a non-zero length valid string, otherwise default device ID will be used instead. - * @discussion If value passed for @c deviceID parameter is exactly same to the current device ID, method call is ignored. - * @discussion When passing @c CLYTemporaryDeviceID for @c deviceID parameter, argument for @c onServer parameter does not matter. - * @discussion When setting a new device ID while the current device ID is @c CLYTemporaryDeviceID, argument for @c onServer parameter does not matter. - * @param deviceID New device ID - * @param onServer If set, data on Countly Server will be merged automatically, otherwise device will be counted as a new device - */ -- (void)setNewDeviceID:(NSString * _Nullable)deviceID onServer:(BOOL)onServer DEPRECATED_MSG_ATTRIBUTE("Use 'changeDeviceIDWithMerge: or changeDeviceIDWithoutMerge:' method instead!"); - -/** - * Sets new device ID to be persistently stored and used in following requests. - * @discussion Value passed for @c deviceID parameter has to be a non-zero length valid string, otherwise default device ID will be used instead. + * @discussion Value passed for @c deviceID parameter has to be a non-zero length valid string, otherwise method call is ignored. * @discussion If value passed for @c deviceID parameter is exactly same to the current device ID, method call is ignored. * @discussion When passing @c CLYTemporaryDeviceID for @c deviceID parameter, argument for @c onServer parameter does not matter. * @param deviceID New device ID @@ -195,7 +184,7 @@ NS_ASSUME_NONNULL_BEGIN /** * Sets new device ID to be persistently stored and used in following requests. - * @discussion Value passed for @c deviceID parameter has to be a non-zero length valid string, otherwise default device ID will be used instead. + * @discussion Value passed for @c deviceID parameter has to be a non-zero length valid string, otherwise method call is ignored. * @discussion If value passed for @c deviceID parameter is exactly same to the current device ID, method call is ignored. * @discussion When passing @c CLYTemporaryDeviceID for @c deviceID parameter, argument for @c onServer parameter does not matter. * @param deviceID New device ID @@ -232,13 +221,6 @@ NS_ASSUME_NONNULL_BEGIN */ - (void)giveConsentForFeatures:(NSArray *)features; -/** - * Grants consent to all features and starts them. - * @discussion This is a convenience method for grating consent for all features at once. - * @discussion Inner workings of @c giveConsentForFeature: method applies for this method as well. - */ -- (void)giveConsentForAllFeatures DEPRECATED_MSG_ATTRIBUTE("Use 'giveAllConsents:' method instead!"); - /** * Grants consent to all features and starts them. * @discussion This is a convenience method for grating consent for all features at once. @@ -508,27 +490,6 @@ NS_ASSUME_NONNULL_BEGIN */ - (void)recordError:(NSString *)errorName isFatal:(BOOL)isFatal stackTrace:(NSArray * _Nullable)stackTrace segmentation:(NSDictionary * _Nullable)segmentation; -/** - * Records a handled exception manually. - * @param exception Exception to be recorded - */ -- (void)recordHandledException:(NSException *)exception DEPRECATED_MSG_ATTRIBUTE("Use 'recordException:' method instead!"); - -/** - * Records a handled exception and given stack trace manually. - * @param exception Exception to be recorded - * @param stackTrace Stack trace to be recorded - */ -- (void)recordHandledException:(NSException *)exception withStackTrace:(NSArray * _Nullable)stackTrace DEPRECATED_MSG_ATTRIBUTE("Use 'recordException:isFatal:stackTrace:segmentation:' method instead! (passing isFatal:NO, segmentation:nil)"); - -/** - * Records an unhandled exception and given stack trace manually. - * @discussion For recording non-native level fatal exceptions, where the app keeps running at native level and can recover. - * @param exception Exception to be recorded - * @param stackTrace Stack trace to be recorded - */ -- (void)recordUnhandledException:(NSException *)exception withStackTrace:(NSArray * _Nullable)stackTrace DEPRECATED_MSG_ATTRIBUTE("Use 'recordException:isFatal:stackTrace:segmentation:' method instead! (passing isFatal:YES, segmentation:nil)"); - /** * Records custom logs to be delivered with crash report. * @discussion Logs recorded by this method are stored in a non-persistent structure, and delivered to Countly Server only in case of a crash. @@ -546,50 +507,6 @@ NS_ASSUME_NONNULL_BEGIN #pragma mark - View Tracking -/** - * Records a visited view with given name. - * @discussion Total duration of the view will be calculated on next @c recordView: call. - * @discussion If AutoViewTracking feature is enabled on initial configuration, this method does not need to be called manually. - * @param viewName Name of the view visited, a non-zero length valid string - */ -- (void)recordView:(NSString *)viewName DEPRECATED_MSG_ATTRIBUTE("Use '[views startView/startAutoStoppedView:]' method instead!"); - -/** - * Records a visited view with given name and custom segmentation. - * @discussion This is an extended version of @c recordView: method. - * @discussion If segmentation has any of Countly reserved keys, they will be ignored: - * @discussion @c name, @c segment, @c visit, @c start, @c bounce, @c exit, @c view, @c domain, @c dur - * @discussion Segmentation should be an @c NSDictionary, with keys and values are both @c NSString's only. - * @discussion Custom objects in segmentation will cause events not to be sent to Countly Server. - * @discussion Nested values in segmentation will be ignored by Countly Server event segmentation section. - * @param viewName Name of the view visited, a non-zero length valid string - * @param segmentation Custom segmentation key-value pairs - */ -- (void)recordView:(NSString *)viewName segmentation:(NSDictionary *)segmentation DEPRECATED_MSG_ATTRIBUTE("Use '[views startView/startAutoStoppedView:]' method instead!"); - -#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV ) -/** - * Adds exception for AutoViewTracking. - * @discussion @c UIViewControllers with specified title or class name will be ignored by AutoViewTracking and their appearances and disappearances will not be recorded. - * @discussion Adding an already added @c UIViewController title or subclass name again will have no effect. - * @param exception @c UIViewController title or subclass name to be added as exception - */ -- (void)addExceptionForAutoViewTracking:(NSString *)exception DEPRECATED_MSG_ATTRIBUTE("Use '[views setGlobalViewSegmentation:]' method or 'globalViewSegmentation' of config instead!"); - -/** - * Removes exception for AutoViewTracking. - * @discussion Removing an already removed (or not yet added) @c UIViewController title or subclass name will have no effect. - * @param exception @c UIViewController title or subclass name to be removed - */ -- (void)removeExceptionForAutoViewTracking:(NSString *)exception DEPRECATED_MSG_ATTRIBUTE("Don't use this method, it will be remove in future release"); - -/** - * Temporarily activates or deactivates AutoViewTracking, if AutoViewTracking feature is enabled on initial configuration. - * @discussion If AutoViewTracking feature is not enabled on initial configuration, this property has no effect. - */ -@property (nonatomic) BOOL isAutoViewTrackingActive DEPRECATED_MSG_ATTRIBUTE("Don't use this variable, it will be remove in future release"); -#endif - /** * Interface variable to access views tracking functionalities. * @discussion Views tracking interface for developer to interact with SDK. @@ -602,7 +519,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)."); @@ -617,23 +534,6 @@ NS_ASSUME_NONNULL_BEGIN */ - (void)askForStarRating:(void(^)(NSInteger rating))completion; -/** - * Presents feedback widget with given ID in a WKWebView placed in a UIViewController. - * @discussion First, the availability of the feedback widget will be checked asynchronously. - * @discussion If the feedback widget with given ID is available, it will be modally presented. - * @discussion Otherwise, @c completionHandler will be executed with an @c NSError. - * @discussion @c completionHandler will also be executed with @c nil when feedback widget is dismissed by user. - * @discussion Calls to this method will be ignored and @c completionHandler will not be executed if: - * @discussion - Consent for @c CLYConsentFeedback is not given, while @c requiresConsent flag is set on initial configuration. - * @discussion - Current device ID is @c CLYTemporaryDeviceID. - * @discussion - @c widgetID is not a non-zero length valid string. - * @discussion This is a legacy method for presenting Rating type feedback widgets only. - * @discussion Passing widget ID's of Survey or NPS type feedback widgets will not work. - * @param widgetID ID of the feedback widget created on Countly Server. - * @param completionHandler A completion handler block to be executed when feedback widget is dismissed by user or there is an error. - */ -- (void)presentFeedbackWidgetWithID:(NSString *)widgetID completionHandler:(void (^)(NSError * __nullable error))completionHandler DEPRECATED_MSG_ATTRIBUTE("Use 'presentRatingWidgetWithID:completionHandler' method instead!"); - /** * Presents rating widget with given ID in a WKWebView placed in a UIViewController. * @discussion First, the availability of the rating widget will be checked asynchronously. @@ -682,17 +582,6 @@ NS_ASSUME_NONNULL_BEGIN */ - (void)recordRatingWidgetWithID:(NSString *)widgetID rating:(NSInteger)rating email:(NSString * _Nullable)email comment:(NSString * _Nullable)comment userCanBeContacted:(BOOL)userCanBeContacted; -/** - * Fetches a list of available feedback widgets. - * @discussion When feedback widgets are fetched successfully, @c completionHandler will be executed with an array of @c CountlyFeedbackWidget objects. - * @discussion Otherwise, @c completionHandler will be executed with an @c NSError. - * @discussion Calls to this method will be ignored and @c completionHandler will not be executed if: - * @discussion - Consent for @c CLYConsentFeedback is not given, while @c requiresConsent flag is set on initial configuration. - * @discussion - Current device ID is @c CLYTemporaryDeviceID. - * @param completionHandler A completion handler block to be executed when list is fetched successfully or there is an error. - */ -- (void)getFeedbackWidgets:(void (^)(NSArray * __nullable feedbackWidgets, NSError * __nullable error))completionHandler DEPRECATED_MSG_ATTRIBUTE("Use '[feedback getAvailableFeedbackWidgets:]' method instead!"); - /** * This is an experimental feature and it can have breaking changes * Interface variable to access content functionalities. @@ -707,19 +596,17 @@ NS_ASSUME_NONNULL_BEGIN - (CountlyFeedbacks *) feedback; #endif - - -#pragma mark - Attribution +#pragma mark - User Profiles /** - * Records attribution ID (IDFA) for campaign attribution. - * @discussion This method overrides @c attributionID property specified on initial configuration, and sends an immediate request. - * @discussion Also, this attribution ID will be sent with all @c begin_session requests. - * @discussion Calls to this method will be ignored if: - * @discussion - Consent for @c CLYConsentAttribution is not given, while @c requiresConsent flag is set on initial configuration. - * @param attributionID Attribution ID (IDFA) + * Interface variable to access user profile functionalities. + * @discussion User profile interface for developer to interact with SDK. */ -- (void)recordAttributionID:(NSString *)attributionID DEPRECATED_MSG_ATTRIBUTE("Use 'recordDirectAttributionWithCampaignType:' or 'recordIndirectAttribution' method instead!"); +- (CountlyUserDetails *) userProfile; + + + +#pragma mark - Attribution /** * Records direct attribution with campaign type and data. @@ -744,46 +631,6 @@ NS_ASSUME_NONNULL_BEGIN - (void)recordIndirectAttribution:(NSDictionary *)attribution; #pragma mark - Remote Config -/** - * Returns last retrieved remote config value for given key, if exists. - * @discussion If remote config is never retrieved from Countly Server before, this method will return @c nil. - * @discussion If @c key is not defined in remote config on Countly Server, this method will return @c nil. - * @discussion If Countly Server is not reachable, this method will return the last retrieved value which is stored on device. - * @param key Remote config key specified on Countly Server - */ -- (id)remoteConfigValueForKey:(NSString *)key DEPRECATED_MSG_ATTRIBUTE("Use '[remoteConfig getValue:]' method instead!"); - -/** - * Manually updates all locally stored remote config values by fetching latest values from Countly Server, and executes completion handler. - * @discussion @c completionHandler has an @c NSError parameter that will be either @ nil or an @c NSError object, depending on result. - * @discussion Calls to this method will be ignored and @c completionHandler will not be executed if: - * @discussion - There is not any consent given, while @c requiresConsent flag is set on initial configuration. - * @discussion - Current device ID is @c CLYTemporaryDeviceID. - * @param completionHandler A completion handler block to be executed when updating of remote config is completed, either with success or failure. - */ -- (void)updateRemoteConfigWithCompletionHandler:(void (^)(NSError * __nullable error))completionHandler DEPRECATED_MSG_ATTRIBUTE("Use '[remoteConfig downloadKeys:]' method instead!"); - -/** - * Manually updates locally stored remote config values only for specified keys, by fetching latest values from Countly Server, and executes completion handler. - * @discussion @c completionHandler has an @c NSError parameter that will be either @ nil or an @c NSError object, depending on result. - * @discussion Calls to this method will be ignored and @c completionHandler will not be executed if: - * @discussion - There is not any consent given, while @c requiresConsent flag is set on initial configuration. - * @discussion - Current device ID is @c CLYTemporaryDeviceID. - * @param keys An array of remote config keys to update - * @param completionHandler A completion handler block to be executed when updating of remote config is completed, either with success or failure - */ -- (void)updateRemoteConfigOnlyForKeys:(NSArray *)keys completionHandler:(void (^)(NSError * __nullable error))completionHandler DEPRECATED_MSG_ATTRIBUTE("Use '[remoteConfig downloadSpecificKeys:]' method instead!"); - -/** - * Manually updates locally stored remote config values except for specified keys, by fetching latest values from Countly Server, and executes completion handler. - * @discussion @c completionHandler has an @c NSError parameter that will be either @ nil or an @c NSError object, depending on result. - * @discussion Calls to this method will be ignored and @c completionHandler will not be executed if: - * @discussion - There is not any consent given, while @c requiresConsent flag is set on initial configuration. - * @discussion - Current device ID is @c CLYTemporaryDeviceID. - * @param omitKeys An array of remote config keys to omit from updating - * @param completionHandler A completion handler block to be executed when updating of remote config is completed, either with success or failure - */ -- (void)updateRemoteConfigExceptForKeys:(NSArray *)omitKeys completionHandler:(void (^)(NSError * __nullable error))completionHandler DEPRECATED_MSG_ATTRIBUTE("Use '[remoteConfig downloadOmittingKeys:]' method instead!"); - (CountlyRemoteConfig *) remoteConfig; diff --git a/ios/src/Countly.m b/ios/src/Countly.m index d07ef6fd..bb1a862f 100644 --- a/ios/src/Countly.m +++ b/ios/src/Countly.m @@ -10,6 +10,7 @@ @interface Countly () { NSTimer* timer; BOOL isSuspended; + CountlyConfig* _startConfig; } @end @@ -51,6 +52,16 @@ - (void)resetInstance { } // Remove all notification observers to avoid duplicate registrations after re-init in tests. [NSNotificationCenter.defaultCenter removeObserver:self]; + // Break the previous-event chain, otherwise the first event recorded after a restart + // carries the `peid` / `pen` of an event from the previous SDK lifetime. +#if __has_include() + os_unfair_lock_lock(&previousEventLock); +#endif + previousEventID = nil; + previousEventName = nil; +#if __has_include() + os_unfair_lock_unlock(&previousEventLock); +#endif isSuspended = NO; onceToken = 0; s_sharedCountly = nil; @@ -85,6 +96,19 @@ - (instancetype)init selector:@selector(applicationWillTerminate:) name:NSApplicationWillTerminateNotification object:nil]; + + //NOTE: macOS has no background state, so there is no `suspend` counterpart here. + // `applicationDidBecomeActive:` is observed so that a `begin_session` dropped by + // the "app is not active" guard in `beginSession` (app launched hidden, as a login + // item, or opened by another app) is recovered on the first activation. + //NOTE: Resign-active is deliberately NOT observed. Its handler saves health tracker + // state, which forces an NSUserDefaults synchronize, and on macOS the user + // switches away from the app constantly. `applicationWillTerminate:` already + // saves that state. + [NSNotificationCenter.defaultCenter addObserver:self + selector:@selector(applicationDidBecomeActive:) + name:NSApplicationDidBecomeActiveNotification + object:nil]; #endif } @@ -94,16 +118,21 @@ - (instancetype)init - (void)startWithConfig:(CountlyConfig *)config { if (CountlyCommon.sharedInstance.hasStarted_) + { + CLY_LOG_W(@"%s omitting the call, the SDK has already been started", __FUNCTION__); return; + } CountlyCommon.sharedInstance.hasStarted = YES; + [CountlyCommon.sharedInstance resumeSdkLogCaptureForStart]; CountlyCommon.sharedInstance.enableDebug = config.enableDebug; 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,29 +140,22 @@ - (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 - if(CountlyCommon.sharedInstance.maxKeyLength == kCountlyMaxKeyLength && config.maxKeyLength != kCountlyMaxKeyLength) { - CountlyCommon.sharedInstance.maxKeyLength = config.maxKeyLength; - CLY_LOG_I(@"%s deprecated maxKeyLength provided, maxKeyLength: [%lu]", __FUNCTION__, (unsigned long)config.maxKeyLength); - } - if(CountlyCommon.sharedInstance.maxValueLength == kCountlyMaxValueSize && config.maxValueLength != kCountlyMaxValueSize) { - CountlyCommon.sharedInstance.maxValueLength = config.maxValueLength; - CLY_LOG_I(@"%s deprecated maxValueLength provided, maxValueLength: [%lu]", __FUNCTION__, (unsigned long)config.maxValueLength); - } - if(CountlyCommon.sharedInstance.maxSegmentationValues == kCountlyMaxSegmentationValues && config.maxSegmentationValues != kCountlyMaxSegmentationValues) { - CountlyCommon.sharedInstance.maxSegmentationValues = config.maxSegmentationValues; - CLY_LOG_I(@"%s deprecated maxSegmentationValues provided, maxSegmentationValues: [%lu]", __FUNCTION__, (unsigned long)config.maxSegmentationValues); - } - + CountlyConsentManager.sharedInstance.requiresConsent = config.requiresConsent; if (!config.appKey.length || [config.appKey isEqualToString:@"YOUR_APP_KEY"]) + { + CLY_LOG_E(@"%s the appKey on the config object is missing or still holds the placeholder value, initialization will be aborted", __FUNCTION__); [NSException raise:@"CountlyAppKeyNotSetException" format:@"appKey property on CountlyConfig object is not set"]; + } if (!config.host.length || [config.host isEqualToString:@"https://YOUR_COUNTLY_SERVER"]) + { + CLY_LOG_E(@"%s the host on the config object is missing or still holds the placeholder value, initialization will be aborted", __FUNCTION__); [NSException raise:@"CountlyHostNotSetException" format:@"host property on CountlyConfig object is not set"]; + } CLY_LOG_I(@"%s initializing, appKey: [%@], serverUrl: [%@], sdkName: [%@], sdkVersion: [%@], device: [%@], osName: [%@], osVersion: [%@], defaultSDKName: [%@], defaultSDKVersion: [%@]", __FUNCTION__, config.appKey, config.host, CountlyCommon.sharedInstance.SDKName, CountlyCommon.sharedInstance.SDKVersion, CountlyDeviceInfo.device, @@ -160,13 +182,17 @@ - (void)startWithConfig:(CountlyConfig *)config CountlyCommon.sharedInstance.manualSessionHandling = config.manualSessionHandling; CountlyCommon.sharedInstance.enableManualSessionControlHybridMode = config.enableManualSessionControlHybridMode; - - CountlyCommon.sharedInstance.attributionID = config.attributionID; - + NSDictionary* customMetricsTruncated = [config.customMetrics cly_truncated:@"Custom metric"]; CountlyDeviceInfo.sharedInstance.customMetrics = [customMetricsTruncated cly_limited:@"Custom metric"]; - - [Countly.user save]; + + if (config.providedUserProperties.count > 0) { + CLY_LOG_I(@"%s applying the user properties provided at init, count: [%lu]", __FUNCTION__, (unsigned long)config.providedUserProperties.count); + CLY_LOG_D(@"%s provided user property detail at init, providedUserProperties: [%@]", __FUNCTION__, [self loggableUserProperties:config.providedUserProperties]); + [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]; @@ -187,12 +213,13 @@ - (void)startWithConfig:(CountlyConfig *)config [CountlyLocationManager.sharedInstance updateLocation:config.location city:config.city ISOCountryCode:config.ISOCountryCode IP:config.IP]; } - if (!CountlyCommon.sharedInstance.manualSessionHandling) + // Automatic session tracking is resolved through the SBS precedence chain (server can override the developer's manual session control choice) + if (CountlyServerConfig.sharedInstance.automaticSessionTrackingEnabled) [CountlyConnectionManager.sharedInstance beginSession]; else [CountlyCommon.sharedInstance recordOrientation]; - //NOTE: If there is no consent for sessions, location info and attribution should be sent separately, as they cannot be sent with begin_session request. + //NOTE: If there is no consent for sessions, location info should be sent separately, as it cannot be sent with begin_session request. #if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_OSX ) #ifndef COUNTLY_EXCLUDE_PUSHNOTIFICATIONS @@ -214,38 +241,27 @@ - (void)startWithConfig:(CountlyConfig *)config CountlyCrashReporter.sharedInstance.crashSegmentation = config.crashSegmentation; CountlyCrashReporter.sharedInstance.crashLogLimit = config.sdkInternalLimits.getMaxBreadcrumbCount; - // For backward compatibility, deprecated values are only set incase new values are not provided using sdkInternalLimits interface - if(CountlyCrashReporter.sharedInstance.crashLogLimit == kCountlyMaxBreadcrumbCount && config.crashLogLimit != kCountlyMaxBreadcrumbCount) { - CountlyCrashReporter.sharedInstance.crashLogLimit = MAX(1, config.crashLogLimit); - CLY_LOG_W(@"%s deprecated maxBreadcrumbCount provided, maxBreadcrumbCount: [%lu]", __FUNCTION__, (unsigned long)config.crashLogLimit); - } CountlyCrashReporter.sharedInstance.crashFilter = config.crashFilter; CountlyCrashReporter.sharedInstance.shouldUsePLCrashReporter = config.shouldUsePLCrashReporter; CountlyCrashReporter.sharedInstance.shouldUseMachSignalHandler = config.shouldUseMachSignalHandler; CountlyCrashReporter.sharedInstance.crashOccuredOnPreviousSessionCallback = config.crashOccuredOnPreviousSessionCallback; CountlyCrashReporter.sharedInstance.shouldSendCrashReportCallback = config.shouldSendCrashReportCallback; - if ([config.features containsObject:CLYCrashReporting]) + // Automatic crash reporting is resolved through the SBS precedence chain, so the server can enable it even when the developer did not + if (CountlyServerConfig.sharedInstance.crashReportingEnabled && CountlyServerConfig.sharedInstance.automaticCrashReportingEnabled) { - CountlyCrashReporter.sharedInstance.isEnabledOnInitialConfig = YES; - if (CountlyServerConfig.sharedInstance.crashReportingEnabled) - { [CountlyCrashReporter.sharedInstance startCrashReporting]; - } } #if (TARGET_OS_IOS || TARGET_OS_TV ) - if (config.enableAutomaticViewTracking || [config.features containsObject:CLYAutoViewTracking]) - { - // Print deprecation flag for feature - CountlyViewTrackingInternal.sharedInstance.isEnabledOnInitialConfig = YES; - if (CountlyServerConfig.sharedInstance.viewTrackingEnabled) - { - [CountlyViewTrackingInternal.sharedInstance startAutoViewTracking]; - } - } + // before the start, which records the screen already on top unless it is excluded if (config.automaticViewTrackingExclusionList) { [CountlyViewTrackingInternal.sharedInstance addAutoViewTrackingExclutionList:config.automaticViewTrackingExclusionList]; } + // Automatic view tracking is resolved through the SBS precedence chain, so the server can enable it even when the developer did not + if (CountlyServerConfig.sharedInstance.viewTrackingEnabled && CountlyServerConfig.sharedInstance.automaticViewTrackingEnabled) + { + [CountlyViewTrackingInternal.sharedInstance startAutoViewTracking]; + } #endif if(config.disableViewRestartForManualRecording){ @@ -264,9 +280,8 @@ - (void)startWithConfig:(CountlyConfig *)config timer = [NSTimer timerWithTimeInterval:config.updateSessionPeriod target:self selector:@selector(onTimer:) userInfo:nil repeats:YES]; [NSRunLoop.mainRunLoop addTimer:timer forMode:NSRunLoopCommonModes]; - CountlyRemoteConfigInternal.sharedInstance.isRCAutomaticTriggersEnabled = config.enableRemoteConfigAutomaticTriggers || config.enableRemoteConfig; + CountlyRemoteConfigInternal.sharedInstance.isRCAutomaticTriggersEnabled = config.enableRemoteConfigAutomaticTriggers; CountlyRemoteConfigInternal.sharedInstance.isRCValueCachingEnabled = config.enableRemoteConfigValueCaching; - CountlyRemoteConfigInternal.sharedInstance.remoteConfigCompletionHandler = config.remoteConfigCompletionHandler; if (config.getRemoteConfigGlobalCallbacks) { CountlyRemoteConfigInternal.sharedInstance.remoteConfigGlobalCallbacks = config.getRemoteConfigGlobalCallbacks; } @@ -287,6 +302,11 @@ - (void)startWithConfig:(CountlyConfig *)config if(config.content.getWebViewDisplayOption){ CountlyContentBuilderInternal.sharedInstance.webViewDisplayOption = config.content.getWebViewDisplayOption; } + CountlyContentBuilderInternal.sharedInstance.enableContentReloadOnStall = config.content.getEnableContentReloadOnStall; + CountlyContentBuilderInternal.sharedInstance.contentReloadOnStallTimeout = config.content.getContentReloadOnStallTimeout / 1000.0; + CountlyContentBuilderInternal.sharedInstance.disableZoom = config.content.getDisableZoom; + CountlyContentBuilderInternal.sharedInstance.disableRotation = config.content.getDisableRotation; + CountlyContentBuilderInternal.sharedInstance.contentURLHandler = config.content.getContentURLHandler; #endif [CountlyPerformanceMonitoring.sharedInstance startWithConfig:config.apm]; @@ -297,11 +317,12 @@ - (void)startWithConfig:(CountlyConfig *)config [CountlyConnectionManager.sharedInstance proceedOnQueue]; //TODO: Should move at the top after checking the the edge cases of current implementation + BOOL requiresConsent = [CountlyServerConfig.sharedInstance takeConsentRequirementForStart:config]; if (config.enableAllConsents) [self giveAllConsents]; else if (config.consents) [self giveConsentForFeatures:config.consents]; - else if (config.requiresConsent) + else if (requiresConsent) [CountlyConsentManager.sharedInstance sendConsents]; if (!CountlyConsentManager.sharedInstance.consentForSessions) @@ -315,7 +336,6 @@ - (void)startWithConfig:(CountlyConfig *)config { [CountlyLocationManager.sharedInstance sendLocationInfo]; } - [CountlyConnectionManager.sharedInstance sendAttribution]; } @@ -326,54 +346,66 @@ - (void)startWithConfig:(CountlyConfig *)config [self recordIndirectAttribution:config.indirectAttribution]; [CountlyHealthTracker.sharedInstance sendHealthCheck]; + + CountlyCommon.sharedInstance.hasFinishedInit = YES; + + // the request queue is usable from here on, so this is the first chance for the lines gathered during init to go out + [CountlyCommon.sharedInstance scheduleSdkLogsFlush]; + + // The behavior settings response for the fetch started above can arrive before init finishes, in + // which case it deliberately did not touch automatic tracking or location. Apply the resolved values now that + // the configuration is complete, so a server side 'cr', 'avt', 'acr' or 'lt' is never silently dropped. + [CountlyServerConfig.sharedInstance applyConsentRequirement]; + [CountlyServerConfig.sharedInstance applyAutomaticTrackingState]; + [CountlyServerConfig.sharedInstance applyLocationTrackingState]; } - (CountlyConfig *) checkAndFixInternalLimitsConfig:(CountlyConfig *)config { if (config.sdkInternalLimits.getMaxKeyLength == 0) { [config.sdkInternalLimits setMaxKeyLength:kCountlyMaxKeyLength]; - CLY_LOG_W(@"%s Ignoring provided value of %lu for 'maxKeyLength' because it's less than 1", __FUNCTION__, (unsigned long)config.sdkInternalLimits.getMaxKeyLength); + CLY_LOG_W(@"%s ignoring the provided 'maxKeyLength' because it is less than 1, falling back to: [%lu]", __FUNCTION__, (unsigned long)config.sdkInternalLimits.getMaxKeyLength); } else if(config.sdkInternalLimits.getMaxKeyLength != kCountlyMaxKeyLength) { - CLY_LOG_I(@"%s provided 'maxKeyLength' override:[ %lu ]", __FUNCTION__, (unsigned long)config.sdkInternalLimits.getMaxKeyLength); + CLY_LOG_I(@"%s applying the 'maxKeyLength' override, maxKeyLength: [%lu]", __FUNCTION__, (unsigned long)config.sdkInternalLimits.getMaxKeyLength); } if (config.sdkInternalLimits.getMaxValueSize == 0) { [config.sdkInternalLimits setMaxValueSize:kCountlyMaxValueSize]; - CLY_LOG_W(@"%s Ignoring provided value of %lu for 'maxValueSize' because it's less than 1", __FUNCTION__, (unsigned long)config.sdkInternalLimits.getMaxValueSize); + CLY_LOG_W(@"%s ignoring the provided 'maxValueSize' because it is less than 1, falling back to: [%lu]", __FUNCTION__, (unsigned long)config.sdkInternalLimits.getMaxValueSize); } else if(config.sdkInternalLimits.getMaxValueSize != kCountlyMaxValueSize) { - CLY_LOG_I(@"%s provided 'maxValueSize' override:[ %lu ]", __FUNCTION__, (unsigned long)config.sdkInternalLimits.getMaxValueSize); + CLY_LOG_I(@"%s applying the 'maxValueSize' override, maxValueSize: [%lu]", __FUNCTION__, (unsigned long)config.sdkInternalLimits.getMaxValueSize); } if (config.sdkInternalLimits.getMaxSegmentationValues == 0) { [config.sdkInternalLimits setMaxSegmentationValues:kCountlyMaxSegmentationValues]; - CLY_LOG_W(@"%s Ignoring provided value of %lu for 'maxSegmentationValues' because it's less than 1", __FUNCTION__, (unsigned long)config.sdkInternalLimits.getMaxSegmentationValues); + CLY_LOG_W(@"%s ignoring the provided 'maxSegmentationValues' because it is less than 1, falling back to: [%lu]", __FUNCTION__, (unsigned long)config.sdkInternalLimits.getMaxSegmentationValues); } else if(config.sdkInternalLimits.getMaxSegmentationValues != kCountlyMaxSegmentationValues) { - CLY_LOG_I(@"%s provided 'maxSegmentationValues' override:[ %lu ]", __FUNCTION__, (unsigned long)config.sdkInternalLimits.getMaxSegmentationValues); + CLY_LOG_I(@"%s applying the 'maxSegmentationValues' override, maxSegmentationValues: [%lu]", __FUNCTION__, (unsigned long)config.sdkInternalLimits.getMaxSegmentationValues); } if (config.sdkInternalLimits.getMaxBreadcrumbCount == 0) { [config.sdkInternalLimits setMaxBreadcrumbCount:kCountlyMaxBreadcrumbCount]; - CLY_LOG_W(@"%s Ignoring provided value of %lu for 'maxBreadcrumbCount' because it's less than 1", __FUNCTION__, (unsigned long)config.sdkInternalLimits.getMaxBreadcrumbCount); + CLY_LOG_W(@"%s ignoring the provided 'maxBreadcrumbCount' because it is less than 1, falling back to: [%lu]", __FUNCTION__, (unsigned long)config.sdkInternalLimits.getMaxBreadcrumbCount); } else if(config.sdkInternalLimits.getMaxBreadcrumbCount != kCountlyMaxBreadcrumbCount) { - CLY_LOG_I(@"%s provided 'maxBreadcrumbCount' override:[ %lu ]", __FUNCTION__, (unsigned long)config.sdkInternalLimits.getMaxBreadcrumbCount); + CLY_LOG_I(@"%s applying the 'maxBreadcrumbCount' override, maxBreadcrumbCount: [%lu]", __FUNCTION__, (unsigned long)config.sdkInternalLimits.getMaxBreadcrumbCount); } - if(config.sdkInternalLimits.getMaxStackTraceLineLength != kCountlyMaxStackTraceLinesPerThread) + if(config.sdkInternalLimits.getMaxStackTraceLineLength != kCountlyMaxStackTraceLineLength) { - CLY_LOG_W(@"%s 'maxStackTraceLineLength' is currently a placeholder and doesn't actively utilize the set values.", __FUNCTION__); + CLY_LOG_D(@"%s 'maxStackTraceLineLength' is a placeholder and is not applied, provided: [%lu]", __FUNCTION__, (unsigned long)config.sdkInternalLimits.getMaxStackTraceLineLength); } - if(config.sdkInternalLimits.getMaxStackTraceLinesPerThread != kCountlyMaxStackTraceLineLength) + if(config.sdkInternalLimits.getMaxStackTraceLinesPerThread != kCountlyMaxStackTraceLinesPerThread) { - CLY_LOG_I(@"%s 'maxStackTraceLinesPerThread' is currently a placeholder and doesn't actively utilize the set values.", __FUNCTION__); + CLY_LOG_D(@"%s 'maxStackTraceLinesPerThread' is a placeholder and is not applied, provided: [%lu]", __FUNCTION__, (unsigned long)config.sdkInternalLimits.getMaxStackTraceLinesPerThread); } return config; } @@ -382,57 +414,65 @@ - (CountlyConfig *) checkAndFixInternalLimitsConfig:(CountlyConfig *)config - (void)onTimer:(NSTimer *)timer { - CLY_LOG_D(@"%s tick is happening sending events, manualSessions: [%d], hybridSessions: [%d], isSuspended: [%d]", __FUNCTION__, CountlyCommon.sharedInstance.manualSessionHandling, CountlyCommon.sharedInstance.enableManualSessionControlHybridMode, isSuspended); + CLY_LOG_D(@"%s session timer fired, automaticSessions: [%@], hybridSessions: [%@], isSuspended: [%@]", __FUNCTION__, CountlyServerConfig.sharedInstance.automaticSessionTrackingEnabled ? @"YES" : @"NO", CountlyCommon.sharedInstance.enableManualSessionControlHybridMode ? @"YES" : @"NO", isSuspended ? @"YES" : @"NO"); if (isSuspended) return; - if (!CountlyCommon.sharedInstance.manualSessionHandling) - { - [CountlyConnectionManager.sharedInstance updateSession]; - } - // this condtion is called only when both manual session handling and hybrid mode is enabled. - else if (CountlyCommon.sharedInstance.enableManualSessionControlHybridMode) + // Hybrid mode keeps the automatic session update going even when automatic session tracking is not active + if (CountlyServerConfig.sharedInstance.automaticSessionTrackingEnabled || CountlyCommon.sharedInstance.enableManualSessionControlHybridMode) { [CountlyConnectionManager.sharedInstance updateSession]; } [CountlyConnectionManager.sharedInstance sendEventsWithSaveIfNeeded]; + + // a buffer that never reaches the batch size would otherwise sit in memory for the whole run + [CountlyCommon.sharedInstance scheduleSdkLogsFlush]; } - (void)suspend { -#if (TARGET_OS_WATCH) - CLY_LOG_I(@"%s", __FUNCTION__); -#endif + CLY_LOG_I(@"%s suspending the SDK, isSuspended: [%@]", __FUNCTION__, isSuspended ? @"YES" : @"NO"); if (!CountlyCommon.sharedInstance.hasStarted) + { + CLY_LOG_D(@"%s omitting the suspend, the SDK has not been started yet", __FUNCTION__); return; + } if (isSuspended) + { + CLY_LOG_D(@"%s the SDK is already suspended, nothing left to do", __FUNCTION__); return; + } - CLY_LOG_D(@"%s sending events, saving the state, manualSessions: [%d]", __FUNCTION__, CountlyCommon.sharedInstance.manualSessionHandling); + CLY_LOG_D(@"%s flushing events and saving the state before suspend, automaticSessions: [%@]", __FUNCTION__, CountlyServerConfig.sharedInstance.automaticSessionTrackingEnabled ? @"YES" : @"NO"); isSuspended = YES; - + [CountlyViewTrackingInternal.sharedInstance applicationDidEnterBackground]; [CountlyConnectionManager.sharedInstance sendEventsWithSaveIfNeeded]; - - if (!CountlyCommon.sharedInstance.manualSessionHandling) + + if (CountlyServerConfig.sharedInstance.automaticSessionTrackingEnabled) [CountlyConnectionManager.sharedInstance endSession]; - + + // after the session end, so the session summary is queued ahead of the log batches. Nothing about the gathered + // lines is persisted, so the only way the tail survives the app going away is getting it out now + [CountlyCommon.sharedInstance flushSdkLogs]; + [CountlyPersistency.sharedInstance saveToFile]; } - (void)resume { -#if (TARGET_OS_WATCH) - CLY_LOG_I(@"%s", __FUNCTION__); -#endif + CLY_LOG_I(@"%s resuming the SDK, isSuspended: [%@]", __FUNCTION__, isSuspended ? @"YES" : @"NO"); if (!CountlyCommon.sharedInstance.hasStarted) + { + CLY_LOG_D(@"%s omitting the resume, the SDK has not been started yet", __FUNCTION__); return; + } #if (TARGET_OS_WATCH) //NOTE: Skip first time to prevent double begin session because of applicationDidBecomeActive call on launch of watchOS apps @@ -441,15 +481,17 @@ - (void)resume if (isFirstCall) { isFirstCall = NO; + CLY_LOG_D(@"%s skipping the first resume on watchOS to avoid a duplicate begin session", __FUNCTION__); return; } #endif - CLY_LOG_D(@"%s manualSessions: [%d]", __FUNCTION__, CountlyCommon.sharedInstance.manualSessionHandling); + CLY_LOG_D(@"%s restarting session handling after resume, automaticSessions: [%@]", __FUNCTION__, CountlyServerConfig.sharedInstance.automaticSessionTrackingEnabled ? @"YES" : @"NO"); - if (!CountlyCommon.sharedInstance.manualSessionHandling) + // activation also resumes without a suspend before it (every macOS activation, an iOS alert), so a session may still be running + if (CountlyServerConfig.sharedInstance.automaticSessionTrackingEnabled && !CountlyConnectionManager.sharedInstance.isSessionStarted) [CountlyConnectionManager.sharedInstance beginSession]; - + [CountlyViewTrackingInternal.sharedInstance applicationWillEnterForeground]; isSuspended = NO; @@ -457,32 +499,34 @@ - (void)resume - (void)applicationDidBecomeActive:(NSNotification *)notification { - CLY_LOG_D(@"%s app enters foreground", __FUNCTION__); + CLY_LOG_V(@"%s the app became active, checking the server config and resuming", __FUNCTION__); [CountlyServerConfig.sharedInstance fetchServerConfigIfTimeIsUp]; [self resume]; + [CountlyConnectionTest.sharedInstance noteAppResumed]; } - (void)applicationWillResignActive:(NSNotification *)notification { - CLY_LOG_D(@"%s, app enters background", __FUNCTION__); + CLY_LOG_V(@"%s the app will resign active, saving the health tracker state", __FUNCTION__); [CountlyHealthTracker.sharedInstance saveState]; } - (void)applicationDidEnterBackground:(NSNotification *)notification { - CLY_LOG_D(@"%s, app did enter background.", __FUNCTION__); + CLY_LOG_D(@"%s the app did enter background, saving the state and suspending", __FUNCTION__); [CountlyHealthTracker.sharedInstance saveState]; + [CountlyConnectionTest.sharedInstance noteAppSuspended]; [self suspend]; } - (void)applicationWillEnterForeground:(NSNotification *)notification { - CLY_LOG_D(@"%s, app will enter foreground.", __FUNCTION__); + CLY_LOG_V(@"%s the app will enter foreground", __FUNCTION__); } - (void)applicationWillTerminate:(NSNotification *)notification { - CLY_LOG_D(@"%s, app will terminate.", __FUNCTION__); + CLY_LOG_D(@"%s the app will terminate, flushing events and saving the state synchronously", __FUNCTION__); [CountlyHealthTracker.sharedInstance saveState]; @@ -491,6 +535,9 @@ - (void)applicationWillTerminate:(NSNotification *)notification [CountlyViewTrackingInternal.sharedInstance applicationWillTerminate]; [CountlyConnectionManager.sharedInstance sendEventsWithSaveIfNeeded]; + + // the queue is not drained while terminating, but a queued tail survives to the next launch through the sync save below + [CountlyCommon.sharedInstance flushSdkLogs]; [CountlyPerformanceMonitoring.sharedInstance endBackgroundTrace]; @@ -514,11 +561,11 @@ - (void)dealloc - (void)setNewHost:(NSString *)newHost { - CLY_LOG_I(@"%s %@", __FUNCTION__, newHost); + CLY_LOG_I(@"%s newHost: [%@]", __FUNCTION__, newHost); if (!newHost.length) { - CLY_LOG_W(@"%s new host is invalid!", __FUNCTION__); + CLY_LOG_E(@"%s omitting the call, the provided host is nil or empty", __FUNCTION__); return; } @@ -527,18 +574,18 @@ - (void)setNewHost:(NSString *)newHost - (void)setNewURLSessionConfiguration:(NSURLSessionConfiguration *)newURLSessionConfiguration { - CLY_LOG_I(@"%s %@", __FUNCTION__, newURLSessionConfiguration); + CLY_LOG_I(@"%s URLSessionConfiguration is overridden, configurationProvided: [%@]", __FUNCTION__, (newURLSessionConfiguration != nil) ? @"YES" : @"NO"); CountlyConnectionManager.sharedInstance.URLSessionConfiguration = newURLSessionConfiguration; } - (void)setNewAppKey:(NSString *)newAppKey { - CLY_LOG_I(@"%s %@", __FUNCTION__, newAppKey); + CLY_LOG_I(@"%s newAppKey: [%@]", __FUNCTION__, newAppKey); if (!newAppKey.length) { - CLY_LOG_W(@"%s new app key is invalid!", __FUNCTION__); + CLY_LOG_E(@"%s omitting the call, the provided app key is nil or empty", __FUNCTION__); return; } @@ -557,13 +604,14 @@ - (void)setNewAppKey:(NSString *)newAppKey - (void)recordMetrics:(NSDictionary * _Nullable)metricsOverride { - CLY_LOG_I(@"%s %@", __FUNCTION__, metricsOverride); + CLY_LOG_I(@"%s overriding metrics, keys: [%@], count: [%lu]", __FUNCTION__, metricsOverride.allKeys, (unsigned long)metricsOverride.count); + CLY_LOG_D(@"%s metrics override detail, metricsOverride: [%@]", __FUNCTION__, metricsOverride); [CountlyConnectionManager.sharedInstance recordMetrics:metricsOverride]; } - (void)flushQueues { - CLY_LOG_I(@"%s", __FUNCTION__); + CLY_LOG_I(@"%s flushing the event and the request queues", __FUNCTION__); [CountlyPersistency.sharedInstance flushEvents]; [CountlyPersistency.sharedInstance flushQueue]; @@ -571,30 +619,33 @@ - (void)flushQueues - (void)replaceAllAppKeysInQueueWithCurrentAppKey { - CLY_LOG_I(@"%s", __FUNCTION__); + CLY_LOG_I(@"%s replacing all app keys in the request queue with the current app key", __FUNCTION__); [CountlyPersistency.sharedInstance replaceAllAppKeysInQueueWithCurrentAppKey]; } - (void)removeDifferentAppKeysFromQueue { - CLY_LOG_I(@"%s", __FUNCTION__); + CLY_LOG_I(@"%s removing the requests with a different app key from the queue", __FUNCTION__); [CountlyPersistency.sharedInstance removeDifferentAppKeysFromQueue]; } - (void)addDirectRequest:(NSDictionary * _Nullable)requestParameters { - CLY_LOG_I(@"%s %@", __FUNCTION__, requestParameters); + CLY_LOG_I(@"%s adding a direct request, keys: [%@], count: [%lu]", __FUNCTION__, requestParameters.allKeys, (unsigned long)requestParameters.count); + CLY_LOG_D(@"%s direct request parameter detail, requestParameters: [%@]", __FUNCTION__, requestParameters); [CountlyConnectionManager.sharedInstance addDirectRequest:requestParameters]; } - (void)addCustomNetworkRequestHeaders:(NSDictionary *_Nullable)customHeaderValues { - CLY_LOG_I(@"%s %@", __FUNCTION__, customHeaderValues); - + // names only: a header value is often a credential, and log gathering can upload any line + CLY_LOG_I(@"%s adding custom network request headers, keys: [%@], count: [%lu]", __FUNCTION__, customHeaderValues.allKeys, (unsigned long)customHeaderValues.count); + // Ignore nil or empty dictionary if (customHeaderValues == nil || customHeaderValues.count == 0) { + CLY_LOG_W(@"%s omitting the call, no custom network request header is provided", __FUNCTION__); return; } @@ -606,28 +657,63 @@ - (void)addCustomNetworkRequestHeaders:(NSDictionary *_N - (void)beginSession { - CLY_LOG_I(@"%s", __FUNCTION__); - - if (CountlyCommon.sharedInstance.manualSessionHandling) - [CountlyConnectionManager.sharedInstance beginSession]; + CLY_LOG_I(@"%s a manual session begin is requested, automaticSessions: [%@]", __FUNCTION__, CountlyServerConfig.sharedInstance.automaticSessionTrackingEnabled ? @"YES" : @"NO"); + + if (CountlyServerConfig.sharedInstance.automaticSessionTrackingEnabled) + { + CLY_LOG_W(@"%s omitting the session begin, automatic session tracking is active", __FUNCTION__); + return; + } + + [CountlyConnectionManager.sharedInstance beginSession]; } - (void)updateSession { - CLY_LOG_I(@"%s", __FUNCTION__); - - if (CountlyCommon.sharedInstance.manualSessionHandling) - [CountlyConnectionManager.sharedInstance updateSession]; + CLY_LOG_I(@"%s a manual session update is requested, automaticSessions: [%@]", __FUNCTION__, CountlyServerConfig.sharedInstance.automaticSessionTrackingEnabled ? @"YES" : @"NO"); + + if (CountlyServerConfig.sharedInstance.automaticSessionTrackingEnabled) + { + CLY_LOG_W(@"%s omitting the session update, automatic session tracking is active", __FUNCTION__); + return; + } + + [CountlyConnectionManager.sharedInstance updateSession]; } - (void)endSession { - CLY_LOG_I(@"%s", __FUNCTION__); - - if (CountlyCommon.sharedInstance.manualSessionHandling) + CLY_LOG_I(@"%s a manual session end is requested, automaticSessions: [%@]", __FUNCTION__, CountlyServerConfig.sharedInstance.automaticSessionTrackingEnabled ? @"YES" : @"NO"); + + if (CountlyServerConfig.sharedInstance.automaticSessionTrackingEnabled) { - [CountlyConnectionManager.sharedInstance sendEventsWithSaveIfNeeded]; - [CountlyConnectionManager.sharedInstance endSession]; + CLY_LOG_W(@"%s omitting the session end, automatic session tracking is active", __FUNCTION__); + return; + } + + [CountlyConnectionManager.sharedInstance sendEventsWithSaveIfNeeded]; + [CountlyConnectionManager.sharedInstance endSession]; +} + +- (void)applyAutomaticSessionTracking +{ + if (!CountlyCommon.sharedInstance.hasStarted) + return; + + CountlyConnectionManager* connectionManager = CountlyConnectionManager.sharedInstance; + if (CountlyServerConfig.sharedInstance.automaticSessionTrackingEnabled) + { + if (isSuspended || connectionManager.isSessionStarted) + return; + + CLY_LOG_D(@"%s automatic session tracking is on and no session is running, a session will be started", __FUNCTION__); + [connectionManager beginSession]; + } + else if (connectionManager.isSessionStarted && connectionManager.isSessionStartedAutomatically) + { + CLY_LOG_D(@"%s automatic session tracking is off, the session the SDK started automatically will be ended", __FUNCTION__); + [connectionManager sendEventsWithSaveIfNeeded]; + [connectionManager endSession]; } } @@ -638,14 +724,14 @@ - (void)endSession - (NSString *)deviceID { - CLY_LOG_I(@"%s", __FUNCTION__); + CLY_LOG_I(@"%s the device ID is requested", __FUNCTION__); return CountlyDeviceInfo.sharedInstance.deviceID.cly_URLEscaped; } - (CLYDeviceIDType)deviceIDType { - CLY_LOG_I(@"%s", __FUNCTION__); + CLY_LOG_I(@"%s the device ID type is requested, isTemporary: [%@]", __FUNCTION__, CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary ? @"YES" : @"NO"); if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) return CLYDeviceIDTypeTemporary; @@ -662,9 +748,11 @@ - (CLYDeviceIDType)deviceIDType - (void)setID:(NSString *)deviceID; { + CLY_LOG_I(@"%s changing the device ID, valueProvided: [%@]", __FUNCTION__, deviceID.length ? @"YES" : @"NO"); + if (deviceID == nil || !deviceID.length) { - CLY_LOG_W(@"%s Passing `nil` or empty string as devie ID is not allowed.", __FUNCTION__); + CLY_LOG_W(@"%s omitting the call, passing nil or an empty string as the device ID is not allowed", __FUNCTION__); return; } @@ -680,50 +768,49 @@ - (void)setID:(NSString *)deviceID; } - (void)changeDeviceIDWithMerge:(NSString * _Nullable)deviceID { - CLY_LOG_I(@"%s", __FUNCTION__); + CLY_LOG_I(@"%s changing the device ID with a server side merge, valueProvided: [%@]", __FUNCTION__, deviceID.length ? @"YES" : @"NO"); [self setIDInternal:deviceID onServer:YES]; } - (void)changeDeviceIDWithoutMerge:(NSString * _Nullable)deviceID { - CLY_LOG_I(@"%s", __FUNCTION__); + CLY_LOG_I(@"%s changing the device ID without a server side merge, valueProvided: [%@]", __FUNCTION__, deviceID.length ? @"YES" : @"NO"); [self setIDInternal:deviceID onServer:NO]; } - (void)enableTemporaryDeviceIDMode { - CLY_LOG_I(@"%s", __FUNCTION__); + CLY_LOG_I(@"%s switching to temporary device ID mode", __FUNCTION__); [Countly.sharedInstance setIDInternal:CLYTemporaryDeviceID onServer:NO]; } -- (void)setNewDeviceID:(NSString *)deviceID onServer:(BOOL)onServer -{ - [Countly.sharedInstance setIDInternal:deviceID onServer:onServer]; -} - - (void)setIDInternal:(NSString *)deviceID onServer:(BOOL)onServer { - CLY_LOG_I(@"%s deviceID: [%@], onServer: [%d]", __FUNCTION__, deviceID, onServer); + // the value itself is logged only once a gather of the current user is closed, so it never reaches that user's logs + CLY_LOG_D(@"%s applying the device ID change, valueProvided: [%@], onServer: [%@]", __FUNCTION__, deviceID.length ? @"YES" : @"NO", onServer ? @"YES" : @"NO"); if (!CountlyCommon.sharedInstance.hasStarted) + { + CLY_LOG_W(@"%s omitting the device ID change, the SDK has not been started yet", __FUNCTION__); return; + } if (!deviceID.length) { - CLY_LOG_W(@"%s Passing `CLYDefaultDeviceID` or `nil` or empty string as devie ID is deprecated, and will not be allowed in the future.", __FUNCTION__); + CLY_LOG_W(@"%s omitting the call, passing nil or an empty string as the device ID is not allowed", __FUNCTION__); + return; } - - [self storeCustomDeviceIDState:deviceID]; - deviceID = [CountlyDeviceInfo.sharedInstance ensafeDeviceID:deviceID]; + [self storeCustomDeviceIDState:deviceID]; if ([deviceID isEqualToString:CountlyDeviceInfo.sharedInstance.deviceID]) { - CLY_LOG_W(@"%s Attempted to set the same device ID again. So, setting new device ID is aborted.", __FUNCTION__); + CLY_LOG_D(@"%s omitting the call, the provided device ID is identical to the current one", __FUNCTION__); return; } if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) { - CLY_LOG_I(@"%s Going out of CLYTemporaryDeviceID mode and switching back to normal mode.", __FUNCTION__); + CLY_LOG_I(@"%s leaving temporary device ID mode and switching back to normal mode", __FUNCTION__); + CLY_LOG_D(@"%s device ID change detail, deviceID: [%@], onServer: [%@]", __FUNCTION__, deviceID, onServer ? @"YES" : @"NO"); [CountlyDeviceInfo.sharedInstance initializeDeviceID:deviceID]; @@ -731,8 +818,10 @@ - (void)setIDInternal:(NSString *)deviceID onServer:(BOOL)onServer [CountlyConnectionManager.sharedInstance proceedOnQueue]; + [CountlyServerConfig.sharedInstance fetchServerConfig:_startConfig]; + [CountlyRemoteConfigInternal.sharedInstance downloadRemoteConfigAutomatically]; - + [CountlyHealthTracker.sharedInstance sendHealthCheck]; return; @@ -740,14 +829,17 @@ - (void)setIDInternal:(NSString *)deviceID onServer:(BOOL)onServer if ([deviceID isEqualToString:CLYTemporaryDeviceID] && onServer) { - CLY_LOG_W(@"%s Attempted to set device ID as CLYTemporaryDeviceID with onServer option. So, onServer value is overridden as NO.", __FUNCTION__); + CLY_LOG_W(@"%s 'CLYTemporaryDeviceID' cannot be set with the onServer option, onServer is overridden as NO", __FUNCTION__); onServer = NO; } if (onServer) { + // a merge keeps the user, and the server moves a running gather to the new device ID with it + CLY_LOG_D(@"%s device ID change detail, deviceID: [%@], onServer: [YES]", __FUNCTION__, deviceID); NSString* oldDeviceID = CountlyDeviceInfo.sharedInstance.deviceID; + CountlyCommon.sharedInstance.deviceIDMergeCount++; [CountlyDeviceInfo.sharedInstance initializeDeviceID:deviceID]; [CountlyConnectionManager.sharedInstance sendOldDeviceID:oldDeviceID]; @@ -756,6 +848,10 @@ - (void)setIDInternal:(NSString *)deviceID onServer:(BOOL)onServer { [self suspend]; + [CountlyCommon.sharedInstance stopSdkLogGatheringForDeviceIDChange]; + CLY_LOG_D(@"%s device ID change detail, deviceID: [%@], onServer: [NO]", __FUNCTION__, deviceID); + + CountlyCommon.sharedInstance.deviceIDChangeWithoutMergeCount++; [CountlyDeviceInfo.sharedInstance initializeDeviceID:deviceID]; [CountlyConsentManager.sharedInstance cancelConsentForAllFeaturesWithoutSendingConsentsRequest]; @@ -783,51 +879,49 @@ - (void)storeCustomDeviceIDState:(NSString *)deviceID #pragma mark - Consents - (void)giveConsentForFeature:(NSString *)featureName { - CLY_LOG_I(@"%s featureName: [%@]", __FUNCTION__, featureName); + CLY_LOG_I(@"%s giving consent, featureName: [%@]", __FUNCTION__, featureName); if (!featureName.length) + { + CLY_LOG_E(@"%s omitting the call, the provided feature name is empty", __FUNCTION__); return; + } [CountlyConsentManager.sharedInstance giveConsentForFeatures:@[featureName]]; } - (void)giveConsentForFeatures:(NSArray *)features { - CLY_LOG_I(@"%s features: [%@]", __FUNCTION__, features); [CountlyConsentManager.sharedInstance giveConsentForFeatures:features]; } -- (void)giveConsentForAllFeatures -{ - CLY_LOG_I(@"%s", __FUNCTION__); - [CountlyConsentManager.sharedInstance giveAllConsents]; -} - - (void)giveAllConsents { - CLY_LOG_I(@"%s", __FUNCTION__); + CLY_LOG_I(@"%s giving all consents", __FUNCTION__); [CountlyConsentManager.sharedInstance giveAllConsents]; } - (void)cancelConsentForFeature:(NSString *)featureName { - CLY_LOG_I(@"%s featureName: [%@]", __FUNCTION__, featureName); + CLY_LOG_I(@"%s cancelling consent, featureName: [%@]", __FUNCTION__, featureName); if (!featureName.length) + { + CLY_LOG_E(@"%s omitting the call, the feature name to cancel consent for is empty", __FUNCTION__); return; + } [CountlyConsentManager.sharedInstance cancelConsentForFeatures:@[featureName]]; } - (void)cancelConsentForFeatures:(NSArray *)features { - CLY_LOG_I(@"%s features: [%@]", __FUNCTION__, features); [CountlyConsentManager.sharedInstance cancelConsentForFeatures:features]; } - (void)cancelConsentForAllFeatures { - CLY_LOG_I(@"%s", __FUNCTION__); + CLY_LOG_I(@"%s cancelling consent for all features", __FUNCTION__); [CountlyConsentManager.sharedInstance cancelConsentForAllFeatures]; } @@ -876,33 +970,33 @@ - (void)recordEvent:(NSString *)key segmentation:(NSDictionary *)segmentation co - (void)recordEvent:(NSString *)key segmentation:(NSDictionary *)segmentation count:(NSUInteger)count sum:(double)sum duration:(NSTimeInterval)duration { - CLY_LOG_I(@"%s key: [%@], segmentation: [%@], count: [%lu], sum: [%f], duration: [%f]", __FUNCTION__, key, segmentation, (unsigned long)count, sum, duration); + CLY_LOG_I(@"%s recording an event, key: [%@], segmentation keys: [%@], segmentation key count: [%lu], count: [%lu], sum: [%f], duration: [%f]", __FUNCTION__, key, segmentation.allKeys, (unsigned long)segmentation.count, (unsigned long)count, sum, duration); + CLY_LOG_D(@"%s event segmentation detail, key: [%@], segmentation: [%@]", __FUNCTION__, key, [self loggableSegmentation:segmentation eventKey:key]); NSNumber* isReservedEvent = [self isReservedEvent:key]; if (isReservedEvent) { - CLY_LOG_V(@"%s, A reserved event detected: %@", __FUNCTION__, key); if (!isReservedEvent.boolValue) { - CLY_LOG_W(@"%s, No consent given for the reserved event! Event will not be recorded.", __FUNCTION__); + CLY_LOG_V(@"%s no consent given for the reserved event, it will not be recorded, key: [%@]", __FUNCTION__, key); return; } - CLY_LOG_V(@"%s, Specific consent given for the reserved event! So, it will be recorded.", __FUNCTION__); + CLY_LOG_V(@"%s specific consent is given for the reserved event, it will be recorded, key: [%@]", __FUNCTION__, key); } else if (!CountlyConsentManager.sharedInstance.consentForEvents) { - CLY_LOG_W(@"%s, Consent for events not given! Event will not be recorded.", __FUNCTION__); + CLY_LOG_V(@"%s no consent given for events, the event will not be recorded, key: [%@]", __FUNCTION__, key); return; } if (!CountlyServerConfig.sharedInstance.customEventTrackingEnabled) { - CLY_LOG_D(@"%s, aborted: Custom Event Tracking is disabled from server config!", __FUNCTION__); + CLY_LOG_D(@"%s dropping the event, custom event tracking is disabled by the server config, key: [%@]", __FUNCTION__, key); return; } if (![CountlyServerConfig.sharedInstance shouldRecordEvent:key]) { - CLY_LOG_D(@"%s, aborted: Event '%@' is filtered by server config event filter!", __FUNCTION__, key); + CLY_LOG_D(@"%s dropping the event, it is filtered out by the server config event filter, key: [%@]", __FUNCTION__, key); return; } @@ -914,6 +1008,31 @@ - (void)recordEvent:(NSString *)key segmentation:(NSDictionary *)segmentation co [self recordEvent:key segmentation:segmentation count:count sum:sum duration:duration ID:nil timestamp:CountlyCommon.sharedInstance.uniqueTimestamp]; } +/// The segmentation the behavior settings let through, empty for an event they drop, for log lines that log gathering may upload. +- (NSDictionary *)loggableSegmentation:(NSDictionary *)segmentation eventKey:(NSString *)key +{ + // the behavior settings filter custom events only, a reserved event is recorded with its segmentation as it is + if ([self isReservedEvent:key]) + return segmentation; + + if (!CountlyServerConfig.sharedInstance.customEventTrackingEnabled || ![CountlyServerConfig.sharedInstance shouldRecordEvent:key]) + return @{}; + + return [CountlyServerConfig.sharedInstance filterSegmentation:segmentation eventKey:key]; +} + +/// The user properties the behavior settings let through, for log lines that log gathering may upload. +- (NSDictionary *)loggableUserProperties:(NSDictionary *)properties +{ + NSMutableDictionary* loggable = NSMutableDictionary.new; + [properties enumerateKeysAndObjectsUsingBlock:^(id key, id value, BOOL* stop) { + if (![key isKindOfClass:NSString.class] || [CountlyServerConfig.sharedInstance shouldRecordUserProperty:key]) + loggable[key] = value; + }]; + + return loggable; +} + #pragma mark - - (void)recordReservedEvent:(NSString *)key segmentation:(NSDictionary *)segmentation @@ -936,7 +1055,7 @@ - (void)recordReservedEvent:(NSString *)key segmentation:(NSDictionary *)segment - (void)recordEvent:(NSString *)key segmentation:(NSDictionary *)segmentation count:(NSUInteger)count sum:(double)sum duration:(NSTimeInterval)duration ID:(NSString *)ID timestamp:(NSTimeInterval)timestamp { if (key.length == 0) { - CLY_LOG_D(@"%s omitting the call, key is empty", __FUNCTION__); + CLY_LOG_E(@"%s omitting the call, the event key is nil or empty", __FUNCTION__); return; } @@ -957,7 +1076,7 @@ - (void)recordEvent:(NSString *)key segmentation:(NSDictionary *)segmentation co } // Check if the event is a reserved event - BOOL isReservedEvent = [self isReservedEvent:key]; + BOOL isReservedEvent = [self isReservedEvent:key] != nil; NSMutableDictionary *filteredSegmentations = segmentation.cly_filterSupportedDataTypes; if(filteredSegmentations == nil) @@ -972,7 +1091,7 @@ - (void)recordEvent:(NSString *)key segmentation:(NSDictionary *)segmentation co if (!isReservedEvent) { - CLY_LOG_V(@"%s will add event id and name properties because it is not a reserved event ", __FUNCTION__); + CLY_LOG_V(@"%s adding the event id and name properties, it is not a reserved event, key: [%@]", __FUNCTION__, key); key = [key cly_truncatedKey:@"Event key"]; NSString* capturedPreviousID = nil; NSString* capturedPreviousName = nil; @@ -992,18 +1111,10 @@ - (void)recordEvent:(NSString *)key segmentation:(NSDictionary *)segmentation co } event.key = key; event.segmentation = [self processSegmentation:filteredSegmentations eventKey:key]; - id callback = nil; - if ([CountlyServerConfig.sharedInstance isJourneyTriggerEvent:key]){ - callback = ^(NSString *response, BOOL success) { - if (success) - { - #if (TARGET_OS_IOS) - dispatch_async(dispatch_get_main_queue(), ^{ - [CountlyContentBuilderInternal.sharedInstance refreshContentZoneJTE]; - }); - #endif - } - }; + CLYRequestCallback callback = nil; + if ([CountlyServerConfig.sharedInstance isJourneyTriggerEvent:key]) + { + callback = [self journeyTriggerCallback]; } [CountlyPersistency.sharedInstance recordEvent:event callback:callback]; #if __has_include() @@ -1014,10 +1125,37 @@ - (void)recordEvent:(NSString *)key segmentation:(NSDictionary *)segmentation co { event.key = key; event.segmentation = [self processSegmentation:filteredSegmentations eventKey:key]; - [CountlyPersistency.sharedInstance recordEvent:event]; + CLYRequestCallback callback = nil; + // Journey trigger views mirror the journey trigger events behavior. The 'name' segmentation value is + // matched as sent (after truncation and filtering), same as the wire format. + if ([key isEqualToString:kCountlyReservedEventView]) + { + NSString* viewName = event.segmentation[kCountlyVTKeyName]; + if ([viewName isKindOfClass:NSString.class] && [CountlyServerConfig.sharedInstance isJourneyTriggerView:viewName]) + { + callback = [self journeyTriggerCallback]; + } + } + [CountlyPersistency.sharedInstance recordEvent:event callback:callback]; } } +// Callback used for both journey trigger kinds: recording an event with a callback force-flushes the event +// queue, and the content zone is refreshed once that request succeeds. +- (CLYRequestCallback)journeyTriggerCallback +{ + return ^(NSString *response, BOOL success) { + if (success) + { +#if (TARGET_OS_IOS || TARGET_OS_VISION) + dispatch_async(dispatch_get_main_queue(), ^{ + [CountlyContentBuilderInternal.sharedInstance refreshContentZoneJTE]; + }); +#endif + } + }; +} + - (NSDictionary *)processSegmentation:(NSMutableDictionary *)segmentation eventKey:(NSString *)eventKey { BOOL isViewEvent = [eventKey isEqualToString:kCountlyReservedEventView]; @@ -1041,15 +1179,17 @@ - (NSDictionary *)processSegmentation:(NSMutableDictionary *)segmentation eventK } - (BOOL)isAppInForeground { -#if TARGET_OS_IOS || TARGET_OS_TV + // anything but the background counts as visible, as on Android: an inactive app, under a system alert or in the app + // switcher, is still on screen. macOS has no background state, and the SDK treats a resigned app as one there +#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION UIApplicationState state = [UIApplication sharedApplication].applicationState; - return state == UIApplicationStateActive; + return state != UIApplicationStateBackground; #elif TARGET_OS_OSX NSApplication *app = [NSApplication sharedApplication]; return app.isActive; #elif TARGET_OS_WATCH WKExtension *extension = [WKExtension sharedExtension]; - return extension.applicationState == WKApplicationStateActive; + return extension.applicationState != WKApplicationStateBackground; #else return NO; #endif @@ -1076,10 +1216,13 @@ - (NSNumber *)isReservedEvent:(NSString *)key - (void)startEvent:(NSString *)key { - CLY_LOG_I(@"%s key: [%@]", __FUNCTION__, key); + CLY_LOG_I(@"%s starting a timed event, key: [%@]", __FUNCTION__, key); if (!CountlyConsentManager.sharedInstance.consentForEvents) + { + CLY_LOG_V(@"%s no consent given for events, the timed event will not be started, key: [%@]", __FUNCTION__, key); return; + } CountlyEvent *event = CountlyEvent.new; event.key = key; @@ -1095,16 +1238,20 @@ - (void)endEvent:(NSString *)key - (void)endEvent:(NSString *)key segmentation:(NSDictionary *)segmentation count:(NSUInteger)count sum:(double)sum { - CLY_LOG_I(@"%s key: [%@], segmentation: [%@], count: [%lu], sum: [%f]", __FUNCTION__, key, segmentation, (unsigned long)count, sum); + CLY_LOG_I(@"%s ending a timed event, key: [%@], segmentation keys: [%@], segmentation key count: [%lu], count: [%lu], sum: [%f]", __FUNCTION__, key, segmentation.allKeys, (unsigned long)segmentation.count, (unsigned long)count, sum); + CLY_LOG_D(@"%s timed event end segmentation detail, key: [%@], segmentation: [%@]", __FUNCTION__, key, [self loggableSegmentation:segmentation eventKey:key]); if (!CountlyConsentManager.sharedInstance.consentForEvents) + { + CLY_LOG_V(@"%s no consent given for events, the timed event will not be ended, key: [%@]", __FUNCTION__, key); return; + } CountlyEvent *event = [CountlyPersistency.sharedInstance timedEventForKey:key]; if (!event) { - CLY_LOG_W(@"%s Event with key '%@' not started yet or cancelled/ended before!", __FUNCTION__, key); + CLY_LOG_W(@"%s omitting the call, the timed event was not started or was already ended, key: [%@]", __FUNCTION__, key); return; } @@ -1114,20 +1261,23 @@ - (void)endEvent:(NSString *)key segmentation:(NSDictionary *)segmentation count - (void)cancelEvent:(NSString *)key { - CLY_LOG_I(@"%s key: [%@]", __FUNCTION__, key); + CLY_LOG_I(@"%s cancelling a timed event, key: [%@]", __FUNCTION__, key); if (!CountlyConsentManager.sharedInstance.consentForEvents) + { + CLY_LOG_V(@"%s no consent given for events, the timed event will not be cancelled, key: [%@]", __FUNCTION__, key); return; + } CountlyEvent *event = [CountlyPersistency.sharedInstance timedEventForKey:key]; if (!event) { - CLY_LOG_W(@"%s Event with key '%@' not started yet or cancelled/ended before!", __FUNCTION__, key); + CLY_LOG_W(@"%s nothing to cancel, no timed event is running for the key, key: [%@]", __FUNCTION__, key); return; } - CLY_LOG_D(@"%s Event with key '%@' cancelled!", __FUNCTION__, key); + CLY_LOG_D(@"%s the timed event is cancelled, key: [%@]", __FUNCTION__, key); } @@ -1137,31 +1287,30 @@ - (void)cancelEvent:(NSString *)key - (void)askForNotificationPermission { - CLY_LOG_I(@"%s", __FUNCTION__); [CountlyPushNotifications.sharedInstance askForNotificationPermissionWithOptions:0 completionHandler:nil]; } - (void)askForNotificationPermissionWithOptions:(UNAuthorizationOptions)options completionHandler:(void (^)(BOOL granted, NSError * error))completionHandler; { - CLY_LOG_I(@"%s options: [%lu], completionHandler: [%@]", __FUNCTION__, (unsigned long)options, completionHandler); [CountlyPushNotifications.sharedInstance askForNotificationPermissionWithOptions:options completionHandler:completionHandler]; } - (void)recordActionForNotification:(NSDictionary *)userInfo clickedButtonIndex:(NSInteger)buttonIndex; { - CLY_LOG_I(@"%s userInfo: [%@], buttonIndex: [%ld]", __FUNCTION__, userInfo, (long)buttonIndex); + CLY_LOG_I(@"%s recording a notification action, buttonIndex: [%ld], payloadProvided: [%@], payloadKeyCount: [%lu]", __FUNCTION__, (long)buttonIndex, userInfo ? @"YES" : @"NO", (unsigned long)userInfo.count); + CLY_LOG_D(@"%s notification action payload detail, buttonIndex: [%ld], userInfo: [%@]", __FUNCTION__, (long)buttonIndex, userInfo); [CountlyPushNotifications.sharedInstance recordActionForNotification:userInfo clickedButtonIndex:buttonIndex]; } - (void)recordPushNotificationToken { - CLY_LOG_I(@"%s", __FUNCTION__); + CLY_LOG_I(@"%s sending the push notification token", __FUNCTION__); [CountlyPushNotifications.sharedInstance sendToken]; } - (void)clearPushNotificationToken { - CLY_LOG_I(@"%s", __FUNCTION__); + CLY_LOG_I(@"%s clearing the push notification token", __FUNCTION__); [CountlyPushNotifications.sharedInstance clearToken]; } #endif @@ -1173,13 +1322,14 @@ - (void)clearPushNotificationToken - (void)recordLocation:(CLLocationCoordinate2D)location city:(NSString * _Nullable)city ISOCountryCode:(NSString * _Nullable)ISOCountryCode IP:(NSString * _Nullable)IP { - CLY_LOG_I(@"%s lat: [%f], long: [%f], city: [%@], country: [%@], ip: [%@]", __FUNCTION__, location.latitude, location.longitude, city, ISOCountryCode, IP); + // log gathering can upload any line, so the values are not quoted once the server turned location tracking off + if (CountlyServerConfig.sharedInstance.locationTrackingEnabled) + CLY_LOG_D(@"%s location detail, latitude: [%f], longitude: [%f], city: [%@], ISOCountryCode: [%@], IP: [%@]", __FUNCTION__, location.latitude, location.longitude, city, ISOCountryCode, IP); [CountlyLocationManager.sharedInstance recordLocation:location city:city ISOCountryCode:ISOCountryCode IP:IP]; } - (void)disableLocationInfo { - CLY_LOG_I(@"%s", __FUNCTION__); [CountlyLocationManager.sharedInstance disableLocationInfo]; } @@ -1189,184 +1339,97 @@ - (void)disableLocationInfo - (void)recordException:(NSException *)exception { - CLY_LOG_I(@"%s exception: [%@]", __FUNCTION__, exception); + CLY_LOG_D(@"%s exception detail, exception: [%@]", __FUNCTION__, exception); [CountlyCrashReporter.sharedInstance recordException:exception isFatal:NO stackTrace:nil segmentation:nil]; } - (void)recordException:(NSException *)exception isFatal:(BOOL)isFatal { - CLY_LOG_I(@"%s exception: [%@], isFatal: [%d]", __FUNCTION__, exception, isFatal); + CLY_LOG_D(@"%s exception detail with fatality, exception: [%@], isFatal: [%@]", __FUNCTION__, exception, isFatal ? @"YES" : @"NO"); [CountlyCrashReporter.sharedInstance recordException:exception isFatal:isFatal stackTrace:nil segmentation:nil]; } - (void)recordException:(NSException *)exception isFatal:(BOOL)isFatal stackTrace:(NSArray *)stackTrace segmentation:(NSDictionary *)segmentation { - CLY_LOG_I(@"%s exception: [%@], isFatal: [%d], stackTrace: [%@], segmentation: [%@]", __FUNCTION__, exception, isFatal, stackTrace, segmentation); + CLY_LOG_D(@"%s exception detail with stack trace and segmentation, exception: [%@], isFatal: [%@], stackTrace: [%@], segmentation: [%@]", __FUNCTION__, exception, isFatal ? @"YES" : @"NO", stackTrace, segmentation); [CountlyCrashReporter.sharedInstance recordException:exception isFatal:isFatal stackTrace:stackTrace segmentation:segmentation]; } - (void)recordError:(NSString *)errorName stackTrace:(NSArray * _Nullable)stackTrace { - CLY_LOG_I(@"%s errorName: [%@], stackTrace: [%@]", __FUNCTION__, errorName, stackTrace); + CLY_LOG_D(@"%s error detail with stack trace, errorName: [%@], stackTrace: [%@]", __FUNCTION__, errorName, stackTrace); [CountlyCrashReporter.sharedInstance recordError:errorName isFatal:NO stackTrace:stackTrace segmentation:nil]; } - (void)recordError:(NSString *)errorName isFatal:(BOOL)isFatal stackTrace:(NSArray * _Nullable)stackTrace segmentation:(NSDictionary *)segmentation { - CLY_LOG_I(@"%s errorName: [%@], isFatal: [%d], stackTrace: [%@], segmentation: [%@]", __FUNCTION__, errorName, isFatal, stackTrace, segmentation); + CLY_LOG_D(@"%s error detail with stack trace and segmentation, errorName: [%@], isFatal: [%@], stackTrace: [%@], segmentation: [%@]", __FUNCTION__, errorName, isFatal ? @"YES" : @"NO", stackTrace, segmentation); [CountlyCrashReporter.sharedInstance recordError:errorName isFatal:isFatal stackTrace:stackTrace segmentation:segmentation]; } -- (void)recordHandledException:(NSException *)exception -{ - CLY_LOG_I(@"%s exception: [%@]", __FUNCTION__, exception); - [CountlyCrashReporter.sharedInstance recordException:exception isFatal:NO stackTrace:nil segmentation:nil]; -} - -- (void)recordHandledException:(NSException *)exception withStackTrace:(NSArray *)stackTrace -{ - CLY_LOG_I(@"%s exception: [%@], stackTrace: [%@]", __FUNCTION__, exception, stackTrace); - [CountlyCrashReporter.sharedInstance recordException:exception isFatal:NO stackTrace:stackTrace segmentation:nil]; -} - -- (void)recordUnhandledException:(NSException *)exception withStackTrace:(NSArray * _Nullable)stackTrace -{ - CLY_LOG_I(@"%s exception: [%@], stackTrace: [%@]", __FUNCTION__, exception, stackTrace); - [CountlyCrashReporter.sharedInstance recordException:exception isFatal:YES stackTrace:stackTrace segmentation:nil]; -} - - (void)recordCrashLog:(NSString *)log { - CLY_LOG_I(@"%s log: [%@]", __FUNCTION__, log); + CLY_LOG_I(@"%s recording a crash breadcrumb, length: [%lu]", __FUNCTION__, (unsigned long)log.length); + CLY_LOG_D(@"%s crash breadcrumb detail, log: [%@]", __FUNCTION__, log); [CountlyCrashReporter.sharedInstance log:log]; } - (void)clearCrashLogs { - CLY_LOG_I(@"%s", __FUNCTION__); + CLY_LOG_I(@"%s clearing the recorded crash breadcrumbs", __FUNCTION__); [CountlyCrashReporter.sharedInstance clearCrashLogs]; } - (void)crashLog:(NSString *)format, ... { - -} -#pragma mark - View Tracking - -- (void)recordView:(NSString *)viewName; -{ - CLY_LOG_I(@"%s viewName: [%@]", __FUNCTION__, viewName); - [CountlyViewTrackingInternal.sharedInstance startAutoStoppedView:viewName segmentation:nil]; + CLY_LOG_W(@"%s this method does nothing, use 'recordCrashLog:' to record a crash breadcrumb", __FUNCTION__); } - -- (void)recordView:(NSString *)viewName segmentation:(NSDictionary *)segmentation -{ - CLY_LOG_I(@"%s viewName: [%@], segmentation: [%@]", __FUNCTION__, viewName, segmentation); - - [CountlyViewTrackingInternal.sharedInstance startAutoStoppedView:viewName segmentation:segmentation]; -} - -#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV ) -- (void)addExceptionForAutoViewTracking:(NSString *)exception -{ - CLY_LOG_I(@"%s exception: [%@]", __FUNCTION__, exception); - - [CountlyViewTrackingInternal.sharedInstance addExceptionForAutoViewTracking:exception.copy]; -} - -- (void)removeExceptionForAutoViewTracking:(NSString *)exception -{ - CLY_LOG_I(@"%s exception: [%@]", __FUNCTION__, exception); - - [CountlyViewTrackingInternal.sharedInstance removeExceptionForAutoViewTracking:exception.copy]; -} - -- (void)setIsAutoViewTrackingActive:(BOOL)isAutoViewTrackingActive -{ - CLY_LOG_I(@"%s isAutoViewTrackingActive: [%d]", __FUNCTION__, isAutoViewTrackingActive); - - CountlyViewTrackingInternal.sharedInstance.isAutoViewTrackingActive = isAutoViewTrackingActive; -} - -- (BOOL)isAutoViewTrackingActive -{ - CLY_LOG_I(@"%s", __FUNCTION__); - return CountlyViewTrackingInternal.sharedInstance.isAutoViewTrackingActive; -} -#endif #pragma mark - Star Rating -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) - (void)askForStarRating:(void(^)(NSInteger rating))completion { - CLY_LOG_I(@"%s completion: [%@]", __FUNCTION__, completion); [CountlyFeedbacksInternal.sharedInstance showDialog:completion]; } -- (void)presentFeedbackWidgetWithID:(NSString *)widgetID completionHandler:(void (^)(NSError * error))completionHandler -{ - CLY_LOG_I(@"%s widgetID: [%@], completionHandler: [%@]", __FUNCTION__, widgetID, completionHandler); - - [self presentRatingWidgetWithID:widgetID closeButtonText:nil completionHandler:completionHandler]; -} - - (void)presentRatingWidgetWithID:(NSString *)widgetID completionHandler:(void (^)(NSError * error))completionHandler { - CLY_LOG_I(@"%s widgetID: [%@], completionHandler: [%@]", __FUNCTION__, widgetID, completionHandler); [self presentRatingWidgetWithID:widgetID closeButtonText:nil completionHandler:completionHandler]; } - (void)presentRatingWidgetWithID:(NSString *)widgetID closeButtonText:(NSString * _Nullable)closeButtonText completionHandler:(void (^)(NSError * __nullable error))completionHandler { - - CLY_LOG_I(@"%s widgetID: [%@], closeButtonText: [%@], completionHandler: [%@]", __FUNCTION__, widgetID, closeButtonText, completionHandler); - [CountlyFeedbacksInternal.sharedInstance presentRatingWidgetWithID:widgetID closeButtonText:closeButtonText completionHandler:completionHandler]; } - (void)recordRatingWidgetWithID:(NSString *)widgetID rating:(NSInteger)rating email:(NSString * _Nullable)email comment:(NSString * _Nullable)comment userCanBeContacted:(BOOL)userCanBeContacted { - CLY_LOG_I(@"%s widgetID: [%@], rating: [%ld], email: [%@], comment: [%@], userCanBeContacted: [%d]", __FUNCTION__, widgetID, (long)rating, email, comment, userCanBeContacted); - + CLY_LOG_D(@"%s rating widget answer detail, widgetID: [%@], rating: [%ld], email: [%@], comment: [%@], userCanBeContacted: [%@]", __FUNCTION__, widgetID, (long)rating, email, comment, userCanBeContacted ? @"YES" : @"NO"); [CountlyFeedbacksInternal.sharedInstance recordRatingWidgetWithID:widgetID rating:rating email:email comment:comment userCanBeContacted:userCanBeContacted]; } - -- (void)getFeedbackWidgets:(void (^)(NSArray *feedbackWidgets, NSError * error))completionHandler -{ - CLY_LOG_I(@"%s completionHandler: [%@]", __FUNCTION__, completionHandler); - [CountlyFeedbacksInternal.sharedInstance getFeedbackWidgets:completionHandler]; -} #endif #pragma mark - Attribution -- (void)recordAttributionID:(NSString *)attributionID -{ - CLY_LOG_I(@"%s attributionID: [%@]", __FUNCTION__, attributionID); - - if (!CountlyConsentManager.sharedInstance.consentForAttribution) - return; - - CountlyCommon.sharedInstance.attributionID = attributionID; - - [CountlyConnectionManager.sharedInstance sendAttribution]; -} - - (void)recordDirectAttributionWithCampaignType:(NSString *)campaignType andCampaignData:(NSString *)campaignData { - CLY_LOG_I(@"%s campaignType: [%@], campaignData: [%@]", __FUNCTION__, campaignType, campaignData); + CLY_LOG_I(@"%s recording direct attribution, campaignType: [%@], campaignDataLength: [%lu]", __FUNCTION__, campaignType, (unsigned long)campaignData.length); + CLY_LOG_D(@"%s direct attribution campaign data detail, campaignType: [%@], campaignData: [%@]", __FUNCTION__, campaignType, campaignData); if (!CountlyConsentManager.sharedInstance.consentForAttribution) + { + CLY_LOG_V(@"%s no consent given for attribution, the direct attribution will not be recorded", __FUNCTION__); return; + } if (!campaignType.length) { - CLY_LOG_E(@"%s campaignType must be non-zero length valid string. Method execution will be aborted!", __FUNCTION__); + CLY_LOG_E(@"%s omitting the call, campaignType must be a non zero length string, campaignType: [%@]", __FUNCTION__, campaignType); return; } if (!campaignData.length) { - CLY_LOG_E(@"%s campaignData must be non-zero length valid string. Method execution will be aborted!", __FUNCTION__); + CLY_LOG_E(@"%s omitting the call, campaignData must be a non zero length string, campaignType: [%@]", __FUNCTION__, campaignType); return; } @@ -1378,7 +1441,7 @@ - (void)recordDirectAttributionWithCampaignType:(NSString *)campaignType andCamp if (![campaignType isEqualToString:@"countly"]) { - CLY_LOG_W(@"%s Recording direct attribution with a type other than 'countly' is currently not supported. Method execution will be aborted!", __FUNCTION__); + CLY_LOG_W(@"%s omitting the call, direct attribution is only supported for the 'countly' campaign type, campaignType: [%@]", __FUNCTION__, campaignType); return; } @@ -1386,21 +1449,21 @@ - (void)recordDirectAttributionWithCampaignType:(NSString *)campaignType andCamp NSDictionary* campaignDataDictionary = [NSJSONSerialization JSONObjectWithData:[campaignData cly_dataUTF8] options:0 error:&error]; if (error) { - CLY_LOG_E(@"%s Campaign data is not in expected format. Method execution will be aborted!", __FUNCTION__); + CLY_LOG_E(@"%s omitting the call, the campaign data is not valid JSON, error: [%@]", __FUNCTION__, error.localizedDescription); return; } NSString* campaignID = campaignDataDictionary[@"cid"]; if (!campaignID.length) { - CLY_LOG_E(@"%s Campaign ID must be non-zero length valid string. Method execution will be aborted!", __FUNCTION__); + CLY_LOG_E(@"%s omitting the call, the campaign ID in the campaign data is missing or empty", __FUNCTION__); return; } NSString* campaignUserID = campaignDataDictionary[@"cuid"]; if (!campaignUserID.length) { - CLY_LOG_W(@"%s Campaign User ID must be non-zero length valid string. It will be ignored!", __FUNCTION__); + CLY_LOG_D(@"%s the campaign user ID in the campaign data is missing or empty, it will be ignored", __FUNCTION__); } [CountlyConnectionManager.sharedInstance sendDirectAttributionWithCampaignID:campaignID andCampaignUserID:campaignUserID]; @@ -1408,10 +1471,14 @@ - (void)recordDirectAttributionWithCampaignType:(NSString *)campaignType andCamp - (void)recordIndirectAttribution:(NSDictionary *)attribution { - CLY_LOG_I(@"%s attribution: [%@]", __FUNCTION__, attribution); + CLY_LOG_I(@"%s recording indirect attribution, keys: [%@], count: [%lu]", __FUNCTION__, attribution.allKeys, (unsigned long)attribution.count); + CLY_LOG_D(@"%s indirect attribution parameter detail, attribution: [%@]", __FUNCTION__, attribution); if (!CountlyConsentManager.sharedInstance.consentForAttribution) + { + CLY_LOG_V(@"%s no consent given for attribution, the indirect attribution will not be recorded", __FUNCTION__); return; + } NSMutableDictionary* filtered = attribution.mutableCopy; [attribution enumerateKeysAndObjectsUsingBlock:^(NSString * key, NSString * value, BOOL * stop) @@ -1426,68 +1493,37 @@ - (void)recordIndirectAttribution:(NSDictionary *)attrib [CountlyConnectionManager.sharedInstance sendIndirectAttribution:limited]; } -#pragma mark - Remote Config - -- (id)remoteConfigValueForKey:(NSString *)key -{ - CLY_LOG_I(@"%s key: [%@]", __FUNCTION__, key); - return [CountlyRemoteConfigInternal.sharedInstance remoteConfigValueForKey:key]; -} - -- (void)updateRemoteConfigWithCompletionHandler:(void (^)(NSError * error))completionHandler -{ - CLY_LOG_I(@"%s completionHandler: [%@]", __FUNCTION__, completionHandler); - [CountlyRemoteConfigInternal.sharedInstance updateRemoteConfigForKeys:nil omitKeys:nil completionHandler:completionHandler]; -} - -- (void)updateRemoteConfigOnlyForKeys:(NSArray *)keys completionHandler:(void (^)(NSError * error))completionHandler -{ - CLY_LOG_I(@"%s keys: [%@], completionHandler: [%@]", __FUNCTION__, keys, completionHandler); - [CountlyRemoteConfigInternal.sharedInstance updateRemoteConfigForKeys:keys omitKeys:nil completionHandler:completionHandler]; -} - -- (void)updateRemoteConfigExceptForKeys:(NSArray *)omitKeys completionHandler:(void (^)(NSError * error))completionHandler -{ - CLY_LOG_I(@"%s omitKeys: [%@], completionHandler: [%@]", __FUNCTION__, omitKeys, completionHandler); - [CountlyRemoteConfigInternal.sharedInstance updateRemoteConfigForKeys:nil omitKeys:omitKeys completionHandler:completionHandler]; -} - #pragma mark - Performance Monitoring - (void)recordNetworkTrace:(NSString *)traceName requestPayloadSize:(NSInteger)requestPayloadSize responsePayloadSize:(NSInteger)responsePayloadSize responseStatusCode:(NSInteger)responseStatusCode startTime:(long long)startTime endTime:(long long)endTime { - CLY_LOG_I(@"%s traceName: [%@], requestPayloadSize: [%ld], responsePayloadSize: [%ld], responseStatusCode: [%ld], startTime: [%lld], endTime: [%lld]", __FUNCTION__, traceName, (long)requestPayloadSize, (long)responsePayloadSize, (long)responseStatusCode, startTime, endTime); - [CountlyPerformanceMonitoring.sharedInstance recordNetworkTrace:traceName requestPayloadSize:requestPayloadSize responsePayloadSize:responsePayloadSize responseStatusCode:responseStatusCode startTime:startTime endTime:endTime]; } - (void)startCustomTrace:(NSString *)traceName { - CLY_LOG_I(@"%s traceName: [%@]", __FUNCTION__, traceName); [CountlyPerformanceMonitoring.sharedInstance startCustomTrace:traceName]; } - (void)endCustomTrace:(NSString *)traceName metrics:(NSDictionary * _Nullable)metrics { - CLY_LOG_I(@"%s traceName: [%@], metrics: [%@]", __FUNCTION__, traceName, metrics); [CountlyPerformanceMonitoring.sharedInstance endCustomTrace:traceName metrics:metrics]; } - (void)cancelCustomTrace:(NSString *)traceName { - CLY_LOG_I(@"%s traceName: [%@]", __FUNCTION__, traceName); [CountlyPerformanceMonitoring.sharedInstance cancelCustomTrace:traceName]; } - (void)clearAllCustomTraces { - CLY_LOG_I(@"%s", __FUNCTION__); + CLY_LOG_I(@"%s clearing all custom traces", __FUNCTION__); [CountlyPerformanceMonitoring.sharedInstance clearAllCustomTraces]; } - (void)appLoadingFinished { - CLY_LOG_I(@"%s", __FUNCTION__); + CLY_LOG_I(@"%s app loading finished, recording the app start duration trace", __FUNCTION__); long long appLoadEndTime = floor(NSDate.date.timeIntervalSince1970 * 1000); @@ -1496,36 +1532,31 @@ - (void)appLoadingFinished - (void)halt { - CLY_LOG_I(@"%s", __FUNCTION__); [self halt:true]; } - (void)halt:(BOOL) clearStorage { - CLY_LOG_I(@"%s clearStorage: [%d]", __FUNCTION__, clearStorage); + CLY_LOG_I(@"%s halting the SDK, clearStorage: [%@]", __FUNCTION__, clearStorage ? @"YES" : @"NO"); - // Reset view tracking state BEFORE halt — sharedInstance() returns nil after halt. - // Use KVC to clear internal state directly since stopAllViews checks consent - // and may be a no-op if previous test required consent. - if (CountlyViewTrackingInternal.sharedInstance) - { - CountlyViewTrackingInternal* viewTracking = CountlyViewTrackingInternal.sharedInstance; - [viewTracking setValue:NSMutableDictionary.new forKey:@"viewDataDictionary"]; - [viewTracking setValue:nil forKey:@"currentViewID"]; - [viewTracking setValue:nil forKey:@"currentViewName"]; - [viewTracking setValue:nil forKey:@"previousViewID"]; - [viewTracking setValue:nil forKey:@"previousViewName"]; - [viewTracking setValue:@NO forKey:@"isAutoViewTrackingActive"]; - [viewTracking resetFirstView]; - } + // before any singleton goes: a log batch delivery or a connection test report landing in the middle of the + // resets below would queue a request through a recreated connection manager that has no app key yet + CountlyCommon.sharedInstance.hasFinishedInit = NO; + [CountlyConnectionTest.sharedInstance resetInstance]; + + // Reset view tracking BEFORE halt: sharedInstance returns nil once hasStarted is false. + // Dropping its singleton clears the recorded views and the one-way configuration flags, + // which stopAllViews cannot do because it is gated on view tracking consent. + [CountlyViewTrackingInternal.sharedInstance resetInstance]; // Reset health tracker state [CountlyHealthTracker.sharedInstance resetInstance]; - // Clear crash logs (safe operation - just clears array or deletes file) + // Clear crash logs (safe, it only clears the array or deletes the file) and put back the crash handlers Countly replaced if (CountlyCrashReporter.sharedInstance) { [CountlyCrashReporter.sharedInstance clearCrashLogs]; + [CountlyCrashReporter.sharedInstance resetInstance]; } // Clear custom performance monitoring traces (safe operation - just clears dictionary) @@ -1534,58 +1565,35 @@ - (void)halt:(BOOL) clearStorage [CountlyPerformanceMonitoring.sharedInstance clearAllCustomTraces]; } - // Note: CountlyRemoteConfigInternal.clearAll is not called here because it triggers - // storeRemoteConfig which involves file I/O and can block during shutdown. - // Remote config state will persist across halt/start but is cleared via UserDefaults - // key removal below. - [CountlyConsentManager.sharedInstance resetInstance]; + [CountlyLocationManager.sharedInstance resetInstance]; [CountlyPersistency.sharedInstance resetInstance:clearStorage]; [CountlyDeviceInfo.sharedInstance resetInstance]; [CountlyConnectionManager.sharedInstance resetInstance]; [CountlyServerConfig.sharedInstance resetInstance]; +#if (TARGET_OS_IOS || TARGET_OS_VISION) + [CountlyContentBuilderInternal.sharedInstance resetInstance]; +#endif [CountlyUserDetails.sharedInstance clearUserDetails]; [self resetInstance]; + // last before CountlyCommon, so nothing recreates it from the storage that is about to be cleared + [CountlyRemoteConfigInternal.sharedInstance resetInstance]; [CountlyCommon.sharedInstance resetInstance]; if(clearStorage) { - NSString *appDomain = [[NSBundle mainBundle] bundleIdentifier]; - [NSUserDefaults.standardUserDefaults removePersistentDomainForName:appDomain]; - - // halt(true) calls removePersistentDomainForName which doesn't work in xctest - // environment (different bundle ID), so clear SDK keys manually - NSArray* sdkKeys = @[ - @"kCountlyServerConfigPersistencyKey", - @"kCountlyHealthCheckStatePersistencyKey", - @"kCountlyQueuedRequestsPersistencyKey", - @"kCountlyStartedEventsPersistencyKey", - @"kCountlyStoredDeviceIDKey", - @"kCountlyStoredNSUUIDKey", - @"kCountlyStarRatingStatusKey", - @"kCountlyRemoteConfigKey", - @"kCountlyIsCustomDeviceIDKey", - @"kCountlyNotificationPermissionKey", - @"kCountlyWatchParentDeviceIDKey" - ]; - for (NSString* key in sdkKeys) - { - [NSUserDefaults.standardUserDefaults removeObjectForKey:key]; - } - - // Single synchronize after all UserDefaults modifications - [NSUserDefaults.standardUserDefaults synchronize]; + [CountlyPersistency removeStoredUserDefaults]; } } - (void)attemptToSendStoredRequests { - CLY_LOG_I(@"%s", __FUNCTION__); + CLY_LOG_I(@"%s attempting to send the stored requests", __FUNCTION__); [CountlyConnectionManager.sharedInstance attemptToSendStoredRequests]; } #pragma mark - Interfaces -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) - (CountlyContentBuilder *) content { return CountlyContentBuilder.sharedInstance; @@ -1611,4 +1619,9 @@ - (CountlyRemoteConfig *) remoteConfig { return CountlyRemoteConfig.sharedInstance; } +- (CountlyUserDetails *) userProfile +{ + return CountlyUserDetails.sharedInstance; +} + @end diff --git a/ios/src/Countly.podspec b/ios/src/Countly.podspec index d162fc6b..981806bf 100644 --- a/ios/src/Countly.podspec +++ b/ios/src/Countly.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'Countly' - s.version = '26.1.1' + s.version = '26.8.0' s.license = { :type => 'MIT', :file => 'LICENSE' } s.summary = 'Countly is an innovative, real-time, open source mobile analytics platform.' s.homepage = 'https://github.com/Countly/countly-sdk-ios' @@ -10,21 +10,24 @@ Pod::Spec.new do |s| s.requires_arc = true s.default_subspecs = 'Core' - s.ios.deployment_target = '10.0' - s.osx.deployment_target = '10.14' - s.watchos.deployment_target = '4.0' - s.tvos.deployment_target = '10.0' + s.ios.deployment_target = '15.0' + s.osx.deployment_target = '12.0' + s.watchos.deployment_target = '9.0' + s.tvos.deployment_target = '15.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.resource_bundles = { 'Countly' => ['PrivacyInfo.xcprivacy'] } core.ios.frameworks = ['Foundation', 'UIKit', 'UserNotifications', 'CoreLocation', 'WebKit', 'CoreTelephony', 'WatchConnectivity'] + core.visionos.frameworks = ['Foundation', 'UIKit', 'UserNotifications', 'CoreLocation', 'WebKit'] end s.subspec 'NotificationService' do |ns| ns.source_files = 'CountlyNotificationService.{m,h}' - ns.ios.deployment_target = '10.0' + ns.ios.deployment_target = '15.0' ns.ios.frameworks = ['Foundation', 'UserNotifications'] end diff --git a/ios/src/Countly.xcodeproj/project.pbxproj b/ios/src/Countly.xcodeproj/project.pbxproj index b394f24d..2c414b21 100644 --- a/ios/src/Countly.xcodeproj/project.pbxproj +++ b/ios/src/Countly.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 62868085F6E0E2F98BD3804A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 395683372BB2988300C7A06B /* PrivacyInfo.xcprivacy */; }; 1A3110632A7128CD001CB507 /* CountlyViewData.m in Sources */ = {isa = PBXBuildFile; fileRef = 1A3110622A7128CD001CB507 /* CountlyViewData.m */; }; 1A3110652A7128ED001CB507 /* CountlyViewData.h in Headers */ = {isa = PBXBuildFile; fileRef = 1A3110642A7128DC001CB507 /* CountlyViewData.h */; }; 1A3110702A7141AF001CB507 /* CountlyViewTracking.h in Headers */ = {isa = PBXBuildFile; fileRef = 1A31106E2A7141AF001CB507 /* CountlyViewTracking.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -18,7 +19,7 @@ 1A478D032AB314750056A5E7 /* CountlyExperimentInformation.h in Headers */ = {isa = PBXBuildFile; fileRef = 1A9027FF2AB197C00044EBCF /* CountlyExperimentInformation.h */; settings = {ATTRIBUTES = (Public, ); }; }; 1A50D7052B3C5AA3009C6938 /* CountlyBaseTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A50D7042B3C5AA3009C6938 /* CountlyBaseTestCase.swift */; }; 1A5C4C972B35B0850032EE1F /* CountlyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A5C4C962B35B0850032EE1F /* CountlyTests.swift */; }; - 1A5C4C982B35B0850032EE1F /* Countly.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B20A9822245225A00E3D7AE /* Countly.framework */; platformFilters = (ios, macos, ); }; + 1A5C4C982B35B0850032EE1F /* Countly.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B20A9822245225A00E3D7AE /* Countly.framework */; platformFilters = (ios, macos, tvos, watchos, xros, ); }; 1A9027FE2AB197B50044EBCF /* CountlyExperimentInformation.m in Sources */ = {isa = PBXBuildFile; fileRef = 1A9027FD2AB197B50044EBCF /* CountlyExperimentInformation.m */; }; 1ACA5DC12A309E7F001F770B /* CountlyRemoteConfigInternal.h in Headers */ = {isa = PBXBuildFile; fileRef = 1ACA5DBF2A309E7F001F770B /* CountlyRemoteConfigInternal.h */; }; 1ACA5DC22A309E7F001F770B /* CountlyRemoteConfigInternal.m in Sources */ = {isa = PBXBuildFile; fileRef = 1ACA5DC02A309E7F001F770B /* CountlyRemoteConfigInternal.m */; }; @@ -82,22 +83,46 @@ 3B20A9D32245228700E3D7AE /* CountlyPushNotifications.h in Headers */ = {isa = PBXBuildFile; fileRef = 3B20A9AE2245228600E3D7AE /* CountlyPushNotifications.h */; }; 3B20A9D42245228700E3D7AE /* CountlyPersistency.m in Sources */ = {isa = PBXBuildFile; fileRef = 3B20A9AF2245228600E3D7AE /* CountlyPersistency.m */; }; 3B20A9D62245228700E3D7AE /* CountlyConsentManager.h in Headers */ = {isa = PBXBuildFile; fileRef = 3B20A9B12245228700E3D7AE /* CountlyConsentManager.h */; }; + 4C3A4CA02EB4C40000827FEA /* EventThreadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3A4C9F2EB4C40000827FEA /* EventThreadTests.swift */; }; + 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 */; }; + 96329DE22D9426F300BFD6A2 /* CountlySDKLogsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96329DE12D9426F300BFD6A1 /* CountlySDKLogsTests.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 */; }; + 2E45BD6E9775428C9ACA156B /* CountlyConnectionTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 346E56EF0A3F4C1591EF3048 /* CountlyConnectionTest.m */; }; + F50FB0BC77B4448496B04291 /* CountlyConnectionTest.h in Headers */ = {isa = PBXBuildFile; fileRef = 188C63D9258A4759A4DDD025 /* CountlyConnectionTest.h */; }; + DA78BF04FF874F7794E91EBF /* CountlyConnectionTestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDE46466C384B6198600E66 /* CountlyConnectionTestTests.swift */; }; 965A2E9D2DDDCDAC00F28F6A /* CountlyHealthTracker.h in Headers */ = {isa = PBXBuildFile; fileRef = 965A2E9A2DDDCDAC00F28F6A /* CountlyHealthTracker.h */; }; + 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 */; }; + AB10000011112222333344B1 /* CountlyThemeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB10000011112222333344B0 /* CountlyThemeTests.swift */; }; + AB10000011112222333355C1 /* CountlyPlatformTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB10000011112222333355C0 /* CountlyPlatformTests.swift */; }; + AB10000011112222333356C1 /* TestAppActivation.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB10000011112222333356C0 /* TestAppActivation.swift */; }; + AB10000011112222333357C1 /* TestPlatform.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB10000011112222333357C0 /* TestPlatform.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 */; }; + 96AAC90A2F20B7790021CA5A /* TouchDelegatingView.m in Sources */ = {isa = PBXBuildFile; fileRef = 96AAC9092F20B7790021CA5A /* TouchDelegatingView.m */; }; 96C05AB02E82936F0028A976 /* CountlyHealthTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96C05AAF2E8293630028A976 /* CountlyHealthTrackerTests.swift */; }; 96DA74BB2D9FB687006FA6FF /* MockFeedbackWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96DA74BA2D9FB687006FA6FF /* MockFeedbackWidget.swift */; }; 96E680422BFF89AC0091E105 /* CountlyCrashReporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E680412BFF89AC0091E105 /* CountlyCrashReporterTests.swift */; }; + 96F80BE72F17D066006B4F71 /* CountlyWebViewController.h in Headers */ = {isa = PBXBuildFile; fileRef = 96F80BE62F17D066006B4F71 /* CountlyWebViewController.h */; }; + 96F80BE92F17D06F006B4F71 /* CountlyWebViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 96F80BE82F17D06F006B4F71 /* CountlyWebViewController.m */; }; + A431BFDB50E23B1C056F93A1 /* CountlyQueueFlushRunnablesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B81507954AACF61B41133AA /* CountlyQueueFlushRunnablesTests.swift */; }; + CD99EB53BFF33C2DA9C50715 /* CountlyCallbackBaseTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A0A9216F7158834687B2631 /* CountlyCallbackBaseTestCase.swift */; }; D219374B248AC71C00E5798B /* CountlyPerformanceMonitoring.h in Headers */ = {isa = PBXBuildFile; fileRef = D2193749248AC71C00E5798B /* CountlyPerformanceMonitoring.h */; }; D219374C248AC71C00E5798B /* CountlyPerformanceMonitoring.m in Sources */ = {isa = PBXBuildFile; fileRef = D219374A248AC71C00E5798B /* CountlyPerformanceMonitoring.m */; }; D249BF5E254D3D180058A6C2 /* CountlyFeedbackWidget.h in Headers */ = {isa = PBXBuildFile; fileRef = D249BF5C254D3D170058A6C2 /* CountlyFeedbackWidget.h */; settings = {ATTRIBUTES = (Public, ); }; }; D249BF5F254D3D180058A6C2 /* CountlyFeedbackWidget.m in Sources */ = {isa = PBXBuildFile; fileRef = D249BF5D254D3D180058A6C2 /* CountlyFeedbackWidget.m */; }; D2CFEF972545FBE80026B044 /* CountlyFeedbacksInternal.h in Headers */ = {isa = PBXBuildFile; fileRef = D2CFEF952545FBE80026B044 /* CountlyFeedbacksInternal.h */; }; D2CFEF982545FBE80026B044 /* CountlyFeedbacksInternal.m in Sources */ = {isa = PBXBuildFile; fileRef = D2CFEF962545FBE80026B044 /* CountlyFeedbacksInternal.m */; }; + F041D46C963F3EA85882CB0D /* CountlyRequestCallbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C1C3803ECB0A386136072D6 /* CountlyRequestCallbackTests.swift */; }; + 69F2F899F8C641418B746050 /* CountlyWebViewManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBD1642D826B471A80175BE3 /* CountlyWebViewManagerTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -111,6 +136,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 1A0A9216F7158834687B2631 /* CountlyCallbackBaseTestCase.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CountlyCallbackBaseTestCase.swift; sourceTree = ""; }; 1A3110622A7128CD001CB507 /* CountlyViewData.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CountlyViewData.m; sourceTree = ""; }; 1A3110642A7128DC001CB507 /* CountlyViewData.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CountlyViewData.h; sourceTree = ""; }; 1A31106E2A7141AF001CB507 /* CountlyViewTracking.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CountlyViewTracking.h; sourceTree = ""; }; @@ -189,23 +215,47 @@ 3B20A9AE2245228600E3D7AE /* CountlyPushNotifications.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CountlyPushNotifications.h; sourceTree = ""; }; 3B20A9AF2245228600E3D7AE /* CountlyPersistency.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CountlyPersistency.m; sourceTree = ""; }; 3B20A9B12245228700E3D7AE /* CountlyConsentManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CountlyConsentManager.h; sourceTree = ""; }; + 4C3A4C9F2EB4C40000827FEA /* EventThreadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventThreadTests.swift; sourceTree = ""; }; + 6B81507954AACF61B41133AA /* CountlyQueueFlushRunnablesTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CountlyQueueFlushRunnablesTests.swift; sourceTree = ""; }; + 7C1C3803ECB0A386136072D6 /* CountlyRequestCallbackTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CountlyRequestCallbackTests.swift; sourceTree = ""; }; + 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 = ""; }; + 96329DE12D9426F300BFD6A1 /* CountlySDKLogsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlySDKLogsTests.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 = ""; }; 965A2E9B2DDDCDAC00F28F6A /* CountlyHealthTracker.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CountlyHealthTracker.m; sourceTree = ""; }; + 188C63D9258A4759A4DDD025 /* CountlyConnectionTest.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CountlyConnectionTest.h; sourceTree = ""; }; + 346E56EF0A3F4C1591EF3048 /* CountlyConnectionTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CountlyConnectionTest.m; sourceTree = ""; }; + 9EDE46466C384B6198600E66 /* CountlyConnectionTestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyConnectionTestTests.swift; sourceTree = ""; }; 96681A9B2D97D9B300A4845A /* CountlyTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = CountlyTests.xctestplan; sourceTree = ""; }; + 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 = ""; }; + AB10000011112222333344B0 /* CountlyThemeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyThemeTests.swift; sourceTree = ""; }; + AB10000011112222333355C0 /* CountlyPlatformTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyPlatformTests.swift; sourceTree = ""; }; + AB10000011112222333356C0 /* TestAppActivation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAppActivation.swift; sourceTree = ""; }; + AB10000011112222333357C0 /* TestPlatform.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestPlatform.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 = ""; }; + 96AAC9092F20B7790021CA5A /* TouchDelegatingView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = TouchDelegatingView.m; sourceTree = ""; }; 96C05AAF2E8293630028A976 /* CountlyHealthTrackerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyHealthTrackerTests.swift; sourceTree = ""; }; 96DA74BA2D9FB687006FA6FF /* MockFeedbackWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockFeedbackWidget.swift; sourceTree = ""; }; 96E680412BFF89AC0091E105 /* CountlyCrashReporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyCrashReporterTests.swift; sourceTree = ""; }; + 96F80BE62F17D066006B4F71 /* CountlyWebViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CountlyWebViewController.h; sourceTree = ""; }; + 96F80BE82F17D06F006B4F71 /* CountlyWebViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CountlyWebViewController.m; sourceTree = ""; }; D2193749248AC71C00E5798B /* CountlyPerformanceMonitoring.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CountlyPerformanceMonitoring.h; sourceTree = ""; }; D219374A248AC71C00E5798B /* CountlyPerformanceMonitoring.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CountlyPerformanceMonitoring.m; sourceTree = ""; }; D249BF5C254D3D170058A6C2 /* CountlyFeedbackWidget.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CountlyFeedbackWidget.h; sourceTree = ""; }; D249BF5D254D3D180058A6C2 /* CountlyFeedbackWidget.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CountlyFeedbackWidget.m; sourceTree = ""; }; D2CFEF952545FBE80026B044 /* CountlyFeedbacksInternal.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CountlyFeedbacksInternal.h; sourceTree = ""; }; D2CFEF962545FBE80026B044 /* CountlyFeedbacksInternal.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CountlyFeedbacksInternal.m; sourceTree = ""; }; + EBD1642D826B471A80175BE3 /* CountlyWebViewManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyWebViewManagerTests.swift; sourceTree = ""; }; + 265156CB5F59417EA14BAC2F /* CountlyWebViewManager+Tests.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "CountlyWebViewManager+Tests.h"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -230,23 +280,40 @@ 1A5C4C952B35B0850032EE1F /* CountlyTests */ = { isa = PBXGroup; children = ( + 969E5BCD2ECC4D2C00AB406A /* CountlyConsentManagerTests.swift */, + 9673567E2EC60CD400C742D8 /* TestURLProtocol.swift */, + 4C3A4C9F2EB4C40000827FEA /* EventThreadTests.swift */, 96C05AAF2E8293630028A976 /* CountlyHealthTrackerTests.swift */, 96DA74BA2D9FB687006FA6FF /* MockFeedbackWidget.swift */, 962485B92D9E971400FA3C20 /* TestUtils.swift */, 96329DE32D952F1500BFD641 /* MockURLProtocol.swift */, 96329DE12D94299D00BFD641 /* ServerConfigBuilder.swift */, 96329DDF2D9426F300BFD641 /* CountlyServerConfigTests.swift */, + 96329DE12D9426F300BFD6A1 /* CountlySDKLogsTests.swift */, + 9EDE46466C384B6198600E66 /* CountlyConnectionTestTests.swift */, + ABCDE002000000000000ABCD /* CountlyServerConfigValidationTests.swift */, 1A5C4C962B35B0850032EE1F /* CountlyTests.swift */, 1A50D7042B3C5AA3009C6938 /* CountlyBaseTestCase.swift */, 1AFD79012B3EF82C00772FBD /* CountlyTests-Bridging-Header.h */, 968426802BF2302C007B303E /* CountlyConnectionManagerTests.swift */, + 96CB10A02F04B3A100D1E2F0 /* CountlyContentBuilderTests.swift */, + AB10000011112222333344A0 /* CountlyScreenMetricsTests.swift */, + AB10000011112222333344B0 /* CountlyThemeTests.swift */, + AB10000011112222333355C0 /* CountlyPlatformTests.swift */, + AB10000011112222333356C0 /* TestAppActivation.swift */, + AB10000011112222333357C0 /* TestPlatform.swift */, 96E680412BFF89AC0091E105 /* CountlyCrashReporterTests.swift */, + EBD1642D826B471A80175BE3 /* CountlyWebViewManagerTests.swift */, + 265156CB5F59417EA14BAC2F /* CountlyWebViewManager+Tests.h */, 3979E47C2C0760E900FA1CA4 /* CountlyUserProfileTests.swift */, 3972EDDA2C08A38D00EB9D3E /* CountlyEventStruct.swift */, 3966DBCE2C11EE270002ED97 /* CountlyDeviceIDTests.swift */, 3964A3E62C2AF8E90091E677 /* CountlySegmentationTests.swift */, 399B464F2C52813700AD384E /* CountlyLocationTests.swift */, 3969D0222CB80848000F8A32 /* CountlyViewTests.swift */, + 6B81507954AACF61B41133AA /* CountlyQueueFlushRunnablesTests.swift */, + 7C1C3803ECB0A386136072D6 /* CountlyRequestCallbackTests.swift */, + 1A0A9216F7158834687B2631 /* CountlyCallbackBaseTestCase.swift */, ); path = CountlyTests; sourceTree = ""; @@ -254,6 +321,12 @@ 3B20A9782245225A00E3D7AE = { isa = PBXGroup; children = ( + 96AAC9092F20B7790021CA5A /* TouchDelegatingView.m */, + 96095A5E2F20105600FDE933 /* TouchDelegatingView.h */, + 9691B7662F1FA35C00A6ADCB /* CountlyOverlayWindow.m */, + 9691B7642F1FA35500A6ADCB /* CountlyOverlayWindow.h */, + 96F80BE82F17D06F006B4F71 /* CountlyWebViewController.m */, + 96F80BE62F17D066006B4F71 /* CountlyWebViewController.h */, 96681A9B2D97D9B300A4845A /* CountlyTests.xctestplan */, 39BDF7562CC7CA870066DE7C /* CountlyFeedbacks.h */, 39BDF7582CC7CA920066DE7C /* CountlyFeedbacks.m */, @@ -329,6 +402,8 @@ 3B20A9A32245228500E3D7AE /* CountlyViewTrackingInternal.m */, 965A2E9A2DDDCDAC00F28F6A /* CountlyHealthTracker.h */, 965A2E9B2DDDCDAC00F28F6A /* CountlyHealthTracker.m */, + 188C63D9258A4759A4DDD025 /* CountlyConnectionTest.h */, + 346E56EF0A3F4C1591EF3048 /* CountlyConnectionTest.m */, 3B20A9862245225A00E3D7AE /* Info.plist */, 1A5C4C952B35B0850032EE1F /* CountlyTests */, 3B20A9832245225A00E3D7AE /* Products */, @@ -364,12 +439,15 @@ 39BDF7572CC7CA870066DE7C /* CountlyFeedbacks.h in Headers */, 3B20A9D32245228700E3D7AE /* CountlyPushNotifications.h in Headers */, 3B20A9C42245228700E3D7AE /* CountlyUserDetails.h in Headers */, + 96095A5F2F20105600FDE933 /* TouchDelegatingView.h in Headers */, 965A2E9D2DDDCDAC00F28F6A /* CountlyHealthTracker.h in Headers */, + F50FB0BC77B4448496B04291 /* CountlyConnectionTest.h in Headers */, 3961C6B72C6633C000DD38BA /* PassThroughBackgroundView.h in Headers */, 3B20A9CA2245228700E3D7AE /* CountlyConfig.h in Headers */, 3B20A9872245225A00E3D7AE /* Countly.h in Headers */, 1A423EA02A271FE0008C4757 /* CountlyRCData.h in Headers */, 3B20A9C12245228700E3D7AE /* CountlyRemoteConfig.h in Headers */, + 96F80BE72F17D066006B4F71 /* CountlyWebViewController.h in Headers */, 1A3110652A7128ED001CB507 /* CountlyViewData.h in Headers */, 3B20A9B22245228700E3D7AE /* CountlyConnectionManager.h in Headers */, 1A3A576529ED47BD0041B7BE /* CountlyServerConfig.h in Headers */, @@ -382,6 +460,7 @@ D219374B248AC71C00E5798B /* CountlyPerformanceMonitoring.h in Headers */, 39911B672B457DBB00AC053C /* Resettable.h in Headers */, 3B20A9BC2245228700E3D7AE /* CountlyCommon.h in Headers */, + 9691B7652F1FA35500A6ADCB /* CountlyOverlayWindow.h in Headers */, 399117D42C69F73D00DC4C66 /* CountlyContentBuilder.h in Headers */, 39527E182B5FD54C00EE5D7B /* CountlyAPMConfig.h in Headers */, 3B20A9C52245228700E3D7AE /* CountlyCrashReporter.h in Headers */, @@ -489,6 +568,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 62868085F6E0E2F98BD3804A /* PrivacyInfo.xcprivacy in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -499,6 +579,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 969E5BCE2ECC4D3200AB406A /* CountlyConsentManagerTests.swift in Sources */, 96329DE22D94299D00BFD641 /* ServerConfigBuilder.swift in Sources */, 1A5C4C972B35B0850032EE1F /* CountlyTests.swift in Sources */, 3969D0232CB80848000F8A32 /* CountlyViewTests.swift in Sources */, @@ -506,15 +587,30 @@ 1A50D7052B3C5AA3009C6938 /* CountlyBaseTestCase.swift in Sources */, 3979E47D2C0760E900FA1CA4 /* CountlyUserProfileTests.swift in Sources */, 968426812BF2302C007B303E /* CountlyConnectionManagerTests.swift in Sources */, + 96CB10A12F04B3A100D1E2F0 /* CountlyContentBuilderTests.swift in Sources */, + AB10000011112222333344A1 /* CountlyScreenMetricsTests.swift in Sources */, + AB10000011112222333344B1 /* CountlyThemeTests.swift in Sources */, + AB10000011112222333355C1 /* CountlyPlatformTests.swift in Sources */, + AB10000011112222333356C1 /* TestAppActivation.swift in Sources */, + AB10000011112222333357C1 /* TestPlatform.swift in Sources */, 96329DE42D952F1500BFD641 /* MockURLProtocol.swift in Sources */, 96329DE02D9426F300BFD641 /* CountlyServerConfigTests.swift in Sources */, + 96329DE22D9426F300BFD6A2 /* CountlySDKLogsTests.swift in Sources */, + DA78BF04FF874F7794E91EBF /* CountlyConnectionTestTests.swift in Sources */, + ABCDE001000000000000ABCD /* CountlyServerConfigValidationTests.swift in Sources */, 3966DBCF2C11EE270002ED97 /* CountlyDeviceIDTests.swift in Sources */, 3964A3E72C2AF8E90091E677 /* CountlySegmentationTests.swift in Sources */, 96DA74BB2D9FB687006FA6FF /* MockFeedbackWidget.swift in Sources */, + 4C3A4CA02EB4C40000827FEA /* EventThreadTests.swift in Sources */, 962485BA2D9E971800FA3C20 /* TestUtils.swift in Sources */, 96C05AB02E82936F0028A976 /* CountlyHealthTrackerTests.swift in Sources */, 3972EDDB2C08A38D00EB9D3E /* CountlyEventStruct.swift in Sources */, + 9673567F2EC60CD400C742D8 /* TestURLProtocol.swift in Sources */, 96E680422BFF89AC0091E105 /* CountlyCrashReporterTests.swift in Sources */, + 69F2F899F8C641418B746050 /* CountlyWebViewManagerTests.swift in Sources */, + A431BFDB50E23B1C056F93A1 /* CountlyQueueFlushRunnablesTests.swift in Sources */, + F041D46C963F3EA85882CB0D /* CountlyRequestCallbackTests.swift in Sources */, + CD99EB53BFF33C2DA9C50715 /* CountlyCallbackBaseTestCase.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -522,7 +618,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 96AAC90A2F20B7790021CA5A /* TouchDelegatingView.m in Sources */, 3B20A9B92245228700E3D7AE /* Countly.m in Sources */, + 3961C6B92C6633C000DD38BA /* PassThroughBackgroundView.m in Sources */, + 96F80BE92F17D06F006B4F71 /* CountlyWebViewController.m in Sources */, + 9691B7672F1FA35C00A6ADCB /* CountlyOverlayWindow.m in Sources */, + 3961C6BA2C6633C000DD38BA /* CountlyWebViewManager.m in Sources */, D249BF5F254D3D180058A6C2 /* CountlyFeedbackWidget.m in Sources */, 3B20A9BA2245228700E3D7AE /* CountlyEvent.m in Sources */, 3B20A9B52245228700E3D7AE /* CountlyCommon.m in Sources */, @@ -535,7 +636,6 @@ 3948A8572BAC2E7D002D09AA /* CountlySDKLimitsConfig.m in Sources */, D2CFEF982545FBE80026B044 /* CountlyFeedbacksInternal.m in Sources */, 1A3110632A7128CD001CB507 /* CountlyViewData.m in Sources */, - 3961C6B92C6633C000DD38BA /* PassThroughBackgroundView.m in Sources */, 399117D32C69F73D00DC4C66 /* CountlyContentBuilderInternal.m in Sources */, 39BDF7592CC7CA920066DE7C /* CountlyFeedbacks.m in Sources */, 1A423E9E2A271E46008C4757 /* CountlyRCData.m in Sources */, @@ -548,12 +648,12 @@ 3903429D2C8051C700238C96 /* CountlyExperimentalConfig.m in Sources */, 1A3A576329ED47A20041B7BE /* CountlyServerConfig.m in Sources */, 965A2E9C2DDDCDAC00F28F6A /* CountlyHealthTracker.m in Sources */, + 2E45BD6E9775428C9ACA156B /* CountlyConnectionTest.m in Sources */, D219374C248AC71C00E5798B /* CountlyPerformanceMonitoring.m in Sources */, 3B20A9B42245228700E3D7AE /* CountlyPushNotifications.m in Sources */, 3B20A9C92245228700E3D7AE /* CountlyUserDetails.m in Sources */, 399117D12C69F73D00DC4C66 /* CountlyContentBuilder.m in Sources */, 3B20A9D12245228700E3D7AE /* CountlyConfig.m in Sources */, - 3961C6BA2C6633C000DD38BA /* CountlyWebViewManager.m in Sources */, 1A9027FE2AB197B50044EBCF /* CountlyExperimentInformation.m in Sources */, 39924ED62BEBD20F00139F91 /* CountlyCrashData.m in Sources */, 3B20A9C82245228700E3D7AE /* CountlyViewTrackingInternal.m in Sources */, @@ -569,6 +669,9 @@ platformFilters = ( ios, macos, + tvos, + watchos, + xros, ); target = 3B20A9812245225A00E3D7AE /* Countly */; targetProxy = 1A5C4C992B35B0850032EE1F /* PBXContainerItemProxy */; @@ -587,16 +690,19 @@ IPHONEOS_DEPLOYMENT_TARGET = 16.1; MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 1.0; + TVOS_DEPLOYMENT_TARGET = 16.1; + WATCHOS_DEPLOYMENT_TARGET = 9.1; + XROS_DEPLOYMENT_TARGET = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.countly.CountlyTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx appletvos appletvsimulator watchos watchsimulator xros xrsimulator"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_OBJC_BRIDGING_HEADER = "CountlyTests/CountlyTests-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = "1,2,3,4,7"; }; name = Debug; }; @@ -611,16 +717,19 @@ IPHONEOS_DEPLOYMENT_TARGET = 16.1; MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 1.0; + TVOS_DEPLOYMENT_TARGET = 16.1; + WATCHOS_DEPLOYMENT_TARGET = 9.1; + XROS_DEPLOYMENT_TARGET = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.countly.CountlyTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx appletvos appletvsimulator watchos watchsimulator xros xrsimulator"; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_OBJC_BRIDGING_HEADER = "CountlyTests/CountlyTests-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = "1,2,3,4,7"; }; name = Release; }; @@ -678,13 +787,16 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; - MACOSX_DEPLOYMENT_TARGET = 10.14; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MACOSX_DEPLOYMENT_TARGET = 12.0; + TVOS_DEPLOYMENT_TARGET = 15.0; + WATCHOS_DEPLOYMENT_TARGET = 9.0; + XROS_DEPLOYMENT_TARGET = 1.0; 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 = ""; @@ -739,12 +851,15 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; - MACOSX_DEPLOYMENT_TARGET = 10.14; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MACOSX_DEPLOYMENT_TARGET = 12.0; + TVOS_DEPLOYMENT_TARGET = 15.0; + WATCHOS_DEPLOYMENT_TARGET = 9.0; + XROS_DEPLOYMENT_TARGET = 1.0; 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"; @@ -765,14 +880,14 @@ DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.14; - MARKETING_VERSION = 25.4.6; + MACOSX_DEPLOYMENT_TARGET = 12.0; + MARKETING_VERSION = 26.8.0; PRODUCT_BUNDLE_IDENTIFIER = ly.count.CountlyiOSSDK; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -797,14 +912,14 @@ DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.14; - MARKETING_VERSION = 25.4.6; + MACOSX_DEPLOYMENT_TARGET = 12.0; + MARKETING_VERSION = 26.8.0; PRODUCT_BUNDLE_IDENTIFIER = ly.count.CountlyiOSSDK; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/ios/src/Countly.xcodeproj/xcshareddata/xcschemes/Countly.xcscheme b/ios/src/Countly.xcodeproj/xcshareddata/xcschemes/Countly.xcscheme index 0fb62e3c..1d112bff 100644 --- a/ios/src/Countly.xcodeproj/xcshareddata/xcschemes/Countly.xcscheme +++ b/ios/src/Countly.xcodeproj/xcshareddata/xcschemes/Countly.xcscheme @@ -34,6 +34,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + enableThreadSanitizer = "YES" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" diff --git a/ios/src/Countly.xcodeproj/xcshareddata/xcschemes/CountlyTests.xcscheme b/ios/src/Countly.xcodeproj/xcshareddata/xcschemes/CountlyTests.xcscheme index a0e59595..85dfe858 100644 --- a/ios/src/Countly.xcodeproj/xcshareddata/xcschemes/CountlyTests.xcscheme +++ b/ios/src/Countly.xcodeproj/xcshareddata/xcschemes/CountlyTests.xcscheme @@ -35,6 +35,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + enableThreadSanitizer = "YES" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" diff --git a/ios/src/CountlyAPMConfig.m b/ios/src/CountlyAPMConfig.m index 05a0293b..2c08dc39 100644 --- a/ios/src/CountlyAPMConfig.m +++ b/ios/src/CountlyAPMConfig.m @@ -28,11 +28,4 @@ - (long long)getAppStartTimestampOverride { return appLoadStartTimeOverride; } - -- (void)enableAPMInternal:(BOOL)enableAPM -{ - self.enableForegroundBackgroundTracking = enableAPM; - self.enableAppStartTimeTracking = enableAPM; - self.enableManualAppLoadedTrigger = enableAPM; -} @end diff --git a/ios/src/CountlyCommon.h b/ios/src/CountlyCommon.h index 42b4e1ad..08fe336e 100644 --- a/ios/src/CountlyCommon.h +++ b/ios/src/CountlyCommon.h @@ -32,14 +32,20 @@ #import "CountlyContentBuilderInternal.h" #import "CountlyExperimentalConfig.h" #import "CountlyHealthTracker.h" +#import "CountlyConnectionTest.h" +//NOTE: Error and Warning call through unconditionally, because CountlyInternalLog also increments the +//health tracker's error and warning counters, which must keep counting even when logging is disabled. #define CLY_LOG_E(fmt, ...) CountlyInternalLog(CLYInternalLogLevelError, fmt, ##__VA_ARGS__) #define CLY_LOG_W(fmt, ...) CountlyInternalLog(CLYInternalLogLevelWarning, fmt, ##__VA_ARGS__) -#define CLY_LOG_I(fmt, ...) CountlyInternalLog(CLYInternalLogLevelInfo, fmt, ##__VA_ARGS__) -#define CLY_LOG_D(fmt, ...) CountlyInternalLog(CLYInternalLogLevelDebug, fmt, ##__VA_ARGS__) -#define CLY_LOG_V(fmt, ...) CountlyInternalLog(CLYInternalLogLevelVerbose, fmt, ##__VA_ARGS__) -#if (TARGET_OS_IOS) +//NOTE: Info, Debug and Verbose are guarded so their arguments are not evaluated when nobody wants the line. +//Gated on 'wanted', not 'enabled': log gathering has to receive these lines while console logging is off. +#define CLY_LOG_I(fmt, ...) do { if (CountlyInternalLogIsWanted(CLYInternalLogLevelInfo)) CountlyInternalLog(CLYInternalLogLevelInfo, fmt, ##__VA_ARGS__); } while (0) +#define CLY_LOG_D(fmt, ...) do { if (CountlyInternalLogIsWanted(CLYInternalLogLevelDebug)) CountlyInternalLog(CLYInternalLogLevelDebug, fmt, ##__VA_ARGS__); } while (0) +#define CLY_LOG_V(fmt, ...) do { if (CountlyInternalLogIsWanted(CLYInternalLogLevelVerbose)) CountlyInternalLog(CLYInternalLogLevelVerbose, fmt, ##__VA_ARGS__); } while (0) + +#if (TARGET_OS_IOS || TARGET_OS_VISION) #import #import #endif @@ -80,12 +86,12 @@ 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; @property (nonatomic, weak) id loggerDelegate; @property (nonatomic) CLYInternalLogLevel internalLogLevel; -@property (nonatomic, copy) NSString* attributionID; @property (nonatomic) BOOL manualSessionHandling; @property (nonatomic) BOOL enableManualSessionControlHybridMode; @property (nonatomic) BOOL enableOrientationTracking; @@ -95,10 +101,26 @@ extern NSString* const kCountlySDKName; @property (nonatomic) NSUInteger maxKeyLength; @property (nonatomic) NSUInteger maxValueLength; +@property (nonatomic) NSUInteger maxValueLengthPicture; @property (nonatomic) NSUInteger maxSegmentationValues; +/// Counts the device ID changes made without merge this run, so work started for one user can tell that it now belongs to another. +@property (atomic) NSUInteger deviceIDChangeWithoutMergeCount; +/// Counts the device ID changes made with merge this run. The server cancels the connection test it armed for a merged user. +@property (atomic) NSUInteger deviceIDMergeCount; + void CountlyInternalLog(CLYInternalLogLevel level, NSString *format, ...) NS_FORMAT_FUNCTION(2, 3); +/// YES when console output or the logger delegate would show a line of this level. Gate expensive dumps with this, it says nothing about log gathering. +BOOL CountlyInternalLogIsEnabled(CLYInternalLogLevel level); +/// YES when anything at all wants a line of this level: a developer facing sink, or the log gathering capture. +BOOL CountlyInternalLogIsWanted(CLYInternalLogLevel level); +/// Stops log gathering capture for the rest of the process, so the threads that log while a crash is reported never wait +/// for a gathering lock the crashing thread may hold. Async signal safe. +void CountlySDKLogsStopCaptureForCrash(void); void CountlyPrint(NSString *stringToPrint); +NSString* CountlyJSONFromObject(id object); +/// Runs the block right away when called on the main thread, so the state it reads is the state at the call, and queues it there otherwise. +void CountlyRunOnMainThread(dispatch_block_t block); + (instancetype)sharedInstance; - (NSInteger)hourOfDay; @@ -115,6 +137,12 @@ void CountlyPrint(NSString *stringToPrint); - (UIViewController *)topViewController; - (void)tryPresentingViewController:(UIViewController *)viewController; - (void)tryPresentingViewController:(UIViewController *)viewController withCompletion:(void (^ __nullable) (void))completion; ++ (UIWindow *)keyWindow; ++ (CGRect)screenBounds; +#endif + +#if (TARGET_OS_IOS || TARGET_OS_VISION) +- (UIInterfaceOrientation)interfaceOrientation; #endif - (void)observeDeviceOrientationChanges; @@ -125,11 +153,59 @@ void CountlyPrint(NSString *stringToPrint); - (NSURLSession *)URLSession; +- (NSURLSession *)ImmediateURLSession; + +/// Creates a task on the immediate session. A header or configuration change can drop the session kept while certificates are pinned at any time, and creating a task on a dropped session raises, so the task is created under the lock that drops it. +- (NSURLSessionDataTask *)immediateDataTaskWithRequest:(NSURLRequest *)request completionHandler:(void (^)(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error))completionHandler; + +/// Drops the immediate session kept while certificates are pinned, so the next immediate request builds one from the current URL session configuration. Requests in flight finish first. +- (void)invalidatePinnedImmediateURLSession; + - (CGSize)getWindowSize; + +/// Applies a live 'lg' directive. Enabling adopts the held lines, disabling or a different gather id drops them. +- (void)updateLogGatheringState:(BOOL)enabled levels:(nullable NSString *)levels batch:(NSInteger)batch lgid:(nullable NSString *)lgid; + +/// Decides against gathering when no directive has been seen yet and none can arrive this run (failed fetch, temporary device id, updates disabled). A running gather is left alone. +- (void)decideLogGatheringOffIfUndecided:(NSString *)reason; + +/// Uploads every held line now, partial batch included, on the calling thread, also while another batch waits in the request queue. Background, terminate and a device ID change call this, since the held lines live in memory only. +- (void)flushSdkLogs; + +/// Uploads one batch, partial included, on the delivery queue, and waits for the request queue to send it before the next. The timer tick and init end call this. +- (void)scheduleSdkLogsFlush; + +/// Moves held lines between the buffer and the awaiting list to match the current consent, keeping capture order. +- (void)redecideSdkLogLinesConsent; + +/// Counts the lines of a queued 'sdk_logs' request the queue had to drop into the next batch's drop count, when it belongs to the running gather. +- (void)countEvictedSdkLogRequest:(NSString *)queryString; + +/// Called before the device ID changes without merge. Uploads what consent allows under the current device ID, then stops gathering and drops the rest, since the gather belongs to the current user. +- (void)stopSdkLogGatheringForDeviceIDChange; +/// Lets log gathering capture again from the first line of a start, after halt paused it so the lines the app logged +/// between the halt and this start are not gathered for the user who starts now. +- (void)resumeSdkLogCaptureForStart; + +/// YES while lines are still being held or gathered, so log calls can skip formatting when nothing wants them. +- (BOOL)isCapturingSdkLogs; + +/// YES while lines of this level are still being held or gathered, so log calls of the levels a gather leaves out skip formatting. +- (BOOL)isCapturingSdkLogLevel:(CLYInternalLogLevel)level; + +/// Marks the calling thread as sending a gathered batch, so what it logs meanwhile is not gathered again. +- (void)setSdkLogsTransportWork:(BOOL)transporting; + +/// Whether the calling thread is currently marked as transport work. +- (BOOL)isSdkLogsTransportWork; + +// the capture funnel, called from CountlyInternalLog above the console logging gates. Declared here +// so tests can feed it exact lines instead of going through a variadic C function +- (void)captureSdkLogLine:(NSString *)logString level:(char)levelChar; @end -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) @interface CLYInternalViewController : UIViewController @property (nonatomic, weak) WKWebView* webView; @end @@ -154,6 +230,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) @@ -173,13 +250,27 @@ void CountlyPrint(NSString *stringToPrint); @end @interface Countly (RecordReservedEvent) +/// The segmentation the behavior settings let through, for log lines that log gathering may upload. +- (NSDictionary *)loggableSegmentation:(NSDictionary *)segmentation eventKey:(NSString *)key; - (void)recordReservedEvent:(NSString *)key segmentation:(NSDictionary *)segmentation; - (void)recordReservedEvent:(NSString *)key segmentation:(NSDictionary *)segmentation ID:(NSString *)ID; - (void)recordReservedEvent:(NSString *)key segmentation:(NSDictionary *)segmentation count:(NSUInteger)count sum:(double)sum duration:(NSTimeInterval)duration ID:(NSString *)ID timestamp:(NSTimeInterval)timestamp; @end +@interface Countly (AutomaticSessionTracking) +/// Ends a session the SDK started once automatic session tracking is off, and starts one in the foreground once it is on. Main thread only. +- (void)applyAutomaticSessionTracking; +@end + @interface CountlyUserDetails (ClearUserDetails) - (void)clearUserDetails; @end +#if (TARGET_OS_IOS || TARGET_OS_VISION) +@interface CountlyContentConfig (ZoneTimerIntervalRestore) +/// Sets the zone timer interval without the lower bound of the public setter, so the unset value 0 can be put back. +- (void)restoreZoneTimerInterval:(NSUInteger)zoneTimerIntervalSeconds; +@end +#endif + NS_ASSUME_NONNULL_END diff --git a/ios/src/CountlyCommon.m b/ios/src/CountlyCommon.m index 280d26fd..a2cf899f 100644 --- a/ios/src/CountlyCommon.m +++ b/ios/src/CountlyCommon.m @@ -6,17 +6,62 @@ #import "CountlyCommon.h" #include +#include NSString* const kCountlyReservedEventOrientation = @"[CLY]_orientation"; NSString* const kCountlyOrientationKeyMode = @"mode"; NSString* const kCountlyVisibility = @"cly_v"; +NSString* const kCountlyQSSdkLogsMessage = @"m"; +NSString* const kCountlyQSSdkLogsLevel = @"l"; +NSString* const kCountlyQSSDKLogsTimestamp = @"t"; +NSString* const kCountlyQSSDKLogsGatherId = @"i"; +NSString* const kCountlyQSSDKLogsDropCount = @"d"; +NSString* const kCountlyQSSDKLogsBatchLines = @"l"; + +// undecided is the state the SDK starts in: it gathers on the chance that a directive will adopt the +// lines, which is the only way the init logs can ever be delivered +typedef NS_ENUM(NSInteger, CLYSDKLogsState) +{ + CLYSDKLogsStateUndecided = 0, + CLYSDKLogsStateGathering, + CLYSDKLogsStateOff, +}; + +static const NSInteger kCountlySDKLogsMaxMessageLength = 4096; +// How many lines are held at most, and so also the largest batch the server may ask for: a batch +// bigger than the buffer would never fill, and the SDK would drop lines forever without flushing. +// The buffer only reaches this while nothing is decided yet, since every captured line drains a +// whole batch once gathering is on. It is also what the ingestor takes in one upload +static const NSInteger kCountlySDKLogsMaxBufferedLines = 500; +static const NSInteger kCountlySDKLogsDefaultBatchSize = 100; +// a batch of one or two would upload a request per log line, and the lines the upload itself logs +// would keep it going. The server clamps to this too, nothing else stops a malformed directive +static const NSInteger kCountlySDKLogsMinBatchSize = 10; +// second ceiling, on the characters held: a line may be 4096 long, so a line count alone does not bound memory +static const NSInteger kCountlySDKLogsCharCeiling = 128 * 1024; +// long lines reach the character ceiling before a batch fills, so from here the buffer goes out as a partial batch +static const NSInteger kCountlySDKLogsDeliveryCharThreshold = kCountlySDKLogsCharCeiling * 3 / 4; +static NSString* const kCountlySDKLogsAllLevels = @"ewidv"; +// gathering the log line that carries a batch would nest each batch inside the next one +static NSString* const kCountlySDKLogsTransportMarker = @"sdk_logs"; +// the consent getter a line needs, NSNull for a line that needs both events and user details. Never part of an uploaded line +static NSString* const kCountlySDKLogsAwaitedConsent = @"consent"; +// the characters of kCountlySDKLogsAllLevels, in its order, which indexes the drops counted while undecided +static const char kCountlySDKLogsLevelChars[] = "ewidv"; +// a thread local and not the thread dictionary, since every captured line reads it +static __thread BOOL s_isSdkLogsTransportWork = NO; +static atomic_bool s_isSdkLogCaptureStoppedForCrash = false; +// set by halt until the next start, so what the app logs between a logout and the next user's start is not gathered for that user +static atomic_bool s_isSdkLogCapturePausedUntilStart = false; @interface CountlyCommon () { NSCalendar* gregorianCalendar; NSTimeInterval startTime; + // lines dropped while undecided, per level: only the levels the adopted directive asks for become part of 'd' + NSInteger sdkLogsUndecidedDrops[sizeof(kCountlySDKLogsLevelChars) - 1]; } @property long long lastTimestamp; @@ -27,9 +72,54 @@ @interface CountlyCommon () #if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV ) @property (nonatomic) UIBackgroundTaskIdentifier bgTask; #endif + +@property (nonatomic) NSMutableArray* sdkLogs; +// atomic: read on every log call without the lock, written under it +@property (atomic) CLYSDKLogsState sdkLogsState; +@property (nonatomic) NSInteger sdkLogsBatchSize; +@property (nonatomic) NSString *sdkLogsLevels; +// the bits of sdkLogsLevels, kept by its setter. Atomic: log calls read it without the lock to skip unwanted levels +@property (atomic) NSUInteger sdkLogsLevelMask; +@property (nonatomic) NSString *sdkLogsGatheringId; +@property (nonatomic) NSInteger sdkLogsDropCount; +@property (nonatomic) NSInteger sdkLogsChars; +// lines whose feature has no consent yet, kept apart from the buffer so they can never be uploaded before it is given +@property (nonatomic) NSMutableArray* sdkLogsAwaitingConsent; +@property (nonatomic) NSInteger sdkLogsAwaitingConsentChars; +// a lock of its own rather than the array, so every piece of gathering state shares one monitor +@property (nonatomic) NSObject* sdkLogsLock; +// batch-full deliveries run here and never on the capturing thread, which may be inside a persistency lock +@property (nonatomic) dispatch_queue_t sdkLogsDeliveryQueue; +// set while a delivery waits for the request queue to send the log batch it holds, so it waits once +@property (nonatomic) BOOL sdkLogsDeliveryAwaitsQueueDrain; +@property (nonatomic) NSURLSession* pinnedImmediateURLSession; @end -NSString* const kCountlySDKVersion = @"26.1.1"; +/// The level character a gathered line carries for a log level. +static char CountlySDKLogsLevelChar(CLYInternalLogLevel level) +{ + static const char levelChars[] = {'n', 'e', 'w', 'i', 'd', 'v'}; + return level < sizeof(levelChars) ? levelChars[level] : 'n'; +} + +/// The bit of a level character in the gathered level mask, 0 for a character outside kCountlySDKLogsAllLevels. +static NSUInteger CountlySDKLogsLevelBit(char levelChar) +{ + const char* found = levelChar ? strchr(kCountlySDKLogsLevelChars, levelChar) : NULL; + return found ? (NSUInteger)1 << (found - kCountlySDKLogsLevelChars) : 0; +} + +/// The level mask of a level string. +static NSUInteger CountlySDKLogsLevelMask(NSString* levels) +{ + NSUInteger mask = 0; + for (NSUInteger i = 0; i < levels.length; i++) + mask |= CountlySDKLogsLevelBit((char)[levels characterAtIndex:i]); + + return mask; +} + +NSString* const kCountlySDKVersion = @"26.8.0"; NSString* const kCountlySDKName = @"objc-native-ios"; NSString* const kCountlyErrorDomain = @"ly.count.ErrorDomain"; @@ -59,24 +149,56 @@ - (instancetype)init self.lastTimestamp = 0; self.SDKVersion = kCountlySDKVersion; self.SDKName = kCountlySDKName; + + self.sdkLogs = NSMutableArray.new; + self.sdkLogsAwaitingConsent = NSMutableArray.new; + self.sdkLogsState = atomic_load(&s_isSdkLogCapturePausedUntilStart) ? CLYSDKLogsStateOff : CLYSDKLogsStateUndecided; + self.sdkLogsBatchSize = kCountlySDKLogsDefaultBatchSize; + self.sdkLogsLevels = kCountlySDKLogsAllLevels; + self.sdkLogsGatheringId = @""; + self.sdkLogsLock = NSObject.new; + self.sdkLogsDeliveryQueue = dispatch_queue_create("ly.count.sdklogs.delivery", DISPATCH_QUEUE_SERIAL); } return self; } - (void)resetInstance { - CLY_LOG_I(@"%s", __FUNCTION__); -#if (TARGET_OS_IOS || TARGET_OS_VISION ) + CLY_LOG_I(@"%s resetting shared state, sdkVersion: [%@], sdkName: [%@]", __FUNCTION__, kCountlySDKVersion, kCountlySDKName); +#if (TARGET_OS_IOS) [NSNotificationCenter.defaultCenter removeObserver:self name:UIDeviceOrientationDidChangeNotification object:nil]; #endif _hasStarted = false; + _hasFinishedInit = false; + // `startWithConfig:` only ever sets this flag to YES, so without clearing it here it + // would survive a `halt:` and leak visibility segmentation into the next start. + _enableVisibiltyTracking = NO; _maxKeyLength = kCountlyMaxKeyLength; _maxValueLength = kCountlyMaxValueSize; + _maxValueLengthPicture = kCountlyMaxValueSizePicture; _maxSegmentationValues = kCountlyMaxSegmentationValues; + //under the lock like every other write to it: a reset races the log lines that other threads are + //still capturing, and mutating the array from two threads at once crashes + atomic_store(&s_isSdkLogCapturePausedUntilStart, true); + @synchronized (_sdkLogsLock) { + [self setLogGatheringOffLocked]; + } + [self invalidatePinnedImmediateURLSession]; onceToken = 0; s_sharedInstance = nil; } +- (void)resumeSdkLogCaptureForStart +{ + if (!atomic_exchange(&s_isSdkLogCapturePausedUntilStart, false)) + return; + + @synchronized (_sdkLogsLock) { + if (_sdkLogsState == CLYSDKLogsStateOff) + _sdkLogsState = CLYSDKLogsStateUndecided; + } +} + - (BOOL)hasStarted { @@ -92,6 +214,20 @@ - (BOOL)hasStarted_ return _hasStarted; } +BOOL CountlyInternalLogIsEnabled(CLYInternalLogLevel level) +{ + CountlyCommon* common = CountlyCommon.sharedInstance; + if (!common.enableDebug && !common.loggerDelegate) + return NO; + + return level <= common.internalLogLevel; +} + +BOOL CountlyInternalLogIsWanted(CLYInternalLogLevel level) +{ + return CountlyInternalLogIsEnabled(level) || [CountlyCommon.sharedInstance isCapturingSdkLogLevel:level]; +} + void CountlyInternalLog(CLYInternalLogLevel level, NSString *format, ...) { if (level == CLYInternalLogLevelError) { @@ -99,17 +235,26 @@ void CountlyInternalLog(CLYInternalLogLevel level, NSString *format, ...) } else if(level == CLYInternalLogLevelWarning) { [CountlyHealthTracker.sharedInstance logWarning]; } - - if (!CountlyCommon.sharedInstance.enableDebug && !CountlyCommon.sharedInstance.loggerDelegate) - return; - if (level > CountlyCommon.sharedInstance.internalLogLevel) + CountlyCommon* common = CountlyCommon.sharedInstance; + // gathering has to work while console logging is off, but when nothing wants the line it must not be formatted at all + BOOL wantsConsole = CountlyInternalLogIsEnabled(level); + BOOL wantsCapture = [common isCapturingSdkLogLevel:level]; + if (!wantsConsole && !wantsCapture) return; va_list args; va_start(args, format); - NSString* logString = [NSString.alloc initWithFormat:format arguments:args]; + va_end(args); + + // before the level prefix is added: a gathered line carries its level in its own field, and the + // dashboard renders that, so a prefix here would only be the same level a second time + if (wantsCapture) + [common captureSdkLogLine:logString level:CountlySDKLogsLevelChar(level)]; + + if (!wantsConsole) + return; NSArray *logLevelPrefixes = @[ @@ -124,17 +269,866 @@ void CountlyInternalLog(CLYInternalLogLevel level, NSString *format, ...) logString = [NSString stringWithFormat:@"[%@] %@", logLevelPrefixes[level], logString]; #if DEBUG - if (CountlyCommon.sharedInstance.enableDebug) + if (common.enableDebug) CountlyPrint(logString); #endif - if ([CountlyCommon.sharedInstance.loggerDelegate respondsToSelector:@selector(internalLog:withLevel:)]) + if ([common.loggerDelegate respondsToSelector:@selector(internalLog:withLevel:)]) { NSString* logStringWithPrefix = [NSString stringWithFormat:@"%@%@", kCountlyInternalLogPrefix, logString]; - [CountlyCommon.sharedInstance.loggerDelegate internalLog:logStringWithPrefix withLevel:level]; + [common.loggerDelegate internalLog:logStringWithPrefix withLevel:level]; } +} - va_end(args); +- (BOOL)isCapturingSdkLogs +{ + return self.sdkLogsState != CLYSDKLogsStateOff && !atomic_load(&s_isSdkLogCaptureStoppedForCrash); +} + +void CountlySDKLogsStopCaptureForCrash(void) +{ + atomic_store(&s_isSdkLogCaptureStoppedForCrash, true); +} + +- (BOOL)isCapturingSdkLogLevel:(CLYInternalLogLevel)level +{ + return self.isCapturingSdkLogs && (self.sdkLogsLevelMask & CountlySDKLogsLevelBit(CountlySDKLogsLevelChar(level))) != 0; +} + +/// Sets the gathered levels and the mask that log calls read without taking the lock. +- (void)setSdkLogsLevels:(NSString *)sdkLogsLevels +{ + _sdkLogsLevels = sdkLogsLevels; + self.sdkLogsLevelMask = CountlySDKLogsLevelMask(sdkLogsLevels); +} + +/// The CountlyConsentManager getter a gathered line needs, keyed by the class that writes it, or by "Class selector" where one class serves several features. +static NSDictionary* CountlySDKLogsConsentByOwner(void) +{ + static NSDictionary* consentByOwner; + static dispatch_once_t once; + dispatch_once(&once, ^{ + NSString* sessions = NSStringFromSelector(@selector(consentForSessions)); + NSString* events = NSStringFromSelector(@selector(consentForEvents)); + NSString* users = NSStringFromSelector(@selector(consentForUserDetails)); + NSString* crashes = NSStringFromSelector(@selector(consentForCrashReporting)); + NSString* push = NSStringFromSelector(@selector(consentForPushNotifications)); + NSString* location = NSStringFromSelector(@selector(consentForLocation)); + NSString* views = NSStringFromSelector(@selector(consentForViewTracking)); + NSString* attribution = NSStringFromSelector(@selector(consentForAttribution)); + NSString* apm = NSStringFromSelector(@selector(consentForPerformanceMonitoring)); + NSString* feedback = NSStringFromSelector(@selector(consentForFeedback)); + NSString* remoteConfig = NSStringFromSelector(@selector(consentForRemoteConfig)); + NSString* content = NSStringFromSelector(@selector(consentForContent)); + NSString* metrics = NSStringFromSelector(@selector(consentForMetrics)); + + consentByOwner = @{ + @"CountlyViewTracking": views, + @"CountlyViewTrackingInternal": views, + @"CountlyViewData": views, + @"CountlyUserDetails": users, + @"CountlyCrashReporter": crashes, + @"CountlyCrashData": crashes, + @"CountlyExceptionHandler": crashes, + @"CountlyPushNotifications": push, + @"CountlyLocationManager": location, + @"CountlyPerformanceMonitoring": apm, + @"CountlyFeedbacks": feedback, + @"CountlyFeedbacksInternal": feedback, + @"CountlyFeedbackWidget": feedback, + @"CountlyRemoteConfig": remoteConfig, + @"CountlyRemoteConfigInternal": remoteConfig, + @"CountlyRCData": remoteConfig, + @"CountlyExperimentInformation": remoteConfig, + @"CountlyContentBuilder": content, + @"CountlyContentBuilderInternal": content, + + @"Countly recordEvent": events, + @"Countly recordReservedEvent": events, + @"Countly isReservedEvent": events, + @"Countly processSegmentation": events, + @"Countly journeyTriggerCallback": events, + @"Countly startEvent": events, + @"Countly endEvent": events, + @"Countly cancelEvent": events, + @"Countly beginSession": sessions, + @"Countly updateSession": sessions, + @"Countly endSession": sessions, + @"Countly recordMetrics": metrics, + @"Countly askForNotificationPermission": push, + @"Countly askForNotificationPermissionWithOptions": push, + @"Countly recordActionForNotification": push, + @"Countly recordPushNotificationToken": push, + @"Countly clearPushNotificationToken": push, + @"Countly recordLocation": location, + @"Countly disableLocationInfo": location, + @"Countly recordException": crashes, + @"Countly recordError": crashes, + @"Countly recordCrashLog": crashes, + @"Countly clearCrashLogs": crashes, + @"Countly crashLog": crashes, + @"Countly askForStarRating": feedback, + @"Countly presentRatingWidgetWithID": feedback, + @"Countly recordRatingWidgetWithID": feedback, + @"Countly recordDirectAttributionWithCampaignType": attribution, + @"Countly recordIndirectAttribution": attribution, + @"Countly recordNetworkTrace": apm, + @"Countly startCustomTrace": apm, + @"Countly endCustomTrace": apm, + @"Countly cancelCustomTrace": apm, + @"Countly clearAllCustomTraces": apm, + @"Countly appLoadingFinished": apm, + }; + }); + + return consentByOwner; +} + +/// Whether a character can be part of a class or selector name in __FUNCTION__. +static BOOL CountlySDKLogsIsNameCharacter(unichar character) +{ + return (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || (character >= '0' && character <= '9') || character == '_'; +} + +/// The consent getter a gathered line needs, or nil for a line no single feature owns, such as a queued request or a stored state. +/// Every line opens with __FUNCTION__ ("-[Class selector]", "+[Class selector]", "-[Class(Category) selector]", "__12-[Class selector]_block_invoke" +/// in a block, or the bare name of a C function) or with "[Class]". +static NSString* CountlySDKLogsConsentForLine(NSString* line) +{ + // a character scan rather than a regular expression, since this runs for every captured line + unichar prefix[128]; + NSUInteger length = MIN(line.length, sizeof(prefix) / sizeof(prefix[0])); + [line getCharacters:prefix range:NSMakeRange(0, length)]; + + NSUInteger index = 0; + if (length > 2 && prefix[0] == '_' && prefix[1] == '_') + { + index = 2; + while (index < length && prefix[index] >= '0' && prefix[index] <= '9') + index++; + + if (index == 2) + return nil; + } + + if (index < length && (prefix[index] == '-' || prefix[index] == '+')) + index++; + + NSDictionary* consentByOwner = CountlySDKLogsConsentByOwner(); + if (index == 0 && length > 0 && prefix[0] != '[') + { + // a C function, such as the crash handler, opens with its bare name + while (index < length && CountlySDKLogsIsNameCharacter(prefix[index])) + index++; + + return index > 0 ? consentByOwner[[NSString stringWithCharacters:prefix length:index]] : nil; + } + + if (index >= length || prefix[index] != '[') + return nil; + + NSUInteger classStart = ++index; + while (index < length && CountlySDKLogsIsNameCharacter(prefix[index])) + index++; + + if (index == classStart) + return nil; + + NSString* className = [NSString stringWithCharacters:prefix + classStart length:index - classStart]; + // a method a category adds to a system class, such as the swizzled push and view callbacks, belongs to the module + // the category is named after + if (index < length && prefix[index] == '(') + { + NSUInteger categoryStart = ++index; + while (index < length && CountlySDKLogsIsNameCharacter(prefix[index])) + index++; + + if (index > categoryStart) + className = [NSString stringWithCharacters:prefix + categoryStart length:index - categoryStart]; + + if (index < length && prefix[index] == ')') + index++; + } + + // the Countly class is the only owner keyed by selector, it serves every feature through its public methods + if ([className isEqualToString:@"Countly"] && index < length && prefix[index] == ' ') + { + NSUInteger selectorStart = ++index; + while (index < length && CountlySDKLogsIsNameCharacter(prefix[index])) + index++; + + if (index > selectorStart) + { + NSString* selectorName = [NSString stringWithCharacters:prefix + selectorStart length:index - selectorStart]; + NSString* methodConsent = consentByOwner[[@"Countly " stringByAppendingString:selectorName]]; + if (methodConsent) + return methodConsent; + } + } + + return consentByOwner[className]; +} + +/// The one character level string of a gathered line. A boxed char would go on the wire as a number, @('e') as 101. +static NSString* CountlySDKLogsLevelString(char levelChar) +{ + switch (levelChar) + { + case 'e': return @"e"; + case 'w': return @"w"; + case 'i': return @"i"; + case 'd': return @"d"; + case 'v': return @"v"; + default: return [NSString stringWithFormat:@"%c", levelChar]; + } +} + +/// The message of a gathered line, cut to the longest message the server keeps. +static NSString* CountlySDKLogsTrimmedMessage(NSString* logString) +{ + if (logString.length <= kCountlySDKLogsMaxMessageLength) + return logString; + + // never split a surrogate pair, a lone surrogate would make the whole batch unserialisable + NSRange boundary = [logString rangeOfComposedCharacterSequenceAtIndex:kCountlySDKLogsMaxMessageLength]; + return [logString substringToIndex:boundary.location]; +} + +/// Whether a line needing the given consent getter may be gathered now. Always when consent is not required, never before +/// the SDK has started, and a line of no single feature (NSNull) needs both the events and the user details consent. +static BOOL CountlySDKLogsConsentGiven(id consent) +{ + CountlyConsentManager* consentManager = CountlyConsentManager.sharedInstance; + if (!consentManager) + return NO; + + if (!consentManager.requiresConsent) + return YES; + + if (![consent isKindOfClass:NSString.class]) + return consentManager.consentForEvents && consentManager.consentForUserDetails; + + return [[consentManager valueForKey:consent] boolValue]; +} + +/// A copy of the line carrying the consent it needs, or, with nil, the line as it is uploaded. +static NSDictionary* CountlySDKLogsLineWithConsent(NSDictionary* line, id consent) +{ + NSMutableDictionary* copy = line.mutableCopy; + copy[kCountlySDKLogsAwaitedConsent] = consent; + return copy; +} + +/// The batch as it goes on the wire, its lines without the consent they were gathered under. +static NSDictionary* CountlySDKLogsUploadableBatch(NSDictionary* batch) +{ + NSMutableArray* lines = NSMutableArray.new; + for (NSDictionary* line in batch[kCountlyQSSDKLogsBatchLines]) + [lines addObject:CountlySDKLogsLineWithConsent(line, nil)]; + + NSMutableDictionary* uploadable = batch.mutableCopy; + uploadable[kCountlyQSSDKLogsBatchLines] = lines; + return uploadable; +} + +/// Marks init as finished or not. Finishing decides the consent of the lines captured while init ran, under the lock the +/// capture reads this flag in, so every line is decided either here or when it is captured. +- (void)setHasFinishedInit:(BOOL)hasFinishedInit +{ + @synchronized (_sdkLogsLock) { + _hasFinishedInit = hasFinishedInit; + if (hasFinishedInit) + [self redecideSdkLogLinesConsentLocked]; + } +} + +- (void)captureSdkLogLine:(NSString *)logString level:(char)levelChar { + if (!self.isCapturingSdkLogs) + return; + + // this thread is delivering or sending a batch, so anything it logs is this feature's own transport commentary + if (s_isSdkLogsTransportWork) + return; + + // checked on every thread, because a queued batch is sent, and logged in full, by whichever + // thread happens to drain the request queue later on + if ([logString rangeOfString:kCountlySDKLogsTransportMarker options:NSLiteralSearch].location != NSNotFound) + return; + + // while nothing is decided the wanted levels are unknown, so every level is gathered and the + // buffer is filtered again once a directive names them. Checked again under the lock + if (!(self.sdkLogsLevelMask & CountlySDKLogsLevelBit(levelChar))) + return; + + // built before taking the lock, which every logging thread contends for + NSString* message = CountlySDKLogsTrimmedMessage(logString); + // every line keeps its consent so a later withdrawal can send it back to wait, init lines are decided when init finishes + id consent = CountlySDKLogsConsentForLine(message) ?: NSNull.null; + NSDictionary* line = @{ + kCountlyQSSDKLogsTimestamp: @((long long)floor((CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) * 1000)), + kCountlyQSSdkLogsMessage: message, + kCountlyQSSdkLogsLevel: CountlySDKLogsLevelString(levelChar), + kCountlySDKLogsAwaitedConsent: consent + }; + + BOOL isDeliveryDue = NO; + BOOL includePartialBatch = NO; + + @synchronized (_sdkLogsLock) { + if (_sdkLogsState == CLYSDKLogsStateOff) + return; + + if (!(_sdkLogsLevelMask & CountlySDKLogsLevelBit(levelChar))) + return; + + if (_hasFinishedInit && !CountlySDKLogsConsentGiven(consent)) + { + [_sdkLogsAwaitingConsent addObject:line]; + _sdkLogsAwaitingConsentChars += message.length; + [self trimSdkLogsAwaitingConsentLocked]; + return; + } + + NSInteger bufferedBefore = (NSInteger)_sdkLogs.count; + NSInteger charsBefore = _sdkLogsChars; + [_sdkLogs addObject:line]; + _sdkLogsChars += message.length; + [self trimSdkLogsBufferLocked]; + + isDeliveryDue = [self isSdkLogsDeliveryDueLockedFromCount:bufferedBefore chars:charsBefore includePartialBatch:&includePartialBatch]; + } + + // never on this thread: delivering touches the request queue and logs, which comes straight back + // into this method, and the caller may be inside a persistency lock + if (isDeliveryDue) + [self scheduleSdkLogsDelivery:includePartialBatch]; +} + +/// Characters of one gathered line's message, the unit the character ceiling counts. +static NSInteger CountlySDKLogsLineLength(NSDictionary* line) +{ + return (NSInteger)((NSString *)line[kCountlyQSSdkLogsMessage]).length; +} + +/// Characters of the messages of all the given lines. +static NSInteger CountlySDKLogsLength(NSArray* lines) +{ + NSInteger chars = 0; + for (NSDictionary* line in lines) + chars += CountlySDKLogsLineLength(line); + + return chars; +} + +/// Keeps only the lines of the given levels and returns the characters of what is kept. +static NSInteger CountlySDKLogsKeepLevels(NSMutableArray* lines, NSString* levels) +{ + [lines filterUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(NSDictionary* line, NSDictionary* bindings) { + return [levels containsString:line[kCountlyQSSdkLogsLevel]]; + }]]; + + return CountlySDKLogsLength(lines); +} + +/// Appends every line whose consent is given to buffered and every other line to awaiting, in the order of the given lines. +static void CountlySDKLogsSplitByConsent(NSArray* lines, NSMutableArray* buffered, NSMutableArray* awaiting) +{ + for (NSDictionary* line in lines) + { + if (CountlySDKLogsConsentGiven(line[kCountlySDKLogsAwaitedConsent])) + [buffered addObject:line]; + else + [awaiting addObject:line]; + } +} + +/// Drops the oldest lines until both the line and the character ceiling hold. While gathering they count into the drop count, +/// while undecided per level, so that only the levels a directive adopts are reported. Call under the lock. +- (void)trimSdkLogsBufferLocked +{ + while ((NSInteger)_sdkLogs.count > kCountlySDKLogsMaxBufferedLines || (_sdkLogsChars > kCountlySDKLogsCharCeiling && _sdkLogs.count > 1)) + { + NSDictionary* dropped = _sdkLogs.firstObject; + _sdkLogsChars -= CountlySDKLogsLineLength(dropped); + [_sdkLogs removeObjectAtIndex:0]; + + if (_sdkLogsState == CLYSDKLogsStateGathering) + _sdkLogsDropCount++; + else + [self countUndecidedSdkLogDropLocked:dropped]; + } +} + +/// Counts a line dropped while undecided under its level. Call under the lock. +- (void)countUndecidedSdkLogDropLocked:(NSDictionary *)line +{ + NSString* level = line[kCountlyQSSdkLogsLevel]; + const char* found = level.length == 1 ? strchr(kCountlySDKLogsLevelChars, (char)[level characterAtIndex:0]) : NULL; + if (found && *found) + sdkLogsUndecidedDrops[found - kCountlySDKLogsLevelChars]++; +} + +/// Adds the lines dropped while undecided to the drop count, for the given levels only, and clears every level's count. Call under the lock. +- (void)adoptUndecidedSdkLogDropsLocked:(NSString *)levels +{ + NSUInteger mask = CountlySDKLogsLevelMask(levels); + for (size_t i = 0; i < sizeof(sdkLogsUndecidedDrops) / sizeof(sdkLogsUndecidedDrops[0]); i++) + { + if (mask & ((NSUInteger)1 << i)) + _sdkLogsDropCount += sdkLogsUndecidedDrops[i]; + + sdkLogsUndecidedDrops[i] = 0; + } +} + +/// Drops the oldest lines awaiting consent until both ceilings hold. Call under the lock. +- (void)trimSdkLogsAwaitingConsentLocked +{ + // not counted into the drop count: a line that never had its consent was never gathered + while ((NSInteger)_sdkLogsAwaitingConsent.count > kCountlySDKLogsMaxBufferedLines || (_sdkLogsAwaitingConsentChars > kCountlySDKLogsCharCeiling && _sdkLogsAwaitingConsent.count > 1)) + { + _sdkLogsAwaitingConsentChars -= CountlySDKLogsLineLength(_sdkLogsAwaitingConsent.firstObject); + [_sdkLogsAwaitingConsent removeObjectAtIndex:0]; + } +} + +/// Whether the buffer grown from the given counts calls for a delivery, setting includePartialBatch when only the character threshold does. Call under the lock. +- (BOOL)isSdkLogsDeliveryDueLockedFromCount:(NSInteger)bufferedBefore chars:(NSInteger)charsBefore includePartialBatch:(BOOL *)includePartialBatch +{ + // only the line that completes a batch schedules a delivery, so a full buffer held back by tracking does not queue one per line + BOOL completesBatch = _sdkLogsState == CLYSDKLogsStateGathering && bufferedBefore < _sdkLogsBatchSize && (NSInteger)_sdkLogs.count >= _sdkLogsBatchSize; + BOOL reachesCharThreshold = !completesBatch && _sdkLogsState == CLYSDKLogsStateGathering && charsBefore < kCountlySDKLogsDeliveryCharThreshold && _sdkLogsChars >= kCountlySDKLogsDeliveryCharThreshold; + + *includePartialBatch = reachesCharThreshold; + return completesBatch || reachesCharThreshold; +} + +/// Moves lines into the buffer or the awaiting list by their consent, oldest first, and returns NO when none moved. Call under the lock. +- (BOOL)redecideSdkLogLinesConsentLocked +{ + NSMutableArray* buffered = NSMutableArray.new; + NSMutableArray* awaiting = NSMutableArray.new; + + CountlySDKLogsSplitByConsent(_sdkLogs, buffered, awaiting); + NSUInteger withdrawnCount = awaiting.count; + CountlySDKLogsSplitByConsent(_sdkLogsAwaitingConsent, buffered, awaiting); + + if (withdrawnCount == 0 && awaiting.count == _sdkLogsAwaitingConsent.count) + return NO; + + NSComparator byCaptureTime = ^NSComparisonResult(NSDictionary* a, NSDictionary* b) { + return [a[kCountlyQSSDKLogsTimestamp] compare:b[kCountlyQSSDKLogsTimestamp]]; + }; + [buffered sortWithOptions:NSSortStable usingComparator:byCaptureTime]; + [awaiting sortWithOptions:NSSortStable usingComparator:byCaptureTime]; + + [_sdkLogs setArray:buffered]; + _sdkLogsChars = CountlySDKLogsLength(_sdkLogs); + [_sdkLogsAwaitingConsent setArray:awaiting]; + _sdkLogsAwaitingConsentChars = CountlySDKLogsLength(_sdkLogsAwaitingConsent); + + [self trimSdkLogsBufferLocked]; + [self trimSdkLogsAwaitingConsentLocked]; + return YES; +} + +- (void)redecideSdkLogLinesConsent +{ + BOOL isDeliveryDue = NO; + BOOL includePartialBatch = NO; + + @synchronized (_sdkLogsLock) { + // the lines captured during init are decided when init finishes, the consent state is incomplete before that + if (!_hasFinishedInit) + return; + + NSInteger bufferedBefore = (NSInteger)_sdkLogs.count; + NSInteger charsBefore = _sdkLogsChars; + if (![self redecideSdkLogLinesConsentLocked]) + return; + + isDeliveryDue = [self isSdkLogsDeliveryDueLockedFromCount:bufferedBefore chars:charsBefore includePartialBatch:&includePartialBatch]; + } + + if (isDeliveryDue) + [self scheduleSdkLogsDelivery:includePartialBatch]; +} + +- (void)countEvictedSdkLogRequest:(NSString *)queryString +{ + NSString* payload = [queryString cly_valueForQueryStringKey:kCountlyQSSdkLogs]; + id batch = payload ? [NSJSONSerialization JSONObjectWithData:[payload cly_dataUTF8] options:0 error:nil] : nil; + if (![batch isKindOfClass:NSDictionary.class] || ![batch[kCountlyQSSDKLogsBatchLines] isKindOfClass:NSArray.class] || ![batch[kCountlyQSSDKLogsDropCount] isKindOfClass:NSNumber.class]) + return; + + [self countSdkLogBatchAsDropped:batch]; +} + +/// Queues a delivery run on the delivery queue. Nothing is uploaded before init finishes, the init lines go out from there. +- (void)scheduleSdkLogsDelivery:(BOOL)includePartialBatch +{ + if (!self.hasFinishedInit) + return; + + dispatch_async(self.sdkLogsDeliveryQueue, ^{ + [self deliverSdkLogBatches:includePartialBatch drainAll:NO]; + }); +} + +- (void)flushSdkLogs { + [self deliverSdkLogBatches:YES drainAll:YES]; +} + +- (void)scheduleSdkLogsFlush +{ + [self scheduleSdkLogsDelivery:YES]; +} + +- (void)stopSdkLogGatheringForDeviceIDChange +{ + if (!self.isCapturingSdkLogs) + return; + + // a delivery already running on the delivery queue would otherwise send the old user's lines under the new device ID + dispatch_semaphore_t delivered = dispatch_semaphore_create(0); + dispatch_async(self.sdkLogsDeliveryQueue, ^{ + dispatch_semaphore_signal(delivered); + }); + dispatch_semaphore_wait(delivered, dispatch_time(DISPATCH_TIME_NOW, (int64_t)NSEC_PER_SEC)); + + [self flushSdkLogs]; + [self updateLogGatheringState:NO levels:nil batch:0 lgid:nil]; +} + +/// Uploads held lines on the calling thread: one batch while no other waits in the request queue, or with drainAll every +/// held line. Lines stay buffered while tracking is off. +- (void)deliverSdkLogBatches:(BOOL)includePartialBatch drainAll:(BOOL)drainAll +{ + if (!self.hasFinishedInit) + return; + + // marks everything logged until the sends return as this feature's own transport work + BOOL callerTransportWork = self.isSdkLogsTransportWork; + [self setSdkLogsTransportWork:YES]; + [self deliverSdkLogBatchesMarked:includePartialBatch drainAll:drainAll]; + [self setSdkLogsTransportWork:callerTransportWork]; +} + +/// The delivery loop proper, run with the transport marker already set on this thread so its own lines are not gathered. +- (void)deliverSdkLogBatchesMarked:(BOOL)includePartialBatch drainAll:(BOOL)drainAll +{ + if (!CountlyServerConfig.sharedInstance.trackingEnabled) + { + // the request queue refuses everything while tracking is off, taking the lines out now would only lose them + CLY_LOG_D(@"%s, tracking is disabled, keeping the gathered lines buffered", __FUNCTION__); + return; + } + + if (!drainAll) + { + // one batch waits in the request queue at a time, so the lines held while requests fail stay in the capped + // buffer instead of crowding the queue + if ([CountlyPersistency.sharedInstance queueContainsSdkLogs]) + { + CLY_LOG_D(@"%s, a gathered log batch is still waiting in the request queue, the rest goes out once it is sent", __FUNCTION__); + [self resumeSdkLogsDeliveryWhenQueueDrains]; + return; + } + + // no batch waits, so a wait armed earlier and lost with its runnable must not hold up the next one + @synchronized (_sdkLogsLock) { + _sdkLogsDeliveryAwaitsQueueDrain = NO; + } + } + + BOOL takePartial = includePartialBatch; + NSDictionary* batch; + while ((batch = [self takeSdkLogBatch:takePartial])) + { + // draining takes the partial tail too, a single delivery takes only its first batch + takePartial = drainAll; + CLY_LOG_D(@"%s, delivering [ %lu ] gathered log lines, [ %@ ] dropped", __FUNCTION__, (unsigned long)((NSArray *)batch[kCountlyQSSDKLogsBatchLines]).count, batch[kCountlyQSSDKLogsDropCount]); + + NSString* payload = CountlyJSONFromObject(CountlySDKLogsUploadableBatch(batch)); + if (!payload) + { + // putting an unserialisable batch back would make it the head of every later delivery, so it is lost and counted + CLY_LOG_W(@"%s, a gathered log batch could not be serialised, dropping it", __FUNCTION__); + [self countSdkLogBatchAsDropped:batch]; + continue; + } + + if (![CountlyConnectionManager.sharedInstance sendSdkLogs:payload]) + { + // the queue refused it, so the lines are still ours + [self restoreSdkLogBatch:batch]; + return; + } + + if (!drainAll) + { + // the next delivery finds this batch waiting in the queue and resumes once it is sent + BOOL nextIncludesPartialBatch = NO; + if ([self isSdkLogsDeliveryDueBeforeTheTimer:&nextIncludesPartialBatch]) + [self scheduleSdkLogsDelivery:nextIncludesPartialBatch]; + + return; + } + } +} + +/// Whether held lines call for a delivery before the timer's: a full batch, or a buffer near the character ceiling, which +/// then goes as a partial batch. A smaller remainder waits for the timer, background or terminate, since the request path +/// logs a line or two per send on threads the transport mark does not cover, and chasing those would send a tiny batch +/// per round trip of the queue. +- (BOOL)isSdkLogsDeliveryDueBeforeTheTimer:(BOOL *)includePartialBatch +{ + @synchronized (_sdkLogsLock) { + if (_sdkLogsState != CLYSDKLogsStateGathering) + return NO; + + *includePartialBatch = _sdkLogsChars >= kCountlySDKLogsDeliveryCharThreshold; + return *includePartialBatch || (NSInteger)_sdkLogs.count >= _sdkLogsBatchSize; + } +} + +/// Runs a delivery again once the request queue has sent what it holds, the waiting log batch included, so a backlog goes +/// out one batch at a time. Only one such wait is armed at a time. +- (void)resumeSdkLogsDeliveryWhenQueueDrains +{ + @synchronized (_sdkLogsLock) { + if (_sdkLogsDeliveryAwaitsQueueDrain) + return; + + _sdkLogsDeliveryAwaitsQueueDrain = YES; + } + + __weak CountlyCommon* weakSelf = self; + [CountlyConnectionManager.sharedInstance addQueueFlushRunnable:^{ + [weakSelf resumeSdkLogsDeliveryAfterQueueDrain]; + }]; + + // the queue may have been sent between the check and the registration, and then only its next request would run it + if (![CountlyPersistency.sharedInstance queueContainsSdkLogs]) + [self resumeSdkLogsDeliveryAfterQueueDrain]; +} + +/// Ends the armed wait and schedules the delivery it held back, once for each wait, when held lines call for one before the timer. +- (void)resumeSdkLogsDeliveryAfterQueueDrain +{ + @synchronized (_sdkLogsLock) { + if (!_sdkLogsDeliveryAwaitsQueueDrain) + return; + + _sdkLogsDeliveryAwaitsQueueDrain = NO; + } + + BOOL includePartialBatch = NO; + if ([self isSdkLogsDeliveryDueBeforeTheTimer:&includePartialBatch]) + [self scheduleSdkLogsDelivery:includePartialBatch]; +} + +/// Accounts a batch that can never be sent as dropped lines, so the loss still reaches the operator with the next batch. +- (void)countSdkLogBatchAsDropped:(NSDictionary *)batch +{ + @synchronized (_sdkLogsLock) { + if (_sdkLogsState != CLYSDKLogsStateGathering || ![_sdkLogsGatheringId isEqualToString:batch[kCountlyQSSDKLogsGatherId]]) + return; + + _sdkLogsDropCount += ((NSArray *)batch[kCountlyQSSDKLogsBatchLines]).count + ((NSNumber *)batch[kCountlyQSSDKLogsDropCount]).integerValue; + } +} + +/// Takes up to one batch of the oldest lines out of the buffer as the 'sdk_logs' payload, or nil when there is nothing to upload. +- (NSDictionary *)takeSdkLogBatch:(BOOL)includePartialBatch +{ + @synchronized (_sdkLogsLock) { + if (_sdkLogsState != CLYSDKLogsStateGathering) + return nil; + + // a consent setter redecides only after its own side effects, whose log lines can complete a batch in between + [self redecideSdkLogLinesConsentLocked]; + if (_sdkLogs.count == 0) + return nil; + if (!includePartialBatch && (NSInteger)_sdkLogs.count < _sdkLogsBatchSize) + return nil; + + NSInteger count = MIN((NSInteger)_sdkLogs.count, _sdkLogsBatchSize); + NSArray* lines = [_sdkLogs subarrayWithRange:NSMakeRange(0, count)]; + [_sdkLogs removeObjectsInRange:NSMakeRange(0, count)]; + for (NSDictionary* line in lines) + _sdkLogsChars -= CountlySDKLogsLineLength(line); + + NSDictionary* batch = @{ + kCountlyQSSDKLogsGatherId: _sdkLogsGatheringId, + kCountlyQSSDKLogsDropCount: @(_sdkLogsDropCount), + kCountlyQSSDKLogsBatchLines: lines + }; + // the loss is reported once, with the batch that follows it + _sdkLogsDropCount = 0; + return batch; + } +} + +/// Puts a batch the queue refused back at the front of the buffer, oldest first, drop count included. +- (void)restoreSdkLogBatch:(NSDictionary *)batch +{ + @synchronized (_sdkLogsLock) { + if (_sdkLogsState != CLYSDKLogsStateGathering || ![_sdkLogsGatheringId isEqualToString:batch[kCountlyQSSDKLogsGatherId]]) + return; + + NSArray* lines = batch[kCountlyQSSDKLogsBatchLines]; + [_sdkLogs insertObjects:lines atIndexes:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, lines.count)]]; + for (NSDictionary* line in lines) + _sdkLogsChars += CountlySDKLogsLineLength(line); + _sdkLogsDropCount += ((NSNumber *)batch[kCountlyQSSDKLogsDropCount]).integerValue; + [self trimSdkLogsBufferLocked]; + } +} + +- (void)setSdkLogsTransportWork:(BOOL)transporting +{ + s_isSdkLogsTransportWork = transporting; +} + +- (BOOL)isSdkLogsTransportWork +{ + return s_isSdkLogsTransportWork; +} + +- (void)decideLogGatheringOffIfUndecided:(NSString *)reason +{ + // one critical section: a directive landing between a check and a separate write would be wiped out + @synchronized (_sdkLogsLock) { + if (_sdkLogsState != CLYSDKLogsStateUndecided) + return; + [self setLogGatheringOffLocked]; + } + + CLY_LOG_D(@"%s, %@, log gathering is off, anything gathered so far has been discarded", __FUNCTION__, reason); +} + +/// Arms a gather with the given directive, leaving the buffer as it is. Call under the lock. +- (void)setLogGatheringOnLocked:(NSString *)levels batch:(NSInteger)batch gatheringId:(NSString *)gatheringId +{ + _sdkLogsState = CLYSDKLogsStateGathering; + self.sdkLogsLevels = levels; + _sdkLogsBatchSize = batch; + _sdkLogsGatheringId = gatheringId; +} + +/// Off with an empty buffer and default directive values. Call under the lock. +- (void)setLogGatheringOffLocked +{ + _sdkLogsState = CLYSDKLogsStateOff; + _sdkLogsBatchSize = kCountlySDKLogsDefaultBatchSize; + self.sdkLogsLevels = kCountlySDKLogsAllLevels; + _sdkLogsGatheringId = @""; + [_sdkLogs removeAllObjects]; + _sdkLogsDropCount = 0; + memset(sdkLogsUndecidedDrops, 0, sizeof(sdkLogsUndecidedDrops)); + _sdkLogsChars = 0; + [_sdkLogsAwaitingConsent removeAllObjects]; + _sdkLogsAwaitingConsentChars = 0; +} + +/// Takes every held line as one batch of the running gather, deciding consent again first, or nil when none is held. Call under the lock. +- (NSDictionary *)takeAllSdkLogLinesLocked +{ + [self redecideSdkLogLinesConsentLocked]; + if (_sdkLogs.count == 0 || _sdkLogsGatheringId.length == 0) + return nil; + + return @{ + kCountlyQSSDKLogsGatherId: _sdkLogsGatheringId, + kCountlyQSSDKLogsDropCount: @(_sdkLogsDropCount), + kCountlyQSSDKLogsBatchLines: _sdkLogs.copy + }; +} + +/// Hands the last batch of a stopped gather to the request queue, once. Its lines are gone if the queue refuses it. +- (void)uploadSdkLogsTailBatch:(NSDictionary *)batch +{ + if (!self.hasFinishedInit) + return; + + NSString* payload = CountlyJSONFromObject(CountlySDKLogsUploadableBatch(batch)); + if (!payload) + return; + + BOOL callerTransportWork = self.isSdkLogsTransportWork; + [self setSdkLogsTransportWork:YES]; + BOOL isQueued = [CountlyConnectionManager.sharedInstance sendSdkLogs:payload]; + [self setSdkLogsTransportWork:callerTransportWork]; + + CLY_LOG_D(@"%s, the last [ %lu ] lines of the stopped gather %@", __FUNCTION__, (unsigned long)((NSArray *)batch[kCountlyQSSDKLogsBatchLines]).count, isQueued ? @"are queued" : @"are dropped, the request queue refused them"); +} + +- (void)updateLogGatheringState:(BOOL)enabled levels:(nullable NSString *)levels batch:(NSInteger)batch lgid:(nullable NSString *)lgid { + // batches without a gather id can not be attributed to a capture and the server rejects them all + if (enabled && lgid.length == 0) { + CLY_LOG_W(@"%s, log gathering was enabled without a gather id, not gathering", __FUNCTION__); + enabled = NO; + } + + NSString* sanitizedLevels = [self sanitizeLogGatheringLevels:levels]; + NSInteger sanitizedBatch = MIN(MAX(batch > 0 ? batch : kCountlySDKLogsDefaultBatchSize, kCountlySDKLogsMinBatchSize), kCountlySDKLogsMaxBufferedLines); + NSUInteger held = 0; + BOOL wasGathering = NO; + NSDictionary* tailBatch = nil; + + @synchronized (_sdkLogsLock) { + // held lines belong to the previous gather and the server rejects them. This can not fire while + // undecided, where the provisional lines belong to whichever gather adopts them + BOOL belongToAnotherGather = _sdkLogsState != CLYSDKLogsStateUndecided && ![_sdkLogsGatheringId isEqualToString:lgid]; + wasGathering = _sdkLogsState == CLYSDKLogsStateGathering; + + if (!enabled) { + // a stopped gather's held lines go out once under its id, the server still takes them for a while. + // Lines held while undecided belong to no gather and are dropped + if (wasGathering && _hasFinishedInit) + tailBatch = [self takeAllSdkLogLinesLocked]; + [self setLogGatheringOffLocked]; + } else if (belongToAnotherGather) { + [self setLogGatheringOffLocked]; + [self setLogGatheringOnLocked:sanitizedLevels batch:sanitizedBatch gatheringId:lgid]; + } else { + [self setLogGatheringOnLocked:sanitizedLevels batch:sanitizedBatch gatheringId:lgid]; + [self adoptUndecidedSdkLogDropsLocked:sanitizedLevels]; + // adoption: the provisional lines were gathered at every level, so apply the filter now + _sdkLogsChars = CountlySDKLogsKeepLevels(_sdkLogs, sanitizedLevels); + [self trimSdkLogsBufferLocked]; + _sdkLogsAwaitingConsentChars = CountlySDKLogsKeepLevels(_sdkLogsAwaitingConsent, sanitizedLevels); + held = _sdkLogs.count; + } + } + + if (tailBatch) + [self uploadSdkLogsTailBatch:tailBatch]; + + if (enabled) { + CLY_LOG_D(@"%s, log gathering on, id:[ %@ ] levels:[ %@ ] batch:[ %ld ], holding [ %lu ] lines", __FUNCTION__, lgid, sanitizedLevels, (long)sanitizedBatch, (unsigned long)held); + // adoption may hand over a buffer that is already past the batch size, partial batches wait for the timer + if (!wasGathering) + [self scheduleSdkLogsDelivery:NO]; + } else { + CLY_LOG_D(@"%s, log gathering is off, the held lines of a stopped gather went out once and the rest is discarded", __FUNCTION__); + } +} + +/// Keeps only the level characters this SDK knows, lowercased, in the order sent, without duplicates. +/// Falls back to every level when nothing usable is left, an armed gather that captures nothing would look like a broken feature. +- (NSString *)sanitizeLogGatheringLevels:(nullable NSString *)levels +{ + NSMutableString* filtered = NSMutableString.new; + for (NSUInteger i = 0; i < levels.length; i++) + { + NSString* level = [[levels substringWithRange:NSMakeRange(i, 1)] lowercaseString]; + if ([kCountlySDKLogsAllLevels containsString:level] && ![filtered containsString:level]) + [filtered appendString:level]; + } + + return filtered.length > 0 ? filtered.copy : kCountlySDKLogsAllLevels; } void CountlyPrint(NSString *stringToPrint) @@ -142,6 +1136,14 @@ void CountlyPrint(NSString *stringToPrint) NSLog(@"%@%@", kCountlyInternalLogPrefix, stringToPrint); } +void CountlyRunOnMainThread(dispatch_block_t block) +{ + if (NSThread.isMainThread) + block(); + else + dispatch_async(dispatch_get_main_queue(), block); +} + #pragma mark - Time/Date related methods - (NSInteger)hourOfDay { @@ -215,7 +1217,7 @@ - (void)recordOrientation return; if ([UIApplication sharedApplication].applicationState == UIApplicationStateBackground) { - CLY_LOG_W(@"%s App is in the background, 'Record Orientation' will be ignored", __FUNCTION__); + CLY_LOG_D(@"%s app is in the background, orientation recording will be ignored", __FUNCTION__); return; } #pragma GCC diagnostic push @@ -231,17 +1233,17 @@ - (void)recordOrientation if (!mode) { - CLY_LOG_D(@"Interface orientation is not landscape or portrait."); + CLY_LOG_D(@"%s interface orientation is neither landscape nor portrait, orientation event will be skipped", __FUNCTION__); return; } if ([mode isEqualToString:self.lastInterfaceOrientation]) { - CLY_LOG_V(@"Interface orientation is still same: %@", self.lastInterfaceOrientation); + CLY_LOG_V(@"%s interface orientation did not change, orientation event will be skipped, mode: [%@]", __FUNCTION__, self.lastInterfaceOrientation); return; } - CLY_LOG_D(@"Interface orientation is now: %@", mode); + CLY_LOG_D(@"%s interface orientation changed, previousMode: [%@], mode: [%@]", __FUNCTION__, self.lastInterfaceOrientation, mode); if (!CountlyConsentManager.sharedInstance.consentForUserDetails) return; @@ -279,13 +1281,76 @@ - (void)finishBackgroundTask #endif } -#if (TARGET_OS_IOS || TARGET_OS_TV) -- (UIViewController *)topViewController +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV) ++ (UIWindow *)keyWindow +{ + if (@available(iOS 13.0, *)) + { + // connectedScenes is an unordered NSSet, so never rely on iteration order: search + // explicitly for the correct window rather than acting on whichever scene comes first. + NSArray *scenes = UIApplication.sharedApplication.connectedScenes.allObjects; + + // 1) The key window of the foreground-active scene (the one the user is interacting with). + for (UIScene *scene in scenes) + { + if (scene.activationState == UISceneActivationStateForegroundActive && [scene isKindOfClass:[UIWindowScene class]]) + { + for (UIWindow *window in ((UIWindowScene *)scene).windows) + if (window.isKeyWindow) + return window; + } + } + + // 2) Any key window across all scenes. + for (UIScene *scene in scenes) + { + if ([scene isKindOfClass:[UIWindowScene class]]) + { + for (UIWindow *window in ((UIWindowScene *)scene).windows) + if (window.isKeyWindow) + return window; + } + } + + // 3) Fall back to the first window of a foreground-active scene. + for (UIScene *scene in scenes) + { + if (scene.activationState == UISceneActivationStateForegroundActive && [scene isKindOfClass:[UIWindowScene class]]) + { + UIWindow *window = ((UIWindowScene *)scene).windows.firstObject; + if (window) + return window; + } + } + } +#if (TARGET_OS_VISION) + return nil; +#else +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + return UIApplication.sharedApplication.keyWindow; +#pragma GCC diagnostic pop +#endif +} + ++ (CGRect)screenBounds { + UIWindow *window = [self keyWindow]; + if (window) + return window.bounds; +#if (TARGET_OS_VISION) + return CGRectZero; +#else #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wdeprecated-declarations" - UIViewController* topVC = UIApplication.sharedApplication.keyWindow.rootViewController; + return UIScreen.mainScreen.bounds; #pragma GCC diagnostic pop +#endif +} + +- (UIViewController *)topViewController +{ + UIViewController* topVC = CountlyCommon.keyWindow.rootViewController; while (YES) { @@ -341,7 +1406,55 @@ - (NSURLSession *)URLSession } } -#if (TARGET_OS_IOS) +- (NSURLSession *)ImmediateURLSession +{ + // one lock for the check, the copy and the creation, or a header change between them finds nothing to drop + @synchronized (self) { + CountlyConnectionManager* connectionManager = CountlyConnectionManager.sharedInstance; + BOOL usesPinnedCertificates = connectionManager.pinnedCertificates.count > 0; + if (usesPinnedCertificates && self.pinnedImmediateURLSession) + return self.pinnedImmediateURLSession; + + // 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 = connectionManager.URLSessionConfiguration; + NSURLSessionConfiguration *immediateConfig = userConfig ? [userConfig copy] : [NSURLSessionConfiguration defaultSessionConfiguration]; + + // Immediate requests must not be constrained by the SDK's configured + // request timeout, so they get the system defaults. + immediateConfig.timeoutIntervalForRequest = 60; + immediateConfig.timeoutIntervalForResource = 7 * 24 * 60 * 60; + + if (!usesPinnedCertificates) + return [NSURLSession sessionWithConfiguration:immediateConfig]; + + // the connection manager checks the pinned certificates, and one session is kept since a delegate session lives until invalidated + self.pinnedImmediateURLSession = [NSURLSession sessionWithConfiguration:immediateConfig delegate:connectionManager delegateQueue:nil]; + return self.pinnedImmediateURLSession; + } +} + +- (NSURLSessionDataTask *)immediateDataTaskWithRequest:(NSURLRequest *)request completionHandler:(void (^)(NSData *, NSURLResponse *, NSError *))completionHandler +{ + @synchronized (self) { + return [self.ImmediateURLSession dataTaskWithRequest:request completionHandler:completionHandler]; + } +} + +- (void)invalidatePinnedImmediateURLSession +{ + @synchronized (self) { + if (!self.pinnedImmediateURLSession) + return; + + CLY_LOG_D(@"%s the immediate session kept for pinned certificates is dropped, the next immediate request builds a new one", __FUNCTION__); + [self.pinnedImmediateURLSession finishTasksAndInvalidate]; + self.pinnedImmediateURLSession = nil; + } +} + +#if (TARGET_OS_IOS || TARGET_OS_VISION) - (bool) hasTopNotch:(UIEdgeInsets)safeArea { return safeArea.top >= 44; @@ -349,19 +1462,8 @@ - (bool) hasTopNotch:(UIEdgeInsets)safeArea #endif - (CGSize)getWindowSize{ -#if (TARGET_OS_IOS) - UIWindow *window = nil; - - if (@available(iOS 13.0, *)) { - for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) { - if ([scene isKindOfClass:[UIWindowScene class]]) { - window = ((UIWindowScene *)scene).windows.firstObject; - break; - } - } - } else { - window = UIApplication.sharedApplication.delegate.window; - } +#if (TARGET_OS_IOS || TARGET_OS_VISION) + UIWindow *window = [CountlyCommon keyWindow]; if (!window) return CGSizeZero; @@ -384,12 +1486,32 @@ - (CGSize)getWindowSize{ #endif } +#if (TARGET_OS_IOS || TARGET_OS_VISION) +- (UIInterfaceOrientation)interfaceOrientation +{ + if (@available(iOS 13.0, *)) + { + UIWindowScene *windowScene = [CountlyCommon keyWindow].windowScene; + if (windowScene) + return windowScene.interfaceOrientation; + } +#if (TARGET_OS_IOS) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + return UIApplication.sharedApplication.statusBarOrientation; +#pragma GCC diagnostic pop +#else + return UIInterfaceOrientationPortrait; +#endif +} +#endif + @end #pragma mark - Internal ViewController -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) @implementation CLYInternalViewController : UIViewController - (void)viewWillLayoutSubviews @@ -403,10 +1525,7 @@ - (void)viewWillLayoutSubviews UIEdgeInsets insets = UIEdgeInsetsZero; if (@available(iOS 11.0, *)) { - #pragma GCC diagnostic push - #pragma GCC diagnostic ignored "-Wdeprecated-declarations" - insets = UIApplication.sharedApplication.keyWindow.safeAreaInsets; - #pragma GCC diagnostic pop + insets = CountlyCommon.keyWindow.safeAreaInsets; } self.webView.navigationDelegate = self; @@ -416,15 +1535,18 @@ - (void)viewWillLayoutSubviews } - (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler { - NSString *url = navigationAction.request.URL.absoluteString; - if ([url containsString:@"cly_x_int=1"]) { - CLY_LOG_I(@"%s Opening url [%@] in external browser", __FUNCTION__, url); - [[UIApplication sharedApplication] openURL:navigationAction.request.URL options:@{} completionHandler:^(BOOL success) { + NSURL *contentURL = navigationAction.request.URL; + if ([contentURL.absoluteString containsString:@"cly_x_int=1"]) { + CLY_LOG_I(@"%s content url is marked for the external browser and will be handed over, host: [%@], path: [%@]", __FUNCTION__, contentURL.host, contentURL.path); + CLY_LOG_D(@"%s external browser handover content url detail, contentURL: [%@]", __FUNCTION__, contentURL.absoluteString); + [[UIApplication sharedApplication] openURL:contentURL options:@{} completionHandler:^(BOOL success) { if (success) { - CLY_LOG_I(@"%s url [%@] opened in external browser", __FUNCTION__, url); + CLY_LOG_I(@"[CLYInternalViewController] openURL completion, external browser accepted the content url, host: [%@], path: [%@]", contentURL.host, contentURL.path); + CLY_LOG_D(@"[CLYInternalViewController] openURL completion detail, the content url accepted by the external browser, contentURL: [%@]", contentURL.absoluteString); } else { - CLY_LOG_I(@"%s unable to open url [%@] in external browser", __FUNCTION__, url); + CLY_LOG_W(@"[CLYInternalViewController] openURL completion, external browser could not open the content url, host: [%@], path: [%@]", contentURL.host, contentURL.path); + CLY_LOG_D(@"[CLYInternalViewController] openURL completion detail, the content url rejected by the external browser, contentURL: [%@]", contentURL.absoluteString); } }]; decisionHandler(WKNavigationActionPolicyCancel); @@ -435,12 +1557,12 @@ - (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigati - (void)webView:(WKWebView *)webView didStartProvisionalNavigation:(null_unspecified WKNavigation *)navigation { - CLY_LOG_I(@"%s Web view has start loading", __FUNCTION__); - + CLY_LOG_D(@"%s internal web view started loading", __FUNCTION__); + } - (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation { - CLY_LOG_I(@"%s Web view has finished loading", __FUNCTION__); + CLY_LOG_D(@"%s internal web view finished loading", __FUNCTION__); } @@ -512,10 +1634,7 @@ - (void)positionToTopRight:(BOOL)shouldConsiderStatusBar { if (@available(iOS 11.0, *)) { -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - CGFloat top = UIApplication.sharedApplication.keyWindow.safeAreaInsets.top; -#pragma GCC diagnostic pop + CGFloat top = CountlyCommon.keyWindow.safeAreaInsets.top; if (top) { @@ -559,6 +1678,12 @@ - (void)forwardInvocation:(NSInvocation *)invocation #pragma mark - Categories +/// Whether the value is a number JSON can represent: NaN and the infinities are numbers too, but fail a whole serialization. +static BOOL CountlyIsSupportedNumber(id value) +{ + return [value isKindOfClass:NSNumber.class] && isfinite(((NSNumber *)value).doubleValue); +} + NSString* CountlyJSONFromObject(id object) { if (!object) @@ -566,7 +1691,7 @@ - (void)forwardInvocation:(NSInvocation *)invocation if (![NSJSONSerialization isValidJSONObject:object]) { - CLY_LOG_W(@"Object is not valid for converting to JSON!"); + CLY_LOG_E(@"%s object can not be represented as JSON and will be dropped, objectType: [%@]", __FUNCTION__, [object class]); return nil; } @@ -574,7 +1699,7 @@ - (void)forwardInvocation:(NSInvocation *)invocation NSData *data = [NSJSONSerialization dataWithJSONObject:object options:0 error:&error]; if (error) { - CLY_LOG_W(@"%s, JSON can not be created error:[ %@ ]", __FUNCTION__, error); + CLY_LOG_E(@"%s JSON serialization failed and the data will be lost, objectType: [%@], error: [%@]", __FUNCTION__, [object class], error.localizedDescription); } return [data cly_stringUTF8]; @@ -624,18 +1749,31 @@ - (NSString *)cly_truncatedKey:(NSString *)explanation { if (self.length > CountlyCommon.sharedInstance.maxKeyLength) { - CLY_LOG_W(@"%@ length is more than the limit (%ld)! So, it will be truncated: %@.", explanation, (long)CountlyCommon.sharedInstance.maxKeyLength, self); + CLY_LOG_D(@"%s key exceeds the SDK key length limit and will be truncated, target: [%@], key: [%@], keyLength: [%lu], truncatedTo: [%lu]", __FUNCTION__, explanation, self, (unsigned long)self.length, (unsigned long)CountlyCommon.sharedInstance.maxKeyLength); return [self substringToIndex:CountlyCommon.sharedInstance.maxKeyLength]; } return self; } +- (NSString *)cly_truncatedPictureValue:(NSString *)explanation +{ + NSUInteger limit = CountlyCommon.sharedInstance.maxValueLengthPicture; + if (self.length > limit) + { + CLY_LOG_D(@"%s value exceeds the SDK picture value limit and will be truncated, target: [%@], valueLength: [%lu], truncatedTo: [%lu]", __FUNCTION__, explanation, (unsigned long)self.length, (unsigned long)limit); + CLY_LOG_D(@"%s truncated picture value detail, target: [%@], fullValue: [%@]", __FUNCTION__, explanation, self); + return [self substringToIndex:limit]; + } + return self; +} + - (NSString *)cly_truncatedValue:(NSString *)explanation { if (self.length > CountlyCommon.sharedInstance.maxValueLength) { - CLY_LOG_W(@"%@ length is more than the limit (%ld)! So, it will be truncated: %@.", explanation, (long)CountlyCommon.sharedInstance.maxValueLength, self); + CLY_LOG_D(@"%s value exceeds the SDK value length limit and will be truncated, target: [%@], valueLength: [%lu], truncatedTo: [%lu]", __FUNCTION__, explanation, (unsigned long)self.length, (unsigned long)CountlyCommon.sharedInstance.maxValueLength); + CLY_LOG_D(@"%s truncated value detail, target: [%@], fullValue: [%@]", __FUNCTION__, explanation, self); return [self substringToIndex:CountlyCommon.sharedInstance.maxValueLength]; } @@ -653,10 +1791,10 @@ - (NSString *)cly_JSONify - (NSArray *) cly_filterSupportedDataTypes { NSMutableArray *filteredArray = [NSMutableArray array]; for (id obj in self) { - if ([obj isKindOfClass:[NSNumber class]] || [obj isKindOfClass:[NSString class]]) { + if (CountlyIsSupportedNumber(obj) || [obj isKindOfClass:[NSString class]]) { [filteredArray addObject:obj]; } else { - CLY_LOG_W(@"%s, Removed invalid type from array: %@", __FUNCTION__, [obj class]); + CLY_LOG_D(@"%s dropping array element with an unsupported type, only NSNumber and NSString are allowed, receivedType: [%@]", __FUNCTION__, [obj class]); } } return filteredArray.copy; @@ -704,7 +1842,7 @@ - (NSDictionary *)cly_limited:(NSString *)explanation NSMutableArray* excessKeys = allKeys.mutableCopy; [excessKeys removeObjectsInRange:(NSRange){0, CountlyCommon.sharedInstance.maxSegmentationValues}]; - CLY_LOG_W(@"%s, Number of key-value pairs in %@ is more than the limit (%ld)! So, some of them will be removed %@", __FUNCTION__, explanation, (long)CountlyCommon.sharedInstance.maxSegmentationValues, [excessKeys description]); + CLY_LOG_D(@"%s segmentation exceeds the SDK segmentation value limit and will be trimmed, target: [%@], keyCount: [%lu], trimmedTo: [%lu], droppedKeyCount: [%lu], droppedKeys: [%@]", __FUNCTION__, explanation, (unsigned long)allKeys.count, (unsigned long)CountlyCommon.sharedInstance.maxSegmentationValues, (unsigned long)excessKeys.count, excessKeys); NSMutableDictionary* limitedDict = self.mutableCopy; [limitedDict removeObjectsForKeys:excessKeys]; @@ -719,12 +1857,12 @@ - (NSMutableDictionary *) cly_filterSupportedDataTypes for (NSString *key in self) { id value = [self objectForKey:key]; - if ([value isKindOfClass:[NSNumber class]] || + if (CountlyIsSupportedNumber(value) || [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]); + CLY_LOG_D(@"%s dropping dictionary entry with an unsupported value type, key: [%@], receivedType: [%@]", __FUNCTION__, key, [value class]); } } diff --git a/ios/src/CountlyConfig.h b/ios/src/CountlyConfig.h index 215586a5..c34f48e9 100644 --- a/ios/src/CountlyConfig.h +++ b/ios/src/CountlyConfig.h @@ -35,12 +35,10 @@ typedef NSString* CLYFeature NS_EXTENSIBLE_STRING_ENUM; extern CLYFeature const CLYPushNotifications; #endif extern CLYFeature const CLYCrashReporting; -extern CLYFeature const CLYAutoViewTracking DEPRECATED_MSG_ATTRIBUTE("Use 'config.enableAutomaticViewTracking' instead"); #elif (TARGET_OS_WATCH) extern CLYFeature const CLYCrashReporting; #elif (TARGET_OS_TV) extern CLYFeature const CLYCrashReporting; -extern CLYFeature const CLYAutoViewTracking DEPRECATED_MSG_ATTRIBUTE("Use 'config.enableAutomaticViewTracking' instead"); #elif (TARGET_OS_OSX) #ifndef COUNTLY_EXCLUDE_PUSHNOTIFICATIONS extern CLYFeature const CLYPushNotifications; @@ -51,8 +49,7 @@ extern CLYFeature const CLYCrashReporting; //NOTE: Device ID options /** - * Can be used as device ID to switch back to default device ID, if a custom device ID is set before. - * @discussion It can be used as @c deviceID on initial configuration, or passed as an argument for @c deviceID parameter on @c setNewDeviceID:onServer: method. + * Can be used as @c deviceID on initial configuration to use the default device ID. * @discussion On iOS and tvOS, default device ID is Identifier For Vendor (IDFV). * @discussion On watchOS and macOS, default device ID is a persistently stored random NSUUID string. */ @@ -60,9 +57,9 @@ extern NSString* const CLYDefaultDeviceID; /** * Use this as device ID for keeping all requests on hold until the real device ID is set later. - * @discussion It can be used as @c deviceID on initial configuration, or passed as an argument for @c deviceID parameter on @c setNewDeviceID:onServer: method. + * @discussion It can be used as @c deviceID on initial configuration, or passed as an argument for @c deviceID parameter on @c changeDeviceIDWithoutMerge: method. * @discussion As long as device ID is @c CLYTemporaryDeviceID, all requests will be on hold, but they will be persistently stored. - * @discussion Later when the real device ID is set using @c setNewDeviceID:onServer: method, all requests kept on hold so far will start with the real device ID. + * @discussion Later when the real device ID is set using @c changeDeviceIDWithMerge: or @c changeDeviceIDWithoutMerge: method, all requests kept on hold so far will start with the real device ID. * @discussion When in @c CLYTemporaryDeviceID mode, method calls for presenting feedback widgets and updating remote config will be ignored. */ extern NSString* const CLYTemporaryDeviceID; @@ -187,6 +184,7 @@ typedef enum : NSUInteger * For receiving SDK's internal logs even in production builds. * @discussion If set, SDK will forward its internal logs to this delegate object regardless of @c enableDebug initial config value. * @discussion @c internalLog:withLevel: method declared as @c required in @c CountlyLoggerDelegate protocol will be called with log @c NSString. + * @discussion It is called on whichever thread the SDK logs from, sometimes while the SDK holds one of its internal locks. Keep it quick, do not call back into the SDK from it, and do not wait for the main thread in it. */ @property (nonatomic, weak) id loggerDelegate; @@ -204,15 +202,14 @@ typedef enum : NSUInteger * @discussion @b iOS: * @discussion @c CLYPushNotifications for push notifications * @discussion @c CLYCrashReporting for crash reporting - * @discussion @c CLYAutoViewTracking for auto view tracking * @discussion @b watchOS: * @discussion @c CLYCrashReporting for crash reporting * @discussion @b tvOS: * @discussion @c CLYCrashReporting for crash reporting - * @discussion @c CLYAutoViewTracking for auto view tracking * @discussion @b macOS: * @discussion @c CLYPushNotifications for push notifications * @discussion @c CLYCrashReporting for crash reporting + * @discussion @c CLYCrashReporting turns automatic crash reporting on. SDK behavior settings can turn it off, and can also turn it on for an app that does not list @c CLYCrashReporting, when an operator enables automatic crash reporting in SDK Manager. Crash consent still applies. */ @property (nonatomic, copy) NSArray* features; @@ -253,6 +250,7 @@ typedef enum : NSUInteger /** * For limiting features based on user consent. * @discussion If set, SDK will wait for explicit consent to be given for features to work. + * @discussion SDK behavior settings can require consent when this is not set, but can never lift a requirement set here. */ @property (nonatomic) BOOL requiresConsent; @@ -397,49 +395,20 @@ typedef enum : NSUInteger */ @property (nonatomic) NSInteger requestTimeoutDuration; -/** - * Limit for the length of all string keys. - * @discussion It affects: - * @discussion - event names - * @discussion - view names - * @discussion - APM network trace names - * @discussion - APM custom trace names - * @discussion - APM custom trace metric keys - * @discussion - segmentation keys - * @discussion - custom metric keys - * @discussion - custom user property keys - * @discussion Keys longer than this limit will be truncated. - * @discussion If not set, it will be 128 chars by default. - */ -@property(nonatomic) NSUInteger maxKeyLength DEPRECATED_MSG_ATTRIBUTE("Use 'sdkInternalLimits' CountlySDKLimitsConfig object instead"); - -/** - * Limit for the length of values in all key-value pairs. - * @discussion It affects: - * @discussion - segmentation values - * @discussion - APM custom trace metric values - * @discussion - custom crash logs - * @discussion - custom metric values - * @discussion - custom user property values - * @discussion Values longer than this limit will be truncated. - * @discussion If not set, it will be 256 chars by default. - */ -@property(nonatomic) NSUInteger maxValueLength DEPRECATED_MSG_ATTRIBUTE("Use 'sdkInternalLimits' CountlySDKLimitsConfig object instead"); - -/** - * Limit for the number of key-value pairs in segmentations. - * @discussion If there are more key-value pairs than this limit, some of them will be removed. - * @discussion As obviously there is no order among the keys of an NSDictionary, it is not defined which ones will be removed. - * @discussion If not set, it will be 30 by default. - */ -@property(nonatomic) NSUInteger maxSegmentationValues DEPRECATED_MSG_ATTRIBUTE("Use 'sdkInternalLimits' CountlySDKLimitsConfig object instead"); - /** * Variable to access sdkInternalLimits configurations. * @discussion SDK internal limits configurations for developer to interact with SDK. */ - (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. @@ -451,6 +420,7 @@ typedef enum : NSUInteger /** * For handling sessions manually. * @discussion If set, SDK does not handle beginning, updating and ending sessions automatically. Methods @c beginSession, @c updateSession and @c endSession need to be called manually. + * @discussion SDK behavior settings can turn automatic session tracking on or off and override this. While it is on, the manual session methods are ignored. */ @property (nonatomic) BOOL manualSessionHandling; @@ -464,12 +434,6 @@ typedef enum : NSUInteger #pragma mark - -/** - * For specifying attribution ID (IDFA). - * @discussion If set, this attribution ID will be sent with all @c begin_session requests. - */ -@property (nonatomic, copy) NSString* attributionID DEPRECATED_MSG_ATTRIBUTE("Use 'campaignType' and 'campaignData' for direct attribution or 'indirectAttribution' for indirect attribution"); - /** * For specifying direct attribution campaign type. * @discussion Currently supported campaign types are "countly" and "_special_test". @@ -498,14 +462,6 @@ typedef enum : NSUInteger */ @property (nonatomic, copy) NSDictionary* crashSegmentation; -/** - * Crash log limit is used for limiting the number of crash logs to be stored on the device. - * @discussion If number of stored crash logs reaches @c crashLogLimit, SDK will start to drop oldest crash log while appending the newest one. - * @discussion If not set, it will be 100 by default. - * @discussion If @c shouldUsePLCrashReporter flag is set on initial config, this limit will not be applied. - */ -@property (nonatomic) NSUInteger crashLogLimit DEPRECATED_MSG_ATTRIBUTE("Use 'sdkInternalLimits' CountlySDKLimitsConfig object instead"); - /** * Regular expression used for filtering crash reports and preventing them from being sent to Countly Server. * @discussion If a crash's name, description or any line of stack trace matches given regular expression, it will not be sent to Countly Server. @@ -527,6 +483,9 @@ typedef enum : NSUInteger * @discussion 2) Mach: PLCrashReporterSignalHandlerTypeMach * @discussion For more information about PLCrashReporter please see: https://github.com/microsoft/plcrashreporter * @discussion By default, BSD type will be used. + * @discussion The Mach type signal handler is not compatible with Apple's Enhanced Security capability when its additional runtime platform restrictions are enabled, which Xcode does by default. + * @discussion Under these restrictions a process may set exception ports only with the @c EXCEPTION_IDENTITY_PROTECTED, @c EXCEPTION_STATE or @c EXCEPTION_STATE_IDENTITY_PROTECTED behavior. PLCrashReporter sets its Mach exception port with @c EXCEPTION_DEFAULT, so on device the system terminates the app with an @c EXC_GUARD exception (@c SET_EXCEPTION_BEHAVIOR) as soon as the SDK starts crash reporting: in @c startWithConfig:, or later when crash reporting consent is given or SDK behavior settings enable it. The Simulator does not enforce these restrictions. + * @discussion On apps that adopt Enhanced Security, leave this flag unset to use the default BSD type signal handler. */ @property (nonatomic) BOOL shouldUseMachSignalHandler; @@ -534,6 +493,7 @@ typedef enum : NSUInteger * Callback block to be executed when the app is launched again following a crash which is detected by PLCrashReporter on the previous session. * @discussion It has an @c NSDictionary parameter that represents crash report object. * @discussion If @c shouldUsePLCrashReporter flag is not set on initial config, it will never be executed. + * @discussion It is executed on the main thread. A crash captured while crash reporting consent or automatic crash reporting was off, or after @c halt, is discarded without executing it. */ @property (nonatomic, copy) void (^crashOccuredOnPreviousSessionCallback)(NSDictionary * crashReport); @@ -543,6 +503,7 @@ typedef enum : NSUInteger * @discussion If set, crash report will be sent to Countly Server only if `YES` is returned. * @discussion It has an @c NSDictionary parameter that represents crash report object. * @discussion If @c shouldUsePLCrashReporter flag is not set on initial config, it will never be executed. + * @discussion It is executed on the main thread. */ @property (nonatomic, copy) BOOL (^shouldSendCrashReportCallback)(NSDictionary * crashReport); @@ -597,22 +558,6 @@ typedef enum : NSUInteger #pragma mark - -/** - * For enabling automatic fetching of remote config values. - * @discussion If set, Remote Config values specified on Countly Server will be fetched on beginning of sessions. - */ -@property (nonatomic) BOOL enableRemoteConfig DEPRECATED_MSG_ATTRIBUTE("Use 'enableRemoteConfigAutomaticTriggers' instead"); - -/** - * Completion block to be executed after remote config is fetched from Countly Server, on start or device ID change. - * @discussion This completion block can be used to detect updating of remote config values is completed, either with success or failure. - * @discussion It has an @c NSError parameter that will be either @ nil or an @c NSError object, depending of request result. - * @discussion If there is no error, it will be executed with an @c nil, which means latest remote config values are ready to be used. - * @discussion If Countly Server is not reachable or if there is another error, it will be executed with an @c NSError indicating the problem. - * @discussion If @c enableRemoteConfig flag is not set on initial config, it will never be executed. - */ -@property (nonatomic, copy) void (^remoteConfigCompletionHandler)(NSError * _Nullable error) DEPRECATED_MSG_ATTRIBUTE("Use 'remoteConfigRegisterGlobalCallback' instead"); - /** * For enabling automatic fetching of remote config values. * @discussion If set, Remote Config values specified on Countly Server will be fetched on beginning of sessions, exit temporary device id and changing device id onserver. @@ -645,12 +590,6 @@ typedef enum : NSUInteger #pragma mark - -/** - * For enabling automatic performance monitoring. - * @discussion If set, Performance Monitoring feature will be started automatically on SDK start. - */ -@property (nonatomic) BOOL enablePerformanceMonitoring DEPRECATED_MSG_ATTRIBUTE("Use 'apm' CountlyAPMConfig object instead"); - /** * Variable to access apm configurations. * @discussion APM configurations for developer to interact with SDK. @@ -670,16 +609,9 @@ typedef enum : NSUInteger */ @property (nonatomic) BOOL enableOrientationTracking; -/** - * This is an experimental feature and it can have breaking changes - * For enabling fetching and application of server config values. - * @discussion If set, Server Config values from Countly Server will be fetched at the beginning of a session. - */ -@property(nonatomic) BOOL enableServerConfiguration DEPRECATED_MSG_ATTRIBUTE("Will do nothing"); -; - /** * Set the server configuration to be set while initializing the SDK + * @discussion They apply only while no behavior settings from the server are stored, so on the first run. The first answer from the server replaces them as a whole, so a setting the server does not send goes back to its value in this configuration. A consent requirement that has to hold in any case belongs in @c requiresConsent, which no behavior setting lifts. */ @property(nonatomic, copy) NSString *sdkBehaviorSettings; diff --git a/ios/src/CountlyConfig.m b/ios/src/CountlyConfig.m index a9b10e5c..9b7da0c3 100644 --- a/ios/src/CountlyConfig.m +++ b/ios/src/CountlyConfig.m @@ -10,24 +10,16 @@ @interface CountlyConfig () @property (nonatomic) NSMutableArray *remoteConfigGlobalCallbacks; @end -@interface CountlyAPMConfig () -- (void)enableAPMInternal:(BOOL)enableAPM; -@end - @implementation CountlyConfig //NOTE: Countly features #if (TARGET_OS_IOS || TARGET_OS_VISION) CLYFeature const CLYPushNotifications = @"CLYPushNotifications"; CLYFeature const CLYCrashReporting = @"CLYCrashReporting"; -// CLYAutoViewTracking is deprecated, Use 'config.enableAutomaticViewTracking' instead -CLYFeature const CLYAutoViewTracking = @"CLYAutoViewTracking" ; #elif (TARGET_OS_WATCH) CLYFeature const CLYCrashReporting = @"CLYCrashReporting"; #elif (TARGET_OS_TV) CLYFeature const CLYCrashReporting = @"CLYCrashReporting"; -// CLYAutoViewTracking is deprecated, Use 'config.enableAutomaticViewTracking' instead -CLYFeature const CLYAutoViewTracking = @"CLYAutoViewTracking"; #elif (TARGET_OS_OSX) CLYFeature const CLYPushNotifications = @"CLYPushNotifications"; CLYFeature const CLYCrashReporting = @"CLYCrashReporting"; @@ -63,17 +55,12 @@ - (instancetype)init #endif self.eventSendThreshold = 100; self.storedRequestsLimit = 1000; - self.crashLogLimit = kCountlyMaxBreadcrumbCount; - - self.maxKeyLength = kCountlyMaxKeyLength; - self.maxValueLength = kCountlyMaxValueSize; - self.maxSegmentationValues = kCountlyMaxSegmentationValues; self.location = kCLLocationCoordinate2DInvalid; self.URLSessionConfiguration = NSURLSessionConfiguration.defaultSessionConfiguration; - self.internalLogLevel = CLYInternalLogLevelVerbose; + self.internalLogLevel = CLYInternalLogLevelDebug; self.enableOrientationTracking = YES; self.remoteConfigGlobalCallbacks = NSMutableArray.new; @@ -99,15 +86,6 @@ -(void)remoteConfigRegisterGlobalCallback:(RCDownloadCallback) callback return self.remoteConfigGlobalCallbacks; } -- (void)setEnablePerformanceMonitoring:(BOOL)enablePerformanceMonitoring -{ - if (apmConfig == nil) { - apmConfig = CountlyAPMConfig.new; - } - [apmConfig enableAPMInternal:enablePerformanceMonitoring]; - -} - - (nonnull CountlyAPMConfig *)apm { if (apmConfig == nil) { apmConfig = CountlyAPMConfig.new; diff --git a/ios/src/CountlyConnectionManager.h b/ios/src/CountlyConnectionManager.h index 00b07dd6..97ccd57a 100644 --- a/ios/src/CountlyConnectionManager.h +++ b/ios/src/CountlyConnectionManager.h @@ -14,6 +14,8 @@ extern NSString* const kCountlyQSKeySDKVersion; extern NSString* const kCountlyQSKeySDKName; extern NSString* const kCountlyQSKeyMethod; extern NSString* const kCountlyQSKeyMetrics; +extern NSString* const kCountlyQSKeyTheme; +extern NSString* const kCountlyQSSdkLogs; extern NSString* const kCountlyEndpointI; extern NSString* const kCountlyEndpointO; @@ -48,13 +50,16 @@ extern const NSInteger kCountlyGETRequestMaxLength; - (void)sendEventsWithSaveIfNeeded; - (void)sendEvents; +/// Queues one serialised 'sdk_logs' batch. Returns NO when the queue refuses it and the caller still owns the lines. +- (BOOL)sendSdkLogs:(NSString * _Nonnull)sdkLogsJSON; +/// Queues the connection test report as its own 'ct_results' request under the given device ID, and drops it when there is no app key. +- (void)sendConnectionTestResults:(NSString * _Nonnull)resultsJSON deviceID:(NSString * _Nonnull)deviceID deviceIDType:(NSUInteger)deviceIDType; - (void)attemptToSendStoredRequests; - (void)sendPushToken:(NSString *)token; - (void)sendLocationInfo; - (void)sendUserDetails:(NSString *)userDetails; - (void)sendCrashReport:(NSString *)report immediately:(BOOL)immediately; - (void)sendOldDeviceID:(NSString *)oldDeviceID; -- (void)sendAttribution; - (void)sendDirectAttributionWithCampaignID:(NSString *)campaignID andCampaignUserID:(NSString *)campaignUserID; - (void)sendAttributionData:(NSString *)attributionData; - (void)sendIndirectAttribution:(NSDictionary *)attribution; @@ -73,6 +78,8 @@ extern const NSInteger kCountlyGETRequestMaxLength; - (NSString *)appendChecksum:(NSString *)queryString; - (BOOL)isSessionStarted; +/// Whether the running session was started by the SDK's automatic session tracking rather than by the app. +- (BOOL)isSessionStartedAutomatically; #pragma mark - Request Callbacks diff --git a/ios/src/CountlyConnectionManager.m b/ios/src/CountlyConnectionManager.m index 4e9d5a45..e573dffa 100644 --- a/ios/src/CountlyConnectionManager.m +++ b/ios/src/CountlyConnectionManager.m @@ -13,11 +13,13 @@ @interface CountlyConnectionManager () NSTimeInterval lastSessionStartTime; BOOL isCrashing; BOOL isSessionStarted; + BOOL isSessionStartedAutomatically; } @property (nonatomic) NSURLSession* URLSession; @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; @@ -67,8 +69,11 @@ @interface CountlyConnectionManager () NSString* const kCountlyQSKeyConsent = @"consent"; NSString* const kCountlyQSKeyAPM = @"apm"; NSString* const kCountlyQSKeyRemainingRequest = @"rr"; +NSString* const kCountlyQSSdkLogs = @"sdk_logs"; +NSString* const kCountlyQSConnectionTestResults = @"ct_results"; NSString* const kCountlyQSKeyMethod = @"method"; +NSString* const kCountlyQSKeyTheme = @"th"; NSString* const kCountlyRCKeyABOptIn = @"ab"; NSString* const kCountlyRCKeyABOptOut = @"ab_opt_out"; @@ -89,6 +94,8 @@ @interface CountlyConnectionManager () NSString* const kCountlyEndpointSurveys = @"/surveys"; const NSInteger kCountlyGETRequestMaxLength = 2048; +// how long a crashing app waits for the immediate crash report before queueing it for the next start +static const NSTimeInterval kCountlyCrashReportSendTimeout = 5.0; @implementation CountlyConnectionManager : NSObject @@ -109,6 +116,7 @@ - (instancetype)init unsentSessionLength = 0.0; isSessionStarted = NO; atomic_init(&_backoff, NO); + atomic_init(&_isProcessingQueue, NO); _internalRequestCallbacks = [NSMutableDictionary dictionary]; _queueFlushRunnables = [NSMutableArray array]; _hasAnyRequestFailed = NO; @@ -123,8 +131,12 @@ - (BOOL)isSessionStarted { return isSessionStarted; } +- (BOOL)isSessionStartedAutomatically { + return isSessionStartedAutomatically; +} + - (void)resetInstance { - CLY_LOG_I(@"%s", __FUNCTION__); + CLY_LOG_I(@"%s connection manager is being reset, pending request callbacks and queue flush runnables will be cleared", __FUNCTION__); onceToken = 0; s_sharedInstance = nil; isSessionStarted = NO; @@ -133,14 +145,14 @@ - (void)resetInstance { [self->_queueFlushRunnables removeAllObjects]; }); _hasAnyRequestFailed = NO; + atomic_store(&_isProcessingQueue, NO); } - (void)setHost:(NSString *)host { if ([host hasSuffix:@"/"]) { - CLY_LOG_W(@"Host has an extra \"/\" at the end! It will be removed by the SDK.\ - But please make sure you fix it to avoid this warning in the future."); + CLY_LOG_W(@"%s host has an extra \"/\" at the end, it will be removed by the SDK, please fix it to avoid this warning in the future", __FUNCTION__); _host = [host substringToIndex:host.length - 1]; } else @@ -153,13 +165,17 @@ - (void)setURLSessionConfiguration:(NSURLSessionConfiguration *)URLSessionConfig { if (URLSessionConfiguration != nil) { - _URLSessionConfiguration = URLSessionConfiguration; + CLY_LOG_D(@"%s custom URL session configuration is set, the URL session will be recreated on the next request", __FUNCTION__); + // a copy: the custom headers and timeouts written into it belong to this run, not to the app's object + _URLSessionConfiguration = URLSessionConfiguration.copy; _URLSession = nil; + [CountlyCommon.sharedInstance invalidatePinnedImmediateURLSession]; } } - (void)addCustomNetworkRequestHeaders:(NSDictionary *_Nullable)customHeaderValues { if (_URLSessionConfiguration == nil) { + CLY_LOG_W(@"%s custom network request headers are ignored, reason: URL session configuration is not set, provided header count: [%lu]", __FUNCTION__, (unsigned long)customHeaderValues.count); return; } @@ -167,11 +183,16 @@ - (void)addCustomNetworkRequestHeaders:(NSDictionary *_N NSMutableDictionary *updatedHeaders = [NSMutableDictionary dictionaryWithDictionary:_URLSessionConfiguration.HTTPAdditionalHeaders ?: @{}]; // Enumerate and validate custom headers + __block NSUInteger skippedHeaderCount = 0; [customHeaderValues enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *value, BOOL *stop) { if (key == nil || key.length == 0) { + skippedHeaderCount++; + CLY_LOG_V(@"[CountlyConnectionManager] addCustomNetworkRequestHeaders, a custom network request header with a nil or empty key is skipped"); return; // Skip empty key } if (value == nil) { + skippedHeaderCount++; + CLY_LOG_V(@"[CountlyConnectionManager] addCustomNetworkRequestHeaders, a custom network request header with a nil value is skipped, header key: [%@]", key); return; // Skip nil value } @@ -179,55 +200,65 @@ - (void)addCustomNetworkRequestHeaders:(NSDictionary *_N updatedHeaders[key] = value; }]; + if (skippedHeaderCount) + { + CLY_LOG_W(@"%s some custom network request headers are skipped, reason: nil or empty key or nil value, skipped header count: [%lu]", __FUNCTION__, (unsigned long)skippedHeaderCount); + } + // Apply updated headers + CLY_LOG_I(@"%s custom network request headers are applied, total header count: [%lu]", __FUNCTION__, (unsigned long)updatedHeaders.count); _URLSessionConfiguration.HTTPAdditionalHeaders = [updatedHeaders copy]; _URLSession = nil; + // a session copies its configuration when it is created, so the one kept for pinned certificates would never send these headers + [CountlyCommon.sharedInstance invalidatePinnedImmediateURLSession]; } - (void)proceedOnQueue { - CLY_LOG_D(@"Proceeding on queue..."); - if (!CountlyServerConfig.sharedInstance.networkingEnabled) { - CLY_LOG_D(@"Proceeding on queue is aborted: SDK Networking is disabled from server config!"); + CLY_LOG_D(@"%s aborting queue processing, reason: SDK networking is disabled from server config", __FUNCTION__); 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!"); + CLY_LOG_D(@"%s aborting queue processing, reason: another request is already in process", __FUNCTION__); return; } - + if (isCrashing) { - CLY_LOG_D(@"Proceeding on queue is aborted: Application is crashing!"); + CLY_LOG_D(@"%s aborting queue processing, reason: application is crashing", __FUNCTION__); + atomic_store(&_isProcessingQueue, NO); return; } - + if (self.isTerminating) { - CLY_LOG_D(@"Proceeding on queue is aborted: Application is terminating!"); + CLY_LOG_D(@"%s aborting queue processing, reason: application is terminating", __FUNCTION__); + atomic_store(&_isProcessingQueue, NO); return; } - + if (CountlyPersistency.sharedInstance.isQueueBeingModified) { - CLY_LOG_D(@"Proceeding on queue is aborted: Queue is being modified!"); + CLY_LOG_D(@"%s aborting queue processing, reason: request queue is being modified", __FUNCTION__); + 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__); + CLY_LOG_D(@"%s aborting queue processing, reason: backoff is currently active", __FUNCTION__); + atomic_store(&_isProcessingQueue, NO); return; } if (!self.startTime) { self.startTime = [NSDate date]; // Record start time only when it's not already recorded self.hasAnyRequestFailed = NO; // Reset failure flag when starting queue processing - CLY_LOG_D(@"%s, Proceeding on queue started, queued request count %lu", __FUNCTION__, [CountlyPersistency.sharedInstance remainingRequestCount]); + CLY_LOG_D(@"%s queue processing is started, queued request count: [%lu]", __FUNCTION__, (unsigned long)[CountlyPersistency.sharedInstance remainingRequestCount]); } NSString* firstItemInQueue = [CountlyPersistency.sharedInstance firstItemInQueue]; @@ -235,7 +266,7 @@ - (void)proceedOnQueue { // Calculate total time when the queue becomes empty NSTimeInterval elapsedTime = -[self.startTime timeIntervalSinceNow]; - CLY_LOG_D(@"%s, Queue is empty. All requests are processed. Total time taken: %.2f seconds", __FUNCTION__, elapsedTime); + CLY_LOG_D(@"%s request queue is empty, all requests are processed, total time taken: [%.2f] seconds", __FUNCTION__, elapsedTime); // Execute and clear runnables only if all requests succeeded if (!self.hasAnyRequestFailed) { @@ -243,7 +274,7 @@ - (void)proceedOnQueue __block NSArray *runnablesToExecute = nil; dispatch_sync(_callbackQueue, ^{ if (self->_queueFlushRunnables.count > 0) { - CLY_LOG_D(@"%s, All requests succeeded. Executing %lu queue flush runnables.", __FUNCTION__, (unsigned long)self->_queueFlushRunnables.count); + CLY_LOG_D(@"[CountlyConnectionManager] proceedOnQueue, all requests succeeded, executing queue flush runnables, count: [%lu]", (unsigned long)self->_queueFlushRunnables.count); runnablesToExecute = [self->_queueFlushRunnables copy]; [self->_queueFlushRunnables removeAllObjects]; } @@ -254,35 +285,54 @@ - (void)proceedOnQueue for (CLYQueueFlushRunnable runnable in runnablesToExecute) { runnable(); } - CLY_LOG_D(@"%s, All queue flush runnables executed and removed.", __FUNCTION__); + CLY_LOG_D(@"%s all queue flush runnables are executed and removed", __FUNCTION__); } } else { - CLY_LOG_D(@"%s, Some requests failed. Runnables will not be executed.", __FUNCTION__); + CLY_LOG_D(@"%s at least one request failed, queue flush runnables will not be executed", __FUNCTION__); } // Reset start time and failure flag for future queue processing self.startTime = nil; self.hasAnyRequestFailed = NO; + atomic_store(&_isProcessingQueue, NO); + + // a request queued after the check above was turned away by the flag this pass held, as is one a runnable queued + if ([CountlyPersistency.sharedInstance firstItemInQueue]) + { + CLY_LOG_D(@"%s a request was queued while the queue was being finished, processing continues", __FUNCTION__); + [self proceedOnQueue]; + } return; } + // lines logged while sending a gathered log batch are not gathered, or every tick would upload a batch about the + // last one. The caller may already be marked (the delivery loop lands here), so its mark is restored on the way out + BOOL isSdkLogsTransport = [CountlyPersistency isSdkLogRequest:firstItemInQueue]; + BOOL callerTransportWork = CountlyCommon.sharedInstance.isSdkLogsTransportWork; + [CountlyCommon.sharedInstance setSdkLogsTransportWork:callerTransportWork || isSdkLogsTransport]; + BOOL isOldRequest = [CountlyPersistency.sharedInstance isOldRequest:firstItemInQueue]; if(isOldRequest) { + CLY_LOG_W(@"%s dropping the request at the head of the queue, reason: request age exceeded the configured drop age, request size: [%lu] bytes", __FUNCTION__, (unsigned long)firstItemInQueue.length); [CountlyPersistency.sharedInstance removeFromQueue:firstItemInQueue]; [CountlyPersistency.sharedInstance saveToFile]; - + + atomic_store(&_isProcessingQueue, NO); + [CountlyCommon.sharedInstance setSdkLogsTransportWork:callerTransportWork]; [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!"); + CLY_LOG_D(@"%s aborting queue processing, reason: device ID of the request at the head of the queue is the temporary device ID", __FUNCTION__); + atomic_store(&_isProcessingQueue, NO); + [CountlyCommon.sharedInstance setSdkLogsTransportWork:callerTransportWork]; return; } @@ -342,6 +392,7 @@ - (void)proceedOnQueue [pictureUploadData appendData:[boundaryEnd cly_dataUTF8]]; request.HTTPMethod = @"POST"; request.HTTPBody = pictureUploadData; + CLY_LOG_D(@"%s request will be sent as a multipart POST with picture data, endpoint: [%@], upload data size: [%lu] bytes", __FUNCTION__, endPoint, (unsigned long)pictureUploadData.length); } else if (queryString.length > kCountlyGETRequestMaxLength || self.alwaysUsePOST) { @@ -359,15 +410,15 @@ - (void)proceedOnQueue NSDate *startTimeRequest = [NSDate date]; self.connection = [self.URLSession dataTaskWithRequest:request completionHandler:^(NSData * data, NSURLResponse * response, NSError * error) { + [CountlyCommon.sharedInstance setSdkLogsTransportWork:isSdkLogsTransport]; self.connection = nil; NSDate *endTimeRequest = [NSDate date]; long duration = (long)[endTimeRequest timeIntervalSinceDate:startTimeRequest]; - CLY_LOG_V(@"Approximate received data size for request <%p> is %ld bytes.", (id)request, (long)data.length); - if(response) { NSInteger code = ((NSHTTPURLResponse*)response).statusCode; - CLY_LOG_V(@"%s, Response received from server with status code:[ %ld ] request:[ %@ ]", __FUNCTION__, (long)code, ((NSHTTPURLResponse*)response).URL); + CLY_LOG_D(@"[CountlyConnectionManager] proceedOnQueue completion, response received, endpoint: [%@], status code: [%ld], response size: [%ld] bytes, duration: [%ld] seconds", request.URL.path, (long)code, (long)data.length, duration); + CLY_LOG_D(@"[CountlyConnectionManager] proceedOnQueue completion, response detail, full response URL: [%@], status code: [%ld]", request.URL.absoluteString, (long)code); } @@ -375,7 +426,7 @@ - (void)proceedOnQueue { if ([self isRequestSuccessful:response data:data]) { - CLY_LOG_D(@"Request <%p> successfully completed.", request); + CLY_LOG_D(@"[CountlyConnectionManager] proceedOnQueue completion, request completed successfully, endpoint: [%@], duration: [%ld] seconds", request.URL.path, duration); if(requestCallback){ requestCallback([response description], YES); @@ -391,8 +442,13 @@ - (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__); + CLY_LOG_D(@"[CountlyConnectionManager] proceedOnQueue completion, stopping queue processing, reason: backoff is triggered, last response duration: [%ld] seconds", duration); self.startTime = nil; self.hasAnyRequestFailed = NO; // Reset on backoff [self backoffCountdown]; @@ -403,7 +459,13 @@ - (void)proceedOnQueue } else { - CLY_LOG_D(@"%s, request:[ <%p> ] failed! response:[ %@ ]", __FUNCTION__, request, [data cly_stringUTF8]); + CLY_LOG_D(@"[CountlyConnectionManager] proceedOnQueue completion, request failed, endpoint: [%@], status code: [%ld], response size: [%ld] bytes", request.URL.path, (long)((NSHTTPURLResponse*)response).statusCode, (long)data.length); + + if (CountlyInternalLogIsEnabled(CLYInternalLogLevelDebug)) + { + NSString* errorResponseBody = [data cly_stringUTF8]; + CLY_LOG_D(@"[CountlyConnectionManager] proceedOnQueue completion, failed request detail, full response URL: [%@], server error response body: [%@]", request.URL.absoluteString, errorResponseBody); + } self.hasAnyRequestFailed = YES; // Mark that a request has failed @@ -420,11 +482,12 @@ - (void)proceedOnQueue [CountlyHealthTracker.sharedInstance logFailedNetworkRequestWithStatusCode:((NSHTTPURLResponse*)response).statusCode errorResponse: [data cly_stringUTF8]]; [CountlyHealthTracker.sharedInstance saveState]; self.startTime = nil; + atomic_store(&self->_isProcessingQueue, NO); } } else { - CLY_LOG_D(@"%s, request:[ <%p> ] failed! error:[ %@ ]", __FUNCTION__, request, error); + CLY_LOG_D(@"[CountlyConnectionManager] proceedOnQueue completion, request failed with a network error, endpoint: [%@], error domain: [%@], error code: [%ld], error description: [%@]", request.URL.path, error.domain, (long)error.code, error.localizedDescription); self.hasAnyRequestFailed = YES; // Mark that a request has failed @@ -441,27 +504,28 @@ - (void)proceedOnQueue [CountlyPersistency.sharedInstance saveToFile]; #endif self.startTime = nil; + atomic_store(&self->_isProcessingQueue, NO); } + [CountlyCommon.sharedInstance setSdkLogsTransportWork:NO]; }]; [self.connection resume]; [self logRequest:request]; + [CountlyCommon.sharedInstance setSdkLogsTransportWork:callerTransportWork]; } - (void)recordMetrics:(nullable NSDictionary *)metricsOverride { - CLY_LOG_I(@"%s", __FUNCTION__, metricsOverride); + CLY_LOG_I(@"%s recording metrics, override metric count: [%lu]", __FUNCTION__, (unsigned long)metricsOverride.count); if (!CountlyConsentManager.sharedInstance.consentForMetrics) return; NSDictionary *defaultMetrics = [CountlyDeviceInfo metricsDictionary]; if (!defaultMetrics) { - CLY_LOG_W(@"%s Default metrics is nil, aborting", __FUNCTION__); + CLY_LOG_W(@"%s aborting metrics recording, reason: default metrics dictionary is nil", __FUNCTION__); return; } - CLY_LOG_I(@"%s default metrics:[%@]", __FUNCTION__, defaultMetrics); - NSDictionary *finalMetrics; if (metricsOverride && metricsOverride.count > 0) { NSMutableDictionary *mutableMetrics = [defaultMetrics mutableCopy]; @@ -470,7 +534,10 @@ - (void)recordMetrics:(nullable NSDictionary *)metricsOverride } else { finalMetrics = defaultMetrics; } - CLY_LOG_I(@"%s final metrics:[%@]", __FUNCTION__, finalMetrics); + CLY_LOG_D(@"%s metrics request is being prepared, final metric count: [%lu]", __FUNCTION__, (unsigned long)finalMetrics.count); + CLY_LOG_V(@"%s final metric keys: [%@]", __FUNCTION__, finalMetrics.allKeys); + CLY_LOG_D(@"%s default metrics detail, default metrics: [%@]", __FUNCTION__, defaultMetrics); + CLY_LOG_D(@"%s final metrics detail, final metrics: [%@]", __FUNCTION__, finalMetrics); NSString* queryString = [[self queryEssentials] stringByAppendingFormat:@"&%@=%@", kCountlyQSKeyMetrics, [finalMetrics cly_JSONify]]; @@ -496,6 +563,7 @@ - (BOOL)backoff:(long)responseTimeSeconds queryString:(NSString *)queryString if (requestAgeInSeconds <= [CountlyServerConfig.sharedInstance bomRequestAge] * 3600.0) { // Server is too busy, back off result = YES; + CLY_LOG_W(@"%s backoff is triggered, response time: [%ld] seconds, remaining request count: [%lu], request count threshold: [%lu], request age: [%.2f] seconds", __FUNCTION__, responseTimeSeconds, (unsigned long)remainingRequests, (unsigned long)threshold, requestAgeInSeconds); [CountlyHealthTracker.sharedInstance logBackoffRequest]; } } @@ -505,13 +573,15 @@ - (BOOL)backoff:(long)responseTimeSeconds queryString:(NSString *)queryString [CountlyHealthTracker.sharedInstance logConsecutiveBackoffRequest]; } + CLY_LOG_D(@"%s backoff evaluation is completed, decision: [%@], response time: [%ld] seconds", __FUNCTION__, result ? @"YES" : @"NO", responseTimeSeconds); + return result; } - (void)backoffCountdown { __weak typeof(self) weakSelf = self; - CLY_LOG_D(@"%s, backed off, countdown start for %f seconds", __FUNCTION__, [CountlyServerConfig.sharedInstance bomDuration]); + CLY_LOG_D(@"%s backoff countdown is started, queue processing will be paused for [%ld] seconds", __FUNCTION__, (long)[CountlyServerConfig.sharedInstance bomDuration]); atomic_store(&_backoff, YES); dispatch_time_t delay = dispatch_time(DISPATCH_TIME_NOW, (int64_t)([CountlyServerConfig.sharedInstance bomDuration] * NSEC_PER_SEC)); @@ -520,7 +590,7 @@ - (void)backoffCountdown if (!strongSelf) return; - CLY_LOG_D(@"%s, countdown finished, running tick in background thread", __FUNCTION__); + CLY_LOG_D(@"[CountlyConnectionManager] backoffCountdown, backoff countdown is finished, resuming queue processing on a background thread"); atomic_store(&strongSelf->_backoff, NO); [strongSelf proceedOnQueue]; }); @@ -528,39 +598,44 @@ - (void)backoffCountdown - (NSString*)extractAndRemoveParameter:(NSString **)queryString parameter:(NSString*)parameter { - CLY_LOG_D(@"%s, Extracting parameter: %@", __FUNCTION__, parameter); - if([*queryString containsString:parameter]) { NSString* parameterExtracted = [*queryString cly_valueForQueryStringKey:parameter]; if(parameterExtracted) { NSString* stringToRemove = [NSString stringWithFormat:@"&%@=%@",parameter,parameterExtracted]; *queryString = [*queryString stringByReplacingOccurrencesOfString:stringToRemove withString:@""]; - CLY_LOG_D(@"%s, Parameter extracted successfully: %@ = %@", __FUNCTION__, parameter, parameterExtracted); + CLY_LOG_D(@"%s parameter is extracted and removed from the query string, parameter: [%@], value length: [%lu]", __FUNCTION__, parameter, (unsigned long)parameterExtracted.length); return parameterExtracted; } - CLY_LOG_D(@"%s, Parameter found but value extraction failed for: %@", __FUNCTION__, parameter); + CLY_LOG_W(@"%s parameter is present in the query string but its value could not be extracted, parameter: [%@]", __FUNCTION__, parameter); } else { - CLY_LOG_D(@"%s, Parameter not found in query string: %@", __FUNCTION__, parameter); + CLY_LOG_V(@"%s parameter is not present in the query string, parameter: [%@]", __FUNCTION__, parameter); } return nil; } - (void)logRequest:(NSURLRequest *)request { - NSString* bodyAsString = @""; NSInteger sentSize = request.URL.absoluteString.length; if (request.HTTPBody) { - bodyAsString = [request.HTTPBody cly_stringUTF8]; - if (!bodyAsString) - bodyAsString = @"Picture uploading..."; - sentSize += request.HTTPBody.length; } - CLY_LOG_D(@"%s, request:[ <%p> ] started. [%@] %@ %@", __FUNCTION__, (id)request, request.HTTPMethod, request.URL.absoluteString, bodyAsString); - CLY_LOG_V(@"Approximate sent data size for request <%p> is %ld bytes.", (id)request, (long)sentSize); + CLY_LOG_I(@"%s sending request, endpoint: [%@], method: [%@], approximate sent data size: [%ld] bytes, body size: [%ld] bytes", __FUNCTION__, request.URL.path, request.HTTPMethod, (long)sentSize, (long)request.HTTPBody.length); + + if (CountlyInternalLogIsEnabled(CLYInternalLogLevelDebug)) + { + NSString* bodyAsString = @""; + if (request.HTTPBody) + { + bodyAsString = [request.HTTPBody cly_stringUTF8]; + if (!bodyAsString) + bodyAsString = @"Picture uploading..."; + } + + CLY_LOG_D(@"%s request detail, request: [<%p>], method: [%@], URL: [%@], body: [%@]", __FUNCTION__, request, request.HTTPMethod, request.URL.absoluteString, bodyAsString); + } } #pragma mark --- @@ -578,29 +653,31 @@ - (void)beginSession return; } -#if TARGET_OS_IOS || TARGET_OS_TV - if (!CountlyCommon.sharedInstance.manualSessionHandling && [UIApplication sharedApplication].applicationState == UIApplicationStateBackground) { - CLY_LOG_W(@"%s App is in the background, 'beginSession' will be ignored", __FUNCTION__); +#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION + if (CountlyServerConfig.sharedInstance.automaticSessionTrackingEnabled && [UIApplication sharedApplication].applicationState == UIApplicationStateBackground) { + CLY_LOG_D(@"%s App is in the background, 'beginSession' will be ignored", __FUNCTION__); return; } #elif TARGET_OS_OSX - if (!CountlyCommon.sharedInstance.manualSessionHandling && ![NSApplication sharedApplication].isActive) { - CLY_LOG_W(@"%s App is not active, 'beginSession' will be ignored", __FUNCTION__); + if (CountlyServerConfig.sharedInstance.automaticSessionTrackingEnabled && ![NSApplication sharedApplication].isActive) { + CLY_LOG_D(@"%s App is not active, 'beginSession' will be ignored", __FUNCTION__); return; } #elif TARGET_OS_WATCH - if (!CountlyCommon.sharedInstance.manualSessionHandling && [WKExtension sharedExtension].applicationState == WKApplicationStateBackground) { - CLY_LOG_W(@"%s App is in the background, 'beginSession' will be ignored", __FUNCTION__); + if (CountlyServerConfig.sharedInstance.automaticSessionTrackingEnabled && [WKExtension sharedExtension].applicationState == WKApplicationStateBackground) { + CLY_LOG_D(@"%s watch app is in the background, 'beginSession' will be ignored", __FUNCTION__); return; } #endif - if([Countly.user hasUnsyncedChanges]) + if ([CountlyUserDetails.sharedInstance hasUnsyncedChanges]) { - [Countly.user save]; + [CountlyUserDetails.sharedInstance save]; } isSessionStarted = YES; + // the manual session API refuses to begin while automatic session tracking is on, so a session begun now is the SDK's own + isSessionStartedAutomatically = CountlyServerConfig.sharedInstance.automaticSessionTrackingEnabled; lastSessionStartTime = NSDate.date.timeIntervalSince1970; unsentSessionLength = 0.0; @@ -614,10 +691,6 @@ - (void)beginSession queryString = [queryString stringByAppendingString:locationRelatedInfoQueryString]; } - NSString* attributionQueryString = [self attributionQueryString]; - if (attributionQueryString) - queryString = [queryString stringByAppendingString:attributionQueryString]; - [CountlyPersistency.sharedInstance addToQueue:queryString]; [CountlyCommon.sharedInstance recordOrientation]; @@ -634,13 +707,13 @@ - (void)updateSession return; if (!isSessionStarted) { - CLY_LOG_W(@"%s No session is running, this 'updateSession' will be ignored", __FUNCTION__); + CLY_LOG_D(@"%s No session is running, this 'updateSession' will be ignored", __FUNCTION__); return; } - if([Countly.user hasUnsyncedChanges]) + if ([CountlyUserDetails.sharedInstance hasUnsyncedChanges]) { - [Countly.user save]; + [CountlyUserDetails.sharedInstance save]; } NSString* queryString = [[self queryEssentials] stringByAppendingFormat:@"&%@=%d", @@ -660,11 +733,12 @@ - (void)endSession return; if (!isSessionStarted) { - CLY_LOG_W(@"%s No session is running, this 'endSession' will be ignored", __FUNCTION__); + CLY_LOG_D(@"%s No session is running, this 'endSession' will be ignored", __FUNCTION__); return; } isSessionStarted = NO; + isSessionStartedAutomatically = NO; NSString* queryString = [[self queryEssentials] stringByAppendingFormat:@"&%@=%@&%@=%d", kCountlyQSKeySessionEnd, @"1", kCountlyQSKeySessionDuration, (int)[self sessionLengthInSeconds]]; @@ -680,9 +754,9 @@ - (void)endSession - (void)sendEventsWithSaveIfNeeded { - if([Countly.user hasUnsyncedChanges]) + if ([CountlyUserDetails.sharedInstance hasUnsyncedChanges]) { - [Countly.user save]; + [CountlyUserDetails.sharedInstance save]; } else { @@ -779,15 +853,19 @@ - (void)sendUserDetails:(NSString *)userDetails - (void)sendCrashReport:(NSString *)report immediately:(BOOL)immediately; { + // NOTE: this method is reachable from CountlySignalHandler through + // CountlyUncaughtExceptionHandler and CountlyExceptionHandler, so every log on it stays at Debug or + // lower. CountlyInternalLog would otherwise call into CountlyHealthTracker, which touches + // dispatch_once, dispatch_queue_create and dispatch_async, none of which are async-signal-safe. if (!CountlyServerConfig.sharedInstance.networkingEnabled) { - CLY_LOG_D(@"'sendCrashReport' is aborted: SDK Networking is disabled from server config!"); + CLY_LOG_D(@"%s crash report is dropped, reason: SDK networking is disabled from server config, crash report length: [%lu]", __FUNCTION__, (unsigned long)report.length); return; } if (!report) { - CLY_LOG_W(@"Crash report is nil. Converting to JSON may have failed due to custom objects in initial config's crashSegmentation property."); + CLY_LOG_D(@"%s crash report is nil, converting it to JSON may have failed due to custom objects in the initial config crashSegmentation property", __FUNCTION__); return; } @@ -806,12 +884,12 @@ - (void)sendCrashReport:(NSString *)report immediately:(BOOL)immediately; [self sendEventsWithSaveIfNeeded]; - if (!CountlyCommon.sharedInstance.manualSessionHandling) + if (CountlyServerConfig.sharedInstance.automaticSessionTrackingEnabled) [self endSession]; if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) { - CLY_LOG_D(@"Device ID is set as CLYTemporaryDeviceID! Crash report stored to be sent later!"); + CLY_LOG_D(@"%s crash report is stored to be sent later, reason: device ID is the temporary device ID, crash report length: [%lu]", __FUNCTION__, (unsigned long)report.length); [CountlyPersistency.sharedInstance addToQueue:queryString]; [CountlyPersistency.sharedInstance saveToFileSync]; @@ -820,27 +898,50 @@ - (void)sendCrashReport:(NSString *)report immediately:(BOOL)immediately; [CountlyPersistency.sharedInstance saveToFileSync]; + // queued without the app version, which the queue adds to every request itself + NSString* storedQueryString = queryString; queryString = [queryString stringByAppendingFormat:@"&%@=%@", kCountlyAppVersionKey, CountlyDeviceInfo.appVersion]; - + NSString* serverInputEndpoint = [self.host stringByAppendingString:kCountlyEndpointI]; NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:serverInputEndpoint]]; request.HTTPMethod = @"POST"; request.HTTPBody = [[self appendChecksum:queryString] cly_dataUTF8]; + CLY_LOG_I(@"%s sending crash report immediately, endpoint: [%@], method: [%@], body size: [%lu] bytes", __FUNCTION__, kCountlyEndpointI, request.HTTPMethod, (unsigned long)request.HTTPBody.length); + + // the completion and the timeout below race to settle the report, and only the first one may queue it for later + NSObject* settleLock = NSObject.new; + __block BOOL isSettled = NO; + BOOL (^settle)(void) = ^BOOL { + @synchronized (settleLock) { + BOOL isFirst = !isSettled; + isSettled = YES; + return isFirst; + } + }; + + // a session of its own: a crash inside a completion of the queue's session holds that session's serial delegate queue, + // where this report's completion would then never run + NSURLSession* crashReportSession = [NSURLSession sessionWithConfiguration:self.URLSessionConfiguration delegate:(self.pinnedCertificates ? self : nil) delegateQueue:NSOperationQueue.new]; dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); - [[self.URLSession dataTaskWithRequest:request completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) + [[crashReportSession dataTaskWithRequest:request completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { - if (error || ![self isRequestSuccessful:response data:data]) + BOOL isFailed = error || ![self isRequestSuccessful:response data:data]; + if (isFailed) { - CLY_LOG_D(@"%s, request: [ %p ] failed! %@: %@", __FUNCTION__, request, error ? @"Error" : @"Server reply", error ?: [data cly_stringUTF8]); - [CountlyPersistency.sharedInstance addToQueue:queryString]; - [CountlyPersistency.sharedInstance saveToFileSync]; + CLY_LOG_D(@"[CountlyConnectionManager] sendCrashReport completion, immediate crash report request failed and it is queued to be sent later, endpoint: [%@], status code: [%ld], response size: [%ld] bytes, error domain: [%@], error code: [%ld], error description: [%@]", request.URL.path, (long)((NSHTTPURLResponse*)response).statusCode, (long)data.length, error.domain ?: @"none", (long)error.code, error.localizedDescription ?: @"none"); } else { - CLY_LOG_D(@"Request <%p> successfully completed.", request); + CLY_LOG_D(@"[CountlyConnectionManager] sendCrashReport completion, immediate crash report request completed successfully, endpoint: [%@], response size: [%ld] bytes", request.URL.path, (long)data.length); + } + + if (settle() && isFailed) + { + [CountlyPersistency.sharedInstance addToQueue:storedQueryString]; + [CountlyPersistency.sharedInstance saveToFileSync]; } dispatch_semaphore_signal(semaphore); @@ -849,7 +950,14 @@ - (void)sendCrashReport:(NSString *)report immediately:(BOOL)immediately; [self logRequest:request]; - dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER); + if (dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kCountlyCrashReportSendTimeout * NSEC_PER_SEC))) != 0 && settle()) + { + CLY_LOG_D(@"%s immediate crash report request did not complete in time, it is queued to be sent later, timeout: [%.0f] seconds", __FUNCTION__, kCountlyCrashReportSendTimeout); + [CountlyPersistency.sharedInstance addToQueue:storedQueryString]; + [CountlyPersistency.sharedInstance saveToFileSync]; + } + + [crashReportSession finishTasksAndInvalidate]; } - (void)sendOldDeviceID:(NSString *)oldDeviceID @@ -862,13 +970,26 @@ - (void)sendOldDeviceID:(NSString *)oldDeviceID [self proceedOnQueue]; } -- (void)sendAttribution +- (BOOL)sendSdkLogs:(NSString *)sdkLogsJSON { - NSString * attributionQueryString = [self attributionQueryString]; - if (!attributionQueryString) + NSString* queryString = [[self queryEssentials] stringByAppendingFormat:@"&%@=%@", kCountlyQSSdkLogs, sdkLogsJSON.cly_URLEscaped]; + + if (![CountlyPersistency.sharedInstance addToQueue:queryString]) + return NO; + + [self proceedOnQueue]; + return YES; +} + +- (void)sendConnectionTestResults:(NSString *)resultsJSON deviceID:(NSString *)deviceID deviceIDType:(NSUInteger)deviceIDType +{ + if (!self.appKey.length) + { + CLY_LOG_D(@"%s connection test report is dropped, reason: there is no app key, the SDK is not started or is halting", __FUNCTION__); return; + } - NSString* queryString = [[self queryEssentials] stringByAppendingString:attributionQueryString]; + NSString* queryString = [[self queryEssentialsForDeviceID:deviceID deviceIDType:deviceIDType] stringByAppendingFormat:@"&%@=%@", kCountlyQSConnectionTestResults, resultsJSON.cly_URLEscaped]; [CountlyPersistency.sharedInstance addToQueue:queryString]; @@ -885,6 +1006,8 @@ - (void)sendDirectAttributionWithCampaignID:(NSString *)campaignID andCampaignUs [queryString appendFormat:@"&%@=%@", kCountlyQSKeyCampaignUser, campaignUserID]; } + CLY_LOG_I(@"%s direct attribution request is being queued, campaign ID: [%@], campaign user ID provided: [%@]", __FUNCTION__, campaignID, campaignUserID.length ? @"YES" : @"NO"); + [CountlyPersistency.sharedInstance addToQueue:queryString.copy]; [self proceedOnQueue]; @@ -942,7 +1065,7 @@ - (void)sendEnrollABRequestForKeys:(NSArray*)keys } queryString = [queryString stringByAppendingFormat:@"%@%@%@", kCountlyEndPointOverrideTag, kCountlyEndpointO, kCountlyEndpointSDK]; - + [CountlyPersistency.sharedInstance addToQueue:queryString]; [self proceedOnQueue]; @@ -951,12 +1074,14 @@ - (void)sendEnrollABRequestForKeys:(NSArray*)keys - (void)sendExitABRequestForKeys:(NSArray*)keys { NSString* queryString = [[self queryEssentials] stringByAppendingFormat:@"&%@=%@", kCountlyQSKeyMethod, kCountlyRCKeyABOptOut]; - + if (keys) { queryString = [queryString stringByAppendingFormat:@"&%@=%@", kCountlyRCKeyKeys, [keys cly_JSONify]]; } + CLY_LOG_I(@"%s A/B test exit request is being queued, key count: [%lu]", __FUNCTION__, (unsigned long)keys.count); + [CountlyPersistency.sharedInstance addToQueue:queryString]; [self proceedOnQueue]; @@ -975,12 +1100,14 @@ - (void)addDirectRequest:(NSDictionary *)requestParamete { if (mutableRequestParameters[reservedKey]) { - CLY_LOG_W(@"A reserved query string key detected in direct request parameters and it will be removed: %@", reservedKey); + CLY_LOG_W(@"%s a reserved query string key is detected in the direct request parameters and it will be removed, key: [%@]", __FUNCTION__, reservedKey); [mutableRequestParameters removeObjectForKey:reservedKey]; } } mutableRequestParameters[@"dr"] = [NSNumber numberWithInt:1]; + + CLY_LOG_I(@"%s direct request is being queued, parameter count: [%lu]", __FUNCTION__, (unsigned long)mutableRequestParameters.count); NSMutableString* queryString = [self queryEssentials].mutableCopy; [mutableRequestParameters enumerateKeysAndObjectsUsingBlock:^(NSString * key, NSString * value, BOOL * stop) @@ -996,11 +1123,17 @@ - (void)addDirectRequest:(NSDictionary *)requestParamete #pragma mark --- - (NSString *)queryEssentials +{ + return [self queryEssentialsForDeviceID:CountlyDeviceInfo.sharedInstance.deviceID deviceIDType:CountlyDeviceInfo.sharedInstance.deviceIDTypeValue]; +} + +/// The parameters every request carries, built for the given device ID and its type value. +- (NSString *)queryEssentialsForDeviceID:(NSString *)deviceID deviceIDType:(NSUInteger)deviceIDType { return [NSString stringWithFormat:@"%@=%@&%@=%@&%@=%d&%@=%lld&%@=%d&%@=%d&%@=%d&%@=%@&%@=%@", kCountlyQSKeyAppKey, self.appKey.cly_URLEscaped, - kCountlyQSKeyDeviceID, CountlyDeviceInfo.sharedInstance.deviceID.cly_URLEscaped, - kCountlyQSKeyDeviceIDType, (int)CountlyDeviceInfo.sharedInstance.deviceIDTypeValue, + kCountlyQSKeyDeviceID, deviceID.cly_URLEscaped, + kCountlyQSKeyDeviceIDType, (int)deviceIDType, kCountlyQSKeyTimestamp, (long long)(CountlyCommon.sharedInstance.uniqueTimestamp * 1000), kCountlyQSKeyTimeHourOfDay, (int)CountlyCommon.sharedInstance.hourOfDay, kCountlyQSKeyTimeDayOfWeek, (int)CountlyCommon.sharedInstance.dayOfWeek, @@ -1063,19 +1196,6 @@ - (NSString *)locationRelatedInfoQueryString return nil; } -- (NSString *)attributionQueryString -{ - if (!CountlyConsentManager.sharedInstance.consentForAttribution) - return nil; - - if (!CountlyCommon.sharedInstance.attributionID) - return nil; - - NSDictionary* attribution = @{kCountlyQSKeyIDFA: CountlyCommon.sharedInstance.attributionID}; - - return [NSString stringWithFormat:@"&%@=%@", kCountlyQSKeyAttributionID, [attribution cly_JSONify]]; -} - - (NSMutableData *)pictureUploadDataForQueryString:(NSString *)queryString { #if (TARGET_OS_IOS || TARGET_OS_VISION) @@ -1087,12 +1207,15 @@ - (NSMutableData *)pictureUploadDataForQueryString:(NSString *)queryString return nil; NSDictionary* pathDictionary = [NSJSONSerialization JSONObjectWithData:[unescapedUserDetails cly_dataUTF8] options:0 error:nil]; - localPicturePath = pathDictionary[kCountlyLocalPicturePath]; + if (![pathDictionary isKindOfClass:NSDictionary.class]) + return nil; - if (!localPicturePath.length) + // a request queued with a null path goes out as plain user details instead of failing every send + localPicturePath = pathDictionary[kCountlyLocalPicturePath]; + if (![localPicturePath isKindOfClass:NSString.class] || !localPicturePath.length) return nil; - CLY_LOG_D(@"Local picture path successfully extracted from query string: %@", localPicturePath); + CLY_LOG_D(@"%s local picture path is extracted from the query string, path length: [%lu], file extension: [%@]", __FUNCTION__, (unsigned long)localPicturePath.length, localPicturePath.pathExtension.lowercaseString); NSArray* allowedFileTypes = @[@"gif", @"png", @"jpg", @"jpeg"]; NSString* fileExt = localPicturePath.pathExtension.lowercaseString; @@ -1100,7 +1223,7 @@ - (NSMutableData *)pictureUploadDataForQueryString:(NSString *)queryString if (fileExtIndex == NSNotFound) { - CLY_LOG_W(@"Unsupported file extension for picture upload: %@", fileExt); + CLY_LOG_W(@"%s picture upload is skipped, reason: unsupported file extension, extension: [%@]", __FUNCTION__, fileExt); return nil; } @@ -1108,11 +1231,11 @@ - (NSMutableData *)pictureUploadDataForQueryString:(NSString *)queryString if (!imageData) { - CLY_LOG_W(@"Local picture data can not be read!"); + CLY_LOG_W(@"%s picture upload is skipped, reason: local picture data can not be read, extension: [%@]", __FUNCTION__, fileExt); return nil; } - CLY_LOG_D(@"Local picture data read successfully."); + CLY_LOG_D(@"%s local picture data is read successfully, size: [%lu] bytes", __FUNCTION__, (unsigned long)imageData.length); //NOTE: Overcome failing PNG file upload if data is directly read from disk if (fileExtIndex == 1) @@ -1151,6 +1274,7 @@ - (NSString *)appendChecksum:(NSString *)queryString if (self.secretSalt) { NSString* checksum = [[queryString stringByAppendingString:self.secretSalt] cly_SHA256]; + CLY_LOG_V(@"%s checksum is appended to the query string, query string length: [%lu]", __FUNCTION__, (unsigned long)queryString.length); return [queryString stringByAppendingFormat:@"&%@=%@", kCountlyQSKeyChecksum256, checksum]; } @@ -1175,29 +1299,39 @@ - (BOOL)isRequestSuccessful:(NSURLResponse *)response data:(NSData *)data if (code >= 200 && code < 300) { NSError* error = nil; - NSDictionary* serverReply = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; + NSDictionary* serverReply = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; if (error) { - CLY_LOG_W(@"Server reply is not a valid JSON!"); + CLY_LOG_E(@"%s server reply is not a valid JSON, the request is considered failed, response size: [%ld] bytes, error description: [%@]", __FUNCTION__, (long)data.length, error.localizedDescription); + return NO; + } + + // a proxy or a misconfigured host can answer a list, which has no result field to read + if (![serverReply isKindOfClass:NSDictionary.class]) + { + CLY_LOG_E(@"%s server reply is not a JSON object, the request is considered failed, response size: [%ld] bytes, replyType: [%@]", __FUNCTION__, (long)data.length, NSStringFromClass([serverReply class])); return NO; } - CLY_LOG_V(@"%s, response:[ %@ ] request:[ %@ ]", __FUNCTION__, serverReply, ((NSHTTPURLResponse*)response).URL); - + CLY_LOG_D(@"%s server reply detail, full response URL: [%@], parsed server reply: [%@]", __FUNCTION__, response.URL.absoluteString, serverReply); + NSString* result = serverReply[@"result"]; if(result) { return YES; } + + CLY_LOG_E(@"%s server reply does not contain the result field, the request is considered failed, response size: [%ld] bytes", __FUNCTION__, (long)data.length); return NO; } else { - CLY_LOG_V(@"HTTP status code is not 2XX series."); + CLY_LOG_V(@"%s HTTP status code is not in the 2XX series, status code: [%ld], response size: [%ld] bytes", __FUNCTION__, (long)code, (long)data.length); + CLY_LOG_D(@"%s non 2XX response detail, full response URL: [%@]", __FUNCTION__, response.URL.absoluteString); return NO; } } @@ -1220,7 +1354,7 @@ - (NSURLSession *)URLSession { if (self.pinnedCertificates) { - CLY_LOG_D(@"%d pinned certificate(s) specified in config.", (int)self.pinnedCertificates.count); + CLY_LOG_D(@"%s creating the URL session with certificate pinning, pinned certificate count: [%d]", __FUNCTION__, (int)self.pinnedCertificates.count); _URLSession = [NSURLSession sessionWithConfiguration:self.URLSessionConfiguration delegate:self delegateQueue:nil]; } else @@ -1282,7 +1416,7 @@ - (void)URLSession:(NSURLSession *)session didReceiveChallenge:(NSURLAuthenticat if (serverKey != NULL && localKey != NULL && [(__bridge id)serverKey isEqual:(__bridge id)localKey]) { - CLY_LOG_D(@"Pinned certificate and server certificate match."); + CLY_LOG_D(@"%s pinned certificate and server certificate match", __FUNCTION__); isLocalAndServerCertMatch = YES; CFRelease(localKey); @@ -1311,18 +1445,18 @@ - (void)URLSession:(NSURLSession *)session didReceiveChallenge:(NSURLAuthenticat if (isLocalAndServerCertMatch && isServerCertValid) { - CLY_LOG_D(@"Pinned certificate check is successful. Proceeding with request."); + CLY_LOG_D(@"%s pinned certificate check is successful, proceeding with the request, host: [%@]", __FUNCTION__, challenge.protectionSpace.host); completionHandler(NSURLSessionAuthChallengeUseCredential, [NSURLCredential credentialForTrust:serverTrust]); } else { if (!isLocalAndServerCertMatch) - CLY_LOG_W(@"Pinned certificate and server certificate does not match!"); + CLY_LOG_D(@"%s pinned certificate and server certificate do not match", __FUNCTION__); if (!isServerCertValid) - CLY_LOG_W(@"Server certificate is not valid! SecTrustEvaluate result is: %u", serverTrustResult); + CLY_LOG_D(@"%s server certificate is not valid, SecTrustEvaluate result: [%u]", __FUNCTION__, (unsigned int)serverTrustResult); - CLY_LOG_D(@"Pinned certificate check failed! Cancelling request."); + CLY_LOG_E(@"%s pinned certificate check failed, cancelling the request, host: [%@]", __FUNCTION__, challenge.protectionSpace.host); completionHandler(NSURLSessionAuthChallengeCancelAuthenticationChallenge, NULL); } @@ -1338,18 +1472,18 @@ - (void)registerRequestCallback:(NSString *)callbackID callback:(CLYRequestCallb { if (!callbackID || callbackID.length == 0) { - CLY_LOG_W(@"%s, Callback ID is nil or empty. Callback registration ignored.", __FUNCTION__); + CLY_LOG_W(@"%s request callback registration is ignored, reason: callback ID is nil or empty", __FUNCTION__); return; } if (!callback) { - CLY_LOG_W(@"%s, Callback block is nil. Callback registration ignored.", __FUNCTION__); + CLY_LOG_W(@"%s request callback registration is ignored, reason: callback block is nil, callback ID: [%@]", __FUNCTION__, callbackID); return; } dispatch_sync(_callbackQueue, ^{ - CLY_LOG_D(@"%s, Registering request callback with ID: %@", __FUNCTION__, callbackID); + CLY_LOG_D(@"[CountlyConnectionManager] registerRequestCallback, registering a request callback, callback ID: [%@], registered callback count before registration: [%lu]", callbackID, (unsigned long)self.internalRequestCallbacks.count); self.internalRequestCallbacks[callbackID] = callback; }); } @@ -1358,12 +1492,12 @@ - (void)removeRequestCallback:(NSString *)callbackID { if (!callbackID || callbackID.length == 0) { - CLY_LOG_W(@"%s, Callback ID is nil or empty. Callback removal ignored.", __FUNCTION__); + CLY_LOG_W(@"%s request callback removal is ignored, reason: callback ID is nil or empty", __FUNCTION__); return; } dispatch_sync(_callbackQueue, ^{ - CLY_LOG_D(@"%s, Removing request callback with ID: %@", __FUNCTION__, callbackID); + CLY_LOG_D(@"[CountlyConnectionManager] removeRequestCallback, removing a request callback, callback ID: [%@], registered callback count before removal: [%lu]", callbackID, (unsigned long)self.internalRequestCallbacks.count); [self.internalRequestCallbacks removeObjectForKey:callbackID]; }); } @@ -1372,13 +1506,13 @@ - (void)addQueueFlushRunnable:(CLYQueueFlushRunnable)runnable { if (!runnable) { - CLY_LOG_W(@"%s, Runnable is nil. Cannot add to queue flush runnables.", __FUNCTION__); + CLY_LOG_W(@"%s queue flush runnable is not added, reason: runnable is nil", __FUNCTION__); return; } CLYQueueFlushRunnable runnableCopy = [runnable copy]; dispatch_sync(_callbackQueue, ^{ - CLY_LOG_D(@"%s, Adding queue flush runnable. Total count: %lu", __FUNCTION__, (unsigned long)(self->_queueFlushRunnables.count + 1)); + CLY_LOG_D(@"[CountlyConnectionManager] addQueueFlushRunnable, adding a queue flush runnable, total runnable count: [%lu]", (unsigned long)(self->_queueFlushRunnables.count + 1)); [self->_queueFlushRunnables addObject:runnableCopy]; }); } @@ -1386,16 +1520,18 @@ - (void)addQueueFlushRunnable:(CLYQueueFlushRunnable)runnable - (void)clearQueueFlushRunnables { dispatch_sync(_callbackQueue, ^{ - CLY_LOG_D(@"%s, Clearing %lu queue flush runnables.", __FUNCTION__, (unsigned long)self->_queueFlushRunnables.count); + CLY_LOG_D(@"[CountlyConnectionManager] clearQueueFlushRunnables, clearing all queue flush runnables, count: [%lu]", (unsigned long)self->_queueFlushRunnables.count); [self->_queueFlushRunnables removeAllObjects]; }); } - (void)addToQueueWithCallback:(NSString *)queryString callback:(CLYRequestCallback)callback { + // NOTE: reachable from CountlySignalHandler through sendCrashReport, keep every log here at + // Debug or lower. See the note on sendCrashReport. if (!queryString || queryString.length == 0) { - CLY_LOG_W(@"%s, Query string is nil or empty. Cannot add to queue with callback.", __FUNCTION__); + CLY_LOG_D(@"%s request is not queued, reason: query string is nil or empty, callback provided: [%@]", __FUNCTION__, (callback != nil) ? @"YES" : @"NO"); return; } @@ -1407,7 +1543,7 @@ - (void)addToQueueWithCallback:(NSString *)queryString callback:(CLYRequestCallb // Generate a unique callback ID NSString* callbackID = [[NSUUID UUID] UUIDString]; - CLY_LOG_D(@"%s, Adding request to queue with callback ID: %@", __FUNCTION__, callbackID); + CLY_LOG_D(@"%s queueing a request with a callback, callback ID: [%@], query string length: [%lu]", __FUNCTION__, callbackID, (unsigned long)queryString.length); // Register the callback [self registerRequestCallback:callbackID callback:callback]; diff --git a/ios/src/CountlyConnectionTest.h b/ios/src/CountlyConnectionTest.h new file mode 100644 index 00000000..7ef154b8 --- /dev/null +++ b/ios/src/CountlyConnectionTest.h @@ -0,0 +1,56 @@ +// CountlyConnectionTest.h +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import + +NS_ASSUME_NONNULL_BEGIN + +extern NSString* const kCountlyCTErrorTimeout; +extern NSString* const kCountlyCTErrorTransport; +extern NSString* const kCountlyCTErrorRedirected; +extern NSString* const kCountlyCTErrorNotRun; +extern NSString* const kCountlyCTErrorOpaque; + +/// Server armed connection test: a top level 'ct' on a live behavior settings response runs one battery of +/// parameterless GET probes and queues one 'ct_results' report. Nothing about it is persisted. +@interface CountlyConnectionTest : NSObject + ++ (instancetype)sharedInstance; +- (void)resetInstance; + +/// Deadline of a single probe request, in seconds. Exposed so tests can shrink it. +@property (nonatomic) NSTimeInterval perRequestTimeout; +/// Slack added to the battery cap on top of the per request deadlines, in seconds. Exposed so tests can shrink it. +@property (nonatomic) NSTimeInterval batteryCapExtra; +/// YES while a battery is in flight. A second delivery meanwhile is ignored. +@property (nonatomic, readonly) BOOL isBatteryRunning; + +/// Runs the battery on a background queue, once per delivery, and queues the report. scLatencyMs is the wall clock +/// of the behavior settings fetch that delivered the flag, negative when unknown. +/// The battery runs for the current user. +- (void)startBatteryWithServerConfigLatency:(long long)scLatencyMs; + +/// Runs the battery for the user whose settings fetch armed it: reports under that user, waits for the foreground, skips after a merge. +- (void)startBatteryWithServerConfigLatency:(long long)scLatencyMs deviceID:(NSString *)deviceID deviceIDType:(NSUInteger)deviceIDType deviceIDMergeCount:(NSUInteger)deviceIDMergeCount; + +/// Called when the app goes to the background. A battery running across it is discarded, since its probes stalled. +- (void)noteAppSuspended; + +/// Called when the app becomes active. Runs a battery the app going to the background interrupted again, while the server still awaits the report. +- (void)noteAppResumed; + +/// Resolves a probe path against the configured server URL, path prefix included, and appends the marker and cache buster. ++ (NSString *)probeURLForPath:(NSString *)path serverURL:(NSString *)serverURL; + +/// Grades one probe outcome. Returns nil when the Countly application answered, otherwise the 'e' reason. ++ (nullable NSString *)gradeStatus:(NSInteger)status failure:(nullable NSString *)failure requiresSuccessStatus:(BOOL)requiresSuccessStatus; + +/// Builds the 'ct_results' JSON from graded rows, enforcing the row, byte and 'e' length caps. +- (NSString *)buildReport:(NSArray *)rows; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/src/CountlyConnectionTest.m b/ios/src/CountlyConnectionTest.m new file mode 100644 index 00000000..ac759e55 --- /dev/null +++ b/ios/src/CountlyConnectionTest.m @@ -0,0 +1,498 @@ +// CountlyConnectionTest.m +// +// This code is provided under the MIT License. +// +// Please visit www.count.ly for more information. + +#import "CountlyCommon.h" +#import + +NSString* const kCountlyCTErrorTimeout = @"timeout"; +NSString* const kCountlyCTErrorTransport = @"transport error"; +NSString* const kCountlyCTErrorRedirected = @"redirected"; +NSString* const kCountlyCTErrorNotRun = @"not run: battery cap"; +// success qualifier of opaque (browser) probes, never produced here, but by cross SDK rule it survives the size cap +NSString* const kCountlyCTErrorOpaque = @"opaque"; + +// report keys +NSString* const kCountlyCTKeyTimestamp = @"ts"; +NSString* const kCountlyCTKeySDK = @"sdk"; +NSString* const kCountlyCTKeySDKName = @"name"; +NSString* const kCountlyCTKeySDKVersion = @"version"; +NSString* const kCountlyCTKeyResults = @"results"; +NSString* const kCountlyCTKeyFeature = @"f"; +NSString* const kCountlyCTKeyOk = @"ok"; +NSString* const kCountlyCTKeyStatus = @"st"; +NSString* const kCountlyCTKeyLatency = @"ms"; +NSString* const kCountlyCTKeyError = @"e"; +NSString* const kCountlyCTKeyPathCount = @"n"; + +// row definition keys, internal +static NSString* const kRowFeature = @"feature"; +static NSString* const kRowPaths = @"paths"; +static NSString* const kRowRequiresSuccess = @"requiresSuccess"; + +static const NSTimeInterval kCountlyCTDefaultPerRequestTimeout = 10; +static const NSTimeInterval kCountlyCTDefaultBatteryCapExtra = 30; +static const NSUInteger kCountlyCTMaxRows = 32; +static const NSUInteger kCountlyCTMaxReportBytes = 8 * 1024; +static const NSUInteger kCountlyCTMaxErrorLength = 256; + +@interface CountlyConnectionTest () +{ + atomic_bool batteryRunning; + atomic_uint suspendCount; +} +// atomic: set by the halting thread, read by the battery on the utility queue +@property (atomic) BOOL halted; +@property (atomic) BOOL appSuspended; +// the battery to run again once the app is active, for the same user and 'sc' latency, or nil +@property (atomic, copy) dispatch_block_t deferredBattery; +@end + +/// Seconds on a clock the system time can not move and that stands still while the device sleeps. +static NSTimeInterval CountlyCTUptime(void) +{ + return (NSTimeInterval)clock_gettime_nsec_np(CLOCK_UPTIME_RAW) / NSEC_PER_SEC; +} + +@implementation CountlyConnectionTest + +static CountlyConnectionTest *s_sharedInstance = nil; +static dispatch_once_t onceToken; + ++ (instancetype)sharedInstance +{ + dispatch_once(&onceToken, ^{ + s_sharedInstance = self.new; + }); + return s_sharedInstance; +} + +- (instancetype)init +{ + if (self = [super init]) + { + _perRequestTimeout = kCountlyCTDefaultPerRequestTimeout; + _batteryCapExtra = kCountlyCTDefaultBatteryCapExtra; + atomic_store(&batteryRunning, false); + atomic_store(&suspendCount, 0); + } + return self; +} + +- (void)resetInstance +{ + CLY_LOG_I(@"%s", __FUNCTION__); + // a battery still in flight keeps the old instance alive and drops its report on seeing this + self.halted = YES; + onceToken = 0; + s_sharedInstance = nil; +} + +- (BOOL)isBatteryRunning +{ + return atomic_load(&batteryRunning); +} + +/// Feature rows in report order. A row with no paths is the 'sc' row, measured from the fetch that armed the test. +/// Rows 'core' and 'feedback-assets' have no legitimate 4xx answer, so anything but a 2xx is a fault there. ++ (NSArray *)rows +{ + static NSArray *rows = nil; + static dispatch_once_t rowsOnce; + dispatch_once(&rowsOnce, ^{ + rows = @[ + @{kRowFeature: @"core", kRowRequiresSuccess: @YES, kRowPaths: @[@"/o/ping"]}, + @{kRowFeature: @"core-write", kRowRequiresSuccess: @NO, kRowPaths: @[@"/i"]}, + @{kRowFeature: @"sc", kRowRequiresSuccess: @NO, kRowPaths: @[]}, + @{kRowFeature: @"rc", kRowRequiresSuccess: @NO, kRowPaths: @[@"/o/sdk?method=rc"]}, + @{kRowFeature: @"ab", kRowRequiresSuccess: @NO, kRowPaths: @[@"/o/sdk?method=ab_fetch_variants"]}, + @{kRowFeature: @"feedback", kRowRequiresSuccess: @NO, kRowPaths: @[@"/o/sdk?method=feedback"]}, + @{kRowFeature: @"feedback-widget", kRowRequiresSuccess: @NO, kRowPaths: @[@"/o/surveys/nps/widget", @"/o/surveys/survey/widget", @"/o/feedback/widget"]}, + @{kRowFeature: @"feedback-submit", kRowRequiresSuccess: @NO, kRowPaths: @[@"/i/feedback/inputs"]}, + @{kRowFeature: @"content", kRowRequiresSuccess: @NO, kRowPaths: @[@"/o/sdk/content"]}, + @{kRowFeature: @"feedback-page", kRowRequiresSuccess: @NO, kRowPaths: @[@"/feedback/nps", @"/feedback/survey", @"/feedback/rating"]}, + @{kRowFeature: @"feedback-assets", kRowRequiresSuccess: @YES, kRowPaths: @[@"/surveys/images/ct-probe.png", @"/star-rating/images/ct-probe.png"]}, + @{kRowFeature: @"content-page", kRowRequiresSuccess: @NO, kRowPaths: @[@"/_external/content/"]}, + ]; + }); + return rows; +} + +/// One graded row of the report, without the optional reason and path count. +static NSMutableDictionary *CountlyCTResultRow(NSString *feature, BOOL ok, NSInteger status, long long latencyMs) +{ + return [@{kCountlyCTKeyFeature: feature, kCountlyCTKeyOk: @(ok), kCountlyCTKeyStatus: @(status), kCountlyCTKeyLatency: @(latencyMs)} mutableCopy]; +} + +#pragma mark - Battery + +- (void)startBatteryWithServerConfigLatency:(long long)scLatencyMs +{ + [self startBatteryWithServerConfigLatency:scLatencyMs + deviceID:CountlyDeviceInfo.sharedInstance.deviceID + deviceIDType:CountlyDeviceInfo.sharedInstance.deviceIDTypeValue + deviceIDMergeCount:CountlyCommon.sharedInstance.deviceIDMergeCount]; +} + +- (void)startBatteryWithServerConfigLatency:(long long)scLatencyMs deviceID:(NSString *)deviceID deviceIDType:(NSUInteger)deviceIDType deviceIDMergeCount:(NSUInteger)deviceIDMergeCount +{ + if (self.halted) + { + CLY_LOG_D(@"%s, module halted, ignoring", __FUNCTION__); + return; + } + + if (!CountlyServerConfig.sharedInstance.networkingEnabled) + { + // the spec does not cover it, honouring the operator's own kill switch was judged safer than probe traffic + CLY_LOG_D(@"%s, networking is disabled by server config, ignoring", __FUNCTION__); + return; + } + + if (!CountlyServerConfig.sharedInstance.trackingEnabled) + { + // the request queue refuses every request while tracking is off, so the report could never go out + CLY_LOG_D(@"%s, tracking is disabled by server config, ignoring", __FUNCTION__); + return; + } + + if (deviceIDMergeCount != CountlyCommon.sharedInstance.deviceIDMergeCount) + { + CLY_LOG_D(@"%s, the user it was armed for was merged into another since, the server cancelled the run, ignoring", __FUNCTION__); + return; + } + + // read before the background check, which noteAppSuspended sets before counting, so no suspension goes unseen + unsigned int suspendCountAtStart = atomic_load(&suspendCount); + if (self.appSuspended) + { + CLY_LOG_D(@"%s, the app is in the background where probes stall, the battery runs once it is active again", __FUNCTION__); + [self deferBatteryWithServerConfigLatency:scLatencyMs deviceID:deviceID deviceIDType:deviceIDType deviceIDMergeCount:deviceIDMergeCount]; + return; + } + + bool expected = false; + if (!atomic_compare_exchange_strong(&batteryRunning, &expected, true)) + { + CLY_LOG_D(@"%s, a battery is already running, ignoring this delivery", __FUNCTION__); + return; + } + + CLY_LOG_I(@"%s, connection test armed by the server, running the probe battery", __FUNCTION__); + + dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{ + [self runBatteryAndReport:scLatencyMs deviceID:deviceID deviceIDType:deviceIDType deviceIDMergeCount:deviceIDMergeCount suspendCountAtStart:suspendCountAtStart]; + atomic_store(&self->batteryRunning, false); + [self rerunInterruptedBattery]; + }); +} + +/// Keeps the battery to run again once the app is active, for the same user and with the same 'sc' latency. +- (void)deferBatteryWithServerConfigLatency:(long long)scLatencyMs deviceID:(NSString *)deviceID deviceIDType:(NSUInteger)deviceIDType deviceIDMergeCount:(NSUInteger)deviceIDMergeCount +{ + __weak CountlyConnectionTest *weakSelf = self; + self.deferredBattery = ^{ + [weakSelf startBatteryWithServerConfigLatency:scLatencyMs deviceID:deviceID deviceIDType:deviceIDType deviceIDMergeCount:deviceIDMergeCount]; + }; +} + +- (void)noteAppSuspended +{ + self.appSuspended = YES; + atomic_fetch_add(&suspendCount, 1); +} + +- (void)noteAppResumed +{ + self.appSuspended = NO; + [self rerunInterruptedBattery]; +} + +/// Starts the battery the background interrupted or held back, once the app is back in the foreground and no battery runs. +- (void)rerunInterruptedBattery +{ + dispatch_block_t deferredBattery = self.deferredBattery; + if (!deferredBattery || self.appSuspended || self.halted || self.isBatteryRunning) + return; + + self.deferredBattery = nil; + CLY_LOG_D(@"%s, the battery the app going to the background interrupted runs again", __FUNCTION__); + deferredBattery(); +} + +/// Runs every row and queues the report under the armed user, unless halt, the background or a merge got in the way. +- (void)runBatteryAndReport:(long long)scLatencyMs deviceID:(NSString *)deviceID deviceIDType:(NSUInteger)deviceIDType deviceIDMergeCount:(NSUInteger)deviceIDMergeCount suspendCountAtStart:(unsigned int)suspendCountAtStart +{ + NSArray *rows = [self runBattery:scLatencyMs suspendCountAtStart:suspendCountAtStart]; + + if (self.halted) + { + CLY_LOG_D(@"%s, halted during the battery, dropping the report", __FUNCTION__); + return; + } + + if (atomic_load(&suspendCount) != suspendCountAtStart) + { + CLY_LOG_D(@"%s, the app went to the background during the battery, its probes stalled, dropping the report and running it again in the foreground", __FUNCTION__); + [self deferBatteryWithServerConfigLatency:scLatencyMs deviceID:deviceID deviceIDType:deviceIDType deviceIDMergeCount:deviceIDMergeCount]; + return; + } + + if (deviceIDMergeCount != CountlyCommon.sharedInstance.deviceIDMergeCount) + { + // under the merged ID the server rejects it, and under the old ID it would create that user again + CLY_LOG_D(@"%s, the user the battery was armed for was merged during the battery, the server cancelled the run, dropping the report", __FUNCTION__); + return; + } + + NSString *report = [self buildReport:rows]; + CLY_LOG_D(@"%s, queueing report [ %@ ]", __FUNCTION__, report); + [CountlyConnectionManager.sharedInstance sendConnectionTestResults:report deviceID:deviceID deviceIDType:deviceIDType]; +} + +/// Probes every row sequentially against the configured host. Rows not reached before the battery cap report 'not run'. +/// Stops early when the app goes to the background, the report is dropped then anyway. +- (NSArray *)runBattery:(long long)scLatencyMs suspendCountAtStart:(unsigned int)suspendCountAtStart +{ + NSString *serverURL = CountlyConnectionManager.sharedInstance.host; + NSURLSession *session = [self probeSession]; + + NSUInteger requestCount = 0; + for (NSDictionary *row in CountlyConnectionTest.rows) + requestCount += ((NSArray *)row[kRowPaths]).count; + NSTimeInterval cap = requestCount * self.perRequestTimeout + self.batteryCapExtra; + NSTimeInterval batteryStart = CountlyCTUptime(); + + NSMutableArray *results = NSMutableArray.new; + for (NSDictionary *row in CountlyConnectionTest.rows) + { + if (self.halted || atomic_load(&suspendCount) != suspendCountAtStart) + break; + + NSArray *paths = row[kRowPaths]; + if (paths.count == 0) + { + if (scLatencyMs >= 0) + [results addObject:CountlyCTResultRow(row[kRowFeature], YES, 200, scLatencyMs)]; + continue; + } + + if (CountlyCTUptime() - batteryStart > cap) + { + NSMutableDictionary *notRun = CountlyCTResultRow(row[kRowFeature], NO, 0, 0); + notRun[kCountlyCTKeyError] = kCountlyCTErrorNotRun; + [results addObject:notRun]; + continue; + } + + [results addObject:[self probeRow:row serverURL:serverURL session:session]]; + } + + [session finishTasksAndInvalidate]; + return results.copy; +} + +/// Probes every path of one row and combines them: ok only if all passed, first failing status and reason, summed latency. +- (NSDictionary *)probeRow:(NSDictionary *)row serverURL:(NSString *)serverURL session:(NSURLSession *)session +{ + NSArray *paths = row[kRowPaths]; + BOOL requiresSuccess = ((NSNumber *)row[kRowRequiresSuccess]).boolValue; + + NSInteger status = 0; + long long latency = 0; + NSString *error = nil; + + for (NSUInteger i = 0; i < paths.count; i++) + { + // a halted instance stops probing at the next path, the report is dropped anyway + if (self.halted) + break; + + NSString *url = [CountlyConnectionTest probeURLForPath:paths[i] serverURL:serverURL]; + NSDictionary *outcome = [self probe:url session:session]; + NSInteger outcomeStatus = ((NSNumber *)outcome[kCountlyCTKeyStatus]).integerValue; + latency += ((NSNumber *)outcome[kCountlyCTKeyLatency]).longLongValue; + + NSString *failure = [CountlyConnectionTest gradeStatus:outcomeStatus failure:outcome[kCountlyCTKeyError] requiresSuccessStatus:requiresSuccess]; + CLY_LOG_V(@"%s, [ %@ ] %@ -> status[ %ld ] ms[ %@ ] failure[ %@ ]", __FUNCTION__, row[kRowFeature], url, (long)outcomeStatus, outcome[kCountlyCTKeyLatency], failure); + + // the first failing path decides the row's status and reason, otherwise the first status observed is reported + if (failure && !error) + { + status = outcomeStatus; + error = failure; + } + else if (i == 0) + status = outcomeStatus; + } + + NSMutableDictionary *result = CountlyCTResultRow(row[kRowFeature], error == nil, status, latency); + if (error) + result[kCountlyCTKeyError] = error; + if (paths.count > 1) + result[kCountlyCTKeyPathCount] = @(paths.count); + return result.copy; +} + +/// One bare GET, waited for synchronously. Returns the status (0 when none could be read), the wall clock latency +/// and, for a status of 0, whether it was a timeout or another transport error. +- (NSDictionary *)probe:(NSString *)url session:(NSURLSession *)session +{ + NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:url]]; + request.HTTPMethod = @"GET"; + request.cachePolicy = NSURLRequestReloadIgnoringLocalAndRemoteCacheData; + request.timeoutInterval = self.perRequestTimeout; + + __block NSInteger status = 0; + __block NSString *failure = nil; + dispatch_semaphore_t done = dispatch_semaphore_create(0); + NSTimeInterval start = CountlyCTUptime(); + + NSURLSessionDataTask *task = [session dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) + { + if (error) + failure = error.code == NSURLErrorTimedOut ? kCountlyCTErrorTimeout : kCountlyCTErrorTransport; + else if ([response isKindOfClass:NSHTTPURLResponse.class]) + status = ((NSHTTPURLResponse *)response).statusCode; + else + failure = kCountlyCTErrorTransport; + dispatch_semaphore_signal(done); + }]; + [task resume]; + + // the request's own deadline should fire first, this only guards against a completion that never comes + if (dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW, (int64_t)((self.perRequestTimeout + 5) * NSEC_PER_SEC))) != 0) + { + // cancelling makes the completion run, wait for it so its write cannot race the verdict below + [task cancel]; + dispatch_semaphore_wait(done, DISPATCH_TIME_FOREVER); + status = 0; + failure = kCountlyCTErrorTimeout; + } + + long long latency = (long long)((CountlyCTUptime() - start) * 1000); + NSMutableDictionary *outcome = [@{kCountlyCTKeyStatus: @(status), kCountlyCTKeyLatency: @(latency)} mutableCopy]; + if (failure) + outcome[kCountlyCTKeyError] = failure; + return outcome.copy; +} + +/// A session on the SDK's own configuration, so probes ride the same custom headers and, through the delegate, +/// the same pinning. Caching is off and redirects are not followed. +- (NSURLSession *)probeSession +{ + NSURLSessionConfiguration *userConfig = CountlyConnectionManager.sharedInstance.URLSessionConfiguration; + NSURLSessionConfiguration *configuration = userConfig ? [userConfig copy] : NSURLSessionConfiguration.defaultSessionConfiguration; + configuration.timeoutIntervalForRequest = self.perRequestTimeout; + configuration.timeoutIntervalForResource = self.perRequestTimeout; + configuration.requestCachePolicy = NSURLRequestReloadIgnoringLocalAndRemoteCacheData; + configuration.URLCache = nil; + configuration.HTTPShouldSetCookies = NO; + + return [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:nil]; +} + +#pragma mark - NSURLSessionTaskDelegate + +- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task willPerformHTTPRedirection:(NSHTTPURLResponse *)response newRequest:(NSURLRequest *)request completionHandler:(void (^)(NSURLRequest * _Nullable))completionHandler +{ + // a redirect must not be mistaken for a healthy answer, the 3xx itself becomes the task's response + completionHandler(nil); +} + +- (void)URLSession:(NSURLSession *)session didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition, NSURLCredential * _Nullable))completionHandler +{ + if (CountlyConnectionManager.sharedInstance.pinnedCertificates.count > 0) + { + // without this a pinned host would report 'transport error' while the SDK itself works + [CountlyConnectionManager.sharedInstance URLSession:session didReceiveChallenge:challenge completionHandler:completionHandler]; + return; + } + + completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, nil); +} + +#pragma mark - Grading and report + ++ (NSString *)probeURLForPath:(NSString *)path serverURL:(NSString *)serverURL +{ + NSString *base = serverURL ?: @""; + while ([base hasSuffix:@"/"]) + base = [base substringToIndex:base.length - 1]; + + NSString *joined = [path hasPrefix:@"/"] ? [base stringByAppendingString:path] : [NSString stringWithFormat:@"%@/%@", base, path]; + NSString *separator = [joined containsString:@"?"] ? @"&" : @"?"; + return [NSString stringWithFormat:@"%@%@ct=1&_=%lld", joined, separator, (long long)(NSDate.date.timeIntervalSince1970 * 1000)]; +} + ++ (nullable NSString *)gradeStatus:(NSInteger)status failure:(nullable NSString *)failure requiresSuccessStatus:(BOOL)requiresSuccessStatus +{ + if (status <= 0) + return failure ?: kCountlyCTErrorTransport; + if (status >= 200 && status < 300) + return nil; + if (status >= 300 && status < 400) + return kCountlyCTErrorRedirected; + // a 4xx other than 403 is the Countly application answering, unless this row has no legitimate 4xx answer + if (!requiresSuccessStatus && status >= 400 && status < 500 && status != 403) + return nil; + return [NSString stringWithFormat:@"HTTP %ld", (long)status]; +} + +- (NSString *)buildReport:(NSArray *)rows +{ + NSMutableArray *results = NSMutableArray.new; + for (NSDictionary *row in rows) + { + if (results.count >= kCountlyCTMaxRows) + break; + + NSMutableDictionary *result = row.mutableCopy; + NSString *error = result[kCountlyCTKeyError]; + if (error.length > kCountlyCTMaxErrorLength) + result[kCountlyCTKeyError] = [error substringToIndex:kCountlyCTMaxErrorLength]; + [results addObject:result]; + } + + NSString *report = [self serializedReportWithResults:results]; + if ([self utf8Length:report] > kCountlyCTMaxReportBytes) + { + CLY_LOG_W(@"%s, report is over the size cap, dropping error details", __FUNCTION__); + for (NSMutableDictionary *result in results) + { + // the qualifier of an opaque success is not detail, dropping it would upgrade the row's apparent certainty + if (![result[kCountlyCTKeyError] isEqualToString:kCountlyCTErrorOpaque]) + [result removeObjectForKey:kCountlyCTKeyError]; + } + report = [self serializedReportWithResults:results]; + } + + while ([self utf8Length:report] > kCountlyCTMaxReportBytes && results.count > 0) + { + CLY_LOG_W(@"%s, report is still over the size cap, dropping the last row", __FUNCTION__); + [results removeLastObject]; + report = [self serializedReportWithResults:results]; + } + + return report; +} + +/// The report JSON for the given rows: device clock, SDK identity and the rows. +- (NSString *)serializedReportWithResults:(NSArray *)results +{ + return CountlyJSONFromObject(@{ + kCountlyCTKeyTimestamp: @((long long)(NSDate.date.timeIntervalSince1970 * 1000)), + kCountlyCTKeySDK: @{kCountlyCTKeySDKName: CountlyCommon.sharedInstance.SDKName, kCountlyCTKeySDKVersion: CountlyCommon.sharedInstance.SDKVersion}, + kCountlyCTKeyResults: results, + }); +} + +/// Size of the report on the wire before URL escaping, which is what the server's cap is measured against. +- (NSUInteger)utf8Length:(NSString *)report +{ + return [report lengthOfBytesUsingEncoding:NSUTF8StringEncoding]; +} + +@end diff --git a/ios/src/CountlyConsentManager.m b/ios/src/CountlyConsentManager.m index 6cbf8c38..c126ab04 100644 --- a/ios/src/CountlyConsentManager.m +++ b/ios/src/CountlyConsentManager.m @@ -61,9 +61,18 @@ - (instancetype)init return self; } +/// Sets whether consent is required, and decides the gathered lines against it again. +- (void)setRequiresConsent:(BOOL)requiresConsent +{ + _requiresConsent = requiresConsent; + + [CountlyCommon.sharedInstance redecideSdkLogLinesConsent]; +} + - (void)resetInstance { - CLY_LOG_I(@"%s", __FUNCTION__); - [self cancelConsentForAllFeatures]; + // the given consents are forgotten, not withdrawn: a withdrawal would tell the server the user withdrew every consent + // and unregister the app from push notifications, while halt only resets the SDK + CLY_LOG_I(@"%s resetting consent manager instance, the given consents are forgotten without a consent request", __FUNCTION__); onceToken = 0; s_sharedInstance = nil; } @@ -72,17 +81,26 @@ - (void)resetInstance { - (void)giveAllConsents { - [self giveConsentForFeatures:[self allFeatures]]; + NSArray* allFeatures = [self allFeatures]; + [self giveConsentForFeatures:allFeatures]; } - (void)giveConsentForFeatures:(NSArray *)features { + CLY_LOG_I(@"%s giving consent for features, feature count: [%lu], features: [%@]", __FUNCTION__, (unsigned long)features.count, features); + if (!self.requiresConsent) + { + CLY_LOG_V(@"%s requiresConsent is not enabled, giving consent for features will be ignored", __FUNCTION__); return; + } if (!features.count) + { + CLY_LOG_E(@"%s feature list is empty, there is no consent to give", __FUNCTION__); return; + } //NOTE: Due to some legacy Countly Server location info problems, giving consent for location should be the first. //NOTE: Otherwise, if location consent is given after sessions consent, begin_session request will be sent with an empty string as location. @@ -126,18 +144,22 @@ - (void)giveConsentForFeatures:(NSArray *)features self.consentForMetrics = YES; [self sendConsents]; + + [CountlyCommon.sharedInstance redecideSdkLogLinesConsent]; } - (void)cancelConsentForAllFeatures { - [self cancelConsentForFeatures:[self allFeatures]]; + NSArray* allFeatures = [self allFeatures]; + [self cancelConsentForFeatures:allFeatures]; } - (void)cancelConsentForAllFeaturesWithoutSendingConsentsRequest { - [self cancelConsentForFeatures:[self allFeatures] shouldSkipSendingConsentsRequest:YES]; + NSArray* allFeatures = [self allFeatures]; + [self cancelConsentForFeatures:allFeatures shouldSkipSendingConsentsRequest:YES]; } @@ -149,8 +171,13 @@ - (void)cancelConsentForFeatures:(NSArray *)features - (void)cancelConsentForFeatures:(NSArray *)features shouldSkipSendingConsentsRequest:(BOOL)shouldSkipSendingConsentsRequest { + CLY_LOG_I(@"%s cancelling consent for features, feature count: [%lu], features: [%@], skipSendingConsentsRequest: [%@]", __FUNCTION__, (unsigned long)features.count, features, shouldSkipSendingConsentsRequest ? @"YES" : @"NO"); + if (!self.requiresConsent) + { + CLY_LOG_V(@"%s requiresConsent is not enabled, cancelling consent for features will be ignored", __FUNCTION__); return; + } if ([features containsObject:CLYConsentSessions] && self.consentForSessions) { @@ -196,6 +223,8 @@ - (void)cancelConsentForFeatures:(NSArray *)features shouldSkipSendingConsentsRe if (!shouldSkipSendingConsentsRequest) [self sendConsents]; + + [CountlyCommon.sharedInstance redecideSdkLogLinesConsent]; } @@ -218,6 +247,8 @@ - (void)sendConsents CLYConsentMetrics: @(self.consentForMetrics), }; + CLY_LOG_D(@"%s consents state will be sent to the server, feature count: [%lu]", __FUNCTION__, (unsigned long)consents.count); + [CountlyConnectionManager.sharedInstance sendConsents:[consents cly_JSONify]]; } @@ -271,14 +302,14 @@ - (void)setConsentForSessions:(BOOL)consentForSessions if (consentForSessions) { - CLY_LOG_D(@"Consent for Session is given."); + CLY_LOG_D(@"%s consent granted, feature: [%@], begin session will be requested unless manual session handling is enabled", __FUNCTION__, CLYConsentSessions); - if (!CountlyCommon.sharedInstance.manualSessionHandling) + if (CountlyServerConfig.sharedInstance.automaticSessionTrackingEnabled) [CountlyConnectionManager.sharedInstance beginSession]; } else { - CLY_LOG_D(@"Consent for Session is cancelled."); + CLY_LOG_D(@"%s consent cancelled, feature: [%@], session tracking will no longer be performed", __FUNCTION__, CLYConsentSessions); } } @@ -289,11 +320,11 @@ - (void)setConsentForEvents:(BOOL)consentForEvents if (consentForEvents) { - CLY_LOG_D(@"Consent for Events is given."); + CLY_LOG_D(@"%s consent granted, feature: [%@], custom events will be recorded", __FUNCTION__, CLYConsentEvents); } else { - CLY_LOG_D(@"Consent for Events is cancelled."); + CLY_LOG_D(@"%s consent cancelled, feature: [%@], recorded events will be sent and timed events will be cleared", __FUNCTION__, CLYConsentEvents); [CountlyConnectionManager.sharedInstance sendEventsWithSaveIfNeeded]; [CountlyPersistency.sharedInstance clearAllTimedEvents]; @@ -307,13 +338,13 @@ - (void)setConsentForUserDetails:(BOOL)consentForUserDetails if (consentForUserDetails) { - CLY_LOG_D(@"Consent for UserDetails is given."); + CLY_LOG_D(@"%s consent granted, feature: [%@], orientation will be recorded and user details will be saved", __FUNCTION__, CLYConsentUserDetails); [CountlyCommon.sharedInstance recordOrientation]; - [Countly.user save]; + [CountlyUserDetails.sharedInstance save]; } else { - CLY_LOG_D(@"Consent for UserDetails is cancelled."); + CLY_LOG_D(@"%s consent cancelled, feature: [%@], stored user details will be cleared", __FUNCTION__, CLYConsentUserDetails); [CountlyUserDetails.sharedInstance clearUserDetails]; } @@ -326,13 +357,13 @@ - (void)setConsentForCrashReporting:(BOOL)consentForCrashReporting if (consentForCrashReporting) { - CLY_LOG_D(@"Consent for CrashReporting is given."); + CLY_LOG_D(@"%s consent granted, feature: [%@], crash reporting will be started", __FUNCTION__, CLYConsentCrashReporting); [CountlyCrashReporter.sharedInstance startCrashReporting]; } else { - CLY_LOG_D(@"Consent for CrashReporting is cancelled."); + CLY_LOG_D(@"%s consent cancelled, feature: [%@], crash reporting will be stopped", __FUNCTION__, CLYConsentCrashReporting); [CountlyCrashReporter.sharedInstance stopCrashReporting]; } @@ -346,7 +377,7 @@ - (void)setConsentForPushNotifications:(BOOL)consentForPushNotifications #if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_OSX) if (consentForPushNotifications) { - CLY_LOG_D(@"Consent for PushNotifications is given."); + CLY_LOG_D(@"%s consent granted, feature: [%@], push notifications will be started", __FUNCTION__, CLYConsentPushNotifications); #ifndef COUNTLY_EXCLUDE_PUSHNOTIFICATIONS [CountlyPushNotifications.sharedInstance startPushNotifications]; @@ -354,7 +385,7 @@ - (void)setConsentForPushNotifications:(BOOL)consentForPushNotifications } else { - CLY_LOG_D(@"Consent for PushNotifications is cancelled."); + CLY_LOG_D(@"%s consent cancelled, feature: [%@], push notifications will be stopped", __FUNCTION__, CLYConsentPushNotifications); #ifndef COUNTLY_EXCLUDE_PUSHNOTIFICATIONS [CountlyPushNotifications.sharedInstance stopPushNotifications]; #endif @@ -369,13 +400,13 @@ - (void)setConsentForLocation:(BOOL)consentForLocation if (consentForLocation) { - CLY_LOG_D(@"Consent for Location is given."); + CLY_LOG_D(@"%s consent granted, feature: [%@], location info will be sent", __FUNCTION__, CLYConsentLocation); [CountlyLocationManager.sharedInstance sendLocationInfo]; } else { - CLY_LOG_D(@"Consent for Location is cancelled."); + CLY_LOG_D(@"%s consent cancelled, feature: [%@], location info will be cleared on the server", __FUNCTION__, CLYConsentLocation); [CountlyConnectionManager.sharedInstance sendLocationInfo]; } @@ -386,16 +417,18 @@ - (void)setConsentForViewTracking:(BOOL)consentForViewTracking { _consentForViewTracking = consentForViewTracking; -#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV) +// Automatic view tracking is only implemented for iOS and tvOS, so the guard must not include +// visionOS: the methods below would be declared but have no definition there +#if (TARGET_OS_IOS || TARGET_OS_TV) if (consentForViewTracking) { - CLY_LOG_D(@"Consent for ViewTracking is given."); + CLY_LOG_D(@"%s consent granted, feature: [%@], automatic view tracking will be started", __FUNCTION__, CLYConsentViewTracking); [CountlyViewTrackingInternal.sharedInstance startAutoViewTracking]; } else { - CLY_LOG_D(@"Consent for ViewTracking is cancelled."); + CLY_LOG_D(@"%s consent cancelled, feature: [%@], automatic view tracking will be stopped", __FUNCTION__, CLYConsentViewTracking); [CountlyViewTrackingInternal.sharedInstance stopAutoViewTracking]; } @@ -409,13 +442,11 @@ - (void)setConsentForAttribution:(BOOL)consentForAttribution if (consentForAttribution) { - CLY_LOG_D(@"Consent for Attribution is given."); - - [CountlyConnectionManager.sharedInstance sendAttribution]; + CLY_LOG_D(@"%s consent granted, feature: [%@], attribution can be recorded", __FUNCTION__, CLYConsentAttribution); } else { - CLY_LOG_D(@"Consent for Attribution is cancelled."); + CLY_LOG_D(@"%s consent cancelled, feature: [%@], attribution will no longer be sent", __FUNCTION__, CLYConsentAttribution); } } @@ -427,13 +458,13 @@ - (void)setConsentForPerformanceMonitoring:(BOOL)consentForPerformanceMonitoring #if (TARGET_OS_IOS || TARGET_OS_VISION) if (consentForPerformanceMonitoring) { - CLY_LOG_D(@"Consent for PerformanceMonitoring is given."); + CLY_LOG_D(@"%s consent granted, feature: [%@], performance monitoring will be started", __FUNCTION__, CLYConsentPerformanceMonitoring); [CountlyPerformanceMonitoring.sharedInstance startPerformanceMonitoring]; } else { - CLY_LOG_D(@"Consent for PerformanceMonitoring is cancelled."); + CLY_LOG_D(@"%s consent cancelled, feature: [%@], performance monitoring will be stopped", __FUNCTION__, CLYConsentPerformanceMonitoring); [CountlyPerformanceMonitoring.sharedInstance stopPerformanceMonitoring]; } @@ -444,16 +475,16 @@ - (void)setConsentForFeedback:(BOOL)consentForFeedback { _consentForFeedback = consentForFeedback; -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) if (consentForFeedback) { - CLY_LOG_D(@"Consent for Feedback is given."); + CLY_LOG_D(@"%s consent granted, feature: [%@], star rating auto ask will be checked", __FUNCTION__, CLYConsentFeedback); [CountlyFeedbacksInternal.sharedInstance checkForStarRatingAutoAsk]; } else { - CLY_LOG_D(@"Consent for Feedback is cancelled."); + CLY_LOG_D(@"%s consent cancelled, feature: [%@], feedback widgets will no longer be shown", __FUNCTION__, CLYConsentFeedback); } #endif } @@ -464,13 +495,13 @@ - (void)setConsentForRemoteConfig:(BOOL)consentForRemoteConfig if (consentForRemoteConfig) { - CLY_LOG_D(@"Consent for RemoteConfig is given."); + CLY_LOG_D(@"%s consent granted, feature: [%@], automatic remote config download will be triggered", __FUNCTION__, CLYConsentRemoteConfig); [CountlyRemoteConfigInternal.sharedInstance downloadRemoteConfigAutomatically]; } else { - CLY_LOG_D(@"Consent for RemoteConfig is cancelled."); + CLY_LOG_D(@"%s consent cancelled, feature: [%@], remote config values will no longer be downloaded", __FUNCTION__, CLYConsentRemoteConfig); } } @@ -480,11 +511,11 @@ - (void)setConsentForMetrics:(BOOL)consentForMetrics if (consentForMetrics) { - CLY_LOG_D(@"Consent for Metrics is given."); + CLY_LOG_D(@"%s consent granted, feature: [%@], metrics will be included in requests", __FUNCTION__, CLYConsentMetrics); } else { - CLY_LOG_D(@"Consent for Metrics is cancelled."); + CLY_LOG_D(@"%s consent cancelled, feature: [%@], metrics will be omitted from requests", __FUNCTION__, CLYConsentMetrics); } } @@ -494,12 +525,12 @@ - (void)setConsentForContent:(BOOL)consentForContent if (consentForContent) { - CLY_LOG_D(@"Consent for Content is given."); + CLY_LOG_D(@"%s consent granted, feature: [%@], content zone can be entered", __FUNCTION__, CLYConsentContent); } else { - CLY_LOG_D(@"Consent for Content is cancelled."); -#if (TARGET_OS_IOS) + CLY_LOG_D(@"%s consent cancelled, feature: [%@], content zone will be exited", __FUNCTION__, CLYConsentContent); +#if (TARGET_OS_IOS || TARGET_OS_VISION) [CountlyContentBuilderInternal.sharedInstance exitContentZone]; #endif } diff --git a/ios/src/CountlyContentBuilder.h b/ios/src/CountlyContentBuilder.h index 7b1c9934..74c5289b 100644 --- a/ios/src/CountlyContentBuilder.h +++ b/ios/src/CountlyContentBuilder.h @@ -6,12 +6,12 @@ #import -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) #import #endif NS_ASSUME_NONNULL_BEGIN @interface CountlyContentBuilder: NSObject -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) + (instancetype)sharedInstance; /** @@ -45,6 +45,17 @@ NS_ASSUME_NONNULL_BEGIN */ - (void)previewContent:(NSString *)contentId; +/** + * This is an experimental feature and it can have breaking changes + * Sets a segmentation recorded with every content and feedback widget event, + * on top of the keys those events already report and without replacing them. + * It is kept for as long as the app runs, including when a session ends and a + * new one starts, and it is not restored after an app restart. + * + * @param segmentation the key/value pairs to add, 'nil' or empty clears them + */ +- (void)setGlobalContentSegmentation:(NSDictionary * _Nullable)segmentation; + #endif NS_ASSUME_NONNULL_END @end diff --git a/ios/src/CountlyContentBuilder.m b/ios/src/CountlyContentBuilder.m index 007766e1..51aabcfc 100644 --- a/ios/src/CountlyContentBuilder.m +++ b/ios/src/CountlyContentBuilder.m @@ -9,7 +9,7 @@ #import "CountlyCommon.h" @implementation CountlyContentBuilder -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) + (instancetype)sharedInstance { if (!CountlyCommon.sharedInstance.hasStarted) @@ -30,29 +30,35 @@ - (instancetype)init - (void)enterContentZone { + CLY_LOG_I(@"%s entering the content zone without tags", __FUNCTION__); [self enterContentZone:@[]]; } - (void)enterContentZone:(NSArray *)tags { + CLY_LOG_I(@"%s entering the content zone, tags: [%@], tagCount: [%lu]", __FUNCTION__, tags, (unsigned long)tags.count); [CountlyContentBuilderInternal.sharedInstance enterContentZone:tags]; } - (void)exitContentZone { + CLY_LOG_I(@"%s exiting the content zone", __FUNCTION__); [CountlyContentBuilderInternal.sharedInstance exitContentZone]; } - (void)refreshContentZone { + CLY_LOG_I(@"%s refreshing the content zone", __FUNCTION__); [CountlyContentBuilderInternal.sharedInstance refreshContentZone]; } - (void)changeContent:(NSArray *)tags { + CLY_LOG_I(@"%s changing the content zone tags, tags: [%@], tagCount: [%lu]", __FUNCTION__, tags, (unsigned long)tags.count); [CountlyContentBuilder.sharedInstance changeContent:tags]; } - (void)previewContent:(NSString *)contentId { + CLY_LOG_I(@"%s previewing a content, contentId: [%@]", __FUNCTION__, contentId); if (!contentId || contentId.length == 0) { CLY_LOG_W(@"%s contentId is null or empty, skipping", __FUNCTION__); @@ -62,5 +68,12 @@ - (void)previewContent:(NSString *)contentId [CountlyContentBuilderInternal.sharedInstance previewContent:contentId]; } +- (void)setGlobalContentSegmentation:(NSDictionary *)segmentation +{ + CLY_LOG_I(@"%s setting the global content segmentation, keyCount: [%lu]", __FUNCTION__, (unsigned long)segmentation.count); + + [CountlyContentBuilderInternal.sharedInstance setGlobalContentSegmentation:segmentation]; +} + #endif @end diff --git a/ios/src/CountlyContentBuilderInternal.h b/ios/src/CountlyContentBuilderInternal.h index a9859281..630c9d75 100644 --- a/ios/src/CountlyContentBuilderInternal.h +++ b/ios/src/CountlyContentBuilderInternal.h @@ -5,28 +5,75 @@ // Please visit www.count.ly for more information. #import -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) #import #endif #import "CountlyCommon.h" NS_ASSUME_NONNULL_BEGIN @interface CountlyContentBuilderInternal: NSObject -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) @property (nonatomic, strong) NSArray *currentTags; @property (nonatomic, assign) NSTimeInterval zoneTimerInterval; @property (nonatomic) ContentCallback contentCallback; @property (nonatomic, assign) WebViewDisplayOption webViewDisplayOption; +@property (nonatomic, assign) BOOL enableContentReloadOnStall; +@property (nonatomic, assign) NSTimeInterval contentReloadOnStallTimeout; // seconds +@property (nonatomic, assign) BOOL disableZoom; +@property (nonatomic, assign) BOOL disableRotation; +@property (nonatomic, copy, nullable) ContentURLHandler contentURLHandler; @property (nonatomic, assign) int contentInitialDelay; + (instancetype)sharedInstance; - (void)enterContentZone:(NSArray *)tags; - (void)exitContentZone; +/// Resets retrieval state without closing displayed content, unlike exitContentZone. +- (void)clearContentState; +- (void)resetInstance; - (void)changeContent:(NSArray *)tags; - (void)refreshContentZone; - (void)refreshContentZoneJTE; - (void)previewContent:(NSString *)contentId; +/** + * Sanitizes the given segmentation and makes it the segmentation added to every content and + * feedback widget event. It is held in memory only, so it survives a session ending and a new one + * starting and is gone after an app restart. + * + * @param segmentation the dictionary given by the developer, 'nil' or empty clears the stored one + */ +- (void)setGlobalContentSegmentation:(NSDictionary * _Nullable)segmentation; + +/** + * Puts the global content segmentation underneath the segmentation a content event built for itself. + * The event's own entries win. When both together exceed the SDK segmentation value limit, global + * entries are dropped first, so the limit never trims the event's own. + * + * @param eventSegmentation the segmentation the event built for itself + * @return the merged segmentation, or eventSegmentation itself when there is nothing to add + */ +- (NSDictionary *)mergeGlobalContentSegmentationInto:(NSDictionary *)eventSegmentation; + +/** + * The global content segmentation as feedback widgets carry it: without the keys the server reads as a + * widget answer or uses to route a widget event ("closed", "shown", "rating", "comment", "email", + * "contactMe", "widget_id", "platform", "app_version", "campaign_id" and survey answer keys), since a + * global value under one of those names would turn a dismissal into an answer or discard a real answer. + * + * @return the remaining segmentation, empty when nothing is left + */ +- (NSDictionary *)globalContentSegmentationForFeedbackWidget; + +/** + * Puts the feedback widget form of the global content segmentation underneath the segmentation a feedback + * widget event built for itself. The event's own entries win. Widget events are not trimmed to the SDK + * segmentation value limit, which would cut survey answers. + * + * @param eventSegmentation the segmentation the event built for itself + * @return the merged segmentation, or eventSegmentation itself when there is nothing to add + */ +- (NSDictionary *)mergeGlobalContentSegmentationIntoFeedbackWidgetEvent:(NSDictionary *)eventSegmentation; + #endif NS_ASSUME_NONNULL_END @end diff --git a/ios/src/CountlyContentBuilderInternal.m b/ios/src/CountlyContentBuilderInternal.m index 634f0576..d3ce314c 100644 --- a/ios/src/CountlyContentBuilderInternal.m +++ b/ios/src/CountlyContentBuilderInternal.m @@ -9,16 +9,36 @@ //TODO: improve logging, check edge cases NSString* const kCountlyEndpointContent = @"/o/sdk/content"; NSString* const kCountlyCBFetchContent = @"queue"; +NSString* const kCountlyCBKeyCampaignID = @"campaign_id"; +NSString* const kCountlyCBSurveyAnswerKeyPrefix = @"answ-"; +#if (TARGET_OS_IOS || TARGET_OS_VISION) +static const NSTimeInterval kCountlyCBDefaultZoneTimerInterval = 30.0; +#endif + +@interface CountlyContentBuilderInternal () +// 'atomic' rather than the 'nonatomic' used elsewhere here: an event can be recorded on one thread +// while the setter runs on another, and the dictionary is always replaced whole rather than +// mutated, so a reader either sees the old segmentation or the complete new one. +@property (atomic, copy) NSDictionary *contentSegmentation; +@end @implementation CountlyContentBuilderInternal { BOOL _isRequestQueueLocked; BOOL _isCurrentlyContentShown; + BOOL _refreshRunnablePending; + // Bumped per presentation claim, so a deferred teardown can tell if a newer one took the slot. + NSUInteger _presentationSequence; NSTimer *_requestTimer; NSTimer *_minuteTimer; dispatch_queue_t _contentQueue; +#if (TARGET_OS_IOS || TARGET_OS_VISION) + // The presented content's manager, retained so the zone can close it. Main thread only. + // Guarded: CountlyWebViewManager only exists on iOS/visionOS, and this file builds everywhere. + CountlyWebViewManager *_webViewManager; +#endif } -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) + (instancetype)sharedInstance { static CountlyContentBuilderInternal *instance = nil; static dispatch_once_t onceToken; @@ -32,7 +52,7 @@ - (instancetype)init { if (self = [super init]) { - self.zoneTimerInterval = 30.0; + self.zoneTimerInterval = kCountlyCBDefaultZoneTimerInterval; _requestTimer = nil; _isCurrentlyContentShown = NO; _contentQueue = dispatch_queue_create("ly.countly.content.queue", DISPATCH_QUEUE_SERIAL); @@ -42,51 +62,133 @@ - (instancetype)init return self; } -- (BOOL)isRequestQueueLockedThreadSafe { - __block BOOL locked = NO; +#pragma mark - Thread-safe flag helpers + +// Every BOOL flag below (_isRequestQueueLocked, _isCurrentlyContentShown, +// _refreshRunnablePending) is accessed only through these helpers so all reads/writes are +// serialized on the single serial _contentQueue. The blocks capture the raw ivar pointer, +// which is safe because this class is a never-released singleton (its ivars outlive any +// queued block). +- (BOOL)readFlag:(BOOL *)flag { if (!_contentQueue) { - return _isRequestQueueLocked; + return *flag; } + __block BOOL value = NO; dispatch_sync(_contentQueue, ^{ - locked = self->_isRequestQueueLocked; + value = *flag; }); - return locked; + return value; } -- (void)setRequestQueueLockedThreadSafe:(BOOL)locked { +- (void)writeFlag:(BOOL *)flag value:(BOOL)value { if (!_contentQueue) { - _isRequestQueueLocked = locked; + *flag = value; return; } dispatch_async(_contentQueue, ^{ - self->_isRequestQueueLocked = locked; + *flag = value; + }); +} + +// Atomically set *flag to YES if it was NO. Returns YES only for the caller that made the +// NO->YES transition, so exactly one caller "wins" a contended flag. +- (BOOL)testAndSetFlag:(BOOL *)flag { + __block BOOL acquired = NO; + if (!_contentQueue) { + if (!*flag) { *flag = YES; acquired = YES; } + return acquired; + } + dispatch_sync(_contentQueue, ^{ + if (!*flag) { *flag = YES; acquired = YES; } + }); + return acquired; +} + +- (BOOL)isRequestQueueLockedThreadSafe { + return [self readFlag:&_isRequestQueueLocked]; +} + +- (void)setRequestQueueLockedThreadSafe:(BOOL)locked { + [self writeFlag:&_isRequestQueueLocked value:locked]; +} + +// Atomically claim the single "content is shown" slot. Returns YES if the caller acquired it, +// NO if content is already shown or a racing fetch already claimed it, in which case the caller +// MUST NOT present another web view. Previously the flag was set only when the web view was +// presented (after a network round trip), so two near-simultaneous fetches could both pass the +// guard and present two overlapping web views; this test-and-set closes that window. +- (BOOL)tryBeginContentPresentation { + __block BOOL acquired = NO; + + if (!_contentQueue) { + if (!_isCurrentlyContentShown) { _isCurrentlyContentShown = YES; _presentationSequence++; acquired = YES; } + return acquired; + } + + dispatch_sync(_contentQueue, ^{ + if (!self->_isCurrentlyContentShown) { + self->_isCurrentlyContentShown = YES; + self->_presentationSequence++; + acquired = YES; + } }); + + return acquired; +} + +- (NSUInteger)currentPresentationSequence { + if (!_contentQueue) { + return _presentationSequence; + } + __block NSUInteger value = 0; + dispatch_sync(_contentQueue, ^{ + value = self->_presentationSequence; + }); + return value; +} + +// Release the content slot (called when the shown web view is dismissed, or on reset) so the +// next zone cycle can present again. +- (void)endContentPresentation { + [self writeFlag:&_isCurrentlyContentShown value:NO]; +} + +- (BOOL)isContentShownThreadSafe { + return [self readFlag:&_isCurrentlyContentShown]; +} + +// Whether a zone re-entry is scheduled. Read by tests. +- (BOOL)isZoneReentryTimerArmed { + return _minuteTimer != nil; } - (void)enterContentZone { - - if(_isCurrentlyContentShown){ - CLY_LOG_I(@"%s a content is already shown, skipping" ,__FUNCTION__); + + if([self isContentShownThreadSafe]){ + CLY_LOG_D(@"%s a content is already shown, skipping this untagged enter", __FUNCTION__); return; } - + [self enterContentZone:@[]]; } - (void)enterContentZone:(NSArray *)tags { - if(_isCurrentlyContentShown){ - CLY_LOG_I(@"%s a content is already shown, skipping" ,__FUNCTION__); + if([self isContentShownThreadSafe]){ + CLY_LOG_D(@"%s a content is already shown, skipping this tagged enter, tagCount: [%lu]", __FUNCTION__, (unsigned long)tags.count); return; } - + [_minuteTimer invalidate]; _minuteTimer = nil; if (!CountlyConsentManager.sharedInstance.consentForContent) + { + CLY_LOG_V(@"%s no consent given for content, skipping the content zone enter", __FUNCTION__); return; + } if(_requestTimer != nil) { - CLY_LOG_I(@"%s already entered for content zone, please exit from content zone first to start again", __FUNCTION__); + CLY_LOG_W(@"%s already entered the content zone, please exit from the content zone first to start again", __FUNCTION__); return; } @@ -97,6 +199,8 @@ - (void)enterContentZone:(NSArray *)tags { contentDelay = _contentInitialDelay; } + CLY_LOG_D(@"%s content zone entered, tagCount: [%lu], initialDelay: [%d] seconds, zoneTimerInterval: [%.1f] seconds", __FUNCTION__, (unsigned long)tags.count, contentDelay, self.zoneTimerInterval); + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(contentDelay * NSEC_PER_SEC)), dispatch_get_main_queue(), ^ { [self fetchContents];; @@ -109,34 +213,96 @@ - (void)enterContentZone:(NSArray *)tags { } - (void)exitContentZone { + CLY_LOG_I(@"%s exiting the content zone and taking the content off screen", __FUNCTION__); [self clearContentState]; + // Also takes the content off screen. Internal cycles use clearContentState instead. + [self closeShownContent]; +} + +// Dismisses the content on screen and frees the presentation slot, in one main-queue turn. +// The slot is deliberately not released earlier: that would let a fetch present a second web view +// mid-teardown. +- (void)closeShownContent { + // Synchronous when already on the main thread, which is where exitContentZone is called from in + // practice. Deferring the slot release would leave it claimed for the rest of the turn, so an + // exitContentZone immediately followed by enterContentZone would silently no-op and dead-end + // the zone. + if ([NSThread isMainThread]) { + [self tearDownShownContent]; + return; + } + + NSUInteger requestedForSequence = [self currentPresentationSequence]; + + dispatch_async(dispatch_get_main_queue(), ^{ + if ([self currentPresentationSequence] != requestedForSequence) { + // A newer presentation owns the slot now; it is not ours to tear down. + CLY_LOG_D(@"%s a newer presentation superseded this close request, skipping", __FUNCTION__); + return; + } + + [self tearDownShownContent]; + }); +} + +// Main thread only. +- (void)tearDownShownContent { + CountlyWebViewManager *manager = self->_webViewManager; + if (manager) { + CLY_LOG_I(@"%s closing the currently shown content", __FUNCTION__); + // Cleared first so the dismiss block skips re-arming the zone for an SDK-initiated close. + self->_webViewManager = nil; + [manager closeWebView]; + } + + // Released even with nothing retained, so a stranded claim cannot dead-end later enters. + [self endContentPresentation]; } - (void)changeContent:(NSArray *)tags { if (![tags isEqualToArray:self.currentTags]) { + CLY_LOG_D(@"%s the tags changed, restarting the content zone, newTagCount: [%lu], oldTagCount: [%lu]", __FUNCTION__, (unsigned long)tags.count, (unsigned long)self.currentTags.count); [self exitContentZone]; [self enterContentZone:tags]; } } - (void)previewContent:(NSString *)contentId { + CLY_LOG_D(@"%s fetching a content for preview, contentId: [%@]", __FUNCTION__, contentId); [self fetchContents:nil contentId:contentId]; } - (void)refreshContentZone { if (![CountlyServerConfig.sharedInstance refreshContentZoneEnabled]) { + CLY_LOG_D(@"%s the content zone refresh is disabled by the server config, skipping", __FUNCTION__); return; } - if(_isCurrentlyContentShown){ - CLY_LOG_I(@"%s a content is already shown, skipping" ,__FUNCTION__); + if([self isContentShownThreadSafe]){ + CLY_LOG_D(@"%s a content is already shown, skipping this content zone refresh", __FUNCTION__); return; } - + + // Coalesce refreshes: only one refresh runnable may be pending at a time. Without this, + // multiple refreshContentZone calls before the request queue flushes each append a runnable + // (addQueueFlushRunnable does not dedup); they then all fire together and trigger N + // concurrent content fetches (a burst against the edge) whose extra results are discarded. + if (![self testAndSetFlag:&_refreshRunnablePending]) { + CLY_LOG_D(@"%s a content refresh is already pending, skipping this duplicate refresh", __FUNCTION__); + return; + } + + __weak typeof(self) weakSelf = self; [CountlyConnectionManager.sharedInstance addQueueFlushRunnable:^{ - CLY_LOG_I(@"%s queue flueshed, will re-fetch contents" ,__FUNCTION__); - [self exitContentZone]; - [self enterContentZone]; + __strong typeof(weakSelf) strongSelf = weakSelf; + if (!strongSelf) return; + // Clear the pending flag first so a refresh requested during/after this flush can + // schedule the next one. + [strongSelf writeFlag:&strongSelf->_refreshRunnablePending value:NO]; + CLY_LOG_D(@"%s the request queue flushed, will re-fetch contents", __FUNCTION__); + // State only: a refresh must never pull content off screen + [strongSelf clearContentState]; + [strongSelf enterContentZone]; }]; [CountlyConnectionManager.sharedInstance attemptToSendStoredRequests]; } @@ -144,30 +310,30 @@ - (void)refreshContentZone { - (void)refreshContentZoneJTE { if (![CountlyServerConfig.sharedInstance refreshContentZoneEnabled]) { - CLY_LOG_D(@"%s, refresh content zone is disabled, skipping JTE content refresh", __FUNCTION__); + CLY_LOG_D(@"%s the content zone refresh is disabled by the server config, skipping the JTE content refresh", __FUNCTION__); return; } - if(_isCurrentlyContentShown){ - CLY_LOG_I(@"%s a content is already shown, skipping JTE content refresh" ,__FUNCTION__); + if([self isContentShownThreadSafe]){ + CLY_LOG_D(@"%s a content is already shown, skipping the JTE content refresh", __FUNCTION__); return; } - CLY_LOG_D(@"%s, Starting JTE content refresh with retries", __FUNCTION__); - [self exitContentZone]; + // State only: a journey trigger must never pull content off screen + [self clearContentState]; [self fetchContentsForJourneyWithMaxAttempts:3 currentAttempt:1]; } - (void)fetchContentsForJourneyWithMaxAttempts:(NSInteger)maxAttempts currentAttempt:(NSInteger)currentAttempt { - CLY_LOG_D(@"%s, JTE content fetch attempt %ld of %ld", __FUNCTION__, (long)currentAttempt, (long)maxAttempts); + CLY_LOG_D(@"%s JTE content fetch starting, attempt: [%ld], maxAttempts: [%ld]", __FUNCTION__, (long)currentAttempt, (long)maxAttempts); [self fetchContents:^{ if (currentAttempt < maxAttempts) { - CLY_LOG_D(@"Retrying JTE content fetch in 1 second (attempt %ld of %ld)", (long)(currentAttempt + 1), (long)maxAttempts); + CLY_LOG_D(@"%s retrying the JTE content fetch in [1] second, reason: [the content fetch failed or returned no content], nextAttempt: [%ld], maxAttempts: [%ld]", __FUNCTION__, (long)(currentAttempt + 1), (long)maxAttempts); dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ [self fetchContentsForJourneyWithMaxAttempts:maxAttempts currentAttempt:currentAttempt + 1]; }); } else { - CLY_LOG_D(@"JTE content fetch exhausted all %ld attempts. Re-entering content zone.", (long)maxAttempts); + CLY_LOG_D(@"%s the JTE content fetch exhausted every attempt, reason: [the content fetch failed or returned no content], maxAttempts: [%ld], re-entering the content zone", __FUNCTION__, (long)maxAttempts); dispatch_async(dispatch_get_main_queue(), ^{ [self enterContentZone]; }); @@ -180,38 +346,151 @@ - (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 resetting the content builder state", __FUNCTION__); + [self clearContentState]; + // Also releases the shown slot. A second release here used to race its own deferred teardown: + // freeing the slot early let a fetch claim it, which made the queued close skip and strand the + // overlay with nothing left holding a reference to it. + [self closeShownContent]; + [self writeFlag:&_refreshRunnablePending value:NO]; + self.contentSegmentation = nil; + // the singleton outlives halt, and a next start that applies no behavior settings never sets the interval + self.zoneTimerInterval = kCountlyCBDefaultZoneTimerInterval; +} + +#pragma mark - Global content segmentation + +- (void)setGlobalContentSegmentation:(NSDictionary *)segmentation +{ + CLY_LOG_I(@"%s global content segmentation will be set, key count: [%lu], keys: [%@]", __FUNCTION__, (unsigned long)segmentation.count, segmentation.allKeys); + CLY_LOG_D(@"%s global content segmentation value detail, segmentation: [%@]", __FUNCTION__, segmentation); + + if (!segmentation.count) + { + self.contentSegmentation = nil; + return; + } + + NSDictionary* filteredSegmentation = segmentation.cly_filterSupportedDataTypes; + NSDictionary* truncatedSegmentation = [filteredSegmentation cly_truncated:@"Global content segmentation"]; + self.contentSegmentation = [truncatedSegmentation cly_limited:@"Global content segmentation"]; +} + +- (NSDictionary *)mergeGlobalContentSegmentationInto:(NSDictionary *)eventSegmentation +{ + NSDictionary* globalSegmentation = self.contentSegmentation; + if (!globalSegmentation.count) + { + return eventSegmentation; + } + + NSMutableDictionary* merged = globalSegmentation.mutableCopy; + [merged addEntriesFromDictionary:eventSegmentation]; + + NSUInteger maxSegmentationValues = CountlyCommon.sharedInstance.maxSegmentationValues; + if (merged.count > maxSegmentationValues) + { + NSMutableArray* globalOnlyKeys = globalSegmentation.allKeys.mutableCopy; + [globalOnlyKeys removeObjectsInArray:eventSegmentation.allKeys]; + NSUInteger droppedKeyCount = MIN(merged.count - maxSegmentationValues, globalOnlyKeys.count); + NSArray* droppedKeys = [globalOnlyKeys subarrayWithRange:(NSRange){0, droppedKeyCount}]; + [merged removeObjectsForKeys:droppedKeys]; + + CLY_LOG_D(@"%s global content segmentation exceeds the room the event leaves under the SDK segmentation value limit and will be trimmed, eventKeyCount: [%lu], limit: [%lu], droppedKeyCount: [%lu], droppedKeys: [%@]", __FUNCTION__, (unsigned long)eventSegmentation.count, (unsigned long)maxSegmentationValues, (unsigned long)droppedKeyCount, droppedKeys); + } + + return merged; +} + +- (NSDictionary *)globalContentSegmentationForFeedbackWidget +{ + NSDictionary* globalSegmentation = self.contentSegmentation; + if (!globalSegmentation.count) + { + return @{}; + } + + NSSet* reservedKeys = [NSSet setWithArray:@[kCountlyFBKeyPlatform, kCountlyFBKeyAppVersion, kCountlyFBKeyWidgetID, kCountlyFBKeyClosed, + kCountlyFBKeyShown, kCountlyFBKeyRating, kCountlyFBKeyComment, kCountlyFBKeyEmail, + kCountlyFBKeyContactMe, kCountlyCBKeyCampaignID]]; + + NSMutableDictionary* widgetSegmentation = globalSegmentation.mutableCopy; + for (NSString* key in globalSegmentation) + { + if ([reservedKeys containsObject:key] || [key hasPrefix:kCountlyCBSurveyAnswerKeyPrefix]) + [widgetSegmentation removeObjectForKey:key]; + } + + if (widgetSegmentation.count != globalSegmentation.count) + { + CLY_LOG_D(@"%s keys the server reads as a widget answer or uses to route a widget event are left out of feedback widget events, droppedKeyCount: [%lu]", __FUNCTION__, (unsigned long)(globalSegmentation.count - widgetSegmentation.count)); + } + + return widgetSegmentation; +} + +- (NSDictionary *)mergeGlobalContentSegmentationIntoFeedbackWidgetEvent:(NSDictionary *)eventSegmentation +{ + NSDictionary* widgetSegmentation = [self globalContentSegmentationForFeedbackWidget]; + if (!widgetSegmentation.count) + { + return eventSegmentation; + } + + NSMutableDictionary* merged = widgetSegmentation.mutableCopy; + [merged addEntriesFromDictionary:eventSegmentation]; + + return merged; +} + - (void)fetchContents { [self fetchContents:nil contentId:nil]; } - (void)fetchContents:(void (^)(void))failureCallback contentId:(NSString *)contentId { if (!CountlyConsentManager.sharedInstance.consentForContent) + { + CLY_LOG_V(@"%s no consent given for content, skipping the content fetch", __FUNCTION__); return; + } if (!CountlyServerConfig.sharedInstance.networkingEnabled) + { + CLY_LOG_D(@"%s networking is disabled by the server config, skipping the content fetch", __FUNCTION__); return; - - if(_isCurrentlyContentShown){ - CLY_LOG_I(@"%s a content is already shown, skipping" ,__FUNCTION__); + } + + if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) + { + CLY_LOG_D(@"%s content can not be fetched while in temporary device ID mode", __FUNCTION__); return; } - + + if([self isContentShownThreadSafe]){ + CLY_LOG_D(@"%s a content is already shown, skipping the content fetch", __FUNCTION__); + return; + } + if ([self isRequestQueueLockedThreadSafe]) { + CLY_LOG_D(@"%s a content fetch is already in flight, skipping this one", __FUNCTION__); return; } + CLY_LOG_D(@"%s fetching the content details, isPreview: [%@]", __FUNCTION__, contentId ? @"YES" : @"NO"); [self setRequestQueueLockedThreadSafe:YES]; - NSURLSessionTask *dataTask = [CountlyCommon.sharedInstance.URLSession dataTaskWithRequest:[self fetchContentsRequest: contentId] completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + NSURLSessionTask *dataTask = [CountlyCommon.sharedInstance immediateDataTaskWithRequest:[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); + CLY_LOG_D(@"%s fetching the content details failed, it will be retried on the next content zone tick, error: [%@], domain: [%@], code: [%ld]", __FUNCTION__, error.localizedDescription, error.domain, (long)error.code); [self setRequestQueueLockedThreadSafe:NO]; if (failureCallback) { failureCallback(); @@ -222,8 +501,8 @@ - (void)fetchContents:(void (^)(void))failureCallback contentId:(NSString *)cont NSError *jsonError; NSDictionary *jsonResponse = [NSJSONSerialization JSONObjectWithData:data options:0 error:&jsonError]; - if (jsonError || !jsonResponse) { - CLY_LOG_I(@"%s failed to parse JSON or empty response: [%@]", __FUNCTION__, jsonError); + if (jsonError || ![jsonResponse isKindOfClass:NSDictionary.class]) { + CLY_LOG_D(@"%s failed to parse the content details JSON or the response was empty, it will be retried on the next content zone tick, error: [%@], responseLength: [%lu]", __FUNCTION__, jsonError.localizedDescription, (unsigned long)data.length); [self setRequestQueueLockedThreadSafe:NO]; if (failureCallback) { failureCallback(); @@ -231,11 +510,15 @@ - (void)fetchContents:(void (^)(void))failureCallback contentId:(NSString *)cont return; } + CLY_LOG_D(@"%s the content details response parsed, url: [%@], jsonResponse: [%@]", __FUNCTION__, response.URL.absoluteString, jsonResponse); + NSString *pathToHtml = jsonResponse[@"html"]; NSDictionary *placementCoordinates = jsonResponse[@"geo"]; if(pathToHtml) { + CLY_LOG_D(@"%s the content details response carries a content, will present it", __FUNCTION__); [self showContentWithHtmlPath:pathToHtml placementCoordinates:placementCoordinates]; } else if (failureCallback) { + CLY_LOG_D(@"%s the content details response carries no content, invoking the failure callback", __FUNCTION__); failureCallback(); } [self setRequestQueueLockedThreadSafe:NO]; @@ -274,12 +557,14 @@ - (NSURLRequest *)fetchContentsRequest:(NSString *)contentId NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:contentEndpoint]]; request.HTTPMethod = @"POST"; request.HTTPBody = [queryString cly_dataUTF8]; + CLY_LOG_D(@"%s the content fetch request built as POST, url: [%@], body: [%@]", __FUNCTION__, contentEndpoint, queryString); return request.copy; } else { NSString* withQueryString = [contentEndpoint stringByAppendingFormat:@"?%@", queryString]; NSURLRequest* request = [NSURLRequest requestWithURL:[NSURL URLWithString:withQueryString]]; + CLY_LOG_D(@"%s the content fetch request built as GET, url: [%@]", __FUNCTION__, withQueryString); return request; } } @@ -288,7 +573,7 @@ - (NSString *)resolutionJson { //TODO: check why area is not clickable and safearea things CGSize size = [CountlyCommon.sharedInstance getWindowSize]; - UIInterfaceOrientation orientation = [UIApplication sharedApplication].statusBarOrientation; + UIInterfaceOrientation orientation = [CountlyCommon.sharedInstance interfaceOrientation]; BOOL isLandscape = UIInterfaceOrientationIsLandscape(orientation); CGFloat lHpW = isLandscape ? size.height : size.width; @@ -299,7 +584,9 @@ - (NSString *)resolutionJson { @"landscape": @{@"height": @(lHpW), @"width": @(lWpH)} }; - CLY_LOG_D(@"%s, resolutionDict: [%@]", __FUNCTION__, resolutionDict); + CLY_LOG_D(@"%s resolution reported to the server, portraitWidth: [%.0f], portraitHeight: [%.0f], isLandscape: [%@]", __FUNCTION__, lHpW, lWpH, isLandscape ? @"YES" : @"NO"); + + CLY_LOG_D(@"%s the resolution payload detail, resolutionDict: [%@]", __FUNCTION__, resolutionDict); NSData *jsonData = [NSJSONSerialization dataWithJSONObject:resolutionDict options:0 error:nil]; return [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; @@ -310,47 +597,82 @@ - (void)showContentWithHtmlPath:(NSString *)urlString placementCoordinates:(NSDi NSURL *url = [NSURL URLWithString:urlString]; if (!url || !url.scheme || !url.host) { - CLY_LOG_E(@"%s the URL is not valid: [%@]", __FUNCTION__, urlString); + CLY_LOG_E(@"%s the content URL is not valid, urlLength: [%lu]", __FUNCTION__, (unsigned long)urlString.length); + CLY_LOG_D(@"%s the invalid content URL detail, url: [%@]", __FUNCTION__, urlString); return; } - + // Claim the single content slot before dispatching to main. If content is already shown, + // or a racing content fetch already claimed it, skip: never present a second overlapping + // web view for the same zone. + if (![self tryBeginContentPresentation]) { + CLY_LOG_D(@"%s a content is already shown, skipping this duplicate presentation", __FUNCTION__); + return; + } + + NSUInteger claimedSequence = [self currentPresentationSequence]; + dispatch_async(dispatch_get_main_queue(), ^ { + // The zone can be exited while this is in flight, which releases our claim. Do not present + // content the app has already exited, and do not ride a claim a newer fetch has taken. + if (![self isContentShownThreadSafe] || [self currentPresentationSequence] != claimedSequence) { + CLY_LOG_D(@"%s the presentation claim was released or superseded before presenting, skipping", __FUNCTION__); + return; + } + // Detect screen orientation - UIInterfaceOrientation orientation = [UIApplication sharedApplication].statusBarOrientation; - BOOL isLandscape = UIInterfaceOrientationIsLandscape(orientation); - - // Get the appropriate coordinates based on the orientation + UIInterfaceOrientation orientation = [CountlyCommon.sharedInstance interfaceOrientation]; + // disableRotation pins content to the portrait layout, whatever the device is doing. + BOOL isLandscape = self.disableRotation ? NO : UIInterfaceOrientationIsLandscape(orientation); + + // Initial placement only; from here on the page's resize_me reply drives the size. NSDictionary *coordinates = isLandscape ? placementCoordinates[@"l"] : placementCoordinates[@"p"]; - + CGFloat x = [coordinates[@"x"] floatValue]; CGFloat y = [coordinates[@"y"] floatValue]; CGFloat width = [coordinates[@"w"] floatValue]; CGFloat height = [coordinates[@"h"] floatValue]; - + CGRect frame = CGRectMake(x, y, width, height); - - // Log the URL and the frame - CLY_LOG_I(@"%s showing content from URL: [%@], frame: [%@]", __FUNCTION__, url, NSStringFromCGRect(frame)); + + // Append the current theme so the content renders matching the app (resolved on the main + // thread here, where the trait collection is safe to read). + NSURL *themedURL = [NSURL URLWithString:[CountlyDeviceInfo URLStringByAppendingThemeMode:urlString]] ?: url; + + // Host and path only: the content URL query carries the app key and the device ID. + CLY_LOG_I(@"%s showing a content, host: [%@], path: [%@], frame: [%@], isLandscape: [%@], rotationDisabled: [%@]", __FUNCTION__, themedURL.host, themedURL.path, NSStringFromCGRect(frame), isLandscape ? @"YES" : @"NO", self.disableRotation ? @"YES" : @"NO"); + CLY_LOG_D(@"%s the content being shown URL detail, url: [%@], placementCoordinates: [%@]", __FUNCTION__, themedURL.absoluteString, placementCoordinates); CountlyWebViewManager* webViewManager = CountlyWebViewManager.new; - [webViewManager createWebViewWithURL:url frame:frame appearBlock:^ + // Retained so exitContentZone / resetInstance can dismiss what we present + self->_webViewManager = webViewManager; + // Weak: the manager owns this block, so a strong capture would be a retain cycle. + __weak CountlyWebViewManager *weakManager = webViewManager; + [webViewManager createWebViewWithURL:themedURL frame:frame appearBlock:^ { - CLY_LOG_I(@"%s webview should be appeared", __FUNCTION__); + CLY_LOG_I(@"%s the content web view appeared on screen", __FUNCTION__); } dismissBlock:^ { - CLY_LOG_I(@"%s webview dismissed", __FUNCTION__); - self->_isCurrentlyContentShown = NO; - self->_minuteTimer = [NSTimer scheduledTimerWithTimeInterval:self->_zoneTimerInterval - target:self - selector:@selector(enterContentZone) - userInfo:nil - repeats:NO]; + CLY_LOG_I(@"%s the content web view was dismissed, isCurrentContent: [%@]", __FUNCTION__, (self->_webViewManager == weakManager) ? @"YES" : @"NO"); + + // Only the current content's dismissal may touch shared zone state; this block can + // belong to a superseded web view. + if (self->_webViewManager == weakManager) { + self->_webViewManager = nil; + [self endContentPresentation]; + self->_minuteTimer = [NSTimer scheduledTimerWithTimeInterval:self->_zoneTimerInterval + target:self + selector:@selector(enterContentZone) + userInfo:nil + repeats:NO]; + } + + // Reported regardless: this web view really did close. if(self.contentCallback) { self.contentCallback(CLOSED, NSDictionary.new); } }]; - CLY_LOG_I(@"%s webview initiated pausing content calls ", __FUNCTION__); - self->_isCurrentlyContentShown = YES; + // The shown slot was already claimed synchronously by tryBeginContentPresentation + // above, before this async block was dispatched. [self clearContentState]; }); } diff --git a/ios/src/CountlyContentConfig.h b/ios/src/CountlyContentConfig.h index 9aa29b12..acd25bb9 100644 --- a/ios/src/CountlyContentConfig.h +++ b/ios/src/CountlyContentConfig.h @@ -8,7 +8,7 @@ NS_ASSUME_NONNULL_BEGIN -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) typedef enum : NSUInteger { COMPLETED, @@ -22,11 +22,17 @@ typedef enum: NSUInteger } WebViewDisplayOption; typedef void (^ContentCallback)(ContentStatus contentStatus, NSDictionary* contentData); + +// Handler the host app can provide to take over opening a link tapped in the content web view +// (e.g. to route the app's own deep link to the right screen). Return YES if the app handled +// the URL; return NO to let the SDK open it in the system browser as usual. Called on the main +// thread. +typedef BOOL (^ContentURLHandler)(NSURL *url); #endif @interface CountlyContentConfig : NSObject -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) /** * This is an experimental feature and it can have breaking changes * Register global completion blocks to be executed on content. @@ -59,6 +65,57 @@ typedef void (^ContentCallback)(ContentStatus contentStatus, NSDictionary @interface CountlyCrashReporter : NSObject -@property (nonatomic) BOOL isEnabledOnInitialConfig; @property (nonatomic) NSDictionary* crashSegmentation; @property (nonatomic) NSUInteger crashLogLimit; @property (nonatomic) NSRegularExpression* crashFilter; @@ -25,6 +24,10 @@ - (void)recordError:(NSString *)errorName isFatal:(BOOL)isFatal stackTrace:(NSArray *)stackTrace segmentation:(NSDictionary *)segmentation; - (void)log:(NSString *)log; - (void)clearCrashLogs; +/// Puts back the crash handlers Countly replaced, or with PLCrashReporter, which stays installed, discards what it captures until the next start. +- (void)resetInstance; +/// Marks what PLCrashReporter captures from now on as unwanted, so it is purged instead of sent. No-op for the built in handlers. +- (void)suppressAutomaticCrashCapture; @end diff --git a/ios/src/CountlyCrashReporter.m b/ios/src/CountlyCrashReporter.m index ef1d697d..df83668f 100644 --- a/ios/src/CountlyCrashReporter.m +++ b/ios/src/CountlyCrashReporter.m @@ -7,6 +7,9 @@ #import "CountlyCommon.h" #import #include +#include +#include +#include #if __has_include() #define COUNTLY_PLCRASHREPORTER_EXISTS true @@ -51,7 +54,160 @@ NSString* const kCountlyCRKeyOB = @"_ob"; +// Which mechanism, if any, currently has Countly's unhandled crash handlers installed. Tracking the +// mechanism instead of a plain flag keeps two things correct: 'stopCrashReporting' only tears down the +// handlers Countly itself installed, and a change of 'shouldUsePLCrashReporter' between calls is not +// mistaken for "already installed" +typedef NS_ENUM(NSInteger, CLYCrashHandlerInstallation) +{ + CLYCrashHandlerInstallationNone = 0, + CLYCrashHandlerInstallationDefault, + CLYCrashHandlerInstallationPLCrashReporter +}; + +void CountlyUncaughtExceptionHandler(NSException *exception); +void CountlySignalHandler(int signalCode); + +static NSUncaughtExceptionHandler* s_previousExceptionHandler = NULL; +static BOOL s_hasInstalledExceptionHandler = NO; +static atomic_bool s_isHandlingUncaughtException = false; +// set by the first automatically detected crash, so the signal that ends the process after it, which a later crash +// tool can chain back into Countly, is not reported as a second crash +static atomic_bool s_hasReportedAutomaticCrash = false; + +/// Installs Countly's uncaught exception handler, chaining to the one it replaces, and leaves a handler installed over it in place. +static void CountlyInstallUncaughtExceptionHandler(void) +{ + NSUncaughtExceptionHandler* current = NSGetUncaughtExceptionHandler(); + if (current == &CountlyUncaughtExceptionHandler) + return; + + if (current && s_hasInstalledExceptionHandler && current != s_previousExceptionHandler) + return; + + s_previousExceptionHandler = current; + s_hasInstalledExceptionHandler = YES; + NSSetUncaughtExceptionHandler(&CountlyUncaughtExceptionHandler); +} + +/// Puts back the uncaught exception handler Countly replaced, unless another handler has replaced Countly's since, and then +/// forgets it, so the next install chains to whatever is live by then. +static void CountlyUninstallUncaughtExceptionHandler(void) +{ + // a handler installed over Countly's still chains into it, so the replaced one has to stay known for that chain + if (NSGetUncaughtExceptionHandler() != &CountlyUncaughtExceptionHandler) + return; + + NSSetUncaughtExceptionHandler(s_previousExceptionHandler); + s_previousExceptionHandler = NULL; + s_hasInstalledExceptionHandler = NO; +} + +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV || TARGET_OS_OSX) +static const int kCountlyCrashSignals[] = {SIGABRT, SIGILL, SIGSEGV, SIGFPE, SIGBUS, SIGPIPE, SIGTRAP}; +static const size_t kCountlyCrashSignalsCount = sizeof(kCountlyCrashSignals) / sizeof(kCountlyCrashSignals[0]); +static struct sigaction s_previousSignalActions[NSIG]; +static BOOL s_hasPreviousSignalAction[NSIG]; +// the thread reporting each signal, or the handed over mark once that is done, until Countly's handler is installed again +static _Atomic(pthread_t) s_signalHandlingThread[NSIG]; +#define kCountlySignalHandedOver ((pthread_t)(uintptr_t)1) + +/// Whether the given action runs CountlySignalHandler. +static BOOL CountlyIsCountlySignalAction(const struct sigaction *action) +{ + return !(action->sa_flags & SA_SIGINFO) && action->sa_handler == CountlySignalHandler; +} + +/// Whether two actions run the same handler. +static BOOL CountlyIsSameSignalAction(const struct sigaction *action, const struct sigaction *other) +{ + return (action->sa_flags & SA_SIGINFO) == (other->sa_flags & SA_SIGINFO) && action->sa_handler == other->sa_handler; +} + +/// Installs CountlySignalHandler for every crash signal the app does not ignore, keeping the replaced action and leaving later handlers in place. +static void CountlyInstallSignalHandlers(void) +{ + for (size_t i = 0; i < kCountlyCrashSignalsCount; i++) + { + int signalCode = kCountlyCrashSignals[i]; + struct sigaction current; + if (sigaction(signalCode, NULL, ¤t) != 0 || CountlyIsCountlySignalAction(¤t)) + continue; + + BOOL isPlainDisposition = !(current.sa_flags & SA_SIGINFO); + if (isPlainDisposition && current.sa_handler == SIG_IGN) + continue; + + BOOL isDefault = isPlainDisposition && current.sa_handler == SIG_DFL; + // a broken pipe is often survived: only the app's own handler knows, so a SIGPIPE it handles is left to it like one it ignores + if (signalCode == SIGPIPE && !isDefault) + continue; + + BOOL isReplacedAction = s_hasPreviousSignalAction[signalCode] && CountlyIsSameSignalAction(¤t, &s_previousSignalActions[signalCode]); + if (s_hasPreviousSignalAction[signalCode] && !isDefault && !isReplacedAction) + continue; + + struct sigaction action; + memset(&action, 0, sizeof(action)); + action.sa_handler = CountlySignalHandler; + sigemptyset(&action.sa_mask); + + struct sigaction previous; + if (sigaction(signalCode, &action, &previous) == 0) + { + s_previousSignalActions[signalCode] = previous; + s_hasPreviousSignalAction[signalCode] = YES; + atomic_store(&s_signalHandlingThread[signalCode], NULL); + } + } +} + +/// Puts back the actions Countly replaced, for the signals where Countly's handler is still the live one, and forgets those, so +/// the next install chains to whatever is live by then. A signal whose handler was installed over Countly's keeps its record. +static void CountlyUninstallSignalHandlers(void) +{ + for (size_t i = 0; i < kCountlyCrashSignalsCount; i++) + { + int signalCode = kCountlyCrashSignals[i]; + struct sigaction current; + if (s_hasPreviousSignalAction[signalCode] && sigaction(signalCode, NULL, ¤t) == 0 && CountlyIsCountlySignalAction(¤t)) + { + sigaction(signalCode, &s_previousSignalActions[signalCode], NULL); + memset(&s_previousSignalActions[signalCode], 0, sizeof(s_previousSignalActions[signalCode])); + s_hasPreviousSignalAction[signalCode] = NO; + } + } +} + +/// Restores the action Countly replaced, or the default one, and raises the signal again so it lands there once the handler returns. +static void CountlyHandOverSignal(int signalCode) +{ + if (s_hasPreviousSignalAction[signalCode] && !CountlyIsCountlySignalAction(&s_previousSignalActions[signalCode])) + sigaction(signalCode, &s_previousSignalActions[signalCode], NULL); + else + signal(signalCode, SIG_DFL); + + raise(signalCode); +} +#endif + +/// Forgets the handlers Countly replaced and the marks of a crash being handled, so the next install chains to whatever is live then. +static void CountlyForgetReplacedCrashHandlers(void) +{ + s_previousExceptionHandler = NULL; + s_hasInstalledExceptionHandler = NO; + atomic_store(&s_isHandlingUncaughtException, false); + atomic_store(&s_hasReportedAutomaticCrash, false); +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV || TARGET_OS_OSX) + memset(s_previousSignalActions, 0, sizeof(s_previousSignalActions)); + memset(s_hasPreviousSignalAction, 0, sizeof(s_hasPreviousSignalAction)); + for (int signalCode = 0; signalCode < NSIG; signalCode++) + atomic_store(&s_signalHandlingThread[signalCode], NULL); +#endif +} + @interface CountlyCrashReporter () +@property (nonatomic) CLYCrashHandlerInstallation crashHandlerInstallation; @property (nonatomic) NSMutableArray* customCrashLogs; @property (nonatomic) NSDateFormatter* dateFormatter; @property (nonatomic) NSString* buildUUID; @@ -91,89 +247,214 @@ - (instancetype)init - (void)startCrashReporting { - if (!self.isEnabledOnInitialConfig) - return; - - if (!CountlyConsentManager.sharedInstance.consentForCrashReporting) - return; +#ifdef COUNTLY_PLCRASHREPORTER_EXISTS + NSDictionary* pendingCrashReport = nil; +#endif - if (self.shouldUsePLCrashReporter) + // Serialized because the behavior settings response handler calls this from a background thread while + // init and consent changes call it from another. 'stopCrashReporting' is deliberately left unlocked, + // since it is also reached from the crash handler itself, where blocking on a lock is not acceptable. + @synchronized (self) { + // Gated on the resolved 'acr' value (seeded from the developer config, overridable by the server), + // so the server can enable automatic crash reporting even when the developer did not opt in + if (!CountlyServerConfig.sharedInstance.automaticCrashReportingEnabled) + { + CLY_LOG_D(@"%s crash handlers are not installed, automatic crash reporting is disabled", __FUNCTION__); + return; + } + + if (!CountlyConsentManager.sharedInstance.consentForCrashReporting) + { + CLY_LOG_V(@"%s no crashes consent given, crash handlers are not installed", __FUNCTION__); + return; + } + + if (self.shouldUsePLCrashReporter) + { + if (self.crashHandlerInstallation == CLYCrashHandlerInstallationPLCrashReporter) + { + CLY_LOG_D(@"%s PLCrashReporter based crash handlers are already installed, capture is wanted again", __FUNCTION__); + [CountlyPersistency.sharedInstance storePLCrashCaptureSuppressedSince:nil]; + return; + } + + if (self.crashHandlerInstallation == CLYCrashHandlerInstallationDefault) + { + // 'shouldUsePLCrashReporter' changed since the last install, so remove the default handlers + // Countly put in place before handing over to PLCrashReporter + CLY_LOG_D(@"%s built in crash handlers are being removed before handing over to PLCrashReporter", __FUNCTION__); + [self uninstallDefaultCrashHandlers]; + self.crashHandlerInstallation = CLYCrashHandlerInstallationNone; + } + + CLY_LOG_D(@"%s installing PLCrashReporter based crash handlers, useMachSignalHandler: [%@]", __FUNCTION__, self.shouldUseMachSignalHandler ? @"YES" : @"NO"); #ifdef COUNTLY_PLCRASHREPORTER_EXISTS - [self startPLCrashReporter]; + pendingCrashReport = [self startPLCrashReporter]; + // 'startPLCrashReporter' does not report whether PLCrashReporter accepted the install + self.crashHandlerInstallation = CLYCrashHandlerInstallationPLCrashReporter; + [CountlyPersistency.sharedInstance storePLCrashCaptureSuppressedSince:nil]; #else - [NSException raise:@"CountlyPLCrashReporterDependencyNotFoundException" format:@"PLCrashReporter dependency can not be found in Project"]; + // Logged rather than raised: this is a build configuration mistake, and this method is also reached + // from the behavior settings response handler on a background thread, where raising would crash the + // app, and from init, where it would abort the rest of 'startWithConfig' + CLY_LOG_E(@"%s PLCrashReporter dependency can not be found in Project, automatic crash reporting will not start", __FUNCTION__); #endif - return; - } - - NSSetUncaughtExceptionHandler(&CountlyUncaughtExceptionHandler); + } + else + { + if (self.crashHandlerInstallation == CLYCrashHandlerInstallationDefault) + CLY_LOG_V(@"%s built in crash handlers are already installed, any of them that was removed is put back", __FUNCTION__); + else + CLY_LOG_D(@"%s installing the built in uncaught exception handler and signal handlers", __FUNCTION__); + CountlyInstallUncaughtExceptionHandler(); #if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV || TARGET_OS_OSX) - signal(SIGABRT, CountlySignalHandler); - signal(SIGILL, CountlySignalHandler); - signal(SIGSEGV, CountlySignalHandler); - signal(SIGFPE, CountlySignalHandler); - signal(SIGBUS, CountlySignalHandler); - signal(SIGPIPE, CountlySignalHandler); - signal(SIGTRAP, CountlySignalHandler); + CountlyInstallSignalHandlers(); +#endif + atomic_store(&s_hasReportedAutomaticCrash, false); + self.crashHandlerInstallation = CLYCrashHandlerInstallationDefault; + } + } + +#ifdef COUNTLY_PLCRASHREPORTER_EXISTS + // outside the lock: the developer's callbacks run here and may call back into the SDK + if (pendingCrashReport) + { + CountlyRunOnMainThread(^{ + [self processPendingCrashReport:pendingCrashReport]; + }); + } #endif } - (void)stopCrashReporting { - if (!self.isEnabledOnInitialConfig) + // Cleared before the installation check: breadcrumbs are gathered under crash consent alone, so they + // can exist even when no handler was ever installed (for example with 'acr' resolved to false), and a + // consent revocation has to drop them either way + [self clearCrashLogs]; + + if (self.crashHandlerInstallation == CLYCrashHandlerInstallationNone) + { + CLY_LOG_D(@"%s crash handler uninstall is a no-op, no crash handlers were installed by the SDK", __FUNCTION__); + return; + } + + if (self.crashHandlerInstallation == CLYCrashHandlerInstallationPLCrashReporter) + { + // PLCrashReporter can not be uninstalled once enabled, and Countly never installed the default + // handlers on that path, so they are left alone rather than clobbering handlers that belong to the + // host app or another SDK. What it captures from now on is purged instead of sent. + // NOTE: kept at Debug on purpose, this method is reachable from the signal handler and the Error and + // Warning macros are not async signal safe + CLY_LOG_D(@"%s PLCrashReporter can not be uninstalled, crashes it captures from now on will be discarded", __FUNCTION__); + [self markPLCrashCaptureSuppressed]; + } + else + { + CLY_LOG_D(@"%s uninstalling the uncaught exception handler and restoring the replaced signal handlers", __FUNCTION__); + [self uninstallDefaultCrashHandlers]; + } + + self.crashHandlerInstallation = CLYCrashHandlerInstallationNone; +} + +- (void)resetInstance +{ + if (self.crashHandlerInstallation == CLYCrashHandlerInstallationDefault) + { + CLY_LOG_D(@"%s putting back the crash handlers Countly replaced", __FUNCTION__); + [self uninstallDefaultCrashHandlers]; + self.crashHandlerInstallation = CLYCrashHandlerInstallationNone; + } + else if (self.crashHandlerInstallation == CLYCrashHandlerInstallationPLCrashReporter) + { + CLY_LOG_D(@"%s PLCrashReporter stays installed, crashes it captures until the SDK starts again will be discarded", __FUNCTION__); + [self markPLCrashCaptureSuppressed]; + } + + CountlyForgetReplacedCrashHandlers(); +} + +- (void)suppressAutomaticCrashCapture +{ + if (self.crashHandlerInstallation != CLYCrashHandlerInstallationPLCrashReporter) return; - NSSetUncaughtExceptionHandler(NULL); + CLY_LOG_D(@"%s automatic crash reporting is off while PLCrashReporter stays installed, crashes it captures from now on will be discarded", __FUNCTION__); + [self markPLCrashCaptureSuppressed]; +} + +/// Records when automatic capture stopped being wanted while PLCrashReporter stays installed. An earlier record is kept. +- (void)markPLCrashCaptureSuppressed +{ + if (![CountlyPersistency.sharedInstance retrievePLCrashCaptureSuppressedSince]) + [CountlyPersistency.sharedInstance storePLCrashCaptureSuppressedSince:NSDate.date]; +} + +/// Puts back the uncaught exception handler and the signal actions Countly replaced, where Countly's are still the live ones. +- (void)uninstallDefaultCrashHandlers +{ + CountlyUninstallUncaughtExceptionHandler(); #if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV || TARGET_OS_OSX) - signal(SIGABRT, SIG_DFL); - signal(SIGILL, SIG_DFL); - signal(SIGSEGV, SIG_DFL); - signal(SIGFPE, SIG_DFL); - signal(SIGBUS, SIG_DFL); - signal(SIGPIPE, SIG_DFL); - signal(SIGTRAP, SIG_DFL); + CountlyUninstallSignalHandlers(); #endif - - [self clearCrashLogs]; } #ifdef COUNTLY_PLCRASHREPORTER_EXISTS -- (void)startPLCrashReporter +/// Enables PLCrashReporter and returns the report it left from the previous run, to be processed outside the lock, or nil when there is none to send. +- (NSDictionary *)startPLCrashReporter { PLCrashReporterSignalHandlerType type = self.shouldUseMachSignalHandler ? PLCrashReporterSignalHandlerTypeMach : PLCrashReporterSignalHandlerTypeBSD; PLCrashReporterConfig* config = [PLCrashReporterConfig.alloc initWithSignalHandlerType:type symbolicationStrategy:PLCrashReporterSymbolicationStrategyNone]; self.crashReporter = [PLCrashReporter.alloc initWithConfiguration:config]; + CLY_LOG_D(@"%s PLCrashReporter is configured, hasPendingCrashReport: [%@]", __FUNCTION__, self.crashReporter.hasPendingCrashReport ? @"YES" : @"NO"); + + NSDictionary* pendingCrashReport = nil; if (self.crashReporter.hasPendingCrashReport) - [self handlePendingCrashReport]; + pendingCrashReport = [self loadPendingCrashReport]; else [CountlyPersistency.sharedInstance deleteCustomCrashLogFile]; [self.crashReporter enableCrashReporter]; + + return pendingCrashReport; } -- (void)handlePendingCrashReport +/// Builds the pending PLCrashReporter crash report with its breadcrumbs and clears them, or purges it and returns nil when capture was not wanted. +- (NSDictionary *)loadPendingCrashReport { NSError *error; NSData* crashData = [self.crashReporter loadPendingCrashReportDataAndReturnError:&error]; if (!crashData) { - CLY_LOG_W(@"Could not load crash report data: %@", error); - return; + CLY_LOG_E(@"%s pending crash report data could not be loaded from disk, error: [%@]", __FUNCTION__, error.localizedDescription); + return nil; } PLCrashReport *report = [PLCrashReport.alloc initWithData:crashData error:&error]; if (!report) { - CLY_LOG_W(@"Could not initialize crash report using data %@", error); - return; + CLY_LOG_E(@"%s pending crash report could not be parsed from the loaded data, error: [%@]", __FUNCTION__, error.localizedDescription); + return nil; + } + + NSDate* suppressedSince = [CountlyPersistency.sharedInstance retrievePLCrashCaptureSuppressedSince]; + NSDate* crashedAt = report.systemInfo.timestamp; + // a report without a time can not be shown to predate the window, so it is treated as captured inside it + if (suppressedSince && (!crashedAt || [crashedAt compare:suppressedSince] != NSOrderedAscending)) + { + CLY_LOG_D(@"%s pending crash report was captured while automatic crash reporting was not wanted, it will be discarded", __FUNCTION__); + [CountlyPersistency.sharedInstance deleteCustomCrashLogFile]; + [self.crashReporter purgePendingCrashReport]; + return nil; } NSString* reportText = [PLCrashReportTextFormatter stringValueForCrashReport:report withTextFormat:PLCrashReportTextFormatiOS]; @@ -185,27 +466,34 @@ - (void)handlePendingCrashReport crashReport[kCountlyCRKeyPLCrash] = @YES; crashReport[kCountlyCRKeyCustom] = [CountlyPersistency.sharedInstance customCrashLogsFromFile]; + [CountlyPersistency.sharedInstance deleteCustomCrashLogFile]; + + return crashReport; +} + +/// Hands the pending report to the developer's callbacks, queues it unless they decline it, then purges it. Main thread only. +- (void)processPendingCrashReport:(NSDictionary *)crashReport +{ if (self.crashOccuredOnPreviousSessionCallback) self.crashOccuredOnPreviousSessionCallback(crashReport); BOOL shouldSend = YES; if (self.shouldSendCrashReportCallback) { - CLY_LOG_D(@"shouldSendCrashReportCallback is set, asking it if the report should be sent or not."); + CLY_LOG_D(@"%s asking shouldSendCrashReportCallback whether the pending report should be sent", __FUNCTION__); shouldSend = self.shouldSendCrashReportCallback(crashReport); - if (shouldSend) - CLY_LOG_D(@"shouldSendCrashReportCallback returned YES, sending the report."); - else - CLY_LOG_D(@"shouldSendCrashReportCallback returned NO, not sending the report."); + CLY_LOG_D(@"%s shouldSendCrashReportCallback decision received, shouldSend: [%@]", __FUNCTION__, shouldSend ? @"YES" : @"NO"); } if (shouldSend) { + CLY_LOG_I(@"%s pending crash report recorded, isFatal: [%@], breadcrumbLength: [%lu], customSegmentationProvided: [%@]", __FUNCTION__, @"YES", (unsigned long)[(NSString *)crashReport[kCountlyCRKeyCustom] length], (self.crashSegmentation.count > 0) ? @"YES" : @"NO"); + CLY_LOG_D(@"%s pending crash report detail, report: [%@]", __FUNCTION__, crashReport); + [CountlyConnectionManager.sharedInstance sendCrashReport:[crashReport cly_JSONify] immediately:NO]; } - [CountlyPersistency.sharedInstance deleteCustomCrashLogFile]; [self.crashReporter purgePendingCrashReport]; } @@ -213,8 +501,14 @@ - (void)handlePendingCrashReport - (void)recordException:(NSException *)exception isFatal:(BOOL)isFatal stackTrace:(NSArray *)stackTrace segmentation:(NSDictionary *)segmentation { + CLY_LOG_I(@"%s exception recording requested, exceptionName: [%@], isFatal: [%@], stackTraceFrameCount: [%lu], segmentationKeyCount: [%lu]", __FUNCTION__, exception.name, isFatal ? @"YES" : @"NO", (unsigned long)stackTrace.count, (unsigned long)segmentation.count); + CLY_LOG_D(@"%s exception recording detail, exception: [%@], stackTrace: [%@], segmentation: [%@]", __FUNCTION__, exception, stackTrace, segmentation); + if (!CountlyConsentManager.sharedInstance.consentForCrashReporting) + { + CLY_LOG_V(@"%s no crashes consent given, exception is not recorded", __FUNCTION__); return; + } if (stackTrace || segmentation) { @@ -233,20 +527,53 @@ - (void)recordException:(NSException *)exception isFatal:(BOOL)isFatal stackTrac - (void)recordError:(NSString *)errorName isFatal:(BOOL)isFatal stackTrace:(NSArray *)stackTrace segmentation:(NSDictionary *)segmentation { + CLY_LOG_I(@"%s error recording requested, errorNameLength: [%lu], isFatal: [%@], stackTraceFrameCount: [%lu], segmentationKeyCount: [%lu]", __FUNCTION__, (unsigned long)errorName.length, isFatal ? @"YES" : @"NO", (unsigned long)stackTrace.count, (unsigned long)segmentation.count); + CLY_LOG_D(@"%s error recording detail, errorName: [%@], stackTrace: [%@], segmentation: [%@]", __FUNCTION__, errorName, stackTrace, segmentation); + NSException* exception = [NSException exceptionWithName:@"Swift Error" reason:errorName userInfo:nil]; [self recordException:exception isFatal:isFatal stackTrace:stackTrace segmentation:segmentation]; } +/// The uncaught exception handler Countly installs: reports the crash, then passes it on to the handler Countly replaced. void CountlyUncaughtExceptionHandler(NSException *exception) { + // a handler that chains back into Countly's would otherwise call the two in turn forever + bool expected = false; + if (!atomic_compare_exchange_strong(&s_isHandlingUncaughtException, &expected, true)) + return; + CountlyExceptionHandler(exception, true, true); + + NSUncaughtExceptionHandler* previousHandler = s_previousExceptionHandler; + if (previousHandler && previousHandler != &CountlyUncaughtExceptionHandler) + previousHandler(exception); } void CountlyExceptionHandler(NSException *exception, bool isFatal, bool isAutoDetect) { if (!CountlyServerConfig.sharedInstance.crashReportingEnabled) return; - + + // Automatically detected crashes are additionally gated on the resolved 'acr' value, so a runtime + // 'acr' = false from the server disables them without uninstalling the handler. Manually recorded + // exceptions stay governed by 'crt' only. + if (isAutoDetect && !CountlyServerConfig.sharedInstance.automaticCrashReportingEnabled) + return; + + // a handler installed over Countly's can still chain into it after crash consent is withdrawn + if (isAutoDetect && !CountlyConsentManager.sharedInstance.consentForCrashReporting) + return; + + if (isAutoDetect) + { + if (atomic_exchange(&s_hasReportedAutomaticCrash, true)) + return; + + // the immediate send below waits for its completion, which logs on another thread and must not wait for a log + // gathering lock this thread may hold + CountlySDKLogsStopCaptureForCrash(); + } + NSArray* stackTrace = exception.userInfo[kCountlyExceptionUserInfoBacktraceKey]; if (!stackTrace) stackTrace = exception.callStackSymbols; @@ -285,7 +612,7 @@ void CountlyExceptionHandler(NSException *exception, bool isFatal, bool isAutoDe //NOTE: Do not send crash report if it is matching optional regex filter. if (matchesFilter || filterCrash) { - CLY_LOG_D(@"Crash matches filter and it will not be processed."); + CLY_LOG_D(@"%s crash report is filtered out and will not be processed, matchesRegexFilter: [%@], matchesFilterCallback: [%@]", __FUNCTION__, matchesFilter ? @"YES" : @"NO", filterCrash ? @"YES" : @"NO"); } else { @@ -315,6 +642,9 @@ void CountlyExceptionHandler(NSException *exception, bool isFatal, bool isAutoDe crashReport[kCountlyCRKeyLogs] = [crashData.breadcrumbs componentsJoinedByString:@"\n"]; } + CLY_LOG_I(@"%s crash recorded, isFatal: [%@], isAutoDetected: [%@], customSegmentationKeyCount: [%lu], breadcrumbCount: [%lu]", __FUNCTION__, crashData.fatal ? @"YES" : @"NO", isAutoDetect ? @"YES" : @"NO", (unsigned long)crashData.crashSegmentation.count, (unsigned long)crashData.breadcrumbs.count); + CLY_LOG_D(@"%s crash detail, breadcrumbs: [%@], crashSegmentation: [%@], stackTrace: [%@]", __FUNCTION__, crashData.breadcrumbs, crashData.crashSegmentation, crashData.stackTrace); + [CountlyConnectionManager.sharedInstance sendCrashReport:[crashReport cly_JSONify] immediately:isAutoDetect]; } @@ -324,6 +654,28 @@ void CountlyExceptionHandler(NSException *exception, bool isFatal, bool isAutoDe void CountlySignalHandler(int signalCode) { +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV || TARGET_OS_OSX) + pthread_t handlingThread = NULL; + if (!atomic_compare_exchange_strong(&s_signalHandlingThread[signalCode], &handlingThread, pthread_self())) + { + if (handlingThread != pthread_self() && handlingThread != kCountlySignalHandedOver) + { + // another thread crashed with this signal first, its report goes out before its hand over ends the process + struct timespec pollInterval = {0, 10 * NSEC_PER_MSEC}; + while (atomic_load(&s_signalHandlingThread[signalCode]) == handlingThread) + nanosleep(&pollInterval, NULL); + + raise(signalCode); + return; + } + + // re-entered on the reporting thread or after the hand over, so a handler chains back into Countly's and the default action ends the loop + signal(signalCode, SIG_DFL); + raise(signalCode); + return; + } +#endif + const unsigned int kCountlyStackFramesMax = 128; void *stack[kCountlyStackFramesMax]; NSInteger frameCount = backtrace(stack, kCountlyStackFramesMax); @@ -346,18 +698,29 @@ void CountlySignalHandler(int signalCode) NSString *reason = [NSString stringWithFormat:@"App terminated by SIG%@", [NSString stringWithUTF8String:sys_signame[signalCode]].uppercaseString]; NSException *e = [NSException exceptionWithName:@"Fatal Signal" reason:reason userInfo:userInfo]; - CountlyUncaughtExceptionHandler(e); + CountlyExceptionHandler(e, true, true); + +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV || TARGET_OS_OSX) + CountlyHandOverSignal(signalCode); + atomic_store(&s_signalHandlingThread[signalCode], kCountlySignalHandedOver); +#endif } - (void)log:(NSString *)log { if (!CountlyConsentManager.sharedInstance.consentForCrashReporting) + { + CLY_LOG_V(@"%s no crashes consent given, breadcrumb is not recorded", __FUNCTION__); return; + } log = [log cly_truncatedValue:@"Custom Crash log"]; + CLY_LOG_D(@"%s breadcrumb text detail, log: [%@]", __FUNCTION__, log); + if (self.shouldUsePLCrashReporter) { + CLY_LOG_D(@"%s breadcrumb is being written to the crash log file", __FUNCTION__); [CountlyPersistency.sharedInstance writeCustomCrashLogToFile:log]; } else @@ -365,12 +728,21 @@ - (void)log:(NSString *)log [self.customCrashLogs addObject:log]; if (self.customCrashLogs.count > self.crashLogLimit) + { + CLY_LOG_D(@"%s breadcrumb buffer is full, the oldest breadcrumb is dropped, breadcrumbCount: [%lu], limit: [%lu]", __FUNCTION__, (unsigned long)self.customCrashLogs.count, (unsigned long)self.crashLogLimit); [self.customCrashLogs removeObjectAtIndex:0]; + } + else + { + CLY_LOG_D(@"%s breadcrumb added to the in memory buffer, breadcrumbCount: [%lu]", __FUNCTION__, (unsigned long)self.customCrashLogs.count); + } } } - (void)clearCrashLogs { + CLY_LOG_D(@"%s breadcrumbs are being cleared, breadcrumbCount: [%lu]", __FUNCTION__, (unsigned long)self.customCrashLogs.count); + if (self.shouldUsePLCrashReporter) { [CountlyPersistency.sharedInstance deleteCustomCrashLogFile]; @@ -406,7 +778,7 @@ - (NSDictionary *)binaryImagesForStackTrace:(NSArray *)stackTrace const char* imageNameChar = _dyld_get_image_name(i); if (imageNameChar == NULL) { - CLY_LOG_W(@"Image Name can not be retrieved!"); + CLY_LOG_V(@"%s binary image name can not be retrieved, it will be skipped, imageIndex: [%u]", __FUNCTION__, i); continue; } @@ -418,12 +790,12 @@ - (NSDictionary *)binaryImagesForStackTrace:(NSArray *)stackTrace continue; } - CLY_LOG_D(@"%s, imageName:[%@] is in the stack trace, so it will be used!", __FUNCTION__, imageName); + CLY_LOG_V(@"%s, imageName:[%@] is in the stack trace, so it will be used!", __FUNCTION__, imageName); const struct mach_header* imageHeader = _dyld_get_image_header(i); if (imageHeader == NULL) { - CLY_LOG_W(@"Image Header can not be retrieved!"); + CLY_LOG_V(@"%s binary image header can not be retrieved, it will be skipped, imageName: [%@]", __FUNCTION__, imageName); continue; } @@ -452,7 +824,7 @@ - (NSDictionary *)binaryImagesForStackTrace:(NSArray *)stackTrace if (!imageUUID) { - CLY_LOG_W(@"Image UUID can not be retrieved!"); + CLY_LOG_V(@"%s binary image UUID can not be retrieved, symbolication may fail for it, imageName: [%@]", __FUNCTION__, imageName); continue; } @@ -469,6 +841,9 @@ - (NSDictionary *)binaryImagesForStackTrace:(NSArray *)stackTrace } CountlyCrashReporter.sharedInstance.architecture = [CountlyDeviceInfo architectureNameForCPUType:cpuType subtype:cpuSubType]; + + CLY_LOG_D(@"%s binary images resolved for the stack trace, loadedImageCount: [%u], matchedImageCount: [%lu]", __FUNCTION__, imageCount, (unsigned long)binaryImages.count); + return [NSDictionary dictionaryWithDictionary:binaryImages]; } @@ -487,13 +862,16 @@ - (BOOL)isMatchingFilter:(NSString *)string -(void) setCrashSegmentation:(NSDictionary*) crashSegmentation { + CLY_LOG_I(@"%s global crash segmentation is being set, keyCount: [%lu], keys: [%@]", __FUNCTION__, (unsigned long)crashSegmentation.count, crashSegmentation.allKeys); + CLY_LOG_D(@"%s global crash segmentation detail, segmentation: [%@]", __FUNCTION__, crashSegmentation); + NSDictionary* truncatedSegmentation = [crashSegmentation cly_truncated:@"Crash segmentation"]; _crashSegmentation = [truncatedSegmentation cly_limited:@"Crash segmentation"]; } - (CountlyCrashData *)prepareCrashDataWithError:(NSString *)error name:(NSString *)name description:(NSString *)description isFatal:(BOOL)isFatal customSegmentation:(NSMutableDictionary *)customSegmentation { if(error == nil) { - CLY_LOG_W(@"Error must not be nil"); + CLY_LOG_D(@"%s crash data is being prepared with a nil stack trace, crashName: [%@], isFatal: [%@]", __FUNCTION__, name, isFatal ? @"YES" : @"NO"); } NSDictionary* truncatedSegmentation = [customSegmentation cly_truncated:@"Exception segmentation"]; @@ -545,6 +923,13 @@ - (void)reportException:(NSException *)exception { [super reportException:exception]; + // the exception is captured without the app asking, so it follows automatic crash reporting as the uncaught handler does + if (!CountlyServerConfig.sharedInstance.automaticCrashReportingEnabled) + { + CLY_LOG_D(@"%s exception caught by the application class is not recorded, automatic crash reporting is disabled", __FUNCTION__); + return; + } + //NOTE: Custom UncaughtExceptionHandler is called with an irrelevant stack trace, not the original crash call stack trace. //NOTE: And system's own UncaughtExceptionHandler handles the exception by just printing it to the Console. //NOTE: So, we intercept the exception here and record manually. diff --git a/ios/src/CountlyDeviceInfo.h b/ios/src/CountlyDeviceInfo.h index 90d53d00..f0f43e21 100644 --- a/ios/src/CountlyDeviceInfo.h +++ b/ios/src/CountlyDeviceInfo.h @@ -51,4 +51,18 @@ typedef enum : NSUInteger + (BOOL)isJailbroken; + (BOOL)isInBackground; + (NSString *)architectureNameForCPUType:(cpu_type_t)cpuType subtype:(cpu_subtype_t)cpuSubtype; + +// Resolves the theme the app is currently rendering with. Returns @"d" for dark, @"l" for light, +// or nil when the mode is undefined/unavailable (e.g. below iOS 13). Reads the key window's trait +// collection because in-app messages render in that window - so its style is the effective theme. ++ (nullable NSString *)themeMode; + +// Appends the current theme as the "th" query parameter to the given URL, so a feedback/rating +// widget or content loaded in a WebView renders matching the theme. "?" is used when the URL has +// no query yet, "&" otherwise. When the theme is undefined the URL is returned unchanged. ++ (NSString *)URLStringByAppendingThemeMode:(NSString *)urlString NS_SWIFT_NAME(urlStringByAppendingThemeMode(_:)); + +// Primitive used by URLStringByAppendingThemeMode: with the resolved theme passed explicitly so +// the separator/omission logic is testable independently of the runtime interface style. ++ (NSString *)URLString:(NSString *)urlString byAppendingThemeMode:(nullable NSString *)theme; @end diff --git a/ios/src/CountlyDeviceInfo.m b/ios/src/CountlyDeviceInfo.m index 7af25929..5b8f091f 100644 --- a/ios/src/CountlyDeviceInfo.m +++ b/ios/src/CountlyDeviceInfo.m @@ -98,7 +98,11 @@ - (void)applicationWillEnterForeground:(NSNotification *)notification - (void)initializeDeviceID:(NSString *)deviceID { + CLY_LOG_D(@"%s device ID will be initialized, providedLength: [%lu]", __FUNCTION__, (unsigned long)deviceID.length); + CLY_LOG_D(@"%s provided device ID value detail, deviceID: [%@]", __FUNCTION__, deviceID); + self.deviceID = [self ensafeDeviceID:deviceID]; + CLY_LOG_D(@"%s resolved device ID value detail, deviceID: [%@]", __FUNCTION__, self.deviceID); [CountlyPersistency.sharedInstance storeDeviceID:self.deviceID]; } @@ -139,7 +143,7 @@ - (CLYDeviceIDTypeValue)deviceIDTypeValue if ([deviceIDType isEqual:CLYDeviceIDTypeTemporary]) return CLYDeviceIDTypeValueTemporary; - CLY_LOG_E(@"Device ID type is not one of the defined types."); + CLY_LOG_D(@"%s device ID type is not one of the defined types, type: [%@]", __FUNCTION__, deviceIDType); return (CLYDeviceIDTypeValue)-1; } @@ -261,11 +265,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; @@ -293,6 +315,65 @@ + (NSString *)density return [NSString stringWithFormat:@"@%dx", (int)scale]; } ++ (NSString *)themeMode +{ +#if (TARGET_OS_IOS || TARGET_OS_TV) + @try + { + if (@available(iOS 12.0, tvOS 12.0, *)) + { + UITraitCollection *traits = nil; + + if (@available(iOS 13.0, tvOS 13.0, *)) + { + for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) + { + if ([scene isKindOfClass:UIWindowScene.class]) + { + UIWindow *window = ((UIWindowScene *)scene).windows.firstObject; + if (window) + traits = window.traitCollection; + break; + } + } + } + + if (!traits) + traits = UIScreen.mainScreen.traitCollection; + + switch (traits.userInterfaceStyle) + { + case UIUserInterfaceStyleDark: + return @"d"; + case UIUserInterfaceStyleLight: + return @"l"; + default: + return nil; + } + } + } + @catch (NSException *exception) + { + CLY_LOG_E(@"%s failed to resolve theme mode, exception: [%@]", __FUNCTION__, exception.reason); + } +#endif + return nil; +} + ++ (NSString *)URLStringByAppendingThemeMode:(NSString *)urlString +{ + return [self URLString:urlString byAppendingThemeMode:[self themeMode]]; +} + ++ (NSString *)URLString:(NSString *)urlString byAppendingThemeMode:(NSString *)theme +{ + if (!theme.length) + return urlString; + + NSString *separator = [urlString containsString:@"?"] ? @"&" : @"?"; + return [urlString stringByAppendingFormat:@"%@%@=%@", separator, kCountlyQSKeyTheme, theme]; +} + + (NSString *)locale { return NSLocale.currentLocale.localeIdentifier; @@ -327,6 +408,9 @@ + (NSDictionary *)metricsDictionary [metricsDictionary addEntriesFromDictionary:CountlyDeviceInfo.sharedInstance.customMetrics]; + CLY_LOG_D(@"%s metrics collected, keys: [%@], count: [%lu]", __FUNCTION__, metricsDictionary.allKeys, (unsigned long)metricsDictionary.count); + CLY_LOG_D(@"%s collected metrics dictionary value detail, metrics: [%@]", __FUNCTION__, metricsDictionary); + return metricsDictionary; } @@ -379,7 +463,7 @@ + (NSUInteger)connectionType } @catch (NSException *exception) { - CLY_LOG_W(@"%s, Connection type can not be retrieved, got exception: %@", __FUNCTION__, exception); + CLY_LOG_D(@"%s connection type can not be retrieved, exception: [%@]", __FUNCTION__, exception.reason); } return connType; @@ -512,10 +596,12 @@ + (NSString *)architectureNameForCPUType:(cpu_type_t)cpuType subtype:(cpu_subtyp - (void)resetInstance { - CLY_LOG_I(@"%s", __FUNCTION__); + CLY_LOG_I(@"%s device info instance will be reset", __FUNCTION__); + self.deviceID = nil; onceToken = 0; s_sharedInstance = nil; } @end + diff --git a/ios/src/CountlyEvent.m b/ios/src/CountlyEvent.m index 8d6d7193..cf8b7db5 100644 --- a/ios/src/CountlyEvent.m +++ b/ios/src/CountlyEvent.m @@ -42,11 +42,14 @@ - (NSDictionary *)dictionaryRepresentation eventData[kCountlyEventKeyPVID] = self.PVID; eventData[kCountlyEventKeyPEID] = self.PEID; eventData[kCountlyEventKeyCount] = @(self.count); - eventData[kCountlyEventKeySum] = @(self.sum); + // a value JSON can not represent, such as NaN, is left out as Android does, since it would fail the whole batch + if (isfinite(self.sum)) + eventData[kCountlyEventKeySum] = @(self.sum); eventData[kCountlyEventKeyTimestamp] = @((long long)(self.timestamp * 1000)); eventData[kCountlyEventKeyHourOfDay] = @(self.hourOfDay); eventData[kCountlyEventKeyDayOfWeek] = @(self.dayOfWeek); - eventData[kCountlyEventKeyDuration] = @(self.duration); + if (isfinite(self.duration)) + eventData[kCountlyEventKeyDuration] = @(self.duration); return eventData; } diff --git a/ios/src/CountlyFeedbackWidget.h b/ios/src/CountlyFeedbackWidget.h index 5ae8f0fe..6ead8bfb 100644 --- a/ios/src/CountlyFeedbackWidget.h +++ b/ios/src/CountlyFeedbackWidget.h @@ -19,7 +19,7 @@ extern NSString* const kCountlyReservedEventNPS; extern NSString* const kCountlyReservedEventRating; @interface CountlyFeedbackWidget : NSObject -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) @property (nonatomic, readonly) CLYFeedbackWidgetType type; @property (nonatomic, readonly) NSString* ID; diff --git a/ios/src/CountlyFeedbackWidget.m b/ios/src/CountlyFeedbackWidget.m index a229678a..688461f8 100644 --- a/ios/src/CountlyFeedbackWidget.m +++ b/ios/src/CountlyFeedbackWidget.m @@ -6,7 +6,7 @@ #import "CountlyCommon.h" #import "CountlyWebViewManager.h" -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) #import #endif @@ -21,7 +21,7 @@ NSString* const kCountlyFBKeyClosed = @"closed"; NSString* const kCountlyFBKeyShown = @"shown"; -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) @interface CountlyFeedbackWidget () #else @interface CountlyFeedbackWidget () @@ -33,14 +33,14 @@ @interface CountlyFeedbackWidget () @property (nonatomic) NSArray* tags; @property (nonatomic) NSDictionary* data; @property (nonatomic) WidgetCallback widgetCallback; -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) @property (nonatomic) CLYInternalViewController* webVC; #endif @end @implementation CountlyFeedbackWidget -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) + (CountlyFeedbackWidget *)createWithDictionary:(NSDictionary *)dictionary { @@ -50,19 +50,20 @@ + (CountlyFeedbackWidget *)createWithDictionary:(NSDictionary *)dictionary feedback.name = dictionary[@"name"]; feedback.tags = dictionary[@"tg"]; feedback.widgetVersion = dictionary[@"wv"]; + CLY_LOG_V(@"%s feedback widget parsed from the list response, widgetID: [%@], widgetType: [%@]", __FUNCTION__, feedback.ID, feedback.type); return feedback; } - (void)present { - CLY_LOG_I(@"%s", __FUNCTION__); + CLY_LOG_I(@"%s widget presentation requested without any block, widgetID: [%@], widgetType: [%@]", __FUNCTION__, self.ID, self.type); [self presentWithAppearBlock:nil andDismissBlock:nil]; } - (void)presentWithAppearBlock:(void(^ __nullable)(void))appearBlock andDismissBlock:(void(^ __nullable)(void))dismissBlock { - CLY_LOG_I(@"%s %@ %@", __FUNCTION__, appearBlock, dismissBlock); + CLY_LOG_I(@"%s widget presentation requested, widgetID: [%@], widgetType: [%@], appearBlockProvided: [%@], dismissBlockProvided: [%@]", __FUNCTION__, self.ID, self.type, (appearBlock != nil) ? @"YES" : @"NO", (dismissBlock != nil) ? @"YES" : @"NO"); id widgetCallback = ^(WidgetState widgetState) { if(appearBlock && widgetState == WIDGET_APPEARED) { appearBlock(); @@ -78,39 +79,49 @@ - (void)presentWithAppearBlock:(void(^ __nullable)(void))appearBlock andDismissB - (void)presentWidget_new:(WidgetCallback) widgetCallback; { - CLY_LOG_I(@"%s %@", __FUNCTION__, widgetCallback); + CLY_LOG_I(@"%s new style widget presentation requested, widgetID: [%@], widgetType: [%@], callbackProvided: [%@]", __FUNCTION__, self.ID, self.type, (widgetCallback != nil) ? @"YES" : @"NO"); if (!CountlyConsentManager.sharedInstance.consentForFeedback) + { + CLY_LOG_V(@"%s no feedback consent given, new style widget will not be presented", __FUNCTION__); return; + } CGSize size = [CountlyCommon.sharedInstance getWindowSize]; dispatch_async(dispatch_get_main_queue(), ^ { CGRect frame = CGRectMake(0.0, 0.0, size.width, size.height); - // Log the URL and the frame - CLY_LOG_I(@"%s, Placement frame: %@", __FUNCTION__, NSStringFromCGRect(frame)); + // Log the frame the widget web view will be placed in + CLY_LOG_D(@"%s widget web view placement computed, widgetID: [%@], width: [%.2f], height: [%.2f]", __FUNCTION__, self.ID, frame.size.width, frame.size.height); CountlyWebViewManager* webViewManager = CountlyWebViewManager.new; + __block BOOL hasAppeared = NO; [webViewManager createWebViewWithURL:[self generateWidgetURL] frame:frame appearBlock:^ { - CLY_LOG_I(@"%s, Webview appeared", __FUNCTION__); + CLY_LOG_I(@"%s new style widget shown, widgetID: [%@], widgetType: [%@]", __FUNCTION__, self.ID, self.type); + hasAppeared = YES; if(widgetCallback) widgetCallback(WIDGET_APPEARED); } dismissBlock:^ { - CLY_LOG_I(@"%s, Webview dismissed", __FUNCTION__); + CLY_LOG_I(@"%s new style widget dismissed, widgetID: [%@], widgetType: [%@], hadAppeared: [%@]", __FUNCTION__, self.ID, self.type, hasAppeared ? @"YES" : @"NO"); if (widgetCallback) widgetCallback(WIDGET_CLOSED); - [self recordReservedEventForDismissing]; + // a widget that failed to load was not closed by the user, and a close event can hide it from them for good + if (hasAppeared) + [self recordReservedEventForDismissing]; }]; }); } - (void)presentWithCallback:(WidgetCallback) widgetCallback; { - CLY_LOG_I(@"%s %@", __FUNCTION__, widgetCallback); + CLY_LOG_I(@"%s widget presentation with a callback requested, widgetID: [%@], widgetType: [%@], callbackProvided: [%@]", __FUNCTION__, self.ID, self.type, (widgetCallback != nil) ? @"YES" : @"NO"); if (!CountlyConsentManager.sharedInstance.consentForFeedback) + { + CLY_LOG_V(@"%s no feedback consent given, widget will not be presented", __FUNCTION__); return; + } if (self.widgetVersion && ![self.widgetVersion isKindOfClass:[NSNull class]]) { [self presentWidget_new:widgetCallback]; @@ -144,7 +155,7 @@ - (void)presentWithCallback:(WidgetCallback) widgetCallback; { [webVC dismissViewControllerAnimated:YES completion:^ { - CLY_LOG_D(@"Feedback widget dismissed. Widget ID: %@, Name: %@", self.ID, self.name); + CLY_LOG_I(@"%s legacy style widget dismissed by the close button, widgetID: [%@], widgetType: [%@]", __FUNCTION__, self.ID, self.type); if (widgetCallback) widgetCallback(WIDGET_CLOSED); webVC = nil; @@ -154,7 +165,7 @@ - (void)presentWithCallback:(WidgetCallback) widgetCallback; [webView addSubview:dismissButton]; [dismissButton positionToTopRight]; [CountlyCommon.sharedInstance tryPresentingViewController:webVC withCompletion:^{ - CLY_LOG_D(@"Feedback widget presented. Widget ID: %@, Name: %@", self.ID, self.name); + CLY_LOG_I(@"%s legacy style widget shown, widgetID: [%@], widgetType: [%@]", __FUNCTION__, self.ID, self.type); if(widgetCallback) widgetCallback(WIDGET_APPEARED); }]; @@ -172,11 +183,14 @@ - (void)webView:(WKWebView *)webView decidePolicyForNavigationResponse:(WKNaviga headers = http.allHeaderFields; } - CLY_LOG_I(@"%s Navigation response received: URL=%@, MIME=%@, status=%ld, headers=%@", - __FUNCTION__, response.URL.absoluteString, mimeType, statusCode, headers); + CLY_LOG_D(@"%s widget navigation response received, widgetID: [%@], path: [%@], mimeType: [%@], statusCode: [%ld], headerCount: [%lu]", + __FUNCTION__, self.ID, response.URL.path, mimeType, statusCode, (unsigned long)headers.count); + + // header names only: a response header value can be a session cookie + CLY_LOG_D(@"%s widget navigation response detail, widgetID: [%@], url: [%@], headerNames: [%@]", __FUNCTION__, self.ID, response.URL.absoluteString, headers.allKeys); if (statusCode >= 400) { - CLY_LOG_I(@"%s Cancelling navigation due to HTTP status code: %ld", __FUNCTION__, statusCode); + CLY_LOG_E(@"%s widget navigation cancelled and widget dismissed, widgetID: [%@], statusCode: [%ld]", __FUNCTION__, self.ID, statusCode); decisionHandler(WKNavigationResponsePolicyCancel); dispatch_async(dispatch_get_main_queue(), ^{ if (self.webVC) { @@ -197,16 +211,17 @@ - (void)webView:(WKWebView *)webView decidePolicyForNavigationResponse:(WKNaviga - (void)getWidgetData:(void (^)(NSDictionary * __nullable widgetData, NSError * __nullable error))completionHandler { - CLY_LOG_I(@"%s %@", __FUNCTION__, completionHandler); + CLY_LOG_I(@"%s widget data fetch requested, widgetID: [%@], widgetType: [%@], completionHandlerProvided: [%@]", __FUNCTION__, self.ID, self.type, (completionHandler != nil) ? @"YES" : @"NO"); if (!CountlyServerConfig.sharedInstance.networkingEnabled) { - CLY_LOG_D(@"'getWidgetData' is aborted: SDK Networking is disabled from server config!"); + CLY_LOG_D(@"%s widget data fetch is dropped, networking is disabled by server config", __FUNCTION__); return; } - NSURLSessionTask* task = [CountlyCommon.sharedInstance.URLSession dataTaskWithRequest:[self dataRequest] completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) + NSURLSessionTask* task = [CountlyCommon.sharedInstance immediateDataTaskWithRequest:[self dataRequest] completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { + // IMMEDIATE REQUEST to find them better in search NSDictionary *widgetData = nil; if (!error) @@ -224,6 +239,13 @@ - (void)getWidgetData:(void (^)(NSDictionary * __nullable widgetData, NSError * } } + if (error) + CLY_LOG_W(@"%s widget data fetch failed, widgetID: [%@], error: [%@]", __FUNCTION__, self.ID, error.localizedDescription); + else + CLY_LOG_D(@"%s widget data fetched, widgetID: [%@], fieldCount: [%lu]", __FUNCTION__, self.ID, (unsigned long)widgetData.count); + + CLY_LOG_D(@"%s widget data fetch response detail, widgetID: [%@], url: [%@], widgetData: [%@]", __FUNCTION__, self.ID, response.URL.absoluteString, widgetData); + self.data = widgetData; dispatch_async(dispatch_get_main_queue(), ^ @@ -238,8 +260,9 @@ - (void)getWidgetData:(void (^)(NSDictionary * __nullable widgetData, NSError * - (void)recordResult:(NSDictionary * __nullable)result { - CLY_LOG_I(@"%s %@", __FUNCTION__, result); - + CLY_LOG_I(@"%s widget result recording requested, widgetID: [%@], widgetType: [%@], answeredFieldCount: [%lu]", __FUNCTION__, self.ID, self.type, (unsigned long)result.count); + CLY_LOG_D(@"%s widget result recording detail, widgetID: [%@], widgetType: [%@], result: [%@]", __FUNCTION__, self.ID, self.type, result); + if (!result) [self recordReservedEventForDismissing]; else @@ -273,12 +296,14 @@ - (NSURLRequest *)dataRequest NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:URL]]; request.HTTPMethod = @"POST"; request.HTTPBody = [queryString cly_dataUTF8]; + CLY_LOG_D(@"%s widget data request built as POST, widgetID: [%@], url: [%@], body: [%@]", __FUNCTION__, self.ID, URL, queryString); return request.copy; } else { [URL appendFormat:@"?%@", queryString]; NSURLRequest* request = [NSURLRequest requestWithURL:[NSURL URLWithString:URL]]; + CLY_LOG_D(@"%s widget data request built as GET, widgetID: [%@], url: [%@]", __FUNCTION__, self.ID, URL); return request; } } @@ -323,20 +348,30 @@ - (NSURL *)generateWidgetURL { customParams[@"rw"] = @"1"; customParams[@"xb"] = @"1"; } - + + // the widget page records the answer itself, so this is the only way the global segmentation reaches it + NSDictionary *globalSegmentation = [CountlyContentBuilderInternal.sharedInstance globalContentSegmentationForFeedbackWidget]; + if (globalSegmentation.count && [NSJSONSerialization isValidJSONObject:globalSegmentation]) { + customParams[@"sg"] = globalSegmentation; + } + // Create JSON data from custom parameters NSError *error; NSData *jsonData = [NSJSONSerialization dataWithJSONObject:customParams options:0 error:&error]; if (!jsonData) { - NSLog(@"Failed to serialize JSON: %@", error); + CLY_LOG_E(@"%s custom widget URL parameters could not be serialized, widgetID: [%@], error: [%@]", __FUNCTION__, self.ID, error.localizedDescription); } else { NSString *customString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; // Append the custom parameter to the URL [URL appendFormat:@"&custom=%@", customString.cly_URLEscaped]; } - - return [NSURL URLWithString:URL]; + + NSString *finalURL = [CountlyDeviceInfo URLStringByAppendingThemeMode:URL]; + + CLY_LOG_D(@"%s widget URL generated, widgetID: [%@], widgetType: [%@], url: [%@]", __FUNCTION__, self.ID, self.type, finalURL); + + return [NSURL URLWithString:finalURL]; } @@ -348,7 +383,10 @@ - (void)recordReservedEventForDismissing - (void)recordReservedEventWithSegmentation:(NSDictionary *)segm { if (!CountlyConsentManager.sharedInstance.consentForFeedback) + { + CLY_LOG_V(@"%s no feedback consent given, widget result event is not recorded", __FUNCTION__); return; + } NSString* eventName = nil; if ([self.type isEqualToString:CLYFeedbackWidgetTypeSurvey]) @@ -360,7 +398,7 @@ - (void)recordReservedEventWithSegmentation:(NSDictionary *)segm if (!eventName) { - CLY_LOG_W(@"Unsupported feedback widget type! Event will not be recorded!"); + CLY_LOG_W(@"%s widget result event is dropped, unsupported widget type, widgetID: [%@], widgetType: [%@]", __FUNCTION__, self.ID, self.type); return; } @@ -368,7 +406,12 @@ - (void)recordReservedEventWithSegmentation:(NSDictionary *)segm segmentation[kCountlyFBKeyPlatform] = CountlyDeviceInfo.osName; segmentation[kCountlyFBKeyAppVersion] = CountlyDeviceInfo.appVersion; segmentation[kCountlyFBKeyWidgetID] = self.ID; - [Countly.sharedInstance recordReservedEvent:eventName segmentation:segmentation]; + + CLY_LOG_I(@"%s widget result submitted, widgetID: [%@], widgetType: [%@], submittedFieldCount: [%lu]", __FUNCTION__, self.ID, self.type, (unsigned long)segm.count); + + CLY_LOG_D(@"%s widget result submission detail, widgetID: [%@], eventName: [%@], answers: [%@], segmentation: [%@]", __FUNCTION__, self.ID, eventName, segm, segmentation); + + [Countly.sharedInstance recordReservedEvent:eventName segmentation:[CountlyContentBuilderInternal.sharedInstance mergeGlobalContentSegmentationIntoFeedbackWidgetEvent:segmentation]]; [CountlyConnectionManager.sharedInstance sendEvents]; } diff --git a/ios/src/CountlyFeedbacks.h b/ios/src/CountlyFeedbacks.h index 446d1e55..90facfe6 100644 --- a/ios/src/CountlyFeedbacks.h +++ b/ios/src/CountlyFeedbacks.h @@ -10,7 +10,7 @@ #import "CountlyFeedbackWidget.h" @interface CountlyFeedbacks: NSObject -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) + (instancetype)sharedInstance; - (void) presentNPS; diff --git a/ios/src/CountlyFeedbacks.m b/ios/src/CountlyFeedbacks.m index 5b566c0d..f7506b0e 100644 --- a/ios/src/CountlyFeedbacks.m +++ b/ios/src/CountlyFeedbacks.m @@ -9,7 +9,7 @@ #import "CountlyCommon.h" @implementation CountlyFeedbacks -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) + (instancetype)sharedInstance { if (!CountlyCommon.sharedInstance.hasStarted) @@ -30,6 +30,7 @@ - (instancetype)init - (void)enterContentZone:(NSArray *)tags { + CLY_LOG_I(@"%s content zone entry requested, tagCount: [%lu]", __FUNCTION__, (unsigned long)tags.count); [CountlyContentBuilderInternal.sharedInstance enterContentZone:tags]; } @@ -42,6 +43,7 @@ - (void)presentNPS:(NSString *)nameIDorTag { } - (void) presentNPS:(NSString *)nameIDorTag widgetCallback:(WidgetCallback) widgetCallback { + CLY_LOG_I(@"%s NPS widget requested, nameIDorTag: [%@], callbackProvided: [%@]", __FUNCTION__, nameIDorTag, (widgetCallback != nil) ? @"YES" : @"NO"); [CountlyFeedbacksInternal.sharedInstance presentNPS:nameIDorTag widgetCallback:widgetCallback]; } @@ -54,6 +56,7 @@ - (void)presentSurvey:(NSString *)nameIDorTag { } - (void) presentSurvey:(NSString *)nameIDorTag widgetCallback:(WidgetCallback) widgetCallback { + CLY_LOG_I(@"%s survey widget requested, nameIDorTag: [%@], callbackProvided: [%@]", __FUNCTION__, nameIDorTag, (widgetCallback != nil) ? @"YES" : @"NO"); [CountlyFeedbacksInternal.sharedInstance presentSurvey:nameIDorTag widgetCallback:widgetCallback]; } @@ -66,12 +69,13 @@ - (void)presentRating:(NSString *)nameIDorTag { } - (void) presentRating:(NSString *)nameIDorTag widgetCallback:(WidgetCallback) widgetCallback { + CLY_LOG_I(@"%s rating widget requested, nameIDorTag: [%@], callbackProvided: [%@]", __FUNCTION__, nameIDorTag, (widgetCallback != nil) ? @"YES" : @"NO"); [CountlyFeedbacksInternal.sharedInstance presentRating:nameIDorTag widgetCallback:widgetCallback]; } - (void)getAvailableFeedbackWidgets:(void (^)(NSArray *feedbackWidgets, NSError * error))completionHandler { - CLY_LOG_I(@"%s %@", __FUNCTION__, completionHandler); + CLY_LOG_I(@"%s available feedback widgets requested, completionHandlerProvided: [%@]", __FUNCTION__, (completionHandler != nil) ? @"YES" : @"NO"); [CountlyFeedbacksInternal.sharedInstance getFeedbackWidgets:completionHandler]; } #endif diff --git a/ios/src/CountlyFeedbacksInternal.h b/ios/src/CountlyFeedbacksInternal.h index 85518ebe..4242814b 100644 --- a/ios/src/CountlyFeedbacksInternal.h +++ b/ios/src/CountlyFeedbacksInternal.h @@ -12,11 +12,17 @@ extern NSString* const kCountlyFBKeyPlatform; extern NSString* const kCountlyFBKeyAppVersion; extern NSString* const kCountlyFBKeyWidgetID; extern NSString* const kCountlyFBKeyID; +extern NSString* const kCountlyFBKeyClosed; +extern NSString* const kCountlyFBKeyShown; +extern NSString* const kCountlyFBKeyRating; +extern NSString* const kCountlyFBKeyEmail; +extern NSString* const kCountlyFBKeyComment; +extern NSString* const kCountlyFBKeyContactMe; extern NSString* const kCountlyReservedEventStarRating; @interface CountlyFeedbacksInternal : NSObject -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) + (instancetype)sharedInstance; - (void)showDialog:(void(^)(NSInteger rating))completion; diff --git a/ios/src/CountlyFeedbacksInternal.m b/ios/src/CountlyFeedbacksInternal.m index b2a9aa80..94f10bba 100644 --- a/ios/src/CountlyFeedbacksInternal.m +++ b/ios/src/CountlyFeedbacksInternal.m @@ -5,7 +5,7 @@ // Please visit www.count.ly for more information. #import "CountlyCommon.h" -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) #import #endif @@ -16,7 +16,7 @@ + (CountlyFeedbackWidget *)createWithDictionary:(NSDictionary *)dictionary; @interface CountlyFeedbacksInternal () -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) @property (nonatomic) UIAlertController* alertController; @property (nonatomic, copy) void (^ratingCompletion)(NSInteger); #endif @@ -42,7 +42,7 @@ @interface CountlyFeedbacksInternal () const CGFloat kCountlyStarRatingButtonSize = 40.0; @implementation CountlyFeedbacksInternal -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) { UIButton* btn_star[5]; } @@ -89,8 +89,13 @@ - (instancetype)init - (void)showDialog:(void(^)(NSInteger rating))completion { + CLY_LOG_I(@"%s star rating dialog requested, completionProvided: [%@]", __FUNCTION__, (completion != nil) ? @"YES" : @"NO"); + if (!CountlyConsentManager.sharedInstance.consentForFeedback) + { + CLY_LOG_V(@"%s no feedback consent given, star rating dialog will not be shown", __FUNCTION__); return; + } self.ratingCompletion = completion; @@ -117,7 +122,7 @@ - (void)showDialog:(void(^)(NSInteger rating))completion } @catch (NSException* exception) { - CLY_LOG_W(@"%s, UIAlertController's contentViewController can not be set, got exception %@", __FUNCTION__, exception); + CLY_LOG_E(@"%s contentViewController of the star rating alert can not be set, exception: [%@]", __FUNCTION__, exception.reason); } [CountlyCommon.sharedInstance tryPresentingViewController:self.alertController]; @@ -129,7 +134,10 @@ - (void)checkForStarRatingAutoAsk return; if (!CountlyConsentManager.sharedInstance.consentForFeedback) + { + CLY_LOG_V(@"%s no feedback consent given, star rating auto ask check is skipped", __FUNCTION__); return; + } NSMutableDictionary* status = [CountlyPersistency.sharedInstance retrieveStarRatingStatus].mutableCopy; @@ -142,7 +150,7 @@ - (void)checkForStarRatingAutoAsk if (self.sessionCount == sessionCountSoFar) { - CLY_LOG_D(@"Asking for star-rating as session count reached specified limit %d ...", (int)self.sessionCount); + CLY_LOG_D(@"%s asking for star rating as session count reached the limit, sessionCount: [%ld]", __FUNCTION__, (long)self.sessionCount); [self showDialog:self.ratingCompletionForAutoAsk]; @@ -205,6 +213,10 @@ - (void)onClick_star:(id)sender - (void)finishWithRating:(NSInteger)rating { + CLY_LOG_I(@"%s star rating dialog finished, widgetType: [%@], answerProvided: [%@]", __FUNCTION__, @"star-rating", (rating != 0) ? @"YES" : @"NO"); + + CLY_LOG_D(@"%s star rating dialog answer detail, widgetType: [%@], rating: [%ld]", __FUNCTION__, @"star-rating", (long)rating); + if (self.ratingCompletion) self.ratingCompletion(rating); @@ -236,42 +248,60 @@ - (UIColor *)passiveStarColor - (void)presentRatingWidgetWithID:(NSString *)widgetID closeButtonText:(NSString *)closeButtonText completionHandler:(void (^)(NSError * error))completionHandler { + CLY_LOG_I(@"%s rating widget presentation requested, widgetID: [%@], closeButtonTextProvided: [%@], completionHandlerProvided: [%@]", __FUNCTION__, widgetID, (closeButtonText != nil) ? @"YES" : @"NO", (completionHandler != nil) ? @"YES" : @"NO"); + if (!CountlyServerConfig.sharedInstance.networkingEnabled) { - CLY_LOG_D(@"'presentRatingWidgetWithID' is aborted: SDK Networking is disabled from server config!"); + CLY_LOG_D(@"%s rating widget presentation is dropped, networking is disabled by server config", __FUNCTION__); return; } if (!CountlyConsentManager.sharedInstance.consentForFeedback) + { + CLY_LOG_V(@"%s no feedback consent given, rating widget presentation is skipped", __FUNCTION__); return; + } if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) + { + CLY_LOG_W(@"%s rating widget presentation is dropped, device ID is temporary", __FUNCTION__); return; + } if (!widgetID.length) + { + CLY_LOG_E(@"%s rating widget presentation is dropped, widget ID is empty or nil", __FUNCTION__); return; + } NSURLRequest* feedbackWidgetCheckRequest = [self widgetCheckURLRequest:widgetID]; - NSURLSessionTask* task = [CountlyCommon.sharedInstance.URLSession dataTaskWithRequest:feedbackWidgetCheckRequest completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) + NSURLSessionTask* task = [CountlyCommon.sharedInstance immediateDataTaskWithRequest:feedbackWidgetCheckRequest completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { + // IMMEDIATE REQUEST to find them better in search NSDictionary* widgetInfo = nil; if (!error) { widgetInfo = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; + if (![widgetInfo isKindOfClass:NSDictionary.class]) + widgetInfo = nil; } + CLY_LOG_D(@"%s rating widget check response detail, widgetID: [%@], url: [%@], widgetInfo: [%@]", __FUNCTION__, widgetID, response.URL.absoluteString, widgetInfo); + if (!error) { NSMutableDictionary* userInfo = widgetInfo.mutableCopy; if (![widgetInfo[kCountlyFBKeyID] isEqualToString:widgetID]) { + CLY_LOG_W(@"%s no rating widget found on the server for the given widgetID: [%@]", __FUNCTION__, widgetID); userInfo[NSLocalizedDescriptionKey] = [NSString stringWithFormat:@"Feedback widget with ID %@ is not available.", widgetID]; error = [NSError errorWithDomain:kCountlyErrorDomain code:CLYErrorFeedbackWidgetNotAvailable userInfo:userInfo]; } else if (![self isDeviceTargetedByWidget:widgetInfo]) { + CLY_LOG_D(@"%s this device is not in the target devices list of the widget, widgetID: [%@]", __FUNCTION__, widgetID); userInfo[NSLocalizedDescriptionKey] = [NSString stringWithFormat:@"Feedback widget with ID %@ does not include this device in target devices list.", widgetID]; error = [NSError errorWithDomain:kCountlyErrorDomain code:CLYErrorFeedbackWidgetNotTargetedForDevice userInfo:userInfo]; } @@ -279,6 +309,7 @@ - (void)presentRatingWidgetWithID:(NSString *)widgetID closeButtonText:(NSString if (error) { + CLY_LOG_W(@"%s rating widget check request failed, widgetID: [%@], error: [%@]", __FUNCTION__, widgetID, error.localizedDescription); dispatch_async(dispatch_get_main_queue(), ^ { if (completionHandler) @@ -300,7 +331,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]; @@ -314,6 +346,8 @@ - (void)presentRatingWidgetInternal:(NSString *)widgetID closeButtonText:(NSStri { [webVC dismissViewControllerAnimated:YES completion:^ { + CLY_LOG_I(@"%s rating widget web view is dismissed, widgetID: [%@], widgetType: [%@]", __FUNCTION__, widgetID, CLYFeedbackWidgetTypeRating); + if (completionHandler) completionHandler(nil); @@ -323,6 +357,8 @@ - (void)presentRatingWidgetInternal:(NSString *)widgetID closeButtonText:(NSStri [webVC.view addSubview:dismissButton]; [dismissButton positionToTopRightConsideringStatusBar]; + CLY_LOG_I(@"%s rating widget web view is being presented, widgetID: [%@], widgetType: [%@]", __FUNCTION__, widgetID, CLYFeedbackWidgetTypeRating); + [CountlyCommon.sharedInstance tryPresentingViewController:webVC]; } @@ -375,6 +411,8 @@ - (NSURL *)widgetDisplayURL:(NSString *)widgetID kCountlyEndpointFeedback, queryString]; + URLString = [CountlyDeviceInfo URLStringByAppendingThemeMode:URLString]; + return [NSURL URLWithString:URLString]; } @@ -390,11 +428,19 @@ - (BOOL)isDeviceTargetedByWidget:(NSDictionary *)widgetInfo - (void)recordRatingWidgetWithID:(NSString *)widgetID rating:(NSInteger)rating email:(NSString *)email comment:(NSString *)comment userCanBeContacted:(BOOL)userCanBeContacted { + CLY_LOG_I(@"%s rating widget result recording requested, widgetID: [%@], emailProvided: [%@], commentProvided: [%@], userCanBeContacted: [%@]", __FUNCTION__, widgetID, (email.length > 0) ? @"YES" : @"NO", (comment.length > 0) ? @"YES" : @"NO", userCanBeContacted ? @"YES" : @"NO"); + if (!CountlyConsentManager.sharedInstance.consentForFeedback) + { + CLY_LOG_V(@"%s no feedback consent given, rating widget result is not recorded", __FUNCTION__); return; + } if (!widgetID.length) + { + CLY_LOG_E(@"%s rating widget result is dropped, widget ID is empty or nil", __FUNCTION__); return; + } NSMutableDictionary* segmentation = NSMutableDictionary.new; segmentation[kCountlyFBKeyPlatform] = CountlyDeviceInfo.osName; @@ -405,6 +451,10 @@ - (void)recordRatingWidgetWithID:(NSString *)widgetID rating:(NSInteger)rating e segmentation[kCountlyFBKeyComment] = comment; segmentation[kCountlyFBKeyContactMe] = @(userCanBeContacted); + CLY_LOG_I(@"%s rating widget result submitted, widgetID: [%@], widgetType: [%@], answeredFieldCount: [%lu]", __FUNCTION__, widgetID, CLYFeedbackWidgetTypeRating, (unsigned long)segmentation.count); + + CLY_LOG_D(@"%s rating widget result submission detail, widgetID: [%@], rating: [%ld], email: [%@], comment: [%@], userCanBeContacted: [%@], segmentation: [%@]", __FUNCTION__, widgetID, (long)rating, email, comment, userCanBeContacted ? @"YES" : @"NO", segmentation); + [Countly.sharedInstance recordReservedEvent:kCountlyReservedEventStarRating segmentation:segmentation]; } @@ -413,9 +463,11 @@ - (void)recordRatingWidgetWithID:(NSString *)widgetID rating:(NSInteger)rating e - (void)getFeedbackWidgets:(void (^)(NSArray *feedbackWidgets, NSError *error))completionHandler { + CLY_LOG_I(@"%s feedback widget list fetch requested, completionHandlerProvided: [%@]", __FUNCTION__, (completionHandler != nil) ? @"YES" : @"NO"); + if (!CountlyServerConfig.sharedInstance.networkingEnabled) { - CLY_LOG_D(@"'getFeedbackWidgets' is aborted: SDK Networking is disabled from server config!"); + CLY_LOG_D(@"%s feedback widget list fetch is dropped, networking is disabled by server config", __FUNCTION__); if(completionHandler){ completionHandler(nil, nil); } @@ -423,6 +475,7 @@ - (void)getFeedbackWidgets:(void (^)(NSArray *feedback } if (!CountlyConsentManager.sharedInstance.consentForFeedback) { + CLY_LOG_V(@"%s no feedback consent given, feedback widget list is not fetched", __FUNCTION__); if(completionHandler){ completionHandler(nil, nil); } @@ -431,16 +484,20 @@ - (void)getFeedbackWidgets:(void (^)(NSArray *feedback if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) { + CLY_LOG_W(@"%s feedback widget list fetch is dropped, device ID is temporary", __FUNCTION__); if(completionHandler){ completionHandler(nil, nil); } return; } - NSURLSessionTask* task = [CountlyCommon.sharedInstance.URLSession dataTaskWithRequest:[self feedbacksRequest] completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) + NSURLSessionTask* task = [CountlyCommon.sharedInstance immediateDataTaskWithRequest:[self feedbacksRequest] completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { + // IMMEDIATE REQUEST to find them better in search NSDictionary *feedbacksResponse = nil; + CLY_LOG_D(@"%s feedback widget list raw response received, url: [%@], responseBody: [%@]", __FUNCTION__, response.URL.absoluteString, [data cly_stringUTF8]); + if (!error) { feedbacksResponse = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; @@ -458,7 +515,7 @@ - (void)getFeedbackWidgets:(void (^)(NSArray *feedback if (error || !feedbacksResponse || ![feedbacksResponse isKindOfClass:[NSDictionary class]]) { - CLY_LOG_D(@"%s, aborting call, feedbacksResponse: [%@], error: [%@]",__FUNCTION__, feedbacksResponse, error); + CLY_LOG_W(@"%s feedback widget list fetch failed, responseIsDictionary: [%@], error: [%@]", __FUNCTION__, [feedbacksResponse isKindOfClass:[NSDictionary class]] ? @"YES" : @"NO", error.localizedDescription); dispatch_async(dispatch_get_main_queue(), ^ { if (completionHandler) @@ -472,7 +529,7 @@ - (void)getFeedbackWidgets:(void (^)(NSArray *feedback NSMutableArray* feedbacks = NSMutableArray.new; NSArray* rawFeedbackObjects = feedbacksResponse[@"result"]; if(!rawFeedbackObjects){ - CLY_LOG_D(@"%s, aborting call, response is not valid, error: [%@]", __FUNCTION__, error); + CLY_LOG_E(@"%s feedback widget list response has no result field, ignoring the response", __FUNCTION__); dispatch_async(dispatch_get_main_queue(), ^ { if (completionHandler) @@ -488,6 +545,10 @@ - (void)getFeedbackWidgets:(void (^)(NSArray *feedback [feedbacks addObject:feedback]; } + CLY_LOG_I(@"%s feedback widget list fetched, widgetCount: [%lu]", __FUNCTION__, (unsigned long)feedbacks.count); + + CLY_LOG_D(@"%s feedback widget list parsed response detail, widgetCount: [%lu], parsedResponse: [%@]", __FUNCTION__, (unsigned long)feedbacks.count, feedbacksResponse); + dispatch_async(dispatch_get_main_queue(), ^ { if (completionHandler) @@ -532,34 +593,31 @@ - (NSURLRequest *)feedbacksRequest } - (void) presentNPS:(NSString *)nameIDorTag widgetCallback:(WidgetCallback) widgetCallback { - CLY_LOG_D(@"Presenting NPS widget with nameIDorTag: %@ and WidgetCallback: %@", nameIDorTag, widgetCallback); [self presentFeedbackWidget:CLYFeedbackWidgetTypeNPS nameIDorTag:nameIDorTag widgetCallback:widgetCallback]; } - (void) presentSurvey:(NSString *)nameIDorTag widgetCallback:(WidgetCallback) widgetCallback { - CLY_LOG_D(@"Presenting Survey widget with nameIDorTag: %@ and WidgetCallback: %@", nameIDorTag, widgetCallback); [self presentFeedbackWidget:CLYFeedbackWidgetTypeSurvey nameIDorTag:nameIDorTag widgetCallback:widgetCallback]; } - (void) presentRating:(NSString *)nameIDorTag widgetCallback:(WidgetCallback) widgetCallback { - CLY_LOG_D(@"Presenting Rating widget with nameIDorTag: %@ and WidgetCallback: %@", nameIDorTag, widgetCallback); [self presentFeedbackWidget:CLYFeedbackWidgetTypeRating nameIDorTag:nameIDorTag widgetCallback:widgetCallback]; } -(void)presentFeedbackWidget:(CLYFeedbackWidgetType)widgetType nameIDorTag:(NSString *)nameIDorTag widgetCallback:(WidgetCallback) widgetCallback { - [Countly.sharedInstance getFeedbackWidgets:^(NSArray *feedbackWidgets, NSError *error) { + [self getFeedbackWidgets:^(NSArray *feedbackWidgets, NSError *error) { if (error) { - CLY_LOG_D(@"Getting widgets list failed. Error: %@", error); + CLY_LOG_D(@"%s widget list could not be retrieved while resolving a widget to present, widgetType: [%@], error: [%@]", __FUNCTION__, widgetType, error.localizedDescription); return; } - CLY_LOG_D(@"Successfully retrieved feedback widgets. Total widgets count: %lu", (unsigned long)feedbackWidgets.count); + CLY_LOG_D(@"%s widget list available for resolving, totalWidgetCount: [%lu]", __FUNCTION__, (unsigned long)feedbackWidgets.count); NSPredicate *typePredicate = [NSPredicate predicateWithFormat:@"type == %@", widgetType]; NSArray *filteredWidgets = [feedbackWidgets filteredArrayUsingPredicate:typePredicate]; - CLY_LOG_D(@"Filtered widgets count for type '%@': %lu", widgetType, (unsigned long)filteredWidgets.count); + CLY_LOG_D(@"%s widget list filtered by type, widgetType: [%@], matchingWidgetCount: [%lu]", __FUNCTION__, widgetType, (unsigned long)filteredWidgets.count); CountlyFeedbackWidget *widgetToPresent = nil; @@ -569,7 +627,7 @@ -(void)presentFeedbackWidget:(CLYFeedbackWidgetType)widgetType nameIDorTag:(NSSt [nameIDorTag isEqualToString:feedbackWidget.ID] || [feedbackWidget.tags containsObject:nameIDorTag]) { widgetToPresent = feedbackWidget; - CLY_LOG_D(@"Exact match found for nameIDorTag '%@'. Widget ID: %@, Name: %@", nameIDorTag, feedbackWidget.ID, feedbackWidget.name); + CLY_LOG_D(@"%s exact widget match found, nameIDorTag: [%@], widgetID: [%@], widgetType: [%@]", __FUNCTION__, nameIDorTag, feedbackWidget.ID, widgetType); break; } } @@ -577,13 +635,13 @@ -(void)presentFeedbackWidget:(CLYFeedbackWidgetType)widgetType nameIDorTag:(NSSt if (!widgetToPresent && filteredWidgets.count > 0) { widgetToPresent = filteredWidgets.firstObject; - CLY_LOG_D(@"No exact match found for nameIDorTag '%@'. Falling back to the first widget of type '%@'. Widget ID: %@, Name: %@", nameIDorTag, widgetType, widgetToPresent.ID, widgetToPresent.name); + CLY_LOG_D(@"%s no exact widget match, falling back to the first widget of the type, nameIDorTag: [%@], widgetType: [%@], widgetID: [%@]", __FUNCTION__, nameIDorTag, widgetType, widgetToPresent.ID); } if (widgetToPresent) { [widgetToPresent presentWithCallback:widgetCallback]; } else { - CLY_LOG_D(@"No feedback widget found for the specified type: %@", widgetType); + CLY_LOG_W(@"%s no feedback widget found to present, widgetType: [%@], nameIDorTag: [%@]", __FUNCTION__, widgetType, nameIDorTag); } }]; } diff --git a/ios/src/CountlyHealthTracker.m b/ios/src/CountlyHealthTracker.m index 139860e8..2cdebd9e 100644 --- a/ios/src/CountlyHealthTracker.m +++ b/ios/src/CountlyHealthTracker.m @@ -21,6 +21,8 @@ @interface CountlyHealthTracker () @property (nonatomic, copy) NSString *errorMessage; @property (nonatomic, assign) BOOL healthCheckEnabled; @property (nonatomic, assign) BOOL healthCheckSent; +// NO while the counters of the previous run are still to be read, the storage can not be read before the SDK starts +@property (nonatomic, assign) BOOL persistedStateLoaded; @property (nonatomic, strong) dispatch_queue_t hcQueue; @end @@ -61,25 +63,48 @@ - (instancetype)init{ // queue for health tracker state _hcQueue = dispatch_queue_create("ly.count.healthtracker.queue", DISPATCH_QUEUE_SERIAL); - NSDictionary *initialState = [CountlyPersistency.sharedInstance retrieveHealthCheckTrackerState]; - [self setupInitialCounters:initialState]; + _persistedStateLoaded = NO; + [self loadPersistedStateIfNeeded]; } return self; } +/// Adds the counters the previous run stored to the current ones, once the storage can be read. Call from init or on the health tracker queue. +- (void)loadPersistedStateIfNeeded { + if (self.persistedStateLoaded) { + return; + } + + CountlyPersistency *persistency = CountlyPersistency.sharedInstance; + if (!persistency) { + return; + } + + self.persistedStateLoaded = YES; + [self setupInitialCounters:[persistency retrieveHealthCheckTrackerState]]; +} + +/// Adds the stored counters to the ones counted so far, which may include warnings logged before the SDK started. - (void)setupInitialCounters:(NSDictionary *)initialState { if (initialState == nil || [initialState count] == 0) { return; } - self.countLogWarning = [initialState[keyLogWarning] longValue]; - self.countLogError = [initialState[keyLogError] longValue]; - self.statusCode = [initialState[keyStatusCode] integerValue]; - self.errorMessage = initialState[keyErrorMessage] ?: @""; - self.countBackoffRequest = [initialState[keyBackoffRequest] longValue]; - self.consecutiveBackoffRequest = [initialState[keyConsecutiveBackoffRequest] longValue]; + self.countLogWarning += [initialState[keyLogWarning] longValue]; + self.countLogError += [initialState[keyLogError] longValue]; + if (self.statusCode == -1 && initialState[keyStatusCode]) { + self.statusCode = [initialState[keyStatusCode] integerValue]; + } + if (self.errorMessage.length == 0) { + self.errorMessage = initialState[keyErrorMessage] ?: @""; + } + self.countBackoffRequest += [initialState[keyBackoffRequest] longValue]; + self.consecutiveBackoffRequest = MAX(self.consecutiveBackoffRequest, [initialState[keyConsecutiveBackoffRequest] longValue]); - CLY_LOG_D(@"%s loaded initial health check state: [%@]", __FUNCTION__, initialState); + // NOTE: this runs from -init inside the sharedInstance dispatch_once, and CountlyInternalLog + // calls back into this class for Error and Warning levels. Keep every log on this path at + // Debug or lower, otherwise the shared instance would be re-entered while being created. + CLY_LOG_D(@"%s restored persisted counters, errorCount: [%ld], warningCount: [%ld], statusCode: [%ld], errorMessageLength: [%lu], backoffRequestCount: [%ld], consecutiveBackoffRequestCount: [%ld]", __FUNCTION__, self.countLogError, self.countLogWarning, (long)self.statusCode, (unsigned long)self.errorMessage.length, self.countBackoffRequest, self.consecutiveBackoffRequest); } - (void)logWarning { @@ -109,37 +134,37 @@ - (void)logFailedNetworkRequestWithStatusCode:(NSInteger)statusCode } else { self.errorMessage = [errorResponse copy]; } - CLY_LOG_D(@"%s statusCode: [%d], errorResponse: [%@]", __FUNCTION__, (int)statusCode, errorResponse); + CLY_LOG_D(@"%s recorded failed network request, statusCode: [%ld], errorResponseLength: [%lu], storedErrorMessageLength: [%lu]", __FUNCTION__, (long)statusCode, (unsigned long)errorResponse.length, (unsigned long)self.errorMessage.length); }); } - (void)logBackoffRequest { - CLY_LOG_D(@"%s", __FUNCTION__); dispatch_async(self.hcQueue, ^{ self.countBackoffRequest++; self.countConsecutiveBackoffRequest++; + CLY_LOG_D(@"%s recorded a backed off request, backoffRequestCount: [%ld], currentConsecutiveBackoffRequestCount: [%ld]", __FUNCTION__, self.countBackoffRequest, self.countConsecutiveBackoffRequest); }); } - (void)logConsecutiveBackoffRequest { - CLY_LOG_D(@"%s", __FUNCTION__); dispatch_async(self.hcQueue, ^{ self.consecutiveBackoffRequest = MAX(self.consecutiveBackoffRequest, self.countConsecutiveBackoffRequest); self.countConsecutiveBackoffRequest = 0; + CLY_LOG_D(@"%s consecutive backoff streak closed, peakConsecutiveBackoffRequestCount: [%ld]", __FUNCTION__, self.consecutiveBackoffRequest); }); } - (void)clearAndSave { - CLY_LOG_D(@"%s", __FUNCTION__); dispatch_async(self.hcQueue, ^{ [self clearValues]; [CountlyPersistency.sharedInstance storeHealthCheckTrackerState:@{}]; + CLY_LOG_D(@"%s counters cleared and empty state persisted", __FUNCTION__); }); } - (void)saveState { - CLY_LOG_D(@"%s", __FUNCTION__); dispatch_async(self.hcQueue, ^{ + [self loadPersistedStateIfNeeded]; [self logConsecutiveBackoffRequest]; NSDictionary *healthCheckState = @{ @@ -152,21 +177,26 @@ - (void)saveState { }; [CountlyPersistency.sharedInstance storeHealthCheckTrackerState:healthCheckState]; + CLY_LOG_D(@"%s counters persisted, errorCount: [%ld], warningCount: [%ld], statusCode: [%ld], errorMessageLength: [%lu], backoffRequestCount: [%ld], peakConsecutiveBackoffRequestCount: [%ld]", __FUNCTION__, self.countLogError, self.countLogWarning, (long)self.statusCode, (unsigned long)self.errorMessage.length, self.countBackoffRequest, self.consecutiveBackoffRequest); }); } - (void)resetInstance { - CLY_LOG_D(@"%s resetting health check state", __FUNCTION__); - dispatch_async(self.hcQueue, ^{ + // synchronous: halt goes on to free the shared state this block uses, and nothing on hcQueue waits for the caller + dispatch_sync(self.hcQueue, ^{ [self clearValues]; self->_healthCheckSent = NO; self->_healthCheckEnabled = YES; + self->_persistedStateLoaded = YES; [CountlyPersistency.sharedInstance storeHealthCheckTrackerState:@{}]; + CLY_LOG_D(@"%s health tracker reset, healthCheckSent and healthCheckEnabled restored to their initial values", __FUNCTION__); }); } - (void)clearValues { - CLY_LOG_W(@"%s clearing counters", __FUNCTION__); + // NOTE: Debug on purpose. A Warning or Error here would be counted by CountlyInternalLog + // through logWarning/logError and would immediately repopulate the counters being cleared. + CLY_LOG_D(@"%s clearing counters, discardedErrorCount: [%ld], discardedWarningCount: [%ld], discardedBackoffRequestCount: [%ld]", __FUNCTION__, self.countLogError, self.countLogWarning, self.countBackoffRequest); self.countLogWarning = 0; self.countLogError = 0; @@ -179,42 +209,44 @@ - (void)clearValues { - (void)sendHealthCheck { if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) { - CLY_LOG_W(@"%s currently in temporary id mode, omitting", __FUNCTION__); + CLY_LOG_D(@"%s health check send skipped, sdk is in temporary device ID mode", __FUNCTION__); return; } - + if (!CountlyServerConfig.sharedInstance.networkingEnabled) { - CLY_LOG_D(@"%s 'sendHealthCheck' is aborted: SDK Networking is disabled from server config!", __FUNCTION__); + CLY_LOG_D(@"%s health check send skipped, networking is disabled by behavior settings", __FUNCTION__); return; } - + if (!_healthCheckEnabled || _healthCheckSent) { - CLY_LOG_D(@"%s health check status, healthCheckSent: [%d], healthCheckEnabled: [%d]", __FUNCTION__, _healthCheckSent, _healthCheckEnabled); + CLY_LOG_D(@"%s health check send skipped, healthCheckSent: [%@], healthCheckEnabled: [%@]", __FUNCTION__, _healthCheckSent ? @"YES" : @"NO", _healthCheckEnabled ? @"YES" : @"NO"); return; } - - NSURLSessionTask* task = [CountlyCommon.sharedInstance.URLSession dataTaskWithRequest:[self healthCheckRequest] completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) + + NSURLSessionTask* task = [CountlyCommon.sharedInstance immediateDataTaskWithRequest:[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); + CLY_LOG_D(@"%s health check request failed at the transport level, it will be retried later, error: [%@]", __FUNCTION__, error.localizedDescription); return; } - + NSError *jsonError; NSDictionary *jsonResponse = [NSJSONSerialization JSONObjectWithData:data options:0 error:&jsonError]; - - if (jsonError || !jsonResponse) { - CLY_LOG_I(@"%s error while sending health checks, Failed to parse JSON response error: [%@]", __FUNCTION__, jsonError); + + if (jsonError || ![jsonResponse isKindOfClass:NSDictionary.class]) { + CLY_LOG_E(@"%s could not parse the health check response, counters will not be cleared, error: [%@]", __FUNCTION__, jsonError.localizedDescription); return; } - + if (!jsonResponse[@"result"]) { - CLY_LOG_D(@"%s Retrieved request response does not match expected pattern response: [%@]", __FUNCTION__, jsonResponse); + CLY_LOG_D(@"%s health check response does not carry the expected result key, counters will not be cleared, responseKeyCount: [%lu]", __FUNCTION__, (unsigned long)jsonResponse.count); return; } - + + CLY_LOG_D(@"%s health check accepted by the server, counters will be cleared", __FUNCTION__); [self clearAndSave]; self->_healthCheckSent = YES; }]; @@ -231,7 +263,7 @@ - (NSString *)dictionaryToJsonString:(NSDictionary *)json { NSString *jsonString = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; encodedData = [jsonString stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]]; } else { - CLY_LOG_W(@"%s failed to create json for hc request error: [%@]", __FUNCTION__, error); + CLY_LOG_E(@"%s could not build the json payload for the health check request, an empty value will be sent, keyCount: [%lu], error: [%@]", __FUNCTION__, (unsigned long)json.count, error.localizedDescription); } return encodedData; @@ -246,6 +278,7 @@ - (NSURLRequest *)healthCheckRequest { __block long snapshotConsecutiveBackoffRequest; dispatch_sync(self.hcQueue, ^{ + [self loadPersistedStateIfNeeded]; snapshotLogError = self.countLogError; snapshotLogWarning = self.countLogWarning; snapshotStatusCode = self.statusCode; @@ -273,10 +306,11 @@ - (NSURLRequest *)healthCheckRequest { queryString = [CountlyConnectionManager.sharedInstance appendChecksum:queryString]; NSString* hcSendURL = [CountlyConnectionManager.sharedInstance.host stringByAppendingFormat:@"%@",kCountlyEndpointI]; - CLY_LOG_D(@"%s generated health check request: [%@]", __FUNCTION__, queryString); + CLY_LOG_I(@"%s sending health check request, errorCount: [%ld], warningCount: [%ld], statusCode: [%ld], errorMessageLength: [%lu], backoffRequestCount: [%ld], consecutiveBackoffRequestCount: [%ld]", __FUNCTION__, snapshotLogError, snapshotLogWarning, (long)snapshotStatusCode, (unsigned long)snapshotErrorMessage.length, snapshotBackoffRequest, snapshotConsecutiveBackoffRequest); if (queryString.length > kCountlyGETRequestMaxLength || CountlyConnectionManager.sharedInstance.alwaysUsePOST) { + CLY_LOG_D(@"%s health check request prepared as POST, queryLength: [%lu]", __FUNCTION__, (unsigned long)queryString.length); NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:hcSendURL]]; request.HTTPMethod = @"POST"; request.HTTPBody = [queryString cly_dataUTF8]; @@ -284,6 +318,7 @@ - (NSURLRequest *)healthCheckRequest { } else { + CLY_LOG_D(@"%s health check request prepared as GET, queryLength: [%lu]", __FUNCTION__, (unsigned long)queryString.length); NSString* withQueryString = [hcSendURL stringByAppendingFormat:@"?%@", queryString]; NSURLRequest* request = [NSURLRequest requestWithURL:[NSURL URLWithString:withQueryString]]; return request; diff --git a/ios/src/CountlyLocationManager.h b/ios/src/CountlyLocationManager.h index ecf89078..66d70f69 100644 --- a/ios/src/CountlyLocationManager.h +++ b/ios/src/CountlyLocationManager.h @@ -5,8 +5,9 @@ // Please visit www.count.ly for more information. #import +#import "Resettable.h" -@interface CountlyLocationManager : NSObject +@interface CountlyLocationManager : NSObject @property (nonatomic, copy) NSString* location; @property (nonatomic, copy) NSString* city; @property (nonatomic, copy) NSString* ISOCountryCode; diff --git a/ios/src/CountlyLocationManager.m b/ios/src/CountlyLocationManager.m index 6f0b3959..e1bb1908 100644 --- a/ios/src/CountlyLocationManager.m +++ b/ios/src/CountlyLocationManager.m @@ -9,17 +9,26 @@ @implementation CountlyLocationManager +static CountlyLocationManager* s_sharedInstance = nil; +static dispatch_once_t onceToken; + + (instancetype)sharedInstance { if (!CountlyCommon.sharedInstance.hasStarted) return nil; - static CountlyLocationManager* s_sharedInstance; - static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{s_sharedInstance = self.new;}); return s_sharedInstance; } +/// Drops the singleton, so the next start begins without the stored location fields and with location info enabled. +- (void)resetInstance +{ + CLY_LOG_I(@"%s location manager is being reset, stored location fields and the disabled state are cleared", __FUNCTION__); + onceToken = 0; + s_sharedInstance = nil; +} + - (instancetype)init { if (self = [super init]) @@ -34,12 +43,23 @@ - (instancetype)init - (void)recordLocation:(CLLocationCoordinate2D)location city:(NSString *)city ISOCountryCode:(NSString *)ISOCountryCode IP:(NSString *)IP { + CLY_LOG_I(@"%s location recording requested, coordinateProvided: [%@], cityProvided: [%@], countryCodeProvided: [%@], IPProvided: [%@]", __FUNCTION__, CLLocationCoordinate2DIsValid(location) ? @"YES" : @"NO", (city.length > 0) ? @"YES" : @"NO", (ISOCountryCode.length > 0) ? @"YES" : @"NO", (IP.length > 0) ? @"YES" : @"NO"); + // log gathering can upload any line, so the values are not quoted once the server turned location tracking off + if (CountlyServerConfig.sharedInstance.locationTrackingEnabled) + CLY_LOG_D(@"%s location recording detail, latitude: [%f], longitude: [%f], city: [%@], countryCode: [%@], IP: [%@]", __FUNCTION__, location.latitude, location.longitude, city, ISOCountryCode, IP); + if (!CountlyConsentManager.sharedInstance.consentForLocation) + { + CLY_LOG_V(@"%s no location consent given, location is not recorded", __FUNCTION__); return; + } if(!CountlyServerConfig.sharedInstance.locationTrackingEnabled) + { + CLY_LOG_D(@"%s location recording is dropped, location tracking is disabled by server config", __FUNCTION__); return; - + } + [self updateLocation:location city:city ISOCountryCode:ISOCountryCode IP:IP]; [CountlyConnectionManager.sharedInstance sendLocationInfo]; @@ -56,13 +76,17 @@ - (void)updateLocation:(CLLocationCoordinate2D)location city:(NSString *)city IS self.ISOCountryCode = ISOCountryCode.length ? ISOCountryCode : nil; self.IP = IP.length ? IP : nil; + CLY_LOG_D(@"%s stored location fields updated, providedFields: [%@%@%@%@]", __FUNCTION__, self.location ? @"coordinate " : @"", self.city ? @"city " : @"", self.ISOCountryCode ? @"countryCode " : @"", self.IP ? @"IP" : @""); + if (CountlyServerConfig.sharedInstance.locationTrackingEnabled) + CLY_LOG_D(@"%s stored location field detail, location: [%@], city: [%@], countryCode: [%@], IP: [%@]", __FUNCTION__, self.location, self.city, self.ISOCountryCode, self.IP); + if (self.city && !self.ISOCountryCode) { - CLY_LOG_W(@"City and Country Code should be set as a pair. Country Code is missing while City is set!"); + CLY_LOG_W(@"%s city and country code should be set as a pair, country code is missing while city is set", __FUNCTION__); } else if (self.ISOCountryCode && !self.city) { - CLY_LOG_W(@"City and Country Code should be set as a pair. City is missing while Country Code is set!"); + CLY_LOG_W(@"%s city and country code should be set as a pair, city is missing while country code is set", __FUNCTION__); } if ((self.location || self.city || self.ISOCountryCode || self.IP)) @@ -71,28 +95,43 @@ - (void)updateLocation:(CLLocationCoordinate2D)location city:(NSString *)city IS - (void)sendLocationInfo { + CLY_LOG_I(@"%s stored location info send requested, locationInfoDisabled: [%@]", __FUNCTION__, self.isLocationInfoDisabled ? @"YES" : @"NO"); + if (!CountlyConsentManager.sharedInstance.consentForLocation) + { + CLY_LOG_V(@"%s no location consent given, stored location info is not sent", __FUNCTION__); return; + } if(!CountlyServerConfig.sharedInstance.locationTrackingEnabled) + { + CLY_LOG_D(@"%s stored location info send is dropped, location tracking is disabled by server config", __FUNCTION__); return; + } [CountlyConnectionManager.sharedInstance sendLocationInfo]; } - (void)disableLocationInfo { + CLY_LOG_I(@"%s location clearing requested", __FUNCTION__); + if (!CountlyConsentManager.sharedInstance.consentForLocation) + { + CLY_LOG_V(@"%s no location consent given, location info is not cleared", __FUNCTION__); return; + } [self disableLocation]; - CLY_LOG_D(@"%s, disabling location info!", __FUNCTION__); + CLY_LOG_I(@"%s location info is cleared and location tracking is disabled for this device", __FUNCTION__); [CountlyConnectionManager.sharedInstance sendLocationInfo]; } - (void)disableLocation { + CLY_LOG_D(@"%s clearing all stored location fields, clearedFields: [%@]", __FUNCTION__, @"coordinate city countryCode IP"); + self.isLocationInfoDisabled = YES; self.location = nil; self.city = nil; diff --git a/ios/src/CountlyNotificationService.m b/ios/src/CountlyNotificationService.m index a42a08ad..bec63764 100644 --- a/ios/src/CountlyNotificationService.m +++ b/ios/src/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/ios/src/CountlyOverlayWindow.h b/ios/src/CountlyOverlayWindow.h index 8f562f04..8ccd87c9 100644 --- a/ios/src/CountlyOverlayWindow.h +++ b/ios/src/CountlyOverlayWindow.h @@ -3,14 +3,14 @@ // This code is provided under the MIT License. // // Please visit www.count.ly for more information. -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) #import #endif #import "CountlyCommon.h" NS_ASSUME_NONNULL_BEGIN -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) @interface CountlyOverlayWindow : UIWindow @end #endif diff --git a/ios/src/CountlyOverlayWindow.m b/ios/src/CountlyOverlayWindow.m index 174c2e82..89285e7d 100644 --- a/ios/src/CountlyOverlayWindow.m +++ b/ios/src/CountlyOverlayWindow.m @@ -5,8 +5,9 @@ // Please visit www.count.ly for more information. #import "CountlyOverlayWindow.h" #import "CountlyWebViewController.h" +#import "CountlyCommon.h" -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) @implementation CountlyOverlayWindow - (instancetype)init { @@ -36,7 +37,7 @@ - (instancetype)init if (!initialized) { - if (self = [super initWithFrame:UIScreen.mainScreen.bounds]) + if (self = [super initWithFrame:CountlyCommon.screenBounds]) { initialized = YES; } diff --git a/ios/src/CountlyPerformanceMonitoring.m b/ios/src/CountlyPerformanceMonitoring.m index b174b9ba..c03ebcb9 100644 --- a/ios/src/CountlyPerformanceMonitoring.m +++ b/ios/src/CountlyPerformanceMonitoring.m @@ -50,6 +50,8 @@ + (instancetype)sharedInstance - (void) startWithConfig:(CountlyAPMConfig *) apmConfig { + CLY_LOG_I(@"%s apm module starting, enableAppStartTimeTracking: [%@], enableManualAppLoadedTrigger: [%@], enableForegroundBackgroundTracking: [%@]", __FUNCTION__, apmConfig.enableAppStartTimeTracking ? @"YES" : @"NO", apmConfig.enableManualAppLoadedTrigger ? @"YES" : @"NO", apmConfig.enableForegroundBackgroundTracking ? @"YES" : @"NO"); + enableAppStartTimeTracking = apmConfig.enableAppStartTimeTracking; enableManualAppLoadedTrigger = apmConfig.enableManualAppLoadedTrigger; if(enableAppStartTimeTracking && !enableManualAppLoadedTrigger) { @@ -74,12 +76,18 @@ - (instancetype)init - (void)startPerformanceMonitoring { if (!enableForegroundBackgroundTracking) + { + CLY_LOG_D(@"%s foreground and background tracking is not enabled in config, observers will not be added", __FUNCTION__); return; + } if (!CountlyConsentManager.sharedInstance.consentForPerformanceMonitoring) + { + CLY_LOG_V(@"%s no apm consent given, foreground and background tracking is not started", __FUNCTION__); return; + } - CLY_LOG_D(@"Starting performance monitoring foreground/background tracking..."); + CLY_LOG_D(@"%s foreground and background tracking is starting, isInBackground: [%@]", __FUNCTION__, CountlyDeviceInfo.isInBackground ? @"YES" : @"NO"); #if (TARGET_OS_OSX) [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(applicationDidBecomeActive:) name:NSApplicationDidBecomeActiveNotification object:nil]; @@ -97,6 +105,8 @@ - (void)startPerformanceMonitoring - (void)stopPerformanceMonitoring { + CLY_LOG_D(@"%s foreground and background tracking observers are being removed", __FUNCTION__); + #if (TARGET_OS_OSX) [NSNotificationCenter.defaultCenter removeObserver:self name:NSApplicationDidBecomeActiveNotification object:nil]; [NSNotificationCenter.defaultCenter removeObserver:self name:NSApplicationWillResignActiveNotification object:nil]; @@ -112,7 +122,7 @@ - (void)stopPerformanceMonitoring - (void)applicationDidBecomeActive:(NSNotification *)notification { - CLY_LOG_D(@"applicationDidBecomeActive: (Performance Monitoring)"); + CLY_LOG_V(@"%s did become active notification received by the apm module", __FUNCTION__); if (!enableForegroundBackgroundTracking) return; @@ -123,7 +133,7 @@ - (void)applicationDidBecomeActive:(NSNotification *)notification - (void)applicationWillResignActive:(NSNotification *)notification { - CLY_LOG_D(@"applicationWillResignActive: (Performance Monitoring)"); + CLY_LOG_V(@"%s will resign active notification received by the apm module", __FUNCTION__); if (!enableForegroundBackgroundTracking) return; @@ -170,24 +180,32 @@ - (void)endBackgroundTrace - (void)recordAppStartDurationTraceWithStartTime:(long long)startTime endTime:(long long)endTime { + CLY_LOG_I(@"%s app start duration trace recording requested, startTime: [%lld], endTime: [%lld]", __FUNCTION__, startTime, endTime); + if (!CountlyConsentManager.sharedInstance.consentForPerformanceMonitoring) + { + CLY_LOG_V(@"%s no apm consent given, app start duration trace is not recorded", __FUNCTION__); return; + } if(!enableAppStartTimeTracking || !enableManualAppLoadedTrigger) { - CLY_LOG_W(@"Set 'enableAppStartTimeTracking' and 'enableManualAppLoadedTrigger' in config to record App start duration trace!"); + CLY_LOG_D(@"%s app start duration trace is dropped, set 'enableAppStartTimeTracking' and 'enableManualAppLoadedTrigger' in config to record it", __FUNCTION__); return; } if (self.hasAlreadyRecordedAppStartDurationTrace) { - CLY_LOG_W(@"App start duration trace can be recorded once per app launch. So, it will not be recorded this time!"); + CLY_LOG_W(@"%s app start duration trace is dropped, it can only be recorded once per app launch", __FUNCTION__); return; } long long appStartDuration = endTime - startTime; - CLY_LOG_D(@"App is loaded and displayed its first view in %lld milliseconds.", appStartDuration); + if (appStartDuration < 0) + CLY_LOG_W(@"%s app start duration is negative, it will still be recorded, duration: [%lld]", __FUNCTION__, appStartDuration); + + CLY_LOG_D(@"%s app start trace recorded, traceName: [%@], duration: [%lld] ms", __FUNCTION__, kCountlyPMKeyAppStart, appStartDuration); NSDictionary* metrics = @{ @@ -215,11 +233,22 @@ - (void)recordNetworkTrace:(NSString *)traceName startTime:(long long)startTime endTime:(long long)endTime { + CLY_LOG_I(@"%s network trace recording requested, traceName: [%@], requestPayloadSize: [%ld], responsePayloadSize: [%ld], responseStatusCode: [%ld]", __FUNCTION__, traceName, (long)requestPayloadSize, (long)responsePayloadSize, (long)responseStatusCode); + if (!CountlyConsentManager.sharedInstance.consentForPerformanceMonitoring) + { + CLY_LOG_V(@"%s no apm consent given, network trace is not recorded", __FUNCTION__); return; + } if (!traceName.length) + { + CLY_LOG_E(@"%s network trace is dropped, trace name is empty or nil", __FUNCTION__); return; + } + + if (endTime - startTime < 0) + CLY_LOG_W(@"%s network trace response time is negative, traceName: [%@], responseTime: [%lld] ms", __FUNCTION__, traceName, endTime - startTime); traceName = [traceName cly_truncatedKey:@"Network trace name"]; @@ -240,39 +269,60 @@ - (void)recordNetworkTrace:(NSString *)traceName kCountlyPMKeyEndTime: @(endTime), }; + CLY_LOG_D(@"%s network trace recorded, traceName: [%@], responseTime: [%lld] ms", __FUNCTION__, traceName, endTime - startTime); + [CountlyConnectionManager.sharedInstance sendPerformanceMonitoringTrace:[trace cly_JSONify]]; } - (void)startCustomTrace:(NSString *)traceName { + CLY_LOG_I(@"%s custom trace start requested, traceName: [%@]", __FUNCTION__, traceName); + if (!CountlyConsentManager.sharedInstance.consentForPerformanceMonitoring) + { + CLY_LOG_V(@"%s no apm consent given, custom trace is not started", __FUNCTION__); return; + } if (!traceName.length) + { + CLY_LOG_E(@"%s custom trace start is dropped, trace name is empty or nil", __FUNCTION__); return; + } + + NSUInteger runningTraceCount = 0; @synchronized (self.startedCustomTraces) { if (self.startedCustomTraces[traceName]) { - CLY_LOG_W(@"Custom trace with name '%@' already started!", traceName); + CLY_LOG_W(@"%s custom trace start is ignored, a trace with the same name is already running, traceName: [%@]", __FUNCTION__, traceName); return; } NSNumber* startTime = @((long long)(CountlyCommon.sharedInstance.uniqueTimestamp * 1000)); self.startedCustomTraces[traceName] = startTime; + runningTraceCount = self.startedCustomTraces.count; } - - CLY_LOG_D(@"Custom trace with name '%@' just started!", traceName); + + CLY_LOG_D(@"%s custom trace started, traceName: [%@], runningTraceCount: [%lu]", __FUNCTION__, traceName, (unsigned long)runningTraceCount); } - (void)endCustomTrace:(NSString *)traceName metrics:(NSDictionary *)metrics { + CLY_LOG_I(@"%s custom trace end requested, traceName: [%@], metricCount: [%lu]", __FUNCTION__, traceName, (unsigned long)metrics.count); + if (!CountlyConsentManager.sharedInstance.consentForPerformanceMonitoring) + { + CLY_LOG_V(@"%s no apm consent given, custom trace is not ended", __FUNCTION__); return; + } if (!traceName.length) + { + CLY_LOG_E(@"%s custom trace end is dropped, trace name is empty or nil", __FUNCTION__); return; + } NSNumber* startTime = nil; @@ -284,7 +334,7 @@ - (void)endCustomTrace:(NSString *)traceName metrics:(NSDictionary *)metrics if (!startTime) { - CLY_LOG_W(@"Custom trace with name '%@' not started yet or cancelled/ended before!", traceName); + CLY_LOG_W(@"%s custom trace end is a no-op, no running trace with this name, traceName: [%@]", __FUNCTION__, traceName); return; } @@ -310,40 +360,54 @@ - (void)endCustomTrace:(NSString *)traceName metrics:(NSDictionary *)metrics kCountlyPMKeyEndTime: endTime, }; - CLY_LOG_D(@"Custom trace with name '%@' just ended with duration %lld ms.", traceName, duration); + if (duration < 0) + CLY_LOG_W(@"%s custom trace duration is negative, it will still be recorded, traceName: [%@], duration: [%lld]", __FUNCTION__, traceName, duration); + + CLY_LOG_D(@"%s custom trace ended, traceName: [%@], duration: [%lld] ms, metricCount: [%lu]", __FUNCTION__, traceName, duration, (unsigned long)mutableMetrics.count); [CountlyConnectionManager.sharedInstance sendPerformanceMonitoringTrace:[trace cly_JSONify]]; } - (void)cancelCustomTrace:(NSString *)traceName { + CLY_LOG_I(@"%s custom trace cancellation requested, traceName: [%@]", __FUNCTION__, traceName); + if (!CountlyConsentManager.sharedInstance.consentForPerformanceMonitoring) + { + CLY_LOG_V(@"%s no apm consent given, custom trace is not cancelled", __FUNCTION__); return; + } if (!traceName.length) + { + CLY_LOG_E(@"%s custom trace cancellation is dropped, trace name is empty or nil", __FUNCTION__); return; + } NSNumber* startTime = nil; + NSUInteger runningTraceCount = 0; @synchronized (self.startedCustomTraces) { startTime = self.startedCustomTraces[traceName]; [self.startedCustomTraces removeObjectForKey:traceName]; + runningTraceCount = self.startedCustomTraces.count; } if (!startTime) { - CLY_LOG_W(@"Custom trace with name '%@' not started yet or cancelled/ended before!", traceName); + CLY_LOG_D(@"%s custom trace cancellation is a no-op, no running trace with this name, traceName: [%@]", __FUNCTION__, traceName); return; } - CLY_LOG_D(@"Custom trace with name '%@' cancelled!", traceName); + CLY_LOG_D(@"%s custom trace cancelled, its duration is discarded, traceName: [%@], startTime: [%lld], runningTraceCount: [%lu]", __FUNCTION__, traceName, startTime.longLongValue, (unsigned long)runningTraceCount); } - (void)clearAllCustomTraces { @synchronized (self.startedCustomTraces) { + CLY_LOG_D(@"%s clearing all running custom traces, runningTraceCount: [%lu]", __FUNCTION__, (unsigned long)self.startedCustomTraces.count); [self.startedCustomTraces removeAllObjects]; } } diff --git a/ios/src/CountlyPersistency.h b/ios/src/CountlyPersistency.h index c4060afe..5068f1fc 100644 --- a/ios/src/CountlyPersistency.h +++ b/ios/src/CountlyPersistency.h @@ -12,10 +12,17 @@ @interface CountlyPersistency : NSObject + (instancetype)sharedInstance; +/// Whether the queued request is a gathered log upload. ++ (BOOL)isSdkLogRequest:(NSString *)queryString; +/// Removes the values the SDK keeps in the standard user defaults, also those older SDK versions kept, since every one of them carries the "kCountly" prefix. The app's own values stay, and so does the PLCrashReporter suppression marker, so a crash between halt and the next start is still discarded. ++ (void)removeStoredUserDefaults; -- (void)addToQueue:(NSString *)queryString; +/// Appends a request to the queue. Returns NO when it was refused: tracking is disabled by server config, or the request is empty. +- (BOOL)addToQueue:(NSString *)queryString; - (void)removeFromQueue:(NSString *)queryString; - (NSString *)firstItemInQueue; +/// Whether a gathered log upload waits in the request queue. +- (BOOL)queueContainsSdkLogs; - (void)flushQueue; - (NSUInteger)remainingRequestCount; - (void)replaceAllTemporaryDeviceIDsInQueueWithDeviceID:(NSString *)deviceID; @@ -66,6 +73,11 @@ - (NSDictionary *)retrieveHealthCheckTrackerState; - (void)storeHealthCheckTrackerState:(NSDictionary *)healthCheckTrackerState; +/// When automatic crash capture stopped being wanted while PLCrashReporter stays installed, or nil while it is wanted. +- (NSDate *)retrievePLCrashCaptureSuppressedSince; +/// Stores when automatic crash capture stopped being wanted, nil clears it. +- (void)storePLCrashCaptureSuppressedSince:(NSDate *)date; + -(BOOL)isOldRequest:(NSString*) queryString; @property (nonatomic) NSUInteger eventSendThreshold; diff --git a/ios/src/CountlyPersistency.m b/ios/src/CountlyPersistency.m index c50d7aa4..91e5aecd 100644 --- a/ios/src/CountlyPersistency.m +++ b/ios/src/CountlyPersistency.m @@ -24,6 +24,7 @@ @implementation CountlyPersistency NSString* const kCountlyIsCustomDeviceIDKey = @"kCountlyIsCustomDeviceIDKey"; NSString* const kCountlyRemoteConfigKey = @"kCountlyRemoteConfigKey"; NSString* const kCountlyServerConfigPersistencyKey = @"kCountlyServerConfigPersistencyKey"; +NSString* const kCountlyPLCrashCaptureSuppressedSinceKey = @"kCountlyPLCrashCaptureSuppressedSinceKey"; NSString* const kCountlyCustomCrashLogFileName = @"CountlyCustomCrash.log"; @@ -56,6 +57,8 @@ - (instancetype)init #pragma GCC diagnostic pop self.queuedRequests = [readDict[kCountlyQueuedRequestsPersistencyKey] mutableCopy]; + + CLY_LOG_D(@"%s persistent storage file is loaded, data size: [%lu] bytes, restored request count: [%lu]", __FUNCTION__, (unsigned long)readData.length, (unsigned long)self.queuedRequests.count); } if (!self.queuedRequests) @@ -72,16 +75,23 @@ - (instancetype)init #pragma mark --- -- (void)addToQueue:(NSString *)queryString +- (BOOL)addToQueue:(NSString *)queryString { + // NOTE: reachable from CountlySignalHandler through CountlyConnectionManager sendCrashReport, + // so every log here and in removeOldAgeRequestsFromQueue stays at Debug or lower, even the + // request dropping ones. CountlyInternalLog re-enters CountlyHealthTracker, which uses + // dispatch_once and dispatch_async, and neither is async-signal-safe. if (!CountlyServerConfig.sharedInstance.trackingEnabled) { - CLY_LOG_D(@"'addToQueue' is aborted: SDK Tracking is disabled from server config!"); - return; + CLY_LOG_D(@"%s request is not queued, reason: SDK tracking is disabled from server config, query string length: [%lu]", __FUNCTION__, (unsigned long)queryString.length); + return NO; } if (!queryString.length || [queryString isEqual:NSNull.null]) - return; + { + CLY_LOG_D(@"%s request is not queued, reason: query string is nil or empty", __FUNCTION__); + return NO; + } queryString = [queryString stringByAppendingFormat:@"&%@=%@", kCountlyAppVersionKey, CountlyDeviceInfo.appVersion]; @@ -91,6 +101,13 @@ - (void)addToQueue:(NSString *)queryString if (self.queuedRequests.count >= self.storedRequestsLimit) { [self removeOldAgeRequestsFromQueue]; + [self removeSdkLogRequestsToMakeRoom]; + if (self.queuedRequests.count >= self.storedRequestsLimit && [CountlyPersistency isSdkLogRequest:queryString]) + { + // refused rather than making room, a log upload never pushes other data out, its lines go back to the gathering buffer + CLY_LOG_D(@"%s request is not queued, reason: the queue is full and a gathered log upload never pushes other requests out, queue size: [%lu]", __FUNCTION__, (unsigned long)self.queuedRequests.count); + return NO; + } if (self.queuedRequests.count >= self.storedRequestsLimit) { NSUInteger exceededSize = self.queuedRequests.count - self.storedRequestsLimit; @@ -98,13 +115,15 @@ - (void)addToQueue:(NSString *)queryString // for example if exceeded count is 136 and our limit is 100 we should remove 100 items // in other case if exceeded count is 36 and out limit is 100 we can only remove 36 items because we have that amount NSUInteger gonnaRemoveSize = MIN(exceededSize, kCountlyRequestRemovalLoopLimit) + 1; - CLY_LOG_W(@"[CountlyPersistency] addToQueue, request queue size:[ %lu ] exceeded limit:[ %lu ], will remove first:[ %lu ] request(s)", self.queuedRequests.count, self.storedRequestsLimit, gonnaRemoveSize); + CLY_LOG_D(@"%s request queue size: [%lu] exceeded the limit: [%lu], the first [%lu] request(s) will be dropped", __FUNCTION__, (unsigned long)self.queuedRequests.count, (unsigned long)self.storedRequestsLimit, (unsigned long)gonnaRemoveSize); NSRange itemsToRemove = NSMakeRange(0, gonnaRemoveSize); [self.queuedRequests removeObjectsInRange:itemsToRemove]; } } [self.queuedRequests addObject:queryString]; + CLY_LOG_V(@"%s request is queued, query string length: [%lu], queue size: [%lu]", __FUNCTION__, (unsigned long)queryString.length, (unsigned long)self.queuedRequests.count); } + return YES; } - (void)removeFromQueue:(NSString *)queryString @@ -112,7 +131,10 @@ - (void)removeFromQueue:(NSString *)queryString @synchronized (self) { if (self.queuedRequests.count) + { [self.queuedRequests removeObject:queryString inRange:(NSRange){0, 1}]; + CLY_LOG_D(@"%s request at the head of the queue is removed, remaining request count: [%lu]", __FUNCTION__, (unsigned long)self.queuedRequests.count); + } } } @@ -124,10 +146,28 @@ - (NSString *)firstItemInQueue } } +- (BOOL)queueContainsSdkLogs +{ + @synchronized (self) + { + for (NSString* queryString in self.queuedRequests) + { + if ([CountlyPersistency isSdkLogRequest:queryString]) + return YES; + } + + return NO; + } +} + - (void)flushQueue { @synchronized (self) { + if (self.queuedRequests.count) + { + CLY_LOG_D(@"%s request queue is being flushed, [%lu] queued request(s) will be dropped", __FUNCTION__, (unsigned long)self.queuedRequests.count); + } [self.queuedRequests removeAllObjects]; } } @@ -142,6 +182,7 @@ - (NSUInteger)remainingRequestCount - (void)replaceAllTemporaryDeviceIDsInQueueWithDeviceID:(NSString *)deviceID { + CLY_LOG_D(@"%s replacing the temporary device ID in the queued requests, queued request count: [%lu]", __FUNCTION__, (unsigned long)[self remainingRequestCount]); NSString* temporaryDeviceIDQueryString = [NSString stringWithFormat:@"&%@=%@", kCountlyQSKeyDeviceID, CLYTemporaryDeviceID]; NSString* realDeviceIDQueryString = [NSString stringWithFormat:@"&%@=%@", kCountlyQSKeyDeviceID, deviceID.cly_URLEscaped]; @@ -156,7 +197,7 @@ - (void)replaceAllTemporaryDeviceIDsInQueueWithDeviceID:(NSString *)deviceID { if ([queryString containsString:temporaryDeviceIDQueryString]) { - CLY_LOG_D(@"Detected a request with temporary device ID in queue and replaced it with real device ID."); + CLY_LOG_V(@"[CountlyPersistency] replaceAllTemporaryDeviceIDsInQueueWithDeviceID, a queued request with the temporary device ID is replaced with the real device ID, request index: [%lu]", (unsigned long)idx); NSString * replacedQueryString = [queryString stringByReplacingOccurrencesOfString:temporaryDeviceIDQueryString withString:realDeviceIDQueryString]; replacedQueryString = [replacedQueryString stringByReplacingOccurrencesOfString:temporaryDeviceIDTypeQueryString withString:realDeviceIDTypeQueryString]; self.queuedRequests[idx] = replacedQueryString; @@ -171,6 +212,7 @@ - (void)replaceAllAppKeysInQueueWithCurrentAppKey { @synchronized (self) { + CLY_LOG_D(@"%s replacing different app keys in the queued requests, queued request count: [%lu]", __FUNCTION__, (unsigned long)self.queuedRequests.count); self.isQueueBeingModified = YES; [self.queuedRequests.copy enumerateObjectsUsingBlock:^(NSString* queryString, NSUInteger idx, BOOL* stop) @@ -179,7 +221,7 @@ - (void)replaceAllAppKeysInQueueWithCurrentAppKey if (![appKeyInQueryString isEqualToString:CountlyConnectionManager.sharedInstance.appKey.cly_URLEscaped]) { - CLY_LOG_D(@"Detected a request with a different app key (%@) in queue and replaced it with current app key.", appKeyInQueryString); + CLY_LOG_V(@"[CountlyPersistency] replaceAllAppKeysInQueueWithCurrentAppKey, a queued request with a different app key is replaced with the current app key, request index: [%lu]", (unsigned long)idx); NSString* currentAppKeyQueryString = [NSString stringWithFormat:@"%@=%@", kCountlyQSKeyAppKey, CountlyConnectionManager.sharedInstance.appKey.cly_URLEscaped]; NSString* differentAppKeyQueryString = [NSString stringWithFormat:@"%@=%@", kCountlyQSKeyAppKey, appKeyInQueryString]; @@ -196,6 +238,7 @@ - (void)removeDifferentAppKeysFromQueue { @synchronized (self) { + NSUInteger requestCountBeforeAppKeyFilter = self.queuedRequests.count; self.isQueueBeingModified = YES; NSPredicate* predicate = [NSPredicate predicateWithBlock:^BOOL(NSString* queryString, NSDictionary * bindings) @@ -205,7 +248,7 @@ - (void)removeDifferentAppKeysFromQueue BOOL isSameAppKey = [appKeyInQueryString isEqualToString:CountlyConnectionManager.sharedInstance.appKey.cly_URLEscaped]; if (!isSameAppKey) { - CLY_LOG_D(@"Detected a request with a different app key (%@) in queue and removed it.", appKeyInQueryString); + CLY_LOG_V(@"[CountlyPersistency] removeDifferentAppKeysFromQueue, a queued request with a different app key will be dropped"); } return isSameAppKey; @@ -213,15 +256,61 @@ - (void)removeDifferentAppKeysFromQueue [self.queuedRequests filterUsingPredicate:predicate]; + if (requestCountBeforeAppKeyFilter > self.queuedRequests.count) + { + CLY_LOG_W(@"%s dropped [%lu] queued request(s) with a different app key, remaining request count: [%lu]", __FUNCTION__, (unsigned long)(requestCountBeforeAppKeyFilter - self.queuedRequests.count), (unsigned long)self.queuedRequests.count); + } + self.isQueueBeingModified = NO; } } +/// Drops the oldest gathered log uploads until the full queue has room, counting their lines as dropped. Call while holding the queue lock. +- (void)removeSdkLogRequestsToMakeRoom +{ + NSInteger excessCount = (NSInteger)self.queuedRequests.count - (NSInteger)self.storedRequestsLimit + 1; + if (excessCount <= 0) + return; + + NSMutableIndexSet* sdkLogRequestIndexes = NSMutableIndexSet.new; + [self.queuedRequests enumerateObjectsUsingBlock:^(NSString* queryString, NSUInteger idx, BOOL* stop) + { + if ([CountlyPersistency isSdkLogRequest:queryString]) + [sdkLogRequestIndexes addIndex:idx]; + + *stop = (NSInteger)sdkLogRequestIndexes.count >= excessCount; + }]; + + if (sdkLogRequestIndexes.count == 0) + return; + + NSArray* droppedRequests = [self.queuedRequests objectsAtIndexes:sdkLogRequestIndexes]; + self.isQueueBeingModified = YES; + [self.queuedRequests removeObjectsAtIndexes:sdkLogRequestIndexes]; + self.isQueueBeingModified = NO; + for (NSString* queryString in droppedRequests) + [CountlyCommon.sharedInstance countEvictedSdkLogRequest:queryString]; + + CLY_LOG_D(@"%s request queue is full, gathered log uploads are dropped before any other request, droppedCount: [%lu], queue size: [%lu]", __FUNCTION__, (unsigned long)droppedRequests.count, (unsigned long)self.queuedRequests.count); +} + ++ (BOOL)isSdkLogRequest:(NSString *)queryString +{ + static NSString* sdkLogsParameter; + static dispatch_once_t sdkLogsParameterOnce; + dispatch_once(&sdkLogsParameterOnce, ^{ + sdkLogsParameter = [NSString stringWithFormat:@"&%@=", kCountlyQSSdkLogs]; + }); + + return [queryString rangeOfString:sdkLogsParameter].location != NSNotFound; +} + - (void)removeOldAgeRequestsFromQueue { @synchronized (self) { if(self.requestDropAgeHours && self.requestDropAgeHours > 0) { + NSUInteger requestCountBeforeAgeFilter = self.queuedRequests.count; self.isQueueBeingModified = YES; NSPredicate* predicate = [NSPredicate predicateWithBlock:^BOOL(NSString* queryString, NSDictionary * bindings) @@ -232,6 +321,11 @@ - (void)removeOldAgeRequestsFromQueue [self.queuedRequests filterUsingPredicate:predicate]; + if (requestCountBeforeAgeFilter > self.queuedRequests.count) + { + CLY_LOG_D(@"%s dropped [%lu] old age queued request(s), request drop age in hours: [%lu], remaining request count: [%lu]", __FUNCTION__, (unsigned long)(requestCountBeforeAgeFilter - self.queuedRequests.count), (unsigned long)self.requestDropAgeHours, (unsigned long)self.queuedRequests.count); + } + self.isQueueBeingModified = NO; } } @@ -254,7 +348,7 @@ -(BOOL)isOldRequestInternal:(NSString *)queryString BOOL isOldAgeRequest = durationInHours >= self.requestDropAgeHours; if (isOldAgeRequest) { - CLY_LOG_D(@"Detected a request with an old age (age in hours: %f) in queue and removed it.", durationInHours); + CLY_LOG_V(@"%s a queued request exceeded the request drop age and will be dropped, request age in hours: [%.2f], request drop age in hours: [%lu]", __FUNCTION__, durationInHours, (unsigned long)self.requestDropAgeHours); } return isOldAgeRequest; @@ -271,12 +365,14 @@ - (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]; + + CLY_LOG_D(@"%s event is added to the in memory event queue, pending event count: [%lu], event send threshold: [%lu], callback provided: [%@]", __FUNCTION__, (unsigned long)self.recordedEvents.count, (unsigned long)self.eventSendThreshold, (callback != nil) ? @"YES" : @"NO"); if (callback != nil || self.recordedEvents.count >= self.eventSendThreshold) { @@ -292,10 +388,24 @@ - (NSString *)serializedRecordedEvents if (self.recordedEvents.count == 0) return nil; - NSArray *eventDictionaries = [self.recordedEvents valueForKey:@"dictionaryRepresentation"]; + NSMutableArray *eventDictionaries = NSMutableArray.new; + for (CountlyEvent *event in self.recordedEvents) + { + NSDictionary *eventDictionary = event.dictionaryRepresentation; + // one event JSON can not represent would otherwise take every event of the batch down with it + if ([NSJSONSerialization isValidJSONObject:eventDictionary]) + [eventDictionaries addObject:eventDictionary]; + else + CLY_LOG_D(@"%s an event JSON can not represent is dropped, key: [%@]", __FUNCTION__, event.key); + } + + CLY_LOG_D(@"%s serializing the recorded events for the next request, event count: [%lu]", __FUNCTION__, (unsigned long)eventDictionaries.count); [self.recordedEvents removeAllObjects]; + if (eventDictionaries.count == 0) + return nil; + return [eventDictionaries cly_JSONify]; } } @@ -305,21 +415,30 @@ - (void)flushEvents { @synchronized (self.recordedEvents) { + if (self.recordedEvents.count) + { + CLY_LOG_D(@"%s in memory event queue is being flushed, [%lu] recorded event(s) will be dropped", __FUNCTION__, (unsigned long)self.recordedEvents.count); + } [self.recordedEvents removeAllObjects]; } } - (void)resetInstance:(BOOL) clearStorage { - CLY_LOG_I(@"%s Clear Storage: %d", __FUNCTION__, clearStorage); + CLY_LOG_I(@"%s persistency instance is being reset, clear storage: [%@]", __FUNCTION__, clearStorage ? @"YES" : @"NO"); [CountlyConnectionManager.sharedInstance sendEventsWithSaveIfNeeded]; [self flushEvents]; [self clearAllTimedEvents]; - [self flushQueue]; if(clearStorage) { + [self flushQueue]; [self saveToFile]; } + else + { + // keeping the storage keeps every queued request, also the ones queued since the last save + [self saveToFileSync]; + } onceToken = 0; s_sharedInstance = nil; } @@ -332,11 +451,13 @@ - (void)recordTimedEvent:(CountlyEvent *)event { if (self.startedEvents[event.key]) { - CLY_LOG_W(@"Event with key '%@' already started!", event.key); + CLY_LOG_W(@"%s timed event is not started, reason: a timed event with the same key is already started, key: [%@]", __FUNCTION__, event.key); return; } self.startedEvents[event.key] = event; + + CLY_LOG_D(@"%s timed event is started, key: [%@], started timed event count: [%lu]", __FUNCTION__, event.key, (unsigned long)self.startedEvents.count); } } @@ -345,6 +466,9 @@ - (CountlyEvent *)timedEventForKey:(NSString *)key @synchronized (self.startedEvents) { CountlyEvent *event = self.startedEvents[key]; + + CLY_LOG_D(@"%s timed event is requested from the started timed events, key: [%@], found: [%@]", __FUNCTION__, key, (event != nil) ? @"YES" : @"NO"); + [self.startedEvents removeObjectForKey:key]; return event; @@ -355,6 +479,7 @@ - (void)clearAllTimedEvents { @synchronized (self.startedEvents) { + CLY_LOG_D(@"%s clearing all started timed events, started timed event count: [%lu]", __FUNCTION__, (unsigned long)self.startedEvents.count); [self.startedEvents removeAllObjects]; } } @@ -380,6 +505,7 @@ - (void)writeCustomCrashLogToFile:(NSString *)log [fileHandle seekToEndOfFile]; [fileHandle writeData:[line dataUsingEncoding:NSUTF8StringEncoding]]; [fileHandle closeFile]; + CLY_LOG_V(@"[CountlyPersistency] writeCustomCrashLogToFile, a line is appended to the custom crash log file, line length: [%lu]", (unsigned long)line.length); } else { @@ -387,7 +513,7 @@ - (void)writeCustomCrashLogToFile:(NSString *)log [line writeToFile:crashLogFileURL.path atomically:YES encoding:NSUTF8StringEncoding error:&error]; if (error) { - CLY_LOG_W(@"%s, Crash Log File can not be created, got error %@", __FUNCTION__, error); + CLY_LOG_W(@"[CountlyPersistency] writeCustomCrashLogToFile, custom crash log file can not be created, error domain: [%@], error code: [%ld], error description: [%@]", error.domain, (long)error.code, error.localizedDescription); } } }); @@ -398,6 +524,8 @@ - (NSString *)customCrashLogsFromFile NSURL* crashLogFileURL = [[self storageDirectoryURL] URLByAppendingPathComponent:kCountlyCustomCrashLogFileName]; NSData* readData = [NSData dataWithContentsOfURL:crashLogFileURL]; + CLY_LOG_D(@"%s custom crash logs are read from the custom crash log file, data size: [%lu] bytes", __FUNCTION__, (unsigned long)readData.length); + NSString* storedCustomCrashLogs = nil; if (readData) { @@ -413,11 +541,11 @@ - (void)deleteCustomCrashLogFile NSError* error = nil; if ([NSFileManager.defaultManager fileExistsAtPath:crashLogFileURL.path]) { - CLY_LOG_D(@"Detected Crash Log File and deleting it."); + CLY_LOG_D(@"%s custom crash log file is detected and it is being deleted", __FUNCTION__); [NSFileManager.defaultManager removeItemAtURL:crashLogFileURL error:&error]; if (error) { - CLY_LOG_W(@"%s, Crash Log File can not be deleted, got error %@", __FUNCTION__, error); + CLY_LOG_W(@"%s custom crash log file can not be deleted, error domain: [%@], error code: [%ld], error description: [%@]", __FUNCTION__, error.domain, (long)error.code, error.localizedDescription); } } } @@ -448,7 +576,7 @@ - (NSURL *)storageDirectoryURL [NSFileManager.defaultManager createDirectoryAtURL:URL withIntermediateDirectories:YES attributes:nil error:&error]; if (error) { - CLY_LOG_W(@"%s, Application Support directory can not be created, got error %@", __FUNCTION__, error); + CLY_LOG_E(@"[CountlyPersistency] storageDirectoryURL, storage directory can not be created, error domain: [%@], error code: [%ld], error description: [%@]", error.domain, (long)error.code, error.localizedDescription); } } }); @@ -481,18 +609,27 @@ - (void)saveToFile - (void)saveToFileSync { + // NOTE: reachable from CountlySignalHandler through CountlyConnectionManager sendCrashReport, + // keep every log here at Debug or lower. See the note on addToQueue. NSData* saveData; + NSUInteger queuedRequestCountForLog = 0; @synchronized (self) { #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wdeprecated-declarations" saveData = [NSKeyedArchiver archivedDataWithRootObject:@{kCountlyQueuedRequestsPersistencyKey: self.queuedRequests}]; + queuedRequestCountForLog = self.queuedRequests.count; #pragma GCC diagnostic pop } BOOL writeResult = [saveData writeToFile:[self storageFileURL].path atomically:YES]; - CLY_LOG_D(@"Result of writing data to file: %d", writeResult); + CLY_LOG_D(@"%s request queue is written to the persistent storage file, success: [%@], data size: [%lu] bytes, request count: [%lu]", __FUNCTION__, writeResult ? @"YES" : @"NO", (unsigned long)saveData.length, (unsigned long)queuedRequestCountForLog); + + if (!writeResult) + { + CLY_LOG_D(@"%s writing the request queue to the persistent storage file failed, data size: [%lu] bytes, request count: [%lu]", __FUNCTION__, (unsigned long)saveData.length, (unsigned long)queuedRequestCountForLog); + } [CountlyCommon.sharedInstance finishBackgroundTask]; } @@ -505,11 +642,12 @@ - (NSString* )retrieveDeviceID if (retrievedDeviceID) { - CLY_LOG_D(@"Device ID successfully retrieved from UserDefaults: %@", retrievedDeviceID); + CLY_LOG_D(@"%s device ID is retrieved from the user defaults, device ID length: [%lu]", __FUNCTION__, (unsigned long)retrievedDeviceID.length); + CLY_LOG_D(@"%s retrieved device ID detail, device ID: [%@]", __FUNCTION__, retrievedDeviceID); return retrievedDeviceID; } - CLY_LOG_D(@"There is no stored Device ID in UserDefaults!"); + CLY_LOG_D(@"%s there is no stored device ID in the user defaults", __FUNCTION__); return nil; } @@ -519,7 +657,8 @@ - (void)storeDeviceID:(NSString *)deviceID [NSUserDefaults.standardUserDefaults setObject:deviceID forKey:kCountlyStoredDeviceIDKey]; [NSUserDefaults.standardUserDefaults synchronize]; - CLY_LOG_D(@"Device ID successfully stored in UserDefaults: %@", deviceID); + CLY_LOG_D(@"%s device ID is stored in the user defaults, device ID length: [%lu]", __FUNCTION__, (unsigned long)deviceID.length); + CLY_LOG_D(@"%s stored device ID detail, device ID: [%@]", __FUNCTION__, deviceID); } - (NSString *)retrieveNSUUID @@ -587,6 +726,8 @@ - (NSDictionary *)retrieveRemoteConfig NSDictionary* remoteConfig = [NSKeyedUnarchiver unarchiveObjectWithData:data]; if (!remoteConfig) remoteConfig = NSDictionary.new; + + CLY_LOG_D(@"%s remote config is retrieved from the storage, stored data size: [%lu] bytes, key count: [%lu]", __FUNCTION__, (unsigned long)data.length, (unsigned long)remoteConfig.count); return remoteConfig; } @@ -595,15 +736,20 @@ - (void)storeRemoteConfig:(NSDictionary *)remoteConfig { [NSUserDefaults.standardUserDefaults setObject:[NSKeyedArchiver archivedDataWithRootObject:remoteConfig] forKey:kCountlyRemoteConfigKey]; [NSUserDefaults.standardUserDefaults synchronize]; + + CLY_LOG_D(@"%s remote config is stored, key count: [%lu]", __FUNCTION__, (unsigned long)remoteConfig.count); } - (NSMutableDictionary *)retrieveServerConfig { NSDictionary* serverConfig = [NSUserDefaults.standardUserDefaults objectForKey:kCountlyServerConfigPersistencyKey]; if ([serverConfig isKindOfClass:[NSDictionary class]]) { + CLY_LOG_D(@"%s server config is retrieved from the storage, key count: [%lu]", __FUNCTION__, (unsigned long)serverConfig.count); return [serverConfig mutableCopy]; } + CLY_LOG_D(@"%s there is no stored server config, returning an empty one", __FUNCTION__); + return [NSMutableDictionary new]; } @@ -611,6 +757,8 @@ - (void)storeServerConfig:(NSMutableDictionary *)serverConfig { [NSUserDefaults.standardUserDefaults setObject:serverConfig forKey:kCountlyServerConfigPersistencyKey]; [NSUserDefaults.standardUserDefaults synchronize]; + + CLY_LOG_D(@"%s server config is stored, key count: [%lu]", __FUNCTION__, (unsigned long)serverConfig.count); } - (NSDictionary *)retrieveHealthCheckTrackerState @@ -618,6 +766,8 @@ - (NSDictionary *)retrieveHealthCheckTrackerState NSDictionary* healthCheckTrackerState = [NSUserDefaults.standardUserDefaults objectForKey:kCountlyHealthCheckStatePersistencyKey]; if (!healthCheckTrackerState) healthCheckTrackerState = NSDictionary.new; + + CLY_LOG_D(@"%s health check tracker state is retrieved from the storage, key count: [%lu]", __FUNCTION__, (unsigned long)healthCheckTrackerState.count); return healthCheckTrackerState; } @@ -627,11 +777,57 @@ - (void)storeHealthCheckTrackerState:(NSDictionary *)healthCheckTrackerState @try { [NSUserDefaults.standardUserDefaults setObject:healthCheckTrackerState forKey:kCountlyHealthCheckStatePersistencyKey]; [NSUserDefaults.standardUserDefaults synchronize]; + + CLY_LOG_D(@"%s health check tracker state is stored, key count: [%lu]", __FUNCTION__, (unsigned long)healthCheckTrackerState.count); } @catch (NSException *exception) { - CLY_LOG_E(@"%s, Exception while storing health check tracker state: %@, reason: %@", __FUNCTION__, + // NOTE: Debug on purpose. This method is only ever called from inside CountlyHealthTracker's + // hcQueue, and CountlyInternalLog re-enters that class for Error and Warning levels, which + // would repopulate the very counters this write is persisting or clearing. + CLY_LOG_D(@"%s exception while storing the health check tracker state, exception name: [%@], reason: [%@]", __FUNCTION__, exception.name, exception.reason); } } +- (NSDate *)retrievePLCrashCaptureSuppressedSince +{ + id suppressedSince = [NSUserDefaults.standardUserDefaults objectForKey:kCountlyPLCrashCaptureSuppressedSinceKey]; + return [suppressedSince isKindOfClass:NSDate.class] ? suppressedSince : nil; +} + +- (void)storePLCrashCaptureSuppressedSince:(NSDate *)date +{ + if (date) + { + [NSUserDefaults.standardUserDefaults setObject:date forKey:kCountlyPLCrashCaptureSuppressedSinceKey]; + } + else + { + if (![NSUserDefaults.standardUserDefaults objectForKey:kCountlyPLCrashCaptureSuppressedSinceKey]) + return; + + [NSUserDefaults.standardUserDefaults removeObjectForKey:kCountlyPLCrashCaptureSuppressedSinceKey]; + } + + [NSUserDefaults.standardUserDefaults synchronize]; + CLY_LOG_D(@"%s automatic crash capture suppression is stored, suppressedSince: [%@]", __FUNCTION__, date); +} + ++ (void)removeStoredUserDefaults +{ + NSUserDefaults* defaults = NSUserDefaults.standardUserDefaults; + NSUInteger removedCount = 0; + for (NSString* key in defaults.dictionaryRepresentation.allKeys) + { + if (![key hasPrefix:@"kCountly"] || [key isEqualToString:kCountlyPLCrashCaptureSuppressedSinceKey]) + continue; + + [defaults removeObjectForKey:key]; + removedCount++; + } + + [defaults synchronize]; + CLY_LOG_D(@"%s the SDK's values are removed from the standard user defaults, removedCount: [%lu]", __FUNCTION__, (unsigned long)removedCount); +} + @end diff --git a/ios/src/CountlyPushNotifications.m b/ios/src/CountlyPushNotifications.m index 4b2cc319..608d3a26 100644 --- a/ios/src/CountlyPushNotifications.m +++ b/ios/src/CountlyPushNotifications.m @@ -61,11 +61,16 @@ - (instancetype)init #if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_OSX) - (void)startPushNotifications { + CLY_LOG_I(@"%s push notifications registration requested, enabledOnInitialConfig: [%@]", __FUNCTION__, self.isEnabledOnInitialConfig ? @"YES" : @"NO"); + if (!self.isEnabledOnInitialConfig) return; if (!CountlyConsentManager.sharedInstance.consentForPushNotifications) + { + CLY_LOG_V(@"%s no push consent given, remote notification registration is skipped", __FUNCTION__); return; + } UNUserNotificationCenter.currentNotificationCenter.delegate = self; @@ -82,6 +87,8 @@ - (void)startPushNotifications - (void)stopPushNotifications { + CLY_LOG_I(@"%s push notifications unregistration requested, enabledOnInitialConfig: [%@]", __FUNCTION__, self.isEnabledOnInitialConfig ? @"YES" : @"NO"); + if (!self.isEnabledOnInitialConfig) return; @@ -95,7 +102,10 @@ - (void)swizzlePushNotificationMethods { static BOOL alreadySwizzled; if (alreadySwizzled) + { + CLY_LOG_D(@"%s app delegate methods are already swizzled, skipping", __FUNCTION__); return; + } alreadySwizzled = YES; @@ -110,6 +120,7 @@ - (void)swizzlePushNotificationMethods { SEL originalSelector = NSSelectorFromString(selectorString); Method originalMethod = class_getInstanceMethod(appDelegateClass, originalSelector); + BOOL wasImplementedByHost = (originalMethod != NULL); if (originalMethod == NULL) { @@ -123,19 +134,31 @@ - (void)swizzlePushNotificationMethods SEL countlySelector = NSSelectorFromString([@"Countly_" stringByAppendingString:selectorString]); Method countlyMethod = class_getInstanceMethod(appDelegateClass, countlySelector); method_exchangeImplementations(originalMethod, countlyMethod); + + CLY_LOG_D(@"%s app delegate method swizzled, selector: [%@], wasImplementedByHost: [%@]", __FUNCTION__, selectorString, wasImplementedByHost ? @"YES" : @"NO"); } } - (void)askForNotificationPermissionWithOptions:(NSUInteger)options completionHandler:(void (^)(BOOL granted, NSError * error))completionHandler { + CLY_LOG_I(@"%s notification permission requested, options: [%lu], completionHandlerProvided: [%@]", __FUNCTION__, (unsigned long)options, (completionHandler != nil) ? @"YES" : @"NO"); + if (!CountlyConsentManager.sharedInstance.consentForPushNotifications) + { + CLY_LOG_V(@"%s no push consent given, notification permission is not requested", __FUNCTION__); return; + } if (options == 0) options = UNAuthorizationOptionBadge | UNAuthorizationOptionSound | UNAuthorizationOptionAlert; [UNUserNotificationCenter.currentNotificationCenter requestAuthorizationWithOptions:options completionHandler:^(BOOL granted, NSError* error) { + if (error) + CLY_LOG_E(@"%s notification permission request returned an error, granted: [%@], error: [%@]", __FUNCTION__, granted ? @"YES" : @"NO", error.localizedDescription); + else + CLY_LOG_I(@"%s notification permission result received, granted: [%@]", __FUNCTION__, granted ? @"YES" : @"NO"); + if (completionHandler) completionHandler(granted, error); @@ -146,19 +169,27 @@ - (void)askForNotificationPermissionWithOptions:(NSUInteger)options completionHa - (void)sendToken { if (!CountlyConsentManager.sharedInstance.consentForPushNotifications) + { + CLY_LOG_V(@"%s no push consent given, push token is not sent", __FUNCTION__); return; + } if (!self.token) + { + CLY_LOG_D(@"%s push token is not sent, no token has been received yet", __FUNCTION__); return; + } if ([self.token isEqualToString:kCountlyTokenError]) { + CLY_LOG_D(@"%s push token is in error state, an empty token will be sent to the server instead", __FUNCTION__); [self clearToken]; return; } if (self.sendPushTokenAlways) { + CLY_LOG_I(@"%s push token is being sent without checking the permission state, tokenLength: [%lu]", __FUNCTION__, (unsigned long)self.token.length); [CountlyConnectionManager.sharedInstance sendPushToken:self.token]; return; } @@ -175,26 +206,39 @@ - (void)sendToken if (settings.authorizationStatus == UNAuthorizationStatusAuthorized || hasProvisionalPermission) { + CLY_LOG_I(@"%s notification permission is granted, push token is being sent, tokenLength: [%lu], provisional: [%@]", __FUNCTION__, (unsigned long)self.token.length, hasProvisionalPermission ? @"YES" : @"NO"); [CountlyConnectionManager.sharedInstance sendPushToken:self.token]; [CountlyPersistency.sharedInstance storeNotificationPermission:YES]; } else if (hasNotificationPermissionBefore) { + CLY_LOG_I(@"%s notification permission has been revoked, the stored push token is being cleared, authorizationStatus: [%ld]", __FUNCTION__, (long)settings.authorizationStatus); [self clearToken]; [CountlyPersistency.sharedInstance storeNotificationPermission:NO]; } + else + { + CLY_LOG_D(@"%s notification permission is not granted and was not granted before, push token is not sent, authorizationStatus: [%ld]", __FUNCTION__, (long)settings.authorizationStatus); + } }]; } - (void)clearToken { + CLY_LOG_D(@"%s clearing the push token on the server by sending an empty token", __FUNCTION__); + [CountlyConnectionManager.sharedInstance sendPushToken:@""]; } - (void)openURL:(NSString *)URLString { if (!URLString) + { + CLY_LOG_V(@"%s no URL is attached to the notification action, nothing will be opened", __FUNCTION__); return; + } + + CLY_LOG_D(@"%s opening the URL attached to the notification action, URLLength: [%lu]", __FUNCTION__, (unsigned long)URLString.length); dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.01 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^ { @@ -208,8 +252,13 @@ - (void)openURL:(NSString *)URLString - (void)recordActionForNotification:(NSDictionary *)userInfo clickedButtonIndex:(NSInteger)buttonIndex { + CLY_LOG_I(@"%s notification action recording requested, buttonIndex: [%ld]", __FUNCTION__, (long)buttonIndex); + if (!CountlyConsentManager.sharedInstance.consentForPushNotifications) + { + CLY_LOG_V(@"%s no push consent given, notification action is not recorded", __FUNCTION__); return; + } NSDictionary* countlyPayload = userInfo[kCountlyPNKeyCountlyPayload]; NSString* notificationID = countlyPayload[kCountlyPNKeyNotificationID]; @@ -220,7 +269,12 @@ - (void)recordActionForNotification:(NSDictionary *)userInfo clickedButtonIndex: - (void)recordActionEvent:(NSString *)notificationID buttonIndex:(NSInteger)buttonIndex { if (!notificationID) + { + CLY_LOG_W(@"%s notification action event is dropped, the notification has no Countly notification ID", __FUNCTION__); return; + } + + CLY_LOG_D(@"%s notification action taken, notificationID: [%@], buttonIndex: [%ld]", __FUNCTION__, notificationID, (long)buttonIndex); NSString* platform = @"unknown"; #if (TARGET_OS_IOS || TARGET_OS_VISION) @@ -243,8 +297,8 @@ - (void)recordActionEvent:(NSString *)notificationID buttonIndex:(NSInteger)butt - (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler API_AVAILABLE(ios(10.0), macos(10.14)) { - CLY_LOG_D(@"userNotificationCenter:willPresentNotification:withCompletionHandler:"); - CLY_LOG_D(@"%@", notification.request.content.userInfo.description); + CLY_LOG_D(@"%s notification received while the app is in the foreground, doNotShowAlertForNotifications: [%@]", __FUNCTION__, self.doNotShowAlertForNotifications ? @"YES" : @"NO"); + CLY_LOG_D(@"%s foreground notification payload detail, userInfo: [%@]", __FUNCTION__, notification.request.content.userInfo); if (!self.doNotShowAlertForNotifications) { @@ -279,8 +333,8 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNot - (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void (^)(void))completionHandler API_AVAILABLE(ios(10.0), macos(10.14)) { - CLY_LOG_D(@"userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:"); - CLY_LOG_D(@"%@", response.notification.request.content.userInfo.description); + CLY_LOG_D(@"%s notification response received, actionIdentifier: [%@]", __FUNCTION__, response.actionIdentifier); + CLY_LOG_D(@"%s notification response payload detail, userInfo: [%@]", __FUNCTION__, response.notification.request.content.userInfo); if (CountlyConsentManager.sharedInstance.consentForPushNotifications) { @@ -292,7 +346,7 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNoti NSInteger buttonIndex = 0; NSString* URL = nil; - CLY_LOG_D(@"Action Identifier: %@", response.actionIdentifier); + CLY_LOG_D(@"%s resolving the tapped notification action, notificationID: [%@]", __FUNCTION__, notificationID); if ([response.actionIdentifier isEqualToString:UNNotificationDefaultActionIdentifier]) { @@ -341,7 +395,8 @@ @implementation NSObject (CountlyPushNotifications) #if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_OSX) - (void)Countly_application:(CLYApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken { - CLY_LOG_D(@"App didRegisterForRemoteNotificationsWithDeviceToken: %@", deviceToken); + CLY_LOG_I(@"%s push device token received from the system, tokenByteLength: [%lu]", __FUNCTION__, (unsigned long)deviceToken.length); + CLY_LOG_D(@"%s push device token detail, rawToken: [%@]", __FUNCTION__, deviceToken); const char* bytes = [deviceToken bytes]; NSMutableString *token = NSMutableString.new; @@ -357,7 +412,7 @@ - (void)Countly_application:(CLYApplication *)application didRegisterForRemoteNo - (void)Countly_application:(CLYApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error { - CLY_LOG_D(@"App didFailToRegisterForRemoteNotificationsWithError: %@", error); + CLY_LOG_E(@"%s remote notification registration failed, error: [%@]", __FUNCTION__, error.localizedDescription); CountlyPushNotifications.sharedInstance.token = kCountlyTokenError; diff --git a/ios/src/CountlyReactNative.m b/ios/src/CountlyReactNative.m index b38d79a2..ad9971c2 100644 --- a/ios/src/CountlyReactNative.m +++ b/ios/src/CountlyReactNative.m @@ -784,7 +784,7 @@ - (void) populateConfig:(id) json { RCT_REMAP_METHOD(setUserData, params : (NSArray *)arguments setUserDataWithResolver : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) { dispatch_async(dispatch_get_main_queue(), ^{ NSDictionary *userData = [arguments objectAtIndex:0]; - [self setUserDataIntenral:userData]; + [self setUserDataInternal:userData]; resolve(@"Success"); }); } @@ -1196,7 +1196,7 @@ + (BOOL)isEchoOfRecordedJSFatal:(CountlyCrashData *)crashData { NSString *keyName = [arguments objectAtIndex:0]; id keyValue = [arguments objectAtIndex:1]; - [self setCustomUserProperty:keyName value:keyValue]; + [self setUserProperty:keyName value:keyValue]; resolve(@"Success"); }); } @@ -1205,7 +1205,7 @@ + (BOOL)isEchoOfRecordedJSFatal:(CountlyCrashData *)crashData { dispatch_async(dispatch_get_main_queue(), ^{ NSString *keyName = [arguments objectAtIndex:0]; - [Countly.user increment:keyName]; + [Countly.sharedInstance.userProfile increment:keyName]; resolve(@"Success"); }); } @@ -1216,7 +1216,7 @@ + (BOOL)isEchoOfRecordedJSFatal:(CountlyCrashData *)crashData { NSString *keyValue = [arguments objectAtIndex:1]; int keyValueInteger = [keyValue intValue]; - [Countly.user incrementBy:keyName value:[NSNumber numberWithInt:keyValueInteger]]; + [Countly.sharedInstance.userProfile incrementBy:keyName value:[NSNumber numberWithInt:keyValueInteger]]; resolve(@"Success"); }); } @@ -1227,7 +1227,7 @@ + (BOOL)isEchoOfRecordedJSFatal:(CountlyCrashData *)crashData { NSString *keyValue = [arguments objectAtIndex:1]; int keyValueInteger = [keyValue intValue]; - [Countly.user multiply:keyName value:[NSNumber numberWithInt:keyValueInteger]]; + [Countly.sharedInstance.userProfile multiply:keyName value:[NSNumber numberWithInt:keyValueInteger]]; resolve(@"Success"); }); } @@ -1238,7 +1238,7 @@ + (BOOL)isEchoOfRecordedJSFatal:(CountlyCrashData *)crashData { NSString *keyValue = [arguments objectAtIndex:1]; int keyValueInteger = [keyValue intValue]; - [Countly.user max:keyName value:[NSNumber numberWithInt:keyValueInteger]]; + [Countly.sharedInstance.userProfile max:keyName value:[NSNumber numberWithInt:keyValueInteger]]; resolve(@"Success"); }); } @@ -1249,7 +1249,7 @@ + (BOOL)isEchoOfRecordedJSFatal:(CountlyCrashData *)crashData { NSString *keyValue = [arguments objectAtIndex:1]; int keyValueInteger = [keyValue intValue]; - [Countly.user min:keyName value:[NSNumber numberWithInt:keyValueInteger]]; + [Countly.sharedInstance.userProfile min:keyName value:[NSNumber numberWithInt:keyValueInteger]]; resolve(@"Success"); }); } @@ -1259,7 +1259,7 @@ + (BOOL)isEchoOfRecordedJSFatal:(CountlyCrashData *)crashData { NSString *keyName = [arguments objectAtIndex:0]; NSString *keyValue = [arguments objectAtIndex:1]; - [Countly.user setOnce:keyName value:keyValue]; + [Countly.sharedInstance.userProfile setOnce:keyName value:keyValue]; resolve(@"Success"); }); } @@ -1269,7 +1269,7 @@ + (BOOL)isEchoOfRecordedJSFatal:(CountlyCrashData *)crashData { NSString *keyName = [arguments objectAtIndex:0]; NSString *keyValue = [arguments objectAtIndex:1]; - [Countly.user pushUnique:keyName value:keyValue]; + [Countly.sharedInstance.userProfile pushUnique:keyName value:keyValue]; resolve(@"Success"); }); } @@ -1279,7 +1279,7 @@ + (BOOL)isEchoOfRecordedJSFatal:(CountlyCrashData *)crashData { NSString *keyName = [arguments objectAtIndex:0]; NSString *keyValue = [arguments objectAtIndex:1]; - [Countly.user push:keyName value:keyValue]; + [Countly.sharedInstance.userProfile push:keyName value:keyValue]; resolve(@"Success"); }); } @@ -1289,21 +1289,21 @@ + (BOOL)isEchoOfRecordedJSFatal:(CountlyCrashData *)crashData { NSString *keyName = [arguments objectAtIndex:0]; NSString *keyValue = [arguments objectAtIndex:1]; - [Countly.user pull:keyName value:keyValue]; + [Countly.sharedInstance.userProfile pull:keyName value:keyValue]; resolve(@"Success"); }); } RCT_REMAP_METHOD(userDataBulk_setUserProperties, params : (NSDictionary *)userProperties userDataBulkSetUserPropertiesWithResolver : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) { dispatch_async(dispatch_get_main_queue(), ^{ - [self setUserDataIntenral:userProperties]; + [self setUserDataInternal:userProperties]; resolve(@"Success"); }); } RCT_REMAP_METHOD(userDataBulk_save, params : (NSArray *)arguments userDataBulkSaveWithResolver : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) { dispatch_async(dispatch_get_main_queue(), ^{ - [Countly.user save]; + [Countly.sharedInstance.userProfile save]; resolve(@"Success"); }); } @@ -1313,7 +1313,7 @@ + (BOOL)isEchoOfRecordedJSFatal:(CountlyCrashData *)crashData { NSString *keyName = [arguments objectAtIndex:0]; id keyValue = [arguments objectAtIndex:1]; - [self setCustomUserProperty:keyName value:keyValue]; + [self setUserProperty:keyName value:keyValue]; resolve(@"Success"); }); } @@ -1322,7 +1322,7 @@ + (BOOL)isEchoOfRecordedJSFatal:(CountlyCrashData *)crashData { dispatch_async(dispatch_get_main_queue(), ^{ NSString *keyName = [arguments objectAtIndex:0]; - [Countly.user increment:keyName]; + [Countly.sharedInstance.userProfile increment:keyName]; resolve(@"Success"); }); } @@ -1333,7 +1333,7 @@ + (BOOL)isEchoOfRecordedJSFatal:(CountlyCrashData *)crashData { NSString *keyValue = [arguments objectAtIndex:1]; int keyValueInteger = [keyValue intValue]; - [Countly.user incrementBy:keyName value:[NSNumber numberWithInt:keyValueInteger]]; + [Countly.sharedInstance.userProfile incrementBy:keyName value:[NSNumber numberWithInt:keyValueInteger]]; resolve(@"Success"); }); } @@ -1344,7 +1344,7 @@ + (BOOL)isEchoOfRecordedJSFatal:(CountlyCrashData *)crashData { NSString *keyValue = [arguments objectAtIndex:1]; int keyValueInteger = [keyValue intValue]; - [Countly.user multiply:keyName value:[NSNumber numberWithInt:keyValueInteger]]; + [Countly.sharedInstance.userProfile multiply:keyName value:[NSNumber numberWithInt:keyValueInteger]]; resolve(@"Success"); }); } @@ -1355,7 +1355,7 @@ + (BOOL)isEchoOfRecordedJSFatal:(CountlyCrashData *)crashData { NSString *keyValue = [arguments objectAtIndex:1]; int keyValueInteger = [keyValue intValue]; - [Countly.user max:keyName value:[NSNumber numberWithInt:keyValueInteger]]; + [Countly.sharedInstance.userProfile max:keyName value:[NSNumber numberWithInt:keyValueInteger]]; resolve(@"Success"); }); } @@ -1366,7 +1366,7 @@ + (BOOL)isEchoOfRecordedJSFatal:(CountlyCrashData *)crashData { NSString *keyValue = [arguments objectAtIndex:1]; int keyValueInteger = [keyValue intValue]; - [Countly.user min:keyName value:[NSNumber numberWithInt:keyValueInteger]]; + [Countly.sharedInstance.userProfile min:keyName value:[NSNumber numberWithInt:keyValueInteger]]; resolve(@"Success"); }); } @@ -1376,7 +1376,7 @@ + (BOOL)isEchoOfRecordedJSFatal:(CountlyCrashData *)crashData { NSString *keyName = [arguments objectAtIndex:0]; NSString *keyValue = [arguments objectAtIndex:1]; - [Countly.user setOnce:keyName value:keyValue]; + [Countly.sharedInstance.userProfile setOnce:keyName value:keyValue]; resolve(@"Success"); }); } @@ -1385,7 +1385,7 @@ + (BOOL)isEchoOfRecordedJSFatal:(CountlyCrashData *)crashData { NSString *keyName = [arguments objectAtIndex:0]; NSString *keyValue = [arguments objectAtIndex:1]; - [Countly.user pushUnique:keyName value:keyValue]; + [Countly.sharedInstance.userProfile pushUnique:keyName value:keyValue]; resolve(@"Success"); }); } @@ -1394,7 +1394,7 @@ + (BOOL)isEchoOfRecordedJSFatal:(CountlyCrashData *)crashData { NSString *keyName = [arguments objectAtIndex:0]; NSString *keyValue = [arguments objectAtIndex:1]; - [Countly.user push:keyName value:keyValue]; + [Countly.sharedInstance.userProfile push:keyName value:keyValue]; resolve(@"Success"); }); } @@ -1404,7 +1404,7 @@ + (BOOL)isEchoOfRecordedJSFatal:(CountlyCrashData *)crashData { NSString *keyName = [arguments objectAtIndex:0]; NSString *keyValue = [arguments objectAtIndex:1]; - [Countly.user pull:keyName value:keyValue]; + [Countly.sharedInstance.userProfile pull:keyName value:keyValue]; resolve(@"Success"); }); } @@ -1979,129 +1979,48 @@ void CountlyRNInternalLog(NSString *format, ...) { va_end(args); } -- (NSDictionary *)removePredefinedUserProperties:(NSDictionary *__nullable)userData { - NSMutableDictionary *userProperties = [userData mutableCopy]; - NSArray *nameFields = [[NSArray alloc] initWithObjects:NAME_KEY, USERNAME_KEY, EMAIL_KEY, ORG_KEY, PHONE_KEY, PICTURE_KEY, PICTURE_PATH_KEY, GENDER_KEY, BYEAR_KEY, nil]; - - for (NSString *nameField in nameFields) { - [userProperties removeObjectForKey:nameField]; - } - return userProperties; -} - -- (BOOL)isBooleanNumber:(NSNumber *)numberValue { - return CFGetTypeID((__bridge CFTypeRef)numberValue) == CFBooleanGetTypeID(); -} - -- (NSMutableDictionary *)currentCustomUserProperties { - if ([Countly.user.custom isKindOfClass:[NSDictionary class]]) { - return [(NSDictionary *)Countly.user.custom mutableCopy]; +// Flattens the JS user data for the iOS SDK's user property API. The legacy nested "custom" dictionary is merged in, +// and the SDK then routes the predefined keys (name, username, email, organization, phone, gender, picture, +// picturePath, byear) to the top-level fields and keeps every other key as a custom property. Null values and +// unsupported types are skipped by the SDK with a log line, as the Android bridge does. +- (NSDictionary *)userPropertiesFromUserData:(NSDictionary *__nullable)userData { + if (![userData isKindOfClass:[NSDictionary class]]) { + return nil; } - return [NSMutableDictionary dictionary]; -} + NSMutableDictionary *userProperties = [userData mutableCopy]; + id explicitCustomProperties = userProperties[CUSTOM_KEY]; + [userProperties removeObjectForKey:CUSTOM_KEY]; -- (void)setCustomUserPropertyDictionaryValue:(id)value forKey:(NSString *)keyName { - if (keyName == nil || keyName.length == 0) { - return; + if ([explicitCustomProperties isKindOfClass:[NSDictionary class]]) { + [userProperties addEntriesFromDictionary:(NSDictionary *)explicitCustomProperties]; } - if (value == nil || [value isKindOfClass:[NSNull class]]) { - [Countly.user unSet:keyName]; - return; + if (userProperties.count == 0) { + return nil; } - NSMutableDictionary *customUserProperties = [self currentCustomUserProperties]; - customUserProperties[keyName] = value; - Countly.user.custom = [customUserProperties copy]; + return [userProperties copy]; } -- (void)setCustomUserProperty:(NSString *)keyName value:(id)value { - if ([value isKindOfClass:[NSString class]]) { - [Countly.user set:keyName value:(NSString *)value]; +- (void)setUserProperty:(NSString *)keyName value:(id)value { + if (![keyName isKindOfClass:[NSString class]] || keyName.length == 0) { + CountlyRNInternalLog(@"setUserProperty, the key is empty, skipping it"); return; } - if ([value isKindOfClass:[NSNumber class]]) { - NSNumber *numberValue = (NSNumber *)value; - if ([self isBooleanNumber:numberValue]) { - [Countly.user set:keyName boolValue:numberValue.boolValue]; - } else { - [Countly.user set:keyName numberValue:numberValue]; - } - return; - } - - [self setCustomUserPropertyDictionaryValue:value forKey:keyName]; -} - -- (NSDictionary *)customUserPropertiesFromUserData:(NSDictionary *__nullable)userData { - if (userData == nil) { - return nil; - } - - NSMutableDictionary *customUserProperties = [[self removePredefinedUserProperties:userData] mutableCopy]; - id explicitCustomProperties = customUserProperties[CUSTOM_KEY]; - [customUserProperties removeObjectForKey:CUSTOM_KEY]; - - if ([explicitCustomProperties isKindOfClass:[NSDictionary class]]) { - [customUserProperties addEntriesFromDictionary:(NSDictionary *)explicitCustomProperties]; - } - - if (customUserProperties.count == 0) { - return nil; - } - - return [customUserProperties copy]; + // The SDK validates the value (a string, a number, a bool or an array of those), truncates it and routes the key. + [Countly.sharedInstance.userProfile setProperty:keyName value:value]; } -- (void)setUserDataIntenral:(NSDictionary *__nullable)userData { - id name = userData[NAME_KEY]; - id username = userData[USERNAME_KEY]; - id email = userData[EMAIL_KEY]; - id organization = userData[ORG_KEY]; - id phone = userData[PHONE_KEY]; - id picture = userData[PICTURE_KEY]; - id picturePath = userData[PICTURE_PATH_KEY]; - id gender = userData[GENDER_KEY]; - id byear = userData[BYEAR_KEY]; - - if (name) { - Countly.user.name = name; - } - if (username) { - Countly.user.username = username; - } - if (email) { - Countly.user.email = email; - } - if (organization) { - Countly.user.organization = organization; - } - if (phone) { - Countly.user.phone = phone; - } - if (picture) { - Countly.user.pictureURL = picture; - } - if (picturePath) { - Countly.user.pictureLocalPath = picturePath; - } - if (gender) { - Countly.user.gender = gender; - } - if (byear) { - if ([byear isKindOfClass:[NSNull class]]) { - Countly.user.birthYear = byear; - } else { - Countly.user.birthYear = @([byear integerValue]); - } +- (void)setUserDataInternal:(NSDictionary *__nullable)userData { + NSDictionary *userProperties = [self userPropertiesFromUserData:userData]; + if (userProperties == nil) { + CountlyRNInternalLog(@"setUserData, no user properties were given, skipping it"); + return; } - NSDictionary *customUserProperties = [self customUserPropertiesFromUserData:userData]; - if (customUserProperties != nil) { - Countly.user.custom = customUserProperties; - } + [Countly.sharedInstance.userProfile setProperties:userProperties]; } @end diff --git a/ios/src/CountlyRemoteConfig.m b/ios/src/CountlyRemoteConfig.m index 45e45c9b..3053e02b 100644 --- a/ios/src/CountlyRemoteConfig.m +++ b/ios/src/CountlyRemoteConfig.m @@ -31,115 +31,116 @@ - (instancetype)init - (NSDictionary *)testingGetAllVariants { - CLY_LOG_I(@"%s", __FUNCTION__); + CLY_LOG_I(@"%s all locally cached variants are requested", __FUNCTION__); return [CountlyRemoteConfigInternal.sharedInstance testingGetAllVariants]; } - (NSArray *)testingGetVariantsForKey:(NSString *)key { - CLY_LOG_I(@"%s %@", __FUNCTION__, key); + CLY_LOG_I(@"%s locally cached variants are requested, key: [%@]", __FUNCTION__, key); return [CountlyRemoteConfigInternal.sharedInstance testingGetVariantsForKey:key]; } - (void)testingEnrollIntoVariant:(NSString *)key variantName:(NSString *)variantName completionHandler:(RCVariantCallback)completionHandler { - CLY_LOG_I(@"%s %@ %@ %@", __FUNCTION__, key, variantName , completionHandler); + CLY_LOG_I(@"%s variant enrollment is requested, key: [%@], variantName: [%@], completionHandlerProvided: [%@]", __FUNCTION__, key, variantName, (completionHandler != nil) ? @"YES" : @"NO"); [CountlyRemoteConfigInternal.sharedInstance testingEnrollIntoVariant:key variantName:variantName completionHandler:completionHandler]; } - (void)testingDownloadVariantInformation:(RCVariantCallback)completionHandler { - CLY_LOG_I(@"%s %@", __FUNCTION__, completionHandler); + CLY_LOG_I(@"%s variant information download is requested, completionHandlerProvided: [%@]", __FUNCTION__, (completionHandler != nil) ? @"YES" : @"NO"); [CountlyRemoteConfigInternal.sharedInstance testingDownloadAllVariants:completionHandler]; } - (CountlyRCData *)getValue:(NSString *)key { - CLY_LOG_I(@"%s %@", __FUNCTION__, key); + CLY_LOG_I(@"%s remote config value is requested, key: [%@]", __FUNCTION__, key); return [CountlyRemoteConfigInternal.sharedInstance getValue:key]; } - (NSDictionary *)getAllValues { - CLY_LOG_I(@"%s", __FUNCTION__); + CLY_LOG_I(@"%s all remote config values are requested", __FUNCTION__); return [CountlyRemoteConfigInternal.sharedInstance getAllValues]; } - (CountlyRCData *)getValueAndEnroll:(NSString *)key { - CLY_LOG_I(@"%s %@", __FUNCTION__, key); + CLY_LOG_I(@"%s remote config value is requested with AB test enrollment, key: [%@]", __FUNCTION__, key); return [CountlyRemoteConfigInternal.sharedInstance getValueAndEnroll:key]; } - (NSDictionary *)getAllValuesAndEnroll { - CLY_LOG_I(@"%s", __FUNCTION__); + CLY_LOG_I(@"%s all remote config values are requested with AB test enrollment", __FUNCTION__); return [CountlyRemoteConfigInternal.sharedInstance getAllValuesAndEnroll]; } - (void)enrollIntoABTestsForKeys:(NSArray *)keys { - CLY_LOG_I(@"%s %@", __FUNCTION__, keys); + CLY_LOG_I(@"%s AB test enrollment is requested, keys: [%@], keyCount: [%lu]", __FUNCTION__, keys, (unsigned long)keys.count); [CountlyRemoteConfigInternal.sharedInstance enrollIntoABTestsForKeys:keys]; } - (void)exitABTestsForKeys:(NSArray *)keys { - CLY_LOG_I(@"%s %@", __FUNCTION__, keys); + CLY_LOG_I(@"%s AB test exit is requested, keys: [%@], keyCount: [%lu]", __FUNCTION__, keys, (unsigned long)keys.count); [CountlyRemoteConfigInternal.sharedInstance exitABTestsForKeys:keys]; } -(void)registerDownloadCallback:(RCDownloadCallback) callback { - CLY_LOG_I(@"%s %@", __FUNCTION__, callback); + CLY_LOG_I(@"%s download callback registration is requested, callbackProvided: [%@]", __FUNCTION__, (callback != nil) ? @"YES" : @"NO"); [CountlyRemoteConfigInternal.sharedInstance registerDownloadCallback:callback]; } -(void)removeDownloadCallback:(RCDownloadCallback) callback { - CLY_LOG_I(@"%s %@", __FUNCTION__, callback); + CLY_LOG_I(@"%s download callback removal is requested, callbackProvided: [%@]", __FUNCTION__, (callback != nil) ? @"YES" : @"NO"); [CountlyRemoteConfigInternal.sharedInstance removeDownloadCallback:callback]; } - (void)downloadKeys:(RCDownloadCallback)completionHandler { - CLY_LOG_I(@"%s %@", __FUNCTION__, completionHandler); + CLY_LOG_I(@"%s download of all remote config keys is requested, completionHandlerProvided: [%@]", __FUNCTION__, (completionHandler != nil) ? @"YES" : @"NO"); [CountlyRemoteConfigInternal.sharedInstance downloadValuesForKeys:nil omitKeys:nil completionHandler:completionHandler]; } - (void)downloadSpecificKeys:(NSArray *)keys completionHandler:(RCDownloadCallback)completionHandler { - CLY_LOG_I(@"%s %@ %@", __FUNCTION__, keys, completionHandler); + CLY_LOG_I(@"%s download of specific remote config keys is requested, keys: [%@], keyCount: [%lu], completionHandlerProvided: [%@]", __FUNCTION__, keys, (unsigned long)keys.count, (completionHandler != nil) ? @"YES" : @"NO"); [CountlyRemoteConfigInternal.sharedInstance downloadValuesForKeys:keys omitKeys:nil completionHandler:completionHandler]; } - (void)downloadOmittingKeys:(NSArray *)omitKeys completionHandler:(RCDownloadCallback)completionHandler { - CLY_LOG_I(@"%s %@ %@", __FUNCTION__, omitKeys, completionHandler); + CLY_LOG_I(@"%s download omitting remote config keys is requested, omitKeys: [%@], omitKeyCount: [%lu], completionHandlerProvided: [%@]", __FUNCTION__, omitKeys, (unsigned long)omitKeys.count, (completionHandler != nil) ? @"YES" : @"NO"); [CountlyRemoteConfigInternal.sharedInstance downloadValuesForKeys:nil omitKeys:omitKeys completionHandler:completionHandler]; } - (void) testingDownloadExperimentInformation:(RCVariantCallback)completionHandler; { - CLY_LOG_I(@"%s %@", __FUNCTION__, completionHandler); + CLY_LOG_I(@"%s experiment information download is requested, completionHandlerProvided: [%@]", __FUNCTION__, (completionHandler != nil) ? @"YES" : @"NO"); [CountlyRemoteConfigInternal.sharedInstance testingDownloadExperimentInformation:completionHandler]; } - (NSDictionary *) testingGetAllExperimentInfo { - CLY_LOG_I(@"%s", __FUNCTION__); + CLY_LOG_I(@"%s all locally cached experiment information is requested", __FUNCTION__); return [CountlyRemoteConfigInternal.sharedInstance testingGetAllExperimentInfo]; } - (void)clearAll { + CLY_LOG_I(@"%s clearing all remote config values is requested", __FUNCTION__); [CountlyRemoteConfigInternal.sharedInstance clearAll]; } diff --git a/ios/src/CountlyRemoteConfigInternal.h b/ios/src/CountlyRemoteConfigInternal.h index 137f1c07..1aaa6742 100644 --- a/ios/src/CountlyRemoteConfigInternal.h +++ b/ios/src/CountlyRemoteConfigInternal.h @@ -11,16 +11,14 @@ @property (nonatomic) BOOL isRCAutomaticTriggersEnabled; @property (nonatomic) BOOL isRCValueCachingEnabled; @property (nonatomic) BOOL enrollABOnRCDownload; -@property (nonatomic, copy) void (^remoteConfigCompletionHandler)(NSError * error); @property (nonatomic) NSMutableArray *remoteConfigGlobalCallbacks; + (instancetype)sharedInstance; +/// Forgets this instance, so the next start reads the stored values again and responses to earlier requests are ignored. +- (void)resetInstance; -- (void)startRemoteConfig; - (void)clearAll; - (void)clearCachedRemoteConfig; -- (id)remoteConfigValueForKey:(NSString *)key; -- (void)updateRemoteConfigForKeys:(NSArray *)keys omitKeys:(NSArray *)omitKeys completionHandler:(void (^)(NSError * error))completionHandler; - (void)downloadRemoteConfigAutomatically; diff --git a/ios/src/CountlyRemoteConfigInternal.m b/ios/src/CountlyRemoteConfigInternal.m index 5e0aee79..1b21dc6e 100644 --- a/ios/src/CountlyRemoteConfigInternal.m +++ b/ios/src/CountlyRemoteConfigInternal.m @@ -6,7 +6,6 @@ #import "CountlyCommon.h" -NSString* const kCountlyRCKeyFetchRemoteConfig = @"fetch_remote_config"; NSString* const kCountlyRCKeyFetchVariant = @"ab_fetch_variants"; NSString* const kCountlyRCKeyEnrollVariant = @"ab_enroll_variant"; NSString* const kCountlyRCKeyFetchExperiments = @"ab_fetch_experiments"; @@ -31,17 +30,40 @@ @interface CountlyRemoteConfigInternal () @implementation CountlyRemoteConfigInternal +static CountlyRemoteConfigInternal* s_sharedInstance = nil; +static dispatch_once_t onceToken; + + (instancetype)sharedInstance { if (!CountlyCommon.sharedInstance.hasStarted) return nil; - static CountlyRemoteConfigInternal* s_sharedInstance = nil; - static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{s_sharedInstance = self.new;}); return s_sharedInstance; } +- (void)resetInstance +{ + CLY_LOG_I(@"%s", __FUNCTION__); + onceToken = 0; + s_sharedInstance = nil; +} + +/// Hands a response to its completion handler on the main thread, unless the SDK was halted after the request was made. +- (void)deliverOnMainUnlessHalted:(dispatch_block_t)delivery +{ + dispatch_async(dispatch_get_main_queue(), ^ + { + if (self != s_sharedInstance) + { + CLY_LOG_D(@"%s a response arrived after halt, it is ignored", __FUNCTION__); + return; + } + + delivery(); + }); +} + - (instancetype)init { if (self = [super init]) @@ -50,6 +72,8 @@ - (instancetype)init if(!self.cachedRemoteConfig) { self.cachedRemoteConfig = NSMutableDictionary.new; } + + CLY_LOG_D(@"%s remote config cache is loaded from persistency, cachedKeyCount: [%lu]", __FUNCTION__, (unsigned long)self.cachedRemoteConfig.count); self.remoteConfigGlobalCallbacks = [[NSMutableArray alloc] init]; @@ -61,112 +85,39 @@ - (instancetype)init #pragma mark --- -- (void)startRemoteConfig -{ - if (!self.isRCAutomaticTriggersEnabled) - return; - - if (!CountlyConsentManager.sharedInstance.consentForRemoteConfig) - return; - - if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) - return; - - CLY_LOG_D(@"Fetching remote config on start..."); - - [self fetchRemoteConfigForKeys:nil omitKeys:nil isLegacy:NO completionHandler:^(NSDictionary *remoteConfig, NSError *error) - { - if (!error) - { - CLY_LOG_D(@"%s, Fetching remote config on start is successful. %@", __FUNCTION__, remoteConfig); - self.cachedRemoteConfig = [self createRCMeta:remoteConfig]; - [CountlyPersistency.sharedInstance storeRemoteConfig:self.cachedRemoteConfig]; - - } - else - { - CLY_LOG_W(@"Fetching remote config on start failed: %@", error); - } - - if (self.remoteConfigCompletionHandler) - self.remoteConfigCompletionHandler(error); - }]; -} - - (void)downloadRemoteConfigAutomatically { if (!self.isRCAutomaticTriggersEnabled) + { + CLY_LOG_D(@"%s automatic triggers are disabled, automatic remote config download will be skipped", __FUNCTION__); return; - + } + if (!CountlyConsentManager.sharedInstance.consentForRemoteConfig) + { + CLY_LOG_V(@"%s no consent for remote config, automatic download will be skipped", __FUNCTION__); return; - + } + if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) + { + CLY_LOG_D(@"%s device ID is in temporary mode, automatic remote config download will be skipped", __FUNCTION__); return; - - CLY_LOG_D(@"Fetching remote config on start..."); - + } + + CLY_LOG_D(@"%s automatic remote config download is starting, fetchType: [full]", __FUNCTION__); + [self downloadValuesForKeys:nil omitKeys:nil completionHandler:^(CLYRequestResult _Nonnull response, NSError * _Nonnull error, BOOL fullValueUpdate, NSDictionary * _Nonnull downloadedValues) { - if (self.remoteConfigCompletionHandler) - self.remoteConfigCompletionHandler(error); + CLY_LOG_D(@"%s automatic remote config download finished, response: [%@], fullValueUpdate: [%@], downloadedValueCount: [%lu]", __FUNCTION__, response, fullValueUpdate ? @"YES" : @"NO", (unsigned long)downloadedValues.count); + CLY_LOG_D(@"%s automatic remote config download payload detail, downloadedValues: [%@]", __FUNCTION__, downloadedValues); }]; } -- (void)updateRemoteConfigForKeys:(NSArray *)keys omitKeys:(NSArray *)omitKeys completionHandler:(void (^)(NSError * error))completionHandler -{ - if (!CountlyConsentManager.sharedInstance.consentForRemoteConfig) - return; - - if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) - return; - - CLY_LOG_D(@"Fetching remote config manually..."); - - [self fetchRemoteConfigForKeys:keys omitKeys:omitKeys isLegacy:YES completionHandler:^(NSDictionary *remoteConfig, NSError *error) - { - if (!error) - { - CLY_LOG_D(@"%s, Fetching remote config manually is successful. %@", __FUNCTION__, remoteConfig); - NSDictionary* remoteConfigMeta = [self createRCMeta:remoteConfig]; - if (!keys && !omitKeys) - { - self.cachedRemoteConfig = remoteConfigMeta; - } - else - { - NSMutableDictionary* partiallyUpdatedRemoteConfigMeta = self.cachedRemoteConfig.mutableCopy; - [partiallyUpdatedRemoteConfigMeta addEntriesFromDictionary:remoteConfigMeta]; - self.cachedRemoteConfig = [NSDictionary dictionaryWithDictionary:partiallyUpdatedRemoteConfigMeta]; - } - - [CountlyPersistency.sharedInstance storeRemoteConfig:self.cachedRemoteConfig]; - - - } - else - { - CLY_LOG_W(@"Fetching remote config manually failed: %@", error); - } - - if (completionHandler) - completionHandler(error); - }]; -} - -- (id)remoteConfigValueForKey:(NSString *)key -{ - CountlyRCData* countlyRCValue = self.cachedRemoteConfig[key]; - if (countlyRCValue) { - return countlyRCValue.value; - } - return nil; -} - - (void)clearCachedRemoteConfig { - CLY_LOG_D(@"'clearCachedRemoteConfig' will cache or erase all remote config values."); + CLY_LOG_D(@"%s cached remote config values will be [%@], valueCachingEnabled: [%@], cachedKeyCount: [%lu]", __FUNCTION__, self.isRCValueCachingEnabled ? @"marked as stale" : @"erased", self.isRCValueCachingEnabled ? @"YES" : @"NO", (unsigned long)self.cachedRemoteConfig.count); if (!self.isRCValueCachingEnabled) { [self clearAll]; @@ -179,33 +130,48 @@ - (void)clearCachedRemoteConfig -(void)clearAll { - CLY_LOG_D(@"'clearAll' will erase all remote config values."); + CLY_LOG_D(@"%s all remote config values will be erased, clearedKeyCount: [%lu]", __FUNCTION__, (unsigned long)self.cachedRemoteConfig.count); self.cachedRemoteConfig = NSMutableDictionary.new; [CountlyPersistency.sharedInstance storeRemoteConfig:self.cachedRemoteConfig]; } #pragma mark --- -- (void)fetchRemoteConfigForKeys:(NSArray *)keys omitKeys:(NSArray *)omitKeys isLegacy:(BOOL)isLegacy completionHandler:(void (^)(NSDictionary* remoteConfig, NSError * error))completionHandler +- (void)fetchRemoteConfigForKeys:(NSArray *)keys omitKeys:(NSArray *)omitKeys completionHandler:(void (^)(NSDictionary* remoteConfig, NSError * error))completionHandler { if (!CountlyServerConfig.sharedInstance.networkingEnabled) { - CLY_LOG_D(@"'fetchRemoteConfigForKeys' is aborted: SDK Networking is disabled from server config!"); + CLY_LOG_D(@"%s remote config fetch is aborted, reason: [networking is disabled from server config]", __FUNCTION__); + return; + } + if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) + { + CLY_LOG_D(@"%s remote config fetch is aborted, reason: [device ID is in temporary mode]", __FUNCTION__); return; } if (!completionHandler) + { + CLY_LOG_E(@"%s remote config fetch is aborted, reason: [completionHandler is not provided]", __FUNCTION__); return; - - NSURLRequest* request = [self remoteConfigRequestForKeys:keys omitKeys:omitKeys isLegacy:isLegacy]; - NSURLSessionTask* task = [CountlyCommon.sharedInstance.URLSession dataTaskWithRequest:request completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) + } + + NSURLRequest* request = [self remoteConfigRequestForKeys:keys omitKeys:omitKeys]; + NSURLSessionTask* task = [CountlyCommon.sharedInstance immediateDataTaskWithRequest:request completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { + // IMMEDIATE REQUEST to find them better in search NSDictionary* remoteConfig = nil; if (!error) { remoteConfig = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; } - + + if (!error && ![remoteConfig isKindOfClass:NSDictionary.class]) + { + remoteConfig = nil; + error = [NSError errorWithDomain:kCountlyErrorDomain code:CLYErrorRemoteConfigGeneralAPIError userInfo:@{NSLocalizedDescriptionKey: @"Remote config response is not a JSON object"}]; + } + if (!error) { if (((NSHTTPURLResponse*)response).statusCode != 200) @@ -218,35 +184,34 @@ - (void)fetchRemoteConfigForKeys:(NSArray *)keys omitKeys:(NSArray *)omitKeys i if (error) { - CLY_LOG_D(@"%s, Remote Config Request:[ %p ] failed! error:[ %@ ]", __FUNCTION__, request, error); + CLY_LOG_D(@"%s remote config request failed, stage: [%@], statusCode: [%ld], error: [%@]", __FUNCTION__, data ? (remoteConfig ? @"API response" : @"JSON parsing") : @"network", (long)((NSHTTPURLResponse*)response).statusCode, error.localizedDescription); - dispatch_async(dispatch_get_main_queue(), ^ - { + [self deliverOnMainUnlessHalted:^{ completionHandler(nil, error); - }); + }]; return; } - CLY_LOG_D(@"Remote Config Request <%p> successfully completed.", request); + CLY_LOG_D(@"%s remote config request successfully completed, statusCode: [%ld], keys: [%@], valueCount: [%lu]", __FUNCTION__, (long)((NSHTTPURLResponse*)response).statusCode, remoteConfig.allKeys, (unsigned long)remoteConfig.count); + CLY_LOG_D(@"%s remote config response payload detail, remoteConfig: [%@]", __FUNCTION__, remoteConfig); - dispatch_async(dispatch_get_main_queue(), ^ - { + [self deliverOnMainUnlessHalted:^{ completionHandler(remoteConfig, nil); - }); + }]; }]; [task resume]; - CLY_LOG_D(@"%s, Remote Config Request <%p> started: [%@] %@", __FUNCTION__, (id)request, request.HTTPMethod, request.URL.absoluteString); + CLY_LOG_D(@"%s remote config request is started, method: [%@], path: [%@], keyCount: [%lu], omitKeyCount: [%lu]", __FUNCTION__, request.HTTPMethod, request.URL.path, (unsigned long)keys.count, (unsigned long)omitKeys.count); + CLY_LOG_D(@"%s remote config request URL detail, url: [%@]", __FUNCTION__, request.URL.absoluteString); } -- (NSURLRequest *)remoteConfigRequestForKeys:(NSArray *)keys omitKeys:(NSArray *)omitKeys isLegacy:(BOOL)isLegacy +- (NSURLRequest *)remoteConfigRequestForKeys:(NSArray *)keys omitKeys:(NSArray *)omitKeys { NSString* queryString = [CountlyConnectionManager.sharedInstance queryEssentials]; - queryString = [queryString stringByAppendingFormat:@"&%@=%@", kCountlyQSKeyMethod, - isLegacy ? kCountlyRCKeyFetchRemoteConfig : kCountlyRCKeyRC]; + queryString = [queryString stringByAppendingFormat:@"&%@=%@", kCountlyQSKeyMethod, kCountlyRCKeyRC]; if (keys) { @@ -293,6 +258,7 @@ - (NSURLRequest *)remoteConfigRequestForKeys:(NSArray *)keys omitKeys:(NSArray * - (CountlyRCData *)getValue:(NSString *)key { CountlyRCData *countlyRCData = self.cachedRemoteConfig[key]; + CLY_LOG_D(@"%s remote config value is read from cache, key: [%@], valueExists: [%@]", __FUNCTION__, key, (countlyRCData != nil) ? @"YES" : @"NO"); if (!countlyRCData) { countlyRCData = [[CountlyRCData alloc] initWithValue:nil isCurrentUsersData:YES]; } @@ -301,6 +267,7 @@ - (CountlyRCData *)getValue:(NSString *)key - (NSDictionary *)getAllValues { + CLY_LOG_D(@"%s all remote config values are read from cache, cachedKeyCount: [%lu]", __FUNCTION__, (unsigned long)self.cachedRemoteConfig.count); return self.cachedRemoteConfig; } @@ -308,10 +275,11 @@ - (CountlyRCData *)getValueAndEnroll:(NSString *)key { CountlyRCData *countlyRCData = [self getValue:key]; if (countlyRCData.value) { + CLY_LOG_D(@"%s a value exists for the key, AB test enrollment will be requested, key: [%@]", __FUNCTION__, key); [self enrollIntoABTestsForKeys:@[key]]; } else { - CLY_LOG_D(@"No value exists against key: %@ to enroll in AB testing", key); + CLY_LOG_D(@"%s no value exists for the key, AB test enrollment will be skipped, key: [%@]", __FUNCTION__, key); } return countlyRCData; } @@ -319,10 +287,11 @@ - (CountlyRCData *)getValueAndEnroll:(NSString *)key - (NSDictionary *)getAllValuesAndEnroll { if (self.cachedRemoteConfig && self.cachedRemoteConfig.count > 0) { + CLY_LOG_D(@"%s AB test enrollment will be requested for all cached keys, cachedKeyCount: [%lu]", __FUNCTION__, (unsigned long)self.cachedRemoteConfig.count); [self enrollIntoABTestsForKeys: self.cachedRemoteConfig.allKeys]; } else { - CLY_LOG_D(@"No values exists to enroll in AB testing..."); + CLY_LOG_D(@"%s no cached remote config values exist, AB test enrollment for all keys will be skipped", __FUNCTION__); } return self.cachedRemoteConfig; } @@ -330,13 +299,19 @@ - (CountlyRCData *)getValueAndEnroll:(NSString *)key - (void)enrollIntoABTestsForKeys:(NSArray *)keys { if (!CountlyConsentManager.sharedInstance.consentForRemoteConfig) + { + CLY_LOG_V(@"%s no consent for remote config, AB test enrollment will be skipped", __FUNCTION__); return; - + } + if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) + { + CLY_LOG_D(@"%s device ID is in temporary mode, AB test enrollment will be skipped", __FUNCTION__); return; - - - CLY_LOG_D(@"Entolling in AB Tests..."); + } + + + CLY_LOG_D(@"%s AB test enrollment request will be sent, keys: [%@], keyCount: [%lu]", __FUNCTION__, keys, (unsigned long)keys.count); [CountlyConnectionManager.sharedInstance sendEnrollABRequestForKeys:keys]; } @@ -344,13 +319,19 @@ - (void)enrollIntoABTestsForKeys:(NSArray *)keys - (void)exitABTestsForKeys:(NSArray *)keys { if (!CountlyConsentManager.sharedInstance.consentForRemoteConfig) + { + CLY_LOG_V(@"%s no consent for remote config, AB test exit will be skipped", __FUNCTION__); return; - + } + if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) + { + CLY_LOG_D(@"%s device ID is in temporary mode, AB test exit will be skipped", __FUNCTION__); return; - - - CLY_LOG_D(@"Exiting AB Tests..."); + } + + + CLY_LOG_D(@"%s AB test exit request will be sent, keys: [%@], keyCount: [%lu]", __FUNCTION__, keys, (unsigned long)keys.count); [CountlyConnectionManager.sharedInstance sendExitABRequestForKeys:keys]; } @@ -358,47 +339,67 @@ - (void)exitABTestsForKeys:(NSArray *)keys - (void)downloadValuesForKeys:(NSArray *)keys omitKeys:(NSArray *)omitKeys completionHandler:(RCDownloadCallback)completionHandler { if (!CountlyConsentManager.sharedInstance.consentForRemoteConfig) + { + CLY_LOG_V(@"%s no consent for remote config, value download will be skipped", __FUNCTION__); return; - + } + if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) + { + CLY_LOG_D(@"%s device ID is in temporary mode, remote config value download will be skipped", __FUNCTION__); return; - - CLY_LOG_D(@"Fetching remote config..."); - - [self fetchRemoteConfigForKeys:keys omitKeys:omitKeys isLegacy:NO completionHandler:^(NSDictionary *remoteConfig, NSError *error) + } + + CLY_LOG_D(@"%s remote config value download is starting, requestKind: [%@], fetchType: [%@], keys: [%@], keyCount: [%lu], omitKeys: [%@], omitKeyCount: [%lu], completionHandlerProvided: [%@]", __FUNCTION__, kCountlyRCKeyRC, (!keys && !omitKeys) ? @"full" : @"partial", keys, (unsigned long)keys.count, omitKeys, (unsigned long)omitKeys.count, (completionHandler != nil) ? @"YES" : @"NO"); + + NSString* requestedDeviceID = CountlyDeviceInfo.sharedInstance.deviceID; + [self fetchRemoteConfigForKeys:keys omitKeys:omitKeys completionHandler:^(NSDictionary *remoteConfig, NSError *error) { + // an answer for the user before a device ID change would otherwise be stored as the values of the next user + if (!error && ![CountlyDeviceInfo.sharedInstance.deviceID isEqualToString:requestedDeviceID]) + { + CLY_LOG_D(@"%s remote config values arrived for a device ID that changed during the download, they are discarded", __FUNCTION__); + remoteConfig = nil; + error = [NSError errorWithDomain:kCountlyErrorDomain code:CLYErrorRemoteConfigGeneralAPIError userInfo:@{NSLocalizedDescriptionKey: @"Remote config values are for the device ID before the change"}]; + } + BOOL fullValueUpdate = false; NSDictionary* remoteConfigMeta = remoteConfig ? [self createRCMeta:remoteConfig] : @{}; CLYRequestResult requestResult = CLYResponseSuccess; if (!error) { - CLY_LOG_D(@"%s, fetching remote config is successful. %@", __FUNCTION__, remoteConfig); + CLY_LOG_D(@"%s remote config value download is successful, keys: [%@], valueCount: [%lu]", __FUNCTION__, remoteConfig.allKeys, (unsigned long)remoteConfig.count); + CLY_LOG_D(@"%s remote config value download payload detail, remoteConfig: [%@]", __FUNCTION__, remoteConfig); if (!keys && !omitKeys) { fullValueUpdate = true; self.cachedRemoteConfig = remoteConfigMeta; + CLY_LOG_D(@"%s remote config cache is replaced on full download, cachedKeyCount: [%lu]", __FUNCTION__, (unsigned long)self.cachedRemoteConfig.count); } else { NSMutableDictionary* partiallyUpdatedRemoteConfigMeta = self.cachedRemoteConfig.mutableCopy; [partiallyUpdatedRemoteConfigMeta addEntriesFromDictionary:remoteConfigMeta]; self.cachedRemoteConfig = [NSDictionary dictionaryWithDictionary:partiallyUpdatedRemoteConfigMeta]; + CLY_LOG_D(@"%s remote config cache is merged on partial download, mergedKeyCount: [%lu], cachedKeyCount: [%lu]", __FUNCTION__, (unsigned long)remoteConfigMeta.count, (unsigned long)self.cachedRemoteConfig.count); } - + [CountlyPersistency.sharedInstance storeRemoteConfig:self.cachedRemoteConfig]; - - + + } else { requestResult = CLYResponseError; - CLY_LOG_W(@"Fetching remote config failed: %@", error); + CLY_LOG_D(@"%s remote config value download failed, error: [%@]", __FUNCTION__, error.localizedDescription); } - + if (completionHandler) completionHandler(requestResult, error, fullValueUpdate, remoteConfigMeta); - - + + CLY_LOG_D(@"%s registered download callbacks will be notified, callbackCount: [%lu], response: [%@], fullValueUpdate: [%@], downloadedValueCount: [%lu]", __FUNCTION__, (unsigned long)self.remoteConfigGlobalCallbacks.count, requestResult, fullValueUpdate ? @"YES" : @"NO", (unsigned long)remoteConfigMeta.count); + CLY_LOG_D(@"%s download callback notification payload detail, remoteConfigMeta: [%@]", __FUNCTION__, remoteConfigMeta); + [self.remoteConfigGlobalCallbacks enumerateObjectsUsingBlock:^(RCDownloadCallback callback, NSUInteger idx, BOOL * stop) { callback(requestResult, error, fullValueUpdate, remoteConfigMeta); @@ -416,13 +417,13 @@ - (NSDictionary *) createRCMeta:(NSDictionary *) remoteConfig remoteConfigMeta[key] = [[CountlyRCData alloc] initWithValue:value isCurrentUsersData:YES]; }]; - + return remoteConfigMeta; } - (void)updateMetaStateToCache { - CLY_LOG_D(@"'updateMetaStateToCache' will cache all remote config values."); + CLY_LOG_D(@"%s all cached remote config values will be marked as not belonging to the current user, cachedKeyCount: [%lu]", __FUNCTION__, (unsigned long)self.cachedRemoteConfig.count); [self.cachedRemoteConfig enumerateKeysAndObjectsUsingBlock:^(NSString * key, CountlyRCData * countlyRCMeta, BOOL * stop) { countlyRCMeta.isCurrentUsersData = NO; @@ -435,19 +436,23 @@ - (void)updateMetaStateToCache -(void)registerDownloadCallback:(RCDownloadCallback) callback { [self.remoteConfigGlobalCallbacks addObject:callback]; + CLY_LOG_D(@"%s download callback is registered, callbackCount: [%lu]", __FUNCTION__, (unsigned long)self.remoteConfigGlobalCallbacks.count); } -(void)removeDownloadCallback:(RCDownloadCallback) callback { [self.remoteConfigGlobalCallbacks removeObject:callback]; + CLY_LOG_D(@"%s download callback is removed, callbackCount: [%lu]", __FUNCTION__, (unsigned long)self.remoteConfigGlobalCallbacks.count); } - (NSDictionary *)testingGetAllVariants { + CLY_LOG_D(@"%s all locally cached variants are read, variantKeyCount: [%lu]", __FUNCTION__, (unsigned long)self.localCachedVariants.count); return self.localCachedVariants; } - (NSDictionary *)testingGetVariantsForKey:(NSString *)key { + CLY_LOG_D(@"%s locally cached variants for a single key are read, key: [%@], variantKeyCount: [%lu]", __FUNCTION__, key, (unsigned long)self.localCachedVariants.count); return self.localCachedVariants[key]; } @@ -455,28 +460,28 @@ - (void)testingDownloadAllVariants:(RCVariantCallback)completionHandler { if (!CountlyConsentManager.sharedInstance.consentForRemoteConfig) { - CLY_LOG_D(@"'fetchVariantForKeys' is aborted: RemoteConfig consent requires"); + CLY_LOG_V(@"%s no consent for remote config, variants download will be skipped", __FUNCTION__); return; } if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) { - CLY_LOG_D(@"'fetchVariantForKeys' is aborted: Due to temporary device id"); + CLY_LOG_D(@"%s device ID is in temporary mode, variants download will be skipped", __FUNCTION__); return; } - - CLY_LOG_D(@"Fetching variants manually..."); - + + CLY_LOG_D(@"%s variants download is starting, requestKind: [%@]", __FUNCTION__, kCountlyRCKeyFetchVariant); + [self testingDownloadAllVariantsInternal:^(CLYRequestResult response, NSDictionary *varaints,NSError *error) { if (!error) { self.localCachedVariants = varaints; - CLY_LOG_D(@"%s, Fetching variants manually is successful. %@", __FUNCTION__, varaints); - + CLY_LOG_D(@"%s variants download is successful, response: [%@], keys: [%@], variantKeyCount: [%lu]", __FUNCTION__, response, varaints.allKeys, (unsigned long)varaints.count); + CLY_LOG_D(@"%s variants download payload detail, variants: [%@]", __FUNCTION__, varaints); } else { - CLY_LOG_W(@"%s, Fetching variants manually failed: %@", __FUNCTION__, error); + CLY_LOG_D(@"%s variants download failed, response: [%@], error: [%@]", __FUNCTION__, response, error.localizedDescription); } if (completionHandler) @@ -489,34 +494,43 @@ - (void)testingDownloadAllVariantsInternal:(void (^)(CLYRequestResult response, { if (!CountlyServerConfig.sharedInstance.networkingEnabled) { - CLY_LOG_D(@"'fetchVariantForKeys' is aborted: SDK Networking is disabled from server config!"); + CLY_LOG_D(@"%s variants request is aborted, reason: [networking is disabled from server config], requestKind: [%@]", __FUNCTION__, kCountlyRCKeyFetchVariant); + return; + } + if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) + { + CLY_LOG_D(@"%s variants request is aborted, reason: [device ID is in temporary mode]", __FUNCTION__); return; } if (!completionHandler) + { + CLY_LOG_E(@"%s variants request is aborted, reason: [completionHandler is not provided]", __FUNCTION__); return; - + } + NSURLRequest* request = [self downloadVariantsRequest]; - NSURLSessionTask* task = [CountlyCommon.sharedInstance.URLSession dataTaskWithRequest:request completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) + NSURLSessionTask* task = [CountlyCommon.sharedInstance immediateDataTaskWithRequest:request completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { + // IMMEDIATE REQUEST to find them better in search NSMutableDictionary* variants = NSMutableDictionary.new; if (!error) { - NSDictionary* variants_ = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; - [variants_ enumerateKeysAndObjectsUsingBlock:^(NSString * key, NSArray* value, BOOL * stop) + id variantsResponse = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; + // an error body such as {"result": "..."} is a dictionary too, so every entry is checked before it is read + NSDictionary* variantsByKey = [variantsResponse isKindOfClass:NSDictionary.class] ? variantsResponse : nil; + [variantsByKey enumerateKeysAndObjectsUsingBlock:^(id key, id value, BOOL * stop) { + if (![key isKindOfClass:NSString.class] || ![value isKindOfClass:NSArray.class]) + return; + NSMutableArray* valuesArray = NSMutableArray.new; - [value enumerateObjectsUsingBlock:^(id arrayValue, NSUInteger idx, BOOL * stop) - { - - NSString *valueType = NSStringFromClass([arrayValue class]); - if ([valueType isEqualToString:@"__NSDictionaryI"]) { - [valuesArray addObject:arrayValue[@"name"]]; - } - else { - [valuesArray addObject:arrayValue]; - } - }]; + for (id arrayValue in (NSArray *)value) + { + id variantName = [arrayValue isKindOfClass:NSDictionary.class] ? arrayValue[@"name"] : arrayValue; + if ([variantName isKindOfClass:NSString.class]) + [valuesArray addObject:variantName]; + } variants[key] = valuesArray; }]; } @@ -533,49 +547,54 @@ - (void)testingDownloadAllVariantsInternal:(void (^)(CLYRequestResult response, if (error) { - CLY_LOG_D(@"%s, Fetch variants Request <%p> failed! Error: %@", __FUNCTION__, request, error); + CLY_LOG_D(@"%s variants request failed, requestKind: [%@], stage: [%@], statusCode: [%ld], error: [%@]", __FUNCTION__, kCountlyRCKeyFetchVariant, data ? @"response handling" : @"network", (long)((NSHTTPURLResponse*)response).statusCode, error.localizedDescription); - dispatch_async(dispatch_get_main_queue(), ^ - { + [self deliverOnMainUnlessHalted:^{ completionHandler(CLYResponseError, nil, error); - }); + }]; return; } - CLY_LOG_D(@"Fetch variants Request <%p> successfully completed.", request); + CLY_LOG_D(@"%s variants request successfully completed, requestKind: [%@], statusCode: [%ld], variantKeyCount: [%lu]", __FUNCTION__, kCountlyRCKeyFetchVariant, (long)((NSHTTPURLResponse*)response).statusCode, (unsigned long)variants.count); + CLY_LOG_D(@"%s variants response payload detail, variants: [%@]", __FUNCTION__, variants); - dispatch_async(dispatch_get_main_queue(), ^ - { + [self deliverOnMainUnlessHalted:^{ completionHandler(CLYResponseSuccess, variants, nil); - }); + }]; }]; [task resume]; - CLY_LOG_D(@"%s, Fetch variants Request <%p> started [%@] %@", __FUNCTION__, (id)request, request.HTTPMethod, request.URL.absoluteString); + CLY_LOG_D(@"%s variants request is started, requestKind: [%@], method: [%@], path: [%@]", __FUNCTION__, kCountlyRCKeyFetchVariant, request.HTTPMethod, request.URL.path); + CLY_LOG_D(@"%s variants request URL detail, url: [%@]", __FUNCTION__, request.URL.absoluteString); } - (void)testingEnrollIntoVariant:(NSString *)key variantName:(NSString *)variantName completionHandler:(RCVariantCallback)completionHandler { if (!CountlyConsentManager.sharedInstance.consentForRemoteConfig) + { + CLY_LOG_V(@"%s no consent for remote config, variant enrollment will be skipped", __FUNCTION__); return; - + } + if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) + { + CLY_LOG_D(@"%s device ID is in temporary mode, variant enrollment will be skipped", __FUNCTION__); return; - - CLY_LOG_D(@"Enrolling RC variant"); - + } + + CLY_LOG_D(@"%s variant enrollment is starting, requestKind: [%@], key: [%@], variantName: [%@]", __FUNCTION__, kCountlyRCKeyEnrollVariant, key, variantName); + [self testingEnrollIntoVariantInternal:key variantName:variantName completionHandler:^(CLYRequestResult response, NSError *error) { if (!error) { - CLY_LOG_D(@"Enrolling RC variant successful."); - + CLY_LOG_D(@"%s variant enrollment is successful, response: [%@], key: [%@], variantName: [%@]", __FUNCTION__, response, key, variantName); } else { - CLY_LOG_W(@"Enrolling RC variant failed: %@", error); + CLY_LOG_D(@"%s variant enrollment failed, response: [%@], key: [%@], variantName: [%@], error: [%@]", __FUNCTION__, response, key, variantName, error.localizedDescription); } if (completionHandler) @@ -587,36 +606,52 @@ - (void)testingEnrollIntoVariantInternal:(NSString *)key variantName:(NSString * { if (!CountlyServerConfig.sharedInstance.networkingEnabled) { - CLY_LOG_D(@"'enrollInRCVariant' is aborted: SDK Networking is disabled from server config!"); + CLY_LOG_D(@"%s variant enrollment request is aborted, reason: [networking is disabled from server config], requestKind: [%@]", __FUNCTION__, kCountlyRCKeyEnrollVariant); return; } if (!completionHandler) { - CLY_LOG_D(@"'enrollInRCVariant' is aborted: 'completionHandler' not provided"); + CLY_LOG_E(@"%s variant enrollment request is aborted, reason: [completionHandler is not provided]", __FUNCTION__); return; } - + if (!key) { - CLY_LOG_D(@"'enrollInRCVariant' is aborted: 'key' is not valid"); + CLY_LOG_E(@"%s variant enrollment request is aborted, reason: [key is not valid], key: [%@]", __FUNCTION__, key); return; } - + if (!variantName) { - CLY_LOG_D(@"'enrollInRCVariant' is aborted: 'variantName' is not valid"); + CLY_LOG_E(@"%s variant enrollment request is aborted, reason: [variantName is not valid], key: [%@], variantName: [%@]", __FUNCTION__, key, variantName); return; } - + + if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) + { + CLY_LOG_D(@"%s variant enrollment request is aborted, reason: [device ID is in temporary mode], key: [%@]", __FUNCTION__, key); + 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 immediateDataTaskWithRequest:request completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { + // IMMEDIATE REQUEST to find them better in search + if (self != s_sharedInstance) + { + CLY_LOG_D(@"%s variant enrollment response arrived after halt, it is ignored, key: [%@]", __FUNCTION__, key); + return; + } + NSDictionary* variants = nil; [self clearCachedRemoteConfig]; if (!error) { variants = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; + if (![variants isKindOfClass:NSDictionary.class]) + variants = nil; + if (((NSHTTPURLResponse*)response).statusCode != 200) { - NSMutableDictionary* userInfo = variants.mutableCopy; + NSMutableDictionary* userInfo = variants.mutableCopy ?: NSMutableDictionary.new; userInfo[NSLocalizedDescriptionKey] = @"Enroll In RC Variant general API error"; error = [NSError errorWithDomain:kCountlyErrorDomain code:CLYErrorRemoteConfigGeneralAPIError userInfo:userInfo]; } @@ -624,18 +659,17 @@ - (void)testingEnrollIntoVariantInternal:(NSString *)key variantName:(NSString * if (error) { - CLY_LOG_D(@"%s, Enroll RC Variant Request <%p> failed! Error: %@", __FUNCTION__, request, error); + CLY_LOG_D(@"%s variant enrollment request failed, requestKind: [%@], key: [%@], stage: [%@], statusCode: [%ld], error: [%@]", __FUNCTION__, kCountlyRCKeyEnrollVariant, key, data ? (variants ? @"API response" : @"JSON parsing") : @"network", (long)((NSHTTPURLResponse*)response).statusCode, error.localizedDescription); - dispatch_async(dispatch_get_main_queue(), ^ - { + [self deliverOnMainUnlessHalted:^{ completionHandler(CLYResponseError, error); - }); + }]; return; } - CLY_LOG_D(@"Enroll RC Variant Request <%p> successfully completed.", request); + CLY_LOG_D(@"%s variant enrollment request successfully completed, requestKind: [%@], key: [%@], statusCode: [%ld]", __FUNCTION__, kCountlyRCKeyEnrollVariant, key, (long)((NSHTTPURLResponse*)response).statusCode); [self downloadRemoteConfigAutomatically]; @@ -643,7 +677,8 @@ - (void)testingEnrollIntoVariantInternal:(NSString *)key variantName:(NSString * [task resume]; - CLY_LOG_D(@"%s, Fetch variants Request <%p> started: [%@] %@", __FUNCTION__, (id)request, request.HTTPMethod, request.URL.absoluteString); + CLY_LOG_D(@"%s variant enrollment request is started, requestKind: [%@], key: [%@], variantName: [%@], method: [%@], path: [%@]", __FUNCTION__, kCountlyRCKeyEnrollVariant, key, variantName, request.HTTPMethod, request.URL.path); + CLY_LOG_D(@"%s variant enrollment request URL detail, url: [%@]", __FUNCTION__, request.URL.absoluteString); } - (NSURLRequest *)downloadVariantsRequest @@ -680,28 +715,28 @@ - (void) testingDownloadExperimentInformation:(RCVariantCallback)completionHandl { if (!CountlyConsentManager.sharedInstance.consentForRemoteConfig) { - CLY_LOG_D(@"'testingDownloadExperimentInformation' is aborted: RemoteConfig consent requires"); + CLY_LOG_V(@"%s no consent for remote config, experiment information download will be skipped", __FUNCTION__); return; } if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) { - CLY_LOG_D(@"'testingDownloadExperimentInformation' is aborted: Due to temporary device id"); + CLY_LOG_D(@"%s device ID is in temporary mode, experiment information download will be skipped", __FUNCTION__); return; } - - CLY_LOG_D(@"Download experiments info..."); - + + CLY_LOG_D(@"%s experiment information download is starting, requestKind: [%@]", __FUNCTION__, kCountlyRCKeyFetchExperiments); + [self testingDownloaExperimentInfoInternal:^(CLYRequestResult response, NSDictionary *experimentInfo,NSError *error) { if (!error) { self.localCachedExperiments = experimentInfo; - CLY_LOG_D(@"%s, Download experiments info is successful. %@", __FUNCTION__,experimentInfo); - + CLY_LOG_D(@"%s experiment information download is successful, response: [%@], keys: [%@], experimentCount: [%lu]", __FUNCTION__, response, experimentInfo.allKeys, (unsigned long)experimentInfo.count); + CLY_LOG_D(@"%s experiment information download payload detail, experimentInfo: [%@]", __FUNCTION__, experimentInfo); } else { - CLY_LOG_W(@"Download experiments info failed: %@", error); + CLY_LOG_D(@"%s experiment information download failed, response: [%@], error: [%@]", __FUNCTION__, response, error.localizedDescription); } if (completionHandler) @@ -714,28 +749,41 @@ - (void)testingDownloaExperimentInfoInternal:(void (^)(CLYRequestResult response { if (!CountlyServerConfig.sharedInstance.networkingEnabled) { - CLY_LOG_D(@"'testingDownloaExperimentInfoInternal' is aborted: SDK Networking is disabled from server config!"); + CLY_LOG_D(@"%s experiments request is aborted, reason: [networking is disabled from server config], requestKind: [%@]", __FUNCTION__, kCountlyRCKeyFetchExperiments); + return; + } + if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) + { + CLY_LOG_D(@"%s experiments request is aborted, reason: [device ID is in temporary mode]", __FUNCTION__); return; } if (!completionHandler) + { + CLY_LOG_E(@"%s experiments request is aborted, reason: [completionHandler is not provided]", __FUNCTION__); return; - + } + NSURLRequest* request = [self downloadExperimentInfoRequest]; - NSURLSessionTask* task = [CountlyCommon.sharedInstance.URLSession dataTaskWithRequest:request completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) + NSURLSessionTask* task = [CountlyCommon.sharedInstance immediateDataTaskWithRequest:request completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { - + // IMMEDIATE REQUEST to find them better in search NSMutableDictionary * experiments = NSMutableDictionary.new; if (!error) { - NSArray* experimentsInfo = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; - [experimentsInfo enumerateObjectsUsingBlock:^(NSDictionary* value, NSUInteger idx, BOOL * stop) - { + id experimentsResponse = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; + // an error body is a dictionary, not the list of experiments + NSArray* experimentsInfo = [experimentsResponse isKindOfClass:NSArray.class] ? experimentsResponse : nil; + for (id value in experimentsInfo) + { + if (![value isKindOfClass:NSDictionary.class]) + continue; + CountlyExperimentInformation* experimentInfo = [[CountlyExperimentInformation alloc] initWithID:value[@"id"] experimentName:value[@"name"] experimentDescription:value[@"description"] currentVariant:value[@"currentVariant"] variants:value[@"variants"]]; - experiments[experimentInfo.experimentID] = experimentInfo; - - }]; + if (experimentInfo.experimentID) + experiments[experimentInfo.experimentID] = experimentInfo; + } } if (!error) @@ -750,27 +798,27 @@ - (void)testingDownloaExperimentInfoInternal:(void (^)(CLYRequestResult response if (error) { - CLY_LOG_D(@"%s, Download experiments Request <%p> failed! Error: %@", __FUNCTION__, request, error); + CLY_LOG_D(@"%s experiments request failed, requestKind: [%@], stage: [%@], statusCode: [%ld], error: [%@]", __FUNCTION__, kCountlyRCKeyFetchExperiments, data ? @"response handling" : @"network", (long)((NSHTTPURLResponse*)response).statusCode, error.localizedDescription); - dispatch_async(dispatch_get_main_queue(), ^ - { + [self deliverOnMainUnlessHalted:^{ completionHandler(CLYResponseError, nil, error); - }); + }]; return; } - CLY_LOG_D(@"Download experiments Request <%p> successfully completed.", request); + CLY_LOG_D(@"%s experiments request successfully completed, requestKind: [%@], statusCode: [%ld], experimentCount: [%lu]", __FUNCTION__, kCountlyRCKeyFetchExperiments, (long)((NSHTTPURLResponse*)response).statusCode, (unsigned long)experiments.count); + CLY_LOG_D(@"%s experiments response payload detail, experiments: [%@]", __FUNCTION__, experiments); - dispatch_async(dispatch_get_main_queue(), ^ - { + [self deliverOnMainUnlessHalted:^{ completionHandler(CLYResponseSuccess, experiments, nil); - }); + }]; }]; [task resume]; - CLY_LOG_D(@"%s, Download experiments Request <%p> started: [%@] %@", __FUNCTION__, (id)request, request.HTTPMethod, request.URL.absoluteString); + CLY_LOG_D(@"%s experiments request is started, requestKind: [%@], method: [%@], path: [%@]", __FUNCTION__, kCountlyRCKeyFetchExperiments, request.HTTPMethod, request.URL.path); + CLY_LOG_D(@"%s experiments request URL detail, url: [%@]", __FUNCTION__, request.URL.absoluteString); } - (NSURLRequest *)downloadExperimentInfoRequest @@ -804,6 +852,7 @@ - (NSURLRequest *)downloadExperimentInfoRequest } - (NSDictionary *) testingGetAllExperimentInfo { + CLY_LOG_D(@"%s all locally cached experiment information is read, experimentCount: [%lu]", __FUNCTION__, (unsigned long)self.localCachedExperiments.count); return self.localCachedExperiments; } diff --git a/ios/src/CountlySDKLimitsConfig.h b/ios/src/CountlySDKLimitsConfig.h index fa3d77f1..74001772 100644 --- a/ios/src/CountlySDKLimitsConfig.h +++ b/ios/src/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/ios/src/CountlySDKLimitsConfig.m b/ios/src/CountlySDKLimitsConfig.m index 2a085e5c..725322de 100644 --- a/ios/src/CountlySDKLimitsConfig.m +++ b/ios/src/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/ios/src/CountlyServerConfig.h b/ios/src/CountlyServerConfig.h index 75a142d0..1e32541d 100644 --- a/ios/src/CountlyServerConfig.h +++ b/ios/src/CountlyServerConfig.h @@ -23,6 +23,9 @@ extern NSString* const kCountlySCKeySC; - (NSInteger)sessionInterval; - (NSInteger)eventQueueSize; - (BOOL)crashReportingEnabled; +- (BOOL)automaticSessionTrackingEnabled; +- (BOOL)automaticViewTrackingEnabled; +- (BOOL)automaticCrashReportingEnabled; - (BOOL)loggingEnabled; - (NSInteger)limitKeyLength; - (NSInteger)limitValueSize; @@ -53,7 +56,15 @@ extern NSString* const kCountlySCKeySC; - (BOOL)shouldRecordUserProperty:(NSString *)propertyKey; - (NSDictionary *)filterSegmentation:(NSDictionary *)segmentation eventKey:(NSString *)eventKey; - (BOOL)isJourneyTriggerEvent:(NSString *)eventKey; +- (BOOL)isJourneyTriggerView:(NSString *)viewName; - (NSInteger)userPropertyCacheLimit; +- (void)applyAutomaticTrackingState; +/// The consent requirement start sends the consent state for. It is remembered, so applyConsentRequirement acts on a change behavior settings make after start handled consent, also one that lands before init has finished. +- (BOOL)takeConsentRequirementForStart:(CountlyConfig *)config; +/// Sends the consent state and the empty location when behavior settings turned the consent requirement on since its consent state was last sent, and starts the features that waited for consent when they turned it off. +- (void)applyConsentRequirement; +/// Clears the stored location info and sends the empty location when behavior settings turn location tracking off. Before init has finished a request would go without the app key, so startWithConfig calls this once init is complete. +- (void)applyLocationTrackingState; @end diff --git a/ios/src/CountlyServerConfig.m b/ios/src/CountlyServerConfig.m index cbf35b34..4503a910 100644 --- a/ios/src/CountlyServerConfig.m +++ b/ios/src/CountlyServerConfig.m @@ -8,10 +8,27 @@ @interface CountlyServerConfig () { NSTimer *_requestTimer; + // the developer's config from start, every later fetch applies the settings on top of it + CountlyConfig *_startConfig; + NSNumber *_lastAppliedAutomaticViewTracking; + // the developer's values from start, before any behavior setting was applied, see rememberDeveloperValues: + NSDictionary *_developerValues; + // what notifySdkConfigChange: last wrote into the start config, see restoreDeveloperValuesInto: + NSDictionary *_writtenValues; + // the keys of the applied settings: a setting they carry wins over the developer's value, also when it is NO or 0, + // and a key that disappears from them is noticed + NSSet *_appliedSettingKeys; + // the consent requirement whose consent state was last sent, nil until start has handled consent + NSNumber *_handledConsentRequirement; + // the developer's disableBackoffMechanism from start, which no behavior setting turns back on + BOOL _isBackoffDisabledByDeveloper; } @property (nonatomic) BOOL trackingEnabled; @property (nonatomic) BOOL networkingEnabled; @property (nonatomic) BOOL crashReportingEnabled; +@property (nonatomic) BOOL automaticSessionTrackingEnabled; +@property (nonatomic) BOOL automaticViewTrackingEnabled; +@property (nonatomic) BOOL automaticCrashReportingEnabled; @property (nonatomic) BOOL loggingEnabled; @property (nonatomic) BOOL customEventTrackingEnabled; @property (nonatomic) BOOL viewTrackingEnabled; @@ -43,16 +60,18 @@ @interface CountlyServerConfig () { @property (nonatomic) NSInteger requestTimeoutDuration; -@property (nonatomic) NSSet *eventFilterSet; +@property (atomic) NSSet *eventFilterSet; @property (nonatomic) BOOL eventFilterIsWhitelist; -@property (nonatomic) NSSet *userPropertyFilterSet; +@property (atomic) NSSet *userPropertyFilterSet; @property (nonatomic) BOOL userPropertyFilterIsWhitelist; @property (nonatomic) NSInteger userPropertyCacheLimit; -@property (nonatomic) NSSet *segmentationFilterSet; +@property (atomic) NSSet *segmentationFilterSet; @property (nonatomic) BOOL segmentationFilterIsWhitelist; -@property (nonatomic) NSDictionary *> *eventSegmentationFilterMap; +@property (atomic) NSDictionary *> *eventSegmentationFilterMap; @property (nonatomic) BOOL eventSegmentationFilterIsWhitelist; -@property (nonatomic) NSSet *journeyTriggerEvents; +// atomic: replaced by a settings response on its own thread while events and views read them on theirs +@property (atomic) NSSet *journeyTriggerEvents; +@property (atomic) NSSet *journeyTriggerViews; @property (nonatomic) NSInteger version; @property (nonatomic) long long timestamp; @@ -91,6 +110,9 @@ @interface CountlyServerConfig () { NSString *const kRConsentRequired = @"cr"; NSString *const kRDropOldRequestTime = @"dort"; NSString *const kRCrashReporting = @"crt"; +NSString *const kRAutomaticSessionTracking = @"ast"; +NSString *const kRAutomaticViewTracking = @"avt"; +NSString *const kRAutomaticCrashReporting = @"acr"; NSString *const kRServerConfigUpdateInterval = @"scui"; NSString *const kRBOMAcceptedTimeout = @"bom_at"; NSString *const kRBOMRQPercentage = @"bom_rqp"; @@ -107,10 +129,61 @@ @interface CountlyServerConfig () { NSString *const kREventSegmentationBlacklist = @"esb"; NSString *const kREventSegmentationWhitelist = @"esw"; NSString *const kRJourneyTriggerEvents = @"jte"; +NSString *const kRJourneyTriggerViews = @"jtv"; + +// sdk log keys +NSString *const kRLogGathering = @"lg"; // a top level key, siblings of c, v and t +NSString *const kRLGEnabled = @"e"; +NSString *const kRLGId = @"i"; +NSString *const kRLGLevels = @"l"; +NSString *const kRLGBatchSize = @"b"; +// connection test key, a top level sibling of c, read from a live response and never stored +NSString *const kRConnectionTest = @"ct"; static CountlyServerConfig *s_sharedInstance = nil; static dispatch_once_t onceToken; +// the behavior settings refresh interval in hours when the server does not set one +static const NSInteger kCountlyDefaultServerConfigUpdateInterval = 4; + +/// Whether a behavior settings version or timestamp can be read as a number. +static BOOL CountlyIsBehaviorSettingsNumber(id value) +{ + return [value isKindOfClass:NSNumber.class] || [value isKindOfClass:NSString.class]; +} + +/// A copy of a behavior setting value without the nulls inside its lists and dictionaries, which the standard user +/// defaults can not store and every reader would have to step around. +static id CountlyBehaviorSettingWithoutNulls(id value) +{ + if ([value isKindOfClass:NSArray.class]) + { + NSMutableArray* cleaned = NSMutableArray.new; + for (id element in (NSArray *)value) + { + if (element != NSNull.null) + [cleaned addObject:CountlyBehaviorSettingWithoutNulls(element)]; + } + return cleaned; + } + + if ([value isKindOfClass:NSDictionary.class]) + { + NSMutableDictionary* cleaned = NSMutableDictionary.new; + [(NSDictionary *)value enumerateKeysAndObjectsUsingBlock:^(id key, id element, BOOL* stop) { + if (element != NSNull.null) + cleaned[key] = CountlyBehaviorSettingWithoutNulls(element); + }]; + return cleaned; + } + + return value; +} +#if (TARGET_OS_IOS) +// the content zone timer interval in seconds when neither the server nor the developer sets one, as CountlyContentBuilderInternal starts with +static const NSUInteger kCountlyDefaultZoneTimerInterval = 30; +#endif + @implementation CountlyServerConfig + (instancetype)sharedInstance @@ -131,7 +204,7 @@ - (instancetype)init { _timestamp = 0; _version = 0; - _currentServerConfigUpdateInterval = 4; + _currentServerConfigUpdateInterval = kCountlyDefaultServerConfigUpdateInterval; _requestTimer = nil; _serverConfigUpdatesDisabled = NO; _requestTimeoutDuration = 30; @@ -142,13 +215,24 @@ - (instancetype)init - (void)resetInstance { - CLY_LOG_I(@"%s", __FUNCTION__); + CLY_LOG_I(@"%s resetting behavior settings state and shared instance", __FUNCTION__); _timestamp = 0; _version = 0; - _currentServerConfigUpdateInterval = 4; + _currentServerConfigUpdateInterval = kCountlyDefaultServerConfigUpdateInterval; _serverConfigUpdatesDisabled = NO; _requestTimeoutDuration = 30; _lastFetchTimestamp = 0; + [self restoreDeveloperValuesInto:_startConfig]; + _startConfig = nil; + _lastAppliedAutomaticViewTracking = nil; + _developerValues = nil; + _writtenValues = nil; + _appliedSettingKeys = nil; + _isBackoffDisabledByDeveloper = NO; + // an answer still in flight applies to this dropped instance, which must not send consents into the next run + @synchronized (self) { + _handledConsentRequirement = nil; + } if (_requestTimer) { [_requestTimer invalidate]; @@ -161,40 +245,161 @@ - (void)resetInstance - (void)retrieveServerConfigFromStorage:(CountlyConfig *)config { + _startConfig = config; + [self rememberDeveloperValues:config]; + _isBackoffDisabledByDeveloper = config.disableBackoffMechanism; + + // Seed the automatic tracking flags from the developer config: it is the lowest-precedence layer. + // The SBS layers override them below (provided -> stored here, server in fetchServerConfig), giving + // the precedence: server SBS > stored SBS > provided SBS > developer config. When the server is + // silent, the resolved value equals the developer config, so behavior stays drop-in. + _automaticSessionTrackingEnabled = !config.manualSessionHandling; +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV) + _automaticViewTrackingEnabled = config.enableAutomaticViewTracking; +#endif + _automaticCrashReportingEnabled = [config.features containsObject:CLYCrashReporting]; + + CLY_LOG_D(@"%s automatic tracking flags seeded from the developer config, automaticSessionTracking: [%@], automaticViewTracking: [%@], automaticCrashReporting: [%@]", __FUNCTION__, _automaticSessionTrackingEnabled ? @"YES" : @"NO", _automaticViewTrackingEnabled ? @"YES" : @"NO", _automaticCrashReportingEnabled ? @"YES" : @"NO"); + NSMutableDictionary *persistentBehaviorSettings = [CountlyPersistency.sharedInstance retrieveServerConfig]; + CLY_LOG_D(@"%s behavior settings read from persisted cache, keyCount: [%lu], developerSuppliedSettings: [%@]", __FUNCTION__, (unsigned long)persistentBehaviorSettings.count, config.sdkBehaviorSettings ? @"YES" : @"NO"); if (persistentBehaviorSettings.count == 0 && config.sdkBehaviorSettings) { NSError *error = nil; id parsed = [NSJSONSerialization JSONObjectWithData:[config.sdkBehaviorSettings cly_dataUTF8] options:0 error:&error]; if ([parsed isKindOfClass:[NSDictionary class]]) { - persistentBehaviorSettings = [(NSDictionary *)parsed mutableCopy]; - [CountlyPersistency.sharedInstance storeServerConfig:persistentBehaviorSettings]; + // checked like a server answer, so a null in them can not crash every start + persistentBehaviorSettings = NSMutableDictionary.new; + [self replaceBehaviorSettings:persistentBehaviorSettings withConfig:parsed]; + CLY_LOG_D(@"%s cache was empty, falling back to developer supplied behavior settings, keyCount: [%lu]", __FUNCTION__, (unsigned long)persistentBehaviorSettings.count); } else { - CLY_LOG_W(@"%s, Failed to parse sdkBehaviorSettings or not a dictionary: %@", __FUNCTION__, error); + CLY_LOG_E(@"%s could not parse developer supplied sdkBehaviorSettings, they will be ignored, error: [%@]", __FUNCTION__, error.localizedDescription); } } [self populateServerConfig:persistentBehaviorSettings withConfig:config]; + [CountlyPersistency.sharedInstance storeServerConfig:persistentBehaviorSettings]; +} + +/// Remembers the developer's values from start, before any behavior setting is applied. A setting the server stops +/// sending goes back to them, see revertSettingsMissingFrom: and notifySdkConfigChange:. The developer config itself is +/// not a reliable source later, because notifySdkConfigChange: writes the resolved values into it. +- (void)rememberDeveloperValues:(CountlyConfig *)config +{ + NSMutableDictionary *values = [self notifiedValuesOf:config].mutableCopy; + values[kRAutomaticSessionTracking] = @(!config.manualSessionHandling); +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV) + values[kRAutomaticViewTracking] = @(config.enableAutomaticViewTracking); +#endif + values[kRAutomaticCrashReporting] = @([config.features containsObject:CLYCrashReporting]); + _developerValues = values.copy; +} + +/// The values of a configuration that notifySdkConfigChange: writes, by the key of the behavior setting that decides each. +- (NSDictionary *)notifiedValuesOf:(CountlyConfig *)config +{ + NSMutableDictionary *values = NSMutableDictionary.new; + values[kRConsentRequired] = @(config.requiresConsent); + values[kRLogging] = @(config.enableDebug); + values[kREventQueueSize] = @(config.eventSendThreshold); + values[kRReqQueueSize] = @(config.storedRequestsLimit); + values[kRDropOldRequestTime] = @(config.requestDropAgeHours); + values[kRSessionUpdateInterval] = @(config.updateSessionPeriod); + values[kRLimitKeyLength] = @(config.sdkInternalLimits.getMaxKeyLength); + values[kRLimitValueSize] = @(config.sdkInternalLimits.getMaxValueSize); + values[kRLimitSegValues] = @(config.sdkInternalLimits.getMaxSegmentationValues); + values[kRLimitBreadcrumb] = @(config.sdkInternalLimits.getMaxBreadcrumbCount); + values[kRLimitTraceLength] = @(config.sdkInternalLimits.getMaxStackTraceLineLength); + values[kRLimitTraceLine] = @(config.sdkInternalLimits.getMaxStackTraceLinesPerThread); +#if (TARGET_OS_IOS) + values[kRContentZoneInterval] = @(config.content.getZoneTimerInterval); +#endif + return values; } -- (void)mergeBehaviorSettings:(NSMutableDictionary *)baseConfig - withConfig:(NSDictionary *)newConfig +/// The developer's value of a setting from start, or the given current value when none was remembered +- (NSNumber *)developerValueForKey:(NSString *)key orCurrent:(NSNumber *)current { - // c, t and v paramters must exist - if(newConfig.count != 3 || !newConfig[kRConfig]) { - CLY_LOG_D(@"%s, missing entries for a behavior settings omitting", __FUNCTION__); + return _developerValues[key] ?: current; +} + +/// Puts the developer's values from start back into the configuration the SDK was started with, where +/// notifySdkConfigChange: wrote a value that is still there. It writes the resolved values into that configuration, and its +/// internal limits and content parts are shared by every configuration in the process, so without this the next start +/// would remember the halted run's behavior settings as the developer's values. A value that changed since was set by the +/// app, on this configuration or on another one sharing those parts, and stays. +- (void)restoreDeveloperValuesInto:(CountlyConfig *)config +{ + if (!config || !_developerValues || !_writtenValues) + return; + + NSDictionary *current = [self notifiedValuesOf:config]; + NSMutableDictionary *restored = NSMutableDictionary.new; + [_writtenValues enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSNumber *written, BOOL *stop) { + if ([current[key] isEqual:written]) + restored[key] = self->_developerValues[key]; + }]; + [self writeValues:restored into:config]; + + CLY_LOG_D(@"%s the developer's values from start are put back into the start configuration, restoredKeys: [%@], keptKeys: [%lu]", __FUNCTION__, [restored.allKeys componentsJoinedByString:@","], (unsigned long)(_writtenValues.count - restored.count)); +} + +/// Writes the values given by behavior setting key into a configuration, leaving out the keys the dictionary does not carry. +- (void)writeValues:(NSDictionary *)values into:(CountlyConfig *)config +{ + if (values[kRLogging]) + config.enableDebug = [values[kRLogging] boolValue]; + if (values[kRConsentRequired]) + config.requiresConsent = [values[kRConsentRequired] boolValue]; + if (values[kREventQueueSize]) + config.eventSendThreshold = [values[kREventQueueSize] unsignedIntegerValue]; + if (values[kRReqQueueSize]) + config.storedRequestsLimit = [values[kRReqQueueSize] unsignedIntegerValue]; + if (values[kRDropOldRequestTime]) + config.requestDropAgeHours = [values[kRDropOldRequestTime] unsignedIntegerValue]; + if (values[kRSessionUpdateInterval]) + config.updateSessionPeriod = [values[kRSessionUpdateInterval] doubleValue]; + if (values[kRLimitKeyLength]) + [config.sdkInternalLimits setMaxKeyLength:[values[kRLimitKeyLength] unsignedIntegerValue]]; + if (values[kRLimitValueSize]) + [config.sdkInternalLimits setMaxValueSize:[values[kRLimitValueSize] unsignedIntegerValue]]; + if (values[kRLimitSegValues]) + [config.sdkInternalLimits setMaxSegmentationValues:[values[kRLimitSegValues] unsignedIntegerValue]]; + if (values[kRLimitBreadcrumb]) + [config.sdkInternalLimits setMaxBreadcrumbCount:[values[kRLimitBreadcrumb] unsignedIntegerValue]]; + if (values[kRLimitTraceLength]) + [config.sdkInternalLimits setMaxStackTraceLineLength:[values[kRLimitTraceLength] unsignedIntegerValue]]; + if (values[kRLimitTraceLine]) + [config.sdkInternalLimits setMaxStackTraceLinesPerThread:[values[kRLimitTraceLine] unsignedIntegerValue]]; +#if (TARGET_OS_IOS) + if (values[kRContentZoneInterval]) + [config.content restoreZoneTimerInterval:[values[kRContentZoneInterval] unsignedIntegerValue]]; +#endif +} + +/// Replaces the stored behavior settings with a valid answer as a whole, so a setting the server no longer sends is +/// gone from them. An answer without a 'c' dictionary, a version or a timestamp changes nothing, and an empty 'c' is +/// valid. A value in an unacceptable format is dropped by populateServerConfig:withConfig: before anything is applied +/// or stored, so that setting is treated as not sent. +- (void)replaceBehaviorSettings:(NSMutableDictionary *)baseConfig + withConfig:(NSDictionary *)newConfig +{ + // c must exist, other top level keys, like lg, are independent of it and handled on their own + if(!newConfig[kRConfig]) { + CLY_LOG_W(@"%s incoming behavior settings will be ignored, config section is missing, entryCount: [%lu]", __FUNCTION__, (unsigned long)newConfig.count); return; } - - if (!newConfig[kRVersion] || !newConfig[kRTimestamp]) + + if (!CountlyIsBehaviorSettingsNumber(newConfig[kRVersion]) || !CountlyIsBehaviorSettingsNumber(newConfig[kRTimestamp])) { - CLY_LOG_D(@"%s, version or timestamp is missing in the behavioır settings omitting", __FUNCTION__); + CLY_LOG_W(@"%s incoming behavior settings will be ignored, version or timestamp entry is missing or not a number", __FUNCTION__); return; } - - if(!([newConfig[kRConfig] isKindOfClass:[NSDictionary class]]) || ((NSDictionary *)newConfig[kRConfig]).count == 0){ - CLY_LOG_D(@"%s, invalid behavior settings omitting", __FUNCTION__); + + // an empty c is a valid answer, the server sends one when nothing is configured + if(!([newConfig[kRConfig] isKindOfClass:[NSDictionary class]])){ + CLY_LOG_W(@"%s incoming behavior settings will be ignored, config section is not a dictionary", __FUNCTION__); return; } @@ -207,87 +412,235 @@ - (void)mergeBehaviorSettings:(NSMutableDictionary *)baseConfig if (version) { baseConfig[kRVersion] = version; } - - NSDictionary *cBase = baseConfig[kRConfig] ?: NSMutableDictionary.new; + NSDictionary *cNew = newConfig[kRConfig]; + NSMutableDictionary *cReplacement = NSMutableDictionary.new; + [cNew enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { + if (obj != nil && obj != [NSNull null]) { + cReplacement[key] = CountlyBehaviorSettingWithoutNulls(obj); + } + }]; - if ([cBase isKindOfClass:[NSDictionary class]] || [cNew isKindOfClass:[NSDictionary class]]) { - NSMutableDictionary *cMerged = [cBase mutableCopy]; + baseConfig[kRConfig] = cReplacement; + CLY_LOG_D(@"%s incoming behavior settings replaced the stored ones, keyCount: [%lu]", __FUNCTION__, (unsigned long)cReplacement.count); +} - [cNew enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { - if (obj != nil && obj != [NSNull null]) { - cMerged[key] = obj; - } - }]; +- (void)setBoolProperty:(BOOL *)property fromDictionary:(NSMutableDictionary *)dictionary key:(NSString *)key logString:(NSMutableString *)logString +{ + id value = dictionary[key]; + if (!value) + return; - [self removeConflictingFilterKeys:cMerged newConfig:cNew]; - baseConfig[kRConfig] = cMerged; + if (CFGetTypeID((__bridge CFTypeRef)value) == CFBooleanGetTypeID()) + { + *property = [value boolValue]; + [logString appendFormat:@"%@: %@, ", key, *property ? @"YES" : @"NO"]; + } + else + { + CLY_LOG_W(@"%s dropping behavior setting, a boolean was expected, key: [%@], receivedType: [%@]", __FUNCTION__, key, [value class]); + [dictionary removeObjectForKey:key]; } } -- (void)setBoolProperty:(BOOL *)property fromDictionary:(NSDictionary *)dictionary key:(NSString *)key logString:(NSMutableString *)logString +/// Reads and removes the top level 'ct' flag from a live response, so it can never be cached and re-arm from storage. +/// Truthy numbers, YES, and non-empty strings other than "0" and "false" arm the test. +- (BOOL)extractConnectionTestFlag:(NSMutableDictionary *)serverConfigResponse { - NSNumber *value = dictionary[key]; - if (value) + id value = serverConfigResponse[kRConnectionTest]; + if (!value) + return NO; + + [serverConfigResponse removeObjectForKey:kRConnectionTest]; + + if ([value isKindOfClass:NSNumber.class]) + return ((NSNumber *)value).doubleValue != 0; + + if ([value isKindOfClass:NSString.class]) { - *property = value.boolValue; - [logString appendFormat:@"%@: %@, ", key, *property ? @"YES" : @"NO"]; + NSString *trimmed = [(NSString *)value stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; + return trimmed.length > 0 && ![trimmed isEqualToString:@"0"] && [trimmed caseInsensitiveCompare:@"false"] != NSOrderedSame; } + + return value != NSNull.null; +} + +// lg is a top level key, a sibling of c, so a directive still applies when c is missing or unusable. +// A nil or malformed directive means the server is not asking for logs +- (void)applyLogGatheringDirective:(id)directive +{ + NSDictionary* lg = [directive isKindOfClass:NSDictionary.class] ? directive : nil; + + NSNumber* enabled = [lg[kRLGEnabled] isKindOfClass:NSNumber.class] ? lg[kRLGEnabled] : nil; + NSNumber* batchSize = [lg[kRLGBatchSize] isKindOfClass:NSNumber.class] ? lg[kRLGBatchSize] : nil; + NSString* levels = [lg[kRLGLevels] isKindOfClass:NSString.class] ? lg[kRLGLevels] : nil; + NSString* gatherId = [lg[kRLGId] isKindOfClass:NSString.class] ? lg[kRLGId] : nil; + + [CountlyCommon.sharedInstance updateLogGatheringState:enabled.boolValue levels:levels batch:batchSize.integerValue lgid:gatherId]; +} + +- (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 dropping behavior setting, value is below the accepted minimum, key: [%@], value: [%ld], minValue: [%ld]", __FUNCTION__, key, (long)intVal, (long)minValue); + [dictionary removeObjectForKey:key]; + } + } + else + { + CLY_LOG_W(@"%s dropping behavior setting, an integer was expected, key: [%@], receivedType: [%@]", __FUNCTION__, key, [value class]); + [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()) + { + double dblVal = [value doubleValue]; + if (dblVal > 0.0 && dblVal < 1.0) + { + *property = dblVal; + [logString appendFormat:@"%@: %lf, ", key, (double)*property]; + } + else + { + CLY_LOG_W(@"%s dropping behavior setting, value is out of the accepted range, key: [%@], value: [%.4f], acceptedRange: [0.0 - 1.0]", __FUNCTION__, key, dblVal); + [dictionary removeObjectForKey:key]; + } + } + else { - *property = value.doubleValue; - [logString appendFormat:@"%@: %lf, ", key, (double)*property]; + CLY_LOG_W(@"%s dropping behavior setting, a double was expected, key: [%@], receivedType: [%@]", __FUNCTION__, key, [value class]); + [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__); + CLY_LOG_D(@"%s no config section in the behavior settings, falling back to SDK defaults", __FUNCTION__); return; } - - NSDictionary *dictionary = serverConfig[kRConfig]; - - if (!serverConfig[kRVersion] || !serverConfig[kRTimestamp]) + + NSMutableDictionary *dictionary; + if ([serverConfig[kRConfig] isKindOfClass:[NSDictionary class]]) + { + dictionary = [serverConfig[kRConfig] mutableCopy]; + } + else { - CLY_LOG_D(@"%s, version or timestamp is missing in the server configuration omitting", __FUNCTION__); + CLY_LOG_W(@"%s config section is not a dictionary, falling back to SDK defaults, receivedType: [%@]", __FUNCTION__, [serverConfig[kRConfig] class]); return; } - + + if (!CountlyIsBehaviorSettingsNumber(serverConfig[kRVersion]) || !CountlyIsBehaviorSettingsNumber(serverConfig[kRTimestamp])) + { + CLY_LOG_W(@"%s version or timestamp is missing in the behavior settings or not a number, falling back to SDK defaults", __FUNCTION__); + return; + } + _version = [serverConfig[kRVersion] integerValue]; _timestamp = [serverConfig[kRTimestamp] longLongValue]; - + + CLY_LOG_D(@"%s applying behavior settings, version: [%ld], timestamp: [%lld], configKeyCount: [%lu], requestTimeoutDuration: [%ld]", __FUNCTION__, (long)_version, _timestamp, (unsigned long)dictionary.count, (long)_requestTimeoutDuration); + 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, + kRAutomaticSessionTracking, + kRAutomaticViewTracking, + kRAutomaticCrashReporting, + 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, + kRJourneyTriggerViews + ]]; + + // Remove unknown keys + for (NSString *key in dictionary.allKeys) + { + if (![knownKeys containsObject:key]) + { + CLY_LOG_D(@"%s unknown behavior setting key will be dropped, key: [%@]", __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]; [self setIntegerProperty:&_requestQueueSize fromDictionary:dictionary key:kRReqQueueSize logString:logString]; [self setIntegerProperty:&_eventQueueSize fromDictionary:dictionary key:kREventQueueSize logString:logString]; [self setBoolProperty:&_crashReportingEnabled fromDictionary:dictionary key:kRCrashReporting logString:logString]; + [self setBoolProperty:&_automaticSessionTrackingEnabled fromDictionary:dictionary key:kRAutomaticSessionTracking logString:logString]; + [self setBoolProperty:&_automaticViewTrackingEnabled fromDictionary:dictionary key:kRAutomaticViewTracking logString:logString]; + [self setBoolProperty:&_automaticCrashReportingEnabled fromDictionary:dictionary key:kRAutomaticCrashReporting logString:logString]; [self setBoolProperty:&_sessionTrackingEnabled fromDictionary:dictionary key:kRSessionTracking logString:logString]; [self setBoolProperty:&_loggingEnabled fromDictionary:dictionary key:kRLogging logString:logString]; [self setIntegerProperty:&_limitKeyLength fromDictionary:dictionary key:kRLimitKeyLength logString:logString]; @@ -299,9 +652,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,117 +667,237 @@ - (void)populateServerConfig:(NSDictionary *)serverConfig withConfig:(CountlyCon [self updateListingFilters:dictionary logString:logString]; - if(![logString isEqualToString: @"Server Config: "]){ - // means new config gotten, if that is the case notify SDK + [self revertSettingsMissingFrom:dictionary]; + + // Update the config dictionary with cleaned values + serverConfig[kRConfig] = dictionary; + + NSSet *settingKeys = [NSSet setWithArray:dictionary.allKeys]; + BOOL settingRemoved = _appliedSettingKeys != nil && ![_appliedSettingKeys isSubsetOfSet:settingKeys]; + _appliedSettingKeys = settingKeys; + + if(![logString isEqualToString: @"Server Config: "] || settingRemoved){ + // means new config gotten, or a setting was removed, if that is the case notify SDK [self notifySdkConfigChange: config]; } - CLY_LOG_D(@"%s, version:[%li], timestamp:[%lli], %@", __FUNCTION__, _version, _timestamp, logString); + CLY_LOG_D(@"%s behavior settings applied, version: [%ld], timestamp: [%lld], appliedValues: [%@]", __FUNCTION__, (long)_version, _timestamp, logString); + + CLY_LOG_D(@"%s resolved behavior settings, tracking: [%@], networking: [%@], crashReporting: [%@], sessionTracking: [%@], viewTracking: [%@], automaticSessionTracking: [%@], automaticViewTracking: [%@], automaticCrashReporting: [%@], customEventTracking: [%@], locationTracking: [%@], refreshContentZone: [%@], enterContentZone: [%@], consentRequired: [%@], backoffMechanism: [%@], loggingForcedByServer: [%@], keyLength: [%ld], valueSize: [%ld], segValues: [%ld], breadcrumb: [%ld], traceLine: [%ld], traceLength: [%ld], sessionInterval: [%ld], eventQueueSize: [%ld], requestQueueSize: [%ld], contentZoneInterval: [%ld], dropOldRequestTime: [%ld], updateIntervalHours: [%ld], userPropertyCacheLimit: [%ld], backoffAcceptedTimeoutSeconds: [%ld], backoffRQPercentage: [%.4f], backoffRequestAgeHours: [%ld], backoffDurationSeconds: [%ld]", __FUNCTION__, + _trackingEnabled ? @"YES" : @"NO", _networkingEnabled ? @"YES" : @"NO", _crashReportingEnabled ? @"YES" : @"NO", _sessionTrackingEnabled ? @"YES" : @"NO", _viewTrackingEnabled ? @"YES" : @"NO", _automaticSessionTrackingEnabled ? @"YES" : @"NO", _automaticViewTrackingEnabled ? @"YES" : @"NO", _automaticCrashReportingEnabled ? @"YES" : @"NO", _customEventTrackingEnabled ? @"YES" : @"NO", _locationTracking ? @"YES" : @"NO", _refreshContentZone ? @"YES" : @"NO", _enterContentZone ? @"YES" : @"NO", _consentRequired ? @"YES" : @"NO", _backoffMechanism ? @"YES" : @"NO", _loggingEnabled ? @"YES" : @"NO", + (long)_limitKeyLength, (long)_limitValueSize, (long)_limitSegValues, (long)_limitBreadcrumb, (long)_limitTraceLine, (long)_limitTraceLength, (long)_sessionInterval, (long)_eventQueueSize, (long)_requestQueueSize, (long)_contentZoneInterval, (long)_dropOldRequestTime, (long)_serverConfigUpdateInterval, (long)_userPropertyCacheLimit, + (long)_bomAcceptedTimeoutSeconds, _bomRQPercentage, (long)_bomRequestAge, (long)_bomDuration); } - (void)notifySdkConfigChange:(CountlyConfig *)config { - config.enableDebug = _loggingEnabled || config.enableDebug; + // A setting the server does not send falls back to the developer's value from start, not to the config's current + // value: this method writes the resolved values into the config, so that would keep the last server value. A + // setting the server sends wins, also when it is NO or 0. + config.enableDebug = [_appliedSettingKeys containsObject:kRLogging] ? _loggingEnabled : [self developerValueForKey:kRLogging orCurrent:@(config.enableDebug)].boolValue; CountlyCommon.sharedInstance.enableDebug = config.enableDebug; // Limits could be moved to another function, but letting them stay here serves us a monopolized view of notify - if (config.maxKeyLength != kCountlyMaxKeyLength) - { - [config.sdkInternalLimits setMaxKeyLength:config.maxKeyLength]; - } - - if (config.maxValueLength != kCountlyMaxValueSize) - { - [config.sdkInternalLimits setMaxValueSize:config.maxValueLength]; - } - - if (config.maxSegmentationValues != kCountlyMaxSegmentationValues) - { - [config.sdkInternalLimits setMaxSegmentationValues:config.maxSegmentationValues]; - } - - if (config.crashLogLimit != kCountlyMaxBreadcrumbCount) - { - [config.sdkInternalLimits setMaxBreadcrumbCount:config.crashLogLimit]; - } - - [config.sdkInternalLimits setMaxKeyLength:_limitKeyLength ?: config.sdkInternalLimits.getMaxKeyLength]; - [config.sdkInternalLimits setMaxValueSize:_limitValueSize ?: config.sdkInternalLimits.getMaxValueSize]; - [config.sdkInternalLimits setMaxSegmentationValues:_limitSegValues ?: config.sdkInternalLimits.getMaxSegmentationValues]; - [config.sdkInternalLimits setMaxBreadcrumbCount:_limitBreadcrumb ?: config.sdkInternalLimits.getMaxBreadcrumbCount]; - [config.sdkInternalLimits setMaxStackTraceLineLength:_limitTraceLength ?: config.sdkInternalLimits.getMaxStackTraceLineLength]; - [config.sdkInternalLimits setMaxStackTraceLinesPerThread:_limitTraceLine ?: config.sdkInternalLimits.getMaxStackTraceLinesPerThread]; + [config.sdkInternalLimits setMaxKeyLength:_limitKeyLength ?: [self developerValueForKey:kRLimitKeyLength orCurrent:@(config.sdkInternalLimits.getMaxKeyLength)].unsignedIntegerValue]; + [config.sdkInternalLimits setMaxValueSize:_limitValueSize ?: [self developerValueForKey:kRLimitValueSize orCurrent:@(config.sdkInternalLimits.getMaxValueSize)].unsignedIntegerValue]; + [config.sdkInternalLimits setMaxSegmentationValues:_limitSegValues ?: [self developerValueForKey:kRLimitSegValues orCurrent:@(config.sdkInternalLimits.getMaxSegmentationValues)].unsignedIntegerValue]; + [config.sdkInternalLimits setMaxBreadcrumbCount:_limitBreadcrumb ?: [self developerValueForKey:kRLimitBreadcrumb orCurrent:@(config.sdkInternalLimits.getMaxBreadcrumbCount)].unsignedIntegerValue]; + [config.sdkInternalLimits setMaxStackTraceLineLength:_limitTraceLength ?: [self developerValueForKey:kRLimitTraceLength orCurrent:@(config.sdkInternalLimits.getMaxStackTraceLineLength)].unsignedIntegerValue]; + [config.sdkInternalLimits setMaxStackTraceLinesPerThread:_limitTraceLine ?: [self developerValueForKey:kRLimitTraceLine orCurrent:@(config.sdkInternalLimits.getMaxStackTraceLinesPerThread)].unsignedIntegerValue]; 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; - config.requestDropAgeHours = _dropOldRequestTime ?: config.requestDropAgeHours; - config.storedRequestsLimit = _requestQueueSize ?: config.storedRequestsLimit; + config.eventSendThreshold = _eventQueueSize ?: [self developerValueForKey:kREventQueueSize orCurrent:@(config.eventSendThreshold)].unsignedIntegerValue; + config.requestDropAgeHours = [_appliedSettingKeys containsObject:kRDropOldRequestTime] ? (NSUInteger)_dropOldRequestTime : [self developerValueForKey:kRDropOldRequestTime orCurrent:@(config.requestDropAgeHours)].unsignedIntegerValue; + config.storedRequestsLimit = _requestQueueSize ?: [self developerValueForKey:kRReqQueueSize orCurrent:@(config.storedRequestsLimit)].unsignedIntegerValue; CountlyPersistency.sharedInstance.eventSendThreshold = config.eventSendThreshold; CountlyPersistency.sharedInstance.requestDropAgeHours = config.requestDropAgeHours; CountlyPersistency.sharedInstance.storedRequestsLimit = MAX(1, config.storedRequestsLimit); - config.updateSessionPeriod = _sessionInterval ?: config.updateSessionPeriod; + config.updateSessionPeriod = _sessionInterval ?: [self developerValueForKey:kRSessionUpdateInterval orCurrent:@(config.updateSessionPeriod)].doubleValue; _sessionInterval = config.updateSessionPeriod; - - BOOL consentDidChange = !config.requiresConsent && _consentRequired; - config.requiresConsent = _consentRequired ?: config.requiresConsent; + + CLY_LOG_D(@"%s effective limits and queue settings applied, maxKeyLength: [%lu], maxValueLength: [%lu], maxValueLengthPicture: [%lu], maxSegmentationValues: [%lu], maxBreadcrumbCount: [%lu], eventSendThreshold: [%lu], requestDropAgeHours: [%lu], storedRequestsLimit: [%lu], updateSessionPeriod: [%.1f]", __FUNCTION__, + (unsigned long)CountlyCommon.sharedInstance.maxKeyLength, (unsigned long)CountlyCommon.sharedInstance.maxValueLength, (unsigned long)CountlyCommon.sharedInstance.maxValueLengthPicture, (unsigned long)CountlyCommon.sharedInstance.maxSegmentationValues, (unsigned long)config.sdkInternalLimits.getMaxBreadcrumbCount, + (unsigned long)config.eventSendThreshold, (unsigned long)config.requestDropAgeHours, (unsigned long)config.storedRequestsLimit, config.updateSessionPeriod); + + // The server can require consent but never lift a requirement the developer set, since the consents the app gave and + // withdrew are not recorded while consent is not required + BOOL developerRequiresConsent = [self developerValueForKey:kRConsentRequired orCurrent:@(config.requiresConsent)].boolValue; + BOOL requiresConsent = developerRequiresConsent || ([_appliedSettingKeys containsObject:kRConsentRequired] && _consentRequired); + config.requiresConsent = requiresConsent; CountlyConsentManager.sharedInstance.requiresConsent = config.requiresConsent; - if(consentDidChange){ - [CountlyConsentManager.sharedInstance sendConsents]; - if (!CountlyConsentManager.sharedInstance.consentForLocation) - { - [CountlyConnectionManager.sharedInstance sendLocationInfo]; - } - } + [self applyConsentRequirement]; #if (TARGET_OS_IOS) - [config.content setZoneTimerInterval:_contentZoneInterval ?: config.content.getZoneTimerInterval]; - if (config.content.getZoneTimerInterval) - { - CountlyContentBuilderInternal.sharedInstance.zoneTimerInterval = config.content.getZoneTimerInterval; - } + // setZoneTimerInterval: ignores values the developer can not set, so a removed 'czi' with no developer interval + // would leave the previous server interval in config.content; the content builder takes the resolved value + NSUInteger zoneTimerInterval = _contentZoneInterval ?: [self developerValueForKey:kRContentZoneInterval orCurrent:@(config.content.getZoneTimerInterval)].unsignedIntegerValue; + [config.content setZoneTimerInterval:zoneTimerInterval]; + CountlyContentBuilderInternal.sharedInstance.zoneTimerInterval = zoneTimerInterval ?: kCountlyDefaultZoneTimerInterval; +#endif +#if (TARGET_OS_IOS || TARGET_OS_VISION) + // clearContentState, not exitContentZone: this runs on every config apply, and exitContentZone + // would pull displayed content off screen. if (!_enterContentZone) { dispatch_async(dispatch_get_main_queue(), ^{ - [CountlyContentBuilderInternal.sharedInstance exitContentZone]; + [CountlyContentBuilderInternal.sharedInstance clearContentState]; }); } else { dispatch_async(dispatch_get_main_queue(), ^{ - [CountlyContentBuilderInternal.sharedInstance exitContentZone]; + [CountlyContentBuilderInternal.sharedInstance clearContentState]; [CountlyContentBuilderInternal.sharedInstance enterContentZone:@[]]; }); } #endif CountlyCrashReporter.sharedInstance.crashLogLimit = config.sdkInternalLimits.getMaxBreadcrumbCount; - if (_serverConfigUpdateInterval && _serverConfigUpdateInterval != _currentServerConfigUpdateInterval && _requestTimer) + NSInteger updateInterval = _serverConfigUpdateInterval ?: kCountlyDefaultServerConfigUpdateInterval; + if (updateInterval != _currentServerConfigUpdateInterval && _requestTimer) { - _currentServerConfigUpdateInterval = _serverConfigUpdateInterval; + CLY_LOG_D(@"%s behavior settings refresh timer will be rescheduled, intervalHours: [%ld]", __FUNCTION__, (long)updateInterval); + _currentServerConfigUpdateInterval = updateInterval; [_requestTimer invalidate]; _requestTimer = nil; _requestTimer = [NSTimer timerWithTimeInterval:_currentServerConfigUpdateInterval * 60 * 60 target:self selector:@selector(fetchServerConfigTimer:) userInfo:config repeats:YES]; [NSRunLoop.mainRunLoop addTimer:_requestTimer forMode:NSRunLoopCommonModes]; } - if (!_locationTracking && !CountlyLocationManager.sharedInstance.isLocationInfoDisabled) + if (CountlyCommon.sharedInstance.hasFinishedInit) { - [CountlyLocationManager.sharedInstance disableLocation]; - [CountlyConnectionManager.sharedInstance sendLocationInfo]; + [self applyLocationTrackingState]; } - - if(_backoffMechanism && config.disableBackoffMechanism){ - _backoffMechanism = NO; + + // Skipped while init is still running: 'shouldUsePLCrashReporter' is not assigned until later in + // 'startWithConfig', so installing the crash handler here would take the default handler path and then + // block PLCrashReporter from ever starting. 'startWithConfig' calls this itself once init is complete. + if (CountlyCommon.sharedInstance.hasFinishedInit) + { + [self applyAutomaticTrackingState]; + } + + _writtenValues = [self notifiedValuesOf:config]; +} + +- (BOOL)takeConsentRequirementForStart:(CountlyConfig *)config +{ + @synchronized (self) { + _handledConsentRequirement = @(config.requiresConsent); + return config.requiresConsent; + } +} + +- (void)applyConsentRequirement +{ + BOOL wasTurnedOn = NO; + BOOL wasLifted = NO; + @synchronized (self) { + // until start has handled consent, start sends the consent state for whichever requirement it finds + if (!_handledConsentRequirement) + return; + + BOOL requiresConsent = CountlyConsentManager.sharedInstance.requiresConsent; + wasTurnedOn = requiresConsent && !_handledConsentRequirement.boolValue; + wasLifted = !requiresConsent && _handledConsentRequirement.boolValue; + _handledConsentRequirement = @(requiresConsent); + } + + if (wasLifted) + { + // features that waited for their consent start the way giving that consent starts them: automatic view + // tracking through applyAutomaticTrackingState, push notifications here, on the main thread its registration needs + CLY_LOG_D(@"%s consent requirement the behavior settings turned on is off again, features waiting for consent will be started", __FUNCTION__); + _lastAppliedAutomaticViewTracking = nil; +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_OSX) +#ifndef COUNTLY_EXCLUDE_PUSHNOTIFICATIONS + CountlyRunOnMainThread(^{ + [CountlyPushNotifications.sharedInstance startPushNotifications]; + }); +#endif +#endif + } + if (wasTurnedOn) + { + CLY_LOG_D(@"%s consent requirement was turned on by behavior settings, consents will be resent", __FUNCTION__); + [CountlyConsentManager.sharedInstance sendConsents]; + if (!CountlyConsentManager.sharedInstance.consentForLocation) + { + [CountlyConnectionManager.sharedInstance sendLocationInfo]; + } + + // crash handlers installed while no consent was required would otherwise keep capturing, and PLCrashReporter would + // send that capture on the next start + if (!CountlyConsentManager.sharedInstance.consentForCrashReporting) + { + [CountlyCrashReporter.sharedInstance stopCrashReporting]; + } + } +} + +- (void)applyLocationTrackingState +{ + if (_locationTracking || CountlyLocationManager.sharedInstance.isLocationInfoDisabled) + return; + + CLY_LOG_D(@"%s location tracking is disabled by behavior settings, location info will be cleared", __FUNCTION__); + [CountlyLocationManager.sharedInstance disableLocation]; + [CountlyConnectionManager.sharedInstance sendLocationInfo]; +} + +// Applies the resolved 'ast', 'avt' and 'acr' values, acting only on what changed, so it is safe after every settings response. +- (void)applyAutomaticTrackingState +{ +// The platform guard matches where automatic view tracking is actually implemented, which is narrower +// than what the header declares +#if (TARGET_OS_IOS || TARGET_OS_TV) + BOOL shouldAutoTrackViews = _viewTrackingEnabled && _automaticViewTrackingEnabled; + BOOL hasChanged = _lastAppliedAutomaticViewTracking == nil || _lastAppliedAutomaticViewTracking.boolValue != shouldAutoTrackViews; + _lastAppliedAutomaticViewTracking = @(shouldAutoTrackViews); + if (hasChanged) + { + CountlyRunOnMainThread(^{ + BOOL isActive = CountlyViewTrackingInternal.sharedInstance.isAutoViewTrackingActive; + if (shouldAutoTrackViews && !isActive) + { + CLY_LOG_D(@"[CountlyServerConfig] applyAutomaticTrackingState, behavior settings enable automatic view tracking, it will be started"); + [CountlyViewTrackingInternal.sharedInstance startAutoViewTracking]; + } + else if (!shouldAutoTrackViews && isActive) + { + CLY_LOG_D(@"[CountlyServerConfig] applyAutomaticTrackingState, behavior settings disable automatic view tracking, it will be stopped"); + [CountlyViewTrackingInternal.sharedInstance stopAutoViewTracking]; + } + }); + } +#endif + + CountlyRunOnMainThread(^{ + [Countly.sharedInstance applyAutomaticSessionTracking]; + }); + + // A runtime 'acr' = false does not uninstall the handler; it no-ops through its own runtime check + // instead, so a later 'acr' = true does not have to reinstall it. + if (_crashReportingEnabled && _automaticCrashReportingEnabled) + { + [CountlyCrashReporter.sharedInstance startCrashReporting]; + } + else + { + CLY_LOG_D(@"%s automatic crash reporting is disabled by behavior settings, the crash handler will not be started, crashReporting: [%@], automaticCrashReporting: [%@]", __FUNCTION__, _crashReportingEnabled ? @"YES" : @"NO", _automaticCrashReportingEnabled ? @"YES" : @"NO"); + [CountlyCrashReporter.sharedInstance suppressAutomaticCrashCapture]; } } - (void)fetchServerConfigTimer:(NSTimer *)timer { CountlyConfig *config = (CountlyConfig *)timer.userInfo; // Retrieve CountlyConfig from userInfo + CLY_LOG_D(@"%s behavior settings refresh timer fired, configAvailable: [%@]", __FUNCTION__, config ? @"YES" : @"NO"); if (config) { [self fetchServerConfig:config]; @@ -434,9 +907,10 @@ - (void)fetchServerConfigTimer:(NSTimer *)timer - (void)fetchServerConfigIfTimeIsUp { if (_serverConfigUpdatesDisabled) { + CLY_LOG_D(@"%s periodic behavior settings check skipped, updates are disabled", __FUNCTION__); return; } - + if (_lastFetchTimestamp) { long long currentTime = NSDate.date.timeIntervalSince1970 * 1000; @@ -444,22 +918,28 @@ - (void)fetchServerConfigIfTimeIsUp if (timePassed > _currentServerConfigUpdateInterval * 60 * 60 * 1000) { - [self fetchServerConfig:CountlyConfig.new]; + CLY_LOG_D(@"%s behavior settings refresh interval elapsed, a fetch will be triggered, timePassedMs: [%lld], intervalHours: [%ld]", __FUNCTION__, timePassed, (long)_currentServerConfigUpdateInterval); + [self fetchServerConfig:_startConfig ?: CountlyConfig.new]; } } } - (void)fetchServerConfig:(CountlyConfig *)config { - CLY_LOG_D(@"%s, fetching sdk behavior settings", __FUNCTION__); - + CLY_LOG_D(@"%s starting a behavior settings fetch, currentVersion: [%ld], updateIntervalHours: [%ld]", __FUNCTION__, (long)_version, (long)_currentServerConfigUpdateInterval); + if (_serverConfigUpdatesDisabled) { - CLY_LOG_D(@"%s, sdk behavior settings updates disabled, omitting fetch", __FUNCTION__); + CLY_LOG_D(@"%s fetch aborted, behavior settings updates are disabled", __FUNCTION__); + [CountlyCommon.sharedInstance decideLogGatheringOffIfUndecided:@"behavior settings updates are disabled, no directive can ever arrive"]; return; } - + if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) + { + CLY_LOG_D(@"%s fetch aborted, sdk is in temporary device ID mode", __FUNCTION__); + [CountlyCommon.sharedInstance decideLogGatheringOffIfUndecided:@"temporary device ID mode, no server response this run"]; return; + } _lastFetchTimestamp = NSDate.date.timeIntervalSince1970 * 1000; @@ -469,14 +949,33 @@ - (void)fetchServerConfig:(CountlyConfig *)config [NSRunLoop.mainRunLoop addTimer:_requestTimer forMode:NSRunLoopCommonModes]; } + NSDate *fetchStart = NSDate.date; + NSString *requestedDeviceID = CountlyDeviceInfo.sharedInstance.deviceID; + NSUInteger requestedDeviceIDType = CountlyDeviceInfo.sharedInstance.deviceIDTypeValue; + NSUInteger requestedDeviceIDChangeCount = CountlyCommon.sharedInstance.deviceIDChangeWithoutMergeCount; + NSUInteger requestedDeviceIDMergeCount = CountlyCommon.sharedInstance.deviceIDMergeCount; id handler = ^(NSData *data, NSURLResponse *response, NSError *error) { - NSDictionary *serverConfigResponse = nil; + if (self != s_sharedInstance) + { + // the SDK was halted while this fetch was in flight, its answer must not decide anything for the next run + CLY_LOG_D(@"%s, response arrived after halt, ignoring", __FUNCTION__); + return; + } + + // wall clock of the whole fetch, the connection test reports it as its 'sc' row + long long fetchLatencyMs = (long long)([NSDate.date timeIntervalSinceDate:fetchStart] * 1000); + NSMutableDictionary *serverConfigResponse = nil; if (!error) { - serverConfigResponse = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; - CLY_LOG_D(@"Server Config Fetched: %@", serverConfigResponse.description); + id parsed = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; + if ([parsed isKindOfClass:NSDictionary.class]) + serverConfigResponse = [parsed mutableCopy]; + CLY_LOG_D(@"[CountlyServerConfig] fetchServerConfig response, behavior settings received from network, statusCode: [%ld], responseKeyCount: [%lu], parsed: [%@]", (long)((NSHTTPURLResponse *)response).statusCode, (unsigned long)serverConfigResponse.count, error ? @"NO" : @"YES"); } + // read only here, from a live response, and removed before anything below sees the response so it is never cached + BOOL connectionTestArmed = [self extractConnectionTestFlag:serverConfigResponse]; + if (!error) { if (((NSHTTPURLResponse *)response).statusCode != 200) @@ -489,26 +988,50 @@ - (void)fetchServerConfig:(CountlyConfig *)config if (error) { - CLY_LOG_E(@"Error while fetching server configs: %@", error.description); + CLY_LOG_D(@"[CountlyServerConfig] fetchServerConfig response, behavior settings fetch failed, previously stored behavior settings will be kept, statusCode: [%ld], error: [%@]", (long)((NSHTTPURLResponse *)response).statusCode, error.localizedDescription); } - - if (serverConfigResponse[kRConfig] != nil) + else if (serverConfigResponse[kRConfig] != nil) { NSMutableDictionary *persistentBehaviorSettings = [CountlyPersistency.sharedInstance retrieveServerConfig]; - [self mergeBehaviorSettings:persistentBehaviorSettings withConfig:serverConfigResponse]; - [CountlyPersistency.sharedInstance storeServerConfig:persistentBehaviorSettings]; + [self replaceBehaviorSettings:persistentBehaviorSettings withConfig:serverConfigResponse]; [self populateServerConfig:persistentBehaviorSettings withConfig:config]; + [CountlyPersistency.sharedInstance storeServerConfig:persistentBehaviorSettings]; + CLY_LOG_D(@"[CountlyServerConfig] fetchServerConfig response, fetched behavior settings replaced the stored ones and were persisted, storedKeyCount: [%lu]", (unsigned long)persistentBehaviorSettings.count); + } + else + { + CLY_LOG_D(@"[CountlyServerConfig] fetchServerConfig response, no config section in the response, previously stored behavior settings will be kept"); } + + if (error) + { + // a failed fetch decides against gathering only while nothing has been decided yet, a + // running gather survives a transient failure of the periodic refetch + [CountlyCommon.sharedInstance decideLogGatheringOffIfUndecided:@"server config fetch failed"]; + return; + } + + // only a fresh response decides log gathering, never a stored config. A live response without a + // usable directive decides against it. A merge keeps the user, and the server moves the gather with it + if (requestedDeviceIDChangeCount == CountlyCommon.sharedInstance.deviceIDChangeWithoutMergeCount) + [self applyLogGatheringDirective:serverConfigResponse[kRLogGathering]]; + else + CLY_LOG_D(@"%s, the device ID changed without merge while the fetch was in flight, its log gathering directive belongs to the previous user and is ignored", __FUNCTION__); + + if (connectionTestArmed) + [CountlyConnectionTest.sharedInstance startBatteryWithServerConfigLatency:fetchLatencyMs deviceID:requestedDeviceID deviceIDType:requestedDeviceIDType deviceIDMergeCount:requestedDeviceIDMergeCount]; }; // Set default values - NSURLSessionTask *task = [CountlyCommon.sharedInstance.URLSession dataTaskWithRequest:[self serverConfigRequest] completionHandler:handler]; + NSURLSessionTask *task = [CountlyCommon.sharedInstance immediateDataTaskWithRequest:[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]; @@ -520,6 +1043,7 @@ - (NSURLRequest *)serverConfigRequest if (queryString.length > kCountlyGETRequestMaxLength || CountlyConnectionManager.sharedInstance.alwaysUsePOST) { + CLY_LOG_D(@"%s behavior settings request prepared as POST, queryLength: [%lu]", __FUNCTION__, (unsigned long)queryString.length); NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:URL]]; request.HTTPMethod = @"POST"; request.HTTPBody = [queryString cly_dataUTF8]; @@ -527,57 +1051,85 @@ - (NSURLRequest *)serverConfigRequest } else { + CLY_LOG_D(@"%s behavior settings request prepared as GET, queryLength: [%lu]", __FUNCTION__, (unsigned long)queryString.length); [URL appendFormat:@"?%@", queryString]; NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:URL]]; return request; } - - CLY_LOG_D(@"serverConfigRequest URL :%@", URL); } - (void)setDefaultValues { - _trackingEnabled = YES; - _networkingEnabled = YES; - _crashReportingEnabled = YES; - _customEventTrackingEnabled = YES; - _enterContentZone = NO; - _locationTracking = YES; - _viewTrackingEnabled = YES; - _sessionTrackingEnabled = YES; - _loggingEnabled = NO; - _refreshContentZone = YES; - _backoffMechanism = YES; - _bomAcceptedTimeoutSeconds = 10; - _bomRQPercentage = 0.5; - _bomRequestAge = 24; - _bomDuration = 60; - - _eventQueueSize = 100; - _requestQueueSize = 1000; - _sessionInterval = 60; - _limitKeyLength = 128; - _limitValueSize = 256; - _limitSegValues = 100; - _limitBreadcrumb = 100; - _limitTraceLine = 200; - _limitTraceLength = 30; - _consentRequired = NO; - _dropOldRequestTime = 0; - _contentZoneInterval = 30; - - _eventFilterSet = [NSSet set]; - _eventFilterIsWhitelist = NO; - _userPropertyFilterSet = [NSSet set]; - _userPropertyFilterIsWhitelist = NO; - _userPropertyCacheLimit = 100; - _segmentationFilterSet = [NSSet set]; - _segmentationFilterIsWhitelist = NO; - _eventSegmentationFilterMap = @{}; - _eventSegmentationFilterIsWhitelist = NO; - _journeyTriggerEvents = [NSSet set]; + [self revertSettingsMissingFrom:@{}]; + + CLY_LOG_D(@"%s behavior settings reset to built in SDK defaults", __FUNCTION__); +} + +/// Puts every setting the given behavior settings do not carry back to its default, so a setting the server stops +/// sending does not keep its last server value. The automatic tracking flags go back to the developer's values from +/// start, and the integer settings to 0, which notifySdkConfigChange: resolves to the developer's value. It runs after +/// the carried settings are applied, so a carried setting is never reset in between. +- (void)revertSettingsMissingFrom:(NSDictionary *)dictionary +{ + if (!dictionary[kTracking]) _trackingEnabled = YES; + if (!dictionary[kNetworking]) _networkingEnabled = YES; + if (!dictionary[kRCrashReporting]) _crashReportingEnabled = YES; + if (!dictionary[kRAutomaticSessionTracking]) _automaticSessionTrackingEnabled = [self developerValueForKey:kRAutomaticSessionTracking orCurrent:@YES].boolValue; + if (!dictionary[kRAutomaticViewTracking]) _automaticViewTrackingEnabled = [self developerValueForKey:kRAutomaticViewTracking orCurrent:@NO].boolValue; + if (!dictionary[kRAutomaticCrashReporting]) _automaticCrashReportingEnabled = [self developerValueForKey:kRAutomaticCrashReporting orCurrent:@NO].boolValue; + if (!dictionary[kRCustomEventTracking]) _customEventTrackingEnabled = YES; + if (!dictionary[kREnterContentZone]) _enterContentZone = NO; + if (!dictionary[kRLocationTracking]) _locationTracking = YES; + if (!dictionary[kRViewTracking]) _viewTrackingEnabled = YES; + if (!dictionary[kRSessionTracking]) _sessionTrackingEnabled = YES; + if (!dictionary[kRLogging]) _loggingEnabled = NO; + if (!dictionary[kRRefreshContentZone]) _refreshContentZone = YES; + if (!dictionary[kRbackoffMechanism]) _backoffMechanism = YES; + if (!dictionary[kRBOMAcceptedTimeout]) _bomAcceptedTimeoutSeconds = 10; + if (!dictionary[kRBOMRQPercentage]) _bomRQPercentage = 0.5; + if (!dictionary[kRBOMRequestAge]) _bomRequestAge = 24; + if (!dictionary[kRBOMDuration]) _bomDuration = 60; + + if (!dictionary[kREventQueueSize]) _eventQueueSize = 0; + if (!dictionary[kRReqQueueSize]) _requestQueueSize = 0; + if (!dictionary[kRSessionUpdateInterval]) _sessionInterval = 0; + if (!dictionary[kRLimitKeyLength]) _limitKeyLength = 0; + if (!dictionary[kRLimitValueSize]) _limitValueSize = 0; + if (!dictionary[kRLimitSegValues]) _limitSegValues = 0; + if (!dictionary[kRLimitBreadcrumb]) _limitBreadcrumb = 0; + if (!dictionary[kRLimitTraceLine]) _limitTraceLine = 0; + if (!dictionary[kRLimitTraceLength]) _limitTraceLength = 0; + if (!dictionary[kRConsentRequired]) _consentRequired = NO; + if (!dictionary[kRDropOldRequestTime]) _dropOldRequestTime = 0; + if (!dictionary[kRContentZoneInterval]) _contentZoneInterval = 0; + if (!dictionary[kRServerConfigUpdateInterval]) _serverConfigUpdateInterval = 0; + if (!dictionary[kRUserPropertyCacheLimit]) _userPropertyCacheLimit = 100; + + if (!dictionary[kREventBlacklist] && !dictionary[kREventWhitelist]) + { + self.eventFilterSet = [NSSet set]; + _eventFilterIsWhitelist = NO; + } + if (!dictionary[kRUserPropertyBlacklist] && !dictionary[kRUserPropertyWhitelist]) + { + self.userPropertyFilterSet = [NSSet set]; + _userPropertyFilterIsWhitelist = NO; + } + if (!dictionary[kRSegmentationBlacklist] && !dictionary[kRSegmentationWhitelist]) + { + self.segmentationFilterSet = [NSSet set]; + _segmentationFilterIsWhitelist = NO; + } + if (!dictionary[kREventSegmentationBlacklist] && !dictionary[kREventSegmentationWhitelist]) + { + self.eventSegmentationFilterMap = @{}; + _eventSegmentationFilterIsWhitelist = NO; + } + if (!dictionary[kRJourneyTriggerEvents]) self.journeyTriggerEvents = [NSSet set]; + if (!dictionary[kRJourneyTriggerViews]) self.journeyTriggerViews = [NSSet set]; } - (void)disableSDKBehaviourSettings { + CLY_LOG_I(@"%s behavior settings updates are being disabled", __FUNCTION__); _serverConfigUpdatesDisabled = YES; } @@ -611,6 +1163,21 @@ - (BOOL)crashReportingEnabled return _crashReportingEnabled; } +- (BOOL)automaticSessionTrackingEnabled +{ + return _automaticSessionTrackingEnabled; +} + +- (BOOL)automaticViewTrackingEnabled +{ + return _automaticViewTrackingEnabled; +} + +- (BOOL)automaticCrashReportingEnabled +{ + return _automaticCrashReportingEnabled; +} + - (BOOL)sessionTrackingEnabled { return _sessionTrackingEnabled; @@ -692,7 +1259,8 @@ - (BOOL)refreshContentZoneEnabled - (BOOL)backoffMechanism { - return _backoffMechanism; + // read here rather than written into the resolved value, which every answer resets to the server's or the default + return _backoffMechanism && !_isBackoffDisabledByDeveloper; } - (NSInteger)bomAcceptedTimeoutSeconds @@ -727,80 +1295,66 @@ - (NSInteger)userPropertyCacheLimit #pragma mark - Listing Filters -- (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) - { - if (newConfig[key]) { newHasWhitelist = YES; break; } - } - for (NSString *key in blacklistKeys) - { - if (newConfig[key]) { newHasBlacklist = YES; break; } - } - - if (newHasWhitelist) - { - for (NSString *key in blacklistKeys) - { - [mergedConfig removeObjectForKey:key]; - } - } - if (newHasBlacklist) - { - for (NSString *key in whitelistKeys) - { - [mergedConfig removeObjectForKey:key]; - } - } -} - -- (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]; NSArray *ew = dictionary[kREventWhitelist]; if ([eb isKindOfClass:NSArray.class]) { - _eventFilterSet = [NSSet setWithArray:eb]; + self.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]; + self.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 NSArray *upb = dictionary[kRUserPropertyBlacklist]; NSArray *upw = dictionary[kRUserPropertyWhitelist]; if ([upb isKindOfClass:NSArray.class]) { - _userPropertyFilterSet = [NSSet setWithArray:upb]; + self.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]; + self.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 NSArray *sb = dictionary[kRSegmentationBlacklist]; NSArray *sw = dictionary[kRSegmentationWhitelist]; if ([sb isKindOfClass:NSArray.class]) { - _segmentationFilterSet = [NSSet setWithArray:sb]; + self.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]; + self.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 @@ -813,9 +1367,11 @@ - (void)updateListingFilters:(NSDictionary *)dictionary logString:(NSMutableStri map[key] = [NSSet setWithArray:obj]; } }]; - _eventSegmentationFilterMap = map.copy; + self.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) { @@ -823,29 +1379,51 @@ - (void)updateListingFilters:(NSDictionary *)dictionary logString:(NSMutableStri map[key] = [NSSet setWithArray:obj]; } }]; - _eventSegmentationFilterMap = map.copy; + self.eventSegmentationFilterMap = map.copy; _eventSegmentationFilterIsWhitelist = YES; [logString appendFormat:@"%@: %@, ", kREventSegmentationWhitelist, esw]; + } else { + if (esb) + [dictionary removeObjectForKey:kREventSegmentationBlacklist]; + if (esw) + [dictionary removeObjectForKey:kREventSegmentationWhitelist]; } // Journey trigger events (jte) NSArray *jte = dictionary[kRJourneyTriggerEvents]; if ([jte isKindOfClass:NSArray.class]) { - _journeyTriggerEvents = [NSSet setWithArray:jte]; + self.journeyTriggerEvents = [NSSet setWithArray:jte]; [logString appendFormat:@"%@: %@, ", kRJourneyTriggerEvents, jte]; + } else { + if (jte) + [dictionary removeObjectForKey:kRJourneyTriggerEvents]; + } + + // Journey trigger views (jtv) + NSArray *jtv = dictionary[kRJourneyTriggerViews]; + if ([jtv isKindOfClass:NSArray.class]) { + self.journeyTriggerViews = [NSSet setWithArray:jtv]; + [logString appendFormat:@"%@: %@, ", kRJourneyTriggerViews, jtv]; + } else { + if (jtv) + [dictionary removeObjectForKey:kRJourneyTriggerViews]; } + + CLY_LOG_D(@"%s resolved listing filters, eventFilterCount: [%lu], eventFilterIsWhitelist: [%@], userPropertyFilterCount: [%lu], userPropertyFilterIsWhitelist: [%@], segmentationFilterCount: [%lu], segmentationFilterIsWhitelist: [%@], eventSegmentationFilteredEventCount: [%lu], eventSegmentationFilterIsWhitelist: [%@], journeyTriggerEventCount: [%lu], journeyTriggerViewCount: [%lu]", __FUNCTION__, (unsigned long)self.eventFilterSet.count, _eventFilterIsWhitelist ? @"YES" : @"NO", (unsigned long)self.userPropertyFilterSet.count, _userPropertyFilterIsWhitelist ? @"YES" : @"NO", (unsigned long)self.segmentationFilterSet.count, _segmentationFilterIsWhitelist ? @"YES" : @"NO", (unsigned long)self.eventSegmentationFilterMap.count, _eventSegmentationFilterIsWhitelist ? @"YES" : @"NO", (unsigned long)self.journeyTriggerEvents.count, (unsigned long)self.journeyTriggerViews.count); } - (BOOL)shouldRecordEvent:(NSString *)eventKey { - if (_eventFilterSet.count == 0) return YES; - return _eventFilterIsWhitelist == [_eventFilterSet containsObject:eventKey]; + NSSet *eventFilterSet = self.eventFilterSet; + if (eventFilterSet.count == 0) return YES; + return _eventFilterIsWhitelist == [eventFilterSet containsObject:eventKey]; } - (BOOL)shouldRecordUserProperty:(NSString *)propertyKey { - if (_userPropertyFilterSet.count == 0) return YES; - return _userPropertyFilterIsWhitelist == [_userPropertyFilterSet containsObject:propertyKey]; + NSSet *userPropertyFilterSet = self.userPropertyFilterSet; + if (userPropertyFilterSet.count == 0) return YES; + return _userPropertyFilterIsWhitelist == [userPropertyFilterSet containsObject:propertyKey]; } - (NSDictionary *)filterSegmentation:(NSDictionary *)segmentation eventKey:(NSString *)eventKey @@ -854,8 +1432,9 @@ - (NSDictionary *)filterSegmentation:(NSDictionary *)segmentation eventKey:(NSSt return segmentation; } - BOOL hasGlobalFilter = _segmentationFilterSet.count > 0; - NSSet *eventFilter = _eventSegmentationFilterMap[eventKey]; + NSSet *segmentationFilterSet = self.segmentationFilterSet; + BOOL hasGlobalFilter = segmentationFilterSet.count > 0; + NSSet *eventFilter = self.eventSegmentationFilterMap[eventKey]; BOOL hasEventFilter = eventFilter.count > 0; if (!hasGlobalFilter && !hasEventFilter) { @@ -864,12 +1443,12 @@ - (NSDictionary *)filterSegmentation:(NSDictionary *)segmentation eventKey:(NSSt NSMutableDictionary *result = [segmentation mutableCopy]; for (NSString *key in segmentation.allKeys) { - if (hasGlobalFilter && _segmentationFilterIsWhitelist != [_segmentationFilterSet containsObject:key]) { - CLY_LOG_D(@"Filtering out segmentation key '%@' by global segmentation filter", key); + if (hasGlobalFilter && _segmentationFilterIsWhitelist != [segmentationFilterSet containsObject:key]) { + CLY_LOG_V(@"%s dropping segmentation key by the global segmentation filter, key: [%@], filterIsWhitelist: [%@]", __FUNCTION__, key, _segmentationFilterIsWhitelist ? @"YES" : @"NO"); [result removeObjectForKey:key]; } else if (hasEventFilter && _eventSegmentationFilterIsWhitelist != [eventFilter containsObject:key]) { - CLY_LOG_D(@"Filtering out segmentation key '%@' for event '%@' by event segmentation filter", key, eventKey); + CLY_LOG_V(@"%s dropping segmentation key by the event level segmentation filter, key: [%@], eventKey: [%@]", __FUNCTION__, key, eventKey); [result removeObjectForKey:key]; } } @@ -879,7 +1458,12 @@ - (NSDictionary *)filterSegmentation:(NSDictionary *)segmentation eventKey:(NSSt - (BOOL)isJourneyTriggerEvent:(NSString *)eventKey { - return [_journeyTriggerEvents containsObject:eventKey]; + return [self.journeyTriggerEvents containsObject:eventKey]; +} + +- (BOOL)isJourneyTriggerView:(NSString *)viewName +{ + return [self.journeyTriggerViews containsObject:viewName]; } @end diff --git a/ios/src/CountlyTests.xctestplan b/ios/src/CountlyTests.xctestplan new file mode 100644 index 00000000..4c42823c --- /dev/null +++ b/ios/src/CountlyTests.xctestplan @@ -0,0 +1,27 @@ +{ + "configurations" : [ + { + "id" : "9FACA4DE-8744-4EA8-9C62-DDE2EB48BB2E", + "name" : "Test Scheme Action", + "options" : { + "maximumParallelTestProcesses" : 1 + } + } + ], + "defaultOptions" : { + "testTimeoutsEnabled" : true, + "threadSanitizerEnabled" : false, + "undefinedBehaviorSanitizerEnabled" : true + }, + "testTargets" : [ + { + "parallelizable" : false, + "target" : { + "containerPath" : "container:Countly.xcodeproj", + "identifier" : "1A5C4C932B35B0850032EE1F", + "name" : "CountlyTests" + } + } + ], + "version" : 1 +} diff --git a/ios/src/CountlyTests/CountlyBaseTestCase.swift b/ios/src/CountlyTests/CountlyBaseTestCase.swift index e98a6147..093f5b82 100644 --- a/ios/src/CountlyTests/CountlyBaseTestCase.swift +++ b/ios/src/CountlyTests/CountlyBaseTestCase.swift @@ -17,8 +17,29 @@ class CountlyBaseTestCase: XCTestCase { override func setUpWithError() throws { // Put setup code here. This method is called before the invocation of each test method in the class. + #if os(macOS) || os(iOS) || os(tvOS) || os(visionOS) + // The test host is not necessarily a foreground app (xctest on macOS, the device + // farm's XCTRunner on iOS); report active so every host exercises the same + // automatic-session paths. See TestAppActivation. + TestAppActivation.installIfNeeded() + TestAppActivation.isActive = true + #endif cleanupState() } + + /// `CountlyConfig.sdkInternalLimits` is a process-wide object, not per-config, so a test + /// (or a server config response) that lowers a limit leaves it lowered for every test + /// that runs afterwards. Restore the defaults from `CountlySDKLimitsConfig`. + func resetSharedSDKLimits() { + let limits = CountlyConfig().sdkInternalLimits() + limits.setMaxKeyLength(128) + limits.setMaxValueSize(256) + limits.setMaxValueSizePicture(4096) + limits.setMaxSegmentationValues(100) + limits.setMaxBreadcrumbCount(100) + limits.setMaxStackTraceLineLength(200) + limits.setMaxStackTraceLinesPerThread(30) + } func createBaseConfig() -> CountlyConfig { let config: CountlyConfig = CountlyConfig() @@ -26,14 +47,29 @@ class CountlyBaseTestCase: XCTestCase { config.host = host config.enableDebug = true config.features = [CLYFeature.crashReporting]; + // Hostless simulator runs can never report an orientation; off keeps hosted runs (device farm) identical. + config.enableOrientationTracking = false return config } override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. + // Restore the shared SDK limits here rather than in setUp: a test legitimately lowers + // them through its own server config, so resetting beforehand would fight the test + // itself. Cleaning up afterwards leaves the next test unaffected either way. + resetSharedSDKLimits() } func cleanupState() { + // 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/ios/src/CountlyTests/CountlyCallbackBaseTestCase.swift b/ios/src/CountlyTests/CountlyCallbackBaseTestCase.swift new file mode 100644 index 00000000..21547cc6 --- /dev/null +++ b/ios/src/CountlyTests/CountlyCallbackBaseTestCase.swift @@ -0,0 +1,163 @@ +// +// CountlyCallbackBaseTestCase.swift +// CountlyTests +// +// Shared base class for callback-related tests. +// Provides common SDK setup, MockURLProtocol configuration, and helpers. +// + +import XCTest +@testable import Countly + +/// Base test class for callback tests (CLYRequestCallback and CLYQueueFlushRunnable). +/// Uses class-level setup to start SDK once, avoiding the halt() singleton issue. +class CountlyCallbackBaseTestCase: XCTestCase { + + // MARK: - Static SDK Setup + + static let testAppKey = "appkey" + static let testHost = "https://testing.count.ly/" + + override class func setUp() { + super.setUp() + + // Always halt first to clear state from previous test classes, + // ensuring start(with:) accepts our MockURLProtocol config + // All cleanup logic now handled inside Countly.halt(true) + Countly.sharedInstance().halt(true) + + // Configure MockURLProtocol to return valid JSON by default + MockURLProtocol.requestHandler = createSuccessHandler() + + let config = CountlyConfig() + config.appKey = testAppKey + config.host = testHost + config.enableDebug = true + config.manualSessionHandling = true + let sessionConfig = URLSessionConfiguration.default + sessionConfig.protocolClasses = [MockURLProtocol.self] + config.urlSessionConfiguration = sessionConfig + Countly.sharedInstance().start(with: config) + } + + // MARK: - Instance Setup/Teardown + + override func setUpWithError() throws { + try super.setUpWithError() + // These tests all fake HTTP responses through MockURLProtocol, which watchOS ignores. + try TestPlatform.skipUnlessHTTPInterceptable() + #if os(macOS) || os(iOS) || os(tvOS) || os(visionOS) + // This class deliberately derives from XCTestCase rather than CountlyBaseTestCase + // (it starts the SDK once per class), so it has to install the activation shim + // itself. Without it the test host can look inactive and the SDK refuses to open + // a session, which starves the request callbacks these tests wait on. + TestAppActivation.isActive = true + #endif + } + + override func setUp() { + super.setUp() + // Reset MockURLProtocol to success handler for each test + MockURLProtocol.requestHandler = Self.createSuccessHandler() + // Clear any leftover runnables + connectionManager?.clearQueueFlushRunnables() + // Drain any pending queue requests + drainQueue() + } + + override func tearDown() { + connectionManager?.clearQueueFlushRunnables() + super.tearDown() + } + + // MARK: - Connection Manager Helper + + var connectionManager: CountlyConnectionManager? { + return CountlyConnectionManager.sharedInstance() + } + + // MARK: - Queue Helpers + + /// Drain the request queue to avoid state pollution between tests + func drainQueue() { + var waitCount = 0 + while CountlyPersistency.sharedInstance().remainingRequestCount() > 0 && waitCount < 50 { + connectionManager?.proceedOnQueue() + Thread.sleep(forTimeInterval: 0.1) + waitCount += 1 + } + } + + // MARK: - MockURLProtocol Helpers + + /// Type alias for request handler closure + typealias RequestHandler = (URLRequest) -> (Data?, URLResponse?, Error?) + + /// Creates a success handler returning valid JSON with status 200 + static func createSuccessHandler() -> RequestHandler { + return { request in + let jsonResponse = Data("{\"result\":\"Success\"}".utf8) + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + return (jsonResponse, response, nil) + } + } + + /// Creates a success handler with custom status code (2xx range) + static func createSuccessHandler(statusCode: Int, result: String = "Success") -> RequestHandler { + return { request in + let jsonResponse = Data("{\"result\":\"\(result)\"}".utf8) + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + return (jsonResponse, response, nil) + } + } + + /// Creates an error handler returning specified HTTP status code + static func createErrorHandler(statusCode: Int, message: String) -> RequestHandler { + return { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: nil + )! + return (Data(message.utf8), response, nil) + } + } + + /// Creates a handler returning plain text (invalid JSON) with 200 + static func createInvalidJSONHandler() -> RequestHandler { + return { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil + )! + return (Data("OK".utf8), response, nil) + } + } + + /// Creates a handler returning JSON without "result" key + static func createMissingResultKeyHandler() -> RequestHandler { + return { request in + let jsonResponse = Data("{\"status\":\"ok\"}".utf8) + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + return (jsonResponse, response, nil) + } + } +} diff --git a/ios/src/CountlyTests/CountlyConnectionManagerTests.swift b/ios/src/CountlyTests/CountlyConnectionManagerTests.swift index e2812d0b..5bd1b728 100644 --- a/ios/src/CountlyTests/CountlyConnectionManagerTests.swift +++ b/ios/src/CountlyTests/CountlyConnectionManagerTests.swift @@ -11,6 +11,19 @@ import XCTest class CountlyConnectionManagerTests: CountlyBaseTestCase { + + override func setUp() { + super.setUp() + // Initialize or reset necessary objects here + Countly.sharedInstance().halt(true) + TestURLProtocol.reset() + } + + override func tearDown() { + // Ensure everything is cleaned up properly + super.tearDown() + Countly.sharedInstance().halt(true) + } /** *
      * 1- Init countly with the limit of 250 requests
@@ -63,10 +76,439 @@ class CountlyConnectionManagerTests: CountlyBaseTestCase {
         
     }
     
+    /**
+     * addCustomNetworkRequestHeaders after SDK init
+     * validate that added network headers are existing with outgoing requests
+     * intercept request with a test protocol and validate existance of 2 added headers
+     */
+    func test_addCustomNetworkRequestHeaders() throws {
+        try TestPlatform.skipUnlessHTTPInterceptable()
+        let config = createBaseConfig()
+        let sessionConfig = URLSessionConfiguration.default
+        sessionConfig.protocolClasses = [TestURLProtocol.self]
+        config.urlSessionConfiguration = sessionConfig
+        
+        Countly.sharedInstance().start(with: config)
+        // Add custom headers through the Objective-C API
+        let customHeaders: [String: String] = [
+            "Authorization": "Bearer 123",
+            "X-Test": "Value1"
+        ]
+        
+        Countly.sharedInstance().addCustomNetworkRequestHeaders(customHeaders)
+        Countly.sharedInstance().addDirectRequest(["test": "request"])
+
+        TestUtils.waitUntil("the request to carry the custom headers") {
+            TestURLProtocol.capturedHeaders()?["Authorization"] == "Bearer 123"
+        }
+
+        let captured = TestURLProtocol.capturedHeaders()
+        XCTAssertEqual(captured?["Authorization"], "Bearer 123")
+        XCTAssertEqual(captured?["X-Test"], "Value1")
+        XCTAssertEqual(captured?.count, 2)
+    }
+    
+    /**
+     * addCustomNetworkRequestHeaders after SDK init and while initializing the SDK
+     * validate that added network headers are existing with outgoing requests and 1 header overridden by post-init headers
+     * after SDK init validate that given 1 header exists
+     * intercept request with a test protocol and validate existance of 2 added headers and validate one header is overridden
+     */
+    func test_addCustomNetworkRequestHeaders_override() throws {
+        try TestPlatform.skipUnlessHTTPInterceptable()
+        let config = createBaseConfig()
+        let sessionConfig = URLSessionConfiguration.default
+        sessionConfig.protocolClasses = [TestURLProtocol.self]
+        sessionConfig.httpAdditionalHeaders = ["X-Test": "Value0"]
+        config.urlSessionConfiguration = sessionConfig
+        
+       
+        Countly.sharedInstance().start(with: config)
+        // Add custom headers through the Objective-C API
+        
+        TestUtils.sleep(1) {}
+        let captured = TestURLProtocol.capturedHeaders()
+        XCTAssertEqual(captured?["X-Test"], "Value0")
+        XCTAssertEqual(captured?.count, 1)
+        
+        let customHeaders: [String: String] = [
+            "Authorization": "Bearer 123",
+            "X-Test": "Value1"
+        ]
+        
+        Countly.sharedInstance().addCustomNetworkRequestHeaders(customHeaders)
+        Countly.sharedInstance().addDirectRequest(["test": "request"])
+        
+        TestUtils.sleep(2) {
+            let captured = TestURLProtocol.capturedHeaders()
+            XCTAssertEqual(captured?["Authorization"], "Bearer 123")
+            XCTAssertEqual(captured?["X-Test"], "Value1")
+            XCTAssertEqual(captured?.count, 2)
+        }
+    }
+    
+    /**
+     * addCustomNetworkRequestHeaders after SDK init invalid
+     * validate that added network headers are existing with outgoing requests and 1 header not there because it is invalid
+     * intercept request with a test protocol and validate that only 1 header exists
+     */
+    func test_addCustomNetworkRequestHeaders_invalid() throws {
+        try TestPlatform.skipUnlessHTTPInterceptable()
+        let config = createBaseConfig()
+        let sessionConfig = URLSessionConfiguration.default
+        sessionConfig.protocolClasses = [TestURLProtocol.self]
+        config.urlSessionConfiguration = sessionConfig
+        
+        Countly.sharedInstance().start(with: config)
+        // Add custom headers through the Objective-C API
+        let customHeaders: [String: String] = [
+            "Authorization": "",
+            "": "Value1"
+        ]
+        
+        Countly.sharedInstance().addCustomNetworkRequestHeaders(customHeaders)
+        Countly.sharedInstance().addDirectRequest(["test": "request"])
+
+        TestUtils.waitUntil("the direct request to be intercepted") {
+            TestURLProtocol.capturedHeaders(forRequestContaining: "test=request") != nil
+        }
+
+        let captured = try XCTUnwrap(TestURLProtocol.capturedHeaders(forRequestContaining: "test=request"), "The direct request was not intercepted")
+        // The empty key must never be forwarded.
+        XCTAssertNil(captured[""])
+        // The SDK keeps an empty value under a valid key, but Foundation strips
+        // empty-valued headers on macOS before they reach the URLProtocol, so accept
+        // either and assert that nothing else was added.
+        XCTAssertEqual("", captured["Authorization"] ?? "")
+        XCTAssertTrue(captured.count <= 1, "Unexpected headers forwarded: \(captured)")
+    }
+    
+    /**
+     * 
+     * Test that all outgoing requests use GET method by default
+     * when alwaysUsePOST is false and query strings are short.
+     *
+     * 1- Init SDK with MockURLProtocol, alwaysUsePOST = false
+     * 2- Call all possible SDK functions: sessions, events, user details,
+     *    location, direct request, attribution, views, crashes, APM, remote config, rating, feedbacks
+     * 3- Wait for requests to be sent
+     * 4- Verify all intercepted requests use GET method
+     * 5- Verify request URL contains query string (no HTTP body)
+     * 
+ */ + func test_allRequests_useGET_whenAlwaysUsePOSTDisabled() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + let expectation = self.expectation(description: "Requests intercepted") + expectation.assertForOverFulfill = false + + let expectedMinimumRequests = 10 + var capturedRequests: [URLRequest] = [] + let lock = NSLock() + MockURLProtocol.requestHandler = { request in + lock.lock() + capturedRequests.append(request) + if capturedRequests.count >= expectedMinimumRequests { + expectation.fulfill() + } + lock.unlock() + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)! + let body = "{\"result\":[]}".data(using: .utf8)! + return (body, response, nil) + } + + let config = createBaseConfig() + config.alwaysUsePOST = false + config.manualSessionHandling = true + config.disableSDKBehaviorSettingsUpdates = true + config.apm().enableForegroundBackgroundTracking = true + config.apm().enableAppStartTimeTracking = true + config.apm().enableManualAppLoadedTrigger = true + let sessionConfig = URLSessionConfiguration.default + sessionConfig.protocolClasses = [MockURLProtocol.self] + config.urlSessionConfiguration = sessionConfig + + Countly.sharedInstance().start(with: config) + + // Session begin + Countly.sharedInstance().beginSession() + + // Events + Countly.sharedInstance().recordEvent("test_event_1") + Countly.sharedInstance().recordEvent("test_event_2", segmentation: ["key": "value"], count: 1, sum: 1.5, duration: 10) + + // User details + Countly.user().set("custom_property", value: "custom_value") + Countly.user().increment("login_count") + Countly.user().push("tags", value: "vip") + Countly.user().save() + + // Location + Countly.sharedInstance().recordLocation(CLLocationCoordinate2D(latitude: 41.0082, longitude: 28.9784), city: "Istanbul", isoCountryCode: "TR", ip: "1.2.3.4") + + // Direct request + Countly.sharedInstance().addDirectRequest(["custom_key": "custom_value"]) + + // Attribution + Countly.sharedInstance().recordDirectAttribution(withCampaignType: "_special_test", andCampaignData: "{\"cid\":\"test_campaign\"}") + Countly.sharedInstance().recordIndirectAttribution(["idfa": "test_idfa_value"]) + + // Views + let _ = Countly.sharedInstance().views().startAutoStoppedView("TestView") + let _ = Countly.sharedInstance().views().startView("TestView2") + Countly.sharedInstance().views().stopView(withName: "TestView2") + + // Crash/Error reporting + Countly.sharedInstance().recordError("TestError", isFatal: false, stackTrace: ["frame1", "frame2"], segmentation: ["errorType": "test"]) + + // Performance monitoring (APM) + Countly.sharedInstance().recordNetworkTrace("test_trace", requestPayloadSize: 100, responsePayloadSize: 200, responseStatusCode: 200, startTime: 1111, endTime: 2222) + + // Remote config + Countly.sharedInstance().remoteConfig().downloadKeys { _, _, _, _ in } + Countly.sharedInstance().remoteConfig().enrollIntoABTests(forKeys: ["test_key"]) + Countly.sharedInstance().remoteConfig().exitABTests(forKeys: ["test_key"]) + + // Feedbacks and Rating (iOS only) + #if os(iOS) + Countly.sharedInstance().recordRatingWidget(withID: "test_widget_id", rating: 5, email: "test@test.com", comment: "Great", userCanBeContacted: true) + Countly.sharedInstance().feedback().getAvailableFeedbackWidgets { _, _ in } + #endif + + // Session update (flushes queued events) + Countly.sharedInstance().updateSession() + + // Session end + Countly.sharedInstance().endSession() + + waitForExpectations(timeout: 15) + + XCTAssertGreaterThanOrEqual(capturedRequests.count, expectedMinimumRequests, "Should have captured at least \(expectedMinimumRequests) requests from various SDK functions") + for request in capturedRequests { + XCTAssertEqual(request.httpMethod, "GET", "Request to \(request.url?.path ?? "") should use GET") + XCTAssertTrue(request.url?.query?.isEmpty == false, "GET request should have query string in URL") + } + } + + /** + *
+     * Test that all outgoing requests use POST method
+     * when alwaysUsePOST config flag is enabled.
+     *
+     * 1- Init SDK with MockURLProtocol, alwaysUsePOST = true
+     * 2- Call all possible SDK functions: sessions, events, user details,
+     *    location, direct request, attribution, views, crashes, APM, remote config, rating, feedbacks
+     * 3- Wait for requests to be sent
+     * 4- Verify all intercepted requests use POST method
+     * 5- Verify requests have HTTP body and no query string in URL
+     * 
+ */ + func test_allRequests_usePOST_whenAlwaysUsePOSTEnabled() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + let expectation = self.expectation(description: "Requests intercepted") + expectation.assertForOverFulfill = false + + let expectedMinimumRequests = 10 + var capturedRequests: [URLRequest] = [] + var capturedBodies: [Data] = [] + let lock = NSLock() + MockURLProtocol.requestHandler = { request in + lock.lock() + capturedRequests.append(request) + // Read body from stream if needed + if let body = request.httpBody { + capturedBodies.append(body) + } else if let stream = request.httpBodyStream { + stream.open() + let bufferSize = 4096 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + var data = Data() + while stream.hasBytesAvailable { + let read = stream.read(buffer, maxLength: bufferSize) + if read > 0 { + data.append(buffer, count: read) + } + } + buffer.deallocate() + stream.close() + capturedBodies.append(data) + } + if capturedRequests.count >= expectedMinimumRequests { + expectation.fulfill() + } + lock.unlock() + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)! + let body = "{\"result\":[]}".data(using: .utf8)! + return (body, response, nil) + } + + let config = createBaseConfig() + config.alwaysUsePOST = true + config.manualSessionHandling = true + config.disableSDKBehaviorSettingsUpdates = true + config.apm().enableForegroundBackgroundTracking = true + config.apm().enableAppStartTimeTracking = true + config.apm().enableManualAppLoadedTrigger = true + let sessionConfig = URLSessionConfiguration.default + sessionConfig.protocolClasses = [MockURLProtocol.self] + config.urlSessionConfiguration = sessionConfig + + Countly.sharedInstance().start(with: config) + + // Session begin + Countly.sharedInstance().beginSession() + + // Events + Countly.sharedInstance().recordEvent("test_event_1") + Countly.sharedInstance().recordEvent("test_event_2", segmentation: ["key": "value"], count: 1, sum: 1.5, duration: 10) + + // User details + Countly.user().set("custom_property", value: "custom_value") + Countly.user().increment("login_count") + Countly.user().push("tags", value: "vip") + Countly.user().save() + + // Location + Countly.sharedInstance().recordLocation(CLLocationCoordinate2D(latitude: 41.0082, longitude: 28.9784), city: "Istanbul", isoCountryCode: "TR", ip: "1.2.3.4") + + // Direct request + Countly.sharedInstance().addDirectRequest(["custom_key": "custom_value"]) + + // Attribution + Countly.sharedInstance().recordDirectAttribution(withCampaignType: "_special_test", andCampaignData: "{\"cid\":\"test_campaign\"}") + Countly.sharedInstance().recordIndirectAttribution(["idfa": "test_idfa_value"]) + + // Views + let _ = Countly.sharedInstance().views().startAutoStoppedView("TestView") + let _ = Countly.sharedInstance().views().startView("TestView2") + Countly.sharedInstance().views().stopView(withName: "TestView2") + + // Crash/Error reporting + Countly.sharedInstance().recordError("TestError", isFatal: false, stackTrace: ["frame1", "frame2"], segmentation: ["errorType": "test"]) + + // Performance monitoring (APM) + Countly.sharedInstance().recordNetworkTrace("test_trace", requestPayloadSize: 100, responsePayloadSize: 200, responseStatusCode: 200, startTime: 1111, endTime: 2222) + + // Remote config + Countly.sharedInstance().remoteConfig().downloadKeys { _, _, _, _ in } + Countly.sharedInstance().remoteConfig().enrollIntoABTests(forKeys: ["test_key"]) + Countly.sharedInstance().remoteConfig().exitABTests(forKeys: ["test_key"]) + + // Feedbacks and Rating (iOS only) + #if os(iOS) + Countly.sharedInstance().recordRatingWidget(withID: "test_widget_id", rating: 5, email: "test@test.com", comment: "Great", userCanBeContacted: true) + Countly.sharedInstance().feedback().getAvailableFeedbackWidgets { _, _ in } + #endif + + // Session update (flushes queued events) + Countly.sharedInstance().updateSession() + + // Session end + Countly.sharedInstance().endSession() + + waitForExpectations(timeout: 15) + + XCTAssertGreaterThanOrEqual(capturedRequests.count, expectedMinimumRequests, "Should have captured at least \(expectedMinimumRequests) requests from various SDK functions") + for (index, request) in capturedRequests.enumerated() { + XCTAssertEqual(request.httpMethod, "POST", "Request to \(request.url?.path ?? "") should use POST") + XCTAssertNil(request.url?.query, "POST request URL should not contain query string") + + if index < capturedBodies.count { + let bodyString = String(data: capturedBodies[index], encoding: .utf8) ?? "" + XCTAssertTrue(bodyString.contains("app_key="), "POST body should contain app_key for request \(index)") + } + } + } + func addRequests(count: Int) { for loop in 0...count-1 { CountlyPersistency.sharedInstance().add(toQueue: "&request=REQUEST\(loop)") } CountlyPersistency.sharedInstance().saveToFileSync() } + + /// A configuration whose requests go to MockURLProtocol, with manual sessions and no behavior settings fetch. + private func mockedConfig() -> CountlyConfig { + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [MockURLProtocol.self] + let config = createBaseConfig() + config.manualSessionHandling = true + config.disableSDKBehaviorSettingsUpdates = true + config.urlSessionConfiguration = sessionConfig + return config + } + + /** + *
+     * A success status whose body is a JSON list, as a proxy or a misconfigured host can answer, fails
+     * the request instead of crashing the app, so the request stays queued for the next attempt.
+     *
+     * 1- Start against a server that answers every event request with a list
+     * 2- Record an event and send it
+     * 3- Verify the app is still running and the event request is still queued
+     * 
+ */ + func test_replyThatIsAList_failsTheRequestInsteadOfCrashing() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + let answered = expectation(description: "the event request is answered") + answered.assertForOverFulfill = false + MockURLProtocol.requestHandler = { request in + let body = CountlyServerConfigTests.capturedQueryString(request) + if body.contains("list_reply_probe") { + answered.fulfill() + let ok = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil) + return ("[]".data(using: .utf8), ok, nil) + } + let ok = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil) + return ("{\"result\":\"Success\"}".data(using: .utf8), ok, nil) + } + defer { MockURLProtocol.replyEmptyToEveryRequest() } + + Countly.sharedInstance().start(with: mockedConfig()) + Countly.sharedInstance().recordEvent("list_reply_probe") + CountlyConnectionManager.sharedInstance().sendEvents() + wait(for: [answered], timeout: 5) + TestUtils.sleep(0.5) {} + + let queue = TestUtils.getCurrentRQ() ?? [] + XCTAssertTrue(queue.contains { $0.contains("list_reply_probe") }, "the request answered with a list stays queued") + } + + /** + *
+     * An automatically detected crash is sent at once, but the crashing app waits for that send for a
+     * bounded time only: a send that does not complete in time is queued for the next start instead of
+     * holding the app forever.
+     *
+     * 1- Start against a server that holds the crash report answer far longer than the wait
+     * 2- Send a crash report the way the crash handler does and time it
+     * 3- Verify the call returned after the bounded wait and the report is queued
+     * 
+ */ + func test_immediateCrashReport_waitsBoundedAndQueuesTheReport() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + let release = DispatchSemaphore(value: 0) + MockURLProtocol.requestHandler = { request in + if CountlyServerConfigTests.capturedQueryString(request).contains("crash=") { + _ = release.wait(timeout: .now() + 15) + } + let ok = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil) + return ("{\"result\":\"Success\"}".data(using: .utf8), ok, nil) + } + defer { + release.signal() + MockURLProtocol.replyEmptyToEveryRequest() + } + + Countly.sharedInstance().start(with: mockedConfig()) + let started = Date() + let report = "{\"_error\":\"bounded_wait_probe\"}" + CountlyConnectionManager.sharedInstance().sendCrashReport(report, immediately: true) + let waited = Date().timeIntervalSince(started) + + XCTAssertGreaterThanOrEqual(waited, 4.5, "the crash report send is waited for") + XCTAssertLessThan(waited, 8, "but not longer than the bounded wait") + let queue = TestUtils.getCurrentRQ() ?? [] + XCTAssertTrue(queue.contains { $0.contains("bounded_wait_probe") }, + "a send that did not complete is queued for the next start") + } } diff --git a/ios/src/CountlyTests/CountlyConnectionTestTests.swift b/ios/src/CountlyTests/CountlyConnectionTestTests.swift new file mode 100644 index 00000000..db504117 --- /dev/null +++ b/ios/src/CountlyTests/CountlyConnectionTestTests.swift @@ -0,0 +1,566 @@ +import XCTest +@testable import Countly + +/// Tests for the server armed connection test: the trigger on a live 'ct', the probe battery, grading, +/// the combined rows, the report shape and its caps, and what must never happen (persisting 'ct', +/// probing while networking is off, two batteries at once). +class CountlyConnectionTestTests: CountlyBaseTestCase { + + private let expectedFeatures = ["core", "core-write", "sc", "rc", "ab", "feedback", "feedback-widget", "feedback-submit", "content", "feedback-page", "feedback-assets", "content-page"] + private let expectedProbePaths = [ + "/o/ping", "/i", "/o/sdk?method=rc", "/o/sdk?method=ab_fetch_variants", "/o/sdk?method=feedback", + "/o/surveys/nps/widget", "/o/surveys/survey/widget", "/o/feedback/widget", "/i/feedback/inputs", "/o/sdk/content", + "/feedback/nps", "/feedback/survey", "/feedback/rating", + "/surveys/images/ct-probe.png", "/star-rating/images/ct-probe.png", "/_external/content/" + ] + + private var probeRequests: [URLRequest] = [] + private let probeLock = NSLock() + + override func setUp() { + super.setUp() + TestUtils.cleanup() + resetRecordedProbes() + UserDefaults.standard.removeObject(forKey: "kCountlyServerConfigPersistencyKey") + } + + override func tearDown() { + // requests still in flight after the test must not trip the protocol's missing handler check + MockURLProtocol.requestHandler = { request in + ("{}".data(using: .utf8), HTTPURLResponse(url: request.url!, statusCode: 400, httpVersion: nil, headerFields: nil), nil) + } + Countly.sharedInstance().halt(true) + UserDefaults.standard.removeObject(forKey: "kCountlyServerConfigPersistencyKey") + super.tearDown() + } + + // MARK: - helpers + + /// Path plus query of a probe, without the marker and cache buster the SDK appends. + private func probePath(_ request: URLRequest) -> String { + guard let url = request.url, let host = url.host, let hostRange = url.absoluteString.range(of: host) else { return "" } + // taken from the raw string, URL.path would drop the trailing slash of '/_external/content/' + var path = String(url.absoluteString[hostRange.upperBound...]) + if let queryStart = path.firstIndex(of: "?") { + let query = String(path[path.index(after: queryStart)...]) + path = String(path[.. [URLRequest] { + probeLock.lock() + defer { probeLock.unlock() } + return probeRequests + } + + private func recordProbe(_ request: URLRequest) { + probeLock.lock() + defer { probeLock.unlock() } + probeRequests.append(request) + } + + private func resetRecordedProbes() { + probeLock.lock() + defer { probeLock.unlock() } + probeRequests = [] + } + + private func isProbe(_ request: URLRequest) -> Bool { + (request.url?.query ?? "").contains("ct=1") && !(request.url?.query ?? "").contains("app_key=") + } + + /// Answers the behavior settings fetch with the given response, records probes and answers them + /// from the status table (400 by default), and fails every queued request so it stays in the queue. + /// With 'holdSettingsUntil', the settings reply, and so the battery, waits until the test signals it. + private func installHandler(scResponse: String, probeStatus: [String: Int] = [:], probeError: [String: Error] = [:], probeDelay: TimeInterval = 0, holdSettingsUntil: DispatchSemaphore? = nil) { + MockURLProtocol.requestHandler = { [self] request in + let urlString = request.url?.absoluteString ?? "" + if urlString.contains("method=sc") && urlString.contains("app_key=") { + _ = holdSettingsUntil?.wait(timeout: .now() + 5) + return (scResponse.data(using: .utf8), HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil), nil) + } + if isProbe(request) { + recordProbe(request) + if probeDelay > 0 { Thread.sleep(forTimeInterval: probeDelay) } + let path = probePath(request) + if let error = probeError[path] { + return (nil, nil, error) + } + let status = probeStatus[path] ?? (path == "/o/ping" || path.hasSuffix(".png") || path.hasPrefix("/_external") ? 200 : 400) + return ("{}".data(using: .utf8), HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil), nil) + } + return ("{\"result\":\"fail\"}".data(using: .utf8), HTTPURLResponse(url: request.url!, statusCode: 400, httpVersion: nil, headerFields: nil), nil) + } + } + + private func armedResponse(networking: Bool = true) -> String { + var json = ServerConfigBuilder().networking(networking).buildJson() + json["ct"] = 1 + let data = try! JSONSerialization.data(withJSONObject: json) + return String(data: data, encoding: .utf8)! + } + + private func startSDK(customHeaders: [String: String]? = nil) { + let config = createBaseConfig() + config.requiresConsent = false + config.manualSessionHandling = true + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [MockURLProtocol.self] + config.urlSessionConfiguration = sessionConfig + Countly.sharedInstance().start(with: config) + if let headers = customHeaders { + Countly.sharedInstance().addCustomNetworkRequestHeaders(headers) + } + } + + /// The decoded 'ct_results' reports in the request queue, in order. + private func queuedReports() -> [[String: Any]] { + guard let rq = TestUtils.getCurrentRQ() else { return [] } + return rq.compactMap { request in + let parsed = TestUtils.parseQueryString(request) + guard let raw = parsed["ct_results"] else { return nil } + if let dict = raw as? [String: Any] { return dict } + if let string = raw as? String, let data = string.data(using: .utf8) { + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } + return nil + } + } + + private func waitForReport(timeout: TimeInterval = TestUtils.asyncTimeout) -> [String: Any]? { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let report = queuedReports().first { return report } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + return queuedReports().first + } + + private func rows(_ report: [String: Any]) -> [[String: Any]] { + report["results"] as? [[String: Any]] ?? [] + } + + private func row(_ report: [String: Any], _ feature: String) -> [String: Any]? { + rows(report).first { ($0["f"] as? String) == feature } + } + + private var connectionTest: CountlyConnectionTest { CountlyConnectionTest.sharedInstance() } + + // MARK: - battery and report + + func test_armedLiveResponse_probesEveryRowAndQueuesOneReportInOrder() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + installHandler(scResponse: armedResponse()) + startSDK() + + let report = try XCTUnwrap(waitForReport(), "no ct_results reached the request queue") + + // one report, every row, in spec order + XCTAssertEqual(1, queuedReports().count) + XCTAssertEqual(expectedFeatures, rows(report).compactMap { $0["f"] as? String }) + XCTAssertEqual(TestUtils.SDK_NAME, (report["sdk"] as? [String: Any])?["name"] as? String) + XCTAssertEqual(TestUtils.SDK_VERSION, (report["sdk"] as? [String: Any])?["version"] as? String) + XCTAssertGreaterThan((report["ts"] as? NSNumber)?.int64Value ?? 0, 1_600_000_000_000) + + // 16 parameterless requests, marker and cache buster on each, host path joined once + let probes = recordedProbes() + XCTAssertEqual(expectedProbePaths, probes.map(probePath)) + for probe in probes { + let url = probe.url!.absoluteString + XCTAssertEqual("GET", probe.httpMethod) + XCTAssertNil(probe.httpBody) + XCTAssertFalse(url.contains("app_key="), url) + XCTAssertFalse(url.contains("device_id="), url) + XCTAssertTrue(url.contains("ct=1"), url) + XCTAssertTrue(url.contains("&_="), url) + XCTAssertTrue(url.hasPrefix("https://testing.count.ly/"), url) + XCTAssertFalse(url.contains("ly//"), url) + } + + // healthy grading: 400 is reachable, the two strict rows saw their 200 + for row in rows(report) { + XCTAssertEqual(true, row["ok"] as? Bool, "\(row)") + XCTAssertNil(row["e"], "\(row)") + } + XCTAssertEqual(200, row(report, "core")?["st"] as? Int) + XCTAssertEqual(400, row(report, "core-write")?["st"] as? Int) + XCTAssertEqual(200, row(report, "feedback-assets")?["st"] as? Int) + + // the sc row carries the latency of the fetch that delivered the flag + let sc = try XCTUnwrap(row(report, "sc")) + XCTAssertEqual(200, sc["st"] as? Int) + XCTAssertGreaterThanOrEqual((sc["ms"] as? NSNumber)?.int64Value ?? -1, 0) + + // path counts only on the combined rows + XCTAssertEqual(3, row(report, "feedback-widget")?["n"] as? Int) + XCTAssertEqual(3, row(report, "feedback-page")?["n"] as? Int) + XCTAssertEqual(2, row(report, "feedback-assets")?["n"] as? Int) + XCTAssertNil(row(report, "core")?["n"]) + XCTAssertNil(row(report, "rc")?["n"]) + } + + func test_faultyEndpoints_areGradedPerSpec() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + installHandler(scResponse: armedResponse(), + probeStatus: [ + "/o/ping": 404, // strict row, a 404 is the DB being down + "/o/sdk?method=rc": 403, // proxy allowlist + "/o/sdk/content": 302, // captive portal + "/o/surveys/survey/widget": 500, // second path of a combined row + "/o/feedback/widget": 404, // widget not found is still the application answering + "/star-rating/images/ct-probe.png": 404, + ], + probeError: [ + "/o/sdk?method=ab_fetch_variants": NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut), + "/i/feedback/inputs": NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotConnectToHost), + ]) + startSDK() + + let report = try XCTUnwrap(waitForReport()) + + let core = try XCTUnwrap(row(report, "core")) + XCTAssertEqual(false, core["ok"] as? Bool) + XCTAssertEqual(404, core["st"] as? Int) + XCTAssertEqual("HTTP 404", core["e"] as? String) + + XCTAssertEqual("HTTP 403", row(report, "rc")?["e"] as? String) + XCTAssertEqual(403, row(report, "rc")?["st"] as? Int) + + XCTAssertEqual("redirected", row(report, "content")?["e"] as? String) + XCTAssertEqual(302, row(report, "content")?["st"] as? Int) + + let ab = try XCTUnwrap(row(report, "ab")) + XCTAssertEqual("timeout", ab["e"] as? String) + XCTAssertEqual(0, ab["st"] as? Int) + XCTAssertEqual(false, ab["ok"] as? Bool) + + XCTAssertEqual("transport error", row(report, "feedback-submit")?["e"] as? String) + XCTAssertEqual(0, row(report, "feedback-submit")?["st"] as? Int) + + // combined row: first failing path decides status and reason, the 404 on the third path is fine + let widget = try XCTUnwrap(row(report, "feedback-widget")) + XCTAssertEqual(false, widget["ok"] as? Bool) + XCTAssertEqual(500, widget["st"] as? Int) + XCTAssertEqual("HTTP 500", widget["e"] as? String) + XCTAssertEqual(3, widget["n"] as? Int) + + // strict combined row: anything but 2xx on any path fails it + let assets = try XCTUnwrap(row(report, "feedback-assets")) + XCTAssertEqual(false, assets["ok"] as? Bool) + XCTAssertEqual(404, assets["st"] as? Int) + XCTAssertEqual("HTTP 404", assets["e"] as? String) + + // untouched rows stay green + XCTAssertEqual(true, row(report, "feedback")?["ok"] as? Bool) + XCTAssertEqual(true, row(report, "content-page")?["ok"] as? Bool) + } + + func test_ctIsStrippedBeforeCachingAndNeverReadFromStorage() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + installHandler(scResponse: armedResponse()) + startSDK() + _ = try XCTUnwrap(waitForReport()) + + let stored = UserDefaults.standard.dictionary(forKey: "kCountlyServerConfigPersistencyKey") + XCTAssertNotNil(stored?["c"], "the config itself must still be cached") + XCTAssertNil(stored?["ct"], "the flag must never survive into storage") + + // a stored flag, even if one got there somehow, must not arm anything on the next start. The storage + // is cleared so the report of this run, which a halt keeping the storage would keep queued, is not + // counted as the next one's + Countly.sharedInstance().halt(true) + var poisoned = ServerConfigBuilder().buildJson() + poisoned["ct"] = 1 + UserDefaults.standard.set(poisoned, forKey: "kCountlyServerConfigPersistencyKey") + resetRecordedProbes() + installHandler(scResponse: ServerConfigBuilder().build()) + startSDK() + TestUtils.sleep(2) {} + + let probes = recordedProbes() + XCTAssertEqual(0, probes.count, "the battery ran from stored config") + XCTAssertEqual(0, queuedReports().count) + } + + func test_networkingDisabledByServerConfig_skipsTheBattery() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + installHandler(scResponse: armedResponse(networking: false)) + startSDK() + TestUtils.sleep(2) {} + + let probes = recordedProbes() + XCTAssertEqual(0, probes.count, "the operator's own kill switch outranks the test") + XCTAssertEqual(0, queuedReports().count) + } + + func test_secondDeliveryWhileABatteryRuns_isIgnored() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + installHandler(scResponse: armedResponse(), probeDelay: 0.15) + startSDK() + + // the battery takes about 2.5 s with the delay, arm again in the middle of it + TestUtils.sleep(0.5) {} + XCTAssertTrue(connectionTest.isBatteryRunning) + connectionTest.startBattery(withServerConfigLatency: 5) + connectionTest.startBattery(withServerConfigLatency: 5) + + _ = try XCTUnwrap(waitForReport()) + TestUtils.sleep(1) {} + + XCTAssertEqual(1, queuedReports().count, "each delivery during a running battery must be dropped, not queued") + let probes = recordedProbes() + XCTAssertEqual(expectedProbePaths.count, probes.count) + XCTAssertFalse(connectionTest.isBatteryRunning) + } + + func test_probesCarryTheSdkCustomHeaders() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + let headersAdded = DispatchSemaphore(value: 0) + installHandler(scResponse: armedResponse(), holdSettingsUntil: headersAdded) + startSDK(customHeaders: ["X-Gateway-Token": "abc123"]) + headersAdded.signal() + _ = try XCTUnwrap(waitForReport()) + + let probes = recordedProbes() + XCTAssertFalse(probes.isEmpty) + for probe in probes { + XCTAssertEqual("abc123", probe.allHTTPHeaderFields?["X-Gateway-Token"], "a header checking gateway would report a healthy server as unreachable") + } + } + + /// While the server turns tracking off the queue refuses every request, so the battery is not run at all. + func test_trackingDisabledByServerConfig_skipsTheBattery() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + var json = ServerConfigBuilder().tracking(false).buildJson() + json["ct"] = 1 + let reply = String(data: try JSONSerialization.data(withJSONObject: json), encoding: .utf8)! + installHandler(scResponse: reply) + startSDK() + TestUtils.sleep(1) {} + + XCTAssertTrue(recordedProbes().isEmpty, "no probe traffic for a report that could never be queued") + XCTAssertFalse(connectionTest.isBatteryRunning) + XCTAssertTrue(queuedReports().isEmpty) + } + + /** + *
+     * A battery the app goes to the background during is not reported, its probes stalled, and it runs again
+     * in full once the app is active, without waiting for the server to deliver the request again.
+     *
+     * 1- Arm the test with slow probes and wait for the first probe
+     * 2- Send the app to the background, verify the battery stops early and nothing is reported
+     * 3- Bring the app back, verify a complete battery runs and exactly one report is queued
+     * 
+ */ + func test_appGoingToBackgroundDuringTheBattery_discardsItAndRunsItAgainWhenActive() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + installHandler(scResponse: armedResponse(), probeDelay: 0.1) + startSDK() + TestUtils.waitUntil("the battery to start probing") { !self.recordedProbes().isEmpty } + + connectionTest.noteAppSuspended() + TestUtils.waitUntil("the interrupted battery to stop") { !self.connectionTest.isBatteryRunning } + XCTAssertFalse(connectionTest.isBatteryRunning) + XCTAssertLessThan(recordedProbes().count, expectedProbePaths.count, "the interrupted battery stops probing") + XCTAssertTrue(queuedReports().isEmpty, "a battery the app went to the background during is not reported") + + resetRecordedProbes() + connectionTest.noteAppResumed() + let report = try XCTUnwrap(waitForReport()) + XCTAssertEqual(expectedFeatures, rows(report).compactMap { $0["f"] as? String }) + XCTAssertEqual(expectedProbePaths.count, recordedProbes().count, "the battery runs again in full") + XCTAssertEqual(1, queuedReports().count) + } + + /** + *
+     * The report of a battery that runs while the device ID changes without merge goes out under the device ID
+     * the test was armed for, the only user the server accepts it from. A report is never queued without an app key.
+     *
+     * 1- Arm the test with slow probes and change the device ID without merge while it runs
+     * 2- Verify the report carries the armed device ID while the SDK moved on to the new one
+     * 3- Clear the app key, queue a report, and verify it is dropped
+     * 
+ */ + func test_report_forAUserReplacedWithoutMerge_goesOutUnderTheArmedDeviceID() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + installHandler(scResponse: armedResponse(), probeDelay: 0.1) + startSDK() + let armedDeviceID = CountlyDeviceInfo.sharedInstance().deviceID + TestUtils.waitUntil("the battery to start probing") { !self.recordedProbes().isEmpty } + + Countly.sharedInstance().changeDeviceIDWithoutMerge("replacement_user") + _ = try XCTUnwrap(waitForReport()) + + let reportRequest = try XCTUnwrap(TestUtils.getCurrentRQ()?.first { $0.contains("ct_results=") }) + XCTAssertEqual(armedDeviceID, TestUtils.parseQueryString(reportRequest)["device_id"] as? String) + XCTAssertEqual("replacement_user", CountlyDeviceInfo.sharedInstance().deviceID) + + let manager = CountlyConnectionManager.sharedInstance()! + let appKey = manager.appKey + manager.appKey = "" + manager.sendConnectionTestResults("{}", deviceID: "any_user", deviceIDType: 0) + manager.appKey = appKey + XCTAssertEqual(1, queuedReports().count, "a report without an app key is dropped") + } + + /** + *
+     * A battery armed while the app is in the background waits for the foreground, where its probes do not stall.
+     *
+     * 1- Hold the armed settings reply, send the app to the background, then release the reply
+     * 2- Verify no probe runs and no report is queued while the app stays in the background
+     * 3- Bring the app back, verify a complete battery runs and one report is queued
+     * 
+ */ + func test_batteryArmedWhileTheAppIsInTheBackground_waitsForTheForeground() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + let releaseSettings = DispatchSemaphore(value: 0) + installHandler(scResponse: armedResponse(), holdSettingsUntil: releaseSettings) + startSDK() + + connectionTest.noteAppSuspended() + releaseSettings.signal() + TestUtils.sleep(1) {} + XCTAssertTrue(recordedProbes().isEmpty, "no probe runs while the app is in the background") + XCTAssertFalse(connectionTest.isBatteryRunning) + XCTAssertTrue(queuedReports().isEmpty) + + connectionTest.noteAppResumed() + let report = try XCTUnwrap(waitForReport()) + XCTAssertEqual(expectedFeatures, rows(report).compactMap { $0["f"] as? String }) + XCTAssertEqual(expectedProbePaths.count, recordedProbes().count, "the battery runs in full once the app is active") + XCTAssertEqual(1, queuedReports().count) + } + + /** + *
+     * The server arms the test for the user whose settings fetch asked for it, so a device ID change without merge while
+     * that fetch is in flight does not move the report to the new user.
+     *
+     * 1- Hold the armed settings reply and change the device ID without merge
+     * 2- Release the reply and verify the first report carries the device ID the fetch was made for
+     * 
+ */ + func test_report_forAUserReplacedWithoutMergeDuringTheSettingsFetch_goesOutUnderTheArmedDeviceID() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + let releaseSettings = DispatchSemaphore(value: 0) + installHandler(scResponse: armedResponse(), holdSettingsUntil: releaseSettings) + startSDK() + let armedDeviceID = CountlyDeviceInfo.sharedInstance().deviceID + + Countly.sharedInstance().changeDeviceIDWithoutMerge("replacement_user") + releaseSettings.signal() + _ = try XCTUnwrap(waitForReport()) + + let reportRequest = try XCTUnwrap(TestUtils.getCurrentRQ()?.first { $0.contains("ct_results=") }) + XCTAssertEqual(armedDeviceID, TestUtils.parseQueryString(reportRequest)["device_id"] as? String) + XCTAssertEqual("replacement_user", CountlyDeviceInfo.sharedInstance().deviceID) + } + + /** + *
+     * A merge cancels the run the server armed for the merged user, so a merge while the arming fetch is on its way
+     * leaves the battery unrun.
+     *
+     * 1- Hold the armed settings reply and change the device ID with merge
+     * 2- Release the reply and verify no probe runs and no report is queued
+     * 
+ */ + func test_mergeDuringTheArmingFetch_skipsTheBattery() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + let releaseSettings = DispatchSemaphore(value: 0) + installHandler(scResponse: armedResponse(), holdSettingsUntil: releaseSettings) + startSDK() + + Countly.sharedInstance().changeDeviceID(withMerge: "merged_user") + releaseSettings.signal() + TestUtils.sleep(1) {} + + XCTAssertTrue(recordedProbes().isEmpty, "the server cancelled the run, so nothing is probed") + XCTAssertFalse(connectionTest.isBatteryRunning) + XCTAssertTrue(queuedReports().isEmpty) + } + + /** + *
+     * A merge while the battery runs drops its report: the server rejects it under the merged ID, and under the old ID it
+     * would create the merged away user again.
+     *
+     * 1- Arm the test with slow probes and change the device ID with merge while it runs
+     * 2- Verify the battery finishes and no report is queued under either device ID
+     * 
+ */ + func test_mergeDuringTheBattery_dropsTheReport() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + installHandler(scResponse: armedResponse(), probeDelay: 0.1) + startSDK() + TestUtils.waitUntil("the battery to start probing") { !self.recordedProbes().isEmpty } + + Countly.sharedInstance().changeDeviceID(withMerge: "merged_user") + TestUtils.waitUntil("the battery to finish", timeout: 15) { !self.connectionTest.isBatteryRunning } + + XCTAssertFalse(connectionTest.isBatteryRunning) + XCTAssertEqual(expectedProbePaths.count, recordedProbes().count, "the battery itself runs to the end") + XCTAssertTrue(queuedReports().isEmpty, "the report of a run the merge cancelled is dropped") + } + + // MARK: - pure pieces + + func test_grading() { + XCTAssertNil(CountlyConnectionTest.gradeStatus(200, failure: nil, requiresSuccessStatus: false)) + XCTAssertNil(CountlyConnectionTest.gradeStatus(400, failure: nil, requiresSuccessStatus: false)) + XCTAssertNil(CountlyConnectionTest.gradeStatus(404, failure: nil, requiresSuccessStatus: false)) + XCTAssertEqual("HTTP 403", CountlyConnectionTest.gradeStatus(403, failure: nil, requiresSuccessStatus: false)) + XCTAssertEqual("HTTP 502", CountlyConnectionTest.gradeStatus(502, failure: nil, requiresSuccessStatus: false)) + XCTAssertEqual("redirected", CountlyConnectionTest.gradeStatus(301, failure: nil, requiresSuccessStatus: false)) + XCTAssertEqual("timeout", CountlyConnectionTest.gradeStatus(0, failure: "timeout", requiresSuccessStatus: false)) + XCTAssertEqual("transport error", CountlyConnectionTest.gradeStatus(0, failure: nil, requiresSuccessStatus: false)) + // strict rows + XCTAssertNil(CountlyConnectionTest.gradeStatus(204, failure: nil, requiresSuccessStatus: true)) + XCTAssertEqual("HTTP 400", CountlyConnectionTest.gradeStatus(400, failure: nil, requiresSuccessStatus: true)) + XCTAssertEqual("HTTP 404", CountlyConnectionTest.gradeStatus(404, failure: nil, requiresSuccessStatus: true)) + } + + func test_probeURL_resolvesAgainstThePathPrefixAndAppendsTheMarker() { + let ping = CountlyConnectionTest.probeURL(forPath: "/o/ping", serverURL: "https://x.com/countly//") + XCTAssertTrue(ping.hasPrefix("https://x.com/countly/o/ping?ct=1&_="), ping) + + let rc = CountlyConnectionTest.probeURL(forPath: "/o/sdk?method=rc", serverURL: "https://x.com") + XCTAssertTrue(rc.hasPrefix("https://x.com/o/sdk?method=rc&ct=1&_="), rc) + + let noSlash = CountlyConnectionTest.probeURL(forPath: "i", serverURL: "https://x.com/") + XCTAssertTrue(noSlash.hasPrefix("https://x.com/i?ct=1&_="), noSlash) + } + + func test_report_enforcesRowByteAndErrorCaps() throws { + var rows: [[String: Any]] = [] + for i in 0..<40 { + rows.append(["f": "row\(i)", "ok": false, "st": 403, "ms": 12, "e": String(repeating: "x", count: 300)]) + } + rows[3]["e"] = "opaque" + + let json = connectionTest.buildReport(rows) + let report = try XCTUnwrap(try JSONSerialization.jsonObject(with: json.data(using: .utf8)!) as? [String: Any]) + let results = try XCTUnwrap(report["results"] as? [[String: Any]]) + + XCTAssertLessThanOrEqual(results.count, 32) + XCTAssertLessThanOrEqual(json.utf8.count, 8 * 1024) + // 32 rows with 256 char reasons overflow the byte cap, so the reasons went, except the opaque qualifier + XCTAssertEqual("opaque", results[3]["e"] as? String, "dropping the qualifier would upgrade a low confidence row") + for (index, row) in results.enumerated() where index != 3 { + XCTAssertNil(row["e"], "\(row)") + } + + // under the byte cap a long reason is only truncated + let single = connectionTest.buildReport([["f": "core", "ok": false, "st": 500, "ms": 1, "e": String(repeating: "y", count: 300)]]) + let singleReport = try XCTUnwrap(try JSONSerialization.jsonObject(with: single.data(using: .utf8)!) as? [String: Any]) + let singleRow = try XCTUnwrap((singleReport["results"] as? [[String: Any]])?.first) + XCTAssertEqual(256, (singleRow["e"] as? String)?.count) + } +} diff --git a/ios/src/CountlyTests/CountlyConsentManagerTests.swift b/ios/src/CountlyTests/CountlyConsentManagerTests.swift new file mode 100644 index 00000000..b3a646c1 --- /dev/null +++ b/ios/src/CountlyTests/CountlyConsentManagerTests.swift @@ -0,0 +1,272 @@ +// +// CountlyConsentManagerTests.swift +// Countly +// +// Created by Arif Burak Demiray on 18.11.2025. +// Copyright © 2025 Countly. All rights reserved. +// +import Foundation + +import XCTest +@testable import Countly + +class CountlyConsentManagerTests: CountlyBaseTestCase { + + override func setUp() { + super.setUp() + // Initialize or reset necessary objects here + Countly.sharedInstance().halt(true) + } + + override func tearDown() { + // Ensure everything is cleaned up properly + super.tearDown() + Countly.sharedInstance().halt(true) + } + /** + * Tests that consent requirement is properly handled when enabled. + * Verifies that: + * 1. Initial consent request is sent as none given + * 2. No data is collected until consent is given + * 3. Location is properly handled with empty value + * 4. After giveAllConsent call, consent request sent as all given + */ + func test_giveAllConsents() { + let config = TestUtils.createBaseConfig() + config.requiresConsent = true + + Countly.sharedInstance().start(with: config) + XCTAssertEqual(2, TestUtils.getCurrentRQ()?.count) + Countly.sharedInstance().giveAllConsents() + XCTAssertEqual(4, TestUtils.getCurrentRQ()?.count) + var consents: [String: Any?] = [ + "push": 0, + "content": 0, + "crashes": 0, + "events": 0, + "users": 0, + "feedback": 0, + "apm": 0, + "location": 0, + "remote-config": 0, + "sessions": 0, + "attribution": 0, + "views": 0, + "metrics": 0 + ] + + TestUtils.validateRequest(["consent": consents], 0) + TestUtils.validateRequest(["location": ""], 1) + TestUtils.validateRequest(["begin_session": "1"], 2) + for key in consents.keys { + consents[key] = 1 + } + TestUtils.validateRequest(["consent": consents], 3) + + } + + /** + *
+     * Behavior settings that turn the consent requirement on can land after start sent its own consent state, while init
+     * is still running. The consent state and the empty location still go out, once each.
+     *
+     * 1- Start without the consent requirement, against a server that requires it
+     * 2- Hold the answer until start has handled consent, then hold start until the answer is applied
+     * 3- Verify consent is required and the consent state and the empty location are queued once each
+     * 
+ */ + func test_consentRequiredByBehaviorSettingsAtTheEndOfStart_sendsTheConsentState() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + MockURLProtocol.requestHandler = { request in + if request.url?.absoluteString.contains("method=sc") == true { + return (ServerConfigBuilder().consentRequired(true).build().data(using: .utf8), 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) + } + defer { MockURLProtocol.replyEmptyToEveryRequest() } + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [MockURLProtocol.self] + let config = TestUtils.createBaseConfig() + config.manualSessionHandling = true + config.urlSessionConfiguration = sessionConfig + // the SDK holds its logger weakly + let logger = EndOfStartAnswerLogger() + config.loggerDelegate = logger + withExtendedLifetime(logger) { + Countly.sharedInstance().start(with: config) + TestUtils.waitUntil("the consent state and the empty location are queued") { (TestUtils.getCurrentRQ()?.count ?? 0) >= 2 } + } + XCTAssertTrue(logger.didHoldStart, "the answer landed between start's consent handling and the end of init") + let queue = TestUtils.getCurrentRQ() ?? [] + XCTAssertTrue(CountlyConsentManager.sharedInstance().requiresConsent, "the behavior settings require consent") + XCTAssertEqual(1, queue.filter { $0.contains("consent=") }.count, "the consent state is queued once: \(queue)") + XCTAssertEqual(1, queue.filter { TestUtils.parseQueryString($0)["location"] as? String == "" }.count, "the empty location is queued once: \(queue)") + } + + /** + *
+     * Behavior settings that turn the consent requirement on after start has finished send the consent state and the
+     * empty location, once each.
+     *
+     * 1- Start without the consent requirement and hold the behavior settings answer until start has returned
+     * 2- Let the answer, which requires consent, through
+     * 3- Verify consent is required and the consent state and the empty location are queued once each
+     * 
+ */ + func test_consentRequiredByBehaviorSettingsAfterStart_sendsTheConsentState() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + let release = holdBehaviorSettingsAnswer(ServerConfigBuilder().consentRequired(true)) + defer { MockURLProtocol.replyEmptyToEveryRequest() } + Countly.sharedInstance().start(with: mockedConfig()) + XCTAssertFalse(CountlyConsentManager.sharedInstance().requiresConsent, "start finishes without the consent requirement") + XCTAssertFalse(TestUtils.getCurrentRQ()?.contains { $0.contains("consent=") } ?? false, "and sends no consent state") + + release.signal() + TestUtils.waitUntil("the consent state and the empty location are queued") { + let queue = TestUtils.getCurrentRQ() ?? [] + return queue.contains { $0.contains("consent=") } && queue.contains { TestUtils.parseQueryString($0)["location"] as? String == "" } + } + let queue = TestUtils.getCurrentRQ() ?? [] + XCTAssertTrue(CountlyConsentManager.sharedInstance().requiresConsent, "the behavior settings require consent") + XCTAssertEqual(1, queue.filter { $0.contains("consent=") }.count, "the consent state is queued once: \(queue)") + XCTAssertEqual(1, queue.filter { TestUtils.parseQueryString($0)["location"] as? String == "" }.count, "the empty location is queued once: \(queue)") + } + +#if os(iOS) || os(tvOS) + /** + *
+     * A consent requirement the behavior settings turned on is lifted again once they stop requiring consent, which
+     * starts the features that were waiting for their consent, such as automatic view tracking.
+     *
+     * 1- Start without requiring consent, with built in behavior settings that require it and automatic view tracking,
+     *    and hold the live behavior settings answer
+     * 2- Verify consent is required and automatic view tracking waits for its consent
+     * 3- Let the answer, which does not require consent, through and verify automatic view tracking starts
+     * 
+ */ + func test_consentRequirementOfTheBehaviorSettingsLiftedAgain_startsTheFeaturesWaitingForConsent() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + let release = holdBehaviorSettingsAnswer(ServerConfigBuilder().consentRequired(false)) + defer { MockURLProtocol.replyEmptyToEveryRequest() } + let config = mockedConfig() + config.requiresConsent = false + config.enableAutomaticViewTracking = true + config.sdkBehaviorSettings = ServerConfigBuilder().consentRequired(true).build() + Countly.sharedInstance().start(with: config) + XCTAssertTrue(CountlyConsentManager.sharedInstance().requiresConsent, + "the built in behavior settings require consent") + XCTAssertFalse(CountlyViewTrackingInternal.sharedInstance().isAutoViewTrackingActive, "automatic view tracking waits for its consent") + + release.signal() + TestUtils.waitUntil("automatic view tracking starts") { CountlyViewTrackingInternal.sharedInstance().isAutoViewTrackingActive } + XCTAssertFalse(CountlyConsentManager.sharedInstance().requiresConsent, + "without the server's requirement the developer's applies") + XCTAssertTrue(CountlyViewTrackingInternal.sharedInstance().isAutoViewTrackingActive, "automatic view tracking starts once nothing waits for consent") + } +#endif + + /** + *
+     * Behavior settings can not lift a consent requirement the developer set, so consents given and
+     * withdrawn keep their effect, and a device ID change without merge still removes them.
+     *
+     * 1- Start requiring consent with events consent given, and hold a behavior settings answer that
+     *    does not require consent
+     * 2- Let the answer through and verify consent is still required and the events consent still given
+     * 3- Withdraw the events consent and verify it is withdrawn, then give it again and change the device
+     *    ID without merge
+     * 4- Verify the change removed the consent
+     * 
+ */ + func test_behaviorSettingsCanNotLiftTheDevelopersConsentRequirement() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + let release = holdBehaviorSettingsAnswer(ServerConfigBuilder().consentRequired(false)) + defer { MockURLProtocol.replyEmptyToEveryRequest() } + let config = mockedConfig() + config.requiresConsent = true + config.consents = [CLYConsent.events] + Countly.sharedInstance().start(with: config) + + release.signal() + TestUtils.waitUntil("the behavior settings answer is stored") { + let stored = CountlyPersistency.sharedInstance().retrieveServerConfig()?["c"] as? [String: Any] + return (stored?["cr"] as? Bool) == false + } + XCTAssertTrue(CountlyConsentManager.sharedInstance().requiresConsent, "the developer's requirement stays") + XCTAssertTrue(CountlyConsentManager.sharedInstance().consentForEvents, "and the consent given at start with it") + + Countly.sharedInstance().cancelConsent(forFeatures: [CLYConsent.events]) + XCTAssertFalse(CountlyConsentManager.sharedInstance().consentForEvents, "a withdrawal takes effect") + + Countly.sharedInstance().giveConsent(forFeatures: [CLYConsent.events]) + Countly.sharedInstance().changeDeviceIDWithoutMerge("next_user") + XCTAssertFalse(CountlyConsentManager.sharedInstance().consentForEvents, + "the next user does not inherit the consent") + } + + /// A configuration whose requests go to an in-process mock, with manual sessions. + private func mockedConfig() -> CountlyConfig { + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [MockURLProtocol.self] + let config = TestUtils.createBaseConfig() + config.manualSessionHandling = true + config.urlSessionConfiguration = sessionConfig + return config + } + + /// Answers the behavior settings fetch with the given settings once the returned semaphore is signalled, and every + /// other request with a failure, so requests stay queued. + private func holdBehaviorSettingsAnswer(_ settings: ServerConfigBuilder) -> DispatchSemaphore { + let release = DispatchSemaphore(value: 0) + let answer = settings.build() + MockURLProtocol.requestHandler = { request in + if request.url?.absoluteString.contains("method=sc") == true { + _ = release.wait(timeout: .now() + 10) + return (answer.data(using: .utf8), 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) + } + return release + } +} + +/// Makes the behavior settings answer land between start's own consent handling and the end of init: it holds the +/// answer until start logs its health check request, which comes right after consent, then holds start there until the +/// answer is applied. +private final class EndOfStartAnswerLogger: NSObject, CountlyLoggerDelegate { + private let startHandledConsent = DispatchSemaphore(value: 0) + private let answerApplied = DispatchSemaphore(value: 0) + private let lock = NSLock() + private var hasHeldStart = false + + /// Whether start was held, so the answer did land at the end of start. + var didHoldStart: Bool { + lock.lock() + defer { lock.unlock() } + return hasHeldStart + } + + /// Holds the thread that logs the answer or the health check request, as described above. + func internalLog(_ log: String, with level: CLYInternalLogLevel) { + if log.contains("behavior settings received from network") { + _ = startHandledConsent.wait(timeout: .now() + 5) + } else if log.contains("fetched behavior settings replaced the stored ones and were persisted") { + answerApplied.signal() + } else if Thread.isMainThread && log.contains("sending health check request") && holdsStartOnce() { + startHandledConsent.signal() + _ = answerApplied.wait(timeout: .now() + 5) + } + } + + /// Whether this is the first health check request, the one start sends. + private func holdsStartOnce() -> Bool { + lock.lock() + defer { lock.unlock() } + let isFirst = !hasHeldStart + hasHeldStart = true + return isFirst + } +} + + + diff --git a/ios/src/CountlyTests/CountlyContentBuilderInternal+Tests.h b/ios/src/CountlyTests/CountlyContentBuilderInternal+Tests.h new file mode 100644 index 00000000..d2677ec1 --- /dev/null +++ b/ios/src/CountlyTests/CountlyContentBuilderInternal+Tests.h @@ -0,0 +1,17 @@ +#import "CountlyContentBuilderInternal.h" + +#if (TARGET_OS_IOS) +@interface CountlyContentBuilderInternal (Tests) + +// Single-content presentation latch (see .m). Exposed so tests can verify that a second +// concurrent presentation is rejected (no duplicate content web views). +- (BOOL)tryBeginContentPresentation; +- (void)endContentPresentation; +- (BOOL)isContentShownThreadSafe; + +// Zone re-entry timer + presentation entry point, so tests can drive a real presentation. +- (BOOL)isZoneReentryTimerArmed; +- (void)showContentWithHtmlPath:(NSString *)urlString placementCoordinates:(NSDictionary *)placementCoordinates; + +@end +#endif diff --git a/ios/src/CountlyTests/CountlyContentBuilderTests.swift b/ios/src/CountlyTests/CountlyContentBuilderTests.swift new file mode 100644 index 00000000..22bce477 --- /dev/null +++ b/ios/src/CountlyTests/CountlyContentBuilderTests.swift @@ -0,0 +1,742 @@ +// +// CountlyContentBuilderTests.swift +// CountlyTests +// +// Created on 02.03.2026. +// Copyright © 2026 Countly. All rights reserved. +// + +import XCTest +@testable import Countly + +#if os(iOS) +class CountlyContentBuilderTests: CountlyBaseTestCase { + + override func setUp() { + super.setUp() + Countly.sharedInstance().halt(true) + MockURLProtocol.requestHandler = nil + } + + override func tearDown() { + CountlyContentBuilderInternal.sharedInstance().exitContentZone() + // Reset reload-on-stall / zoom state so it can't leak into later tests. + CountlyContentBuilderInternal.sharedInstance().enableContentReloadOnStall = false + CountlyContentBuilderInternal.sharedInstance().contentReloadOnStallTimeout = 0 + CountlyContentBuilderInternal.sharedInstance().disableZoom = false + CountlyContentBuilderInternal.sharedInstance().contentURLHandler = nil + Countly.sharedInstance().halt(true) + MockURLProtocol.requestHandler = nil + super.tearDown() + } + + // MARK: - Helpers + + /// Creates a config with MockURLProtocol injected, manual sessions, and server config disabled. + func createContentTestConfig(alwaysUsePOST: Bool = false) -> CountlyConfig { + let config = TestUtils.createBaseConfig() + config.manualSessionHandling = true + config.alwaysUsePOST = alwaysUsePOST + config.disableSDKBehaviorSettingsUpdates = true + + let sessionConfig = URLSessionConfiguration.default + sessionConfig.protocolClasses = [MockURLProtocol.self] + config.urlSessionConfiguration = sessionConfig + return config + } + + /// Starts SDK and enters content zone with zero initial delay to speed up tests. + func startSDKAndEnterContentZone(alwaysUsePOST: Bool = false) { + let config = createContentTestConfig(alwaysUsePOST: alwaysUsePOST) + Countly.sharedInstance().start(with: config) + + // Eliminate the initial delay so content fetch fires immediately + CountlyContentBuilderInternal.sharedInstance().contentInitialDelay = 0 + CountlyContentBuilderInternal.sharedInstance().enterContentZone([]) + } + + // MARK: - Tests + + /** + *
+     * Test that content fetch request uses GET method by default
+     * when query string is short and alwaysUsePOST is false.
+     *
+     * 1- Init SDK with MockURLProtocol, manual sessions, alwaysUsePOST = false
+     * 2- Set contentInitialDelay to 0 to avoid waiting
+     * 3- Trigger enterContentZone
+     * 4- Wait for the content fetch request
+     * 5- Verify the intercepted request uses GET
+     * 6- Verify the request URL contains /o/sdk/content endpoint
+     * 
+ */ + func test_fetchContentRequest_usesGET_whenQueryIsShort() { + let contentExpectation = self.expectation(description: "Content fetch request intercepted") + contentExpectation.assertForOverFulfill = false + + var capturedRequest: URLRequest? + MockURLProtocol.requestHandler = { request in + if let url = request.url?.absoluteString, url.contains("/o/sdk/content") { + capturedRequest = request + contentExpectation.fulfill() + } + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)! + return ("{}".data(using: .utf8)!, response, nil) + } + + startSDKAndEnterContentZone(alwaysUsePOST: false) + + waitForExpectations(timeout: 15) + + XCTAssertNotNil(capturedRequest, "Content fetch request should have been made") + XCTAssertEqual(capturedRequest?.httpMethod, "GET", "Content fetch should use GET for short query strings") + XCTAssertNil(capturedRequest?.httpBody, "GET request should not have HTTP body") + XCTAssertTrue(capturedRequest?.url?.absoluteString.contains("/o/sdk/content") ?? false, "Request URL should contain content endpoint") + } + + /** + *
+     * Test that content fetch request uses POST method
+     * when alwaysUsePOST config flag is enabled.
+     *
+     * 1- Init SDK with MockURLProtocol, manual sessions, alwaysUsePOST = true
+     * 2- Set contentInitialDelay to 0 to avoid waiting
+     * 3- Trigger enterContentZone
+     * 4- Wait for the content fetch request
+     * 5- Verify the intercepted request uses POST
+     * 6- Verify the request has an HTTP body with query parameters
+     * 
+ */ + func test_fetchContentRequest_usesPOST_whenAlwaysUsePOSTEnabled() { + let contentExpectation = self.expectation(description: "Content fetch request intercepted") + contentExpectation.assertForOverFulfill = false + + var capturedRequest: URLRequest? + var capturedBody: Data? + MockURLProtocol.requestHandler = { request in + if let url = request.url?.absoluteString, url.contains("/o/sdk/content") { + capturedRequest = request + // Capture body from the stream if httpBody is nil + if let body = request.httpBody { + capturedBody = body + } else if let stream = request.httpBodyStream { + stream.open() + let bufferSize = 4096 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + var data = Data() + while stream.hasBytesAvailable { + let read = stream.read(buffer, maxLength: bufferSize) + if read > 0 { + data.append(buffer, count: read) + } + } + buffer.deallocate() + stream.close() + capturedBody = data + } + contentExpectation.fulfill() + } + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)! + return ("{}".data(using: .utf8)!, response, nil) + } + + startSDKAndEnterContentZone(alwaysUsePOST: true) + + waitForExpectations(timeout: 15) + + XCTAssertNotNil(capturedRequest, "Content fetch request should have been made") + XCTAssertEqual(capturedRequest?.httpMethod, "POST", "Content fetch should use POST when alwaysUsePOST is enabled") + + if let bodyData = capturedBody, let bodyString = String(data: bodyData, encoding: .utf8) { + XCTAssertTrue(bodyString.contains("method=queue"), "POST body should contain content fetch method parameter") + XCTAssertTrue(bodyString.contains("app_key="), "POST body should contain app_key") + } else { + XCTFail("POST request should have HTTP body") + } + + // URL should not contain query string for POST + XCTAssertFalse(capturedRequest?.url?.absoluteString.contains("?") ?? true, "POST request URL should not contain query string") + } + + /** + *
+     * Test that content fetch request contains required query parameters
+     * (method, resolution, la, app_key, device_id).
+     *
+     * 1- Init SDK with MockURLProtocol
+     * 2- Set contentInitialDelay to 0 to avoid waiting
+     * 3- Trigger enterContentZone
+     * 4- Wait for the content fetch request
+     * 5- Parse the query string and verify required parameters exist
+     * 
+ */ + func test_fetchContentRequest_containsRequiredParameters() { + let contentExpectation = self.expectation(description: "Content fetch request intercepted") + contentExpectation.assertForOverFulfill = false + + var capturedRequest: URLRequest? + MockURLProtocol.requestHandler = { request in + if let url = request.url?.absoluteString, url.contains("/o/sdk/content") { + capturedRequest = request + contentExpectation.fulfill() + } + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)! + return ("{}".data(using: .utf8)!, response, nil) + } + + startSDKAndEnterContentZone() + + waitForExpectations(timeout: 15) + + XCTAssertNotNil(capturedRequest) + + let queryString = capturedRequest?.url?.query ?? "" + XCTAssertTrue(queryString.contains("method=queue"), "Request should contain method=queue parameter") + XCTAssertTrue(queryString.contains("resolution="), "Request should contain resolution parameter") + XCTAssertTrue(queryString.contains("la="), "Request should contain language parameter") + XCTAssertTrue(queryString.contains("app_key="), "Request should contain app_key parameter") + XCTAssertTrue(queryString.contains("device_id="), "Request should contain device_id parameter") + } + + /** + *
+     * Test that exiting content zone stops further fetch requests.
+     *
+     * 1- Init SDK with MockURLProtocol
+     * 2- Set contentInitialDelay to 0 to avoid waiting
+     * 3- Trigger enterContentZone, wait for first fetch
+     * 4- Call exitContentZone
+     * 5- Verify no more content requests are made within the next timer interval
+     * 
+ */ + func test_exitContentZone_stopsFetching() { + let firstFetch = self.expectation(description: "First content fetch") + firstFetch.assertForOverFulfill = false + + var fetchCount = 0 + MockURLProtocol.requestHandler = { request in + if let url = request.url?.absoluteString, url.contains("/o/sdk/content") { + fetchCount += 1 + if fetchCount == 1 { + firstFetch.fulfill() + } + } + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)! + return ("{}".data(using: .utf8)!, response, nil) + } + + startSDKAndEnterContentZone() + + waitForExpectations(timeout: 15) + + let countAfterFirstFetch = fetchCount + CountlyContentBuilderInternal.sharedInstance().exitContentZone() + + // Wait a bit and verify no more requests + TestUtils.sleep(3) {} + + 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) + } + + /** + *
+     * Content reload-on-stall configuration plumbs through to the internal builder.
+     *
+     * 1- A fresh CountlyContentConfig defaults the stall timeout to 1000 ms and reload disabled
+     * 2- enableContentReloadOnStall and setContentReloadOnStallTimeout: round-trip on the config
+     * 3- After start, the internal builder reflects the enabled flag and the timeout
+     *    converted from milliseconds to seconds (2500 ms -> 2.5 s)
+     * 
+ */ + func test_contentReloadOnStall_configDefaultsAndPlumbing() { + // 1- Fresh config object: default 1000 ms, reload disabled. + let freshContent = CountlyContentConfig() + XCTAssertEqual(freshContent.getContentReloadOnStallTimeout(), 1000) + XCTAssertFalse(freshContent.getEnableContentReloadOnStall()) + + // 2- Round-trip on the config used for start. + let config = createContentTestConfig() + config.content().setContentReloadOnStallTimeout(2500) + config.content().enableContentReloadOnStall() + XCTAssertEqual(config.content().getContentReloadOnStallTimeout(), 2500) + XCTAssertTrue(config.content().getEnableContentReloadOnStall()) + + // 3- Plumbed to the internal builder on start, converted ms -> seconds. + MockURLProtocol.requestHandler = { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)! + return ("{}".data(using: .utf8)!, response, nil) + } + Countly.sharedInstance().start(with: config) + XCTAssertTrue(CountlyContentBuilderInternal.sharedInstance().enableContentReloadOnStall) + XCTAssertEqual(CountlyContentBuilderInternal.sharedInstance().contentReloadOnStallTimeout, 2.5, accuracy: 0.0001) + } + + /** + *
+     * disableZoom is a one-way config switch that plumbs through to the internal builder.
+     *
+     * 1- A fresh CountlyContentConfig has zoom enabled (disableZoom == false)
+     * 2- disableZoom() turns it on and round-trips on the config
+     * 3- After start, the internal builder reflects it
+     * 
+ */ + func test_disableZoom_configDefaultsAndPlumbing() { + // 1- Fresh config: zoom NOT disabled by default. + XCTAssertFalse(CountlyContentConfig().getDisableZoom()) + + // 2- One-way switch round-trips on the config used for start. + let config = createContentTestConfig() + config.content().disableZoom() + XCTAssertTrue(config.content().getDisableZoom()) + + // 3- Plumbed to the internal builder on start. + MockURLProtocol.requestHandler = { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)! + return ("{}".data(using: .utf8)!, response, nil) + } + Countly.sharedInstance().start(with: config) + XCTAssertTrue(CountlyContentBuilderInternal.sharedInstance().disableZoom) + } + + /** + *
+     * A content URL handler set on the config plumbs through to the internal builder.
+     *
+     * 1- A fresh CountlyContentConfig has no handler by default
+     * 2- setContentURLHandler: round-trips on the config
+     * 3- After start, the internal builder holds the handler
+     * 
+ */ + func test_contentURLHandler_configDefaultsAndPlumbing() { + XCTAssertNil(CountlyContentConfig().getContentURLHandler()) + + let config = createContentTestConfig() + let handler: (URL) -> Bool = { _ in true } + config.content().setContentURLHandler(handler) + XCTAssertNotNil(config.content().getContentURLHandler()) + + MockURLProtocol.requestHandler = { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)! + return ("{}".data(using: .utf8)!, response, nil) + } + Countly.sharedInstance().start(with: config) + XCTAssertNotNil(CountlyContentBuilderInternal.sharedInstance().contentURLHandler) + } + + /** + *
+     * The single-content presentation latch rejects a second concurrent presentation.
+     *
+     * Two content fetches completing near-simultaneously used to both pass the "already
+     * shown" guard (the flag was only set once the web view was presented, after a network
+     * round trip) and present two overlapping web views. tryBeginContentPresentation is an
+     * atomic test-and-set that closes that window.
+     *
+     * 1- From a clean state, the first presentation acquires the slot
+     * 2- A second presentation while the slot is held is rejected
+     * 3- After the shown content is dismissed, the slot is free again
+     * 
+ */ + func test_contentPresentationLatch_rejectsSecondConcurrentPresentation() { + let cb = CountlyContentBuilderInternal.sharedInstance() + cb.resetInstance() // known "not shown" state + + XCTAssertTrue(cb.tryBeginContentPresentation(), "first presentation should acquire the slot") + XCTAssertFalse(cb.tryBeginContentPresentation(), "second concurrent presentation must be rejected") + XCTAssertTrue(cb.isContentShownThreadSafe()) + + cb.endContentPresentation() + // endContentPresentation is async on the serial content queue; the subsequent sync + // read drains the queue (FIFO), so the slot reads free. + XCTAssertFalse(cb.isContentShownThreadSafe()) + XCTAssertTrue(cb.tryBeginContentPresentation(), "slot should be free again after dismissal") + + cb.endContentPresentation() + XCTAssertFalse(cb.isContentShownThreadSafe()) + } + + /** + *
+     * resetInstance releases the content slot through the serial content queue.
+     *
+     * The flag write in resetInstance must go through the same queue as every other accessor
+     * (not a raw ivar write), so a reset cannot race a concurrent claim and cannot leave the
+     * slot stuck "shown" (which would silently disable the content zone).
+     *
+     * 1- Claim the slot
+     * 2- resetInstance
+     * 3- The slot reads free (a subsequent claim succeeds)
+     * 
+ */ + func test_resetInstance_releasesContentSlot() { + let cb = CountlyContentBuilderInternal.sharedInstance() + cb.resetInstance() + + XCTAssertTrue(cb.tryBeginContentPresentation()) + XCTAssertTrue(cb.isContentShownThreadSafe()) + + cb.resetInstance() + XCTAssertFalse(cb.isContentShownThreadSafe()) + XCTAssertTrue(cb.tryBeginContentPresentation(), "slot should be claimable again after reset") + + cb.endContentPresentation() + } + + // MARK: - Rotation + + /// disableRotation is a one-way switch that defaults to off and plumbs through to the builder. + func test_disableRotation_configDefaultsAndPlumbing() { + XCTAssertFalse(CountlyContentConfig().getDisableRotation()) + + let config = createContentTestConfig() + config.content().disableRotation() + XCTAssertTrue(config.content().getDisableRotation()) + + MockURLProtocol.requestHandler = { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)! + return ("{}".data(using: .utf8)!, response, nil) + } + Countly.sharedInstance().start(with: config) + XCTAssertTrue(CountlyContentBuilderInternal.sharedInstance().disableRotation) + + CountlyContentBuilderInternal.sharedInstance().disableRotation = false + } + + // MARK: - exitContentZone closes displayed content + + /// Waits for pending main-queue work (FIFO), so a deferred teardown has completed. + private func drainMainQueue(_ description: String = "main queue drained") { + let marker = expectation(description: description) + DispatchQueue.main.async { marker.fulfill() } + waitForExpectations(timeout: 2) + } + + /// exitContentZone must release the shown slot, or every later enter silently no-ops. + func test_exitContentZone_releasesShownSlot_soTheZoneIsNotDeadEnded() { + let cb = CountlyContentBuilderInternal.sharedInstance() + cb.resetInstance() + + XCTAssertTrue(cb.tryBeginContentPresentation(), "content is now 'shown'") + XCTAssertTrue(cb.isContentShownThreadSafe()) + + cb.exitContentZone() + // Deliberately NO queue drain. The slot must be free by the time exitContentZone returns, + // otherwise an enterContentZone in the same turn hits the "already shown" guard, silently + // no-ops, and dead-ends the zone until the process restarts. + XCTAssertFalse(cb.isContentShownThreadSafe(), "the slot must be released synchronously on main") + XCTAssertTrue(cb.tryBeginContentPresentation(), "a later presentation must be possible again") + + cb.endContentPresentation() + } + + /// Closing content fires the dismiss block, which schedules the ~30s zone re-entry. An + /// SDK-initiated close must suppress it, or the zone the app exited comes back. + func test_exitContentZone_doesNotReArmTheZone_afterClosingDisplayedContent() { + let cb = CountlyContentBuilderInternal.sharedInstance() + cb.resetInstance() + + // A port nobody listens on: fails instantly, but a real web view is still created. + let placement: [String: Any] = ["x": 0, "y": 0, "w": 200, "h": 200] + cb.showContent(withHtmlPath: "https://localhost:1/content", placementCoordinates: ["p": placement, "l": placement]) + drainMainQueue("content presented") + + XCTAssertTrue(cb.isContentShownThreadSafe(), "content should be presented") + + cb.exitContentZone() + drainMainQueue("content closed") + // closeWebView defers one more turn; the dismiss block runs there. + drainMainQueue("dismiss block ran") + + XCTAssertFalse(cb.isContentShownThreadSafe(), "the content must be gone") + XCTAssertFalse(cb.isZoneReentryTimerArmed(), "an SDK-initiated close must not schedule a zone re-entry") + + // The only test presenting a real overlay window; a lingering one leaks into other suites. + cb.resetInstance() + drainMainQueue("reset settled") + } + + /// Internal zone cycles use clearContentState so a routine config apply cannot dismiss content. + func test_clearContentState_leavesDisplayedContentAlone() { + let cb = CountlyContentBuilderInternal.sharedInstance() + cb.resetInstance() + + XCTAssertTrue(cb.tryBeginContentPresentation()) + + cb.clearContentState() + + XCTAssertTrue(cb.isContentShownThreadSafe(), "clearContentState must not dismiss displayed content") + + cb.endContentPresentation() + } + + // MARK: - Global content segmentation + + /// Starts the SDK with a mock that fails every request, so the request queue keeps what the SDK + /// records instead of draining it as it goes. A given segmentation value limit replaces the default. + private func startSDKWithFailingNetwork(maxSegmentationValues: UInt? = nil) { + MockURLProtocol.requestHandler = { _ in + (nil, nil, NSError(domain: "CountlyTests", code: -1)) + } + let config = createContentTestConfig() + if let maxSegmentationValues = maxSegmentationValues { + config.sdkInternalLimits().setMaxSegmentationValues(maxSegmentationValues) + } + Countly.sharedInstance().start(with: config) + } + + /// Segmentation of the last event recorded with the given key. Both queues are searched because + /// recording a feedback widget event flushes the event queue into the request queue right after, + /// while a content web view event is left in the event queue. + private func lastEventSegmentation(_ key: String) -> [String: Any] { + if let event = (TestUtils.getCurrentEQ() ?? []).last(where: { $0.key == key }) { + return (event.segmentation as? [String: Any]) ?? [:] + } + + for requestStr in (TestUtils.getCurrentRQ() ?? []).reversed() { + let request = TestUtils.parseQueryString(requestStr) + guard let eventsStr = request["events"] as? String, + let data = eventsStr.data(using: .utf8), + let events = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] + else { continue } + + for event in events.reversed() where event["key"] as? String == key { + return event["segmentation"] as? [String: Any] ?? [:] + } + } + XCTFail("No '\(key)' event found in either queue") + return [:] + } + + /** + *
+     * Test that the global content segmentation reaches a feedback widget event, that the keys the
+     * widget builds for itself win over a global value of the same name, and that 'nil' clears it.
+     *
+     * 1- Init SDK with a mock that fails every request, so the request queue keeps what is recorded
+     * 2- Set a global content segmentation that also carries a key the widget answers itself
+     * 3- Record an NPS widget result
+     * 4- Verify the event carries the global keys and that the answer won over the global value
+     * 5- Clear the global content segmentation with 'nil' and record the same result again
+     * 6- Verify the second event carries only what the widget builds for itself
+     * 
+ */ + func test_globalContentSegmentation_stampsFeedbackWidgetEvents_withoutOverridingTheirKeys() { + startSDKWithFailingNetwork() + + let widget = MockFeedbackWidget(id: "1234", type: CLYFeedbackWidgetType.NPS) + + Countly.sharedInstance().content().setGlobalContentSegmentation([ + "screen": "checkout", + "step": 3, + "rating": "should lose to the answer", + ]) + widget.recordResult(["rating": 4]) + + let stamped = lastEventSegmentation("[CLY]_nps") + XCTAssertEqual("checkout", stamped["screen"] as? String) + XCTAssertEqual("3", "\(stamped["step"] ?? "")") + XCTAssertEqual("4", "\(stamped["rating"] ?? "")", "the widget answer must win over the global value") + XCTAssertEqual("1234", stamped["widget_id"] as? String) + XCTAssertEqual(CountlyDeviceInfo.osName(), stamped["platform"] as? String) + + Countly.sharedInstance().content().setGlobalContentSegmentation(nil) + widget.recordResult(["rating": 4]) + + let cleared = lastEventSegmentation("[CLY]_nps") + XCTAssertNil(cleared["screen"], "a cleared global segmentation must not reach the next event") + XCTAssertNil(cleared["step"], "a cleared global segmentation must not reach the next event") + XCTAssertEqual("4", "\(cleared["rating"] ?? "")") + } + + /** + *
+     * Test that a global value never overrides a key the events build themselves, that a later call
+     * replaces the whole segmentation instead of merging into it, that a session ending and a new one
+     * starting do not forget it, and that content web view events carry it.
+     *
+     * 1- Init SDK with a mock that fails every request
+     * 2- Set a global content segmentation with one custom key
+     * 3- Replace it with one that also carries keys the widget builds itself, then restart the session
+     * 4- Record a content web view event and verify it carries the replacement and not the first set
+     * 5- Dismiss an NPS widget and verify the replacement survived the session restart
+     * 6- Verify the keys the widget builds itself still come from the widget
+     * 
+ */ + func test_globalContentSegmentation_neverOverridesTheEventsOwnKeys_andSurvivesASessionRestart() { + startSDKWithFailingNetwork() + + Countly.sharedInstance().content().setGlobalContentSegmentation(["screen": "settings"]) + Countly.sharedInstance().content().setGlobalContentSegmentation([ + "state": "logged_in", + "widget_id": "hijacked", + "platform": "hijacked", + "closed": "hijacked", + ]) + + Countly.sharedInstance().endSession() + Countly.sharedInstance().beginSession() + + let webViewManager = CountlyWebViewManager() + webViewManager.recordEvents(withJSONString: #"[{"key":"[CLY]_action","sg":{"type":"cta"}}]"#) + + let contentSegmentation = lastEventSegmentation("[CLY]_action") + XCTAssertEqual("cta", contentSegmentation["type"] as? String) + XCTAssertEqual("logged_in", contentSegmentation["state"] as? String) + XCTAssertNil(contentSegmentation["screen"], "setting a segmentation replaces the previous one") + + let widget = MockFeedbackWidget(id: "1234", type: CLYFeedbackWidgetType.NPS) + widget.recordResult(nil) + + let widgetSegmentation = lastEventSegmentation("[CLY]_nps") + XCTAssertEqual("logged_in", widgetSegmentation["state"] as? String) + XCTAssertNil(widgetSegmentation["screen"], "setting a segmentation replaces the previous one") + XCTAssertEqual("1234", widgetSegmentation["widget_id"] as? String) + XCTAssertEqual(CountlyDeviceInfo.osName(), widgetSegmentation["platform"] as? String) + XCTAssertEqual("1", "\(widgetSegmentation["closed"] ?? "")") + } + + /** + *
+     * Test that the global content segmentation is limited when it is set, and that a feedback
+     * widget event is not limited again, so it keeps every key of its own next to the global keys.
+     *
+     * 1- Init SDK with a segmentation value limit of 4 and a mock that fails every request
+     * 2- Set a global content segmentation of 6 keys
+     * 3- Record an NPS widget result that builds 5 keys for itself
+     * 4- Verify the event keeps all of its own keys and exactly 4 of the global keys
+     * 
+ */ + func test_globalContentSegmentation_isLimitedWhenSet_andWidgetEventsKeepAllOfTheirKeys() { + startSDKWithFailingNetwork(maxSegmentationValues: 4) + + let globalSegmentation = ["g1": "a", "g2": "b", "g3": "c", "g4": "d", "g5": "e", "g6": "f"] + Countly.sharedInstance().content().setGlobalContentSegmentation(globalSegmentation) + + let widget = MockFeedbackWidget(id: "1234", type: CLYFeedbackWidgetType.NPS) + widget.recordResult(["rating": 4, "comment": "fine"]) + + let widgetSegmentation = lastEventSegmentation("[CLY]_nps") + XCTAssertEqual("4", "\(widgetSegmentation["rating"] ?? "")") + XCTAssertEqual("fine", widgetSegmentation["comment"] as? String) + XCTAssertEqual("1234", widgetSegmentation["widget_id"] as? String) + XCTAssertEqual(CountlyDeviceInfo.osName(), widgetSegmentation["platform"] as? String) + XCTAssertNotNil(widgetSegmentation["app_version"]) + XCTAssertEqual(4, globalSegmentation.keys.filter { widgetSegmentation[$0] != nil }.count, "the global segmentation is limited once, when it is set") + } + + /** + *
+     * Test that a presented widget page receives the global content segmentation without the keys the server
+     * reads as answers, that a dismissal never carries a global answer, and that a content event keeps its own
+     * keys when the merged segmentation is over the limit.
+     *
+     * 1- Init SDK with a segmentation value limit of 3 and a mock that fails every request
+     * 2- Set a global content segmentation with one plain key and two reserved ones
+     * 3- Verify the widget URL's custom object carries only the plain key as 'sg'
+     * 4- Dismiss an NPS widget and verify the event carries the plain key and no global rating
+     * 5- Record a content event with two keys of its own and verify both survive the limit
+     * 
+ */ + func test_globalContentSegmentation_reachesWidgetPagesWithoutReservedKeys_andContentEventsKeepTheirOwnKeys() throws { + startSDKWithFailingNetwork(maxSegmentationValues: 3) + Countly.sharedInstance().content().setGlobalContentSegmentation(["plan": "pro", "closed": 1, "rating": 5]) + + let widget = MockFeedbackWidget(id: "1234", type: CLYFeedbackWidgetType.NPS) + let url = try XCTUnwrap(widget.perform(NSSelectorFromString("generateWidgetURL"))?.takeUnretainedValue() as? URL) + let customValue = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.first { $0.name == "custom" }?.value) + let custom = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(customValue.utf8)) as? [String: Any]) + XCTAssertEqual(["plan": "pro"] as NSDictionary, custom["sg"] as? NSDictionary, "the widget page gets the global segmentation without the keys the server reads as answers") + XCTAssertEqual("1", custom["tc"] as? String) + + widget.recordResult(nil) + let dismissal = lastEventSegmentation("[CLY]_nps") + XCTAssertEqual("pro", dismissal["plan"] as? String) + XCTAssertNil(dismissal["rating"], "a global rating never turns a dismissal into an answer") + XCTAssertEqual("1", "\(dismissal["closed"] ?? "")") + + let webViewManager = CountlyWebViewManager() + webViewManager.recordEvents(withJSONString: #"[{"key":"[CLY]_action","sg":{"type":"cta","content_id":"c1"}}]"#) + let content = lastEventSegmentation("[CLY]_action") + XCTAssertEqual("cta", content["type"] as? String) + XCTAssertEqual("c1", content["content_id"] as? String, "the event's own keys survive the segmentation limit") + XCTAssertEqual(1, ["plan", "closed", "rating"].filter { content[$0] != nil }.count, "global keys fill only the room the event leaves") + } + + /// The rating feedback widget gets the global segmentation on its page and its events, without the keys it records as answers. + func test_globalContentSegmentation_reachesTheRatingFeedbackWidget_withoutItsAnswerKeys() throws { + startSDKWithFailingNetwork() + Countly.sharedInstance().content().setGlobalContentSegmentation(["plan": "pro", "rating": 5, "comment": "global", "email": "global@example.com", "contactMe": true]) + + let widget = MockFeedbackWidget(id: "5678", type: CLYFeedbackWidgetType.rating) + let url = try XCTUnwrap(widget.perform(NSSelectorFromString("generateWidgetURL"))?.takeUnretainedValue() as? URL) + XCTAssertTrue(url.path.hasSuffix("/feedback/rating"), "the rating feedback widget page, got: \(url.path)") + let customValue = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.first { $0.name == "custom" }?.value) + let custom = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(customValue.utf8)) as? [String: Any]) + XCTAssertEqual(["plan": "pro"] as NSDictionary, custom["sg"] as? NSDictionary, "the rating page gets the global segmentation without its answer keys") + + widget.recordResult(["rating": 3, "comment": "user comment"]) + let answer = lastEventSegmentation("[CLY]_star_rating") + XCTAssertEqual(3, answer["rating"] as? Int, "the user's rating is kept") + XCTAssertEqual("user comment", answer["comment"] as? String, "the user's comment is kept") + XCTAssertNil(answer["email"], "a global email never appears as the user's answer") + XCTAssertNil(answer["contactMe"]) + XCTAssertEqual("pro", answer["plan"] as? String, "the global segmentation is recorded with the rating") + XCTAssertEqual("5678", answer["widget_id"] as? String) + } +} +#endif diff --git a/ios/src/CountlyTests/CountlyCrashReporterTests.swift b/ios/src/CountlyTests/CountlyCrashReporterTests.swift index 8bb5a43e..14d0b49c 100644 --- a/ios/src/CountlyTests/CountlyCrashReporterTests.swift +++ b/ios/src/CountlyTests/CountlyCrashReporterTests.swift @@ -7,12 +7,23 @@ // import Foundation - import XCTest + @testable import Countly class CountlyCrashReporterTests: CountlyBaseTestCase { - + + /// Local Countly instances created during tests. + var localCountlyInstances: [Countly] = [] + + override func tearDown() { + for instance in localCountlyInstances { + instance.halt(true) + } + localCountlyInstances = [] + super.tearDown() + } + func testRecordHandledException_globalCrashFilter() throws { let cConfig = createBaseConfig() cConfig.manualSessionHandling = true @@ -26,41 +37,42 @@ class CountlyCrashReporterTests: CountlyBaseTestCase { ] let crashFilterBlock: (CountlyCrashData?) -> Bool = { crash in - if (crash!.crashDescription.contains("Secret")) { + if crash!.crashDescription.contains("Secret") { return true } - + crash?.crashSegmentation.removeObject(forKey: "secret") crash?.fatal = true crash!.crashMetrics["secret"] = "Minato" crash!.crashMetrics.removeObject(forKey: "_ram_total") - + let keys = crash!.crashSegmentation.allKeys as? [String] return keys!.contains("sphinx_no_1") } - + cConfig.crashes().crashFilterCallback = crashFilterBlock - + let countly = Countly() + localCountlyInstances.append(countly) countly.start(with: cConfig) - + // First Exception let exception1 = NSException(name: NSExceptionName(rawValue: "secret"), reason: "Secret message") countly.record(exception1, isFatal: false, stackTrace: nil, segmentation: nil) XCTAssertEqual(0, CountlyPersistency.sharedInstance().remainingRequestCount()) - + // Second Exception let exception2 = NSException(name: NSExceptionName(rawValue: "some"), reason: "Some message") countly.record(exception2, isFatal: false, stackTrace: nil, segmentation: ["sphinx_no_1": "secret"]) XCTAssertEqual(0, CountlyPersistency.sharedInstance().remainingRequestCount()) - + // Third Exception countly.recordCrashLog("Breadcrumb_1") countly.recordCrashLog("Breadcrumb_2") let exception3 = NSException(name: NSExceptionName(rawValue: "some_other"), reason: "Some other message") countly.record(exception3, isFatal: false, stackTrace: nil, segmentation: ["sphinx_no": "324"]) XCTAssertEqual(1, CountlyPersistency.sharedInstance().remainingRequestCount()) - + try validateCrash( CountlyCrashReporterTests.extractStackTrace(exception3), breadcrumbs: "Breadcrumb_1\nBreadcrumb_2", @@ -72,28 +84,34 @@ class CountlyCrashReporterTests: CountlyBaseTestCase { "bool": String(true), "float": String(1.1), "long": String(Int64.max), - "sphinx_no": "324" + "sphinx_no": "324", ], idx: 0, customMetrics: ["secret": "Minato"], metricsToExclude: ["_ram_total"] ) } - - func validateCrash(_ stackTrace: String?, breadcrumbs: String, isFatal: Bool, changedBits: Int, customSegmentation: [String: Any], idx: Int, customMetrics: [String: Any], metricsToExclude: [String]) throws { - + + func validateCrash( + _ stackTrace: String?, breadcrumbs: String, isFatal: Bool, changedBits: Int, customSegmentation: [String: Any], + idx: Int, customMetrics: [String: Any], metricsToExclude: [String] + ) throws { + if let queuedRequests = CountlyPersistency.sharedInstance().value(forKey: "queuedRequests") as? [String] { let request = TestUtils.parseQueryString(queuedRequests[idx]) //TestUtils.validateRequiredParams(RQ[idx]) - + let crash = request["crash"] as! [String: Any] - - var paramCount = 0 //try validateCrashMetrics(crash: crash, customMetrics: customMetrics, metricsToExclude: metricsToExclude) - - paramCount += 2 // for nonFatal and ob + + // try validateCrashMetrics(crash: crash, + // customMetrics: customMetrics, + // metricsToExclude: metricsToExclude) + var paramCount = 0 + + paramCount += 2 // for nonFatal and ob XCTAssertEqual(!isFatal, crash["_nonfatal"] as? Bool) XCTAssertEqual(changedBits, crash["_ob"] as? Int) - + if !customSegmentation.isEmpty { paramCount += 1 let custom = crash["_custom"] as? [String: Any] @@ -103,42 +121,60 @@ class CountlyCrashReporterTests: CountlyBaseTestCase { } XCTAssertEqual(custom?.count, customSegmentation.count) } - + if !breadcrumbs.isEmpty { paramCount += 1 XCTAssertEqual(breadcrumbs, crash["_logs"] as? String) } - + //XCTAssertEqual(paramCount, crash.count) } - + } - - func validateCrashMetrics(crash: [String: Any], customMetrics: [String: Any], metricsToExclude: [String]) throws -> Int { + + func validateCrashMetrics(crash: [String: Any], customMetrics: [String: Any], metricsToExclude: [String]) throws + -> Int { var metricCount = 20 - metricsToExclude.count - try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_device", expectedValue: "C", crash: crash) + try assertMetricIfNotExcluded( + metricsToExclude: metricsToExclude, key: "_device", expectedValue: "C", crash: crash) try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_os", expectedValue: "A", crash: crash) - try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_os_version", expectedValue: "B", crash: crash) - try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_resolution", expectedValue: "E", crash: crash) - try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_app_version", expectedValue: "Countly.DEFAULT_APP_VERSION", crash: crash) - try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_manufacturer", expectedValue: "D", crash: crash) + try assertMetricIfNotExcluded( + metricsToExclude: metricsToExclude, key: "_os_version", expectedValue: "B", crash: crash) + try assertMetricIfNotExcluded( + metricsToExclude: metricsToExclude, key: "_resolution", expectedValue: "E", crash: crash) + try assertMetricIfNotExcluded( + metricsToExclude: metricsToExclude, key: "_app_version", expectedValue: "Countly.DEFAULT_APP_VERSION", + crash: crash) + try assertMetricIfNotExcluded( + metricsToExclude: metricsToExclude, key: "_manufacturer", expectedValue: "D", crash: crash) try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_cpu", expectedValue: "N", crash: crash) - try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_opengl", expectedValue: "O", crash: crash) - try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_root", expectedValue: "T", crash: crash) - try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_has_hinge", expectedValue: "Z", crash: crash) - try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_ram_total", expectedValue: "48", crash: crash) - try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_disk_total", expectedValue: "45", crash: crash) - try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_ram_current", expectedValue: "12", crash: crash) - try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_disk_current", expectedValue: "23", crash: crash) - try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_run", expectedValue: "88", crash: crash) - try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_background", expectedValue: "true", crash: crash) - try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_muted", expectedValue: "V", crash: crash) - try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_orientation", expectedValue: "S", crash: crash) - try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_online", expectedValue: "U", crash: crash) + try assertMetricIfNotExcluded( + metricsToExclude: metricsToExclude, key: "_opengl", expectedValue: "O", crash: crash) + try assertMetricIfNotExcluded( + metricsToExclude: metricsToExclude, key: "_root", expectedValue: "T", crash: crash) + try assertMetricIfNotExcluded( + metricsToExclude: metricsToExclude, key: "_has_hinge", expectedValue: "Z", crash: crash) + try assertMetricIfNotExcluded( + metricsToExclude: metricsToExclude, key: "_ram_total", expectedValue: "48", crash: crash) + try assertMetricIfNotExcluded( + metricsToExclude: metricsToExclude, key: "_disk_total", expectedValue: "45", crash: crash) + try assertMetricIfNotExcluded( + metricsToExclude: metricsToExclude, key: "_ram_current", expectedValue: "12", crash: crash) + try assertMetricIfNotExcluded( + metricsToExclude: metricsToExclude, key: "_disk_current", expectedValue: "23", crash: crash) + try assertMetricIfNotExcluded( + metricsToExclude: metricsToExclude, key: "_run", expectedValue: "88", crash: crash) + try assertMetricIfNotExcluded( + metricsToExclude: metricsToExclude, key: "_background", expectedValue: "true", crash: crash) + try assertMetricIfNotExcluded( + metricsToExclude: metricsToExclude, key: "_muted", expectedValue: "V", crash: crash) + try assertMetricIfNotExcluded( + metricsToExclude: metricsToExclude, key: "_orientation", expectedValue: "S", crash: crash) + try assertMetricIfNotExcluded( + metricsToExclude: metricsToExclude, key: "_online", expectedValue: "U", crash: crash) try assertMetricIfNotExcluded(metricsToExclude: metricsToExclude, key: "_bat", expectedValue: "6", crash: crash) - for (key, value) in customMetrics { guard let crashValue = crash[key], "\(crashValue)" == "\(value)" else { throw ValidationError.assertionFailed @@ -148,22 +184,247 @@ class CountlyCrashReporterTests: CountlyBaseTestCase { return metricCount } - + enum ValidationError: Error { case jsonError case assertionFailed } - - func assertMetricIfNotExcluded(metricsToExclude: [String], key: String, expectedValue: String, crash: [String: Any]) throws { + + func assertMetricIfNotExcluded(metricsToExclude: [String], key: String, expectedValue: String, crash: [String: Any]) + throws + { if !metricsToExclude.contains(key) { XCTAssertTrue(expectedValue == crash[key] as? String) - }else{ + } else { XCTAssertNil(crash[key]) } } - + static func extractStackTrace(_ exception: NSException) -> String? { return exception.callStackSymbols.joined(separator: "\n") } - + + // MARK: - handing crashes on to the handlers the SDK replaced + + #if !os(watchOS) + /// Address of the handler a signal currently runs, so a test can tell whose handler is live. + private func liveSignalHandler(_ signalCode: Int32) -> UnsafeRawPointer? { + var action = sigaction() + sigaction(signalCode, nil, &action) + return unsafeBitCast(action.__sigaction_u.__sa_handler, to: UnsafeRawPointer?.self) + } + + /// Address of the live uncaught exception handler. + private func liveExceptionHandler() -> UnsafeRawPointer? { + unsafeBitCast(NSGetUncaughtExceptionHandler(), to: UnsafeRawPointer?.self) + } + + /** + *
+     * The SDK installs over the handlers the app had, chains to them, leaves a signal the app ignores
+     * alone, never takes the hook back from a crash tool installed after it, and puts its replaced
+     * handlers back on halt.
+     *
+     * 1- Install an app exception handler and an app SIGFPE handler, and ignore SIGPIPE
+     * 2- Start with crash consent and verify the SDK's handlers are live, SIGPIPE stays ignored
+     * 3- Install a later crash tool's exception handler and apply the behavior settings again
+     * 4- Verify the later tool keeps the hook
+     * 5- Withdraw crash consent, verify the app's SIGFPE handler is back and the later tool still holds the exception hook
+     * 6- Call the SDK's handler the way the later tool chains to it, verify the app's handler runs and no crash is recorded
+     * 7- Give consent again, halt, and verify the app's SIGFPE handler is back once more
+     * 
+ */ + func test_crashHandlers_chainToTheReplacedHandlers_andLeaveIgnoredSignalsAndLaterHandlers() throws { + appExceptionHandlerCalls = 0 + let appExceptionHandler: @convention(c) (NSException) -> Void = { _ in appExceptionHandlerCalls += 1 } + let laterExceptionHandler: @convention(c) (NSException) -> Void = { _ in } + let appSignalHandler: @convention(c) (Int32) -> Void = { _ in } + NSSetUncaughtExceptionHandler(appExceptionHandler) + signal(SIGFPE, appSignalHandler) + signal(SIGPIPE, SIG_IGN) + defer { + NSSetUncaughtExceptionHandler(nil) + signal(SIGFPE, SIG_DFL) + signal(SIGPIPE, SIG_DFL) + } + let appExceptionAddress = unsafeBitCast(appExceptionHandler, to: UnsafeRawPointer.self) + let laterExceptionAddress = unsafeBitCast(laterExceptionHandler, to: UnsafeRawPointer.self) + let appSignalAddress = unsafeBitCast(appSignalHandler, to: UnsafeRawPointer.self) + + let config = createBaseConfig() + config.manualSessionHandling = true + config.requiresConsent = true + config.consents = [CLYConsent.crashReporting] + Countly.sharedInstance().start(with: config) + + let sdkExceptionHandler = try XCTUnwrap(NSGetUncaughtExceptionHandler()) + XCTAssertNotEqual(appExceptionAddress, liveExceptionHandler(), "the SDK installs its exception handler over the app's") + XCTAssertNotEqual(appSignalAddress, liveSignalHandler(SIGFPE), "the SDK installs its signal handler over the app's") + XCTAssertEqual(unsafeBitCast(SIG_IGN, to: UnsafeRawPointer?.self), liveSignalHandler(SIGPIPE), "a signal the app ignores is not a crash") + + NSSetUncaughtExceptionHandler(laterExceptionHandler) + CountlyServerConfig.sharedInstance()?.applyAutomaticTrackingState() + XCTAssertEqual(laterExceptionAddress, liveExceptionHandler(), "applying behavior settings never takes the hook back from a crash tool installed later") + + Countly.sharedInstance().cancelConsent(forFeature: CLYConsent.crashReporting) + XCTAssertEqual(appSignalAddress, liveSignalHandler(SIGFPE), "withdrawing consent puts the app's signal handler back") + XCTAssertEqual(laterExceptionAddress, liveExceptionHandler(), "a handler installed over the SDK's is not removed") + + sdkExceptionHandler(NSException(name: NSExceptionName("ChainedException"), reason: "chained by a later crash tool", userInfo: nil)) + XCTAssertEqual(1, appExceptionHandlerCalls, "the SDK's handler passes the crash on to the handler it replaced") + XCTAssertFalse(TestUtils.getCurrentRQ()?.contains { $0.contains("crash=") } ?? true, "without crash consent a chained crash is not recorded") + + Countly.sharedInstance().giveConsent(forFeature: CLYConsent.crashReporting) + XCTAssertNotEqual(appSignalAddress, liveSignalHandler(SIGFPE), "giving consent again reinstalls over the handler the SDK put back") + Countly.sharedInstance().halt(true) + XCTAssertEqual(appSignalAddress, liveSignalHandler(SIGFPE), "halt puts back the handler the SDK replaced") + XCTAssertEqual(laterExceptionAddress, liveExceptionHandler(), "halt leaves a later crash tool's handler alone") + } + + /** + *
+     * A broken pipe is often survived, and only the app's own SIGPIPE handler knows whether it is, so the
+     * SDK leaves a SIGPIPE the app handles to it, as it leaves one the app ignores, while it still
+     * installs over the app's other handlers.
+     *
+     * 1- Install app handlers for SIGPIPE and SIGFPE and start with crash reporting
+     * 2- Verify the app's SIGPIPE handler is still live and the SDK's handler runs for SIGFPE
+     * 3- Raise SIGPIPE and verify only the app's handler ran and nothing is recorded
+     * 
+ */ + func test_crashHandlers_leaveASigpipeTheAppHandlesToTheApp() throws { + appSignalHandlerCalls = 0 + let appSignalHandler: @convention(c) (Int32) -> Void = { _ in appSignalHandlerCalls += 1 } + signal(SIGPIPE, appSignalHandler) + signal(SIGFPE, appSignalHandler) + defer { + signal(SIGPIPE, SIG_DFL) + signal(SIGFPE, SIG_DFL) + } + let appSignalAddress = unsafeBitCast(appSignalHandler, to: UnsafeRawPointer.self) + + let config = createBaseConfig() + config.manualSessionHandling = true + Countly.sharedInstance().start(with: config) + XCTAssertEqual(appSignalAddress, liveSignalHandler(SIGPIPE), "the app's SIGPIPE handler stays live") + XCTAssertNotEqual(appSignalAddress, liveSignalHandler(SIGFPE), + "the SDK still installs over the app's other handlers") + + raise(SIGPIPE) + XCTAssertEqual(1, appSignalHandlerCalls, "the broken pipe goes to the app's handler alone") + let queue = TestUtils.getCurrentRQ() ?? [] + XCTAssertFalse(queue.contains { $0.contains("crash=") }, "and is not reported as a crash") + Countly.sharedInstance().halt(true) + } + + /** + *
+     * A crash tool installed while the SDK's handlers were removed chains to the handler before them, not
+     * to the SDK's, so the SDK installs over it once crash reporting is wanted again instead of staying
+     * silently off.
+     *
+     * 1- Start requiring consent with crash consent given, then withdraw it, which removes the SDK's handler
+     * 2- Install a crash tool's exception handler
+     * 3- Give crash consent again and verify the SDK's handler is live over the tool's
+     * 
+ */ + func test_crashHandlers_installOverAToolInstalledWhileTheyWereRemoved() throws { + let toolExceptionHandler: @convention(c) (NSException) -> Void = { _ in } + defer { NSSetUncaughtExceptionHandler(nil) } + let toolExceptionAddress = unsafeBitCast(toolExceptionHandler, to: UnsafeRawPointer.self) + + let config = createBaseConfig() + config.manualSessionHandling = true + config.requiresConsent = true + config.consents = [CLYConsent.crashReporting] + Countly.sharedInstance().start(with: config) + let sdkExceptionAddress = liveExceptionHandler() + + Countly.sharedInstance().cancelConsent(forFeature: CLYConsent.crashReporting) + XCTAssertNotEqual(sdkExceptionAddress, liveExceptionHandler(), "withdrawing consent removes the SDK's handler") + + NSSetUncaughtExceptionHandler(toolExceptionHandler) + Countly.sharedInstance().giveConsent(forFeature: CLYConsent.crashReporting) + XCTAssertEqual(sdkExceptionAddress, liveExceptionHandler(), + "the SDK installs over the tool installed while it was removed") + XCTAssertNotEqual(toolExceptionAddress, liveExceptionHandler()) + + Countly.sharedInstance().halt(true) + XCTAssertEqual(toolExceptionAddress, liveExceptionHandler(), "halt gives the tool its hook back") + } + + /** + *
+     * A signal that reaches the SDK's handler after the server turned automatic crash reporting off goes
+     * back to the handler the app had instead of returning into the code that raised it, which for a
+     * hardware fault would run the faulting instruction again forever.
+     *
+     * 1- Install an app SIGFPE handler, start the SDK with the settings reply held back
+     * 2- Release a reply that turns automatic crash reporting off, verify the SDK's handler stays live
+     * 3- Raise the signal and verify the app's handler ran once, is live again, and no crash is recorded
+     * 4- Install the SDK's handler again, raise again and verify the signal is handed back the same way
+     * 
+ */ + func test_signalHandler_handsTheSignalBack_whenAutomaticCrashReportingTurnsOffAtRuntime() throws { + appSignalHandlerCalls = 0 + let appSignalHandler: @convention(c) (Int32) -> Void = { _ in appSignalHandlerCalls += 1 } + signal(SIGFPE, appSignalHandler) + defer { signal(SIGFPE, SIG_DFL) } + let appSignalAddress = unsafeBitCast(appSignalHandler, to: UnsafeRawPointer.self) + + let releaseSettings = DispatchSemaphore(value: 0) + let settings = ServerConfigBuilder().automaticCrashReporting(false).build() + MockURLProtocol.requestHandler = { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + if request.url?.absoluteString.contains("method=sc") == true { + _ = releaseSettings.wait(timeout: .now() + 5) + return (settings.data(using: .utf8), response, nil) + } + return ("{\"result\":\"fail\"}".data(using: .utf8), HTTPURLResponse(url: request.url!, statusCode: 400, httpVersion: nil, headerFields: nil), nil) + } + defer { MockURLProtocol.replyEmptyToEveryRequest() } + + let config = createBaseConfig() + config.manualSessionHandling = true + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [MockURLProtocol.self] + config.urlSessionConfiguration = sessionConfig + Countly.sharedInstance().start(with: config) + XCTAssertNotEqual(appSignalAddress, liveSignalHandler(SIGFPE), "the SDK installs its signal handler over the app's") + + releaseSettings.signal() + TestUtils.waitUntil("the reply to turn automatic crash reporting off") { + CountlyServerConfig.sharedInstance()?.automaticCrashReportingEnabled() == false + } + XCTAssertNotEqual(appSignalAddress, liveSignalHandler(SIGFPE), + "a runtime 'acr' = false leaves the SDK's handler installed") + + raise(SIGFPE) + + XCTAssertEqual(1, appSignalHandlerCalls, "the signal is handed back to the app's handler") + XCTAssertEqual(appSignalAddress, liveSignalHandler(SIGFPE), "the app's handler is live again") + XCTAssertFalse(TestUtils.getCurrentRQ()?.contains { $0.contains("crash=") } ?? true, "automatic crash reporting is off, so nothing is recorded") + + let serverConfig = try XCTUnwrap(CountlyServerConfig.sharedInstance()) + serverConfig.setValue(true, forKey: "automaticCrashReportingEnabled") + try XCTUnwrap(CountlyCrashReporter.sharedInstance()).startCrashReporting() + serverConfig.setValue(false, forKey: "automaticCrashReportingEnabled") + XCTAssertNotEqual(appSignalAddress, liveSignalHandler(SIGFPE), + "the SDK installs its handler over the app's again") + + raise(SIGFPE) + + XCTAssertEqual(2, appSignalHandlerCalls, "a signal the app survived does not make the next one end the app") + XCTAssertEqual(appSignalAddress, liveSignalHandler(SIGFPE), "the app's handler is live again") + } + #endif + } + +#if !os(watchOS) +/// Times the app's stand-in exception handler ran. +private var appExceptionHandlerCalls = 0 +/// Times the app's stand-in signal handler ran. +private var appSignalHandlerCalls = 0 +#endif + diff --git a/ios/src/CountlyTests/CountlyDeviceIDTests.swift b/ios/src/CountlyTests/CountlyDeviceIDTests.swift index f507e754..fa262b63 100644 --- a/ios/src/CountlyTests/CountlyDeviceIDTests.swift +++ b/ios/src/CountlyTests/CountlyDeviceIDTests.swift @@ -70,10 +70,9 @@ class CountlyDeviceIDTests: CountlyBaseTestCase { let newId = "New_ID" Countly.sharedInstance().setID(newId) XCTAssertEqual(2, CountlyPersistency.sharedInstance().remainingRequestCount()) - guard let queuedRequests = CountlyPersistency.sharedInstance().value(forKey: "queuedRequests") as? [String] else { - fatalError("Failed to get queuedRequests from CountlyPersistency") + if let setIdRequest = TestUtils.requestAt(1) { + validateSetIdOnServerRequest(request: setIdRequest, newDeviceId: newId) } - validateSetIdOnServerRequest(request: queuedRequests[1], newDeviceId: newId) validateDeveloperSuppliedID(deviceID: newId) Countly.sharedInstance().setID("") @@ -87,8 +86,41 @@ class CountlyDeviceIDTests: CountlyBaseTestCase { Countly.sharedInstance().setID(sdkDeviceID) validateDeveloperSuppliedID(deviceID: newId) } - - + + // "changeDeviceIDWithMerge" and "changeDeviceIDWithoutMerge" with nil, an empty string or "CLYDefaultDeviceID" + // - validate that every call is ignored, the custom device ID stays and no request is queued + // - validate that an ignored change without merge keeps the given consents + // - validate that an empty value does not leave temporary device ID mode + // - validate that a valid device ID is still applied afterwards + + func test_changeDeviceID_emptyValuesAreIgnored() { + let config = createBaseConfig() + config.requiresConsent = true + config.consents = [CLYConsent.sessions, CLYConsent.events] + config.manualSessionHandling = true + config.deviceID = testDeviceID + Countly.sharedInstance().start(with: config) + validateDeveloperSuppliedID(deviceID: testDeviceID) + let requestCountBefore = CountlyPersistency.sharedInstance().remainingRequestCount() + + for emptyValue in [nil, "", CLYDefaultDeviceID] as [String?] { + Countly.sharedInstance().changeDeviceID(withMerge: emptyValue) + Countly.sharedInstance().changeDeviceIDWithoutMerge(emptyValue) + } + + validateDeveloperSuppliedID(deviceID: testDeviceID) + XCTAssertEqual(requestCountBefore, CountlyPersistency.sharedInstance().remainingRequestCount(), "an ignored device ID change must not queue a request") + XCTAssertTrue(CountlyConsentManager.sharedInstance().consentForEvents, "an ignored change without merge must keep the given consents") + + Countly.sharedInstance().enableTemporaryDeviceIDMode() + Countly.sharedInstance().changeDeviceIDWithoutMerge(CLYDefaultDeviceID) + XCTAssertEqual(CLYTemporaryDeviceID, Countly.sharedInstance().deviceID(), "an empty value must not leave temporary device ID mode") + + Countly.sharedInstance().changeDeviceIDWithoutMerge("valid_id") + validateDeveloperSuppliedID(deviceID: "valid_id") + } + + func validateDeveloperSuppliedID(deviceID: String) { let sdkDeviceID = Countly.sharedInstance().deviceID() XCTAssertTrue(Countly.sharedInstance().deviceIDType() == CLYDeviceIDType.custom, "Countly deviced id type should be Custom when device id is provided during init.") @@ -97,7 +129,7 @@ class CountlyDeviceIDTests: CountlyBaseTestCase { func validateSdkGeneratedID() { let sdkDeviceID = Countly.sharedInstance().deviceID() - XCTAssertTrue(Countly.sharedInstance().deviceIDType() == CLYDeviceIDType.IDFV, "Countly deviced id type should be IDFV when no device id is provided during init.") + XCTAssertTrue(Countly.sharedInstance().deviceIDType() == TestPlatform.defaultDeviceIDType, "Countly deviced id type should be the platform default (IDFV on iOS/tvOS/visionOS, NSUUID on watchOS/macOS) when no device id is provided during init.") XCTAssertTrue(sdkDeviceID == getIDFV(), "Countly device id not match with provided device id.") } @@ -106,8 +138,8 @@ class CountlyDeviceIDTests: CountlyBaseTestCase { let sdkDeviceID = Countly.sharedInstance().deviceID() - let oldDeviceID = parsedRequest["old_device_id"] as! String; - let deviceIDInRequest = parsedRequest["device_id"] as! String; + let oldDeviceID = parsedRequest["old_device_id"] as? String + let deviceIDInRequest = parsedRequest["device_id"] as? String XCTAssertTrue(Countly.sharedInstance().deviceIDType() == CLYDeviceIDType.custom, "Countly deviced id type should be Custom.") XCTAssertTrue(oldDeviceID == getIDFV()) diff --git a/ios/src/CountlyTests/CountlyHealthTrackerTests.swift b/ios/src/CountlyTests/CountlyHealthTrackerTests.swift index 68d50cfe..514e9748 100644 --- a/ios/src/CountlyTests/CountlyHealthTrackerTests.swift +++ b/ios/src/CountlyTests/CountlyHealthTrackerTests.swift @@ -6,23 +6,25 @@ // Copyright © 2025 Countly. All rights reserved. // import XCTest + @testable import Countly class CountlyHealthTrackerTests: CountlyBaseTestCase { - + // MARK: - Helper Methods - + /// Executes concurrent stress test with configurable parameters to detect race conditions and memory issues /// - Parameters: /// - iterations: Number of iterations per queue /// - writerDelay: Delay in microseconds between writer operations - /// - readerDelay: Delay in microseconds between reader operations + /// - readerDelay: Delay in microseconds between reader operations /// - extraThreads: Additional threads for mixed operations /// - timeout: Maximum time to wait for completion /// - writerBlock: Block executed by writer threads /// - readerBlock: Block executed by reader threads private func runConcurrentStressTest( iterations: Int = 10_000, + readerIterations: Int? = nil, writerDelay: useconds_t = 0, readerDelay: useconds_t = 0, extraThreads: Int = 0, @@ -34,11 +36,12 @@ class CountlyHealthTrackerTests: CountlyBaseTestCase { ) { let config = createBaseConfig() Countly.sharedInstance().start(with: config) - + + let actualReaderIterations = readerIterations ?? min(iterations, 100) let group = DispatchGroup() let writerQueue = DispatchQueue(label: "test.writer", attributes: .concurrent) let readerQueue = DispatchQueue(label: "test.reader", attributes: .concurrent) - + // Writer operations group.enter() writerQueue.async { @@ -50,11 +53,11 @@ class CountlyHealthTrackerTests: CountlyBaseTestCase { } group.leave() } - - // Reader operations + + // Reader operations (capped to avoid exhausting file descriptors from network calls) group.enter() readerQueue.async { - for _ in 0.. 0 { usleep(readerDelay) } @@ -62,12 +65,13 @@ class CountlyHealthTrackerTests: CountlyBaseTestCase { } group.leave() } - + // Additional mixed operations for maximum contention + let mixedIterations = min(iterations / 2, 100) for threadIndex in 0.. [(name: String, value: String?)] { @@ -96,10 +102,10 @@ class CountlyHealthTrackerTests: CountlyBaseTestCase { ("over_limit", String(repeating: "c", count: 1500)), ("unicode", String(repeating: "🚀", count: 250)), ("mixed_unicode", "Test🚀String🌟With✨Unicode🎉" + String(repeating: "x", count: 950)), - ("special_chars", String(repeating: "line\n\ttab\r", count: 100)) + ("special_chars", String(repeating: "line\n\ttab\r", count: 100)), ] } - + /// Executes all HealthTracker operations to test comprehensive property access /// - Parameter index: Index used for unique identifiers private func executeAllHealthTrackerOperations(index: Int) { @@ -116,12 +122,11 @@ class CountlyHealthTrackerTests: CountlyBaseTestCase { break } } - + // MARK: - Test Cases - - + // MARK: - Concurrent Access Tests - + /// Tests logFailedNetworkRequest under high concurrency to ensure thread safety /// 10,000 iterations with 4 extra threads performing mixed operations /// Validates that concurrent writes don't cause race conditions or crashes @@ -139,17 +144,17 @@ class CountlyHealthTrackerTests: CountlyBaseTestCase { } ) } - + /// Tests logFailedNetworkRequest with memory-intensive strings to catch objc_release errors /// Large strings (36,000+ chars), delayed operations to increase memory pressure /// Specifically targets the objc_release crash that occurred with rapid string copying func testLogFailedNetworkRequest_objcReleaseErrorPrevention() { runConcurrentStressTest( - iterations: 10_000, + iterations: 500, writerDelay: 50, readerDelay: 50, writerBlock: { _ in - let err = String(repeating: UUID().uuidString, count: 1000) // ~36KB string + let err = String(repeating: UUID().uuidString, count: 1000) // ~36KB string CountlyHealthTracker.sharedInstance() .logFailedNetworkRequest(withStatusCode: 500, errorResponse: err) }, @@ -158,20 +163,20 @@ class CountlyHealthTrackerTests: CountlyBaseTestCase { } ) } - + // MARK: - String Memory Management Tests - + /// Tests string memory management across various string types and concurrent scenarios /// Multiple string types (nil, empty, boundary cases, unicode, mutable strings) /// Validates proper copy semantics and immutability protection for errorMessage property func testLogFailedNetworkRequest_stringMemoryManagement() { let config = createBaseConfig() Countly.sharedInstance().start(with: config) - + let iterations = 5_000 let group = DispatchGroup() let testStrings = createTestStrings() - + // Test concurrent access with different string types for (index, testString) in testStrings.enumerated() { group.enter() @@ -180,7 +185,7 @@ class CountlyHealthTrackerTests: CountlyBaseTestCase { autoreleasepool { let errorString = testString.value ?? "" let finalString = errorString.isEmpty ? "nil_test_\(i)" : "\(errorString)_\(i)" - + CountlyHealthTracker.sharedInstance() .logFailedNetworkRequest(withStatusCode: 400 + index, errorResponse: finalString) } @@ -188,7 +193,7 @@ class CountlyHealthTrackerTests: CountlyBaseTestCase { group.leave() } } - + // Test mutable string protection - ensures copy semantics work correctly group.enter() DispatchQueue.global(qos: .userInitiated).async { @@ -197,30 +202,30 @@ class CountlyHealthTrackerTests: CountlyBaseTestCase { let mutableString = NSMutableString(string: "mutable_\(i)") CountlyHealthTracker.sharedInstance() .logFailedNetworkRequest(withStatusCode: 500, errorResponse: mutableString as String) - + // Modify original to test copy protection mutableString.append("_should_not_affect_copy") } } group.leave() } - + let result = group.wait(timeout: .now() + 30) XCTAssertEqual(result, .success, "String memory management test did not complete in time") } - + // MARK: - Race Condition Tests - - /// Tests rapid errorMessage property updates to catch race conditions that cause objc_release errors + + /// Tests rapid errorMessage property updates to catch race conditions that cause objc_release errors /// 8 concurrent writers, 4 readers, 20,000 iterations with mixed operations /// Targets the specific race condition where errorMessage property caused crashes func testLogFailedNetworkRequest_errorMessageRaceCondition() { let config = createBaseConfig() Countly.sharedInstance().start(with: config) - - let iterations = 20_000 + + let iterations = 2_000 let group = DispatchGroup() - + // Multiple writers rapidly updating errorMessage property for writerIndex in 0..<8 { group.enter() @@ -235,12 +240,13 @@ class CountlyHealthTrackerTests: CountlyBaseTestCase { group.leave() } } - + // Multiple readers accessing errorMessage through sendHealthCheck - for _ in 0..<4 { + // Capped to avoid exhausting file descriptors from network calls + for _ in 0..<2 { group.enter() DispatchQueue.global(qos: .userInitiated).async { - for _ in 0.. CountlyConfig { + let config = super.createBaseConfig() + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [MockURLProtocol.self] + config.urlSessionConfiguration = sessionConfig + config.manualSessionHandling = true + return config + } + + /// Keeps the `hc` parameter of every health check request the SDK sends. + private func recordHealthCheck(_ request: URLRequest) { + guard let query = request.url?.query, + let healthCheck = TestUtils.parseQueryString(query)["hc"] as? [String: Any] + else { return } + healthChecksLock.lock() + healthChecks.append(healthCheck) + healthChecksLock.unlock() + } + + /// The `hc` parameters recorded so far. Requests are answered on another thread, so every read is locked. + private func recordedHealthChecks() -> [[String: Any]] { + healthChecksLock.lock() + defer { healthChecksLock.unlock() } + return healthChecks + } + + /// The first health check after start reports the previous run's stored counts together with those counted before start. + func test_countersStoredByThePreviousRun_areAddedToTheOnesCountedBeforeStart() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + + let tracker = try XCTUnwrap(CountlyHealthTracker.sharedInstance()) + let hcQueue = try XCTUnwrap(tracker.value(forKey: "hcQueue") as? DispatchQueue) + hcQueue.sync { tracker.setValue(false, forKey: "persistedStateLoaded") } + UserDefaults.standard.set(["LWar": 5, "LErr": 2], forKey: "kCountlyHealthCheckStatePersistencyKey") + + tracker.logWarning() + tracker.logError() + + Countly.sharedInstance().start(with: createBaseConfig()) + + var reported: [String: Any]? + let didReport = TestUtils.waitUntil("a health check with the stored counters", timeout: 10) { + reported = recordedHealthChecks().first { ($0["wl"] as? Int ?? 0) >= 6 } + return reported != nil + } + let sent = recordedHealthChecks() + XCTAssertTrue(didReport, "the 5 stored warnings must be added to the one logged before start, got: \(sent)") + XCTAssertGreaterThanOrEqual(reported?["el"] as? Int ?? 0, 3, "the 2 stored errors must be added to the one logged before start") + } +} diff --git a/ios/src/CountlyTests/CountlyLocationTests.swift b/ios/src/CountlyTests/CountlyLocationTests.swift index f364b2d8..ecb28b8b 100644 --- a/ios/src/CountlyTests/CountlyLocationTests.swift +++ b/ios/src/CountlyTests/CountlyLocationTests.swift @@ -254,6 +254,83 @@ class CountlyLocationTests: CountlyBaseTestCase { XCTAssertTrue((parsedRequest["country_code"] as! String) == "JP", "Begin session should contains provided country code") XCTAssertTrue((parsedRequest["ip_address"] as! String) == "255.255.255.255", "Begin session should contains provided IP address") } - + + /** + *
+     * Behavior settings that turn location tracking off are applied at start before the SDK has its app key and host, so
+     * the empty location they call for is sent once init is complete. A request queued earlier carries "app_key=(null)",
+     * the server rejects it, and a rejected request stays at the head of the queue, holding back every request behind it.
+     *
+     * 1- Start with provided behavior settings that turn location tracking off, against a server that rejects an unknown app key
+     * 2- Verify the session begin and the empty location reach the server, and every request carries the app key
+     * 3- Halt and start again with the same settings: halt resets the location state, so the restart takes the same path
+     * 
+ */ + func testLocationTrackingOffAtStart_theSessionBeginIsNotHeldBehindARejectedRequest() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + let server = AppKeyCheckingServer(appKey: appKey) + MockURLProtocol.requestHandler = server.answer + defer { MockURLProtocol.replyEmptyToEveryRequest() } + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [MockURLProtocol.self] + + for run in ["first start", "restart after halt"] { + server.forgetReceived() + let config = createBaseConfig() + config.urlSessionConfiguration = sessionConfig + config.sdkBehaviorSettings = ServerConfigBuilder().locationTracking(false).build() + Countly.sharedInstance().start(with: config) + assertEveryRequestReachesTheServer(server, run) + Countly.sharedInstance().halt(true) + } + } + + /// Waits for the request queue to be sent, then checks that the session begin and the empty location reached the server + /// and that every request received or still queued carries the app key. + private func assertEveryRequestReachesTheServer(_ server: AppKeyCheckingServer, _ run: String, file: StaticString = #filePath, line: UInt = #line) { + TestUtils.waitUntil("the request queue is sent") { TestUtils.getCurrentRQ()?.isEmpty ?? true } + let received = server.received + XCTAssertTrue(received.contains { $0.contains("begin_session=1") }, "\(run): the session begin must not wait behind a rejected request, received: \(received)", file: file, line: line) + XCTAssertTrue(received.contains { TestUtils.parseQueryString($0)["location"] as? String == "" }, "\(run): the empty location reaches the server, received: \(received)", file: file, line: line) + for query in received + (TestUtils.getCurrentRQ() ?? []) { + XCTAssertEqual(appKey, TestUtils.parseQueryString(query)["app_key"] as? String, "\(run): every request carries the app key: \(query)", file: file, line: line) + } + } +} + +/// Answers like a Countly server, which rejects a request with an app key it does not know, and records what it received. +private final class AppKeyCheckingServer { + private let lock = NSLock() + private let appKey: String + private var queries: [String] = [] + + init(appKey: String) { + self.appKey = appKey + } + + /// Records the request and answers it, with the server's 400 when it does not carry the known app key. + func answer(_ request: URLRequest) -> (Data?, URLResponse?, Error?) { + let query = CountlyServerConfigTests.capturedQueryString(request) + lock.lock() + queries.append(query) + lock.unlock() + let isKnownApp = TestUtils.parseQueryString(query)["app_key"] as? String == appKey + let body = isKnownApp ? #"{"result":"Success"}"# : #"{"result":"App does not exist"}"# + return (body.data(using: .utf8), HTTPURLResponse(url: request.url!, statusCode: isKnownApp ? 200 : 400, httpVersion: nil, headerFields: nil), nil) + } + + /// The query strings received so far, in the order they arrived. + var received: [String] { + lock.lock() + defer { lock.unlock() } + return queries + } + + /// Forgets the requests received so far. + func forgetReceived() { + lock.lock() + queries = [] + lock.unlock() + } } diff --git a/ios/src/CountlyTests/CountlyPlatformTests.swift b/ios/src/CountlyTests/CountlyPlatformTests.swift new file mode 100644 index 00000000..e0e8f57c --- /dev/null +++ b/ios/src/CountlyTests/CountlyPlatformTests.swift @@ -0,0 +1,549 @@ +// +// CountlyPlatformTests.swift +// CountlyTests +// +// Cross-platform integration coverage for the Apple platforms this SDK ships to: +// iOS, watchOS, tvOS, macOS and visionOS. The same bundle is compiled and run for +// every platform, so each test asserts the expected-for-this-platform behaviour taken +// from `TestPlatform` (see TestPlatform.swift) rather than being duplicated per platform. +// +// Scope: +// - CountlyPlatformIntegrationTests — identity/metrics, config defaults, API surface, +// and that the core recording pipeline works everywhere. +// - CountlyPlatformLifecycleTests — which application-lifecycle callbacks the SDK +// actually observes on each platform, and what they do. +// + +import XCTest + +@testable import Countly + +#if canImport(UIKit) + import UIKit +#endif +#if canImport(AppKit) + import AppKit +#endif +#if canImport(WatchKit) + import WatchKit +#endif + +// MARK: - Identity, configuration and API surface + +class CountlyPlatformIntegrationTests: CountlyBaseTestCase { + + /// Groups everything that identifies the running platform to the server: the + /// metrics blob, the default device-ID type (and the `t` parameter derived from + /// it) and where the request queue is persisted. + func test_platformIdentity_metricsDeviceIDTypeAndStorageLocation() throws { + // --- metrics ----------------------------------------------------------- + let metrics = try XCTUnwrap(CountlyDeviceInfo.metricsDictionary() as? [String: Any]) + + XCTAssertEqual(TestPlatform.osName, metrics["_os"] as? String) + let deviceType = try XCTUnwrap(metrics["_device_type"] as? String) + XCTAssertTrue( + TestPlatform.deviceTypes.contains(deviceType), + "Unexpected _device_type '\(deviceType)' for \(TestPlatform.osName)") + + for key in ["_device", "_os_version", "_locale"] { + let value = metrics[key] as? String + XCTAssertFalse( + (value ?? "").isEmpty, "Metric \(key) must be reported on \(TestPlatform.osName)") + } + + if TestPlatform.reportsScreenMetrics { + // WIDTHxHEIGHT, both parseable as Double. Same contract as + // CountlyScreenMetricsTests.test_resolution_isWellFormedString, which also has to + // tolerate the exponent form `%g` can emit. + let resolution = try XCTUnwrap(metrics["_resolution"] as? String) + let sides = resolution.components(separatedBy: "x") + XCTAssertEqual(2, sides.count, "Unexpected _resolution '\(resolution)'") + for side in sides { + XCTAssertNotNil(Double(side), "Unexpected _resolution '\(resolution)'") + } + let density = try XCTUnwrap(metrics["_density"] as? String) + XCTAssertTrue(density.hasPrefix("@"), "Unexpected _density '\(density)'") + XCTAssertTrue(density.hasSuffix("x"), "Unexpected _density '\(density)'") + XCTAssertNotNil( + Int(density.dropFirst().dropLast()), "Unexpected _density '\(density)'") + } else { + XCTAssertNil(metrics["_resolution"]) + XCTAssertNil(metrics["_density"]) + } + + // `_carrier` is CoreTelephony-backed, so it can only ever appear on iOS — and even + // there it is absent without a SIM (every simulator). Only the negative direction + // is deterministic. + if !TestPlatform.reportsCarrier { + XCTAssertNil( + metrics["_carrier"], "_carrier must not be reported on \(TestPlatform.osName)") + } + + // --- default device ID type ------------------------------------------- + // createBaseConfig deliberately sets no deviceID, which is what this asserts. + let config = createBaseConfig() + config.requiresConsent = false + config.manualSessionHandling = true + Countly.sharedInstance().start(with: config) + + XCTAssertTrue( + Countly.sharedInstance().deviceIDType() == TestPlatform.defaultDeviceIDType, + "Default device ID type on \(TestPlatform.osName) should be " + + "\(TestPlatform.defaultDeviceIDType.rawValue)") + + Countly.sharedInstance().beginSession() + let request = TestUtils.parseQueryString(try XCTUnwrap(TestUtils.getCurrentRQ()?.first)) + XCTAssertEqual(TestPlatform.defaultDeviceIDTypeValue, request["t"] as? String) + XCTAssertEqual(TestPlatform.osName, (request["metrics"] as? [String: Any])?["_os"] as? String) + + // --- persistence location --------------------------------------------- + CountlyPersistency.sharedInstance().saveToFileSync() + + var expectedDirectory = try XCTUnwrap( + FileManager.default.urls(for: TestPlatform.storageDirectory, in: .userDomainMask).last) + #if os(macOS) + // macOS sandboxes per bundle, so the SDK nests the file under the bundle id. + if let bundleID = Bundle.main.bundleIdentifier { + expectedDirectory = expectedDirectory.appendingPathComponent(bundleID) + } + #endif + let storageFile = expectedDirectory.appendingPathComponent("Countly.dat") + XCTAssertTrue( + FileManager.default.fileExists(atPath: storageFile.path), + "Countly.dat is not at the expected \(TestPlatform.osName) location: \(storageFile.path)") + } + + /// The session/queue tuning defaults are the numbers the public documentation + /// quotes, and only `updateSessionPeriod` differs per platform. + func test_platformConfigDefaults_sessionPeriodThresholdAndQueueLimits() { + let config = CountlyConfig() + + XCTAssertEqual( + TestPlatform.defaultUpdateSessionPeriod, config.updateSessionPeriod, accuracy: 0.001, + "updateSessionPeriod default is wrong for \(TestPlatform.osName)") + // eventSendThreshold is 100 on every platform — it has not been watchOS-specific + // since it was raised from the old 3/10 split. + XCTAssertEqual(100, config.eventSendThreshold) + XCTAssertEqual(1000, config.storedRequestsLimit) + XCTAssertEqual(30, config.requestTimeoutDuration) + } + + /// The public API surface is trimmed per platform by `TARGET_OS_*` guards. This + /// asserts both directions: platform-specific features are absent where they are + /// compiled out, and the always-available ones respond everywhere. + func test_platformFeatureSurface_matchesCompiledGuards() { + let countly = Countly.sharedInstance() + + // WebKit-backed: feedback widgets, star rating, content zone. + for selector in [ + NSSelectorFromString("feedback"), + NSSelectorFromString("content"), + NSSelectorFromString("askForStarRating:"), + ] { + XCTAssertEqual( + TestPlatform.hasWebKitFeatures, countly.responds(to: selector), + "\(selector) availability does not match \(TestPlatform.osName)") + } + + // Push notifications. + XCTAssertEqual( + TestPlatform.hasPushNotifications, + countly.responds(to: NSSelectorFromString("askForNotificationPermission")), + "Push notification API availability does not match \(TestPlatform.osName)") + + // the config switch and the implementation must agree, or a consent change hits an unrecognized selector + XCTAssertEqual( + TestPlatform.hasAutoViewTrackingAPI, + CountlyConfig().responds(to: NSSelectorFromString("enableAutomaticViewTracking")), + "enableAutomaticViewTracking availability does not match \(TestPlatform.osName)") + // Checked on the class, not the singleton: `CountlyViewTrackingInternal.sharedInstance` + // returns nil until the SDK has started. + for selector in ["startAutoViewTracking", "stopAutoViewTracking"] { + XCTAssertEqual( + TestPlatform.hasAutoViewTracking, + CountlyViewTrackingInternal.instancesRespond(to: NSSelectorFromString(selector)), + "\(selector) implementation availability does not match \(TestPlatform.osName)") + } + + // `suspend`/`resume` are implemented on every platform (the SDK drives them from + // its own lifecycle handlers) but only *declared* in the public header on watchOS. + // Header visibility is compile-time only — the watchOS test calling them directly + // is what proves it — so at runtime they must respond everywhere. + XCTAssertTrue(countly.responds(to: NSSelectorFromString("suspend"))) + XCTAssertTrue(countly.responds(to: NSSelectorFromString("resume"))) + + // Available on every platform. + for selector in [ + NSSelectorFromString("views"), + NSSelectorFromString("remoteConfig"), + NSSelectorFromString("userProfile"), + NSSelectorFromString("recordEvent:"), + NSSelectorFromString("beginSession"), + NSSelectorFromString("endSession"), + ] { + XCTAssertTrue( + countly.responds(to: selector), + "\(selector) must be available on \(TestPlatform.osName)") + } + } + + /// End-to-end smoke test of the recording pipeline that every platform shares: + /// a manual view, a custom event and a user property all reach the request queue + /// with the right shape. Manual view recording in particular is *not* limited to + /// the auto-view-tracking platforms. + func test_corePipeline_recordsViewEventAndUserPropertyOnEveryPlatform() throws { + let config = TestUtils.createBaseConfig() + config.requiresConsent = false + config.manualSessionHandling = true + Countly.sharedInstance().start(with: config) + + let viewID = Countly.sharedInstance().views().startView("platform_view") + XCTAssertNotNil( + viewID, "Manual view recording must work on \(TestPlatform.osName)") + + Countly.sharedInstance().recordEvent("platform_event", segmentation: ["p": TestPlatform.osName]) + Countly.sharedInstance().views().stopView(withID: viewID) + + Countly.sharedInstance().userProfile().set("platform", value: TestPlatform.osName) + Countly.sharedInstance().userProfile().save() + + // The user-details save flushes the event queue into the request queue first. + let requests = try XCTUnwrap(TestUtils.getCurrentRQ()) + let joined = requests.joined(separator: "\n") + XCTAssertTrue(joined.contains("platform_event"), "Custom event missing from RQ:\n\(joined)") + XCTAssertTrue(joined.contains("%5BCLY%5D_view"), "View event missing from RQ:\n\(joined)") + XCTAssertTrue(joined.contains("user_details"), "User details missing from RQ:\n\(joined)") + + // Every request carries the platform's metrics and device ID type. + for request in requests { + let parsed = TestUtils.parseQueryString(request) + XCTAssertEqual(TestUtils.commonDeviceId, parsed["device_id"] as? String) + XCTAssertEqual(TestUtils.SDK_NAME, parsed["sdk_name"] as? String) + } + } +} + +// MARK: - Application lifecycle + +class CountlyPlatformLifecycleTests: CountlyBaseTestCase { + + /// Starts the SDK with automatic session handling and a live session, so lifecycle + /// notifications have something observable to act on. + /// + /// The assertions below count `begin_session` / `end_session` occurrences in the whole + /// request queue, so the queue has to start empty. `cleanupState()` in setUp is not enough + /// on its own: classes that start the SDK once per class rather than per test can leave + /// requests behind, which showed up as "expected 1 session, got 2". halt(true) here runs + /// while the SDK is live, so it genuinely wipes the persisted queue. + private func startWithAutomaticSession() { + Countly.sharedInstance().halt(true) + let config = TestUtils.createBaseConfig() + config.requiresConsent = false + Countly.sharedInstance().start(with: config) + } + + private func requestQueue() -> [String] { + return TestUtils.getCurrentRQ() ?? [] + } + + private func count(of marker: String) -> Int { + return requestQueue().filter { $0.contains(marker) }.count + } + + /// Posts a notification. The queue mutations these tests assert on are synchronous, so + /// no wait is needed; `waitUntil` is there for the handlers that hop through + /// `dispatch_async` (health state save, `saveToFile`). + private func post(_ name: Notification.Name) { + NotificationCenter.default.post(name: name, object: nil) + } + + /// Returns as soon as `condition` holds, so a satisfied expectation costs no wall clock. + private func waitUntil( + _ description: String, timeout: TimeInterval = 2.0, _ condition: () -> Bool + ) { + let deadline = Date().addingTimeInterval(timeout) + while !condition() && Date() < deadline { + RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.02)) + } + XCTAssertTrue(condition(), "Timed out waiting for \(description)") + } + + /// The core lifecycle contract, asserted per platform in one flow: + /// - iOS/tvOS/visionOS observe UIKit background/foreground and cycle the session. + /// - macOS has no background state; it only recovers a dropped `begin_session` + /// when the app becomes active, and never ends the session automatically. + /// - watchOS observes nothing; the host app drives `suspend`/`resume`. + func test_lifecycleNotifications_cycleSessionAccordingToPlatform() throws { + startWithAutomaticSession() + + #if os(iOS) || os(tvOS) || os(visionOS) + // The simulator's xctest host is active, so start already opened a session. + let sessionsAtStart = count(of: "begin_session=1") + XCTAssertGreaterThanOrEqual(sessionsAtStart, 1, "Expected a session at start") + let endsAtStart = count(of: "end_session=1") + + // Background ends the session and persists health state. + post(UIApplication.didEnterBackgroundNotification) + XCTAssertEqual(endsAtStart + 1, count(of: "end_session=1"), "Background must end the session") + + // Foreground alone is only a notification hook; activation is what resumes. + post(UIApplication.willEnterForegroundNotification) + XCTAssertEqual( + sessionsAtStart, count(of: "begin_session=1"), + "willEnterForeground must not begin a session") + + post(UIApplication.didBecomeActiveNotification) + XCTAssertEqual( + sessionsAtStart + 1, count(of: "begin_session=1"), + "Activation must begin a new session") + + // Resigning active saves health state but must not end the session. + post(UIApplication.willResignActiveNotification) + XCTAssertEqual( + endsAtStart + 1, count(of: "end_session=1"), + "willResignActive must not end the session") + + #elseif os(macOS) + // The base test case reports the app as active, so start opened a session. + let sessionsAtStart = count(of: "begin_session=1") + XCTAssertGreaterThanOrEqual(sessionsAtStart, 1, "Expected a session at start") + + // macOS has no background state, so nothing ends the session on its own and + // re-activation must not open a second one. + let endsAtStart = count(of: "end_session=1") + post(NSApplication.willResignActiveNotification) + XCTAssertEqual( + endsAtStart, count(of: "end_session=1"), + "macOS must not end sessions on resign active") + + post(NSApplication.didBecomeActiveNotification) + XCTAssertEqual( + sessionsAtStart, count(of: "begin_session=1"), + "Re-activation must not open a second session") + + #elseif os(watchOS) + // watchOS registers no lifecycle observers, and there is no notification to post + // there in the first place. The observable contract is that the session opened at + // start stays open until the host app calls `suspend` itself, which is what + // test_manualSuspendResume_isTheWatchOSSessionDriver covers. + XCTAssertEqual(1, count(of: "begin_session=1"), "Expected a session at start") + XCTAssertEqual( + 0, count(of: "end_session=1"), "Nothing may end a watchOS session on its own") + #endif + } + + #if !os(watchOS) + /// `applicationWillTerminate` is observed on every platform but watchOS, where there is + /// no such notification and `suspend` is the documented equivalent (covered by the + /// suspend/resume test). It must flush pending events into the request queue, stop + /// running views and mark the connection manager as terminating. + func test_terminateCallback_flushesEventsAndStopsViews() throws { + let config = TestUtils.createBaseConfig() + config.requiresConsent = false + config.manualSessionHandling = true + Countly.sharedInstance().start(with: config) + + let viewID = Countly.sharedInstance().views().startView("terminating_view") + Countly.sharedInstance().recordEvent("pending_event") + XCTAssertFalse( + (TestUtils.getCurrentEQ() ?? []).isEmpty, "Event should still be queued before terminate") + + #if os(macOS) + post(NSApplication.willTerminateNotification) + #else + post(UIApplication.willTerminateNotification) + #endif + + XCTAssertTrue(CountlyConnectionManager.sharedInstance().isTerminating) + XCTAssertEqual(0, TestUtils.getCurrentEQ()?.count, "Terminate must flush the event queue") + + let joined = requestQueue().joined(separator: "\n") + XCTAssertTrue(joined.contains("pending_event"), "Flushed event missing from RQ:\n\(joined)") + XCTAssertTrue(joined.contains("%5BCLY%5D_view"), "View end event missing from RQ:\n\(joined)") + + // Reset so later tests are not affected by the terminating flag. + CountlyConnectionManager.sharedInstance().isTerminating = false + XCTAssertNotNil(viewID) + } + #endif + + #if os(watchOS) + /// `suspend`/`resume` are the host app's substitute for the UIKit notifications on + /// watchOS. `suspend` must end the session and flush, and `resume` must open exactly one + /// new session however many times it is called. + func test_manualSuspendResume_isTheWatchOSSessionDriver() throws { + startWithAutomaticSession() + Countly.sharedInstance().recordEvent("watch_event") + + Countly.sharedInstance().suspend() + XCTAssertEqual(1, count(of: "end_session=1"), "suspend must end the session") + XCTAssertEqual(0, TestUtils.getCurrentEQ()?.count, "suspend must flush the event queue") + XCTAssertTrue( + requestQueue().joined(separator: "\n").contains("watch_event"), + "suspend must move recorded events into the request queue") + + // suspend is idempotent — a second call must not add another end_session. + Countly.sharedInstance().suspend() + XCTAssertEqual(1, count(of: "end_session=1"), "suspend must be idempotent") + + // `resume` skips its very first invocation in a process to avoid double + // sessions on watch app launch, so two calls yield exactly one new session. + let sessionsBefore = count(of: "begin_session=1") + Countly.sharedInstance().resume() + Countly.sharedInstance().resume() + XCTAssertEqual( + sessionsBefore + 1, count(of: "begin_session=1"), + "resume must open exactly one new session") + } + #endif + + /// Visibility segmentation depends on a per-platform foreground check + /// (`UIApplication` / `NSApplication` / `WKExtension`); the test process is in the foreground everywhere, so it reports 1. + func test_visibilityTracking_resolvesForegroundStateOnEveryPlatform() throws { + let config = TestUtils.createBaseConfig() + config.requiresConsent = false + config.manualSessionHandling = true + config.experimental().enableVisibiltyTracking = true + // `CountlyConfig.experimental` is a process-wide shared object, so leaving the flag + // set would turn visibility segmentation on for every later test. + defer { config.experimental().enableVisibiltyTracking = false } + Countly.sharedInstance().start(with: config) + + Countly.sharedInstance().recordEvent("visibility_event") + CountlyConnectionManager.sharedInstance().sendEvents() + waitUntil("the event to reach the request queue") { + self.requestQueue().contains { $0.contains("visibility_event") } + } + + let eventRequest = try XCTUnwrap( + requestQueue().first { $0.contains("visibility_event") }, + "No request carried the visibility event") + let events = try XCTUnwrap( + TestUtils.parseQueryString(eventRequest)["events"] as? String) + let parsed = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(events.utf8)) as? [[String: Any]]) + let segmentation = try XCTUnwrap( + parsed.first(where: { $0["key"] as? String == "visibility_event" })?["segmentation"] + as? [String: Any]) + let visibility = try XCTUnwrap( + segmentation["cly_v"] as? Int, + "cly_v must be reported on \(TestPlatform.osName), got: \(segmentation)") + XCTAssertEqual( + 1, visibility, "the test process is in the foreground, cly_v must be 1 on \(TestPlatform.osName)") + } + + #if os(iOS) || os(tvOS) || os(visionOS) + /// An inactive app, under a system alert or in the app switcher, is still on screen, so its events + /// count as visible, as on Android, while the events of an app in the background do not. + func test_visibilityTracking_countsAnInactiveAppAsVisible() throws { + // a hostless run has no shared application, so there is no state for the stub to answer with + guard UIApplication.value(forKey: "sharedApplication") != nil else { + throw XCTSkip("needs a host application, as on the device farm") + } + let config = TestUtils.createBaseConfig() + config.requiresConsent = false + config.manualSessionHandling = true + config.experimental().enableVisibiltyTracking = true + defer { + config.experimental().enableVisibiltyTracking = false + TestAppActivation.stateOverride = nil + } + Countly.sharedInstance().start(with: config) + + TestAppActivation.stateOverride = .inactive + XCTAssertEqual(1, try recordedVisibility(of: "inactive_event"), "an inactive app is visible") + + TestAppActivation.stateOverride = .background + XCTAssertEqual(0, try recordedVisibility(of: "background_event"), "an app in the background is not") + } + + /// Records an event, turns it into a request and returns the visibility it was recorded with. + private func recordedVisibility(of key: String) throws -> Int { + Countly.sharedInstance().recordEvent(key) + CountlyConnectionManager.sharedInstance().sendEvents() + waitUntil("the event to reach the request queue") { + self.requestQueue().contains { $0.contains(key) } + } + + let eventRequest = try XCTUnwrap(requestQueue().first { $0.contains(key) }) + let events = try XCTUnwrap(TestUtils.parseQueryString(eventRequest)["events"] as? String) + let parsed = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(events.utf8)) as? [[String: Any]]) + let event = parsed.first(where: { $0["key"] as? String == key }) + let segmentation = try XCTUnwrap(event?["segmentation"] as? [String: Any]) + return try XCTUnwrap(segmentation["cly_v"] as? Int, "cly_v must be reported, got: \(segmentation)") + } + #endif + + #if os(macOS) + /// `beginSession` refuses to open a session while a macOS app is not active (launched + /// hidden, as a login item, or opened by another app). macOS observes + /// `NSApplicationDidBecomeActiveNotification` so that the dropped session is opened on + /// the first activation instead of being lost for the life of the process. + func test_macOSInactiveLaunch_recoversDroppedSessionOnActivation() throws { + TestAppActivation.isActive = false + startWithAutomaticSession() + XCTAssertEqual( + 0, count(of: "begin_session=1"), + "An inactive macOS app must not open a session at start") + + // Still inactive: activation notifications alone must not fabricate a session. + post(NSApplication.didBecomeActiveNotification) + XCTAssertEqual(0, count(of: "begin_session=1")) + + TestAppActivation.isActive = true + post(NSApplication.didBecomeActiveNotification) + XCTAssertEqual( + 1, count(of: "begin_session=1"), + "Activation must recover the session macOS dropped at start") + + post(NSApplication.didBecomeActiveNotification) + XCTAssertEqual(1, count(of: "begin_session=1"), "and only one session") + } + #endif + + #if !os(watchOS) + /// Activation while a session runs, as on macOS or after an iOS alert, keeps the session without a warning. + func test_activationWithARunningSession_keepsItWithoutAWarning() throws { + startWithAutomaticSession() + let sessionsAtStart = count(of: "begin_session=1") + XCTAssertGreaterThanOrEqual(sessionsAtStart, 1, "Expected a session at start") + + let logger = WarningRecordingLogger() + CountlyCommon.sharedInstance().loggerDelegate = logger + defer { CountlyCommon.sharedInstance().loggerDelegate = nil } + + #if os(macOS) + post(NSApplication.willResignActiveNotification) + post(NSApplication.didBecomeActiveNotification) + #else + post(UIApplication.willResignActiveNotification) + post(UIApplication.didBecomeActiveNotification) + #endif + + XCTAssertEqual(sessionsAtStart, count(of: "begin_session=1"), "activation must keep the running session") + XCTAssertEqual( + 0, logger.count(containing: "A session is already running"), + "activation must not warn about the session it keeps") + } + #endif +} + +/// Collects the lines the SDK hands to a host app's logger. Lines can arrive from any queue. +private final class WarningRecordingLogger: NSObject, CountlyLoggerDelegate { + private let lock = NSLock() + private var lines: [String] = [] + + /// Keeps every line the SDK logs. + func internalLog(_ log: String, with level: CLYInternalLogLevel) { + lock.lock() + lines.append(log) + lock.unlock() + } + + /// Number of collected lines that contain the given text. + func count(containing text: String) -> Int { + lock.lock() + defer { lock.unlock() } + return lines.filter { $0.contains(text) }.count + } +} diff --git a/ios/src/CountlyTests/CountlyQueueFlushRunnablesTests.swift b/ios/src/CountlyTests/CountlyQueueFlushRunnablesTests.swift new file mode 100644 index 00000000..dcac0a07 --- /dev/null +++ b/ios/src/CountlyTests/CountlyQueueFlushRunnablesTests.swift @@ -0,0 +1,330 @@ +// +// CountlyQueueFlushRunnablesTests.swift +// CountlyTests +// +// Tests for queue flush runnables feature. +// + +import XCTest +@testable import Countly + +/// Tests for queue flush runnables feature (CLYQueueFlushRunnable). +class CountlyQueueFlushRunnablesTests: CountlyCallbackBaseTestCase { + + // MARK: - Tests + + /** + * Test adding a single queue flush runnable + * Verify that the runnable can be added without error + */ + func test_addQueueFlushRunnable_singleRunnable() throws { + guard let connectionManager = connectionManager else { + XCTFail("ConnectionManager not available") + return + } + + var runnableExecuted = false + connectionManager.addQueueFlushRunnable { + runnableExecuted = true + } + + // Runnable should not be executed yet (no queue flush) + XCTAssertFalse(runnableExecuted) + } + + /** + * Test adding multiple queue flush runnables + * Verify that multiple runnables can be registered + */ + func test_addQueueFlushRunnable_multipleRunnables() throws { + guard let connectionManager = connectionManager else { + XCTFail("ConnectionManager not available") + return + } + + var runnable1Executed = false + var runnable2Executed = false + var runnable3Executed = false + + connectionManager.addQueueFlushRunnable { + runnable1Executed = true + } + connectionManager.addQueueFlushRunnable { + runnable2Executed = true + } + connectionManager.addQueueFlushRunnable { + runnable3Executed = true + } + + // Runnables should not be executed yet + XCTAssertFalse(runnable1Executed) + XCTAssertFalse(runnable2Executed) + XCTAssertFalse(runnable3Executed) + } + + /** + * Test clearing all queue flush runnables + * Verify that clearQueueFlushRunnables removes all registered runnables + */ + func test_clearQueueFlushRunnables() throws { + guard let connectionManager = connectionManager else { + XCTFail("ConnectionManager not available") + return + } + + var runnableExecuted = false + connectionManager.addQueueFlushRunnable { + runnableExecuted = true + } + + // Clear runnables before any queue processing + connectionManager.clearQueueFlushRunnables() + + // Add a request and let it complete (queue will flush) + Countly.sharedInstance().addDirectRequest(["test": "request"]) + + TestUtils.sleep(1) {} + + // Runnable should NOT have executed because it was cleared + XCTAssertFalse(runnableExecuted) + } + + /** + * Test that runnables are executed when all requests succeed + * Uses MockURLProtocol which returns 200 OK with valid JSON + */ + func test_queueFlushRunnables_executedOnSuccess() throws { + guard let connectionManager = connectionManager else { + XCTFail("ConnectionManager not available") + return + } + + var runnableExecuted = false + connectionManager.addQueueFlushRunnable { + runnableExecuted = true + } + + // Add a request - MockURLProtocol will return success + Countly.sharedInstance().addDirectRequest(["test": "request"]) + + TestUtils.sleep(1) {} + + // Runnable should have executed after successful queue flush + XCTAssertTrue(runnableExecuted) + } + + /** + * Test that multiple runnables are all executed in order when queue flushes successfully + */ + func test_queueFlushRunnables_multipleExecutedInOrder() throws { + guard let connectionManager = connectionManager else { + XCTFail("ConnectionManager not available") + return + } + + var executionOrder: [Int] = [] + + connectionManager.addQueueFlushRunnable { + executionOrder.append(1) + } + connectionManager.addQueueFlushRunnable { + executionOrder.append(2) + } + connectionManager.addQueueFlushRunnable { + executionOrder.append(3) + } + + // Add a request - MockURLProtocol will return success + Countly.sharedInstance().addDirectRequest(["test": "request"]) + + TestUtils.sleep(1) {} + + // All runnables should have executed in order + XCTAssertEqual(executionOrder, [1, 2, 3]) + } + + /** + * Test that runnables are removed after successful execution + * Add runnable, let it execute, add another request - first runnable should not execute again + */ + func test_queueFlushRunnables_removedAfterExecution() throws { + guard let connectionManager = connectionManager else { + XCTFail("ConnectionManager not available") + return + } + + var executionCount = 0 + connectionManager.addQueueFlushRunnable { + executionCount += 1 + } + + // First request - runnable should execute + Countly.sharedInstance().addDirectRequest(["test": "request1"]) + + TestUtils.sleep(1) {} + + XCTAssertEqual(executionCount, 1) + + // Second request - runnable should NOT execute again (was removed) + Countly.sharedInstance().addDirectRequest(["test": "request2"]) + + TestUtils.sleep(1) {} + + // Execution count should still be 1 + XCTAssertEqual(executionCount, 1) + } + + /** + * Test that runnables are NOT executed when a request fails + * Uses MockURLProtocol to simulate a failure response + */ + func test_queueFlushRunnables_notExecutedOnFailure() throws { + guard let connectionManager = connectionManager else { + XCTFail("ConnectionManager not available") + return + } + + // Configure MockURLProtocol to return a failure (500 error) + MockURLProtocol.requestHandler = Self.createErrorHandler(statusCode: 500, message: "Server Error") + + var runnableExecuted = false + connectionManager.addQueueFlushRunnable { + runnableExecuted = true + } + + // Add a request - MockURLProtocol will return 500 error + Countly.sharedInstance().addDirectRequest(["test": "request"]) + + TestUtils.sleep(1) {} + + // Runnable should NOT have executed due to failure + XCTAssertFalse(runnableExecuted) + + // Restore success handler for subsequent tests + MockURLProtocol.requestHandler = Self.createSuccessHandler() + } + + /** + * Test thread safety - add runnables from multiple threads concurrently + */ + func test_queueFlushRunnables_threadSafety() throws { + guard let connectionManager = connectionManager else { + XCTFail("ConnectionManager not available") + return + } + + let expectation = XCTestExpectation(description: "All runnables added") + let totalRunnables = 100 + var executedCount = 0 + let lock = NSLock() + + let queue1 = DispatchQueue(label: "test.queue1", attributes: .concurrent) + let queue2 = DispatchQueue(label: "test.queue2", attributes: .concurrent) + let group = DispatchGroup() + + // Add runnables from multiple threads concurrently + for _ in 0..(_ body: () -> T) -> T { + let lock = common.value(forKey: "sdkLogsLock") as AnyObject + objc_sync_enter(lock) + defer { objc_sync_exit(lock) } + return body() + } + + private func clearBuffer() { + withGatheringLock { + (common.value(forKey: "sdkLogs") as? NSMutableArray)?.removeAllObjects() + common.setValue(NSNumber(value: 0), forKey: "sdkLogsChars") + (common.value(forKey: "sdkLogsAwaitingConsent") as? NSMutableArray)?.removeAllObjects() + common.setValue(NSNumber(value: 0), forKey: "sdkLogsAwaitingConsentChars") + common.setValue(NSNumber(value: 0), forKey: "sdkLogsDropCount") + } + } + + private func capture(_ count: Int, level: Character = "d", message: String = "harness line") { + let levelChar = CChar(level.asciiValue!) + for i in 0.. Bool { + (line["m"] as? String)?.hasPrefix(harnessPrefix) == true + } + + private var state: Int { (common.value(forKey: "sdkLogsState") as? NSNumber)?.intValue ?? -1 } + private var levels: String { (common.value(forKey: "sdkLogsLevels") as? String) ?? "" } + private var batchSize: Int { (common.value(forKey: "sdkLogsBatchSize") as? NSNumber)?.intValue ?? -1 } + private var dropCount: Int { (common.value(forKey: "sdkLogsDropCount") as? NSNumber)?.intValue ?? -1 } + + /// Brings up the SDK so the request queue exists, and waits out the behavior settings fetch. + /// + /// CountlyPersistency has no instance after halt(), so reading the queue without this traps. + /// The fetch fails against the test host, and that failure decides against gathering, so it has + /// to land before the test arms anything or it turns gathering off underneath it. + /// + /// - Parameter requiresConsent: start with consent required and nothing granted, so the consent gates are under test + private func startSDK(requiresConsent: Bool = false) { + let config = createBaseConfig() + config.requiresConsent = requiresConsent + config.manualSessionHandling = true + Countly.sharedInstance().start(with: config) + TestUtils.sleep(1) {} + } + + /// The user data the consent tests record, every piece of which a log line quotes verbatim. + private let userData = ["purchase", "secret-sku", "checkout screen", "someone@example.com"] + + /// Puts gathering back into the state the SDK boots in, after something decided against it. + private func forceUndecided() { + withGatheringLock { + common.setValue(NSNumber(value: stateUndecided), forKey: "sdkLogsState") + common.setValue(allLevels, forKey: "sdkLogsLevels") + } + clearBuffer() + } + + /// Every queued request carrying an sdk_logs batch, decoded, oldest first. + private func uploadedBatches() -> [[String: Any]] { + guard let rq = TestUtils.getCurrentRQ() else { return [] } + return rq.compactMap { CountlySDKLogsTests.sdkLogsBatch(ofQueryString: $0) } + } + + /// The sdk_logs batch a request query string carries, decoded, or nil when it carries none. + fileprivate static func sdkLogsBatch(ofQueryString queryString: String) -> [String: Any]? { + guard let raw = TestUtils.parseQueryString(queryString)["sdk_logs"] else { return nil } + if let dict = raw as? [String: Any] { + return dict + } + if let string = raw as? String, let data = string.data(using: .utf8) { + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } + return nil + } + + /// Starts the SDK against a mock server that answers every request with success, so the request queue drains, and + /// returns what records the log batches that server receives. + private func startSDKWithAnsweringServer() -> ReceivedLogBatches { + let received = ReceivedLogBatches(harnessPrefix: harnessPrefix) + MockURLProtocol.requestHandler = { request in + let queryString = CountlyServerConfigTests.capturedQueryString(request) + if let batch = CountlySDKLogsTests.sdkLogsBatch(ofQueryString: queryString) { + received.append(batch) + } + let ok = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil) + return ("{\"result\":\"Success\"}".data(using: .utf8), ok, nil) + } + + let config = createBaseConfig() + config.manualSessionHandling = true + config.disableSDKBehaviorSettingsUpdates = true + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [MockURLProtocol.self] + config.urlSessionConfiguration = sessionConfig + Countly.sharedInstance().start(with: config) + return received + } + + /// The most recent queued request carrying an sdk_logs batch, decoded. + private func lastUploadedBatch() -> [String: Any]? { + uploadedBatches().last + } + + /// The messages of every uploaded line, across all batches. + private func uploadedMessages() -> [String] { + uploadedBatches().flatMap { ($0["l"] as? [[String: Any]]) ?? [] }.compactMap { $0["m"] as? String } + } + + /// Lets the delivery queue, which a full batch or an adoption schedules onto, drain. + private func settleDelivery() { + TestUtils.sleep(0.5) {} + } + + private var serverConfig: CountlyServerConfig { CountlyServerConfig.sharedInstance() } + + // MARK: - the state the SDK starts in + + func testLogGathering_startsUndecidedAndCapturesSpeculatively() throws { + XCTAssertEqual(stateUndecided, state, "the SDK has to gather before a directive exists, or init logs are unreachable") + + capture(5) + + XCTAssertEqual(5, harnessLines.count) + } + + func testLogGathering_capturesWhileConsoleLoggingIsOff() throws { + common.enableDebug = false + common.loggerDelegate = nil + clearBuffer() + + capture(3) + + XCTAssertEqual(3, harnessLines.count, "capture sits above the console logging gates") + } + + func testLogGathering_capturesEveryLevelWhileUndecided() throws { + for level in allLevels { + capture(1, level: level) + } + + // which levels the operator wants arrives with the directive, so nothing can be filtered yet + XCTAssertEqual(allLevels.count, harnessLines.count) + } + + /// What the app logs between halt and the next start, after a logout for example, belongs to no user the next start + /// brings, so capture pauses at halt and resumes with the first line of the next start. + func testLogGathering_haltPausesCaptureUntilTheNextStart() throws { + startSDK() + Countly.sharedInstance().halt(true) + XCTAssertEqual(stateOff, state, "halt pauses capture") + + Countly.sharedInstance().recordEvent("logged_out_event", segmentation: ["card": "4242"]) + capture(2) + XCTAssertEqual(0, bufferedLines.count + awaitingConsentLines.count, "nothing is held for the next user") + XCTAssertFalse(CountlyInternalLogIsWanted(CLYInternalLogLevelVerbose), "and nothing is formatted for capture") + + // the settings answer is held, so its failure can not decide against gathering before the checks below + let releaseSettings = DispatchSemaphore(value: 0) + MockURLProtocol.requestHandler = { request in + if request.url?.absoluteString.contains("method=sc") == true { + _ = releaseSettings.wait(timeout: .now() + 10) + } + let failed = HTTPURLResponse(url: request.url!, statusCode: 400, httpVersion: nil, headerFields: nil) + return ("{\"result\":\"fail\"}".data(using: .utf8), failed, nil) + } + defer { + releaseSettings.signal() + MockURLProtocol.replyEmptyToEveryRequest() + } + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [MockURLProtocol.self] + let config = createBaseConfig() + config.manualSessionHandling = true + config.urlSessionConfiguration = sessionConfig + Countly.sharedInstance().start(with: config) + + let messages = bufferedLines.compactMap { $0["m"] as? String } + XCTAssertEqual(stateUndecided, state, "the next start captures again") + XCTAssertTrue(messages.contains { $0.contains("startWithConfig") }, "from its own first lines") + XCTAssertFalse(messages.contains { $0.contains("logged_out_event") }, "but nothing the app logged while halted") + } + + // MARK: - adoption and discard + + func testLogGathering_adoptsSpeculativeLinesWhenTurnedOn() throws { + capture(7) + + common.updateLogGatheringState(true, levels: allLevels, batch: defaultBatchSize, lgid: "gather_1") + + XCTAssertEqual(stateGathering, state) + XCTAssertEqual(7, harnessLines.count, "the init lines are the reason speculative capture exists") + } + + func testLogGathering_dropsSpeculativeLinesWhenNotGathering() throws { + capture(7) + + common.updateLogGatheringState(false, levels: "", batch: 0, lgid: "") + + XCTAssertEqual(stateOff, state) + XCTAssertEqual(0, bufferedLines.count) + XCTAssertEqual(0, dropCount, "lines nobody asked for are not a reportable loss") + } + + func testLogGathering_filtersAdoptedLinesToTheRequestedLevels() throws { + capture(2, level: "e") + capture(3, level: "d") + capture(1, level: "v") + + common.updateLogGatheringState(true, levels: "e", batch: defaultBatchSize, lgid: "gather_2") + + let kept = harnessLines + XCTAssertEqual(2, kept.count) + XCTAssertTrue(kept.allSatisfy { ($0["l"] as? String) == "e" }) + XCTAssertEqual(0, dropCount, "a level filter is not a buffer overflow") + } + + func testLogGathering_discardsLinesBelongingToAPreviousGather() throws { + common.updateLogGatheringState(true, levels: allLevels, batch: defaultBatchSize, lgid: "gather_old") + clearBuffer() + capture(4) + XCTAssertEqual(4, harnessLines.count) + + common.updateLogGatheringState(true, levels: allLevels, batch: defaultBatchSize, lgid: "gather_new") + + XCTAssertEqual(0, harnessLines.count, "the server rejects a batch attributed to a gather that ended") + } + + func testLogGathering_keepsLinesWhenTheSameGatherIsConfirmedAgain() throws { + common.updateLogGatheringState(true, levels: allLevels, batch: defaultBatchSize, lgid: "gather_same") + clearBuffer() + capture(4) + + common.updateLogGatheringState(true, levels: allLevels, batch: defaultBatchSize, lgid: "gather_same") + + XCTAssertEqual(4, harnessLines.count, "every config fetch repeats the directive, that must not lose lines") + } + + func testLogGathering_stopsCapturingOnceTurnedOff() throws { + common.updateLogGatheringState(false, levels: "", batch: 0, lgid: "") + + capture(5) + + XCTAssertEqual(0, harnessLines.count) + } + + // MARK: - directive sanitising + + func testLogGathering_enabledWithoutAGatherIdDoesNotGather() throws { + common.updateLogGatheringState(true, levels: allLevels, batch: defaultBatchSize, lgid: "") + + XCTAssertEqual(stateOff, state, "a batch with no gather id can not be attributed and the server rejects it") + } + + func testLogGathering_unusableLevelStringFallsBackToEveryLevel() throws { + common.updateLogGatheringState(true, levels: "!!zz", batch: defaultBatchSize, lgid: "gather_3") + + XCTAssertEqual(allLevels, levels, "an armed gather that captures nothing looks like a broken feature") + } + + func testLogGathering_unknownLevelCharactersAreStripped() throws { + common.updateLogGatheringState(true, levels: "e!w", batch: defaultBatchSize, lgid: "gather_4") + + XCTAssertEqual("ew", levels) + } + + func testLogGathering_nilLevelStringFallsBackToEveryLevel() throws { + // a directive that omits 'l' arrives here as nil, and strchr on nil would crash + common.updateLogGatheringState(true, levels: nil, batch: defaultBatchSize, lgid: "gather_5") + + XCTAssertEqual(allLevels, levels) + capture(1) + XCTAssertEqual(1, harnessLines.count) + } + + func testLogGathering_batchSizeIsClampedIntoRange() throws { + common.updateLogGatheringState(true, levels: allLevels, batch: 1, lgid: "gather_6") + XCTAssertEqual(minBatchSize, batchSize, "a batch of one would upload a request per log line") + + common.updateLogGatheringState(true, levels: allLevels, batch: 100_000, lgid: "gather_6") + XCTAssertEqual(maxBufferedLines, batchSize, "a batch bigger than the buffer would never fill") + + common.updateLogGatheringState(true, levels: allLevels, batch: 0, lgid: "gather_6") + XCTAssertEqual(defaultBatchSize, batchSize) + + common.updateLogGatheringState(true, levels: allLevels, batch: -5, lgid: "gather_6") + XCTAssertEqual(defaultBatchSize, batchSize) + } + + // MARK: - the buffer ceiling + + func testLogGathering_ceilingDropsTheOldestLinesAndCountsThemOnceAGatherAdoptsThem() throws { + capture(maxBufferedLines + 25, level: "e") + + XCTAssertEqual(maxBufferedLines, bufferedLines.count) + XCTAssertEqual(0, dropCount, "a loss while undecided belongs to no gather yet") + + // the oldest went first, so the newest line has to still be there + let last = harnessLines.last?["m"] as? String + XCTAssertEqual("harness line \(maxBufferedLines + 24)", last) + + common.updateLogGatheringState(true, levels: "e", batch: maxBufferedLines, lgid: "gather_ceiling") + + // the oldest lines are this test's own, and any line the SDK logged meanwhile pushed one more of them out + XCTAssertGreaterThanOrEqual(dropCount, 25) + XCTAssertEqual(maxBufferedLines + 25 - harnessLines.count, dropCount, "the loss is reported as 'd' instead of disappearing silently") + } + + /// Lines dropped while undecided count into 'd' only for the levels the adopting gather asks for, and not at all when + /// gathering is turned off instead. + func testLogGathering_undecidedDropsCountOnlyForTheAdoptedLevels() throws { + capture(maxBufferedLines, level: "v") + capture(100, level: "e") + common.updateLogGatheringState(true, levels: "e", batch: maxBufferedLines, lgid: "gather_errors_only") + XCTAssertEqual(0, dropCount, "the dropped lines were verbose, which this gather never asked for") + + forceUndecided() + capture(maxBufferedLines, level: "e") + capture(100, level: "v") + common.updateLogGatheringState(true, levels: "ew", batch: maxBufferedLines, lgid: "gather_errors_and_warnings") + XCTAssertEqual(100, dropCount, "the dropped lines were errors, which this gather asked for") + + forceUndecided() + capture(maxBufferedLines + 30, level: "e") + common.decideLogGatheringOffIfUndecided("fetch failed") + common.setValue(NSNumber(value: stateUndecided), forKey: "sdkLogsState") + capture(1, level: "e") + common.updateLogGatheringState(true, levels: allLevels, batch: maxBufferedLines, lgid: "gather_after_off") + XCTAssertEqual(0, dropCount, "turning gathering off forgets what was dropped before it") + } + + func testLogGathering_longMessagesAreTruncated() throws { + capture(1, message: "xxx" + String(repeating: "x", count: maxMessageLength + 500)) + + let stored = bufferedLines.compactMap { $0["m"] as? String }.first { $0.hasPrefix("xxx") } + XCTAssertEqual(maxMessageLength, stored?.count, "a longer line is cut, not dropped") + } + + func testLogGathering_lineCarriesMillisecondTimestampAndSingleCharLevel() throws { + capture(1, level: "e") + + let line = try XCTUnwrap(harnessLines.first) + XCTAssertEqual("e", line["l"] as? String, "a boxed char would go on the wire as 101") + let timestamp = try XCTUnwrap((line["t"] as? NSNumber)?.int64Value) + XCTAssertGreaterThan(timestamp, 1_600_000_000_000, "milliseconds since epoch, not seconds") + } + + // MARK: - upload + + func testLogGathering_flushDoesNothingWhileUndecided() throws { + startSDK() + forceUndecided() + capture(defaultBatchSize * 2) + let before = TestUtils.getCurrentRQ()?.count ?? 0 + + common.flushSdkLogs() + + XCTAssertEqual(before, TestUtils.getCurrentRQ()?.count ?? 0, "there is nothing to attribute a batch to yet") + XCTAssertEqual(defaultBatchSize * 2, harnessLines.count, "and the lines stay held") + } + + func testLogGathering_flushDoesNothingWhenNotGathering() throws { + startSDK() + common.updateLogGatheringState(false, levels: "", batch: 0, lgid: "") + let before = TestUtils.getCurrentRQ()?.count ?? 0 + + common.flushSdkLogs() + + XCTAssertEqual(before, TestUtils.getCurrentRQ()?.count ?? 0) + } + + func testLogGathering_uploadedBatchCarriesTheExpectedShape() throws { + startSDK() + + common.updateLogGatheringState(true, levels: "e", batch: minBatchSize, lgid: "gather_upload") + clearBuffer() + capture(minBatchSize - 1, level: "e") + + common.flushSdkLogs() + + let batch = try XCTUnwrap(lastUploadedBatch(), "the batch never reached the request queue") + XCTAssertEqual("gather_upload", batch["i"] as? String) + XCTAssertEqual(0, (batch["d"] as? NSNumber)?.intValue) + let lines = try XCTUnwrap(batch["l"] as? [[String: Any]]) + XCTAssertEqual(minBatchSize - 1, lines.count, "a flush takes the partial batch that is there") + XCTAssertTrue(lines.allSatisfy { ($0["l"] as? String) == "e" }) + XCTAssertTrue(lines.allSatisfy { ($0["m"] as? String)?.isEmpty == false }) + } + + func testLogGathering_uploadReportsAndResetsTheDropCount() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + let server = startSDKWithAnsweringServer() + defer { MockURLProtocol.replyEmptyToEveryRequest() } + + forceUndecided() + // while gathering is on every captured line drains a batch, so the buffer only ever + // overflows before a directive arrives. Overflow first, then let a gather adopt the rest. + capture(maxBufferedLines + 12, level: "e") + XCTAssertEqual(0, dropCount, "a loss while undecided belongs to no gather yet") + + common.updateLogGatheringState(true, levels: "e", batch: minBatchSize, lgid: "gather_dropped") + + // the SDK's own lines, logged meanwhile, can push out a few more of the oldest test lines, so the check is that + // every captured line either reached the server or was reported as dropped + let capturedCount = maxBufferedLines + 12 + let gatherBatches: () -> [[String: Any]] = { server.batches.filter { ($0["i"] as? String) == "gather_dropped" } } + let accountedCount: () -> Int = { + server.harnessMessages.count + gatherBatches().reduce(0) { $0 + (($1["d"] as? NSNumber)?.intValue ?? 0) } + } + + // one batch waits in the queue at a time, so the backlog reaches the server batch by batch, and the remainder + // smaller than a batch waits for the timer, played here whenever the queue is empty + TestUtils.waitUntil("every held line to reach the server", timeout: 30) { + if accountedCount() < capturedCount && !CountlyPersistency.sharedInstance().queueContainsSdkLogs() { + self.common.scheduleSdkLogsFlush() + } + return accountedCount() >= capturedCount + } + + let dropCounts = gatherBatches().map { ($0["d"] as? NSNumber)?.intValue ?? -1 } + XCTAssertGreaterThanOrEqual(dropCounts.first ?? 0, 12, "adopting the lines adopts their loss, reported with the first batch") + XCTAssertEqual(1, dropCounts.filter { $0 > 0 }.count, "and only once: \(dropCounts)") + XCTAssertEqual(capturedCount, accountedCount()) + let delivered = server.harnessMessages + XCTAssertEqual(((capturedCount - delivered.count)..= self.minBatchSize * 3 } + + let batches = server.batches.filter { ($0["i"] as? String) == "gather_backlog" } + XCTAssertEqual(3, batches.count, "one batch per round trip of the queue") + XCTAssertTrue(batches.allSatisfy { (($0["l"] as? [Any])?.count ?? 0) <= minBatchSize }, "no batch is larger than the batch size") + XCTAssertEqual((0.. Bool = { lines in lines.allSatisfy { line in self.bufferedLines.contains { $0["m"] as? String == line } } } + let isWaiting: ([String]) -> Bool = { lines in lines.allSatisfy { line in self.awaitingConsentLines.contains { $0["m"] as? String == line } } } + + XCTAssertTrue(isHeld(viewLines), "the views consent covers view tracking lines in every opening form, the swizzled callbacks included") + XCTAssertTrue(isWaiting(eventLines + locationLines + userLines + crashLines + pushLines + unownedLines)) + + Countly.sharedInstance().giveConsent(forFeatures: [CLYConsent.events]) + XCTAssertTrue(isHeld(eventLines), "Countly's own methods are attributed by selector") + XCTAssertTrue(isWaiting(locationLines + userLines + crashLines + pushLines + unownedLines), "a line no feature owns needs the user details consent too") + + Countly.sharedInstance().giveConsent(forFeatures: [CLYConsent.userDetails]) + XCTAssertTrue(isHeld(userLines + unownedLines)) + XCTAssertTrue(isWaiting(locationLines + crashLines + pushLines), "the location, crash and push lines still wait for their own consent") + + Countly.sharedInstance().giveConsent(forFeatures: [CLYConsent.crashReporting, CLYConsent.pushNotifications]) + XCTAssertTrue(isHeld(crashLines + pushLines), "the crash handler's lines answer to the crashes consent, the swizzled push callback's to the push consent") + XCTAssertTrue(isWaiting(locationLines)) + } + + /// While a gather asks for some levels only, log calls of the levels it leaves out are not even formatted. + func testLogGathering_levelsAGatherLeavesOutAreNotWanted() throws { + XCTAssertTrue(CountlyInternalLogIsWanted(CLYInternalLogLevelVerbose), "every level is wanted while undecided") + + common.updateLogGatheringState(true, levels: "e", batch: defaultBatchSize, lgid: "gather_errors") + XCTAssertTrue(CountlyInternalLogIsWanted(CLYInternalLogLevelError)) + XCTAssertFalse(CountlyInternalLogIsWanted(CLYInternalLogLevelDebug), "a debug line would be formatted only to be dropped") + + common.updateLogGatheringState(false, levels: "", batch: 0, lgid: "") + XCTAssertFalse(CountlyInternalLogIsWanted(CLYInternalLogLevelError), "with the console off nothing wants a line once gathering is off") + } + + /// A line captured while init runs is decided when init finishes, and goes out without the consent it was checked against. + func testLogGathering_initLinesAreDecidedWhenInitFinishesAndUploadAsPlainLines() throws { + common.updateLogGatheringState(true, levels: allLevels, batch: maxBufferedLines, lgid: "gather_init") + startSDK() + + let uploadedLines = uploadedBatches().flatMap { ($0["l"] as? [[String: Any]]) ?? [] } + XCTAssertFalse(uploadedLines.isEmpty, "init finishing flushes the init lines") + XCTAssertTrue(uploadedLines.allSatisfy { Set($0.keys) == ["t", "l", "m"] }, "the consent a line was checked against never reaches a batch") + } + + /// With consent required and nothing given, the lines captured while init runs wait for their consent instead of uploading. + func testLogGathering_initLinesWaitForTheirConsentWhenInitFinishes() throws { + common.updateLogGatheringState(true, levels: allLevels, batch: maxBufferedLines, lgid: "gather_init") + startSDK(requiresConsent: true) + + XCTAssertEqual(0, uploadedBatches().count) + XCTAssertFalse(awaitingConsentLines.isEmpty) + } + + /// A device ID change without merge uploads the held lines under the old device ID, then stops gathering for the new user. + func testLogGathering_deviceIDChangeWithoutMerge_uploadsUnderTheOldIDAndStops() throws { + startSDK() + let oldDeviceID = Countly.sharedInstance().deviceID() + + common.updateLogGatheringState(true, levels: "e", batch: minBatchSize, lgid: "gather_old_user") + clearBuffer() + capture(3, level: "e") + + Countly.sharedInstance().changeDeviceIDWithoutMerge("new_user") + settleDelivery() + + let logRequests = (TestUtils.getCurrentRQ() ?? []).map { TestUtils.parseQueryString($0) }.filter { $0["sdk_logs"] != nil } + XCTAssertFalse(logRequests.isEmpty, "the held partial batch goes out before the switch") + XCTAssertTrue(logRequests.allSatisfy { ($0["device_id"] as? String) == oldDeviceID }, "every gathered line belongs to the old user") + XCTAssertEqual(stateOff, state) + XCTAssertEqual(0, bufferedLines.count) + + let uploadedBeforeNewUser = uploadedBatches().count + capture(minBatchSize, level: "e") + common.flushSdkLogs() + settleDelivery() + + XCTAssertEqual(uploadedBeforeNewUser, uploadedBatches().count, "the new user is not part of the old user's gather") + } + + /// The last batch of the previous user's gather does not carry the next user's device ID, so one user's logs never name + /// the user after them. + func testLogGathering_deviceIDChangeWithoutMerge_lastBatchDoesNotCarryTheNewDeviceID() throws { + startSDK() + let newDeviceID = "next_user_identifier" + + common.updateLogGatheringState(true, levels: allLevels, batch: maxBufferedLines, lgid: "gather_old_user") + clearBuffer() + capture(3, level: "e") + + Countly.sharedInstance().changeDeviceIDWithoutMerge(newDeviceID) + settleDelivery() + + XCTAssertFalse(uploadedBatches().isEmpty, "the held lines go out before the switch") + XCTAssertFalse(uploadedMessages().contains { $0.contains(newDeviceID) }, "the old user's lines never name the next user") + } + + /// A config response requested for the previous device ID can not arm gathering again once the device ID changed without merge. + func testLogGathering_deviceIDChangeWithoutMerge_ignoresTheDirectiveRequestedForTheOldID() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + let answerAfterTheChange = DispatchSemaphore(value: 0) + MockURLProtocol.requestHandler = { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + guard request.url?.absoluteString.contains("method=sc") == true else { + return ("{}".data(using: .utf8), response, nil) + } + _ = answerAfterTheChange.wait(timeout: .now() + 5) + let armed = "{\"v\":1,\"t\":1234,\"c\":{\"rqs\":1500},\"lg\":{\"e\":true,\"i\":\"gather_old_user\",\"l\":\"ewidv\",\"b\":100}}" + return (armed.data(using: .utf8), response, nil) + } + defer { MockURLProtocol.requestHandler = nil } + + let config = createBaseConfig() + config.manualSessionHandling = true + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [MockURLProtocol.self] + config.urlSessionConfiguration = sessionConfig + Countly.sharedInstance().start(with: config) + + Countly.sharedInstance().changeDeviceIDWithoutMerge("new_user") + answerAfterTheChange.signal() + TestUtils.sleep(1) {} + + XCTAssertEqual(stateOff, state, "the directive belongs to the previous user") + XCTAssertEqual(1500, (serverConfig.value(forKey: "requestQueueSize") as? NSNumber)?.intValue, "the rest of the answer still applies") + } + + /// Lines held back by consent are dropped on a device ID change without merge, so the next user's consent never releases them. + func testLogGathering_deviceIDChangeWithoutMerge_dropsLinesHeldByConsent() throws { + startSDK(requiresConsent: true) + Countly.sharedInstance().giveConsent(forFeatures: [CLYConsent.sessions]) + + common.updateLogGatheringState(true, levels: "e", batch: minBatchSize, lgid: "gather_old_user") + clearBuffer() + capture(minBatchSize, level: "e") + settleDelivery() + XCTAssertEqual(0, uploadedBatches().count) + + Countly.sharedInstance().changeDeviceIDWithoutMerge("new_user") + Countly.sharedInstance().giveConsent(forFeatures: [CLYConsent.events, CLYConsent.userDetails]) + common.flushSdkLogs() + settleDelivery() + + XCTAssertEqual(0, uploadedBatches().count, "the old user's lines never go out under the new user's consent") + XCTAssertEqual(stateOff, state) + } + + func testLogGathering_withoutConsentNoUserDataIsUploadedAndLocalLoggingIsUnchanged() throws { + startSDK(requiresConsent: true) + Countly.sharedInstance().giveConsent(forFeatures: [CLYConsent.sessions]) + + let logger = RecordingLogger() + common.loggerDelegate = logger + defer { common.loggerDelegate = nil } + + common.updateLogGatheringState(true, levels: allLevels, batch: minBatchSize, lgid: "gather_no_data") + clearBuffer() + + Countly.sharedInstance().recordEvent("purchase", segmentation: ["sku": "secret-sku"]) + _ = Countly.sharedInstance().views().startView("checkout screen") + Countly.user().set("email", value: "someone@example.com") + common.flushSdkLogs() + settleDelivery() + + let uploaded = uploadedMessages() + let awaiting = awaitingConsentLines.compactMap { $0["m"] as? String } + for secret in userData { + XCTAssertFalse(uploaded.contains { $0.contains(secret) }, "[\(secret)] reached the server without the consent it belongs to") + XCTAssertTrue(awaiting.contains { $0.contains(secret) }, "[\(secret)] waits for its consent instead of being dropped") + } + + // the gated copy is the uploaded one: a developer watching the console still sees every call + for secret in userData { + XCTAssertTrue(logger.lines.contains { $0.contains(secret) }, "local logging lost [\(secret)]") + } + } + + // MARK: - consent, merge, delivery and filters + + /// A merge keeps the user, so the log gathering directive of a fetch made before it still applies. + func testLogGathering_deviceIDChangeWithMerge_keepsTheDirectiveRequestedBeforeIt() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + let answerAfterTheChange = DispatchSemaphore(value: 0) + MockURLProtocol.requestHandler = { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + guard request.url?.absoluteString.contains("method=sc") == true else { + return ("{}".data(using: .utf8), response, nil) + } + _ = answerAfterTheChange.wait(timeout: .now() + 5) + let armed = "{\"v\":1,\"t\":1234,\"c\":{\"rqs\":1500},\"lg\":{\"e\":true,\"i\":\"gather_merged_user\",\"l\":\"ewidv\",\"b\":100}}" + return (armed.data(using: .utf8), response, nil) + } + defer { MockURLProtocol.replyEmptyToEveryRequest() } + + let config = createBaseConfig() + config.manualSessionHandling = true + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [MockURLProtocol.self] + config.urlSessionConfiguration = sessionConfig + Countly.sharedInstance().start(with: config) + + Countly.sharedInstance().changeDeviceID(withMerge: "merged_user") + answerAfterTheChange.signal() + TestUtils.waitUntil("the directive to be applied") { self.state == self.stateGathering } + + XCTAssertEqual(stateGathering, state, "a merge keeps the user, so the directive still applies") + XCTAssertEqual("gather_merged_user", common.value(forKey: "sdkLogsGatheringId") as? String) + } + + /// A consent withdrawn after its lines were gathered sends them back to wait, they are not uploaded until it is given again. + func testLogGathering_withdrawnConsentSendsBufferedLinesBackToWait() throws { + startSDK(requiresConsent: true) + // no user details consent, so lines that quote whole requests stay held and only the two features are under test + Countly.sharedInstance().giveConsent(forFeatures: [CLYConsent.events, CLYConsent.location]) + common.updateLogGatheringState(true, levels: allLevels, batch: maxBufferedLines, lgid: "gather_withdrawal") + clearBuffer() + + Countly.sharedInstance().recordLocation(CLLocationCoordinate2D(latitude: 41.0082, longitude: 28.9784), city: "Istanbul", isoCountryCode: "TR", ip: nil) + Countly.sharedInstance().recordEvent("purchase") + XCTAssertTrue(bufferedLines.contains { ($0["m"] as? String)?.contains("Istanbul") == true }, "with location consent the line is gathered") + + Countly.sharedInstance().cancelConsent(forFeature: CLYConsent.location) + XCTAssertFalse(bufferedLines.contains { ($0["m"] as? String)?.contains("Istanbul") == true }) + XCTAssertTrue(awaitingConsentLines.contains { ($0["m"] as? String)?.contains("Istanbul") == true }, "withdrawing location consent sends its line back to wait") + + common.flushSdkLogs() + settleDelivery() + XCTAssertFalse(uploadedMessages().contains { $0.contains("Istanbul") }, "a withdrawn consent holds its lines back again") + XCTAssertTrue(uploadedMessages().contains { $0.contains("purchase") }, "lines of other features still go out") + + Countly.sharedInstance().giveConsent(forFeature: CLYConsent.location) + common.flushSdkLogs() + settleDelivery() + XCTAssertTrue(uploadedMessages().contains { $0.contains("Istanbul") }, "giving the consent back releases the line") + XCTAssertFalse(uploadedBatches().contains { batch in + ((batch["l"] as? [[String: Any]]) ?? []).contains { $0["consent"] != nil } + }, "the consent a line needs never goes on the wire") + } + + /// Long lines reach the character ceiling long before a large batch fills, so the buffer goes out as a partial batch instead of dropping lines. + func testLogGathering_longLinesAreDeliveredBeforeALargeBatchFills() throws { + startSDK() + common.updateLogGatheringState(true, levels: "d", batch: maxBufferedLines, lgid: "gather_long_lines") + clearBuffer() + + capture(30, level: "d", message: harnessPrefix + " " + String(repeating: "x", count: 4000)) + settleDelivery() + + let batch = try XCTUnwrap(lastUploadedBatch(), "a buffer close to its character ceiling goes out without waiting for the batch size") + let lineCount = (batch["l"] as? [Any])?.count ?? 0 + XCTAssertGreaterThan(lineCount, 0) + XCTAssertLessThan(lineCount, maxBufferedLines) + XCTAssertEqual(0, (batch["d"] as? NSNumber)?.intValue, "nothing was dropped on the way") + } + + /// A full queue drops gathered log uploads before anything else, refuses new ones, and counts their lines as dropped. + func testLogGathering_fullRequestQueueDropsLogUploadsFirstAndCountsTheirLines() throws { + startSDK() + let persistency = try XCTUnwrap(CountlyPersistency.sharedInstance()) + (persistency.value(forKey: "queuedRequests") as? NSMutableArray)?.removeAllObjects() + persistency.storedRequestsLimit = 4 + + common.updateLogGatheringState(true, levels: "d", batch: minBatchSize, lgid: "gather_full_queue") + clearBuffer() + capture(minBatchSize) + settleDelivery() + capture(minBatchSize) + settleDelivery() + let queuedLogUploads = uploadedBatches() + XCTAssertEqual(1, queuedLogUploads.count, "one log upload waits in the queue at a time") + XCTAssertEqual(minBatchSize, harnessLines.count, "the next batch is held until the waiting one is sent") + let queuedLogLines = queuedLogUploads.reduce(0) { total, batch in + total + ((batch["l"] as? [Any])?.count ?? 0) + ((batch["d"] as? NSNumber)?.intValue ?? 0) + } + + for index in 1...4 { + Countly.sharedInstance().addDirectRequest(["analytics": "request_\(index)"]) + } + XCTAssertEqual(4, TestUtils.getCurrentRQ()?.filter { $0.contains("analytics=request_") }.count, "every other request is kept") + XCTAssertEqual(0, uploadedBatches().count, "log uploads are dropped before any other request") + // a delivery the SDK's own lines start holds the count in its batch until the full queue refuses the batch + TestUtils.waitUntil("the dropped lines to be counted") { self.dropCount >= queuedLogLines } + XCTAssertGreaterThanOrEqual(dropCount, queuedLogLines, "the dropped uploads' lines are reported as dropped") + + capture(minBatchSize) + common.flushSdkLogs() + XCTAssertEqual(4, TestUtils.getCurrentRQ()?.filter { $0.contains("analytics=request_") }.count, "a new log upload does not push a request out of a full queue") + XCTAssertEqual(0, uploadedBatches().count) + XCTAssertGreaterThanOrEqual(harnessLines.count, minBatchSize, "the refused lines are held again") + } + + /// Values the behavior settings filter out are never quoted in a gathered line, values they let through still are. + func testLogGathering_valuesFilteredOutByBehaviorSettingsAreNotQuoted() throws { + UserDefaults.standard.set(ServerConfigBuilder() + .segmentationBlacklist(["secret_key"]) + .locationTracking(false) + .userPropertyBlacklist(["hidden_property"]) + .buildJson(), forKey: "kCountlyServerConfigPersistencyKey") + startSDK() + common.updateLogGatheringState(true, levels: allLevels, batch: maxBufferedLines, lgid: "gather_filtered_values") + clearBuffer() + + Countly.sharedInstance().recordEvent("purchase", segmentation: ["secret_key": "S3CR3T-VALUE", "visible_key": "VISIBLE-VALUE"]) + Countly.sharedInstance().recordLocation(CLLocationCoordinate2D(latitude: 12.3456, longitude: 65.4321), city: "Hiddenville", isoCountryCode: "HV", ip: "10.1.2.3") + Countly.sharedInstance().userProfile().setProperty("hidden_property", value: "HIDDEN-VALUE") + Countly.sharedInstance().userProfile().setProperty("shown_property", value: "SHOWN-VALUE") + // by selector, so the property the server filters out gets a value of a type the SDK rejects + _ = Countly.sharedInstance().userProfile().perform(NSSelectorFromString("multiply:value:"), with: "hidden_property", with: NSDate(timeIntervalSince1970: 0)) + + let captured = (bufferedLines + awaitingConsentLines).compactMap { $0["m"] as? String } + for value in ["S3CR3T-VALUE", "12.3456", "Hiddenville", "10.1.2.3", "HIDDEN-VALUE", "1970-01-01"] { + XCTAssertFalse(captured.contains { $0.contains(value) }, "[\(value)] is filtered out by the server, so no gathered line may quote it") + } + for value in ["VISIBLE-VALUE", "SHOWN-VALUE"] { + XCTAssertTrue(captured.contains { $0.contains(value) }, "[\(value)] passes the filters, so it is still logged") + } + } + + /// The values of an event the behavior settings drop, because custom event tracking is off, are never quoted in a gathered line. + func testLogGathering_eventsDroppedByBehaviorSettingsAreNotQuoted() throws { + UserDefaults.standard.set(ServerConfigBuilder().customEventTracking(false).buildJson(), forKey: "kCountlyServerConfigPersistencyKey") + startSDK() + common.updateLogGatheringState(true, levels: allLevels, batch: maxBufferedLines, lgid: "gather_dropped_events") + clearBuffer() + + Countly.sharedInstance().recordEvent("purchase", segmentation: ["sku": "DROPPED-EVENT-VALUE"]) + + let captured = (bufferedLines + awaitingConsentLines).compactMap { $0["m"] as? String } + XCTAssertTrue(captured.contains { $0.contains("purchase") }, "the call itself is still logged") + XCTAssertFalse(captured.contains { $0.contains("DROPPED-EVENT-VALUE") }, "the values of a dropped event are never quoted") + } + + /// Custom request header values, often credentials, are never quoted in a gathered line, their names still are. + func testLogGathering_customHeaderValuesAreNotQuoted() throws { + let config = createBaseConfig() + config.manualSessionHandling = true + // headers are only taken when a URL session configuration was given at start + config.urlSessionConfiguration = URLSessionConfiguration.ephemeral + Countly.sharedInstance().start(with: config) + TestUtils.sleep(1) {} + common.updateLogGatheringState(true, levels: allLevels, batch: maxBufferedLines, lgid: "gather_custom_headers") + clearBuffer() + + Countly.sharedInstance().addCustomNetworkRequestHeaders(["Authorization": "Bearer S3CR3T-TOKEN", "X-Tenant": "TENANT-VALUE"]) + + let captured = (bufferedLines + awaitingConsentLines).compactMap { $0["m"] as? String } + XCTAssertTrue(captured.contains { $0.contains("Authorization") && $0.contains("X-Tenant") }, "the header names are still logged") + for value in ["S3CR3T-TOKEN", "TENANT-VALUE"] { + XCTAssertFalse(captured.contains { $0.contains(value) }, "[\(value)] is a header value, so no gathered line may quote it") + } + XCTAssertEqual("Bearer S3CR3T-TOKEN", CountlyConnectionManager.sharedInstance().urlSessionConfiguration?.httpAdditionalHeaders?["Authorization"] as? String, "the header itself is still applied") + } + + /// A segmentation value or a user property the behavior settings filter out never reaches the server, so no gathered + /// line quotes it either: not the line of a timed event's end, and not the line of the user properties given at start. + func testLogGathering_valuesTheBehaviorSettingsFilterOutAreNotQuoted() throws { + let config = createBaseConfig() + config.manualSessionHandling = true + config.disableSDKBehaviorSettingsUpdates = true + config.sdkBehaviorSettings = ServerConfigBuilder().segmentationBlacklist(["secret_key"]).userPropertyBlacklist(["secret_property"]).build() + config.providedUserProperties = ["secret_property": "PROPERTY-VALUE", "kept_property": "KEPT-PROPERTY"] + // armed before start, since start decides against gathering while undecided once updates are disabled + common.updateLogGatheringState(true, levels: allLevels, batch: maxBufferedLines, lgid: "gather_filtered_values") + Countly.sharedInstance().start(with: config) + + Countly.sharedInstance().startEvent("timed_event") + Countly.sharedInstance().endEvent("timed_event", segmentation: ["secret_key": "SEGMENT-VALUE", "kept_key": "KEPT-SEGMENT"], count: 1, sum: 0) + + // the init lines already went out with the batch init end sends, the later ones are still held + settleDelivery() + let captured = (bufferedLines + awaitingConsentLines).compactMap { $0["m"] as? String } + uploadedMessages() + XCTAssertTrue(captured.contains { $0.contains("KEPT-SEGMENT") }, "a value the settings let through is still logged") + XCTAssertTrue(captured.contains { $0.contains("KEPT-PROPERTY") }, "and so is a user property they let through") + XCTAssertFalse(captured.contains { $0.contains("SEGMENT-VALUE") }, "a segmentation value the settings filter out is not quoted") + XCTAssertFalse(captured.contains { $0.contains("PROPERTY-VALUE") }, "and neither is a filtered user property") + } + +#if os(iOS) + /// The web view that shows content and feedback widgets logs the names of the response headers, never their values, + /// since a response header can carry a session cookie. + func testLogGathering_webViewResponseHeaderValuesAreNotQuoted() throws { + startSDK() + common.updateLogGatheringState(true, levels: allLevels, batch: maxBufferedLines, lgid: "gather_web_view_headers") + clearBuffer() + + let url = try XCTUnwrap(URL(string: "https://content.example/page")) + let response = try XCTUnwrap(HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: ["Set-Cookie": "session=C00KIE-VALUE", "X-Trace": "TRACE-VALUE"])) + // never released: WKNavigationResponse frees WebKit state in dealloc that a response WebKit did not create lacks + let navigationResponse = Unmanaged.passRetained(NavigationResponseStub(response: response)).takeUnretainedValue() + var policy: WKNavigationResponsePolicy? + let navigationDelegate: WKNavigationDelegate = CountlyWebViewManager() + navigationDelegate.webView?(WKWebView(), decidePolicyFor: navigationResponse) { policy = $0 } + + XCTAssertEqual(.allow, policy, "a successful page is shown") + let captured = (bufferedLines + awaitingConsentLines).compactMap { $0["m"] as? String } + XCTAssertTrue(captured.contains { $0.contains("Set-Cookie") && $0.contains("X-Trace") }, "the header names are still logged") + for value in ["C00KIE-VALUE", "TRACE-VALUE"] { + XCTAssertFalse(captured.contains { $0.contains(value) }, "[\(value)] is a header value, so no gathered line may quote it") + } + } +#endif + + /// A delivery checks the consent of every line it takes, so a line whose consent was just withdrawn stays waiting. + func testLogGathering_deliveryChecksTheConsentOfEveryLineItTakes() throws { + startSDK(requiresConsent: true) + Countly.sharedInstance().giveConsent(forFeatures: [CLYConsent.events, CLYConsent.location]) + common.updateLogGatheringState(true, levels: allLevels, batch: maxBufferedLines, lgid: "gather_consent_at_upload") + clearBuffer() + + Countly.sharedInstance().recordLocation(CLLocationCoordinate2D(latitude: 41.0082, longitude: 28.9784), city: "Istanbul", isoCountryCode: "TR", ip: nil) + XCTAssertTrue(bufferedLines.contains { ($0["m"] as? String)?.contains("Istanbul") == true }) + + // flips the flag through its setter alone, the way a withdrawal does before it decides the held lines again + let consentManager = try XCTUnwrap((NSClassFromString("CountlyConsentManager") as AnyObject).value(forKey: "sharedInstance") as? NSObject) + consentManager.setValue(false, forKey: "consentForLocation") + common.flushSdkLogs() + settleDelivery() + + XCTAssertFalse(uploadedMessages().contains { $0.contains("Istanbul") }, "a line whose consent is gone by the upload is not sent") + XCTAssertTrue(awaitingConsentLines.contains { ($0["m"] as? String)?.contains("Istanbul") == true }, "it waits for its consent again") + } + + // MARK: - deciding without a response + + func testLogGathering_noResponseDecidesOffOnlyWhileUndecided() throws { + capture(3) + XCTAssertEqual(stateUndecided, state) + + common.decideLogGatheringOffIfUndecided("fetch failed") + + XCTAssertEqual(stateOff, state, "nothing can adopt the lines this run, holding them is pointless") + XCTAssertEqual(0, bufferedLines.count) + + common.updateLogGatheringState(true, levels: allLevels, batch: defaultBatchSize, lgid: "gather_keep") + clearBuffer() + capture(3) + + common.decideLogGatheringOffIfUndecided("fetch failed") + + XCTAssertEqual(stateGathering, state, "a running gather survives a transient failure of the periodic refetch") + XCTAssertEqual(3, harnessLines.count) + } + + func testLogGathering_temporaryDeviceIdDecidesOff() throws { + let config = createBaseConfig() + config.requiresConsent = false + config.manualSessionHandling = true + config.enableTemporaryDeviceIDMode() + Countly.sharedInstance().start(with: config) + + XCTAssertEqual(stateOff, state, "no behavior settings are fetched in temporary mode, so no directive can arrive") + } + + func testLogGathering_disabledBehaviorSettingsUpdatesDecideOff() throws { + let config = createBaseConfig() + config.requiresConsent = false + config.manualSessionHandling = true + config.disableSDKBehaviorSettingsUpdates = true + Countly.sharedInstance().start(with: config) + + XCTAssertEqual(stateOff, state) + } + + // MARK: - the character ceiling and level sanitising + + func testLogGathering_characterCeilingDropsTheOldestLines() throws { + // short of the message cap, so the counter suffix the helper appends survives + let long = String(repeating: "c", count: maxMessageLength - 100) + capture(40, message: long) + + // 40 x 4000 is past 128 KB, so the oldest went, and they count once a gather adopts the rest + XCTAssertLessThanOrEqual(bufferedLines.count, 32) + XCTAssertLessThanOrEqual(bufferedLines.reduce(0) { $0 + (($1["m"] as? String)?.count ?? 0) }, 128 * 1024) + XCTAssertEqual(long + " 39", bufferedLines.last?["m"] as? String) + + common.updateLogGatheringState(true, levels: "d", batch: maxBufferedLines, lgid: "gather_long_lines_ceiling") + XCTAssertGreaterThanOrEqual(dropCount, 8) + } + + func testLogGathering_levelsAreLowercasedAndDeduplicated() throws { + common.updateLogGatheringState(true, levels: "EeWwE", batch: defaultBatchSize, lgid: "gather_levels") + + XCTAssertEqual("ew", levels) + } + + func testLogGathering_transportWorkOnTheCallingThreadIsNotGathered() throws { + common.updateLogGatheringState(true, levels: allLevels, batch: defaultBatchSize, lgid: "gather_thread") + clearBuffer() + + common.setSdkLogsTransportWork(true) + capture(3) + common.setSdkLogsTransportWork(false) + capture(2) + + XCTAssertEqual(2, harnessLines.count, "the request path logs the batch it sends, gathering that would re-upload it every tick") + } + + func testLogGathering_ownTransportLinesAreNotGathered() throws { + startSDK() + + common.updateLogGatheringState(true, levels: allLevels, batch: minBatchSize, lgid: "gather_transport") + clearBuffer() + capture(minBatchSize) + + common.flushSdkLogs() + settleDelivery() + + // uploading a batch is itself a request, and the request path logs the request in full. + // Gathering that line would nest each batch inside the next one. + let held = bufferedLines.compactMap { $0["m"] as? String } + XCTAssertTrue(held.allSatisfy { !$0.contains("sdk_logs") }, "held a line describing the upload: \(held)") + } + + func testLogGathering_realLogLinesCarryNoLevelPrefixInTheMessage() throws { + startSDK() + common.updateLogGatheringState(true, levels: allLevels, batch: defaultBatchSize, lgid: "gather_prefix") + clearBuffer() + + // real lines, through CountlyInternalLog, not the capture funnel directly + Countly.sharedInstance().recordEvent("prefix_probe") + Countly.sharedInstance().views().startAutoStoppedView("prefix_probe_view") + + let messages = bufferedLines.compactMap { $0["m"] as? String } + XCTAssertFalse(messages.isEmpty, "the SDK logged nothing to check") + + // the level travels in the line's own field, and the dashboard renders it from there, so a + // prefix in the message would be the same level a second time + let prefixes = ["[Error]", "[Warning]", "[Info]", "[Debug]", "[Verbose]"] + let offenders = messages.filter { message in prefixes.contains { message.hasPrefix($0) } } + XCTAssertTrue(offenders.isEmpty, "level prefix duplicated into the message: \(offenders.prefix(3))") + + XCTAssertTrue(bufferedLines.allSatisfy { allLevels.contains(($0["l"] as? String) ?? "") }, + "and every line still carries its level in its own field") + } + + func testLogGathering_capturedLineCarryingTheTransportMarkerIsIgnored() throws { + common.updateLogGatheringState(true, levels: allLevels, batch: defaultBatchSize, lgid: "gather_marker") + clearBuffer() + + common.captureSdkLogLine("request started &sdk_logs={\"i\":\"x\"}", level: CChar(Character("d").asciiValue!)) + + XCTAssertEqual(0, bufferedLines.count) + } +} + +#if os(iOS) +/// A navigation response the web view delegate can be handed without loading a page. +private final class NavigationResponseStub: WKNavigationResponse { + private let stubbedResponse: URLResponse + + /// A navigation response that hands the delegate the given response. + init(response: URLResponse) { + stubbedResponse = response + super.init() + } + + override var response: URLResponse { stubbedResponse } +} +#endif + +/// The sdk_logs batches a mock server received, in arrival order. Filled on the URL loading threads, read by the test. +private final class ReceivedLogBatches { + private let lock = NSLock() + private var received: [[String: Any]] = [] + private let harnessPrefix: String + + /// Records batches, telling the test's own lines apart by the given message prefix. + init(harnessPrefix: String) { + self.harnessPrefix = harnessPrefix + } + + /// Records one received batch. + func append(_ batch: [String: Any]) { + lock.lock() + received.append(batch) + lock.unlock() + } + + /// Every batch received so far. + var batches: [[String: Any]] { + lock.lock() + defer { lock.unlock() } + return received + } + + /// The messages of the test's own lines across every received batch, in arrival order. + var harnessMessages: [String] { + batches.flatMap { ($0["l"] as? [[String: Any]]) ?? [] }.compactMap { $0["m"] as? String }.filter { $0.hasPrefix(harnessPrefix) } + } +} + +/// Collects everything the SDK hands to a host app's logger, so a test can tell the local log apart from the uploaded one. +private class RecordingLogger: NSObject, CountlyLoggerDelegate { + private let lock = NSLock() + private var recorded: [String] = [] + + /// The lines received so far. The SDK logs from any of its threads, so the list is read and written under a lock. + var lines: [String] { + lock.lock() + defer { lock.unlock() } + return recorded + } + + func internalLog(_ log: String, with level: CLYInternalLogLevel) { + lock.lock() + defer { lock.unlock() } + recorded.append(log) + } +} diff --git a/ios/src/CountlyTests/CountlyScreenMetricsTests.swift b/ios/src/CountlyTests/CountlyScreenMetricsTests.swift new file mode 100644 index 00000000..62f7e5a4 --- /dev/null +++ b/ios/src/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/ios/src/CountlyTests/CountlyServerConfigTests.swift b/ios/src/CountlyTests/CountlyServerConfigTests.swift index bcd65ea3..99c6f25e 100644 --- a/ios/src/CountlyTests/CountlyServerConfigTests.swift +++ b/ios/src/CountlyTests/CountlyServerConfigTests.swift @@ -2,13 +2,53 @@ import XCTest @testable import Countly 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] = [] } - override func setUp() async throws { + /// Local Countly instances created during tests. + /// Stored so they can be properly halted in tearDown to prevent zombie + /// notification observers from polluting subsequent tests. + var localCountlyInstances: [Countly] = [] + + override func setUp() { TestUtils.cleanup() + localCountlyInstances = [] + // Clear server config to prevent test pollution + UserDefaults.standard.removeObject(forKey: "kCountlyServerConfigPersistencyKey") + UserDefaults.standard.synchronize() + } + + override func tearDown() { + // Clean up local instances first + for instance in localCountlyInstances { + instance.halt(true) + } + localCountlyInstances = [] + + // Also clean up sharedInstance if it was used + if Countly.sharedInstance() != nil { + Countly.sharedInstance().halt(true) + } + + // Clear server config after each test + UserDefaults.standard.removeObject(forKey: "kCountlyServerConfigPersistencyKey") + UserDefaults.standard.synchronize() + super.tearDown() + } + + /// Creates a local Countly instance and tracks it for tearDown cleanup. + func createLocalCountly() -> Countly { + let instance = Countly() + localCountlyInstances.append(instance) + return instance } // MARK: - Basic Configuration Tests @@ -31,8 +71,7 @@ class CountlyServerConfigTests: CountlyBaseTestCase { */ func test_defaultConfig_whenServerConfigEnabledAndStorageEmpty() { let config = TestUtils.createBaseConfig() - config.enableServerConfiguration = true - let countly = Countly() + let countly = createLocalCountly() countly.start(with: config) XCTAssertNotNil(retrieveServerConfig()) @@ -47,8 +86,7 @@ class CountlyServerConfigTests: CountlyBaseTestCase { func test_serverConfig_whenEnabledAndAllPropertiesAllowing() throws { setServerConfig(createStorageConfig(tracking: true, networking: true, crashes: true)) let config = TestUtils.createBaseConfig() - config.enableServerConfiguration = true; - let countly = Countly() + let countly = createLocalCountly() countly.start(with: config) XCTAssertNotNil(CountlyPersistency.sharedInstance().retrieveServerConfig()) @@ -61,7 +99,6 @@ class CountlyServerConfigTests: CountlyBaseTestCase { func test_serverConfig_whenEnabledAndAllPropertiesForbidding() { setServerConfig(createStorageConfig(tracking: false, networking: false, crashes: false)) let config = TestUtils.createBaseConfig() - config.enableServerConfiguration = true config.enableDebug = false Countly.sharedInstance().start(with: config) @@ -81,7 +118,7 @@ class CountlyServerConfigTests: CountlyBaseTestCase { setServerConfig(createStorageConfig(tracking: true, networking: true, crashes: true)) let config = TestUtils.createBaseConfig() - let countly = Countly() + let countly = createLocalCountly() countly.start(with: config) XCTAssertNotNil(CountlyPersistency.sharedInstance().retrieveServerConfig()) @@ -118,20 +155,107 @@ 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 * when using default configuration values. */ + // The all-features flows exercise feedback widgets and the content zone, which are + // compiled out on watchOS, tvOS and macOS, and assert exact per-platform request counts. + // + // iOS only, even though feedback and content do exist on visionOS: visionOS produces one + // content request where iOS produces two, because refreshContentZone's queue-flush + // runnable does not fire the same way there. Covering visionOS needs its own expected + // counts, not a widened guard. + #if os(iOS) func test_serverConfig_defaults_allFeatures() throws { - try baseAllFeatures({ _ in }, hc: 0, fc: 1, rc: 1, cc: 2, scc: 1) + try baseAllFeatures({ _ in }, hc: 1, fc: 1, rc: 1, cc: 2, scc: 1) } /** @@ -151,9 +275,10 @@ class CountlyServerConfigTests: CountlyBaseTestCase { .refreshContentZone(false) .tracking(false) .consentRequired(true) - - let tracker = setupTestAllFeatures(sc.buildJson()) - + + // answered after start, so the health check start sends always goes out before networking is turned off + let tracker = setupTestAllFeatures(sc.buildJson(), answerAfterStart: true) + XCTAssertEqual(0, TestUtils.getCurrentRQ()?.count) XCTAssertEqual(0, TestUtils.getCurrentEQ()?.count) @@ -167,7 +292,7 @@ class CountlyServerConfigTests: CountlyBaseTestCase { XCTAssertEqual(0, TestUtils.getCurrentRQ()?.count) XCTAssertEqual(0, TestUtils.getCurrentEQ()?.count) - validateCounts(tracker.counts, hc: 0, fc: 0, rc: 0, cc: 0, sc: 1) + validateCounts(tracker, hc: 1, fc: 0, rc: 0, cc: 0, sc: 1) } /** @@ -182,8 +307,13 @@ class CountlyServerConfigTests: CountlyBaseTestCase { sc.consentRequired(true) let tracker = setupTestAllFeatures(sc.buildJson()) - - XCTAssertEqual(0, TestUtils.getCurrentRQ()?.count) + + // The consent and location requests are emitted by notifySdkConfigChange: only once + // `hasFinishedInit` is set, so they land shortly after start rather than synchronously. + TestUtils.waitUntil("the consent and location requests to be queued") { + TestUtils.getCurrentRQ()?.count == 2 + } + XCTAssertEqual(2, TestUtils.getCurrentRQ()?.count) XCTAssertEqual(0, TestUtils.getCurrentEQ()?.count) flowAllFeatures() @@ -205,12 +335,13 @@ class CountlyServerConfigTests: CountlyBaseTestCase { "remote-config": 0, "sessions": 0, "attribution": 0, - "views": 0 + "views": 0, + "metrics": 0 ] TestUtils.validateRequest(["consent": consents], 0) - TestUtils.validateRequest(["begin_session": "1"], 1) + TestUtils.validateRequest(["location": ""], 1) - validateCounts(tracker.counts, hc: 0, fc: 0, rc: 0, cc: 0, sc: 1) + validateCounts(tracker, hc: 1, fc: 0, rc: 0, cc: 0, sc: 1) } /** @@ -223,21 +354,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() - - //ModuleCrashTests.validateCrash(stackTrace, "", false, false, 7, 0, [:], 0, [:], []) - 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) + 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) + }, from: sent) + try TestUtils.validateEventInRQ("test_event", [:], 1, 7, 0, 2, from: sent) + try TestUtils.validateEventInRQ("[CLY]_view", ["name": "test_view", "segment": CountlyDeviceInfo.osName()!, "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) @@ -247,39 +390,31 @@ 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", + "platform": CountlyDeviceInfo.osName()!, "app_version": CountlyDeviceInfo.appVersion()!, "rating": "5", "widget_id": "test", "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) + "platform": CountlyDeviceInfo.osName()! + ], 7, 8, 1, 2, from: sent) - validateCounts(tracker.counts, hc: 0, fc: 1, rc: 1, cc: 2, sc: 1) + validateCounts(tracker, hc: 1, fc: 1, rc: 1, cc: 2, sc: 1) } - + #endif + // MARK: - Queue Size Tests /** @@ -335,7 +470,15 @@ class CountlyServerConfigTests: CountlyBaseTestCase { countlyConfig.manualSessionHandling = true countlyConfig.urlSessionConfiguration = createUrlSessionConfigForResponse(ServerConfigBuilder().requestQueueSize(3).build()) Countly.sharedInstance().start(with: countlyConfig) - + + // This test asserts absolute queue indices, so it needs the limit actually in effect and + // a queue with nothing else in it. An earlier test's in-flight flush can land here after + // setUp purged, which is why this passed locally and failed on CI. + TestUtils.waitUntil("the request queue size limit to be applied") { + CountlyServerConfig.sharedInstance().requestQueueSize() == 3 + } + CountlyPersistency.sharedInstance().flushQueue() + Countly.sharedInstance().beginSession() XCTAssertTrue(TestUtils.getCurrentRQ()![0].contains("begin_session")) @@ -382,11 +525,24 @@ class CountlyServerConfigTests: CountlyBaseTestCase { } } + /// An empty "c" and sibling top level keys are valid: "t" and "v" are stored, the siblings never reach storage. + func test_serverConfig_emptyInnerConfigAndTopLevelSiblingsAreAccepted() { + let countly = initAndValidateConfigParsingResult("{\"v\":7,\"t\":1234,\"c\":{},\"lg\":{\"e\":false},\"ct\":0,\"unknown\":1}", true) + + let stored = retrieveServerConfig() + XCTAssertEqual(7, stored["v"] as? Int) + XCTAssertEqual(1234, stored["t"] as? Int) + XCTAssertEqual(0, (stored["c"] as? [String: Any])?.count) + XCTAssertNil(stored["lg"]) + XCTAssertNil(stored["ct"]) + XCTAssertNil(stored["unknown"]) + assertDefaultConfigValues(countly) + } + func test_serverConfig_emptyConfig() { setServerConfig([:]) let config = TestUtils.createBaseConfig() - config.enableServerConfiguration = true - let countly = Countly() + let countly = createLocalCountly() countly.start(with: config) XCTAssertTrue(retrieveServerConfig().isEmpty) @@ -398,8 +554,7 @@ class CountlyServerConfigTests: CountlyBaseTestCase { setServerConfig(customConfig) let config = TestUtils.createBaseConfig() - config.enableServerConfiguration = true - let countly = Countly() + let countly = createLocalCountly() countly.start(with: config) XCTAssertFalse(CountlyServerConfig.sharedInstance().trackingEnabled()) @@ -414,8 +569,7 @@ class CountlyServerConfigTests: CountlyBaseTestCase { setServerConfig(customConfig) let config = TestUtils.createBaseConfig() - config.enableServerConfiguration = true - let countly = Countly() + let countly = createLocalCountly() countly.start(with: config) XCTAssertTrue(CountlyServerConfig.sharedInstance().consentRequired()) @@ -428,8 +582,7 @@ class CountlyServerConfigTests: CountlyBaseTestCase { setServerConfig(customConfig) let config = TestUtils.createBaseConfig() - config.enableServerConfiguration = true - let countly = Countly() + let countly = createLocalCountly() countly.start(with: config) XCTAssertTrue(CountlyServerConfig.sharedInstance().loggingEnabled()) @@ -443,14 +596,14 @@ class CountlyServerConfigTests: CountlyBaseTestCase { setServerConfig(customConfig) let config = TestUtils.createBaseConfig() - config.enableServerConfiguration = true - let countly = Countly() + let countly = createLocalCountly() countly.start(with: config) XCTAssertEqual(CountlyServerConfig.sharedInstance().eventQueueSize(), 5) XCTAssertEqual(CountlyServerConfig.sharedInstance().requestQueueSize(), 10) } + #if os(iOS) /** * Tests that event tracking is properly disabled when configured. * Verifies that: @@ -461,28 +614,31 @@ 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() - - XCTAssertTrue(TestUtils.getCurrentRQ()![0].contains("begin_session")) + immediateFlowAllFeatures() + waitForQueueToDrain(tracker, expected: 8) + let sent = tracker.sentRequests + + 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": CountlyDeviceInfo.osName()!, "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: 0, fc: 1, rc: 1, cc: 2, sc: 1) + validateCounts(tracker, hc: 1, fc: 1, rc: 1, cc: 2, sc: 1) } /** @@ -494,22 +650,24 @@ 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) - - validateCounts(tracker.counts, hc: 0, fc: 1, rc: 1, cc: 2, sc: 1) + XCTAssertTrue(sent[0].contains("begin_session")) + try TestUtils.validateEventInRQ("test_event", [:], 2, 7, 0, 2, from: sent) + + validateCounts(tracker, hc: 1, fc: 1, rc: 1, cc: 2, sc: 1) } /** @@ -521,36 +679,39 @@ 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: 0, fc: 1, rc: 1, cc: 2, sc: 1) + + validateCounts(tracker, hc: 1, fc: 1, rc: 1, cc: 2, sc: 1) } + #endif /** * Tests the behavior when server configuration changes between app launches. * Verifies that the SDK correctly applies the new configuration when starting. @@ -561,7 +722,7 @@ class CountlyServerConfigTests: CountlyBaseTestCase { setServerConfig(initialConfig) let config1 = TestUtils.createBaseConfig() - let countly1 = Countly() + let countly1 = createLocalCountly() countly1.start(with: config1) // Record an event to verify it works @@ -582,7 +743,7 @@ class CountlyServerConfigTests: CountlyBaseTestCase { // "Second launch" let config2 = TestUtils.createBaseConfig() - let countly2 = Countly() + let countly2 = createLocalCountly() countly2.start(with: config2) // Try to record an event @@ -594,51 +755,51 @@ class CountlyServerConfigTests: CountlyBaseTestCase { } } - /** - * Tests the behavior when key length and value size limits are enforced. - * Verifies that: - * 1. Keys exceeding the length limit are truncated - * 2. Values exceeding the size limit are truncated - * 3. The number of segmentation values is limited as specified - */ - func test_keyLengthAndValueSizeLimits() throws { - let sc = ServerConfigBuilder() - .limitKeyLength(5) // Limit key length to 5 characters - .limitValueSize(10) // Limit value size to 10 characters - .limitSegmentationValues(2) // Limit segmentation values to 2 - let tracker = setupTestAllFeatures(sc.buildJson()) - - // Record event with long key and values - let longKey = "veryLongEventKey" - let segmentation = [ - "key1": "value1", - "key2": "value2", - "key3": "value3VeryLong", - "veryLongKey": "value4" - ] + /** + * Tests the behavior when key length and value size limits are enforced. + * Verifies that: + * 1. Keys exceeding the length limit are truncated + * 2. Values exceeding the size limit are truncated + * 3. The number of segmentation values is limited as specified + */ + func test_keyLengthAndValueSizeLimits() throws { + let sc = ServerConfigBuilder() + .limitKeyLength(5) // Limit key length to 5 characters + .limitValueSize(10) // Limit value size to 10 characters + .limitSegmentationValues(2) // Limit segmentation values to 2 + let tracker = setupTestAllFeatures(sc.buildJson()) + + // Record event with long key and values + let longKey = "veryLongEventKey" + let segmentation = [ + "key1": "value1", + "key2": "value2", + "key3": "value3VeryLong", + "veryLongKey": "value4" + ] + + Countly.sharedInstance().recordEvent(longKey, segmentation: segmentation) + + TestUtils.sleep(2) { + let eq = TestUtils.getCurrentEQ()! + let event = eq[0] + + // Verify key is truncated to 5 chars + XCTAssertEqual(event.key, "veryL") - Countly.sharedInstance().recordEvent(longKey, segmentation: segmentation) + // Verify only 2 segmentation values + let segmentationCount = event.segmentation.count + XCTAssertEqual(2, segmentationCount) - TestUtils.sleep(2) { - let eq = TestUtils.getCurrentEQ()! - let event = eq[0] - - // Verify key is truncated to 5 chars - XCTAssertEqual(event.key, "veryL") - - // Verify only 2 segmentation values - let segmentationCount = event.segmentation.count - XCTAssertEqual(2, segmentationCount) - - // Verify value is truncated - if let segmentation = event.segmentation, - let longValue = segmentation["key3"] as? String { - XCTAssertEqual(10, longValue.count) - } + // Verify value is truncated + if let segmentation = event.segmentation, + let longValue = segmentation["key3"] as? String { + XCTAssertEqual(10, longValue.count) } - - validateCounts(tracker.counts, hc: 0, fc: 0, rc: 0, cc: 0, sc: 1) } + + validateCounts(tracker, hc: 1, fc: 0, rc: 0, cc: 0, sc: 1) + } // MARK: - Helper Methods private func containsEventWithKey(_ requests: [String], _ key: String) -> Bool { @@ -647,10 +808,11 @@ class CountlyServerConfigTests: CountlyBaseTestCase { private func getEventWithKey(_ requests: [String], _ key: String) -> [String: Any]? { for request in requests { - if let data = request.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let events = json["events"] as? [[String: Any]] { - for event in events { + let parsed = TestUtils.parseQueryString(request) + if let eventsStr = parsed["events"] as? String, + let data = eventsStr.data(using: .utf8), + let eventArray = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] { + for event in eventArray { if let eventKey = event["key"] as? String, eventKey == key { return event } @@ -676,10 +838,9 @@ class CountlyServerConfigTests: CountlyBaseTestCase { private func initAndValidateConfigParsingResult(_ targetResponse: String, _ responseAccepted: Bool) -> Countly { let config = TestUtils.createBaseConfig() - config.enableServerConfiguration = true config.urlSessionConfiguration = createUrlSessionConfigForResponse(targetResponse) - let countly = Countly() + let countly = createLocalCountly() countly.start(with: config) TestUtils.sleep(2){ @@ -755,21 +916,59 @@ class CountlyServerConfigTests: CountlyBaseTestCase { let countlyConfig = TestUtils.createBaseConfig() configSetter(countlyConfig, serverConfig) - let countly = Countly() + let countly = createLocalCountly() countly.start(with: countlyConfig) TestUtils.sleep(2, {builder.validateAgainst()}) } - 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, answerAfterStart: Bool = false) -> CountTracker { let tracker = CountTracker() - + tracker.drainQueue = drainQueue + let startReturned = DispatchSemaphore(value: 0) + // Define the mock behavior MockURLProtocol.requestHandler = { request in let requestString = request.url?.absoluteString ?? "" + var responseString: Data? = nil + NSLog("REQUEST_FROM_PROTOCOL: %@", requestString); - if requestString.contains("hc=") { // TODO IOS DOES NOT HAVE HC + if requestString.contains("hc=") { tracker.counts[0] += 1 } else if requestString.contains("method=feedback") { tracker.counts[1] += 1 @@ -778,27 +977,35 @@ class CountlyServerConfigTests: CountlyBaseTestCase { } else if requestString.contains("method=queue") { tracker.counts[3] += 1 } else if requestString.contains("method=sc") { - // Do nothing - } - - if requestString.contains("method=sc") { tracker.counts[4] += 1 - var configToReturn = Data() + if answerAfterStart { + _ = startReturned.wait(timeout: .now() + TestUtils.asyncTimeout) + } do{ - configToReturn = try JSONSerialization.data(withJSONObject: serverConfig) + responseString = try JSONSerialization.data(withJSONObject: serverConfig) } catch { //ignored } - return (configToReturn, HTTPURLResponse(url: request.url!, + } 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, httpVersion: nil, headerFields: nil), nil) + } else { + return ("{\"result\":\"fail\"}".data(using: .utf8), HTTPURLResponse(url: request.url!, + statusCode: 400, + httpVersion: nil, + headerFields: nil), nil) } - - return ("{\"result\":\"fail\"}".data(using: .utf8), HTTPURLResponse(url: request.url!, - statusCode: 400, - httpVersion: nil, - headerFields: nil), nil) + } // Create a URLSession using the mock protocol @@ -808,22 +1015,46 @@ class CountlyServerConfigTests: CountlyBaseTestCase { let countlyConfig = TestUtils.createBaseConfig() countlyConfig.manualSessionHandling = true countlyConfig.urlSessionConfiguration = config; - + Countly.sharedInstance().start(with: countlyConfig) - //Countly.sharedInstance().moduleContent.CONTENT_START_DELAY_MS = 0 - //Countly.sharedInstance().moduleContent.REFRESH_CONTENT_ZONE_DELAY_MS = 0 - + startReturned.signal() + #if os(iOS) || os(visionOS) + CountlyContentBuilderInternal.sharedInstance().contentInitialDelay = 0; + #endif + + // Wait for the async server config response to be *applied*, not merely requested. + // Keying on the request count was wrong: the SDK parses the response afterwards, so + // tests could set user properties or record events before the filters existed, which is + // why the filter tests failed even run in isolation. `populateServerConfig:` stores the + // merged settings as its last step, so a non-empty stored config means it has landed. + TestUtils.waitUntil("the server config response to be applied") { + CountlyPersistency.sharedInstance().retrieveServerConfig().count > 0 + } + return tracker } - private func validateCounts(_ counts: [Int], hc: Int, fc: Int, rc: Int, cc: Int, sc: Int) { - TestUtils.sleep(2) { - XCTAssertEqual(hc, counts[0]) // health check request - XCTAssertEqual(fc, counts[1]) // feedback request - XCTAssertEqual(rc, counts[2]) // remote config request - XCTAssertEqual(cc, counts[3]) // content request ?? when it debugged counts increasing but some sleep it needs - XCTAssertEqual(sc, counts[4]) // server config request - } + /// Waits for the tracker to reach the expected request counts before asserting. + /// + /// These counts are produced by asynchronous requests, so sampling `tracker.counts` the + /// instant the flow returns is a race: it passes on an idle machine and fails on a busy + /// one, which is why a different test in this family failed on each CI cell. Polling first + /// costs nothing when the counts are already there. + private func validateCounts(_ tracker: CountTracker, hc: Int, fc: Int, rc: Int, cc: Int, sc: Int) { + let expected = [hc, fc, rc, cc, sc] + TestUtils.waitUntil("request counts \(expected)") { tracker.counts == expected } + // Deliberately named differently from this overload: an earlier version called + // `validateCounts(tracker, ...)` here and recursed until the stack overflowed, which + // took the whole test bundle down with a SIGSEGV. + assertCounts(tracker.counts, hc: hc, fc: fc, rc: rc, cc: cc, sc: sc) + } + + private func assertCounts(_ counts: [Int], hc: Int, fc: Int, rc: Int, cc: Int, sc: Int) { + XCTAssertEqual(hc, counts[0], "Health check count mismatch. Expected: \(hc), Got: \(counts[0])") + XCTAssertEqual(fc, counts[1], "Feedback request count mismatch. Expected: \(fc), Got: \(counts[1])") + XCTAssertEqual(rc, counts[2], "Remote config count mismatch. Expected: \(rc), Got: \(counts[2])") + XCTAssertEqual(cc, counts[3], "Content request count mismatch. Expected: \(cc), Got: \(counts[3])") + XCTAssertEqual(sc, counts[4], "Server config count mismatch. Expected: \(sc), Got: \(counts[4])") } private func flowAllFeatures() -> String { @@ -850,6 +1081,8 @@ class CountlyServerConfigTests: CountlyBaseTestCase { private func immediateFlowAllFeatures() { Countly.sharedInstance().remoteConfig().downloadKeys { response, error, fullValueUpdate, downloadedValues in } + // Feedback widgets and the content zone are iOS/visionOS-only. + #if os(iOS) Countly.sharedInstance().feedback().getAvailableFeedbackWidgets { (feedbackWidgets: [CountlyFeedbackWidget]?, error) in if (error != nil) { @@ -864,40 +1097,64 @@ class CountlyServerConfigTests: CountlyBaseTestCase { TestUtils.sleep(2) { Countly.sharedInstance().content().refreshContentZone() } - + TestUtils.sleep(2){} + #endif } private func feedbackFlowAllFeatures() { + // Rating/feedback widgets are compiled out on watchOS, tvOS and macOS. + #if os(iOS) Countly.sharedInstance().recordRatingWidget(withID: "test", rating: 5, email: "test", comment: "test", userCanBeContacted: true) let mockWidget = MockFeedbackWidget( id: "test", type: CLYFeedbackWidgetType.NPS ) mockWidget.recordResult(nil) + #endif } private func baseAllFeatures(_ consumer: (ServerConfigBuilder) -> Void, hc: Int, fc: Int, rc: Int, cc: Int, scc: Int) throws { 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")) - //ModuleCrashTests.validateCrash(stackTrace, "", false, false, 8, 1, [:], 0, [:], []) - try TestUtils.validateEventInRQ("[CLY]_orientation", ["mode": "portrait"], 2, 8, 0, 3) - try TestUtils.validateEventInRQ("test_event", [:], 2, 8, 0, 2) - try TestUtils.validateEventInRQ("[CLY]_view", ["name": "test_view", "segment": "iOS", "visit": "1", "start": "1"], 2, 8, 1, 2) + // 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, from: sent) // 1, 3 + try TestUtils.validateEventInRQ("[CLY]_view", ["name": "test_view", "segment": CountlyDeviceInfo.osName()!, "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) @@ -907,45 +1164,1058 @@ 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", + "platform": CountlyDeviceInfo.osName()!, "app_version": CountlyDeviceInfo.appVersion()!, "rating": "5", "widget_id": "test", "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) - - validateCounts(tracker.counts, hc: hc, fc: fc, rc: rc, cc: cc, sc: scc) + "platform": CountlyDeviceInfo.osName()! + ], 8, 9, 1, 2, from: sent) + + validateCounts(tracker, hc: hc, fc: fc, rc: rc, cc: cc, sc: scc) } - + private func setServerConfig(_ serverConfig: [String: Any]){ UserDefaults.standard.set(serverConfig, forKey: "kCountlyServerConfigPersistencyKey") - UserDefaults.standard.synchronize() // Not needed in modern Swift + UserDefaults.standard.synchronize() } - + func retrieveServerConfig() -> [String: Any] { return UserDefaults.standard.object(forKey: "kCountlyServerConfigPersistencyKey") as? [String: Any] ?? [:] } + + // MARK: - Event Blacklist Tests (eb) + + /** + * Tests that event blacklist blocks filtered events. + * Verifies that: + * 1. Blacklisted events are not recorded + * 2. Non-blacklisted events are recorded normally + */ + func test_eventBlacklist_blocksFilteredEvents() { + let sc = ServerConfigBuilder() + .eventBlacklist(["blocked_event", "another_blocked"]) + let _ = setupTestAllFeatures(sc.buildJson()) + + Countly.sharedInstance().recordEvent("blocked_event") + XCTAssertEqual(0, TestUtils.getCurrentEQ()?.count) + + Countly.sharedInstance().recordEvent("allowed_event") + XCTAssertEqual(1, TestUtils.getCurrentEQ()?.count) + + Countly.sharedInstance().recordEvent("another_blocked") + XCTAssertEqual(1, TestUtils.getCurrentEQ()?.count) + } + + /** + * Tests that event whitelist only allows specified events. + * Verifies that: + * 1. Whitelisted events are recorded + * 2. Non-whitelisted events are blocked + */ + func test_eventWhitelist_onlyAllowsSpecifiedEvents() { + let sc = ServerConfigBuilder() + .eventWhitelist(["allowed_event", "another_allowed"]) + let _ = setupTestAllFeatures(sc.buildJson()) + + Countly.sharedInstance().recordEvent("allowed_event") + XCTAssertEqual(1, TestUtils.getCurrentEQ()?.count) + + Countly.sharedInstance().recordEvent("not_in_whitelist") + XCTAssertEqual(1, TestUtils.getCurrentEQ()?.count) + + Countly.sharedInstance().recordEvent("another_allowed") + XCTAssertEqual(2, TestUtils.getCurrentEQ()?.count) + } + + /** + * Tests that internal/reserved events bypass event filters. + * Verifies that view events and other reserved events still work when event filter is active. + */ + func test_eventFilter_internalEventsNotAffected() { + let sc = ServerConfigBuilder() + .eventBlacklist(["[CLY]_view", "test_blocked"]) + let _ = setupTestAllFeatures(sc.buildJson()) + + // View events are reserved and should bypass the filter + Countly.sharedInstance().views().startView("test_view") + + TestUtils.sleep(1) { + // View event should still be recorded (internal events bypass custom event filters) + let eq = TestUtils.getCurrentEQ() + let hasView = eq?.contains(where: { $0.key == "[CLY]_view" }) ?? false + XCTAssertTrue(hasView, "View events should bypass event blacklist") + } + + // Custom blocked event should be blocked + Countly.sharedInstance().recordEvent("test_blocked") + let customEq = TestUtils.getCurrentEQ()?.filter { $0.key == "test_blocked" } + XCTAssertEqual(0, customEq?.count) + } + + /** + * Tests that blacklist takes precedence when both eb and ew are present. + */ + func test_eventFilter_blacklistPrecedenceOverWhitelist() { + let configDict: [String: Any] = [ + "v": 1, + "t": Int(Date().timeIntervalSince1970), + "c": [ + "eb": ["event_a", "event_b"], + "ew": ["event_a", "event_c"] + ] + ] + setServerConfig(configDict) + let config = TestUtils.createBaseConfig() + config.manualSessionHandling = true + Countly.sharedInstance().start(with: config) + + // With blacklist precedence: event_a and event_b should be blocked + Countly.sharedInstance().recordEvent("event_a") + XCTAssertEqual(0, TestUtils.getCurrentEQ()?.count) + + Countly.sharedInstance().recordEvent("event_b") + XCTAssertEqual(0, TestUtils.getCurrentEQ()?.count) + + // event_c should be allowed (not in blacklist) + Countly.sharedInstance().recordEvent("event_c") + XCTAssertEqual(1, TestUtils.getCurrentEQ()?.count) + } + + /** + * Tests empty event filter allows all events. + */ + func test_eventFilter_emptyFilterAllowsAll() { + let sc = ServerConfigBuilder() + let _ = setupTestAllFeatures(sc.buildJson()) + + Countly.sharedInstance().recordEvent("any_event") + XCTAssertEqual(1, TestUtils.getCurrentEQ()?.count) + + Countly.sharedInstance().recordEvent("another_event") + XCTAssertEqual(2, TestUtils.getCurrentEQ()?.count) + } + + // MARK: - Segmentation Blacklist Tests (sb) + + /** + * Tests that segmentation blacklist removes filtered keys from events. + * Verifies that: + * 1. Blacklisted segmentation keys are removed + * 2. Non-blacklisted keys remain + */ + func test_segmentationBlacklist_removesFilteredKeys() throws { + let sc = ServerConfigBuilder() + .segmentationBlacklist(["blocked_key", "another_blocked"]) + .eventQueueSize(1) + let _ = setupTestAllFeatures(sc.buildJson()) + + Countly.sharedInstance().recordEvent("test_event", segmentation: [ + "blocked_key": "value1", + "another_blocked": "value2", + "allowed_key": "value3" + ]) + + try TestUtils.validateEventInRQ("test_event", ["allowed_key": "value3"], 0, 1, 0, 1) + } + + /** + * Tests that segmentation whitelist only keeps specified keys. + */ + func test_segmentationWhitelist_onlyKeepsSpecifiedKeys() throws { + let sc = ServerConfigBuilder() + .segmentationWhitelist(["allowed_key"]) + .eventQueueSize(1) + let _ = setupTestAllFeatures(sc.buildJson()) + + Countly.sharedInstance().recordEvent("test_event", segmentation: [ + "allowed_key": "value1", + "not_allowed_1": "value2", + "not_allowed_2": "value3" + ]) + + try TestUtils.validateEventInRQ("test_event", ["allowed_key": "value1"], 0, 1, 0, 1) + } + + // MARK: - Event Segmentation Blacklist Tests (esb) + + /** + * Tests that event-specific segmentation blacklist only affects the specified event. + * Verifies that: + * 1. For the targeted event, blacklisted seg keys are removed + * 2. For other events, the same seg keys remain untouched + */ + func test_eventSegmentationBlacklist_affectsSpecificEvents() throws { + let sc = ServerConfigBuilder() + .eventSegmentationBlacklist(["event1": ["blocked_for_event1"]]) + .eventQueueSize(1) + let _ = setupTestAllFeatures(sc.buildJson()) + + // For event1, blocked_for_event1 should be removed + Countly.sharedInstance().recordEvent("event1", segmentation: [ + "blocked_for_event1": "value1", + "allowed_key": "value2" + ]) + + try TestUtils.validateEventInRQ("event1", ["allowed_key": "value2"], 0, 1, 0, 1) + + // For event2, blocked_for_event1 should NOT be removed + Countly.sharedInstance().recordEvent("event2", segmentation: [ + "blocked_for_event1": "value1", + "other_key": "value2" + ]) + + try TestUtils.validateEventInRQ("event2", ["blocked_for_event1": "value1", "other_key": "value2"], 1, 2, 0, 1) + } + + /** + * Tests that event-specific segmentation whitelist only keeps specified keys for the targeted event. + */ + func test_eventSegmentationWhitelist_affectsSpecificEvents() throws { + let sc = ServerConfigBuilder() + .eventSegmentationWhitelist(["purchase": ["keep"]]) + .eventQueueSize(1) + let _ = setupTestAllFeatures(sc.buildJson()) + + // For purchase, only "keep" should remain + Countly.sharedInstance().recordEvent("purchase", segmentation: [ + "card": "1111", + "keep": "ok", + "drop": "no" + ]) + + try TestUtils.validateEventInRQ("purchase", ["keep": "ok"], 0, 1, 0, 1) + + // For non-purchase, all keys should remain + Countly.sharedInstance().recordEvent("not-purchase", segmentation: [ + "card": "1111", + "keep": "ok", + "drop": "no" + ]) + + try TestUtils.validateEventInRQ("not-purchase", ["card": "1111", "keep": "ok", "drop": "no"], 1, 2, 0, 1) + } + + // MARK: - Combined Segmentation Filter Tests + + /** + * Tests that both global and event-specific segmentation filters work together. + */ + func test_combinedSegmentationFilters_bothApplied() throws { + let sc = ServerConfigBuilder() + .segmentationBlacklist(["global_blocked"]) + .eventSegmentationBlacklist(["my_event": ["event_blocked"]]) + .eventQueueSize(1) + let _ = setupTestAllFeatures(sc.buildJson()) + + Countly.sharedInstance().recordEvent("my_event", segmentation: [ + "global_blocked": "value1", + "event_blocked": "value2", + "allowed_key": "value3" + ]) + + try TestUtils.validateEventInRQ("my_event", ["allowed_key": "value3"], 0, 1, 0, 1) + } + + // MARK: - User Property Blacklist Tests (upb) + + /** + * Tests that user property blacklist blocks filtered properties in modifications. + * Verifies that: + * 1. Blacklisted custom properties are not sent + * 2. Non-blacklisted custom properties are sent + */ + func test_userPropertyBlacklist_blocksFilteredProperties() { + let sc = ServerConfigBuilder() + .userPropertyBlacklist(["blocked_prop", "another_blocked"]) + let _ = setupTestAllFeatures(sc.buildJson()) + + Countly.user().set("blocked_prop", value: "value1") + Countly.user().set("another_blocked", value: "value2") + Countly.user().set("allowed_prop", value: "value3") + Countly.user().save() + + TestUtils.sleep(1) { + let rq = TestUtils.getCurrentRQ()! + let userDetailsRequest = rq.first { $0.contains("user_details") } + XCTAssertNotNil(userDetailsRequest) + if let requestStr = userDetailsRequest { + XCTAssertTrue(requestStr.contains("allowed_prop")) + XCTAssertFalse(requestStr.contains("blocked_prop")) + XCTAssertFalse(requestStr.contains("another_blocked")) + } + } + } + + /** + * Tests that user property whitelist only allows specified properties. + */ + func test_userPropertyWhitelist_onlyAllowsSpecifiedProperties() { + let sc = ServerConfigBuilder() + .userPropertyWhitelist(["allowed_prop"]) + let _ = setupTestAllFeatures(sc.buildJson()) + + Countly.user().set("allowed_prop", value: "value1") + Countly.user().set("not_allowed", value: "value2") + Countly.user().save() + + TestUtils.sleep(1) { + let rq = TestUtils.getCurrentRQ()! + let userDetailsRequest = rq.first { $0.contains("user_details") } + XCTAssertNotNil(userDetailsRequest) + if let requestStr = userDetailsRequest { + XCTAssertTrue(requestStr.contains("allowed_prop")) + XCTAssertFalse(requestStr.contains("not_allowed")) + } + } + } + + /** + * Tests that user property blacklist also filters the custom dictionary property. + */ + func test_userPropertyBlacklist_filtersCustomDictionary() { + let sc = ServerConfigBuilder() + .userPropertyBlacklist(["blocked_custom"]) + let _ = setupTestAllFeatures(sc.buildJson()) + + Countly.user().custom = [ + "blocked_custom": "value1", + "allowed_custom": "value2" + ] as NSDictionary + Countly.user().save() + + TestUtils.sleep(1) { + let rq = TestUtils.getCurrentRQ()! + let userDetailsRequest = rq.first { $0.contains("user_details") } + XCTAssertNotNil(userDetailsRequest) + if let requestStr = userDetailsRequest { + XCTAssertTrue(requestStr.contains("allowed_custom")) + XCTAssertFalse(requestStr.contains("blocked_custom")) + } + } + } + + // MARK: - User Property Cache Limit Tests (upcl) + + /** + * Tests that user property cache limit trims excess properties. + */ + func test_userPropertyCacheLimit_trimsExcessProperties() { + let sc = ServerConfigBuilder() + .userPropertyCacheLimit(3) + let _ = setupTestAllFeatures(sc.buildJson()) + + // Set more properties than the limit + Countly.user().set("prop1", value: "value1") + Countly.user().set("prop2", value: "value2") + Countly.user().set("prop3", value: "value3") + Countly.user().set("prop4", value: "value4") + Countly.user().set("prop5", value: "value5") + Countly.user().save() + + TestUtils.sleep(1) { + let rq = TestUtils.getCurrentRQ()! + let userDetailsRequest = rq.first { $0.contains("user_details") } + XCTAssertNotNil(userDetailsRequest) + if let requestStr = userDetailsRequest, + let range = requestStr.range(of: "user_details="), + let decoded = requestStr[range.upperBound...].removingPercentEncoding, + let data = decoded.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let custom = json["custom"] as? [String: Any] { + // Should have at most 3 properties + XCTAssertLessThanOrEqual(custom.count, 3) + } + } + } + + /** + * Tests that default user property cache limit is 100. + */ + func test_userPropertyCacheLimit_defaultIs100() { + let sc = ServerConfigBuilder() + let _ = setupTestAllFeatures(sc.buildJson()) + + XCTAssertEqual(100, CountlyServerConfig.sharedInstance()?.userPropertyCacheLimit()) + } + + // MARK: - Journey Trigger Events Tests (jte) + + /** + * Tests that journey trigger events are configured correctly. + */ + func test_journeyTriggerEvents_configuredCorrectly() { + let sc = ServerConfigBuilder() + .journeyTriggerEvents(["trigger_event", "another_trigger"]) + let _ = setupTestAllFeatures(sc.buildJson()) + + XCTAssertTrue(CountlyServerConfig.sharedInstance()!.isJourneyTriggerEvent("trigger_event")) + XCTAssertTrue(CountlyServerConfig.sharedInstance()!.isJourneyTriggerEvent("another_trigger")) + XCTAssertFalse(CountlyServerConfig.sharedInstance()!.isJourneyTriggerEvent("not_a_trigger")) + } + + /** + * Tests that recording a JTE event forces flush of the event queue. + * Verifies that: + * 1. Regular events stay in event queue (high threshold) + * 2. JTE event forces immediate flush of all events to RQ + * 3. Flushed request contains callback_id (for JTE callback) + */ + func test_journeyTriggerEvents_triggersEventFlush() { + let sc = ServerConfigBuilder() + .journeyTriggerEvents(["jte_event"]) + .eventQueueSize(100) // High threshold + let _ = setupTestAllFeatures(sc.buildJson()) + + XCTAssertEqual(0, TestUtils.getCurrentRQ()?.count) + + // Record non-JTE event - should stay in EQ + Countly.sharedInstance().recordEvent("regular_event") + XCTAssertEqual(0, TestUtils.getCurrentRQ()?.count) + XCTAssertEqual(1, TestUtils.getCurrentEQ()?.count) + + // Record JTE event - should force flush all events to RQ + Countly.sharedInstance().recordEvent("jte_event") + XCTAssertEqual(1, TestUtils.getCurrentRQ()?.count) + XCTAssertEqual(0, TestUtils.getCurrentEQ()?.count) + + // Verify the flushed request contains both events and has callback_id + let rq = TestUtils.getCurrentRQ()! + XCTAssertTrue(rq[0].contains("regular_event")) + XCTAssertTrue(rq[0].contains("jte_event")) + XCTAssertTrue(rq[0].contains("callback_id")) + } + + /** + * Tests that non-JTE events stay queued normally. + */ + func test_journeyTriggerEvents_nonJteStaysQueued() { + let sc = ServerConfigBuilder() + .journeyTriggerEvents(["journey_event"]) + .eventQueueSize(100) + let _ = setupTestAllFeatures(sc.buildJson()) + + // Record non-JTE events - should stay in queue + Countly.sharedInstance().recordEvent("regular_event") + XCTAssertEqual(0, TestUtils.getCurrentRQ()?.count) + XCTAssertEqual(1, TestUtils.getCurrentEQ()?.count) + + Countly.sharedInstance().recordEvent("another_regular") + XCTAssertEqual(0, TestUtils.getCurrentRQ()?.count) + XCTAssertEqual(2, TestUtils.getCurrentEQ()?.count) + + // Record JTE event - should flush ALL events + Countly.sharedInstance().recordEvent("journey_event") + XCTAssertEqual(1, TestUtils.getCurrentRQ()?.count) + XCTAssertEqual(0, TestUtils.getCurrentEQ()?.count) + + // Verify all events in the flushed request + let rq = TestUtils.getCurrentRQ()! + XCTAssertTrue(rq[0].contains("regular_event")) + XCTAssertTrue(rq[0].contains("another_regular")) + XCTAssertTrue(rq[0].contains("journey_event")) + } + + /** + * Tests that event blacklist and JTE work together correctly. + * A blacklisted event that is also a JTE should not be recorded or trigger a flush. + */ + func test_eventBlacklist_withJTE_blacklistedJTENotRecorded() { + let sc = ServerConfigBuilder() + .eventBlacklist(["blocked_jte"]) + .journeyTriggerEvents(["blocked_jte", "allowed_jte"]) + .eventQueueSize(100) + let _ = setupTestAllFeatures(sc.buildJson()) + + // Record a blacklisted JTE - should be blocked before JTE check + Countly.sharedInstance().recordEvent("blocked_jte") + XCTAssertEqual(0, TestUtils.getCurrentRQ()?.count) + XCTAssertEqual(0, TestUtils.getCurrentEQ()?.count) + + // Record a non-blacklisted JTE - should trigger flush + Countly.sharedInstance().recordEvent("allowed_jte") + XCTAssertEqual(1, TestUtils.getCurrentRQ()?.count) + XCTAssertEqual(0, TestUtils.getCurrentEQ()?.count) + } + + // MARK: - Mutual Exclusivity Tests + + /** + * Tests that when config changes from blacklist to whitelist, the old blacklist is removed. + */ + func test_filterMerge_whitelistReplacesBlacklist() { + // First "launch" with event blacklist + let initialConfig = ServerConfigBuilder() + .eventBlacklist(["event_a"]) + .buildJson() + setServerConfig(initialConfig) + let config1 = TestUtils.createBaseConfig() + config1.manualSessionHandling = true + Countly.sharedInstance().start(with: config1) + + Countly.sharedInstance().recordEvent("event_a") + XCTAssertEqual(0, TestUtils.getCurrentEQ()?.count, "event_a should be blocked by blacklist") + + Countly.sharedInstance().recordEvent("event_b") + XCTAssertEqual(1, TestUtils.getCurrentEQ()?.count, "event_b should be allowed") + + TestUtils.cleanup() + + // Second "launch" with event whitelist via mock server + let updatedConfig = ServerConfigBuilder() + .eventWhitelist(["event_a"]) + .buildJson() + setServerConfig(updatedConfig) + let config2 = TestUtils.createBaseConfig() + config2.manualSessionHandling = true + Countly.sharedInstance().start(with: config2) + + Countly.sharedInstance().recordEvent("event_a") + XCTAssertEqual(1, TestUtils.getCurrentEQ()?.count, "event_a should be allowed by whitelist") + + Countly.sharedInstance().recordEvent("event_b") + XCTAssertEqual(1, TestUtils.getCurrentEQ()?.count, "event_b should be blocked (not in whitelist)") + } + + /** + * Tests that server config with all new listing filter keys can be parsed and validated. + */ + func test_serverConfig_allNewKeysProvidedValues() { + let builder = ServerConfigBuilder() + .eventBlacklist(["blocked_ev1", "blocked_ev2"]) + .userPropertyBlacklist(["blocked_prop"]) + .segmentationBlacklist(["blocked_seg"]) + .eventSegmentationBlacklist(["ev1": ["seg1", "seg2"]]) + .journeyTriggerEvents(["jte1", "jte2"]) + .userPropertyCacheLimit(50) + + setServerConfig(builder.buildJson()) + let config = TestUtils.createBaseConfig() + config.manualSessionHandling = true + Countly.sharedInstance().start(with: config) + + TestUtils.sleep(1) { + builder.validateAgainst() + } + } + + // MARK: - All Features with Listing Filters + + // MARK: - Applying behavior settings at runtime + + /** + *
+     * A refresh after the app comes back from the background applies the behavior settings on top of the
+     * configuration given at start, so it never turns the consent requirement, debug logging or the
+     * developer's queue settings back to their defaults.
+     *
+     * 1- Start requiring consent, with debug logging, a custom event threshold and request queue size
+     * 2- Let the refresh interval pass and trigger the foreground refresh with a reply that sets none of those
+     * 3- Verify consent is still required, logging is still on and the queue settings are unchanged
+     * 4- Verify an event without events consent is still not recorded
+     * 
+ */ + func test_foregroundRefresh_appliesTheSettingsOnTopOfTheConfigGivenAtStart() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + let fetches = FetchCounter() + replySettings(ServerConfigBuilder().tracking(true).build()) { fetches.increment() } + defer { MockURLProtocol.replyEmptyToEveryRequest() } + + let config = mockedConfig(manualSessions: true) + config.requiresConsent = true + config.enableDebug = true + config.eventSendThreshold = 7 + config.storedRequestsLimit = 321 + Countly.sharedInstance().start(with: config) + TestUtils.waitUntil("the fetch at start to be answered") { fetches.count == 1 } + TestUtils.sleep(0.5) {} + + let fiveHoursAgo = Int64(Date().timeIntervalSince1970 * 1000) - 5 * 60 * 60 * 1000 + CountlyServerConfig.sharedInstance()?.setValue(NSNumber(value: fiveHoursAgo), forKey: "lastFetchTimestamp") + CountlyServerConfig.sharedInstance()?.fetchIfTimeIsUp() + TestUtils.waitUntil("the foreground refresh to be answered") { fetches.count == 2 } + TestUtils.sleep(0.5) {} + + XCTAssertEqual(2, fetches.count) + XCTAssertTrue(CountlyConsentManager.sharedInstance().requiresConsent, "the refresh must not drop the consent requirement") + XCTAssertTrue(CountlyCommon.sharedInstance().enableDebug, "the refresh must not turn debug logging off") + XCTAssertEqual(7, CountlyPersistency.sharedInstance().eventSendThreshold) + XCTAssertEqual(321, CountlyPersistency.sharedInstance().storedRequestsLimit) + + Countly.sharedInstance().recordEvent("after_the_refresh") + XCTAssertFalse(TestUtils.getCurrentEQ()?.contains { $0.key == "after_the_refresh" } ?? true, "without events consent the event is still dropped") + } + + /** + *
+     * A runtime change of automatic session tracking applies to the running session: a session the SDK
+     * started automatically ends when it is turned off, a session starts when it is turned on, and a
+     * session the app began itself is left alone.
+     *
+     * 1- Start with automatic sessions and the settings reply held back, verify the session begins
+     * 2- Release a reply that turns automatic sessions off, verify the session ends
+     * 3- Start again with manual sessions and a reply that turns automatic sessions on, verify a session begins
+     * 4- Start again with manual sessions, begin a session by hand, release a reply that keeps automatic
+     *    sessions off, and verify the app's session is not ended
+     * 
+ */ + func test_runtimeAutomaticSessionTracking_reconcilesTheRunningSession() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + + var release = startHoldingSettings(ServerConfigBuilder().automaticSessionTracking(false).build(), manualSessions: false) + XCTAssertEqual(1, requestCount("begin_session=1"), "automatic sessions begin one at start") + release.signal() + TestUtils.waitUntil("the automatic session to end") { self.requestCount("end_session=1") == 1 } + XCTAssertEqual(1, requestCount("end_session=1"), "turning automatic sessions off ends the session the SDK started") + Countly.sharedInstance().halt(true) + + release = startHoldingSettings(ServerConfigBuilder().automaticSessionTracking(true).build(), manualSessions: true) + XCTAssertEqual(0, requestCount("begin_session=1"), "manual sessions begin nothing at start") + release.signal() + TestUtils.waitUntil("a session to begin") { self.requestCount("begin_session=1") == 1 } + XCTAssertEqual(1, requestCount("begin_session=1"), "turning automatic sessions on begins a session in the foreground") + Countly.sharedInstance().halt(true) + + release = startHoldingSettings(ServerConfigBuilder().automaticSessionTracking(false).build(), manualSessions: true) + Countly.sharedInstance().beginSession() + XCTAssertEqual(1, requestCount("begin_session=1")) + release.signal() + TestUtils.sleep(1) {} + XCTAssertEqual(0, requestCount("end_session=1"), "a settings reply never ends a session the app began") + MockURLProtocol.replyEmptyToEveryRequest() + } + + /// Starts the SDK with a settings reply held until the returned semaphore is signalled, failing every other request. + private func startHoldingSettings(_ reply: String, manualSessions: Bool) -> DispatchSemaphore { + let release = DispatchSemaphore(value: 0) + replySettings(reply) { _ = release.wait(timeout: .now() + 5) } + Countly.sharedInstance().start(with: mockedConfig(manualSessions: manualSessions)) + return release + } + + /// Answers every behavior settings fetch with the given reply once `beforeReply` returns, and fails every other request so it stays in the queue. + private func replySettings(_ reply: String, beforeReply: @escaping () -> Void) { + MockURLProtocol.requestHandler = { request in + if request.url?.absoluteString.contains("method=sc") == true { + beforeReply() + return (reply.data(using: .utf8), 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) + } + } + + /// The base config with the given session handling and every request routed through `MockURLProtocol`. + private func mockedConfig(manualSessions: Bool) -> CountlyConfig { + let config = TestUtils.createBaseConfig() + config.manualSessionHandling = manualSessions + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [MockURLProtocol.self] + config.urlSessionConfiguration = sessionConfig + return config + } + + /// How many queued requests contain the given text. + private func requestCount(_ text: String) -> Int { + TestUtils.getCurrentRQ()?.filter { $0.contains(text) }.count ?? 0 + } + + #if os(iOS) || os(tvOS) + /** + *
+     * Automatic view tracking the app paused stays paused through settings responses that do not change it.
+     *
+     * 1- Start with automatic view tracking enabled by stored settings and wait for it to run
+     * 2- Pause it the way the app does, then apply the same settings twice
+     * 3- Verify it is still paused
+     * 
+ */ + func test_avt_aPauseByTheAppSurvivesSettingsResponsesThatDoNotChangeIt() { + setServerConfig(ServerConfigBuilder().automaticViewTracking(true).buildJson()) + let config = TestUtils.createBaseConfig() + config.manualSessionHandling = true + Countly.sharedInstance().start(with: config) + TestUtils.waitUntil("automatic view tracking to start") { CountlyViewTrackingInternal.sharedInstance().isAutoViewTrackingActive } + XCTAssertTrue(CountlyViewTrackingInternal.sharedInstance().isAutoViewTrackingActive) + + CountlyViewTrackingInternal.sharedInstance().isAutoViewTrackingActive = false + CountlyServerConfig.sharedInstance()?.applyAutomaticTrackingState() + CountlyServerConfig.sharedInstance()?.applyAutomaticTrackingState() + TestUtils.sleep(0.5) {} + + XCTAssertFalse(CountlyViewTrackingInternal.sharedInstance().isAutoViewTrackingActive, "a settings response that leaves avt as it is keeps the app's pause") + } + #endif + + // MARK: - Automatic Tracking Flags (ast / avt / acr) and Journey Trigger Views (jtv) + + /** + * Tests that the automatic tracking flags are seeded from the developer config when the server is silent. + * Verifies for each developer config fixture that: + * 1. The resolved ast/avt/acr values equal the developer's choices (drop-in behavior) + * 2. A begin_session request is sent automatically only when ast resolves to true + */ + // Automatic view tracking is implemented for iOS and tvOS only; on the other platforms + // the public API is a logged no-op and CountlyViewTrackingInternal does not expose the flag. + #if os(iOS) || os(tvOS) + func test_automaticTrackingFlags_seededFromDeveloperConfig() { + // (manualSessionHandling, enableAutomaticViewTracking, crashFeature, expected ast, expected avt, expected acr) + let fixtures: [(Bool, Bool, Bool, Bool, Bool, Bool)] = [ + (false, false, false, true, false, false), + (true, true, true, false, true, true), + ] + + for (manualSessions, autoViews, crashFeature, ast, avt, acr) in fixtures { + let reason = "fixture: manualSessions=\(manualSessions), autoViews=\(autoViews), crashFeature=\(crashFeature)" + let config = TestUtils.createBaseConfig() + config.manualSessionHandling = manualSessions + config.enableAutomaticViewTracking = autoViews + config.features = crashFeature ? [CLYFeature.crashReporting] : [] + + Countly.sharedInstance().start(with: config) + + XCTAssertEqual(ast, CountlyServerConfig.sharedInstance()?.automaticSessionTrackingEnabled(), reason) + XCTAssertEqual(avt, CountlyServerConfig.sharedInstance()?.automaticViewTrackingEnabled(), reason) + XCTAssertEqual(acr, CountlyServerConfig.sharedInstance()?.automaticCrashReportingEnabled(), reason) + + let beginSessionCount = TestUtils.getCurrentRQ()?.filter { $0.contains("begin_session") }.count ?? 0 + XCTAssertEqual(ast ? 1 : 0, beginSessionCount, reason) + + TestUtils.cleanup() + } + } + + /** + * Tests that the server can force automatic session tracking on an app using manual session control. + * Verifies that: + * 1. A begin_session request is sent automatically at init despite manualSessionHandling + * 2. The manual session API is ignored while automatic session tracking is active + */ + #endif + func test_ast_serverOverridesManualSessionControl() { + setServerConfig(ServerConfigBuilder().automaticSessionTracking(true).buildJson()) + let config = TestUtils.createBaseConfig() + config.manualSessionHandling = true + Countly.sharedInstance().start(with: config) + + XCTAssertEqual(true, CountlyServerConfig.sharedInstance()?.automaticSessionTrackingEnabled()) + XCTAssertTrue(TestUtils.getCurrentRQ()![0].contains("begin_session"), "automatic begin_session expected at init") + + // Manual session API must be ignored while automatic session tracking is active + Countly.sharedInstance().updateSession() + Countly.sharedInstance().endSession() + Countly.sharedInstance().beginSession() + + let rq = TestUtils.getCurrentRQ()! + XCTAssertEqual(1, rq.filter { $0.contains("begin_session") }.count) + XCTAssertEqual(0, rq.filter { $0.contains("end_session") }.count) + XCTAssertEqual(0, rq.filter { $0.contains("session_duration") }.count) + } + + /** + * Tests that the server can disable automatic session tracking on an app using automatic sessions. + * Verifies that: + * 1. No begin_session request is sent at init + * 2. The manual session API becomes functional + */ + func test_ast_serverDisablesAutomaticSessions() { + setServerConfig(ServerConfigBuilder().automaticSessionTracking(false).buildJson()) + let config = TestUtils.createBaseConfig() + Countly.sharedInstance().start(with: config) + + XCTAssertEqual(false, CountlyServerConfig.sharedInstance()?.automaticSessionTrackingEnabled()) + XCTAssertEqual(0, TestUtils.getCurrentRQ()?.filter { $0.contains("begin_session") }.count) + + Countly.sharedInstance().beginSession() + Countly.sharedInstance().endSession() + + let rq = TestUtils.getCurrentRQ()! + XCTAssertEqual(1, rq.filter { $0.contains("begin_session") }.count, "manual beginSession should work when ast is false") + XCTAssertEqual(1, rq.filter { $0.contains("end_session") }.count, "manual endSession should work when ast is false") + } + + /** + * Tests that the server can force-enable automatic view tracking when the developer did not opt in. + */ + // Automatic view tracking is implemented for iOS and tvOS only; on the other platforms + // the public API is a logged no-op and CountlyViewTrackingInternal does not expose the flag. + #if os(iOS) || os(tvOS) + func test_avt_serverForceEnables() { + setServerConfig(ServerConfigBuilder().automaticViewTracking(true).buildJson()) + let config = TestUtils.createBaseConfig() + config.manualSessionHandling = true + Countly.sharedInstance().start(with: config) + + XCTAssertEqual(true, CountlyServerConfig.sharedInstance()?.automaticViewTrackingEnabled()) + XCTAssertTrue(CountlyViewTrackingInternal.sharedInstance().isAutoViewTrackingActive, + "automatic view tracking should be force-enabled by the server") + } + + /** + * Tests that the server can disable automatic view tracking enabled by the developer, + * while manual view recording keeps working. + */ + func test_avt_serverForceDisables() { + setServerConfig(ServerConfigBuilder().automaticViewTracking(false).buildJson()) + let config = TestUtils.createBaseConfig() + config.manualSessionHandling = true + config.enableAutomaticViewTracking = true + Countly.sharedInstance().start(with: config) + + XCTAssertEqual(false, CountlyServerConfig.sharedInstance()?.automaticViewTrackingEnabled()) + XCTAssertFalse(CountlyViewTrackingInternal.sharedInstance().isAutoViewTrackingActive, + "automatic view tracking should stay off when the server disables avt") + + // Manual view recording is governed by 'vt' (true by default), not 'avt' + Countly.sharedInstance().views().startView("manual_view") + let eq = TestUtils.getCurrentEQ()! + let viewEvent = eq.first { $0.key == "[CLY]_view" } + XCTAssertNotNil(viewEvent, "manual startView should still record when avt is false") + XCTAssertEqual("manual_view", viewEvent?.segmentation["name"] as? String) + + // automatic views would not be recorded anyway, so turning the flag on would only misreport the state + CountlyViewTrackingInternal.sharedInstance().isAutoViewTrackingActive = true + XCTAssertFalse(CountlyViewTrackingInternal.sharedInstance().isAutoViewTrackingActive, + "turning the active state on should not re-enable automatic view tracking while the server keeps avt false") + } + + /** + * Tests that revoking view tracking consent clears the automatic view tracking active state, + * even for a tracker the server force-enabled. Verifies that: + * 1. Automatic view tracking becomes active once view tracking consent is given + * 2. Revoking the consent turns it off (the consent flag flips before 'stopAutoViewTracking' runs) + * 3. Giving the consent back re-activates it + */ + #endif + // Automatic view tracking is implemented for iOS and tvOS only; on the other platforms + // the public API is a logged no-op and CountlyViewTrackingInternal does not expose the flag. + #if os(iOS) || os(tvOS) + func test_avt_consentRevocationClearsActiveState() { + setServerConfig(ServerConfigBuilder().automaticViewTracking(true).buildJson()) + let config = TestUtils.createBaseConfig() + config.manualSessionHandling = true + config.requiresConsent = true + Countly.sharedInstance().start(with: config) + + XCTAssertEqual(true, CountlyServerConfig.sharedInstance()?.automaticViewTrackingEnabled()) + XCTAssertFalse(CountlyViewTrackingInternal.sharedInstance().isAutoViewTrackingActive, + "automatic view tracking should not be active before view tracking consent is given") + + Countly.sharedInstance().giveConsent(forFeature: CLYConsent.viewTracking) + XCTAssertTrue(CountlyViewTrackingInternal.sharedInstance().isAutoViewTrackingActive, + "automatic view tracking should start once view tracking consent is given") + + Countly.sharedInstance().cancelConsent(forFeature: CLYConsent.viewTracking) + XCTAssertFalse(CountlyViewTrackingInternal.sharedInstance().isAutoViewTrackingActive, + "automatic view tracking should stop when view tracking consent is revoked") + + Countly.sharedInstance().giveConsent(forFeature: CLYConsent.viewTracking) + XCTAssertTrue(CountlyViewTrackingInternal.sharedInstance().isAutoViewTrackingActive, + "automatic view tracking should start again when view tracking consent is given back") + } + + /** + * Tests that the server can disable automatic crash reporting enabled by the developer. + * Verifies that: + * 1. The uncaught exception handler is not installed + * 2. Manually recorded exceptions still produce a crash request (governed by 'crt' only) + */ + #endif + func test_acr_serverDisablesAutomaticCrashReporting() { + NSSetUncaughtExceptionHandler(nil) + setServerConfig(ServerConfigBuilder().automaticCrashReporting(false).buildJson()) + let config = TestUtils.createBaseConfig() + config.manualSessionHandling = true + Countly.sharedInstance().start(with: config) + + XCTAssertEqual(false, CountlyServerConfig.sharedInstance()?.automaticCrashReportingEnabled()) + XCTAssertNil(NSGetUncaughtExceptionHandler(), "uncaught exception handler should not be installed when acr is false") + + Countly.sharedInstance().record(NSException(name: NSExceptionName("TestException"), reason: "test", userInfo: nil)) + let crashRequests = TestUtils.getCurrentRQ()!.filter { $0.contains("crash=") } + XCTAssertEqual(1, crashRequests.count, "manually recorded exceptions stay governed by crt only") + } + + /** + * Tests that the server can force-enable automatic crash reporting when the developer did not opt in. + */ + func test_acr_serverForceEnables() { + NSSetUncaughtExceptionHandler(nil) + setServerConfig(ServerConfigBuilder().automaticCrashReporting(true).buildJson()) + let config = TestUtils.createBaseConfig() + config.manualSessionHandling = true + config.features = [] + Countly.sharedInstance().start(with: config) + + XCTAssertEqual(true, CountlyServerConfig.sharedInstance()?.automaticCrashReportingEnabled()) + XCTAssertNotNil(NSGetUncaughtExceptionHandler(), "uncaught exception handler should be installed when the server enables acr") + } + + /** + * Tests that recording a view whose name is a journey trigger view force-flushes the event queue. + * Verifies that: + * 1. Regular events and non-matching views stay in the event queue (high threshold) + * 2. A matching view flushes all queued events to the request queue with a callback_id + */ + func test_jtv_triggersEventFlushForMatchingView() { + setServerConfig(ServerConfigBuilder() + .journeyTriggerViews(["jtv_view"]) + .eventQueueSize(100) + .buildJson()) + let config = TestUtils.createBaseConfig() + config.manualSessionHandling = true + Countly.sharedInstance().start(with: config) + + XCTAssertTrue(CountlyServerConfig.sharedInstance()!.isJourneyTriggerView("jtv_view")) + XCTAssertFalse(CountlyServerConfig.sharedInstance()!.isJourneyTriggerView("other_view")) + + Countly.sharedInstance().recordEvent("regular_event") + Countly.sharedInstance().views().startView("other_view") + XCTAssertEqual(0, TestUtils.getCurrentRQ()?.count, "non-matching views should not flush the event queue") + XCTAssertEqual(2, TestUtils.getCurrentEQ()?.count) + + Countly.sharedInstance().views().startView("jtv_view") + XCTAssertEqual(1, TestUtils.getCurrentRQ()?.count, "a journey trigger view should flush the event queue") + XCTAssertEqual(0, TestUtils.getCurrentEQ()?.count) + + let request = TestUtils.getCurrentRQ()![0] + XCTAssertTrue(request.contains("regular_event")) + XCTAssertTrue(request.contains("other_view")) + XCTAssertTrue(request.contains("jtv_view")) + XCTAssertTrue(request.contains("callback_id")) + } + + /** + * Tests that the four new keys are parsed from a provided configuration and that + * an invalid jtv type is ignored, keeping the default empty set. + */ + // Automatic view tracking is implemented for iOS and tvOS only; on the other platforms + // the public API is a logged no-op and CountlyViewTrackingInternal does not expose the flag. + #if os(iOS) || os(tvOS) + func test_automaticTrackingFlags_providedValuesAndInvalidJtv() { + let builder = ServerConfigBuilder() + .automaticSessionTracking(false) + .automaticViewTracking(true) + .automaticCrashReporting(true) + .journeyTriggerViews(["jtv_view_a", "jtv_view_b"]) + setServerConfig(builder.buildJson()) + + let config = TestUtils.createBaseConfig() + config.manualSessionHandling = false + Countly.sharedInstance().start(with: config) + + TestUtils.sleep(1) { + builder.validateAgainst() + } + + TestUtils.cleanup() + + // Invalid jtv type (dictionary instead of array) is ignored; the default empty set is kept + let invalidConfig: [String: Any] = [ + "v": 1, + "t": Int(Date().timeIntervalSince1970), + "c": ["jtv": ["not": "an array"]] + ] + setServerConfig(invalidConfig) + let config2 = TestUtils.createBaseConfig() + config2.manualSessionHandling = true + Countly.sharedInstance().start(with: config2) + + XCTAssertFalse(CountlyServerConfig.sharedInstance()!.isJourneyTriggerView("not")) + XCTAssertFalse(CountlyServerConfig.sharedInstance()!.isJourneyTriggerView("jtv_view_a")) + } + + /** + * Tests that a server response received at runtime updates the resolved automatic tracking values. + * Verifies that: + * 1. ast/avt/acr flip to the server values after the fetch + * 2. Automatic view tracking starts reactively + * 3. The uncaught exception handler installs reactively + */ + func test_automaticTrackingFlags_runtimeChangeFromServer() { + NSSetUncaughtExceptionHandler(nil) + let serverConfig = ServerConfigBuilder() + .automaticSessionTracking(false) + .automaticViewTracking(true) + .automaticCrashReporting(true) + .build() + + let config = TestUtils.createBaseConfig() + config.features = [] + config.urlSessionConfiguration = createUrlSessionConfigForResponse(serverConfig) + Countly.sharedInstance().start(with: config) + + // Seeded values before the fetch lands are the developer's: ast true, avt false, acr false. + // After the mocked fetch they must flip to the server values. + TestUtils.sleep(2) { + XCTAssertEqual(false, CountlyServerConfig.sharedInstance()?.automaticSessionTrackingEnabled()) + XCTAssertEqual(true, CountlyServerConfig.sharedInstance()?.automaticViewTrackingEnabled()) + XCTAssertEqual(true, CountlyServerConfig.sharedInstance()?.automaticCrashReportingEnabled()) + + XCTAssertTrue(CountlyViewTrackingInternal.sharedInstance().isAutoViewTrackingActive, + "automatic view tracking should start reactively on a runtime avt enable") + XCTAssertNotNil(NSGetUncaughtExceptionHandler(), + "crash handler should install reactively on a runtime acr enable") + } + } + + /** + * Tests all features work correctly with event blacklist applied. + * Sessions, views, crashes, etc. should still work while custom events are filtered. + */ + #if os(iOS) + #endif + func test_eventBlacklist_allFeatures() throws { + let sc = ServerConfigBuilder() + .eventBlacklist(["test_event"]) + let tracker = setupTestAllFeatures(sc.buildJson()) + + XCTAssertEqual(0, TestUtils.getCurrentRQ()?.count) + + let _ = flowAllFeatures() + + // test_event should NOT be in the request queue + XCTAssertFalse(containsEventWithKey(TestUtils.getCurrentRQ()!, "test_event")) + + // But other features should work + XCTAssertTrue(TestUtils.getCurrentRQ()![0].contains("begin_session")) + + // Views should still work (reserved events bypass custom event filters) + XCTAssertTrue(containsEventWithKey(TestUtils.getCurrentRQ()!, "[CLY]_view")) + + validateCounts(tracker, hc: 1, fc: 0, rc: 0, cc: 0, sc: 1) + } + #endif +} + +/// Counts the behavior settings fetches a mocked session answered, from whichever thread answers them. +private final class FetchCounter { + private let lock = NSLock() + private var value = 0 + + /// Records one more fetch. + func increment() { + lock.lock() + value += 1 + lock.unlock() + } + + /// The fetches recorded so far. + var count: Int { + lock.lock() + defer { lock.unlock() } + return value + } } diff --git a/ios/src/CountlyTests/CountlyServerConfigValidationTests.swift b/ios/src/CountlyTests/CountlyServerConfigValidationTests.swift new file mode 100644 index 00000000..3ba85621 --- /dev/null +++ b/ios/src/CountlyTests/CountlyServerConfigValidationTests.swift @@ -0,0 +1,976 @@ +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.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.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.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.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.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.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.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: - Replaced, not merged, settings + + /// Drives the SDK's server-config fetch through a mocked URL session so + /// that `replaceBehaviorSettings` 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 + } + + /// Like `mockURLSessionConfig(returning:)`, but the settings request is + /// answered only once the returned semaphore is signalled, so the test can + /// check the state the stored settings set at start first. + private func heldMockURLSessionConfig(returning configDict: [String: Any]) -> (URLSessionConfiguration, DispatchSemaphore) { + let release = DispatchSemaphore(value: 0) + 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") { + _ = release.wait(timeout: .now() + 5) + 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, release) + } + + /// Answers the settings request with the given body, status code or + /// network error as they are, so a test can send an answer the SDK must + /// not accept. The returned closure tells how many settings requests were + /// answered. + private func rawMockURLSessionConfig(body: [String: Any]?, statusCode: Int = 200, error: Error? = nil) -> (URLSessionConfiguration, () -> Int) { + let lock = NSLock() + var answeredCount = 0 + let data = body.map { try! JSONSerialization.data(withJSONObject: $0) } + MockURLProtocol.requestHandler = { request in + let url = request.url?.absoluteString ?? "" + if url.contains("method=sc") { + lock.lock() + answeredCount += 1 + lock.unlock() + if let error = error { + return (nil, nil, error) + } + return (data, HTTPURLResponse(url: request.url!, statusCode: statusCode, 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, { + lock.lock() + defer { lock.unlock() } + return answeredCount + }) + } + + /// Starts the SDK with stored settings that turn tracking off and set the + /// event queue size to 3, lets the given settings answer arrive, and checks + /// that the stored settings and the values they set are unchanged. + private func assertStoredSettingsKept(after answer: (URLSessionConfiguration, () -> Int), file: StaticString = #filePath, line: UInt = #line) { + setStoredServerConfig(makeServerConfig([ + "tracking": false, + "eqs": 3 + ])) + + let cfg = TestUtils.createBaseConfig() + cfg.manualSessionHandling = true + cfg.eventSendThreshold = 7 + cfg.urlSessionConfiguration = answer.0 + + let countly = createLocalCountly() + countly.start(with: cfg) + + TestUtils.sleep(2) { + XCTAssertGreaterThan(answer.1(), 0, "the settings request was answered", file: file, line: line) + XCTAssertFalse(CountlyServerConfig.sharedInstance().trackingEnabled(), "the stored tracking setting still applies", file: file, line: line) + XCTAssertEqual(3, CountlyPersistency.sharedInstance().eventSendThreshold, "the stored event queue size still applies", file: file, line: line) + let persisted = self.storedConfigDictionary() + XCTAssertEqual(2, persisted.count, "the stored settings are not replaced", file: file, line: line) + XCTAssertEqual(persisted["tracking"] as? Bool, false, file: file, line: line) + XCTAssertEqual(persisted["eqs"] as? Int, 3, file: file, line: line) + } + } + + /// The next answer replaces the stored settings as a whole: a filter the + /// server no longer sends is removed, also from a category the answer does + /// not mention, and the user property filter is turned off. + func test_nextAnswer_replacesTheStoredSettings() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + // 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 carries 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() + XCTAssertEqual(["ew"], Array(persisted.keys), "only the keys of the last answer are stored") + XCTAssertEqual(persisted["ew"] as? [String], ["allowed_event"]) + XCTAssertTrue(CountlyServerConfig.sharedInstance().shouldRecordUserProperty("blocked_prop"), + "the user property blacklist the server stopped sending is turned off") + } + } + + /// The answer replaces the stored settings in every category: the stored + /// segmentation whitelist and user property blacklist are gone, the new + /// segmentation blacklist and user property whitelist are kept. + 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 is removed because the answer does not carry it") + XCTAssertEqual(persisted["sb"] as? [String], ["s_blocked"]) + XCTAssertNil(persisted["upb"], "upb is removed because the answer does not carry it") + XCTAssertEqual(persisted["upw"] as? [String], ["u_allowed"]) + } + } + + /// A setting the server stops sending goes back to the developer's value, + /// or to the SDK default when the developer set none, instead of keeping + /// the last server value. + func test_removedSetting_goesBackToTheDeveloperValue() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + setStoredServerConfig(makeServerConfig([ + "tracking": false, + "eqs": 3 + ])) + + let cfg = TestUtils.createBaseConfig() + cfg.manualSessionHandling = true + cfg.eventSendThreshold = 7 + let (sessionConfig, release) = heldMockURLSessionConfig(returning: [ + "networking": true + ]) + cfg.urlSessionConfiguration = sessionConfig + + let countly = createLocalCountly() + countly.start(with: cfg) + XCTAssertEqual(3, CountlyPersistency.sharedInstance().eventSendThreshold, "the stored settings apply at start") + XCTAssertFalse(CountlyServerConfig.sharedInstance().trackingEnabled()) + release.signal() + + TestUtils.sleep(2) { + XCTAssertTrue(CountlyServerConfig.sharedInstance().trackingEnabled(), "tracking was removed and goes back to the default") + XCTAssertEqual(7, CountlyPersistency.sharedInstance().eventSendThreshold, "the event queue size was removed and goes back to the developer's value") + XCTAssertEqual(["networking"], Array(self.storedConfigDictionary().keys)) + } + } + + /// An answer with no settings, which the server sends once every setting + /// is removed from SDK Manager, brings back all developer values, + /// including the developer's consent requirement. + func test_emptySettings_bringBackAllDeveloperValues() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + setStoredServerConfig(makeServerConfig([ + "tracking": false, + "rqs": 50, + "cr": true + ])) + + let cfg = TestUtils.createBaseConfig() + cfg.manualSessionHandling = true + cfg.requiresConsent = false + cfg.storedRequestsLimit = 500 + let (sessionConfig, release) = heldMockURLSessionConfig(returning: [:]) + cfg.urlSessionConfiguration = sessionConfig + + let countly = createLocalCountly() + countly.start(with: cfg) + XCTAssertTrue(CountlyConsentManager.sharedInstance().requiresConsent, "the stored settings require consent at start") + XCTAssertEqual(50, CountlyPersistency.sharedInstance().storedRequestsLimit) + release.signal() + + TestUtils.sleep(2) { + XCTAssertTrue(CountlyServerConfig.sharedInstance().trackingEnabled()) + XCTAssertEqual(500, CountlyPersistency.sharedInstance().storedRequestsLimit) + XCTAssertFalse(CountlyConsentManager.sharedInstance().requiresConsent, "without 'cr' the developer's choice applies again") + XCTAssertEqual(0, self.storedConfigDictionary().count) + } + } + + /// An answer with an error status is not applied, even when its body + /// carries settings. + func test_answerWithAnErrorStatus_keepsTheStoredSettings() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + assertStoredSettingsKept(after: rawMockURLSessionConfig(body: makeServerConfig([ + "tracking": true, + "eqs": 50 + ]), statusCode: 500)) + } + + /// A settings request that fails without an answer keeps the stored settings. + func test_failedSettingsRequest_keepsTheStoredSettings() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + assertStoredSettingsKept(after: rawMockURLSessionConfig(body: nil, error: URLError(.notConnectedToInternet))) + } + + /// An answer whose 'c' is not an object is not valid and keeps the stored settings. + func test_answerWhoseSettingsAreNotAnObject_keepsTheStoredSettings() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + assertStoredSettingsKept(after: rawMockURLSessionConfig(body: [ + "v": 1, + "t": Int(Date().timeIntervalSince1970), + "c": ["tracking"] + ])) + } + + /// An answer without a version is not valid and keeps the stored settings. + func test_answerWithoutAVersion_keepsTheStoredSettings() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + assertStoredSettingsKept(after: rawMockURLSessionConfig(body: [ + "t": Int(Date().timeIntervalSince1970), + "c": ["tracking": true, "eqs": 50] as [String: Any] + ])) + } + + /// An answer whose version is null is not valid and keeps the stored settings, instead of crashing + /// every start while the server keeps sending it. + func test_answerWithANullVersion_keepsTheStoredSettings() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + assertStoredSettingsKept(after: rawMockURLSessionConfig(body: [ + "v": NSNull(), + "t": Int(Date().timeIntervalSince1970), + "c": ["tracking": true, "eqs": 50] as [String: Any] + ])) + } + + /// An answer whose timestamp is null is not valid either. + func test_answerWithANullTimestamp_keepsTheStoredSettings() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + assertStoredSettingsKept(after: rawMockURLSessionConfig(body: [ + "v": 1, + "t": NSNull(), + "c": ["tracking": true, "eqs": 50] as [String: Any] + ])) + } + + /// Nulls inside a list or a dictionary of an answer are left out of what is stored, which the + /// standard user defaults could not store, and the rest of the setting applies. + func test_nullsInsideAnAnswersSettings_areLeftOut() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + let cfg = TestUtils.createBaseConfig() + cfg.manualSessionHandling = true + cfg.urlSessionConfiguration = rawMockURLSessionConfig(body: makeServerConfig([ + "eb": ["blocked_event", NSNull()], + "esb": ["checkout": ["card", NSNull()]] + ])).0 + createLocalCountly().start(with: cfg) + + TestUtils.sleep(2) { + let stored = self.storedConfigDictionary() + XCTAssertEqual(["blocked_event"], stored["eb"] as? [String], "the null is left out of the stored list") + XCTAssertEqual(["checkout": ["card"]], stored["esb"] as? [String: [String]], + "and out of a list inside a dictionary") + XCTAssertFalse(CountlyServerConfig.sharedInstance().shouldRecordEvent("blocked_event"), + "the rest of the setting applies") + } + } + + /// The developer's disableBackoffMechanism holds for the whole run: neither the start without stored settings, nor + /// an answer without 'bom', nor one with 'bom' true turns the backoff back on. + func test_disableBackoffMechanism_holdsForEveryAnswer() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + let cfg = TestUtils.createBaseConfig() + cfg.manualSessionHandling = true + cfg.disableBackoffMechanism = true + let (sessionConfig, release) = heldMockURLSessionConfig(returning: [ + "bom": true + ]) + cfg.urlSessionConfiguration = sessionConfig + createLocalCountly().start(with: cfg) + XCTAssertFalse(CountlyServerConfig.sharedInstance().backoffMechanism(), "the backoff is off from start") + + release.signal() + TestUtils.sleep(2) { + XCTAssertTrue(self.storedConfigDictionary()["bom"] as? Bool == true, + "the answer turning the backoff on is applied") + XCTAssertFalse(CountlyServerConfig.sharedInstance().backoffMechanism(), "and the backoff stays off") + } + } + + /// Built in settings are checked like an answer, so a null in them can not crash every start either. + func test_builtInSettingsWithANullVersion_areIgnored() { + let cfg = TestUtils.createBaseConfig() + cfg.manualSessionHandling = true + cfg.disableSDKBehaviorSettingsUpdates = true + cfg.sdkBehaviorSettings = "{\"v\":null,\"t\":1,\"c\":{\"tracking\":false}}" + createLocalCountly().start(with: cfg) + XCTAssertTrue(CountlyServerConfig.sharedInstance().trackingEnabled(), + "settings without a usable version are not applied") + XCTAssertTrue(storedConfigDictionary().isEmpty, "and nothing is stored") + } + + /// Only the values in an acceptable format replace the stored settings: a + /// setting sent with a wrong type, below its minimum or out of its range + /// is left out, so it goes back to the developer's value or the SDK default. + func test_valuesInAnUnacceptableFormat_areLeftOutOfTheReplacement() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + setStoredServerConfig(makeServerConfig([ + "tracking": false, + "eqs": 3, + "eb": ["blocked_event"] + ])) + + let cfg = TestUtils.createBaseConfig() + cfg.manualSessionHandling = true + cfg.eventSendThreshold = 7 + let (sessionConfig, answered) = rawMockURLSessionConfig(body: makeServerConfig([ + "cet": false, + "tracking": "no", + "eqs": "3", + "rqs": 0, + "bom_rqp": 2.0, + "eb": "blocked_event" + ])) + cfg.urlSessionConfiguration = sessionConfig + + let countly = createLocalCountly() + countly.start(with: cfg) + + TestUtils.sleep(2) { + XCTAssertGreaterThan(answered(), 0, "the settings request was answered") + XCTAssertEqual(["cet"], Array(self.storedConfigDictionary().keys), "only the value in an acceptable format is stored") + XCTAssertFalse(CountlyServerConfig.sharedInstance().customEventTrackingEnabled(), "the value in an acceptable format is applied") + XCTAssertTrue(CountlyServerConfig.sharedInstance().trackingEnabled(), "a tracking value that is not a boolean is left out, the SDK default applies") + XCTAssertEqual(7, CountlyPersistency.sharedInstance().eventSendThreshold, "an event queue size that is not a number is left out, the developer's value applies") + XCTAssertTrue(CountlyServerConfig.sharedInstance().shouldRecordEvent("blocked_event"), "an event blacklist that is not a list is left out, the stored one is gone") + } + } + + /// The server can not lift a consent requirement the developer set, neither from the stored settings + /// nor from a live answer, while a 'cr' true it sends turns the requirement on for a developer who + /// did not set it. + func test_serverCanOnlyTurnTheConsentRequirementOn() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + setStoredServerConfig(makeServerConfig([ + "cr": false + ])) + + let cfg = TestUtils.createBaseConfig() + cfg.manualSessionHandling = true + cfg.requiresConsent = true + let (sessionConfig, release) = heldMockURLSessionConfig(returning: [ + "cr": false + ]) + cfg.urlSessionConfiguration = sessionConfig + + let countly = createLocalCountly() + countly.start(with: cfg) + XCTAssertTrue(CountlyConsentManager.sharedInstance().requiresConsent, + "a stored 'cr' false does not lift the developer's requirement") + release.signal() + + TestUtils.sleep(2) { + XCTAssertTrue(CountlyConsentManager.sharedInstance().requiresConsent, "and neither does a live one") + } + + countly.halt(true) + setStoredServerConfig(makeServerConfig([ + "cr": true + ])) + let next = TestUtils.createBaseConfig() + next.manualSessionHandling = true + next.requiresConsent = false + next.disableSDKBehaviorSettingsUpdates = true + createLocalCountly().start(with: next) + XCTAssertTrue(CountlyConsentManager.sharedInstance().requiresConsent, + "a 'cr' true requires consent the developer did not require") + } + + /// A setting the server sends wins over the developer's value also when + /// it is false or 0: logging the developer turned on goes off, and a + /// request drop age the developer set is turned off. + func test_settingSentAsOffOrZero_winsOverTheDeveloperValue() { + setStoredServerConfig(makeServerConfig([ + "log": false, + "dort": 0 + ])) + + let cfg = TestUtils.createBaseConfig() + cfg.manualSessionHandling = true + cfg.enableDebug = true + cfg.requestDropAgeHours = 48 + let countly = createLocalCountly() + countly.start(with: cfg) + + XCTAssertFalse(CountlyCommon.sharedInstance().enableDebug, "the server's log=false turns the developer's logging off") + XCTAssertEqual(0, CountlyPersistency.sharedInstance().requestDropAgeHours, "the server's dort=0 turns the developer's drop age off") + } + + // 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.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") + } + + /// With pinned certificates the immediate session checks them like the request queue, without pins it keeps the system trust. + func test_immediateURLSession_checksPinnedCertificatesLikeTheRequestQueue() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + // answered in process, so no TLS challenge ever looks up the pinned certificate file, which does not exist + MockURLProtocol.replyEmptyToEveryRequest() + + let cfg = TestUtils.createBaseConfig() + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [MockURLProtocol.self] + cfg.urlSessionConfiguration = sessionConfig + cfg.manualSessionHandling = true + cfg.pinnedCertificates = ["pinned.cer"] + Countly.sharedInstance().start(with: cfg) + + let connectionManager = try XCTUnwrap(CountlyConnectionManager.sharedInstance()) + let pinnedSession = CountlyCommon.sharedInstance().immediateURLSession() + XCTAssertTrue(pinnedSession.delegate === connectionManager, + "with pinned certificates the connection manager must answer the trust challenge") + XCTAssertEqual(60.0, pinnedSession.configuration.timeoutIntervalForRequest, accuracy: 0.001, + "pinning must not change the immediate session timeout") + XCTAssertTrue(CountlyCommon.sharedInstance().immediateURLSession() === pinnedSession, + "a session with a delegate lives until it is invalidated, so every immediate request shares one") + + connectionManager.pinnedCertificates = nil + XCTAssertNil(CountlyCommon.sharedInstance().immediateURLSession().delegate, + "without pinned certificates the system trust evaluation applies") + } + + /** + *
+     * With pinned certificates the immediate session is kept between requests, so it has to be rebuilt when the
+     * custom headers change, or a header added after start would never reach an immediate request.
+     *
+     * 1- Start with pinned certificates and a header given at start, which builds the kept session
+     * 2- Add a header after start and download remote config, an immediate request
+     * 3- Verify the request carried both headers and the new session still answers the trust challenge
+     * 
+ */ + func test_immediateURLSession_pinnedSessionCarriesHeadersAddedAfterStart() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + // answered in process, so no TLS challenge ever looks up the pinned certificate file, which does not exist + TestURLProtocol.reset() + let cfg = TestUtils.createBaseConfig() + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [TestURLProtocol.self] + sessionConfig.httpAdditionalHeaders = ["X-At-Start": "start_value"] + cfg.urlSessionConfiguration = sessionConfig + cfg.manualSessionHandling = true + cfg.pinnedCertificates = ["pinned.cer"] + Countly.sharedInstance().start(with: cfg) + + let connectionManager = try XCTUnwrap(CountlyConnectionManager.sharedInstance()) + let sessionBeforeHeaders = CountlyCommon.sharedInstance().immediateURLSession() + Countly.sharedInstance().addCustomNetworkRequestHeaders(["Authorization": "Bearer 123"]) + + let downloaded = XCTestExpectation(description: "the remote config download") + Countly.sharedInstance().remoteConfig().downloadKeys { _, _, _, _ in downloaded.fulfill() } + wait(for: [downloaded], timeout: TestUtils.asyncTimeout) + + let headers = try XCTUnwrap(TestURLProtocol.capturedHeaders(forRequestContaining: "method=rc"), "the download must be intercepted") + XCTAssertEqual("Bearer 123", headers["Authorization"], "a header added after start must reach an immediate request") + XCTAssertEqual("start_value", headers["X-At-Start"], "the header given at start must still be sent") + + let sessionAfterHeaders = CountlyCommon.sharedInstance().immediateURLSession() + XCTAssertFalse(sessionAfterHeaders === sessionBeforeHeaders, "the session kept before the header change must not be reused") + XCTAssertTrue(sessionAfterHeaders.delegate === connectionManager, "the rebuilt session must still answer the trust challenge") + XCTAssertTrue(CountlyCommon.sharedInstance().immediateURLSession() === sessionAfterHeaders, "the rebuilt session is kept until the next change") + } + + /// Starts with pinned certificates and an in-process mock, so the immediate session is the kept delegate session and + /// no TLS challenge ever looks up the pinned certificate file, which does not exist. Logging is off, since formatting + /// log lines would space out the calls a race needs to overlap. + private func startWithPinnedCertificatesAndNoLogging() { + MockURLProtocol.replyEmptyToEveryRequest() + let cfg = TestUtils.createBaseConfig() + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [MockURLProtocol.self] + cfg.urlSessionConfiguration = sessionConfig + cfg.manualSessionHandling = true + cfg.pinnedCertificates = ["pinned.cer"] + cfg.enableDebug = false + Countly.sharedInstance().start(with: cfg) + CountlyCommon.sharedInstance().decideLogGatheringOffIfUndecided("the test runs without logging") + } + +#if os(iOS) + /** + *
+     * With pinned certificates a header change drops the kept immediate session, possibly while a request is being built
+     * for it. A task created on a dropped session raises and ends the app, so the session is taken once the request is
+     * built.
+     *
+     * 1- Start with pinned certificates and hold the feedback widget list fetch while it builds its request
+     * 2- Change a header meanwhile, which drops the kept session, then let the fetch go on
+     * 3- Verify the fetch completes
+     * 
+ */ + func test_immediateURLSession_headerChangeWhileARequestIsBuiltDoesNotCrash() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + startWithPinnedCertificatesAndNoLogging() + _ = CountlyCommon.sharedInstance().immediateURLSession() + + let builder = try XCTUnwrap(class_getInstanceMethod(CountlyFeedbacksInternal.self, NSSelectorFromString("feedbacksRequest"))) + typealias BuildRequest = @convention(c) (AnyObject, Selector) -> AnyObject + let originalIMP = method_getImplementation(builder) + let buildRequest = unsafeBitCast(originalIMP, to: BuildRequest.self) + let building = DispatchSemaphore(value: 0) + let resume = DispatchSemaphore(value: 0) + let heldBuild: @convention(block) (AnyObject) -> AnyObject = { feedbacks in + building.signal() + _ = resume.wait(timeout: .now() + 10) + return buildRequest(feedbacks, NSSelectorFromString("feedbacksRequest")) + } + method_setImplementation(builder, imp_implementationWithBlock(heldBuild)) + defer { method_setImplementation(builder, originalIMP) } + + let fetched = XCTestExpectation(description: "the feedback widget list fetch") + DispatchQueue.global().async { + Countly.sharedInstance().feedback().getAvailableFeedbackWidgets { _, _ in fetched.fulfill() } + } + XCTAssertEqual(.success, building.wait(timeout: .now() + 5), "the fetch is building its request") + Countly.sharedInstance().addCustomNetworkRequestHeaders(["Authorization": "Bearer 123"]) + // a session refuses new tasks once its queue has processed the invalidation, not as soon as it is asked + TestUtils.sleep(0.5) {} + resume.signal() + + wait(for: [fetched], timeout: TestUtils.asyncTimeout) + } +#endif + + /** + *
+     * With pinned certificates a header change that lands while the kept immediate session is being created is not lost:
+     * the session kept afterwards carries the new header.
+     *
+     * 1- Start with pinned certificates
+     * 2- In each round drop the kept session, then create it on one thread while another changes a header
+     * 3- Verify the session kept after each round carries that round's header
+     * 
+ */ + func test_immediateURLSession_headerChangeRacingTheSessionCreationIsKept() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + startWithPinnedCertificatesAndNoLogging() + + var staleRounds: [Int] = [] + for round in 0..<1000 { + CountlyCommon.sharedInstance().invalidatePinnedImmediateURLSession() + DispatchQueue.concurrentPerform(iterations: 2) { worker in + if worker == 0 { + _ = CountlyCommon.sharedInstance().immediateURLSession() + } else { + Countly.sharedInstance().addCustomNetworkRequestHeaders(["X-Round": "\(round)"]) + } + } + let keptHeader = CountlyCommon.sharedInstance().immediateURLSession().configuration.httpAdditionalHeaders?["X-Round"] as? String + if keptHeader != "\(round)" { + staleRounds.append(round) + } + } + + XCTAssertEqual([], staleRounds, "the kept session must carry the header of its round") + } + + // 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.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() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + 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/ios/src/CountlyTests/CountlyTests-Bridging-Header.h b/ios/src/CountlyTests/CountlyTests-Bridging-Header.h index d744d0d9..45d92dd3 100644 --- a/ios/src/CountlyTests/CountlyTests-Bridging-Header.h +++ b/ios/src/CountlyTests/CountlyTests-Bridging-Header.h @@ -1 +1,9 @@ #import "CountlyCommon.h" +#import "CountlyConnectionManager.h" +#import "CountlyViewTrackingInternal.h" +#import "CountlyHealthTracker.h" +#import "CountlyContentBuilderInternal.h" +#import "CountlyContentBuilderInternal+Tests.h" +#import "CountlyWebViewManager.h" +#import "CountlyWebViewManager+Tests.h" +#import "PassThroughBackgroundView.h" diff --git a/ios/src/CountlyTests/CountlyTests.swift b/ios/src/CountlyTests/CountlyTests.swift index 54fc4168..f806938e 100644 --- a/ios/src/CountlyTests/CountlyTests.swift +++ b/ios/src/CountlyTests/CountlyTests.swift @@ -10,6 +10,39 @@ import XCTest @testable import Countly +/// A logger that, once armed, keeps the first thread other than the main thread that logs a given line waiting for a while. +private final class DelayingLogger: NSObject, CountlyLoggerDelegate { + private let text: String + private let delay: TimeInterval + private let lock = NSLock() + private var isArmed = false + + /// A logger that delays the line containing the text by the given seconds. + init(delaying text: String, by delay: TimeInterval) { + self.text = text + self.delay = delay + } + + /// Delays the next matching line. + func arm() { + lock.lock() + isArmed = true + lock.unlock() + } + + /// Waits on the first matching line logged off the main thread after arm. + func internalLog(_ log: String, with level: CLYInternalLogLevel) { + guard !Thread.isMainThread, log.contains(text) else { return } + lock.lock() + let shouldDelay = isArmed + isArmed = false + lock.unlock() + if shouldDelay { + Thread.sleep(forTimeInterval: delay) + } + } +} + class CountlyTests: CountlyBaseTestCase { // MARK: - Configuration Tests @@ -21,7 +54,7 @@ class CountlyTests: CountlyBaseTestCase { let sdkGeneratedDeviceID = Countly.sharedInstance().deviceID() XCTAssertTrue(CountlyCommon.sharedInstance().hasStarted, "Countly initialization failed.") - XCTAssertTrue(Countly.sharedInstance().deviceIDType() == CLYDeviceIDType.IDFV, "Countly deviced id type should be IDFV when no device id is provided during init.") + XCTAssertTrue(Countly.sharedInstance().deviceIDType() == TestPlatform.defaultDeviceIDType, "Countly deviced id type should be the platform default (IDFV on iOS/tvOS/visionOS, NSUUID on watchOS/macOS) when no device id is provided during init.") Countly.sharedInstance().halt(false) XCTAssertTrue(!CountlyCommon.sharedInstance().hasStarted, "Countly halt failed.") @@ -33,9 +66,488 @@ class CountlyTests: CountlyBaseTestCase { XCTAssertTrue(CountlyCommon.sharedInstance().hasStarted, "Countly initialization failed.") XCTAssertTrue(Countly.sharedInstance().deviceID() == sdkGeneratedDeviceID, "Countly device id not match with provided device id.") - XCTAssertTrue(Countly.sharedInstance().deviceIDType() == CLYDeviceIDType.IDFV, "Countly deviced id type should be custom when device id is provided during init.") + XCTAssertTrue(Countly.sharedInstance().deviceIDType() == TestPlatform.defaultDeviceIDType, "Countly deviced id type should stay the platform default when the stored id is reused.") } + /** + *
+     * Remote config values never outlive "halt": a download still in flight writes nothing into the storage halt cleared,
+     * and the next start in the same process does not serve the values the halted run held in memory.
+     *
+     * 1- Download a value, then start a second download and hold its reply
+     * 2- Halt with storage cleared and start again
+     * 3- Release the held reply, and verify it was not stored and neither value is served
+     * 
+ */ + func testRemoteConfig_afterHalt_noValueOfTheHaltedRunIsStoredOrServed() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + final class Counter { + private let lock = NSLock() + private var value = 0 + /// Counts one more call and returns the new count. + func next() -> Int { + lock.lock() + defer { lock.unlock() } + value += 1 + return value + } + } + let downloads = Counter() + let releaseSecondDownload = DispatchSemaphore(value: 0) + MockURLProtocol.requestHandler = { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil) + guard request.url?.absoluteString.contains("method=rc") == true else { + return ("{}".data(using: .utf8), response, nil) + } + if downloads.next() == 1 { + return (#"{"kept_key":"kept_value"}"#.data(using: .utf8), response, nil) + } + _ = releaseSecondDownload.wait(timeout: .now() + 5) + return (#"{"held_key":"held_value"}"#.data(using: .utf8), response, nil) + } + defer { MockURLProtocol.replyEmptyToEveryRequest() } + + let config = createBaseConfig() + config.manualSessionHandling = true + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [MockURLProtocol.self] + config.urlSessionConfiguration = sessionConfig + Countly.sharedInstance().start(with: config) + + let downloaded = XCTestExpectation(description: "the first download") + Countly.sharedInstance().remoteConfig().downloadKeys { _, _, _, _ in downloaded.fulfill() } + wait(for: [downloaded], timeout: 10) + XCTAssertEqual("kept_value", Countly.sharedInstance().remoteConfig().getValue("kept_key").value as? String) + + Countly.sharedInstance().remoteConfig().downloadKeys { _, _, _, _ in } + Countly.sharedInstance().halt(true) + Countly.sharedInstance().start(with: config) + XCTAssertNil(Countly.sharedInstance().remoteConfig().getValue("kept_key").value, "the next start serves no value the halted run held") + + releaseSecondDownload.signal() + TestUtils.sleep(1) {} + XCTAssertNil(CountlyPersistency.sharedInstance().retrieveRemoteConfig()["held_key"], "a reply to a request made before halt stores nothing") + XCTAssertNil(Countly.sharedInstance().remoteConfig().getValue("held_key").value, "and is not served either") + } + + /// A configuration whose requests go to MockURLProtocol, with manual sessions. + private func mockedConfig() -> CountlyConfig { + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [MockURLProtocol.self] + let config = createBaseConfig() + config.manualSessionHandling = true + config.urlSessionConfiguration = sessionConfig + return config + } + + /** + *
+     * Remote config values downloaded for a user are not stored for the next one when the device ID changes while the
+     * download is in flight.
+     *
+     * 1- Start, and hold the answer of a value download
+     * 2- Change the device ID without merge while the download waits
+     * 3- Let the answer through and verify the next user gets none of its values and the download reports an error
+     * 
+ */ + func testRemoteConfig_answerForThePreviousDeviceID_isNotStoredForTheNextOne() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + let releaseDownload = DispatchSemaphore(value: 0) + MockURLProtocol.requestHandler = { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil) + guard request.url?.absoluteString.contains("method=rc") == true else { + return ("{}".data(using: .utf8), response, nil) + } + _ = releaseDownload.wait(timeout: .now() + 5) + return (#"{"previous_user_key":"previous_user_value"}"#.data(using: .utf8), response, nil) + } + defer { MockURLProtocol.replyEmptyToEveryRequest() } + let config = mockedConfig() + config.enableRemoteConfigAutomaticTriggers = false + Countly.sharedInstance().start(with: config) + + let downloaded = XCTestExpectation(description: "the download completes") + var downloadError: Error? + Countly.sharedInstance().remoteConfig().downloadKeys { _, error, _, _ in + downloadError = error + downloaded.fulfill() + } + Countly.sharedInstance().changeDeviceIDWithoutMerge("next_user") + releaseDownload.signal() + wait(for: [downloaded], timeout: 10) + + XCTAssertNotNil(downloadError, "the download reports that its answer is not for the current device ID") + XCTAssertNil(Countly.sharedInstance().remoteConfig().getValue("previous_user_key").value, + "the next user gets none of the previous user's values") + XCTAssertNil(CountlyPersistency.sharedInstance().retrieveRemoteConfig()["previous_user_key"], + "and none are stored") + } + + /** + *
+     * The A/B testing helpers report an error body as an error instead of crashing on it.
+     *
+     * 1- Start against a server that answers both A/B helper requests with an error body
+     * 2- Download the variants and the experiments
+     * 3- Verify both callbacks report an error
+     * 
+ */ + func testRemoteConfig_abTestingHelpers_reportAnErrorBodyAsAnError() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + MockURLProtocol.requestHandler = { request in + let url = request.url?.absoluteString ?? "" + if url.contains("ab_fetch_variants") || url.contains("ab_fetch_experiments") { + return (#"{"result":"Missing parameter"}"#.data(using: .utf8), HTTPURLResponse(url: request.url!, statusCode: 400, httpVersion: nil, headerFields: nil), nil) + } + return ("{}".data(using: .utf8), HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil), nil) + } + defer { MockURLProtocol.replyEmptyToEveryRequest() } + Countly.sharedInstance().start(with: mockedConfig()) + + let variants = XCTestExpectation(description: "the variants download completes") + let experiments = XCTestExpectation(description: "the experiments download completes") + var variantsError: Error? + var experimentsError: Error? + Countly.sharedInstance().remoteConfig().testingDownloadVariantInformation { _, error in + variantsError = error + variants.fulfill() + } + Countly.sharedInstance().remoteConfig().testingDownloadExperimentInformation { _, error in + experimentsError = error + experiments.fulfill() + } + wait(for: [variants, experiments], timeout: 10) + + XCTAssertNotNil(variantsError, "an error body is an error for the variants") + XCTAssertNotNil(experimentsError, "and for the experiments") + } + + /** + *
+     * One event with a value JSON can not represent, NaN for example, does not take the other events of its batch down:
+     * the value is left out and every event goes out.
+     *
+     * 1- Record an event, one with a NaN segmentation value and a NaN sum, and another event
+     * 2- Turn them into a request
+     * 3- Verify the request carries all three events, without the NaN values
+     * 
+ */ + func testEvents_aNaNValueDoesNotDropTheBatch() throws { + let config = createBaseConfig() + config.manualSessionHandling = true + Countly.sharedInstance().start(with: config) + + Countly.sharedInstance().recordEvent("event_before_nan") + Countly.sharedInstance().recordEvent("nan_event", segmentation: ["ratio": NSNumber(value: Double.nan), "kept": "value"], count: 1, sum: Double.nan) + Countly.sharedInstance().recordEvent("event_after_nan") + CountlyConnectionManager.sharedInstance().sendEvents() + + let request = try XCTUnwrap(TestUtils.getCurrentRQ()?.first { $0.contains("event_before_nan") }, "the batch is queued") + let events = try XCTUnwrap(TestUtils.parseQueryString(request)["events"] as? String) + let parsed = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(events.utf8)) as? [[String: Any]]) + XCTAssertEqual(["event_before_nan", "nan_event", "event_after_nan"], parsed.compactMap { $0["key"] as? String }, "every event of the batch goes out") + let nanEvent = try XCTUnwrap(parsed.first { $0["key"] as? String == "nan_event" }) + XCTAssertNil(nanEvent["sum"], "the NaN sum is left out") + XCTAssertEqual(["kept": "value"], nanEvent["segmentation"] as? [String: String], "and so is the NaN segmentation value") + } + + /** + *
+     * "halt" gives the configuration given at start its own values back. Behavior settings write their resolved values
+     * into it, and its internal limits are shared by every configuration, so without this the next start would take the
+     * halted run's behavior settings for the developer's values.
+     *
+     * 1- Start with developer supplied behavior settings that require consent and lower two values
+     * 2- Verify they applied, then halt
+     * 3- Verify the configuration, and a new one for the shared limits, hold the developer's values again
+     * 4- Start again without behavior settings and verify the developer's values apply
+     * 
+ */ + func testHalt_givesTheStartConfigurationItsOwnValuesBack() throws { + let config = createBaseConfig() + config.manualSessionHandling = true + config.requiresConsent = false + config.eventSendThreshold = 7 + config.sdkInternalLimits().setMaxKeyLength(64) + config.sdkBehaviorSettings = ServerConfigBuilder() + .consentRequired(true) + .logging(false) + .eventQueueSize(3) + .limitKeyLength(20) + .build() + Countly.sharedInstance().start(with: config) + + XCTAssertTrue(CountlyConsentManager.sharedInstance().requiresConsent, "the behavior settings require consent") + XCTAssertEqual(3, CountlyPersistency.sharedInstance().eventSendThreshold) + XCTAssertEqual(20, CountlyCommon.sharedInstance().maxKeyLength) + XCTAssertTrue(config.requiresConsent, "while the SDK runs, the resolved values live in the start configuration") + + Countly.sharedInstance().halt(true) + + XCTAssertFalse(config.requiresConsent, "halt gives back the developer's consent requirement") + XCTAssertTrue(config.enableDebug, "and the developer's logging") + XCTAssertEqual(7, config.eventSendThreshold, "and the developer's event threshold") + XCTAssertEqual(64, config.sdkInternalLimits().getMaxKeyLength(), "and the developer's key length limit") + XCTAssertEqual(64, CountlyConfig().sdkInternalLimits().getMaxKeyLength(), "which every configuration in the process shares") + + // the property is nonnull in Swift, so the provided settings are cleared through KVC + config.setValue(nil, forKey: "sdkBehaviorSettings") + Countly.sharedInstance().start(with: config) + + XCTAssertFalse(CountlyConsentManager.sharedInstance().requiresConsent, "without behavior settings the developer's consent requirement applies") + XCTAssertEqual(7, CountlyPersistency.sharedInstance().eventSendThreshold, "and the developer's event threshold") + XCTAssertEqual(64, CountlyCommon.sharedInstance().maxKeyLength, "and the developer's key length limit") + Countly.sharedInstance().halt(true) + } + + /** + *
+     * "halt" forgets the given consents without telling the server they were withdrawn, and "halt(false)" keeps every
+     * request queued before it for the next start, also the ones queued since the last save.
+     *
+     * 1- Start requiring consent with every consent given, against a server whose answers keep requests queued
+     * 2- Record an event and turn it into a request, then halt without clearing the storage
+     * 3- Start again and verify the event request is still queued and no consent request withdraws a consent
+     * 
+ */ + func testHalt_forgetsConsentsWithoutWithdrawingThemAndKeepsTheQueue() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + MockURLProtocol.requestHandler = { request in + (#"{"result":"fail"}"#.data(using: .utf8), HTTPURLResponse(url: request.url!, statusCode: 400, httpVersion: nil, headerFields: nil), nil) + } + defer { MockURLProtocol.replyEmptyToEveryRequest() } + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [MockURLProtocol.self] + + let config = createBaseConfig() + config.manualSessionHandling = true + config.requiresConsent = true + config.enableAllConsents = true + config.disableSDKBehaviorSettingsUpdates = true + config.urlSessionConfiguration = sessionConfig + Countly.sharedInstance().start(with: config) + Countly.sharedInstance().recordEvent("event_before_halt") + CountlyConnectionManager.sharedInstance().sendEvents() + Countly.sharedInstance().halt(false) + + let next = createBaseConfig() + next.manualSessionHandling = true + next.disableSDKBehaviorSettingsUpdates = true + next.urlSessionConfiguration = sessionConfig + Countly.sharedInstance().start(with: next) + let rq = TestUtils.getCurrentRQ() ?? [] + XCTAssertTrue(rq.contains { $0.contains("event_before_halt") }, "the request queued before halt is kept: \(rq)") + let consentStates = rq.compactMap { TestUtils.parseQueryString($0)["consent"] as? [String: Any] } + XCTAssertFalse(consentStates.isEmpty, "the consent state of the first start is kept too: \(rq)") + XCTAssertFalse(consentStates.contains { $0.values.contains { ($0 as? Bool) == false } }, "halt withdraws no consent: \(consentStates)") + Countly.sharedInstance().halt(true) + } + + /** + *
+     * "halt" resets the location state, so location info disabled before it does not hold for the next start.
+     *
+     * 1- Start and disable location info
+     * 2- Halt and start again with consent required and all consents given
+     * 3- Verify location info is enabled and no request carries the empty location sent for disabled location info
+     * 
+ */ + func testHalt_resetsTheLocationState() throws { + let config = createBaseConfig() + config.manualSessionHandling = true + Countly.sharedInstance().start(with: config) + Countly.sharedInstance().disableLocationInfo() + XCTAssertEqual(true, CountlyLocationManager.sharedInstance()?.isLocationInfoDisabled, "location info is disabled before halt") + + Countly.sharedInstance().halt(true) + let restart = createBaseConfig() + restart.requiresConsent = true + restart.enableAllConsents = true + Countly.sharedInstance().start(with: restart) + + XCTAssertEqual(false, CountlyLocationManager.sharedInstance()?.isLocationInfoDisabled, "halt must reset the location state") + let rq = TestUtils.getCurrentRQ() ?? [] + XCTAssertTrue(rq.first?.contains("begin_session=1") == true, "the session begin must be the first request, got: \(rq)") + for request in rq { + let location = TestUtils.parseQueryString(request)["location"] as? String + XCTAssertNotEqual("", location, "no request may carry the empty location of disabled location info: \(request)") + } + } + + /** + *
+     * "halt" gives back only what behavior settings wrote into the configuration. A value the app set itself before halt
+     * stays, on the configuration given at start and on a newer one, which shares its internal limits and content parts.
+     *
+     * 1- Start without the consent requirement and with behavior settings that lower the key length limit
+     * 2- Require consent on that configuration, and prepare a newer one with its own key length limit and content zone interval
+     * 3- Halt and verify the app's values stayed, then start with each configuration and verify the app's values apply
+     * 
+ */ + func testHalt_keepsTheValuesTheAppSetBeforeHalt() throws { +#if os(iOS) + // the content parts are shared by every configuration in the process, like the limits the base class resets + CountlyConfig().content().restoreZoneTimerInterval(0) + defer { CountlyConfig().content().restoreZoneTimerInterval(0) } +#endif + let config = createBaseConfig() + config.manualSessionHandling = true + config.requiresConsent = false + config.sdkBehaviorSettings = ServerConfigBuilder().limitKeyLength(20).build() + Countly.sharedInstance().start(with: config) + XCTAssertEqual(20, CountlyCommon.sharedInstance().maxKeyLength, "the behavior settings lower the key length limit") + + config.requiresConsent = true + let next = createBaseConfig() + next.manualSessionHandling = true + next.sdkInternalLimits().setMaxKeyLength(50) +#if os(iOS) + next.content().setZoneTimerInterval(60) +#endif + Countly.sharedInstance().halt(true) + + XCTAssertTrue(config.requiresConsent, "the consent requirement the app set before halt stays") + XCTAssertEqual(50, CountlyConfig().sdkInternalLimits().getMaxKeyLength(), "and so does the key length limit of the newer configuration") +#if os(iOS) + XCTAssertEqual(60, next.content().getZoneTimerInterval(), "and its content zone interval") +#endif + + // the property is nonnull in Swift, so the provided settings are cleared through KVC + config.setValue(nil, forKey: "sdkBehaviorSettings") + Countly.sharedInstance().start(with: config) + XCTAssertTrue(CountlyConsentManager.sharedInstance().requiresConsent, "the next start requires consent, as the app asked") + Countly.sharedInstance().halt(true) + + Countly.sharedInstance().start(with: next) + XCTAssertEqual(50, CountlyCommon.sharedInstance().maxKeyLength, "a start with the newer configuration applies its key length limit") +#if os(iOS) + XCTAssertEqual(60, CountlyContentBuilderInternal.sharedInstance().zoneTimerInterval, accuracy: 0.001, "and its content zone interval") +#endif + Countly.sharedInstance().halt(true) + } + +#if os(iOS) + /** + *
+     * The content zone interval that behavior settings gave does not outlive "halt", so a next start that applies no
+     * behavior settings polls content at the default interval.
+     *
+     * 1- Start with behavior settings that set the content zone interval
+     * 2- Halt with storage cleared and start again with behavior settings updates disabled, so none apply
+     * 3- Verify content is polled at the default interval again
+     * 
+ */ + func testHalt_contentZoneIntervalOfBehaviorSettingsDoesNotOutliveHalt() throws { + // the content parts are shared by every configuration in the process, so an interval an earlier test set is cleared + CountlyConfig().content().restoreZoneTimerInterval(0) + let config = createBaseConfig() + config.manualSessionHandling = true + config.sdkBehaviorSettings = ServerConfigBuilder().contentZoneInterval(60).build() + Countly.sharedInstance().start(with: config) + XCTAssertEqual(60, CountlyContentBuilderInternal.sharedInstance().zoneTimerInterval, accuracy: 0.001, "the behavior settings set the interval") + + Countly.sharedInstance().halt(true) + let restart = createBaseConfig() + restart.manualSessionHandling = true + restart.disableSDKBehaviorSettingsUpdates = true + Countly.sharedInstance().start(with: restart) + + XCTAssertEqual(30, CountlyContentBuilderInternal.sharedInstance().zoneTimerInterval, accuracy: 0.001, "without behavior settings the default interval applies") + } +#endif + + /** + *
+     * A header added at runtime belongs to that run. It is not written into the app's URL session configuration, so a
+     * next start with the same configuration, for the next user after a logout for example, does not send it.
+     *
+     * 1- Start with a URL session configuration and add an authorization header at runtime
+     * 2- Verify the URL session configuration the start configuration holds was left as the app set it
+     * 3- Halt with storage cleared, start again with the same configuration, and verify an immediate request goes without the header
+     * 
+ */ + func testHalt_headersAddedAtRuntimeAreNotSentByTheNextStart() throws { + try TestPlatform.skipUnlessHTTPInterceptable() + TestURLProtocol.reset() + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [TestURLProtocol.self] + let config = createBaseConfig() + config.manualSessionHandling = true + config.urlSessionConfiguration = sessionConfig + Countly.sharedInstance().start(with: config) + + Countly.sharedInstance().addCustomNetworkRequestHeaders(["Authorization": "Bearer user-a"]) + XCTAssertEqual("Bearer user-a", CountlyConnectionManager.sharedInstance().urlSessionConfiguration?.httpAdditionalHeaders?["Authorization"] as? String, "the header applies to this run") + XCTAssertNil(config.urlSessionConfiguration.httpAdditionalHeaders?["Authorization"], "the URL session configuration of the start configuration is left as the app set it") + + Countly.sharedInstance().halt(true) + TestURLProtocol.reset() + Countly.sharedInstance().start(with: config) + let downloaded = XCTestExpectation(description: "the remote config download") + Countly.sharedInstance().remoteConfig().downloadKeys { _, _, _, _ in downloaded.fulfill() } + wait(for: [downloaded], timeout: TestUtils.asyncTimeout) + + let headers = try XCTUnwrap(TestURLProtocol.capturedHeaders(forRequestContaining: "method=rc"), "the download must be intercepted") + XCTAssertNil(headers["Authorization"], "the next start must not send the previous run's header") + } + + /** + *
+     * "halt" with storage cleared removes only the SDK's own values from the standard user defaults: the current ones and
+     * those older SDK versions kept, which an app updated from such a version still has. The app's own values stay, and
+     * so does the PLCrashReporter suppression marker, so a crash between halt and the next start is still discarded.
+     * 
+ */ + func testHalt_clearingStorageRemovesOnlyTheSDKsOwnUserDefaults() throws { + let appKey = "ly.count.tests.appOwnValue" + let markerKey = "kCountlyPLCrashCaptureSuppressedSinceKey" + let olderVersionKeys = ["kCountlyRemoteConfigPersistencyKey", "kCountlyRemoteConfigPersistencyKeyMeta", "kCountlyTVOSNSUDKey"] + UserDefaults.standard.set("app value", forKey: appKey) + UserDefaults.standard.set(Date(), forKey: markerKey) + olderVersionKeys.forEach { UserDefaults.standard.set(Data("stored by an older version".utf8), forKey: $0) } + defer { ([appKey, markerKey] + olderVersionKeys).forEach { UserDefaults.standard.removeObject(forKey: $0) } } + + let config = createBaseConfig() + config.manualSessionHandling = true + Countly.sharedInstance().start(with: config) + XCTAssertNotNil(UserDefaults.standard.object(forKey: "kCountlyStoredDeviceIDKey"), "the SDK stores its device ID in the standard user defaults") + + Countly.sharedInstance().halt(true) + + XCTAssertEqual("app value", UserDefaults.standard.string(forKey: appKey), "the app's own value must survive halt") + XCTAssertNotNil(UserDefaults.standard.object(forKey: markerKey), "the PLCrashReporter suppression marker must survive halt") + XCTAssertNil(UserDefaults.standard.object(forKey: "kCountlyStoredDeviceIDKey"), "the SDK's own values must be removed") + for key in olderVersionKeys { + XCTAssertNil(UserDefaults.standard.object(forKey: key), "[\(key)] was stored by an older SDK version, so halt must remove it too") + } + } + + /** + *
+     * "halt" finishes resetting the health tracker before it returns. The reset runs on the tracker's own queue, and one
+     * still running while halt drops the shared state uses objects halt has already freed.
+     *
+     * 1- Start and count a warning in the health tracker
+     * 2- Halt while a logger holds the tracker's reset for a moment
+     * 3- Verify the count is cleared by the time halt returns
+     * 
+ */ + func testHalt_waitsForTheHealthTrackerReset() throws { + let logger = DelayingLogger(delaying: "clearing counters", by: 0.5) + let config = createBaseConfig() + config.manualSessionHandling = true + config.loggerDelegate = logger + Countly.sharedInstance().start(with: config) + let tracker = try XCTUnwrap(CountlyHealthTracker.sharedInstance()) + tracker.logWarning() + TestUtils.waitUntil("the warning is counted") { (tracker.value(forKey: "countLogWarning") as? Int ?? 0) > 0 } + + logger.arm() + Countly.sharedInstance().halt(true) + + XCTAssertEqual(0, tracker.value(forKey: "countLogWarning") as? Int, "the health tracker is reset by the time halt returns") + } + func testPerformanceExample() async throws { // This is an example of a performance test case. measure { diff --git a/ios/src/CountlyTests/CountlyThemeTests.swift b/ios/src/CountlyTests/CountlyThemeTests.swift new file mode 100644 index 00000000..324a4e9b --- /dev/null +++ b/ios/src/CountlyTests/CountlyThemeTests.swift @@ -0,0 +1,116 @@ +// +// CountlyThemeTests.swift +// CountlyTests +// +// Covers the theme ("th") reporting added to the URLs loaded into the WebView for feedback +// widgets, rating widgets, and content. The logic lives in CountlyDeviceInfo: +// - themeMode: resolves the app's current interface style to "d"/"l"/nil. +// - URLString(_:byAppendingThemeMode:): the pure separator/omission primitive. +// - URLStringByAppendingThemeMode(_:): the convenience that resolves themeMode and appends. +// +// The primitive is tested deterministically (theme passed explicitly). themeMode itself is +// environment-dependent, so it is tested for invariants plus, when a key window is available, +// driven deterministically through overrideUserInterfaceStyle. +// + +import XCTest +@testable import Countly + +#if os(iOS) +class CountlyThemeTests: CountlyBaseTestCase { + + // MARK: - URLString(_:byAppendingThemeMode:) — deterministic primitive + + /// The separator depends on whether the URL already has a query, and the resolved theme value + /// is echoed verbatim. Covers dark/light across both separator cases in one flow. + func test_appendThemeMode_appliesCorrectSeparatorAndValue() { + // no existing query -> "?" + XCTAssertEqual( + CountlyDeviceInfo.urlString("https://c.ly/feedback/nps", byAppendingThemeMode: "d"), + "https://c.ly/feedback/nps?th=d") + // existing query -> "&" + XCTAssertEqual( + CountlyDeviceInfo.urlString("https://c.ly/feedback/nps?widget_id=abc&app_key=k", byAppendingThemeMode: "d"), + "https://c.ly/feedback/nps?widget_id=abc&app_key=k&th=d") + // light value is echoed just the same + XCTAssertEqual( + CountlyDeviceInfo.urlString("https://c.ly/o/feedback/widget?a=1", byAppendingThemeMode: "l"), + "https://c.ly/o/feedback/widget?a=1&th=l") + } + + /// When the theme can not be resolved (nil) or is empty, the URL must be returned untouched - + /// no dangling "?th=" or "&th=". + func test_appendThemeMode_omittedWhenThemeUndefined() { + let withQuery = "https://c.ly/content?cid=1" + let withoutQuery = "https://c.ly/content" + + XCTAssertEqual(CountlyDeviceInfo.urlString(withQuery, byAppendingThemeMode: nil), withQuery) + XCTAssertEqual(CountlyDeviceInfo.urlString(withoutQuery, byAppendingThemeMode: nil), withoutQuery) + XCTAssertEqual(CountlyDeviceInfo.urlString(withQuery, byAppendingThemeMode: ""), withQuery) + } + + // MARK: - themeMode — invariants that hold in any test host + + /// themeMode must only ever return "d", "l", or nil, and the convenience appender must be + /// exactly equivalent to feeding themeMode into the primitive. + func test_themeMode_isConstrainedAndConvenienceIsConsistent() { + let mode = CountlyDeviceInfo.themeMode() + if let mode = mode { + XCTAssertTrue(mode == "d" || mode == "l", "themeMode must be d, l, or nil; got \(mode)") + } + + let url = "https://c.ly/feedback/rating?widget_id=abc" + XCTAssertEqual( + CountlyDeviceInfo.urlStringByAppendingThemeMode(url), + CountlyDeviceInfo.urlString(url, byAppendingThemeMode: mode), + "the convenience method must resolve themeMode and delegate to the primitive") + } + + // MARK: - themeMode — deterministic via window override (hosted test only) + + /// Forcing the key window's interface style must flip themeMode and, through it, the appended + /// parameter. Skipped when the test host has no key window (logic-test bundle). + func test_themeMode_reflectsWindowInterfaceStyleOverride() throws { + guard #available(iOS 13.0, *), let window = Self.firstKeyWindow() else { + throw XCTSkip("requires iOS 13+ and a hosted key window") + } + + let original = window.overrideUserInterfaceStyle + defer { + window.overrideUserInterfaceStyle = original + Self.settle() + } + + window.overrideUserInterfaceStyle = .dark + Self.settle() + XCTAssertEqual(CountlyDeviceInfo.themeMode(), "d") + XCTAssertEqual( + CountlyDeviceInfo.urlStringByAppendingThemeMode("https://c.ly/feedback/nps?widget_id=x"), + "https://c.ly/feedback/nps?widget_id=x&th=d") + + window.overrideUserInterfaceStyle = .light + Self.settle() + XCTAssertEqual(CountlyDeviceInfo.themeMode(), "l") + XCTAssertEqual( + CountlyDeviceInfo.urlStringByAppendingThemeMode("https://c.ly/content"), + "https://c.ly/content?th=l") + } + + // MARK: - helpers + + @available(iOS 13.0, *) + private static func firstKeyWindow() -> UIWindow? { + for scene in UIApplication.shared.connectedScenes { + if let windowScene = scene as? UIWindowScene { + return windowScene.windows.first + } + } + return nil + } + + /// Let UIKit propagate the trait-collection change before reading it back. + private static func settle() { + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } +} +#endif diff --git a/ios/src/CountlyTests/CountlyUserProfileTests.swift b/ios/src/CountlyTests/CountlyUserProfileTests.swift index c782c804..2c6539d6 100644 --- a/ios/src/CountlyTests/CountlyUserProfileTests.swift +++ b/ios/src/CountlyTests/CountlyUserProfileTests.swift @@ -25,6 +25,7 @@ class CountlyUserProfileTests: CountlyBaseTestCase { super.setUp() // Initialize or reset necessary objects here Countly.sharedInstance().halt(true) + // The shared SDK limits are restored by CountlyBaseTestCase.setUpWithError. } override func tearDown() { @@ -78,7 +79,12 @@ class CountlyUserProfileTests: CountlyBaseTestCase { Countly.sharedInstance().start(with: config); sendUserProperty() setUserData() - XCTAssertEqual(2, CountlyPersistency.sharedInstance().remainingRequestCount()) // consents, location + // consents, location. Both are enqueued asynchronously, so poll rather than assuming + // they have landed by the time this line runs. + TestUtils.waitUntil("the consent and location requests to be queued") { + CountlyPersistency.sharedInstance().remainingRequestCount() == 2 + } + XCTAssertEqual(2, CountlyPersistency.sharedInstance().remainingRequestCount()) } func test_203_CNR_A() { @@ -321,13 +327,42 @@ class CountlyUserProfileTests: CountlyBaseTestCase { validateCustomUserDetails(request: queuedRequests[0], propertiesToCheck: getUserDataMap()) } + func test_eventSaveScenario_sessionCallsTriggersSave_M() { + let config = createBaseConfig() + config.manualSessionHandling = true; + config.enableOrientationTracking = false; + Countly.sharedInstance().start(with: config); + + Countly.user().set("beforeBeginSession", value: "1"); + Countly.sharedInstance().beginSession() + + Countly.user().set("beforeUpdateSession", value: "1"); + Countly.sharedInstance().updateSession() + + Countly.user().set("beforeEndSession", value: "1"); + Countly.sharedInstance().endSession() + + XCTAssertEqual(6, CountlyPersistency.sharedInstance().remainingRequestCount()) + guard let queuedRequests = CountlyPersistency.sharedInstance().value(forKey: "queuedRequests") as? [String] else { + fatalError("Failed to get queuedRequests from CountlyPersistency") + } + validateCustomUserDetails(request: queuedRequests[0], propertiesToCheck: ["beforeBeginSession": "1"]) + XCTAssertTrue(queuedRequests[1].contains("begin_session=1"), "Begin session failed.") + validateCustomUserDetails(request: queuedRequests[2], propertiesToCheck: ["beforeUpdateSession": "1"]) + XCTAssertTrue(queuedRequests[3].contains("session_duration=0"), "Update session failed.") + validateCustomUserDetails(request: queuedRequests[4], propertiesToCheck: ["beforeEndSession": "1"]) + XCTAssertTrue(queuedRequests[5].contains("end_session=1"), "End session failed.") + } + func validateEvents(request: String, keysToCheck: [String]) { let parsedRequest = TestUtils.parseQueryString(request) let events = parsedRequest["events"]; XCTAssertNotNil(events, "events are nil"); if((events) != nil) { - guard let jsonData = (events as! String).data(using: .utf8) else { - fatalError("Failed to convert JSON string to Data") + guard let eventsString = events as? String, + let jsonData = eventsString.data(using: .utf8) else { + XCTFail("Failed to convert events JSON string to Data") + return } do { // Decode JSON data into an array of CountlyEventStruct @@ -367,8 +402,9 @@ class CountlyUserProfileTests: CountlyBaseTestCase { let userDetails = parsedRequest["user_details"]; XCTAssertNotNil(userDetails, "user details are nil"); if((userDetails) != nil) { - guard let customUserDetails = (userDetails as! [String: Any])["custom"] else { - fatalError("Failed to get custom user details") + guard let customUserDetails = (userDetails as? [String: Any])?["custom"] else { + XCTFail("Failed to get custom user details") + return } do { // Decode JSON data into an array of CountlyEventStruct @@ -413,10 +449,11 @@ class CountlyUserProfileTests: CountlyBaseTestCase { func validateUserDetails(request: String) { let parsedRequest = TestUtils.parseQueryString(request) - let userDetails = parsedRequest["user_details"]; - XCTAssertNotNil(userDetails, "user details is nil"); - let userDetailsMap = userDetails as! [String: Any] - if((userDetails) != nil) { + guard let userDetailsMap = parsedRequest["user_details"] as? [String: Any] else { + XCTFail("user details is nil or not a dictionary, request: \(request)") + return + } + if true { XCTAssertNotNil(userDetailsMap["byear"], "byear should not be nil") XCTAssertNotNil(userDetailsMap["email"], "email should not be nil") XCTAssertNotNil(userDetailsMap["gender"], "gender should not be nil") @@ -469,6 +506,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], @@ -488,7 +527,549 @@ 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") + } + + #if os(iOS) || os(visionOS) + /** + *
+     * Setting the local picture path to NSNull clears the picture on the server, as its documentation says, instead of
+     * queueing a request whose null path crashed every send, and such a request already in the queue goes out as plain
+     * user details.
+     *
+     * 1- Start and set the local picture path to NSNull, then save
+     * 2- Verify the user details clear the picture with the empty string and no local picture request is queued
+     * 3- Verify a queued request with a null local picture path builds no picture upload
+     * 
+ */ + func test_pictureLocalPathSetToNull_clearsThePictureWithoutAPictureRequest() throws { + let config = createBaseConfig() + config.manualSessionHandling = true + Countly.sharedInstance().start(with: config) + + Countly.user().pictureLocalPath = NSNull() + Countly.user().save() + + let queue = TestUtils.getCurrentRQ() ?? [] + let userDetails = queue.compactMap { TestUtils.parseQueryString($0)["user_details"] as? [String: Any] } + XCTAssertTrue(userDetails.contains { $0["picture"] as? String == "" }, "the picture is cleared with the empty string: \(queue)") + XCTAssertFalse(queue.contains { $0.contains("kCountlyLocalPicturePath") }, "no local picture request is queued") + + let legacyRequest = "app_key=key&device_id=id&user_details=" + "{\"kCountlyLocalPicturePath\":null}".addingPercentEncoding(withAllowedCharacters: .alphanumerics)! + let upload = CountlyConnectionManager.sharedInstance().perform(NSSelectorFromString("pictureUploadDataForQueryString:"), with: legacyRequest) + XCTAssertNil(upload, "a queued request with a null path builds no picture upload") + } + #endif } diff --git a/ios/src/CountlyTests/CountlyViewTests.swift b/ios/src/CountlyTests/CountlyViewTests.swift index 6569fd76..a3f95592 100644 --- a/ios/src/CountlyTests/CountlyViewTests.swift +++ b/ios/src/CountlyTests/CountlyViewTests.swift @@ -84,7 +84,7 @@ class CountlyViewTrackingTests: CountlyViewBaseTest { // Call validateRecordedEvents to check if the events match expectations validateRecordedViews(startedEventsCount: startedEventsCount, endedEventsDurations: endedEventsDurations) - validateRecordedEventSegmentations(forEventID: viewID ?? "", expectedSegmentations: ["name": "View1", "visit": 1, "key": "value", "segment": "iOS"]) + validateRecordedEventSegmentations(forEventID: viewID ?? "", expectedSegmentations: ["name": "View1", "visit": 1, "key": "value", "segment": CountlyDeviceInfo.osName()!]) } func testStartViewAndStopViewWithID() throws { @@ -260,8 +260,13 @@ class CountlyViewTrackingTests: CountlyViewBaseTest { stopExpectation.fulfill() } - Countly.sharedInstance().views().startView("View1") - Countly.sharedInstance().views().stopView(withName: "View1") + // A second, immediately stopped view with the SAME name. It has to be stopped by ID: + // `stopView(withName:)` resolves a name by enumerating an NSMutableDictionary and taking + // the first match, so with two running views called "View1" which one it stops is + // undefined. That is what made this test pass locally and fail on CI, where the name + // lookup killed the view the scheduled pause/resume calls below reference. + let secondViewID = Countly.sharedInstance().views().startView("View1") + Countly.sharedInstance().views().stopView(withID: secondViewID) // Wait for all expectations to be fulfilled wait(for: [pauseExpectation, resumeExpectation,pauseExpectation1, resumeExpectation1, stopExpectation], timeout: 35.0) @@ -275,6 +280,9 @@ class CountlyViewTrackingTests: CountlyViewBaseTest { validateRecordedViews(startedEventsCount: startedEventsCount, endedEventsDurations: endedEventsDurations) } + // Automatic view tracking is only implemented for iOS and tvOS; on visionOS the public API + // is a logged no-op, so manual view recording is not suppressed there. + #if os(iOS) || os(tvOS) func testStartViewWhileAutoViewTrackingEnabled() throws { let config = createBaseConfig() config.enableAutomaticViewTracking = true // Enable auto view tracking @@ -294,7 +302,77 @@ class CountlyViewTrackingTests: CountlyViewBaseTest { // Call validateRecordedEvents to check if the events match expectations validateRecordedViews(startedEventsCount: startedEventsCount, endedEventsDurations: endedEventsDurations) } - + + /// Each screen automatic view tracking records ends the one before it, as automatic views do on Android, so every + /// screen reports its own duration instead of staying open. + func testAutomaticViewsEndThePreviousScreen() throws { + let config = createBaseConfig() + config.enableAutomaticViewTracking = true + Countly.sharedInstance().start(with: config) + + let first = UIViewController() + first.title = "FirstScreen" + let second = UIViewController() + second.title = "SecondScreen" + let appear = NSSelectorFromString("performAutoViewTrackingForViewController:") + + CountlyViewTrackingInternal.sharedInstance().perform(appear, with: first) + TestUtils.sleep(2) {} + CountlyViewTrackingInternal.sharedInstance().perform(appear, with: second) + + let openViews = CountlyViewTrackingInternal.sharedInstance().value(forKey: "viewDataDictionary") as? [String: Any] + XCTAssertEqual(1, openViews?.count, "only the screen on top stays open") + validateRecordedViews(startedEventsCount: ["FirstScreen": 1, "SecondScreen": 1], endedEventsDurations: ["FirstScreen": [2]]) + } + + /// The screen already on top when automatic view tracking starts is ended by the next screen too, instead of staying + /// open next to every later one until the app goes to the background. + func testAutomaticViewTrackingEndsTheScreenOnTopAtStart() throws { + let top = UIViewController() + top.title = "TopAtStart" + let topViewController = try XCTUnwrap(class_getInstanceMethod(CountlyCommon.self, NSSelectorFromString("topViewController"))) + let topBlock: @convention(block) (AnyObject) -> UIViewController? = { _ in top } + let originalTopViewController = method_setImplementation(topViewController, imp_implementationWithBlock(topBlock)) + defer { method_setImplementation(topViewController, originalTopViewController) } + + let config = createBaseConfig() + config.enableAutomaticViewTracking = true + Countly.sharedInstance().start(with: config) + + let next = UIViewController() + next.title = "NextScreen" + TestUtils.sleep(2) {} + CountlyViewTrackingInternal.sharedInstance().perform(NSSelectorFromString("performAutoViewTrackingForViewController:"), with: next) + + let openViews = CountlyViewTrackingInternal.sharedInstance().value(forKey: "viewDataDictionary") as? [String: Any] + XCTAssertEqual(1, openViews?.count, "only the screen on top stays open") + validateRecordedViews(startedEventsCount: ["TopAtStart": 1, "NextScreen": 1], endedEventsDurations: ["TopAtStart": [2]]) + } + + #endif + + #if os(iOS) + /// Content and feedback widgets appear in the SDK's own web view controller, which is not a screen of the app, so it + /// neither records a view nor ends the view of the screen under it. + func testAutomaticViewTrackingIgnoresTheSDKsWebViewController() throws { + let config = createBaseConfig() + config.enableAutomaticViewTracking = true + Countly.sharedInstance().start(with: config) + + let screen = UIViewController() + screen.title = "AppScreen" + let webViewControllerClass = try XCTUnwrap(NSClassFromString("CountlyWebViewController") as? UIViewController.Type) + let appear = NSSelectorFromString("performAutoViewTrackingForViewController:") + + CountlyViewTrackingInternal.sharedInstance().perform(appear, with: screen) + CountlyViewTrackingInternal.sharedInstance().perform(appear, with: webViewControllerClass.init()) + + let openViews = CountlyViewTrackingInternal.sharedInstance().value(forKey: "viewDataDictionary") as? [String: Any] + XCTAssertEqual(1, openViews?.count, "the app's screen stays the open view") + validateRecordedViews(startedEventsCount: ["AppScreen": 1, "CountlyWebViewController": 0], endedEventsDurations: [:]) + } + #endif + func testStartAndStopAutoStoppedViewWithSegmentation() throws { let config = createBaseConfig() Countly.sharedInstance().start(with: config) @@ -481,8 +559,8 @@ class CountlyViewTrackingTests: CountlyViewBaseTest { // Wait for all expectations to be fulfilled wait(for: [waitForStart, waitForSecondSegmentation, waitForStop], timeout: 12.0) - validateRecordedEventSegmentations(forEventID: viewID ?? "", expectedSegmentations: ["name": "View1", "visit": 1, "startKey": "startValue", "segment": "iOS"]) - validateRecordedEventSegmentations(forEventID: viewID ?? "", expectedSegmentations: ["name": "View1", "key1": "value1", "key2": "value2", "segment": "iOS"]) + validateRecordedEventSegmentations(forEventID: viewID ?? "", expectedSegmentations: ["name": "View1", "visit": 1, "startKey": "startValue", "segment": CountlyDeviceInfo.osName()!]) + validateRecordedEventSegmentations(forEventID: viewID ?? "", expectedSegmentations: ["name": "View1", "key1": "value1", "key2": "value2", "segment": CountlyDeviceInfo.osName()!]) } func testStartViewWithConsentNotGiven() throws { @@ -550,12 +628,16 @@ class CountlyViewTrackingTests: CountlyViewBaseTest { // Wait for all expectations to be fulfilled wait(for: [stopView1Expectation, startView2Expectation, stopView2Expectation], timeout: 12.0) - validateRecordedEventSegmentations(forEventID: viewID2, expectedSegmentations: ["visit": 1, "key": "value", "name": "View2", "segment": "iOS"]) - validateRecordedEventSegmentations(forEventID: viewID2, expectedSegmentations: ["key": "newValue", "name": "View2", "segment": "iOS"]) + validateRecordedEventSegmentations(forEventID: viewID2, expectedSegmentations: ["visit": 1, "key": "value", "name": "View2", "segment": CountlyDeviceInfo.osName()!]) + validateRecordedEventSegmentations(forEventID: viewID2, expectedSegmentations: ["key": "newValue", "name": "View2", "segment": CountlyDeviceInfo.osName()!]) } } +// The automatic background/foreground view lifecycle is driven by UIApplication +// notifications, which the SDK only observes on iOS, tvOS and visionOS. macOS and +// watchOS lifecycle behaviour is covered by CountlyPlatformLifecycleTests instead. +#if os(iOS) || os(tvOS) || os(visionOS) class CountlyViewForegroundBackgroundTests: CountlyViewBaseTest { func testStartMultipleViewsMoveAppToBackgroundAndReturnToForeground() throws { let config = createBaseConfig() @@ -701,9 +783,148 @@ class CountlyViewForegroundBackgroundTests: CountlyViewBaseTest { func testBackgroundAndForegroundTriggers() throws { let config = createBaseConfig() Countly.sharedInstance().start(with: config) + + Countly.sharedInstance().views().startView("View1") + + goBackgroundForeground() + + let startedQueuedEventsCount = ["View1": 1] + let endedQueuedEventsDurations = ["View1": [3]] + + // Call validateRecordedEvents to check if the events match expectations + validateQueuedViews(startedEventsCount: startedQueuedEventsCount, endedEventsDurations: endedQueuedEventsDurations) + + let startedEventsCount = ["View1": 1] + + let endedEventsDurations: [String: [Int]] = [:] + + // Call validateRecordedEvents to check if the events match expectations + validateRecordedViews(startedEventsCount: startedEventsCount, endedEventsDurations: endedEventsDurations) + } + + func testBackgroundAndForegroundTriggers_manualViewRestartDisabled() throws { + let config = createBaseConfig() + config.disableViewRestartForManualRecording = true + Countly.sharedInstance().start(with: config) + Countly.sharedInstance().views().startView("View1") + goBackgroundForeground() + + // Call validateRecordedEvents to check if the events match expectations + validateQueuedViews(startedEventsCount: ["View1": 1], endedEventsDurations: [:]) // no end view now + + // Call validateRecordedEvents to check if the events match expectations + validateRecordedViews(startedEventsCount: [:], endedEventsDurations: [:]) + } + + /// A view ID handed out before a background and foreground cycle still stops its view afterwards. + /// Without it the stop call is ignored, the reopened view stays open and reports another visit + /// and another duration on every later cycle. + func testStopViewWithIDHandedOutBeforeBackgroundForegroundCycle() throws { + let config = createBaseConfig() + Countly.sharedInstance().start(with: config) + + let viewID = Countly.sharedInstance().views().startView("View1") + + goBackgroundForeground() + + Countly.sharedInstance().views().stopView(withID: viewID) + + validateQueuedViews(startedEventsCount: ["View1": 1], endedEventsDurations: ["View1": [3]]) + + validateRecordedViews(startedEventsCount: ["View1": 1], endedEventsDurations: ["View1": [3]]) + + let eventCountAfterStop = getRecordedViews().count + Countly.sharedInstance().views().stopView(withID: viewID) + XCTAssertEqual(getRecordedViews().count, eventCountAfterStop, "Stopping an already stopped view ID should not record another event.") + } + + /// An auto stopped view running when the app went to the background keeps running after the views restart in the foreground. + func testAutoStoppedViewKeepsRunningWhenViewsRestartAfterForeground() throws { + let config = createBaseConfig() + Countly.sharedInstance().start(with: config) + + let regularViews = ["R1", "R2", "R3", "R4", "R5"] + regularViews.forEach { Countly.sharedInstance().views().startView($0) } + Countly.sharedInstance().views().startAutoStoppedView("A1") + + NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + NotificationCenter.default.post(name: UIApplication.didBecomeActiveNotification, object: nil) + + let restoredViews = getRecordedViews() + let restartedNames = restoredViews.filter { ($0.segmentation?["visit"] as? Int) == 1 }.compactMap { $0.segmentation?["name"] as? String } + XCTAssertEqual((regularViews + ["A1"]).sorted(), restartedNames.sorted(), "every view running before the background must start again") + let stoppedNames = restoredViews.filter { ($0.segmentation?["visit"] as? Int) != 1 }.compactMap { $0.segmentation?["name"] as? String } + XCTAssertEqual([], stoppedNames, "restoring the views must not stop any of them") + + Countly.sharedInstance().views().stopAllViews(nil) + let autoStoppedEnds = getRecordedViews().filter { ($0.segmentation?["name"] as? String) == "A1" && ($0.segmentation?["visit"] as? Int) != 1 } + XCTAssertEqual(1, autoStoppedEnds.count, "the auto stopped view must run until it is stopped") + } + + /// A view ID handed out before two background and foreground cycles still segments, pauses, resumes and + /// stops its view. It keeps a single entry pointing at the view's current ID, dropped once the view stops. + func testHeldViewIDResolvesAfterTwoBackgroundForegroundCycles() throws { + let config = createBaseConfig() + Countly.sharedInstance().start(with: config) + + let viewID = try XCTUnwrap(Countly.sharedInstance().views().startView("View1")) + + goBackgroundForeground() + goBackgroundForeground() + + let currentViewID = try XCTUnwrap(CountlyViewTrackingInternal.sharedInstance().currentViewID) + XCTAssertEqual(restartedViewIDs(), [viewID: currentViewID]) + + Countly.sharedInstance().views().addSegmentationToView(withID: viewID, segmentation: ["key": "value"]) + Countly.sharedInstance().views().pauseView(withID: viewID) + Countly.sharedInstance().views().resumeView(withID: viewID) + Countly.sharedInstance().views().stopView(withID: viewID) + + let endedViews = getRecordedViews().filter { ($0.segmentation?["visit"] as? Int) != 1 } + XCTAssertEqual(endedViews.compactMap { $0.segmentation?["key"] as? String }, ["value", "value"]) + XCTAssertEqual(openViewIDs(), []) + XCTAssertEqual(restartedViewIDs(), [:]) + } + + /// Each view ID handed out before a background and foreground cycle stops its own view afterwards, + /// including the ID of an auto stopped view, which is started again after the regular ones. + func testEachHeldViewIDStopsItsOwnViewAfterBackgroundForegroundCycle() throws { + let config = createBaseConfig() + Countly.sharedInstance().start(with: config) + + let regularViewID = try XCTUnwrap(Countly.sharedInstance().views().startView("R1")) + let autoStoppedViewID = try XCTUnwrap(Countly.sharedInstance().views().startAutoStoppedView("A1")) + + goBackgroundForeground() + + XCTAssertEqual(Set(restartedViewIDs().keys), [regularViewID, autoStoppedViewID]) + + Countly.sharedInstance().views().stopView(withID: autoStoppedViewID) + Countly.sharedInstance().views().stopView(withID: regularViewID) + + let endedViews = getRecordedViews().filter { ($0.segmentation?["visit"] as? Int) != 1 } + XCTAssertEqual(endedViews.compactMap { $0.segmentation?["name"] as? String }, ["A1", "R1"]) + XCTAssertEqual(openViewIDs(), []) + XCTAssertEqual(restartedViewIDs(), [:]) + } + + /// The view IDs handed out to the host whose views were started again, + /// mapped to the IDs those views are open under now. + private func restartedViewIDs() -> [String: String] { + CountlyViewTrackingInternal.sharedInstance().value(forKey: "restartedViewIDs") as? [String: String] ?? [:] + } + + /// The IDs of the views that are open now, sorted. + private func openViewIDs() -> [String] { + let openViews = CountlyViewTrackingInternal.sharedInstance() + .value(forKey: "viewDataDictionary") as? [String: Any] + return (openViews ?? [:]).keys.sorted() + } + + private func goBackgroundForeground() { // Create expectations for various events let waitForStart = XCTestExpectation(description: "Wait for 3 seconds before backgrounding app.") let waitForBackground = XCTestExpectation(description: "Wait for 4 seconds in background.") @@ -730,24 +951,68 @@ class CountlyViewForegroundBackgroundTests: CountlyViewBaseTest { // Wait for all expectations to be fulfilled wait(for: [waitForStart, waitForBackground, waitForForeground], timeout: 15.0) - - let startedQueuedEventsCount = ["View1": 1] - - let endedQueuedEventsDurations = ["View1": [3]] - - // Call validateRecordedEvents to check if the events match expectations - validateQueuedViews(startedEventsCount: startedQueuedEventsCount, endedEventsDurations: endedQueuedEventsDurations) - - let startedEventsCount = ["View1": 1] - - let endedEventsDurations: [String: [Int]] = [:] - - // Call validateRecordedEvents to check if the events match expectations - validateRecordedViews(startedEventsCount: startedEventsCount, endedEventsDurations: endedEventsDurations) } } +#endif + class CountlyViewBaseTest: CountlyBaseTestCase { + + /// These tests run for up to 20 seconds and then read the *event queue* to inspect the + /// views they recorded. `updateSessionPeriod` defaults to 20 s on watchOS (60 s elsewhere), + /// so the session timer fired mid-test and flushed the event queue into the request queue, + /// leaving nothing to assert on. Push it well past the longest test. + override func createBaseConfig() -> CountlyConfig { + let config = super.createBaseConfig() + config.updateSessionPeriod = 300 + return config + } + + /// How far a recorded view duration may sit from the expected whole-second value. + /// + /// These durations come from wall-clock timers driven by `DispatchQueue.asyncAfter`, so on a + /// loaded or slower machine (every CI runner) they drift by a fraction of a second. Matching + /// exact integers made these tests fail on CI while passing locally, and truncating with + /// `Int(event.duration)` made it worse: 3.98 s became 3, a whole second off an expected 4. + /// Half a second catches a segment a whole second off, while leaving room for the drift the test can not control. + static let durationTolerance = 0.5 + + /// Matches each expected duration against the nearest unmatched recorded one, so the + /// comparison is order-insensitive and tolerant of scheduler jitter. + func validateDurations( + _ actual: [String: [Double]], _ expected: [String: [Int]], + file: StaticString = #filePath, line: UInt = #line + ) { + for (key, expectedDurations) in expected { + var remaining = actual[key] ?? [] + XCTAssertEqual( + remaining.count, expectedDurations.count, + "Ended events count for key \(key) does not match expected count \(expectedDurations.count)", + file: file, line: line) + + for expectedDuration in expectedDurations { + let target = Double(expectedDuration) + guard + let closest = remaining.indices.min(by: { + abs(remaining[$0] - target) < abs(remaining[$1] - target) + }), + abs(remaining[closest] - target) <= Self.durationTolerance + else { + XCTFail( + "No recorded duration within \(Self.durationTolerance)s of \(expectedDuration) for key \(key). Unmatched: \(remaining)", + file: file, line: line) + continue + } + remaining.remove(at: closest) + } + + XCTAssertTrue( + remaining.isEmpty, + "Not all actual durations were matched for key \(key). Unmatched: \(remaining)", + file: file, line: line) + } + } + // Helper methods to validate results @@ -761,7 +1026,7 @@ class CountlyViewBaseTest: CountlyBaseTestCase { // Track occurrences for started and ended events var actualStartedEventsCount: [String: Int] = [:] - var actualEndedEventsDurations: [String: [Int]] = [:] + var actualEndedEventsDurations: [String: [Double]] = [:] // Iterate through recorded events to populate actual counts and durations for event in recordedEvents { @@ -773,7 +1038,7 @@ class CountlyViewBaseTest: CountlyBaseTestCase { actualStartedEventsCount[eventKey, default: 0] += 1 } else{ - actualEndedEventsDurations[eventKey, default: []].append(Int(event.duration)) + actualEndedEventsDurations[eventKey, default: []].append(event.duration) } } } @@ -785,30 +1050,7 @@ class CountlyViewBaseTest: CountlyBaseTestCase { XCTAssertEqual(actualCount, expectedCount, "Started events count for key \(key) does not match expected count \(expectedCount)") } - // Validate ended events durations - for (key, expectedDurations) in endedEventsDurations { - let actualDurations = actualEndedEventsDurations[key] ?? [] - - // First, ensure the counts match - XCTAssertEqual(actualDurations.count, expectedDurations.count, "Ended events count for key \(key) does not match expected count \(expectedDurations.count)") - - // Create a mutable copy of actualDurations to modify - var mutableActualDurations = actualDurations - - // Check each duration matches - for (index, expectedDuration) in expectedDurations.enumerated() { - // Check if the expected duration exists in the actual durations - XCTAssertTrue(mutableActualDurations.contains(expectedDuration), "Duration at index \(index) for key \(key) does not match expected duration \(expectedDuration)") - - // Remove the expectedDuration from mutableActualDurations - if let foundIndex = mutableActualDurations.firstIndex(of: expectedDuration) { - mutableActualDurations.remove(at: foundIndex) - } - } - - // Optionally, check if all expected durations have been matched - XCTAssertTrue(mutableActualDurations.isEmpty, "Not all actual durations were matched with expected durations for key \(key)") - } + validateDurations(actualEndedEventsDurations, endedEventsDurations) } @@ -822,7 +1064,7 @@ class CountlyViewBaseTest: CountlyBaseTestCase { // Initialize dictionaries to track actual counts and durations for verification var actualStartedEventsCount: [String: Int] = [:] - var actualEndedEventsDurations: [String: [Int]] = [:] + var actualEndedEventsDurations: [String: [Double]] = [:] // Loop through each event request to process events for request in eventRequests { @@ -847,7 +1089,7 @@ class CountlyViewBaseTest: CountlyBaseTestCase { } // Check for stop events with "dur" for duration else { - actualEndedEventsDurations[eventKey, default: []].append(Int(event.duration)) + actualEndedEventsDurations[eventKey, default: []].append(event.duration) } } } @@ -863,14 +1105,16 @@ class CountlyViewBaseTest: CountlyBaseTestCase { XCTAssertEqual(actualCount, expectedCount, "Started events count for key \(key) does not match expected count \(expectedCount)") } - // Validate ended events durations + // Validate ended events durations, in order. for (key, expectedDurations) in endedEventsDurations { let actualDurations = actualEndedEventsDurations[key] ?? [] XCTAssertEqual(actualDurations.count, expectedDurations.count, "Ended events count for key \(key) does not match expected count \(expectedDurations.count)") - - // Check each duration matches - for (index, expectedDuration) in expectedDurations.enumerated() { - XCTAssertEqual(actualDurations[index], expectedDuration, "Duration at index \(index) for key \(key) does not match expected duration \(expectedDuration)") + + for (index, expectedDuration) in expectedDurations.enumerated() where index < actualDurations.count { + XCTAssertEqual( + actualDurations[index], Double(expectedDuration), + accuracy: CountlyViewBaseTest.durationTolerance, + "Duration at index \(index) for key \(key) does not match expected duration \(expectedDuration)") } } } diff --git a/ios/src/CountlyTests/CountlyWebViewManager+Tests.h b/ios/src/CountlyTests/CountlyWebViewManager+Tests.h new file mode 100644 index 00000000..91d4320e --- /dev/null +++ b/ios/src/CountlyTests/CountlyWebViewManager+Tests.h @@ -0,0 +1,34 @@ +#import "CountlyWebViewManager.h" +#import "PassThroughBackgroundView.h" + +#if (TARGET_OS_IOS) +@interface CountlyWebViewManager (Tests) + +@property(nonatomic) BOOL webViewClosed; +@property(nonatomic) BOOL hasAppeared; +@property(nonatomic) NSInteger resourceRetryCount; +@property(nonatomic, copy) dispatch_block_t pendingReloadBlock; +@property(nonatomic, strong) NSTimer *loadTimeoutTimer; +@property(nonatomic, strong) NSTimer *contentShownDeadlineTimer; +@property(nonatomic, strong) NSDate *loadStartDate; +@property(nonatomic, copy) void (^dismissBlock)(void); +@property(nonatomic, copy) void (^appearBlock)(void); +@property(nonatomic, strong) PassThroughBackgroundView *backgroundView; + +- (NSDictionary *)parseQueryString:(NSString *)url; +- (void)notifyPageLoaded; +- (void)loadDidTimeout; +- (void)closeWebView; +- (void)retryOrCloseWebViewForReason:(NSString *)reason; +- (void)cancelPendingReload; +- (void)contentShownDeadlineReached; +- (void)recordEventsWithJSONString:(NSString *)jsonString; +- (void)resizeWebViewWithJSONString:(NSString *)jsonString; +- (BOOL)isFeedbackWidgetURL:(NSURL *)url; + +// Rotation, so tests can drive a size change without a real device rotation. +@property(nonatomic) BOOL isFeedbackWidget; +- (void)handleInterfaceSizeChange:(CGSize)newSize; + +@end +#endif diff --git a/ios/src/CountlyTests/CountlyWebViewManagerTests.swift b/ios/src/CountlyTests/CountlyWebViewManagerTests.swift new file mode 100644 index 00000000..256e0496 --- /dev/null +++ b/ios/src/CountlyTests/CountlyWebViewManagerTests.swift @@ -0,0 +1,1019 @@ +// +// CountlyWebViewManagerTests.swift +// CountlyTests +// +// Created on 13/03/2026. +// Copyright © 2026 Countly. All rights reserved. +// + +import XCTest +@testable import Countly + +#if os(iOS) +import WebKit + +/// Records how many times the SDK prompts the page with a `{type:'resize'}` message. +private final class ResizePromptSpy: PassThroughBackgroundView { + var promptCount = 0 + override func updateWindowSize() { promptCount += 1 } +} + +class CountlyWebViewManagerTests: XCTestCase { + + var manager: CountlyWebViewManager! + + override func setUp() { + super.setUp() + manager = CountlyWebViewManager() + // Retry-on-failure is opt-in; enable it so the retry-path tests exercise it. + CountlyContentBuilderInternal.sharedInstance().enableContentReloadOnStall = true + } + + override func tearDown() { + manager = nil + CountlyContentBuilderInternal.sharedInstance().enableContentReloadOnStall = false + super.tearDown() + } + + // MARK: - parseQueryString tests + + func testParseQueryString_basicParams() { + let url = "https://example.com?key1=value1&key2=value2" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["key1"] as? String, "value1") + XCTAssertEqual(result["key2"] as? String, "value2") + } + + func testParseQueryString_noParams() { + let url = "https://example.com" + let result = manager.parseQueryString(url)! + + XCTAssertTrue(result.isEmpty) + } + + func testParseQueryString_closeParam() { + let url = "https://countly_action_event?close=1&cly_x_action_event=1" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["close"] as? String, "1") + XCTAssertEqual(result["cly_x_action_event"] as? String, "1") + } + + func testParseQueryString_actionEvent() { + let url = "https://countly_action_event?action=event&event=%5B%7B%22key%22%3A%22test%22%7D%5D&cly_x_action_event=1" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["action"] as? String, "event") + XCTAssertEqual(result["cly_x_action_event"] as? String, "1") + } + + func testParseQueryString_resizeAction() { + let url = "https://countly_action_event?action=resize_me&resize_me=%7B%22p%22%3A%7B%22x%22%3A0%2C%22y%22%3A0%2C%22w%22%3A320%2C%22h%22%3A480%7D%7D&cly_x_action_event=1" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["action"] as? String, "resize_me") + } + + func testParseQueryString_emptyQueryString() { + let url = "https://example.com?" + let result = manager.parseQueryString(url)! + + XCTAssertTrue(result.isEmpty) + } + + // MARK: - link query-param preservation (backward-validating span) + + func testParseQueryString_linkWithSingleQueryParam_preserved() { + let url = "https://countly_action_event/?cly_x_action_event=1&action=link&link=https://example.com/path?foo=bar" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["link"] as? String, "https://example.com/path?foo=bar") + XCTAssertEqual(result["action"] as? String, "link") + } + + func testParseQueryString_linkWithMultipleQueryParams_preserved() { + let url = "https://countly_action_event/?cly_x_action_event=1&action=link&link=https://example.com/path?foo=bar&baz=qux&n=42" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["link"] as? String, "https://example.com/path?foo=bar&baz=qux&n=42") + XCTAssertNil(result["baz"]) + XCTAssertNil(result["n"]) + } + + func testParseQueryString_deeplinkWithQueryParams_preserved() { + let url = "https://countly_action_event/?cly_x_action_event=1&action=link&link=myapp://open?screen=home&id=42&ref=push" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["link"] as? String, "myapp://open?screen=home&id=42&ref=push") + } + + func testParseQueryString_linkWithoutQueryParams_preserved() { + let url = "https://countly_action_event/?cly_x_action_event=1&action=link&link=https://example.com/landing" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["link"] as? String, "https://example.com/landing") + } + + func testParseQueryString_eventAfterLink_separatedFromLink() { + let url = "https://countly_action_event/?cly_x_action_event=1&action=link&link=https://x.com/p?a=b&c=d&event=[{\"key\":\"e\",\"sg\":{\"x\":\"y\"}}]" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["link"] as? String, "https://x.com/p?a=b&c=d") + XCTAssertEqual(result["event"] as? String, "[{\"key\":\"e\",\"sg\":{\"x\":\"y\"}}]") + } + + func testParseQueryString_invalidReservedMarkerInLink_staysInLink() { + let url = "https://countly_action_event/?cly_x_action_event=1&action=link&link=https://x.com/p?a=b&event=notjson" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["link"] as? String, "https://x.com/p?a=b&event=notjson") + XCTAssertNil(result["event"]) + } + + func testParseQueryString_eventJsonContainingReservedText_parsedWhole() { + let url = "https://countly_action_event/?cly_x_action_event=1&action=event&event=[{\"key\":\"k\",\"sg\":{\"u\":\"a&close=1\"}}]" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["event"] as? String, "[{\"key\":\"k\",\"sg\":{\"u\":\"a&close=1\"}}]") + XCTAssertNil(result["close"]) + } + + func testParseQueryString_closeBeforeLink_separated() { + let url = "https://countly_action_event/?cly_x_action_event=1&action=link&close=1&link=https://example.com/path?foo=bar&baz=qux" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["link"] as? String, "https://example.com/path?foo=bar&baz=qux") + XCTAssertEqual(result["close"] as? String, "1") + } + + func testParseQueryString_linkWithTrailingClose_separated() { + let url = "https://countly_action_event/?cly_x_action_event=1&action=link&link=https://example.com/path?foo=bar&baz=qux&close=1" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["link"] as? String, "https://example.com/path?foo=bar&baz=qux") + XCTAssertEqual(result["close"] as? String, "1") + } + + func testParseQueryString_linkWithTrailingCloseZero_separated() { + let url = "https://countly_action_event/?cly_x_action_event=1&action=link&link=https://example.com/path?a=1&b=2&close=0" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["link"] as? String, "https://example.com/path?a=1&b=2") + XCTAssertEqual(result["close"] as? String, "0") + } + + func testParseQueryString_linkEndingInReservedClose_consumedAsFlag() { + let url = "https://countly_action_event/?cly_x_action_event=1&action=link&link=https://x.com?a=b&c=d&close=1&close=1" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["link"] as? String, "https://x.com?a=b&c=d") + XCTAssertEqual(result["close"] as? String, "1") + } + + func testParseQueryString_encodedEventValue_decodedAndAvailable() { + // Encoded on the wire: [{"key":"test_key","sg":{"color":"blue"}}] — parseQueryString decodes. + let url = "https://countly_action_event/?cly_x_action_event=1&action=event&event=%5B%7B%22key%22%3A%22test_key%22%2C%22sg%22%3A%7B%22color%22%3A%22blue%22%7D%7D%5D" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["action"] as? String, "event") + XCTAssertEqual(result["event"] as? String, "[{\"key\":\"test_key\",\"sg\":{\"color\":\"blue\"}}]") + } + + func testParseQueryString_linkWithFragment_preserved() { + let url = "https://countly_action_event/?cly_x_action_event=1&action=link&link=https://example.com/path?a=b#section" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["link"] as? String, "https://example.com/path?a=b#section") + } + + func testParseQueryString_linkWithRepeatedQuestionMark_preserved() { + // Regression guard: the old parser used componentsSeparatedByString:"?"[1] and dropped + // everything after the link's own "?". + let url = "https://countly_action_event/?cly_x_action_event=1&action=link&link=https://example.com/p?a=b?c=d" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["link"] as? String, "https://example.com/p?a=b?c=d") + } + + func testParseQueryString_linkEventAndClose_allSeparated() { + let url = "https://countly_action_event/?cly_x_action_event=1&action=link&link=https://x.com/p?a=b&c=d&event=[{\"key\":\"e\"}]&close=1" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["link"] as? String, "https://x.com/p?a=b&c=d") + XCTAssertEqual(result["close"] as? String, "1") + XCTAssertEqual(result["event"] as? String, "[{\"key\":\"e\"}]") + } + + func testParseQueryString_invalidCloseValue_staysInLink() { + let url = "https://countly_action_event/?cly_x_action_event=1&action=link&link=https://x.com/p?a=b&close=2" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["link"] as? String, "https://x.com/p?a=b&close=2") + XCTAssertNil(result["close"]) + } + + func testParseQueryString_schemelessLink_fallbackTruncates() { + // A schemeless link fails link validation (no URI scheme), so the query falls back to the + // plain '&' split, which truncates a multi-param link. The server always prepends "https://", + // so this is an edge case; the test pins the current behavior. + let url = "https://countly_action_event/?cly_x_action_event=1&action=link&link=example.com/p?a=b&c=d" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["link"] as? String, "example.com/p?a=b") + XCTAssertEqual(result["c"] as? String, "d") + } + + func testParseQueryString_linkWithPlus_preservesPlus() { + // Characterization: stringByRemovingPercentEncoding leaves a literal '+' untouched, so the + // link keeps its '+'. This differs from Android (URLDecoder decodes '+' to a space). + let url = "https://countly_action_event/?cly_x_action_event=1&action=link&link=https://x.com/search?q=a+b&lang=en" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["link"] as? String, "https://x.com/search?q=a+b&lang=en") + } + + // MARK: - notifyPageLoaded tests + + func testNotifyPageLoaded_callsAppearBlock() { + let expectation = expectation(description: "Appear block called") + + manager.webViewClosed = false + manager.hasAppeared = false + manager.appearBlock = { + expectation.fulfill() + } + + manager.notifyPageLoaded() + + waitForExpectations(timeout: TestUtils.asyncTimeout) + XCTAssertTrue(manager.hasAppeared) + } + + func testNotifyPageLoaded_skipsIfWebViewClosed() { + manager.webViewClosed = true + manager.hasAppeared = false + var blockCalled = false + manager.appearBlock = { + blockCalled = true + } + + manager.notifyPageLoaded() + + XCTAssertFalse(blockCalled) + XCTAssertFalse(manager.hasAppeared) + } + + func testNotifyPageLoaded_skipsIfAlreadyAppeared() { + manager.webViewClosed = false + manager.hasAppeared = true + var callCount = 0 + manager.appearBlock = { + callCount += 1 + } + + manager.notifyPageLoaded() + + XCTAssertEqual(callCount, 0) + } + + func testNotifyPageLoaded_invalidatesTimer() { + manager.webViewClosed = false + manager.hasAppeared = false + manager.loadTimeoutTimer = Timer.scheduledTimer(withTimeInterval: 60, repeats: false, block: { _ in }) + + XCTAssertNotNil(manager.loadTimeoutTimer) + + manager.notifyPageLoaded() + + XCTAssertNil(manager.loadTimeoutTimer) + } + + // MARK: - loadDidTimeout tests + + func testLoadDidTimeout_schedulesRetry() { + manager.webViewClosed = false + manager.hasAppeared = false + + manager.loadDidTimeout() + + // A stalled load now triggers a retry (reload) instead of an immediate close. + XCTAssertFalse(manager.webViewClosed) + XCTAssertEqual(manager.resourceRetryCount, 1) + XCTAssertNotNil(manager.pendingReloadBlock) + } + + func testLoadDidTimeout_closesAfterRetriesExhausted() { + manager.webViewClosed = false + manager.hasAppeared = false + manager.resourceRetryCount = 99 // retries already used up + + let bgView = PassThroughBackgroundView(frame: .zero) + bgView.webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) + manager.backgroundView = bgView + + let dismissExpectation = expectation(description: "Dismiss block called") + manager.dismissBlock = { dismissExpectation.fulfill() } + + manager.loadDidTimeout() + + waitForExpectations(timeout: TestUtils.asyncTimeout) + XCTAssertTrue(manager.webViewClosed) + } + + func testLoadDidTimeout_skipsIfAlreadyAppeared() { + manager.webViewClosed = false + manager.hasAppeared = true + var dismissCalled = false + manager.dismissBlock = { + dismissCalled = true + } + + manager.loadDidTimeout() + + // Give dispatch_async a chance to run + let exp = expectation(description: "wait") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + exp.fulfill() + } + waitForExpectations(timeout: TestUtils.asyncTimeout) + + XCTAssertFalse(dismissCalled) + XCTAssertFalse(manager.webViewClosed) + } + + func testLoadDidTimeout_skipsIfAlreadyClosed() { + manager.webViewClosed = true + manager.hasAppeared = false + var dismissCalled = false + manager.dismissBlock = { + dismissCalled = true + } + + manager.loadDidTimeout() + + let exp = expectation(description: "wait") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + exp.fulfill() + } + waitForExpectations(timeout: TestUtils.asyncTimeout) + + XCTAssertFalse(dismissCalled) + } + + // MARK: - webViewClosed guard tests + + func testWebViewClosedGuard_notifyPageLoadedIsIdempotent() { + manager.webViewClosed = false + manager.hasAppeared = false + var callCount = 0 + manager.appearBlock = { + callCount += 1 + } + + manager.notifyPageLoaded() + manager.notifyPageLoaded() + manager.notifyPageLoaded() + + XCTAssertEqual(callCount, 1) + XCTAssertTrue(manager.hasAppeared) + } + + // MARK: - WKScriptMessageHandler tests + + func testDidReceiveScriptMessage_resourceVerifyResult_allOK() { + manager.webViewClosed = false + manager.hasAppeared = false + + let expectation = expectation(description: "Appear block called") + manager.appearBlock = { + expectation.fulfill() + } + + let config = WKWebViewConfiguration() + let contentController = config.userContentController + contentController.add(manager, name: "resourceLoadError") + contentController.add(manager, name: "resourceVerifyResult") + + let webView = WKWebView(frame: .zero, configuration: config) + + let js = """ + window.webkit.messageHandlers.resourceVerifyResult.postMessage({ + results: [ + {tag: "SCRIPT", url: "https://example.com/app.js", status: 200}, + {tag: "LINK", url: "https://example.com/style.css", status: 200} + ] + }); + """ + webView.evaluateJavaScript(js, completionHandler: nil) + + waitForExpectations(timeout: TestUtils.asyncTimeout) + XCTAssertTrue(manager.hasAppeared) + + contentController.removeScriptMessageHandler(forName: "resourceLoadError") + contentController.removeScriptMessageHandler(forName: "resourceVerifyResult") + } + + func testDidReceiveScriptMessage_resourceVerifyResult_http500ClosesWebView() { + // The post-load HEAD verification path closes on a >=400 resource and must NOT + // retry (a reload would re-fire the page's on-load analytics). + manager.webViewClosed = false + manager.hasAppeared = false + manager.appearBlock = nil + + let config = WKWebViewConfiguration() + let contentController = config.userContentController + contentController.add(manager, name: "resourceLoadError") + contentController.add(manager, name: "resourceVerifyResult") + + let webView = WKWebView(frame: .zero, configuration: config) + let bgView = PassThroughBackgroundView(frame: .zero) + bgView.webView = webView + manager.backgroundView = bgView + + let dismissExpectation = expectation(description: "Dismiss block called") + manager.dismissBlock = { dismissExpectation.fulfill() } + + let js = """ + window.webkit.messageHandlers.resourceVerifyResult.postMessage({ + results: [ + {tag: "SCRIPT", url: "https://example.com/app.js", status: 200}, + {tag: "LINK", url: "https://example.com/style.css", status: 500} + ] + }); + """ + webView.evaluateJavaScript(js, completionHandler: nil) + + waitForExpectations(timeout: TestUtils.asyncTimeout) + XCTAssertTrue(manager.webViewClosed) + XCTAssertFalse(manager.hasAppeared) + XCTAssertEqual(manager.resourceRetryCount, 0) // verify path does not retry + + contentController.removeScriptMessageHandler(forName: "resourceLoadError") + contentController.removeScriptMessageHandler(forName: "resourceVerifyResult") + } + + func testDidReceiveScriptMessage_resourceVerifyResult_defersWhileRetryInProgress() { + // If a during-load retry (resourceLoadError path) is already scheduled, a post-load + // verification failure must defer to it, not close. A non-nil pendingReloadBlock marks + // a scheduled reload. + manager.webViewClosed = false + manager.hasAppeared = false + manager.pendingReloadBlock = { } + + let config = WKWebViewConfiguration() + let contentController = config.userContentController + contentController.add(manager, name: "resourceLoadError") + contentController.add(manager, name: "resourceVerifyResult") + + let webView = WKWebView(frame: .zero, configuration: config) + let bgView = PassThroughBackgroundView(frame: .zero) + bgView.webView = webView + manager.backgroundView = bgView + + var dismissCalled = false + manager.dismissBlock = { dismissCalled = true } + + let js = """ + window.webkit.messageHandlers.resourceVerifyResult.postMessage({ + results: [{tag: "SCRIPT", url: "https://example.com/missing.js", status: 404}] + }); + """ + webView.evaluateJavaScript(js, completionHandler: nil) + + let settle = expectation(description: "settle") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { settle.fulfill() } + waitForExpectations(timeout: TestUtils.asyncTimeout) + + XCTAssertFalse(manager.webViewClosed) // deferred to the in-flight retry + XCTAssertFalse(dismissCalled) + + contentController.removeScriptMessageHandler(forName: "resourceLoadError") + contentController.removeScriptMessageHandler(forName: "resourceVerifyResult") + } + + func testDidReceiveScriptMessage_resourceVerifyResult_emptyResultsShowsView() { + manager.webViewClosed = false + manager.hasAppeared = false + + let expectation = expectation(description: "Appear block called") + manager.appearBlock = { + expectation.fulfill() + } + + let config = WKWebViewConfiguration() + let contentController = config.userContentController + contentController.add(manager, name: "resourceLoadError") + contentController.add(manager, name: "resourceVerifyResult") + + let webView = WKWebView(frame: .zero, configuration: config) + + let js = """ + window.webkit.messageHandlers.resourceVerifyResult.postMessage({ + results: [] + }); + """ + webView.evaluateJavaScript(js, completionHandler: nil) + + waitForExpectations(timeout: TestUtils.asyncTimeout) + XCTAssertTrue(manager.hasAppeared) + + contentController.removeScriptMessageHandler(forName: "resourceLoadError") + contentController.removeScriptMessageHandler(forName: "resourceVerifyResult") + } + + func testDidReceiveScriptMessage_resourceVerifyResult_unreachableDefersInsteadOfAppearing() { + // A resource that is unreachable (HEAD status 0) is NOT verified-good: with reload-on-stall + // enabled (setUp enables it) the SDK must NOT appear here (which would cancel a pending + // reload); it defers so the reload/timeout can recover it. + manager.webViewClosed = false + manager.hasAppeared = false + + var appeared = false + manager.appearBlock = { appeared = true } + + let config = WKWebViewConfiguration() + let contentController = config.userContentController + contentController.add(manager, name: "resourceLoadError") + contentController.add(manager, name: "resourceVerifyResult") + + let webView = WKWebView(frame: .zero, configuration: config) + let bgView = PassThroughBackgroundView(frame: .zero) + bgView.webView = webView + manager.backgroundView = bgView + + let js = """ + window.webkit.messageHandlers.resourceVerifyResult.postMessage({ + results: [{tag: "SCRIPT", url: "https://example.com/vendor.js", status: 0}] + }); + """ + webView.evaluateJavaScript(js, completionHandler: nil) + + let settle = expectation(description: "settle") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { settle.fulfill() } + waitForExpectations(timeout: TestUtils.asyncTimeout) + + XCTAssertFalse(manager.hasAppeared) // deferred, did not appear + XCTAssertFalse(appeared) + XCTAssertFalse(manager.webViewClosed) // and did not close from the verify path + + contentController.removeScriptMessageHandler(forName: "resourceLoadError") + contentController.removeScriptMessageHandler(forName: "resourceVerifyResult") + } + + func testDidReceiveScriptMessage_resourceLoadError_schedulesRetry() { + manager.webViewClosed = false + manager.hasAppeared = false + + let config = WKWebViewConfiguration() + let contentController = config.userContentController + contentController.add(manager, name: "resourceLoadError") + contentController.add(manager, name: "resourceVerifyResult") + + let webView = WKWebView(frame: .zero, configuration: config) + + // Attach backgroundView with webView so a later reload is a clean no-op + let bgView = PassThroughBackgroundView(frame: .zero) + bgView.webView = webView + manager.backgroundView = bgView + + var dismissCalled = false + manager.dismissBlock = { dismissCalled = true } + + // Deliver the message from a genuinely loaded page: evaluateJavaScript on a web view + // that never loaded a document delivers the postMessage only intermittently (no stable + // JS context), which made this test flaky. An inline script in loaded HTML is reliable. + let html = """ + + """ + webView.loadHTMLString(html, baseURL: URL(string: "https://example.com")) + + // Wait on the retry count: pendingReloadBlock clears itself after 0.6s, before the predicate is polled. + let scheduled = XCTNSPredicateExpectation(predicate: NSPredicate(block: { [weak manager] _, _ in + (manager?.resourceRetryCount ?? 0) >= 1 + }), object: nil) + wait(for: [scheduled], timeout: TestUtils.asyncTimeout) + + XCTAssertFalse(manager.webViewClosed) + XCTAssertFalse(dismissCalled) + XCTAssertEqual(manager.resourceRetryCount, 1) + + contentController.removeScriptMessageHandler(forName: "resourceLoadError") + contentController.removeScriptMessageHandler(forName: "resourceVerifyResult") + } + + func testDidReceiveScriptMessage_resourceLoadError_closesWhenReloadDisabled() { + // With enableContentReloadOnStall off, a critical-resource failure closes (original behavior). + CountlyContentBuilderInternal.sharedInstance().enableContentReloadOnStall = false + manager.webViewClosed = false + manager.hasAppeared = false + + let config = WKWebViewConfiguration() + let contentController = config.userContentController + contentController.add(manager, name: "resourceLoadError") + contentController.add(manager, name: "resourceVerifyResult") + + let webView = WKWebView(frame: .zero, configuration: config) + let bgView = PassThroughBackgroundView(frame: .zero) + bgView.webView = webView + manager.backgroundView = bgView + + let dismissExpectation = expectation(description: "Dismiss block called") + manager.dismissBlock = { dismissExpectation.fulfill() } + + let js = """ + window.webkit.messageHandlers.resourceLoadError.postMessage({tag: "SCRIPT", url: "https://example.com/broken.js"}); + """ + webView.evaluateJavaScript(js, completionHandler: nil) + + waitForExpectations(timeout: TestUtils.asyncTimeout) + XCTAssertTrue(manager.webViewClosed) + XCTAssertEqual(manager.resourceRetryCount, 0) // did not retry + + contentController.removeScriptMessageHandler(forName: "resourceLoadError") + contentController.removeScriptMessageHandler(forName: "resourceVerifyResult") + } + + func testDidReceiveScriptMessage_resourceLoadError_closesAfterRetriesExhausted() { + manager.webViewClosed = false + manager.hasAppeared = false + // Simulate retries already used up so the next failure closes immediately. + manager.resourceRetryCount = 99 + + let config = WKWebViewConfiguration() + let contentController = config.userContentController + contentController.add(manager, name: "resourceLoadError") + contentController.add(manager, name: "resourceVerifyResult") + + let webView = WKWebView(frame: .zero, configuration: config) + + // Attach backgroundView with webView so closeWebView doesn't bail early + let bgView = PassThroughBackgroundView(frame: .zero) + bgView.webView = webView + manager.backgroundView = bgView + + let dismissExpectation = expectation(description: "Dismiss block called") + manager.dismissBlock = { dismissExpectation.fulfill() } + + let js = """ + window.webkit.messageHandlers.resourceLoadError.postMessage({ + tag: "SCRIPT", + url: "https://example.com/broken.js" + }); + """ + webView.evaluateJavaScript(js, completionHandler: nil) + + waitForExpectations(timeout: TestUtils.asyncTimeout) + XCTAssertTrue(manager.webViewClosed) + } + + func testRetryOrClose_ignoredAfterContentAppeared() { + // Once content is visible, a late resource failure must NOT reload or close it. + manager.webViewClosed = false + manager.hasAppeared = true + manager.resourceRetryCount = 0 + + let webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let bgView = PassThroughBackgroundView(frame: .zero) + bgView.webView = webView + manager.backgroundView = bgView + + var dismissCalled = false + manager.dismissBlock = { dismissCalled = true } + + manager.retryOrCloseWebView(forReason: "late failure after appearance") + + let settle = expectation(description: "settle") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { settle.fulfill() } + waitForExpectations(timeout: TestUtils.asyncTimeout) + + XCTAssertFalse(manager.webViewClosed) + XCTAssertFalse(dismissCalled) + XCTAssertNil(manager.pendingReloadBlock) + XCTAssertEqual(manager.resourceRetryCount, 0) + } + + func testNotifyPageLoaded_cancelsPendingReloadAfterSuccess() { + // A reload scheduled from an earlier failure in the same load cycle must be cancelled + // once the load succeeds, so a stale reload can't reload the good page (which would + // re-fire the page's on-load [CLY]_content_shown). + manager.webViewClosed = false + manager.hasAppeared = false + + // A stall/resource failure schedules a retry. + manager.retryOrCloseWebView(forReason: "stall") + XCTAssertNotNil(manager.pendingReloadBlock) + XCTAssertEqual(manager.resourceRetryCount, 1) + + // The load then verifies good / appears. + manager.notifyPageLoaded() + XCTAssertTrue(manager.hasAppeared) + XCTAssertNil(manager.pendingReloadBlock) // scheduled block cancelled and cleared + + // Wait past the retry delay (0.6s): the cancelled reload must not fire or close the view. + let settle = expectation(description: "settle") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.9) { settle.fulfill() } + waitForExpectations(timeout: TestUtils.asyncTimeout) + XCTAssertFalse(manager.webViewClosed) + XCTAssertTrue(manager.hasAppeared) + } + + func testCancelPendingReload_clearsScheduledRetry() { + manager.webViewClosed = false + manager.hasAppeared = false + + manager.retryOrCloseWebView(forReason: "stall") + XCTAssertNotNil(manager.pendingReloadBlock) + + manager.cancelPendingReload() + XCTAssertNil(manager.pendingReloadBlock) + + // The reload must not fire after cancellation. + let settle = expectation(description: "settle") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.9) { settle.fulfill() } + waitForExpectations(timeout: TestUtils.asyncTimeout) + XCTAssertFalse(manager.webViewClosed) + XCTAssertFalse(manager.hasAppeared) + } + + func testContentShownDeadlineReached_closesWebView() { + // The absolute deadline closes the web view when content_shown never arrived, even if + // the view had (blankly) "appeared" - content_shown would otherwise have cancelled it. + manager.webViewClosed = false + manager.hasAppeared = true + + let webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let bgView = PassThroughBackgroundView(frame: .zero) + bgView.webView = webView + manager.backgroundView = bgView + + let dismissExpectation = expectation(description: "Dismiss block called") + manager.dismissBlock = { dismissExpectation.fulfill() } + + manager.contentShownDeadlineReached() + + waitForExpectations(timeout: TestUtils.asyncTimeout) + XCTAssertTrue(manager.webViewClosed) + } + + func testContentShownEvent_cancelsDeadlineTimer() { + // Receiving a [CLY]_content_shown event cancels the absolute deadline, so genuinely + // shown content is never torn down by it. + let timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: false) { _ in } + manager.contentShownDeadlineTimer = timer + + let json = "[{\"key\":\"[CLY]_content_shown\",\"segmentation\":{\"content_id\":\"abc\"}}]" + manager.recordEvents(withJSONString: json) + + XCTAssertNil(manager.contentShownDeadlineTimer) + XCTAssertFalse(timer.isValid) + } + + func testIsFeedbackWidgetURL_recognizesFeedbackWidgetURLs() { + // Feedback widget URLs (host/feedback/?...&widget_id=...) must be recognized so the + // content-shown deadline is NOT armed for them (widgets never emit [CLY]_content_shown). + XCTAssertTrue(manager.isFeedbackWidgetURL(URL(string: "https://example.count.ly/feedback/nps?app_key=k&widget_id=abc123")!)) + XCTAssertTrue(manager.isFeedbackWidgetURL(URL(string: "https://example.count.ly/feedback/survey?widget_id=x")!)) + XCTAssertTrue(manager.isFeedbackWidgetURL(URL(string: "https://example.count.ly/feedback/rating?widget_id=x")!)) + // Base-path host still matches (uses a path-segment contains check, not a prefix). + XCTAssertTrue(manager.isFeedbackWidgetURL(URL(string: "https://example.com/base/feedback/nps?widget_id=x")!)) + } + + func testIsFeedbackWidgetURL_rejectsContentURLs() { + // Content URLs (host/_external/content?...) must NOT be treated as feedback widgets, so + // the content-shown deadline stays armed for real content. + XCTAssertFalse(manager.isFeedbackWidgetURL(URL(string: "https://example.count.ly/_external/content?app_id=1&id=2&journeyId=3")!)) + XCTAssertFalse(manager.isFeedbackWidgetURL(URL(string: "https://example.count.ly/o/sdk/content?method=queue")!)) + XCTAssertFalse(manager.isFeedbackWidgetURL(URL(string: "about:blank")!)) + } + + func testNonContentShownEvent_leavesDeadlineTimerRunning() { + // A different event must NOT cancel the deadline. + let timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: false) { _ in } + manager.contentShownDeadlineTimer = timer + + let json = "[{\"key\":\"some_other_event\",\"segmentation\":{\"a\":\"b\"}}]" + manager.recordEvents(withJSONString: json) + + XCTAssertNotNil(manager.contentShownDeadlineTimer) + XCTAssertTrue(timer.isValid) + timer.invalidate() + } + + func testDidReceiveScriptMessage_ignoredWhenWebViewClosed() { + manager.webViewClosed = true + manager.hasAppeared = false + + var appearCalled = false + manager.appearBlock = { + appearCalled = true + } + var dismissCalled = false + manager.dismissBlock = { + dismissCalled = true + } + + let config = WKWebViewConfiguration() + let contentController = config.userContentController + contentController.add(manager, name: "resourceLoadError") + contentController.add(manager, name: "resourceVerifyResult") + + let webView = WKWebView(frame: .zero, configuration: config) + + let js = """ + window.webkit.messageHandlers.resourceVerifyResult.postMessage({ + results: [{tag: "SCRIPT", url: "https://example.com/app.js", status: 200}] + }); + """ + webView.evaluateJavaScript(js, completionHandler: nil) + + let exp = expectation(description: "wait for JS") + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + exp.fulfill() + } + waitForExpectations(timeout: TestUtils.asyncTimeout) + + XCTAssertFalse(appearCalled) + XCTAssertFalse(dismissCalled) + XCTAssertFalse(manager.hasAppeared) + + contentController.removeScriptMessageHandler(forName: "resourceLoadError") + contentController.removeScriptMessageHandler(forName: "resourceVerifyResult") + } + + func testDidReceiveScriptMessage_http404ClosesWebView() { + // Post-load verification 404 closes without retrying. + manager.webViewClosed = false + manager.hasAppeared = false + + let config = WKWebViewConfiguration() + let contentController = config.userContentController + contentController.add(manager, name: "resourceLoadError") + contentController.add(manager, name: "resourceVerifyResult") + + let webView = WKWebView(frame: .zero, configuration: config) + let bgView = PassThroughBackgroundView(frame: .zero) + bgView.webView = webView + manager.backgroundView = bgView + + let dismissExpectation = expectation(description: "Dismiss block called") + manager.dismissBlock = { dismissExpectation.fulfill() } + + let js = """ + window.webkit.messageHandlers.resourceVerifyResult.postMessage({ + results: [ + {tag: "SCRIPT", url: "https://example.com/missing.js", status: 404} + ] + }); + """ + webView.evaluateJavaScript(js, completionHandler: nil) + + waitForExpectations(timeout: TestUtils.asyncTimeout) + XCTAssertTrue(manager.webViewClosed) + XCTAssertFalse(manager.hasAppeared) + XCTAssertEqual(manager.resourceRetryCount, 0) // verify path does not retry + + contentController.removeScriptMessageHandler(forName: "resourceLoadError") + contentController.removeScriptMessageHandler(forName: "resourceVerifyResult") + } + + func testDidReceiveScriptMessage_status399DoesNotClose() { + manager.webViewClosed = false + manager.hasAppeared = false + + let expectation = expectation(description: "Appear block called") + manager.appearBlock = { + expectation.fulfill() + } + + let config = WKWebViewConfiguration() + let contentController = config.userContentController + contentController.add(manager, name: "resourceLoadError") + contentController.add(manager, name: "resourceVerifyResult") + + let webView = WKWebView(frame: .zero, configuration: config) + + let js = """ + window.webkit.messageHandlers.resourceVerifyResult.postMessage({ + results: [ + {tag: "SCRIPT", url: "https://example.com/redirect.js", status: 399} + ] + }); + """ + webView.evaluateJavaScript(js, completionHandler: nil) + + waitForExpectations(timeout: TestUtils.asyncTimeout) + XCTAssertTrue(manager.hasAppeared) + XCTAssertFalse(manager.webViewClosed) + + contentController.removeScriptMessageHandler(forName: "resourceLoadError") + contentController.removeScriptMessageHandler(forName: "resourceVerifyResult") + } + + // MARK: - Rotation + + /// A manager holding a presented web view, without a network load. + private func makeManagerWithWebView() -> (CountlyWebViewManager, ResizePromptSpy) { + let m = CountlyWebViewManager() + let spy = ResizePromptSpy(frame: CGRect(x: 0, y: 0, width: 393, height: 852)) + spy.webView = WKWebView(frame: CGRect(x: 0, y: 500, width: 393, height: 300)) + m.backgroundView = spy + m.isFeedbackWidget = false + return (m, spy) + } + + /// An interface size change prompts the page; its resize_me reply is what re-places the content. + func testRotation_promptsThePage() { + CountlyContentBuilderInternal.sharedInstance().disableRotation = false + let (m, spy) = makeManagerWithWebView() + + m.handleInterfaceSizeChange(CGSize(width: 852, height: 393)) + + XCTAssertEqual(spy.promptCount, 1) + } + + /// A pinned content IS re-prompted — that is what corrects a page which laid out for the wrong + /// orientation. It is safe because the size reported to it is always portrait. + func testRotation_disableRotationStillPromptsThePage() { + CountlyContentBuilderInternal.sharedInstance().disableRotation = true + let (m, spy) = makeManagerWithWebView() + + m.handleInterfaceSizeChange(CGSize(width: 852, height: 393)) + + XCTAssertEqual(spy.promptCount, 1, "a pinned content must still be re-prompted") + + CountlyContentBuilderInternal.sharedInstance().disableRotation = false + } + + /// With the portrait pin on, a landscape measurement is transposed before the page is told. + func testPortraitAdjustedSize_transposesOnlyWhenPinned() { + let view = PassThroughBackgroundView(frame: .zero) + let landscape = CGSize(width: 852, height: 393) + let portrait = CGSize(width: 393, height: 852) + + view.reportPortraitSizeOnly = false + XCTAssertEqual(view.portraitAdjustedSize(landscape), landscape, "unpinned reports the real size") + + view.reportPortraitSizeOnly = true + XCTAssertEqual(view.portraitAdjustedSize(landscape), portrait, "pinned transposes a landscape size") + XCTAssertEqual(view.portraitAdjustedSize(portrait), portrait, "an already-portrait size is untouched") + } + + /// disableRotation is content-only: a widget still rotates while content is pinned. + func testRotation_disableRotationDoesNotAffectFeedbackWidgets() { + CountlyContentBuilderInternal.sharedInstance().disableRotation = true + let (m, spy) = makeManagerWithWebView() + m.isFeedbackWidget = true + + m.handleInterfaceSizeChange(CGSize(width: 852, height: 393)) + + let expected = CGRect(origin: .zero, size: CountlyCommon.sharedInstance().getWindowSize()) + XCTAssertEqual(m.backgroundView.webView.frame, expected, "a widget must not be gated by the content option") + XCTAssertEqual(spy.promptCount, 1) + + CountlyContentBuilderInternal.sharedInstance().disableRotation = false + } + + /// A closed web view is never prompted. + func testRotation_closedWebViewIsNotPrompted() { + CountlyContentBuilderInternal.sharedInstance().disableRotation = false + let (m, spy) = makeManagerWithWebView() + m.webViewClosed = true + + m.handleInterfaceSizeChange(CGSize(width: 852, height: 393)) + + XCTAssertEqual(spy.promptCount, 0) + } + + /// The page's resize_me reply sets the frame for the orientation it is in. + func testResize_appliesTheGeometryThePageReports() { + CountlyContentBuilderInternal.sharedInstance().disableRotation = false + let (m, _) = makeManagerWithWebView() + + m.resizeWebView(withJSONString: "{\"p\":{\"x\":1,\"y\":2,\"w\":3,\"h\":4},\"l\":{\"x\":90,\"y\":90,\"w\":90,\"h\":90}}") + + XCTAssertEqual(m.backgroundView.webView.frame, CGRect(x: 1, y: 2, width: 3, height: 4)) + } + + /// disableRotation pins content to portrait even when the page reports landscape dimensions. + func testResize_disableRotationPinsToPortraitDimensions() { + CountlyContentBuilderInternal.sharedInstance().disableRotation = true + let (m, _) = makeManagerWithWebView() + + // A landscape window, so the orientation pick would otherwise choose the landscape rect. + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 852, height: 393)) + window.addSubview(m.backgroundView) + + m.resizeWebView(withJSONString: "{\"p\":{\"x\":1,\"y\":2,\"w\":3,\"h\":4},\"l\":{\"x\":90,\"y\":90,\"w\":90,\"h\":90}}") + + XCTAssertEqual(m.backgroundView.webView.frame, CGRect(x: 1, y: 2, width: 3, height: 4), + "the portrait rect must win while rotation is disabled") + + m.backgroundView.removeFromSuperview() + CountlyContentBuilderInternal.sharedInstance().disableRotation = false + } +} + +#endif diff --git a/ios/src/CountlyTests/EventThreadTests.swift b/ios/src/CountlyTests/EventThreadTests.swift new file mode 100644 index 00000000..aa9096ac --- /dev/null +++ b/ios/src/CountlyTests/EventThreadTests.swift @@ -0,0 +1,131 @@ +// +// CountlyBaseTestCase.swift +// CountlyTests +// +// Created by Muhammad Junaid Akram on 27/12/2023. +// Copyright © 2023 Countly. All rights reserved. +// + +import XCTest +@testable import Countly + +final class EventRaceReproTests: CountlyBaseTestCase { + func testPreviousEventIDRace() { + // CountlyBaseTestCase.setUpWithError purges the state, including the previous-event + // chain, left behind by whichever class ran before this one. Without that the first + // event asserted below inherits a `peid` from the previous test. + let config = CountlyConfig() + config.appKey = "appkey" + config.host = "https://127.0.0.1" + config.enableDebug = true + config.requiresConsent = false + config.eventSendThreshold = UInt(10_000) + config.experimental().enablePreviousNameRecording = true; + // `CountlyConfig.experimental` is a process-wide shared object, so leaving the flag + // set would turn previous-name recording on for every later test. + defer { config.experimental().enablePreviousNameRecording = false } + Countly.sharedInstance().start(with: config) + + Countly.sharedInstance().recordEvent("warmup") + + let group = DispatchGroup() + let workers = 8 + let iterations = 1_000 + + for w in 0.. [EventPayload] { + let params = TestUtils.parseQueryString(request) + guard let eventsJSON = params["events"] as? String, + let data = eventsJSON.data(using: .utf8) else { + return [] + } + + guard let decodedEvents = try? JSONDecoder().decode([CountlyEventStruct].self, from: data) else { + return [] + } + + return decodedEvents.map { event in + let previousName = (event.segmentation?[kCountlyPreviousEventName] as? String) ?? "" + return EventPayload( + key: event.key, + id: event.ID, + previousID: event.PEID ?? "", + previousName: previousName, + timestamp: event.timestamp + ) + } + } + + var queuedEvents: [EventPayload] = [] + for request in queuedRequests { + queuedEvents.append(contentsOf: extractEvents(from: request)) + } + + XCTAssertFalse(queuedEvents.isEmpty, "No events found in queued requests") + + let expectedNonReservedCount = 1 + (workers * iterations) + // Match only this test's own events. Filtering merely on "not reserved" also picked up a + // custom event left by a neighbouring test whose flush completed after setUp purged the + // queue, which showed up as one extra event and a non-empty chain at index 0. + let nonReservedEvents = queuedEvents + .filter { $0.key == "warmup" || $0.key.hasPrefix("Login Result #") } + + XCTAssertEqual(nonReservedEvents.count, expectedNonReservedCount) + + var mismatchSummaries: [String] = [] + + for (index, event) in nonReservedEvents.enumerated() { + if index == 0 { + if !event.previousID.isEmpty || !event.previousName.isEmpty { + mismatchSummaries.append("Index 0 expected empty previous metadata but found id=\(event.previousID), name=\(event.previousName)") + } + continue + } + + let priorEvent = nonReservedEvents[index - 1] + + if event.previousID != priorEvent.id { + mismatchSummaries.append("Index \(index) id=\(event.id) expected prior id=\(priorEvent.id) but was \(event.previousID)") + } + + if event.previousName != priorEvent.key { + mismatchSummaries.append("Index \(index) id=\(event.id) expected prior name=\(priorEvent.key) but was \(event.previousName)") + } + } + + XCTAssertTrue(mismatchSummaries.isEmpty, mismatchSummaries.joined(separator: "\n")) + } +} diff --git a/ios/src/CountlyTests/MockFeedbackWidget.swift b/ios/src/CountlyTests/MockFeedbackWidget.swift index dc541bac..449ba002 100644 --- a/ios/src/CountlyTests/MockFeedbackWidget.swift +++ b/ios/src/CountlyTests/MockFeedbackWidget.swift @@ -9,6 +9,8 @@ import Countly +// CountlyFeedbackWidget only exposes its API on iOS and visionOS. +#if os(iOS) || os(visionOS) class MockFeedbackWidget: CountlyFeedbackWidget { private let _id: String private let _type: CLYFeedbackWidgetType @@ -25,3 +27,4 @@ class MockFeedbackWidget: CountlyFeedbackWidget { super.init() } } +#endif diff --git a/ios/src/CountlyTests/MockURLProtocol.swift b/ios/src/CountlyTests/MockURLProtocol.swift index 8a61554c..1e2b6295 100644 --- a/ios/src/CountlyTests/MockURLProtocol.swift +++ b/ios/src/CountlyTests/MockURLProtocol.swift @@ -43,4 +43,15 @@ class MockURLProtocol: URLProtocol { } override func stopLoading() {} + + /// Answers every later request with an empty JSON object, so requests still in flight after a test do not fail the next one. + static func replyEmptyToEveryRequest() { + requestHandler = { request in + ( + "{}".data(using: .utf8), + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil), + nil + ) + } + } } diff --git a/ios/src/CountlyTests/ServerConfigBuilder.swift b/ios/src/CountlyTests/ServerConfigBuilder.swift index 9142f2f3..20893335 100644 --- a/ios/src/CountlyTests/ServerConfigBuilder.swift +++ b/ios/src/CountlyTests/ServerConfigBuilder.swift @@ -3,9 +3,9 @@ import XCTest class ServerConfigBuilder { private var config: [String: Any] = [:] - + // MARK: - Constants - + private enum Keys { static let sc = "sc" static let tracking = "tracking" @@ -33,155 +33,255 @@ class ServerConfigBuilder { static let consentRequired = "cr" static let dropOldRequestTime = "dort" static let crashReporting = "crt" + static let automaticSessionTracking = "ast" + static let automaticViewTracking = "avt" + static let automaticCrashReporting = "acr" static let serverConfigUpdateInterval = "scui" + static let eventBlacklist = "eb" + static let eventWhitelist = "ew" + static let userPropertyBlacklist = "upb" + static let userPropertyWhitelist = "upw" + static let userPropertyCacheLimit = "upcl" + static let segmentationBlacklist = "sb" + static let segmentationWhitelist = "sw" + static let eventSegmentationBlacklist = "esb" + static let eventSegmentationWhitelist = "esw" + static let journeyTriggerEvents = "jte" + static let journeyTriggerViews = "jtv" } - + // MARK: - Feature Flags - + @discardableResult func tracking(_ enabled: Bool) -> ServerConfigBuilder { config[Keys.tracking] = enabled return this } - + @discardableResult func networking(_ enabled: Bool) -> ServerConfigBuilder { config[Keys.networking] = enabled return this } - + @discardableResult func crashReporting(_ enabled: Bool) -> ServerConfigBuilder { config[Keys.crashReporting] = enabled return this } - + @discardableResult func viewTracking(_ enabled: Bool) -> ServerConfigBuilder { config[Keys.viewTracking] = enabled return this } - + + @discardableResult + func automaticSessionTracking(_ enabled: Bool) -> ServerConfigBuilder { + config[Keys.automaticSessionTracking] = enabled + return this + } + + @discardableResult + func automaticViewTracking(_ enabled: Bool) -> ServerConfigBuilder { + config[Keys.automaticViewTracking] = enabled + return this + } + + @discardableResult + func automaticCrashReporting(_ enabled: Bool) -> ServerConfigBuilder { + config[Keys.automaticCrashReporting] = enabled + return this + } + @discardableResult func sessionTracking(_ enabled: Bool) -> ServerConfigBuilder { config[Keys.sessionTracking] = enabled return this } - + @discardableResult func customEventTracking(_ enabled: Bool) -> ServerConfigBuilder { config[Keys.customEventTracking] = enabled return this } - + @discardableResult func contentZone(_ enabled: Bool) -> ServerConfigBuilder { config[Keys.enterContentZone] = enabled return this } - + @discardableResult func locationTracking(_ enabled: Bool) -> ServerConfigBuilder { config[Keys.locationTracking] = enabled return this } - + @discardableResult func refreshContentZone(_ enabled: Bool) -> ServerConfigBuilder { config[Keys.refreshContentZone] = enabled return this } - + + // MARK: - Listing Filters + + @discardableResult + func eventBlacklist(_ keys: [String]) -> ServerConfigBuilder { + config[Keys.eventBlacklist] = keys + return this + } + + @discardableResult + func eventWhitelist(_ keys: [String]) -> ServerConfigBuilder { + config[Keys.eventWhitelist] = keys + return this + } + + @discardableResult + func userPropertyBlacklist(_ keys: [String]) -> ServerConfigBuilder { + config[Keys.userPropertyBlacklist] = keys + return this + } + + @discardableResult + func userPropertyWhitelist(_ keys: [String]) -> ServerConfigBuilder { + config[Keys.userPropertyWhitelist] = keys + return this + } + + @discardableResult + func userPropertyCacheLimit(_ limit: UInt) -> ServerConfigBuilder { + config[Keys.userPropertyCacheLimit] = limit + return this + } + + @discardableResult + func segmentationBlacklist(_ keys: [String]) -> ServerConfigBuilder { + config[Keys.segmentationBlacklist] = keys + return this + } + + @discardableResult + func segmentationWhitelist(_ keys: [String]) -> ServerConfigBuilder { + config[Keys.segmentationWhitelist] = keys + return this + } + + @discardableResult + func eventSegmentationBlacklist(_ map: [String: [String]]) -> ServerConfigBuilder { + config[Keys.eventSegmentationBlacklist] = map + return this + } + + @discardableResult + func eventSegmentationWhitelist(_ map: [String: [String]]) -> ServerConfigBuilder { + config[Keys.eventSegmentationWhitelist] = map + return this + } + + @discardableResult + func journeyTriggerEvents(_ keys: [String]) -> ServerConfigBuilder { + config[Keys.journeyTriggerEvents] = keys + return this + } + + @discardableResult + func journeyTriggerViews(_ names: [String]) -> ServerConfigBuilder { + config[Keys.journeyTriggerViews] = names + return this + } + // MARK: - Intervals and Sizes - + @discardableResult func serverConfigUpdateInterval(_ interval: UInt) -> ServerConfigBuilder { config[Keys.serverConfigUpdateInterval] = interval return this } - + @discardableResult func requestQueueSize(_ size: UInt) -> ServerConfigBuilder { config[Keys.requestQueueSize] = size return this } - + @discardableResult func eventQueueSize(_ size: UInt) -> ServerConfigBuilder { config[Keys.eventQueueSize] = size return this } - + @discardableResult func logging(_ enabled: Bool) -> ServerConfigBuilder { config[Keys.logging] = enabled return this } - + @discardableResult func sessionUpdateInterval(_ interval: Int) -> ServerConfigBuilder { config[Keys.sessionUpdateInterval] = interval return this } - + @discardableResult func contentZoneInterval(_ interval: TimeInterval) -> ServerConfigBuilder { config[Keys.contentZoneInterval] = interval return this } - + @discardableResult func consentRequired(_ required: Bool) -> ServerConfigBuilder { config[Keys.consentRequired] = required return this } - + @discardableResult func dropOldRequestTime(_ time: UInt) -> ServerConfigBuilder { config[Keys.dropOldRequestTime] = time return this } - + // MARK: - Limits - + @discardableResult func limitKeyLength(_ limit: UInt) -> ServerConfigBuilder { config[Keys.limitKeyLength] = limit return this } - + @discardableResult func limitValueSize(_ limit: UInt) -> ServerConfigBuilder { config[Keys.limitValueSize] = limit return this } - + @discardableResult func limitSegmentationValues(_ limit: UInt) -> ServerConfigBuilder { config[Keys.limitSegValues] = limit return this } - + @discardableResult func limitBreadcrumb(_ limit: UInt) -> ServerConfigBuilder { config[Keys.limitBreadcrumb] = limit return this } - + @discardableResult func limitTraceLength(_ limit: UInt) -> ServerConfigBuilder { config[Keys.limitTraceLength] = limit return this } - + @discardableResult func limitTraceLines(_ limit: UInt) -> ServerConfigBuilder { config[Keys.limitTraceLine] = limit return this } - + // MARK: - Build Methods - + func defaults() -> ServerConfigBuilder { // Feature flags tracking(true) @@ -193,17 +293,21 @@ class ServerConfigBuilder { contentZone(false) locationTracking(true) refreshContentZone(true) - + // Intervals and sizes serverConfigUpdateInterval(4) 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) - + // Limits limitKeyLength(128) limitValueSize(256) @@ -211,80 +315,167 @@ class ServerConfigBuilder { limitBreadcrumb(100) limitTraceLength(200) limitTraceLines(30) - + return this } - + func build() -> String { let configDict: [String: Any] = [ Keys.version: 1, Keys.timestamp: Int(Date().timeIntervalSince1970), - Keys.config: config + Keys.config: config, ] - + if let jsonData = try? JSONSerialization.data(withJSONObject: configDict), - let jsonString = String(data: jsonData, encoding: .utf8) { + let jsonString = String(data: jsonData, encoding: .utf8) { return jsonString } - + return "{}" } - + func buildJson() -> [String: Any] { return [ Keys.version: 1, Keys.timestamp: Int(Date().timeIntervalSince1970), - Keys.config: config + Keys.config: config, ] } - + // MARK: - Validation - + func validateAgainst() { let moduleConfig = CountlyServerConfig.sharedInstance() let common = CountlyCommon.sharedInstance() let consentManager = CountlyConsentManager.sharedInstance() let persistency = CountlyPersistency.sharedInstance() let crashReporter = CountlyCrashReporter.sharedInstance() - - // Feature flags - XCTAssertEqual(config[Keys.tracking] as? Bool, moduleConfig?.trackingEnabled()) - XCTAssertEqual(config[Keys.networking] as? Bool, moduleConfig?.networkingEnabled()) - XCTAssertEqual(config[Keys.crashReporting] as? Bool, moduleConfig?.crashReportingEnabled()) - XCTAssertEqual(config[Keys.viewTracking] as? Bool, moduleConfig?.viewTrackingEnabled()) - XCTAssertEqual(config[Keys.sessionTracking] as? Bool, moduleConfig?.sessionTrackingEnabled()) - XCTAssertEqual(config[Keys.customEventTracking] as? Bool, moduleConfig?.customEventTrackingEnabled()) - XCTAssertEqual(config[Keys.enterContentZone] as? Bool, moduleConfig?.enterContentZone()) - XCTAssertEqual(config[Keys.locationTracking] as? Bool, moduleConfig?.locationTrackingEnabled()) - XCTAssertEqual(config[Keys.refreshContentZone] as? Bool, moduleConfig?.refreshContentZoneEnabled()) - + + // Feature flags (only validate if explicitly set in builder) + if let val = config[Keys.tracking] as? Bool { + XCTAssertEqual(val, moduleConfig?.trackingEnabled()) + } + if let val = config[Keys.networking] as? Bool { + XCTAssertEqual(val, moduleConfig?.networkingEnabled()) + } + if let val = config[Keys.crashReporting] as? Bool { + XCTAssertEqual(val, moduleConfig?.crashReportingEnabled()) + } + if let val = config[Keys.viewTracking] as? Bool { + XCTAssertEqual(val, moduleConfig?.viewTrackingEnabled()) + } + if let val = config[Keys.sessionTracking] as? Bool { + XCTAssertEqual(val, moduleConfig?.sessionTrackingEnabled()) + } + if let val = config[Keys.automaticSessionTracking] as? Bool { + XCTAssertEqual(val, moduleConfig?.automaticSessionTrackingEnabled()) + } + if let val = config[Keys.automaticViewTracking] as? Bool { + XCTAssertEqual(val, moduleConfig?.automaticViewTrackingEnabled()) + } + if let val = config[Keys.automaticCrashReporting] as? Bool { + XCTAssertEqual(val, moduleConfig?.automaticCrashReportingEnabled()) + } + if let val = config[Keys.customEventTracking] as? Bool { + XCTAssertEqual(val, moduleConfig?.customEventTrackingEnabled()) + } + if let val = config[Keys.enterContentZone] as? Bool { + XCTAssertEqual(val, moduleConfig?.enterContentZone()) + } + if let val = config[Keys.locationTracking] as? Bool { + XCTAssertEqual(val, moduleConfig?.locationTrackingEnabled()) + } + if let val = config[Keys.refreshContentZone] as? Bool { + XCTAssertEqual(val, moduleConfig?.refreshContentZoneEnabled()) + } + // Debug mode - XCTAssertEqual(config[Keys.logging] as? Bool, common.enableDebug) - + if let val = config[Keys.logging] as? Bool { + XCTAssertEqual(val, common.enableDebug) + } + // Limits - XCTAssertEqual(config[Keys.limitKeyLength] as? UInt, common.maxKeyLength) - XCTAssertEqual(config[Keys.limitValueSize] as? UInt, common.maxValueLength) - XCTAssertEqual(config[Keys.limitSegValues] as? UInt, common.maxSegmentationValues) - XCTAssertEqual(config[Keys.limitBreadcrumb] as? UInt, CountlyCrashReporter.sharedInstance()?.crashLogLimit) - + if let val = config[Keys.limitKeyLength] as? UInt { + XCTAssertEqual(val, common.maxKeyLength) + } + if let val = config[Keys.limitValueSize] as? UInt { + XCTAssertEqual(val, common.maxValueLength) + } + if let val = config[Keys.limitSegValues] as? UInt { + XCTAssertEqual(val, common.maxSegmentationValues) + } + if let val = config[Keys.limitBreadcrumb] as? UInt { + XCTAssertEqual(val, CountlyCrashReporter.sharedInstance()?.crashLogLimit) + } + // Consent - XCTAssertEqual(config[Keys.consentRequired] as? Bool, consentManager?.requiresConsent) - + if let val = config[Keys.consentRequired] as? Bool { + XCTAssertEqual(val, consentManager?.requiresConsent) + } + // Queue sizes and intervals - XCTAssertEqual(config[Keys.eventQueueSize] as? UInt, CountlyPersistency.sharedInstance().eventSendThreshold) - XCTAssertEqual(config[Keys.requestQueueSize] as? UInt, CountlyPersistency.sharedInstance().storedRequestsLimit) - XCTAssertEqual(config[Keys.dropOldRequestTime] as? UInt, CountlyPersistency.sharedInstance().requestDropAgeHours) - XCTAssertEqual(config[Keys.sessionUpdateInterval] as? Int, moduleConfig?.sessionInterval()) - + if let val = config[Keys.eventQueueSize] as? UInt { + XCTAssertEqual(val, CountlyPersistency.sharedInstance().eventSendThreshold) + } + if let val = config[Keys.requestQueueSize] as? UInt { + XCTAssertEqual(val, CountlyPersistency.sharedInstance().storedRequestsLimit) + } + if let val = config[Keys.dropOldRequestTime] as? UInt { + XCTAssertEqual(val, CountlyPersistency.sharedInstance().requestDropAgeHours) + } + if let val = config[Keys.sessionUpdateInterval] as? Int { + XCTAssertEqual(val, moduleConfig?.sessionInterval()) + } + #if os(iOS) - XCTAssertEqual(config[Keys.contentZoneInterval] as? TimeInterval, CountlyContentBuilderInternal.sharedInstance().zoneTimerInterval) - -#endif + if let val = config[Keys.contentZoneInterval] as? TimeInterval { + XCTAssertEqual(val, CountlyContentBuilderInternal.sharedInstance().zoneTimerInterval) + } + #endif + + // User property cache limit + if let upcl = config[Keys.userPropertyCacheLimit] as? UInt { + XCTAssertEqual(Int(upcl), moduleConfig?.userPropertyCacheLimit()) + } + + // Listing filter validation: event filter + if let eventBlacklist = config[Keys.eventBlacklist] as? [String] { + XCTAssertFalse(moduleConfig!.shouldRecordEvent(eventBlacklist.first ?? "")) + XCTAssertTrue(moduleConfig!.shouldRecordEvent("nonexistent_event_xyz")) + } else if let eventWhitelist = config[Keys.eventWhitelist] as? [String] { + XCTAssertTrue(moduleConfig!.shouldRecordEvent(eventWhitelist.first ?? "")) + XCTAssertFalse(moduleConfig!.shouldRecordEvent("nonexistent_event_xyz")) + } + + // User property filter + if let userPropertyBlacklist = config[Keys.userPropertyBlacklist] as? [String] { + XCTAssertFalse(moduleConfig!.shouldRecordUserProperty(userPropertyBlacklist.first ?? "")) + XCTAssertTrue(moduleConfig!.shouldRecordUserProperty("nonexistent_prop_xyz")) + } else if let userPropertyWhitelist = config[Keys.userPropertyWhitelist] as? [String] { + XCTAssertTrue(moduleConfig!.shouldRecordUserProperty(userPropertyWhitelist.first ?? "")) + XCTAssertFalse(moduleConfig!.shouldRecordUserProperty("nonexistent_prop_xyz")) + } + + // Journey trigger events + if let journeyTriggerEvents = config[Keys.journeyTriggerEvents] as? [String] { + for key in journeyTriggerEvents { + XCTAssertTrue(moduleConfig!.isJourneyTriggerEvent(key)) + } + XCTAssertFalse(moduleConfig!.isJourneyTriggerEvent("nonexistent_jte_xyz")) + } + + // Journey trigger views + if let journeyTriggerViews = config[Keys.journeyTriggerViews] as? [String] { + for name in journeyTriggerViews { + XCTAssertTrue(moduleConfig!.isJourneyTriggerView(name)) + } + XCTAssertFalse(moduleConfig!.isJourneyTriggerView("nonexistent_jtv_xyz")) + } } - + // MARK: - Helper Methods - + private var this: ServerConfigBuilder { return self } -} +} diff --git a/ios/src/CountlyTests/TestAppActivation.swift b/ios/src/CountlyTests/TestAppActivation.swift new file mode 100644 index 00000000..d7995c17 --- /dev/null +++ b/ios/src/CountlyTests/TestAppActivation.swift @@ -0,0 +1,118 @@ +// +// TestAppActivation.swift +// CountlyTests +// +// Test support for the SDK's foreground guards. +// +// `CountlyConnectionManager.beginSession` refuses to open an automatic session for an app +// that is not in the foreground: on macOS while `NSApp.isActive` is false, and on iOS, tvOS +// and visionOS while `UIApplication.applicationState` is `.background`. Inside a test run +// what those report depends on how the bundle is hosted, never on the test itself: +// +// - macOS: an `xctest` process is never a frontmost `NSApplication`, so `NSApp.isActive` +// is always false. +// - iOS: run hostless on a simulator, the bundle reads as active. Packaged into an +// `XCTRunner` app, as the device farm runs it on a phone, the runner is a real application +// that testmanagerd launches without bringing it to the foreground, so it reads +// `.background`. Every automatic `begin_session` is then dropped, and a test that indexes +// the request queue by position reads past its end and traps the whole runner. +// +// Either way the automatic-session tests would fail for a reason that never happens in a +// real app, which is in the foreground by the time it calls `startWithConfig:`. +// +// This shim lets the test bundle report the activation state it wants, so every platform +// and host runs the same assertions by default. The genuinely-inactive macOS path (session +// dropped at start, then recovered when the app becomes active) is asserted explicitly by +// `CountlyPlatformLifecycleTests` by flipping `isActive` to false. +// + +#if os(macOS) + + import AppKit + import ObjectiveC + + enum TestAppActivation { + + /// What `NSApp.isActive` reports to the SDK. Reset to `true` before every test. + /// Writing it installs the stub, so there is no install-before-use ordering to get + /// wrong from the various test base classes. + static var isActive: Bool = true { + didSet { _ = installed } + } + + /// Swaps `NSApplication.isActive` for a stub backed by `isActive`. Idempotent. + static func installIfNeeded() { + _ = installed + } + + private static let installed: Bool = { + // Make sure the shared application exists before touching its class. + _ = NSApplication.shared + guard + let original = class_getInstanceMethod( + NSApplication.self, #selector(getter:NSApplication.isActive)), + let stub = class_getInstanceMethod( + NSApplication.self, #selector(getter:NSApplication.cly_testIsActive)) + else { + return false + } + method_exchangeImplementations(original, stub) + return true + }() + } + + extension NSApplication { + @objc dynamic var cly_testIsActive: Bool { + return TestAppActivation.isActive + } + } + +#elseif os(iOS) || os(tvOS) || os(visionOS) + + import ObjectiveC + import UIKit + + enum TestAppActivation { + + /// Whether `UIApplication.applicationState` reports `.active` (true) or `.background` + /// (false) to the SDK. Reset to `true` before every test. Writing it installs the stub, + /// so there is no install-before-use ordering to get wrong from the various test base + /// classes. + static var isActive: Bool = true { + didSet { _ = installed } + } + + /// A state reported instead of the one `isActive` picks, such as `.inactive`. Reset to nil before every test. + static var stateOverride: UIApplication.State? = nil { + didSet { _ = installed } + } + + /// Swaps `UIApplication.applicationState` for a stub backed by `isActive`. Idempotent. + static func installIfNeeded() { + _ = installed + } + + // No `UIApplication.shared` here, unlike macOS: a hostless simulator run has none. + private static let installed: Bool = { + guard + let original = class_getInstanceMethod( + UIApplication.self, #selector(getter:UIApplication.applicationState)), + let stub = class_getInstanceMethod( + UIApplication.self, #selector(getter:UIApplication.cly_testApplicationState)) + else { + return false + } + method_exchangeImplementations(original, stub) + return true + }() + } + + extension UIApplication { + /// Stands in for `applicationState` once swapped, reporting the state chosen through + /// `TestAppActivation.isActive` instead of where the test host really is. + @objc dynamic var cly_testApplicationState: UIApplication.State { + return TestAppActivation.stateOverride ?? (TestAppActivation.isActive ? .active : .background) + } + } + +#endif diff --git a/ios/src/CountlyTests/TestPlatform.swift b/ios/src/CountlyTests/TestPlatform.swift new file mode 100644 index 00000000..92eb539d --- /dev/null +++ b/ios/src/CountlyTests/TestPlatform.swift @@ -0,0 +1,145 @@ +// +// TestPlatform.swift +// CountlyTests +// +// What the SDK is expected to do on the platform this bundle is compiled for. +// +// Every constant here mirrors a `TARGET_OS_*` guard in the SDK sources, so a guard changing +// without this changing is a test failure rather than a silent behaviour drift. Tests read +// from here instead of carrying their own per-platform copies. +// + +import XCTest + +@testable import Countly + +enum TestPlatform { + + // MARK: - Identity + + /// `_os` metric, from `CountlyDeviceInfo.osName`. + #if os(iOS) + static let osName = "iOS" + #elseif os(watchOS) + static let osName = "watchOS" + #elseif os(tvOS) + static let osName = "tvOS" + #elseif os(macOS) + static let osName = "macOS" + #elseif os(visionOS) + static let osName = "visionOS" + #endif + + /// `_device_type` metric, from `CountlyDeviceInfo.deviceType`. iOS reports the UI idiom, + /// so every value it can produce is accepted there. + #if os(iOS) + static let deviceTypes: Set = ["mobile", "tablet", "desktop"] + #elseif os(watchOS) + static let deviceTypes: Set = ["wearable"] + #elseif os(tvOS) + static let deviceTypes: Set = ["smarttv"] + #elseif os(macOS) + static let deviceTypes: Set = ["desktop"] + #elseif os(visionOS) + static let deviceTypes: Set = ["vr"] + #endif + + /// `Countly.deviceIDType` when the host app supplies no device ID, and the `t` request + /// parameter that goes with it. These two always change together. + #if os(iOS) || os(tvOS) || os(visionOS) + static let defaultDeviceIDType = CLYDeviceIDType.IDFV + static let defaultDeviceIDTypeValue = "1" // CLYDeviceIDTypeValueIDFV + #else + static let defaultDeviceIDType = CLYDeviceIDType.NSUUID + static let defaultDeviceIDTypeValue = "2" // CLYDeviceIDTypeValueNSUUID + #endif + + /// `CountlyConfig.updateSessionPeriod` default: 20s on watchOS, 60s elsewhere. + #if os(watchOS) + static let defaultUpdateSessionPeriod: Double = 20.0 + #else + static let defaultUpdateSessionPeriod: Double = 60.0 + #endif + + /// Where `Countly.dat` is written. tvOS has no Application Support directory. + #if os(tvOS) + static let storageDirectory = FileManager.SearchPathDirectory.cachesDirectory + #else + static let storageDirectory = FileManager.SearchPathDirectory.applicationSupportDirectory + #endif + + // MARK: - Metrics + + /// `_resolution` / `_density` need a screen API. `CountlyDeviceInfo` has no visionOS branch. + #if os(visionOS) + static let reportsScreenMetrics = false + #else + static let reportsScreenMetrics = true + #endif + + /// `_carrier` is CoreTelephony-backed, so iOS only, and not Mac Catalyst. + #if os(iOS) && !targetEnvironment(macCatalyst) + static let reportsCarrier = true + #else + static let reportsCarrier = false + #endif + + // MARK: - Feature surface + + /// Feedback widgets, the star rating dialog and the content zone are WebKit-backed: + /// `TARGET_OS_IOS || TARGET_OS_VISION`. + #if os(iOS) || os(visionOS) + static let hasWebKitFeatures = true + #else + static let hasWebKitFeatures = false + #endif + + /// Push notifications: `TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_OSX`. + #if os(iOS) || os(visionOS) || os(macOS) + static let hasPushNotifications = true + #else + static let hasPushNotifications = false + #endif + + /// The auto-view-tracking *public API* is declared for + /// `TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV`. + #if os(iOS) || os(tvOS) || os(visionOS) + static let hasAutoViewTrackingAPI = true + #else + static let hasAutoViewTrackingAPI = false + #endif + + /// Whether it is actually implemented. It swizzles `UIViewController` and is compiled only + /// for iOS and tvOS, so on visionOS the public API is a logged no-op. The two must be + /// tracked separately: every call site that reaches the internal methods has to be guarded + /// to the narrower set, or a consent change is an unrecognized-selector crash. + #if os(iOS) || os(tvOS) + static let hasAutoViewTracking = true + #else + static let hasAutoViewTracking = false + #endif + + // MARK: - Test environment capabilities + + /// Whether a custom `URLProtocol` registered through + /// `URLSessionConfiguration.protocolClasses` actually intercepts the SDK's requests. + /// + /// watchOS hands URLSession traffic to a system proxy daemon (requests show up as + /// `PDTask` in the log and reach the real network), so custom protocol classes are never + /// consulted. Verified on the watchOS 26 simulator. Any test that fakes an HTTP response, + /// or that depends on requests draining, has to be skipped there. + #if os(watchOS) + static let canInterceptHTTP = false + #else + static let canInterceptHTTP = true + #endif + + /// Throws `XCTSkip` where `canInterceptHTTP` is false. + static func skipUnlessHTTPInterceptable() throws { + if !canInterceptHTTP { + throw XCTSkip( + "Custom URLProtocol interception does not work on \(osName); " + + "URLSession traffic is proxied by the system.") + } + } +} diff --git a/ios/src/CountlyTests/TestURLProtocol.swift b/ios/src/CountlyTests/TestURLProtocol.swift new file mode 100644 index 00000000..879d551a --- /dev/null +++ b/ios/src/CountlyTests/TestURLProtocol.swift @@ -0,0 +1,70 @@ +// +// TestURLProtocol.swift +// Countly +// +// Created by Arif Burak Demiray on 13.11.2025. +// Copyright © 2025 Countly. All rights reserved. +// + + +import Foundation + +final class TestURLProtocol: URLProtocol { + private static var lastRequestHeaders: [String: String]? = nil + private static var capturedRequests: [(url: String, headers: [String: String])] = [] + private static let capturedRequestsLock = NSLock() + + // MARK: - URLProtocol overrides + override class func canInit(with request: URLRequest) -> Bool { + // Intercept all requests + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + // Capture headers + TestURLProtocol.lastRequestHeaders = request.allHTTPHeaderFields + TestURLProtocol.capturedRequestsLock.lock() + TestURLProtocol.capturedRequests.append((request.url?.absoluteString ?? "", request.allHTTPHeaderFields ?? [:])) + TestURLProtocol.capturedRequestsLock.unlock() + + // Return a dummy 200 OK response + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil + )! + + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + // a reply the SDK accepts, so the queue moves on to the requests a test queues after start + client?.urlProtocol(self, didLoad: Data("{\"result\":\"Success\"}".utf8)) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() { + // Nothing to do here + } + + // MARK: - Helpers for tests + static func reset() { + lastRequestHeaders = nil + capturedRequestsLock.lock() + capturedRequests = [] + capturedRequestsLock.unlock() + } + + static func capturedHeaders() -> [String: String]? { + lastRequestHeaders + } + + /// Headers of the first intercepted request whose URL contains the text, or nil. A POST body is a stream here, so only URLs match. + static func capturedHeaders(forRequestContaining text: String) -> [String: String]? { + capturedRequestsLock.lock() + defer { capturedRequestsLock.unlock() } + return capturedRequests.first { $0.url.contains(text) }?.headers + } +} diff --git a/ios/src/CountlyTests/TestUtils.swift b/ios/src/CountlyTests/TestUtils.swift index f60fd433..4fde6a2f 100644 --- a/ios/src/CountlyTests/TestUtils.swift +++ b/ios/src/CountlyTests/TestUtils.swift @@ -1,3 +1,4 @@ +import Countly // // TestUtils.swift // Countly @@ -6,36 +7,61 @@ // Copyright © 2025 Countly. All rights reserved. // import XCTest -import Countly class TestUtils { - + static let commonDeviceId: String = "deviceId" static let commonAppKey: String = "appkey" - static let host: String = "https://testing.count.ly/" - static let SDK_VERSION = "25.1.1" + static let host: String = "https://YOUR_SERVER" + static let SDK_VERSION = "26.8.0" static let SDK_NAME = "objc-native-ios" - - static func cleanup() -> Void { + + static func cleanup() { let config = createBaseConfig() - config.requiresConsent = false; - config.manualSessionHandling = true; - Countly.sharedInstance().start(with: config); - sleep(1){ + config.requiresConsent = false + config.manualSessionHandling = true + Countly.sharedInstance().start(with: config) + sleep(1) { + // All cleanup logic now handled inside Countly.halt(true) Countly.sharedInstance().halt(true) - sleep(1){} + sleep(1) {} } } - + static func getCurrentRQ() -> [String]? { - return CountlyPersistency.sharedInstance().value(forKey: "queuedRequests") as? [String]; + return CountlyPersistency.sharedInstance().value(forKey: "queuedRequests") as? [String] } - + static func getCurrentEQ() -> [CountlyEvent]? { - return CountlyPersistency.sharedInstance().value(forKey: "recordedEvents") as? [CountlyEvent]; + return CountlyPersistency.sharedInstance().value(forKey: "recordedEvents") as? [CountlyEvent] } - - static func sleep(_ seconds: TimeInterval, _ job: () -> Void){ + + /// Generous ceiling for waits on asynchronous work. XCTestExpectation waits return as soon + /// as they are fulfilled, so a large timeout costs nothing when things work and stops the + /// CI runners, which are slower and resolve DNS differently, from tripping over a value + /// tuned on a developer machine. + static let asyncTimeout: TimeInterval = 20.0 + + /// Polls `condition` until it holds or `timeout` elapses, pumping the run loop meanwhile, + /// and reports whether it held. Returns as soon as it holds, so a satisfied wait costs no + /// wall clock and a slower or loaded machine gets the time it actually needs. Prefer this + /// over `sleep` whenever the thing being waited for is observable: a fixed sleep either + /// wastes time or, on CI, expires before the work has landed. + @discardableResult + static func waitUntil( + _ description: String, timeout: TimeInterval = 10.0, _ condition: () -> Bool + ) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while !condition() && Date() < deadline { + RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.02)) + } + // Deliberately does not assert: this is a best-effort wait, and callers that need the + // condition to hold assert on it themselves. Failing here instead would abort flows + // mid-way and leave SDK state behind for the next test. + return condition() + } + + static func sleep(_ seconds: TimeInterval, _ job: () -> Void) { let exp = XCTestExpectation(description: "Run after \(seconds) seconds") let result = XCTWaiter.wait(for: [exp], timeout: seconds) if result == XCTWaiter.Result.timedOut { @@ -44,45 +70,82 @@ class TestUtils { XCTFail("Delay interrupted") } } - - 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){ - let requestStr = getCurrentRQ()![idx] + + /// Returns the request at `idx` in `source`, or in the persisted request queue when no + /// source is given. Records a failure and returns nil when the queue is missing or holds + /// fewer requests than that, so a test whose SDK queued fewer requests than expected fails + /// on its assertions instead of trapping on the subscript and killing the whole test runner. + static func requestAt(_ idx: Int, from source: [String]? = nil) -> String? { + guard let rq = source ?? getCurrentRQ() else { + XCTFail("Request queue is nil.") + return nil + } + guard rq.indices.contains(idx) else { + XCTFail("Request index \(idx) out of bounds. RQ count: \(rq.count).") + return nil + } + return rq[idx] + } + + static func validateRequest(_ params: [String: Any], _ idx: Int, _ customValidator: ([String: Any]) -> Void, from source: [String]? = nil) { + guard let requestStr = requestAt(idx, from: source) else { + return + } + let request = parseQueryString(requestStr) validateRequiredParams(request) - + for (key, value) in params { let reqValue = request[key] - + guard let reqValue else { + XCTFail("Missing key '\(key)' in request. Expected value: \(value), Request:\(requestStr)") + continue + } + if let nestedMap = value as? [String: Any] { - let nestedReqValue = reqValue as! [String: Any] + guard let nestedReqValue = reqValue as? [String: Any] else { + XCTFail("Key '\(key)' expected to be nested dictionary but got: \(reqValue)") + continue + } + for (nestedKey, nestedValue) in nestedMap { XCTAssertEqual("\(String(describing: nestedReqValue[nestedKey]))", "\(nestedValue)") } XCTAssertEqual(nestedMap.count, nestedReqValue.count) } else { - XCTAssertEqual("\(String(describing: reqValue!))", "\(value)") + XCTAssertEqual("\(String(describing: reqValue))", "\(value)") } } - + customValidator(request) } - - static func validateEventInRQ(_ eventName: String, _ segmentation: [String: Any], _ idx: Int, _ rqCount: Int, _ eventIdx: Int, _ eventCount: Int) throws { - let requestStr = getCurrentRQ()![idx] + + static func validateEventInRQ( + _ eventName: String, _ segmentation: [String: Any], _ idx: Int, _ rqCount: Int, _ eventIdx: Int, + _ eventCount: Int, from source: [String]? = nil + ) throws { + guard let requestStr = requestAt(idx, from: source) else { + return + } + let request = parseQueryString(requestStr) validateRequiredParams(request) - + if let eventsStr = request["events"] as? String, - let data = eventsStr.data(using: .utf8) { + let data = eventsStr.data(using: .utf8) { do { let json = try JSONSerialization.jsonObject(with: data, options: []) // Optionally cast it if let eventArray = json as? [[String: Any]] { XCTAssertEqual(eventCount, eventArray.count) + guard eventArray.indices.contains(eventIdx) else { + XCTFail("Event index \(eventIdx) out of bounds. Event count: \(eventArray.count).") + return + } let event = eventArray[eventIdx] XCTAssertEqual(event["key"] as? String, eventName) XCTAssertEqual(event["count"] as? Int, 1) @@ -92,13 +155,22 @@ class TestUtils { let duration = (event["dur"] as? Double) ?? 0.0 XCTAssertEqual(duration, 0, accuracy: 0.0001) - - if(!segmentation.isEmpty){ + + if !segmentation.isEmpty { if let eventSegmentation = event["segmentation"] as? [String: Any] { - XCTAssertEqual(eventSegmentation.count, segmentation.count, "Expected segmentation: \(segmentation), got: \(eventSegmentation)") + XCTAssertEqual( + eventSegmentation.count, segmentation.count, + "Expected segmentation: \(segmentation), got: \(eventSegmentation)") for (key, value) in segmentation { - let segValue = eventSegmentation[key] - XCTAssertEqual("\(String(describing: segValue!))", "\(value)") + guard let segValue = eventSegmentation[key] else { + let msg = + "Missing segmentation key '\(key)' in event." + + " Expected: \(segmentation)," + + " got: \(eventSegmentation)" + XCTFail(msg) + continue + } + XCTAssertEqual("\(String(describing: segValue))", "\(value)") } } else { XCTFail("Missing or invalid 'segmentation' in event") @@ -136,23 +208,23 @@ class TestUtils { } //XCTFail((request["events"] as? String)!) //ModuleEventsTests.validateEventInRQ( - // TestUtils.commonDeviceId, - // eventName, - // segmentation, - //1, - //0.0, - //0.0, - //"_CLY_", - //"_CLY_", - //"_CLY_", - //"_CLY_", - //idx, - //rqCount, - //eventIdx, - //eventCount - // ) + // TestUtils.commonDeviceId, + // eventName, + // segmentation, + //1, + //0.0, + //0.0, + //"_CLY_", + //"_CLY_", + //"_CLY_", + //"_CLY_", + //idx, + //rqCount, + //eventIdx, + //eventCount + // ) } - + static func validateId(_ id: String?, _ toValidate: String?, _ name: String) { if let id = id, id == "_CLY_" { if let val = toValidate, !val.isEmpty { @@ -172,7 +244,8 @@ class TestUtils { let timestampStr = String(val.suffix(13)) let base64Str = String(val.prefix(val.count - 13)) - let matches = base64Pattern.numberOfMatches(in: base64Str, range: NSRange(location: 0, length: base64Str.utf16.count)) + let matches = base64Pattern.numberOfMatches( + in: base64Str, range: NSRange(location: 0, length: base64Str.utf16.count)) if matches > 0, let timestamp = Int64(timestampStr) { let date = Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000) let calendar = Calendar(identifier: .gregorian) @@ -188,97 +261,119 @@ class TestUtils { } } - static func validateSdkIdentityParams(_ params: [String: Any]) { - XCTAssertEqual(SDK_VERSION, params["sdk_version"] as? String) - XCTAssertEqual(SDK_NAME, params["sdk_name"] as? String) + XCTAssertEqual(SDK_VERSION, params["sdk_version"] as? String) + XCTAssertEqual(SDK_NAME, params["sdk_name"] as? String) + } + + static func validateRequiredParams(_ params: [String: Any]) { + guard let hour = (params["hour"] as? String).flatMap({ Int($0) }), + let dow = (params["dow"] as? String).flatMap({ Int($0) }), + let timeZone = (params["tz"] as? String).flatMap({ Int($0) }), + let timestamp = (params["timestamp"] as? String).flatMap({ Int($0) }) + else { + XCTFail("Missing or non-integer hour, dow, tz or timestamp in request: \(params)") + return + } + + validateSdkIdentityParams(params) + XCTAssertEqual(commonDeviceId, params["device_id"] as? String) + XCTAssertEqual(commonAppKey, params["app_key"] as? String) + XCTAssertEqual(CountlyDeviceInfo.appVersion(), params["av"] as? String) + XCTAssertEqual("0", params["t"] as? String) + XCTAssertTrue(timestamp > 0) + XCTAssertTrue(hour >= 0 && hour < 24) + XCTAssertTrue(dow >= 0 && dow < 7) + XCTAssertTrue(timeZone >= -720 && timeZone <= 840) } - - static func validateRequiredParams(_ params: [String: Any]) { - guard let hour = Int((params["hour"] as? String)!), - let dow = Int((params["dow"] as? String)!), - let tz = Int((params["tz"] as? String)!), - let timestamp = Int((params["timestamp"] as? String)!) else { - XCTFail("Invalid parameter types") - return - } - - validateSdkIdentityParams(params) - XCTAssertEqual(commonDeviceId, params["device_id"] as? String) - XCTAssertEqual(commonAppKey, params["app_key"] as? String) - XCTAssertEqual(CountlyDeviceInfo.appVersion(), params["av"] as? String) - XCTAssertEqual("0", params["t"] as? String) - XCTAssertTrue(timestamp > 0) - XCTAssertTrue(hour >= 0 && hour < 24) - XCTAssertTrue(dow >= 0 && dow < 7) - XCTAssertTrue(tz >= -720 && tz <= 840) - } - + static func parseQueryString(_ queryString: String) -> [String: Any] { var result: [String: Any] = [:] - - // Split the query string by '&' to get individual key-value pairs + + // Split query string into pairs let pairs = queryString.split(separator: "&") - + for pair in pairs { - // Split each pair by '=' to separate the key and value - let components = pair.split(separator: "=", maxSplits: 1) - - if components.count == 2 { - let key = String(components[0]) - let value = String(components[1]) - - // If the value is a JSON string (starts and ends with '%7B' and '%7D' respectively after URL decoding), decode it - if let decodedValue = value.removingPercentEncoding, decodedValue.hasPrefix("{"), decodedValue.hasSuffix("}") { - if let jsonData = decodedValue.data(using: .utf8) { - do { - let jsonObject = try JSONSerialization.jsonObject(with: jsonData, options: []) - result[key] = jsonObject - continue - } catch { - print("Error decoding JSON for key \(key): \(error)") - } - } + // Always split into 2 parts; missing value becomes empty string + let components = pair.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + + guard components.count == 2 else { + continue + } + + let key = String(components[0]) + let value = String(components[1]) // <-- empty string stays empty string + + let decodedKey = key.removingPercentEncoding ?? key + let decodedValue = value.removingPercentEncoding ?? value + + // JSON detection (only if non-empty) + if !decodedValue.isEmpty, + decodedValue.hasPrefix("{"), + decodedValue.hasSuffix("}"), + let jsonData = decodedValue.data(using: .utf8) + { + do { + let jsonObject = try JSONSerialization.jsonObject(with: jsonData, options: []) + result[decodedKey] = jsonObject + continue + } catch { } - - // Otherwise, simply assign the value to the key in the result dictionary - result[key] = value.removingPercentEncoding ?? value } + + // Assign empty string value properly + result[decodedKey] = decodedValue } - + return result } - - static func compareDictionaries(_ dict1: [String: Any],_ dict2: [String: Any]) -> Bool { + + /// 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 } - + for (key, value) in dict1 { guard let otherValue = dict2[key] else { return false } - + if let nestedDict1 = value as? [String: Any], let nestedDict2 = otherValue as? [String: Any] { if !compareDictionaries(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 } } - + return true } - + static func createBaseConfig() -> CountlyConfig { let config: CountlyConfig = CountlyConfig() config.appKey = commonAppKey config.deviceID = commonDeviceId config.host = host config.enableDebug = true - config.features = [CLYFeature.crashReporting]; + config.features = [CLYFeature.crashReporting] + // Hostless simulator runs can never report an orientation; off keeps hosted runs (device farm) identical. + config.enableOrientationTracking = false return config } - + } diff --git a/ios/src/CountlyUserDetails.h b/ios/src/CountlyUserDetails.h index 35f6ef32..5b2d0536 100644 --- a/ios/src/CountlyUserDetails.h +++ b/ios/src/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/ios/src/CountlyUserDetails.m b/ios/src/CountlyUserDetails.m index 2bb75188..07e91296 100644 --- a/ios/src/CountlyUserDetails.m +++ b/ios/src/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,51 @@ - (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]; + // a local picture path set to NSNull clears the picture, with the empty string the server clears a field on + if (self.pictureLocalPath == NSNull.null && !self.pictureURL) + userDictionary[kCountlyUDKeyPicture] = @""; + + 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 (customAll.count > 0) + userDictionary[kCountlyUDKeyCustom] = customAll; - if (self.custom) - userDictionary[kCountlyUDKeyCustom] = self.custom; + CLY_LOG_D(@"%s user details serialized, fields: [%@], customPropertyCount: [%lu]", __FUNCTION__, userDictionary.allKeys, (unsigned long)customAll.count); + CLY_LOG_D(@"%s serialized user details dictionary value detail, dictionary: [%@]", __FUNCTION__, userDictionary); - if (userDictionary.allKeys.count) + if (userDictionary.count > 0) return [userDictionary cly_JSONify]; return nil; @@ -109,6 +125,8 @@ - (NSString *)serializedUserDetails - (void)clearUserDetails { + CLY_LOG_D(@"%s user details will be cleared, customPropertyCount: [%lu], modificationCount: [%lu]", __FUNCTION__, (unsigned long)self.customProperties.count, (unsigned long)self.customMods.count); + self.name = nil; self.username = nil; self.email = nil; @@ -120,7 +138,8 @@ - (void)clearUserDetails self.birthYear = nil; self.custom = nil; - [self.modifications removeAllObjects]; + [self.customMods removeAllObjects]; + [self.customProperties removeAllObjects]; } - (BOOL)hasUnsyncedChanges @@ -146,301 +165,451 @@ - (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; + CLY_LOG_I(@"%s custom user property will be set with a string value, key: [%@], valueLength: [%lu]", __FUNCTION__, key, (unsigned long)value.length); + [self setCustomProperty:key value:value]; } - (void)set:(NSString *)key numberValue:(NSNumber *)value { - CLY_LOG_I(@"%s %@ %@", __FUNCTION__, key, value); - - self.modifications[key] = value.copy; + CLY_LOG_I(@"%s custom user property will be set with a number value, key: [%@]", __FUNCTION__, key); + [self setCustomProperty:key value:value]; } - (void)set:(NSString *)key boolValue:(BOOL)value { - CLY_LOG_I(@"%s %@ %d", __FUNCTION__, key, value); - - self.modifications[key] = @(value); + CLY_LOG_I(@"%s custom user property will be set with a bool value, key: [%@]", __FUNCTION__, key); + [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) + if (key == nil || value == nil) { - CLY_LOG_W(@"%s call will be ignored as value is nil!", __FUNCTION__); + CLY_LOG_E(@"%s call will be ignored as key or value is nil, keyProvided: [%@]", __FUNCTION__, (key != nil) ? @"YES" : @"NO"); + return; + } + if (![CountlyServerConfig.sharedInstance shouldRecordUserProperty:key]) { + CLY_LOG_D(@"%s custom property key is filtered out by server config user property filter, key: [%@], omitting call", __FUNCTION__, key); + return; + } + if (![self isValidDataType:value]) { + CLY_LOG_W(@"%s custom property value type is not supported, key: [%@], type: [%@], omitting call", + __FUNCTION__, key, NSStringFromClass([value class])); + CLY_LOG_D(@"%s rejected custom property value detail, key: [%@], value: [%@]", __FUNCTION__, key, value); 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; + CLY_LOG_D(@"%s custom user property stored, key: [%@], totalCustomProperties: [%lu]", __FUNCTION__, truncatedKey, (unsigned long)self.customProperties.count); + CLY_LOG_D(@"%s stored custom user property value detail, key: [%@], value: [%@]", __FUNCTION__, 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 setOnce will be applied with a string value, key: [%@], valueLength: [%lu]", __FUNCTION__, key, (unsigned long)value.length); + [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}; + CLY_LOG_I(@"%s setOnce will be applied with a number value, key: [%@]", __FUNCTION__, key); + [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)}; + CLY_LOG_I(@"%s setOnce will be applied with a bool value, key: [%@]", __FUNCTION__, key); + [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); + CLY_LOG_I(@"%s custom user property will be unset, key: [%@]", __FUNCTION__, key); + + if (key == nil) + { + CLY_LOG_E(@"%s unset call will be ignored as key is nil", __FUNCTION__); + 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 { - CLY_LOG_I(@"%s %@", __FUNCTION__, key); + CLY_LOG_I(@"%s user property will be incremented by [1], key: [%@]", __FUNCTION__, key); [self incrementBy:key value:@1]; } - (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}; + CLY_LOG_I(@"%s user property will be incremented, key: [%@]", __FUNCTION__, key); + [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}; + CLY_LOG_I(@"%s user property will be multiplied, key: [%@]", __FUNCTION__, key); + [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}; + CLY_LOG_I(@"%s max modifier will be applied to user property, key: [%@]", __FUNCTION__, key); + [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}; -} + CLY_LOG_I(@"%s min modifier will be applied to user property, key: [%@]", __FUNCTION__, key); + [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}; + CLY_LOG_I(@"%s push will be applied with a string value, key: [%@], valueLength: [%lu]", __FUNCTION__, key, (unsigned long)value.length); + [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 push will be applied with a number value, key: [%@]", __FUNCTION__, key); + [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)}; + CLY_LOG_I(@"%s push will be applied with a bool value, key: [%@]", __FUNCTION__, key); + [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}; + CLY_LOG_I(@"%s push will be applied with multiple values, key: [%@], elementCount: [%lu]", __FUNCTION__, key, (unsigned long)value.count); + [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}; + CLY_LOG_I(@"%s pushUnique will be applied with a string value, key: [%@], valueLength: [%lu]", __FUNCTION__, key, (unsigned long)value.length); + [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}; + CLY_LOG_I(@"%s pushUnique will be applied with a number value, key: [%@]", __FUNCTION__, key); + [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)}; + CLY_LOG_I(@"%s pushUnique will be applied with a bool value, key: [%@]", __FUNCTION__, key); + [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}; + CLY_LOG_I(@"%s pushUnique will be applied with multiple values, key: [%@], elementCount: [%lu]", __FUNCTION__, key, (unsigned long)value.count); + [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}; + CLY_LOG_I(@"%s pull will be applied with a string value, key: [%@], valueLength: [%lu]", __FUNCTION__, key, (unsigned long)value.length); + [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}; + CLY_LOG_I(@"%s pull will be applied with a number value, key: [%@]", __FUNCTION__, key); + [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)}; + CLY_LOG_I(@"%s pull will be applied with a bool value, key: [%@]", __FUNCTION__, key); + [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}; + CLY_LOG_I(@"%s pull will be applied with multiple values, key: [%@], elementCount: [%lu]", __FUNCTION__, key, (unsigned long)value.count); + [self doModification:kCountlyUDKeyModifierPull key:key value:value]; } - (void)save { - CLY_LOG_I(@"%s", __FUNCTION__); + CLY_LOG_I(@"%s user details will be saved", __FUNCTION__); if (!CountlyCommon.sharedInstance.hasStarted) + { + CLY_LOG_W(@"%s user details save will be ignored as SDK is not initialized yet", __FUNCTION__); return; + } if (!CountlyConsentManager.sharedInstance.consentForUserDetails) + { + CLY_LOG_D(@"%s no consent for user details, save will be skipped", __FUNCTION__); return; + } // Returns early if user properties values are not changed if (![self hasUnsyncedChanges]) + { + CLY_LOG_D(@"%s no unsynced user details changes, save will be skipped", __FUNCTION__); return; + } [CountlyConnectionManager.sharedInstance sendEvents]; NSString* userDetails = [self serializedUserDetails]; if (userDetails) + { + CLY_LOG_D(@"%s serialized user details request will be sent, payloadLength: [%lu]", __FUNCTION__, (unsigned long)userDetails.length); + CLY_LOG_D(@"%s serialized user details payload detail, payload: [%@]", __FUNCTION__, userDetails); [CountlyConnectionManager.sharedInstance sendUserDetails:userDetails]; + } - if (self.pictureLocalPath && !self.pictureURL) + // NSNull clears the picture, which the serialized details above already carry, so only a path goes out on its own + if ([self.pictureLocalPath isKindOfClass:NSString.class] && !self.pictureURL) + { +#if (TARGET_OS_IOS || TARGET_OS_VISION) + CLY_LOG_D(@"%s local picture path will be sent in a separate request, valueType: [%@]", __FUNCTION__, NSStringFromClass([self.pictureLocalPath class])); + CLY_LOG_D(@"%s local picture path value detail, value: [%@]", __FUNCTION__, self.pictureLocalPath); [CountlyConnectionManager.sharedInstance sendUserDetails:[@{kCountlyLocalPicturePath: self.pictureLocalPath} cly_JSONify]]; +#else + // the picture upload is built on UIImage, so on these platforms only the path itself would reach the server + CLY_LOG_W(@"%s a local picture can be uploaded on iOS and visionOS only, the local picture path will not be sent", __FUNCTION__); +#endif + } + + [self clearUserDetails]; +} - if (self.modifications.count) +- (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 modification will be ignored as value is nil, modifier: [%@], key: [%@]", __FUNCTION__, mod, key); + return; } + if (![CountlyServerConfig.sharedInstance shouldRecordUserProperty:key]) { + CLY_LOG_D(@"%s modifier key is filtered out by server config user property filter, key: [%@], omitting call", __FUNCTION__, key); + return; + } + if (![self isValidDataType:value]) { + CLY_LOG_W(@"%s modifier value type is not supported, modifier: [%@], key: [%@], type: [%@], omitting call", + __FUNCTION__, mod, key, NSStringFromClass([value class])); + CLY_LOG_D(@"%s rejected modifier value detail, modifier: [%@], key: [%@], value: [%@]", __FUNCTION__, mod, key, value); + 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}; + } + CLY_LOG_D(@"%s modification stored, modifier: [%@], key: [%@], totalModifications: [%lu]", __FUNCTION__, mod, truncatedKey, (unsigned long)self.customMods.count); + CLY_LOG_D(@"%s stored modification value detail, modifier: [%@], key: [%@], value: [%@]", __FUNCTION__, mod, truncatedKey, 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_W(@"%s call will be ignored as no data was provided", __FUNCTION__); + return; + } + + CLY_LOG_I(@"%s user properties will be applied, keys: [%@], count: [%lu]", __FUNCTION__, data.allKeys, (unsigned long)data.count); + + 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_D(@"%s provided value is null, key: [%@], skipping this entry", __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 server config user property filter while setting properties, key: [%@], omitting entry", __FUNCTION__, key); + continue; + } + NSString* truncatedKey = [[key description] cly_truncatedKey:truncatedLog]; + if ([self isValidDataType:value]) { + self.customProperties[truncatedKey] = value; + CLY_LOG_D(@"%s stored user property value detail, key: [%@], value: [%@]", __FUNCTION__, truncatedKey, value); + anyChange = YES; + } else { + CLY_LOG_D(@"%s provided value type is not supported, key: [%@], type: [%@], omitting entry", __FUNCTION__, + key, NSStringFromClass([value class])); + CLY_LOG_D(@"%s rejected user property entry value detail, key: [%@], value: [%@]", __FUNCTION__, key, value); + } + } + } + + CLY_LOG_D(@"%s user properties processing finished, anyChange: [%@]", __FUNCTION__, anyChange ? @"YES" : @"NO"); + if (anyChange) [self userPropertiesChanged]; } +- (void)serializeStringField:(id)field + key:(NSString *)key + explanation:(NSString *)explanation + into:(NSMutableDictionary *)userDictionary + picture:(BOOL)isPicture +{ + if (!field) return; + + CLY_LOG_D(@"%s user details string field value detail, explanation: [%@], key: [%@], value: [%@]", __FUNCTION__, explanation, key, field); + + 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]; + + CLY_LOG_D(@"%s predefined user property will be assigned, key: [%@], isNull: [%@]", __FUNCTION__, key, isNull ? @"YES" : @"NO"); + CLY_LOG_D(@"%s predefined user property value detail, key: [%@], value: [%@]", __FUNCTION__, key, value); + + 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, pathLength: [%lu], value will be dropped", __FUNCTION__, (unsigned long)path.length); + CLY_LOG_D(@"%s unreadable picture path value detail, path: [%@]", __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 provided byear value can not be parsed as a number, valueLength: [%lu]", __FUNCTION__, (unsigned long)((NSString *)value).length); + CLY_LOG_D(@"%s unparsable byear value detail, value: [%@]", __FUNCTION__, value); + return NO; + } + } else { + return NO; + } + + self.birthYear = parsed; + } + } else { + return NO; + } + + return YES; +} - (void)filterAndLimitUserProperties:(NSMutableDictionary *)properties { @@ -450,56 +619,62 @@ - (void)filterAndLimitUserProperties:(NSMutableDictionary *)properties for (NSString *key in properties.allKeys) { if (![CountlyServerConfig.sharedInstance shouldRecordUserProperty:key]) { - CLY_LOG_D(@"Filtering out user property '%@' by server config user property filter", key); + CLY_LOG_D(@"%s user property is filtered out by server config user property filter, key: [%@], removing", __FUNCTION__, key); [properties removeObjectForKey:key]; } else if (shouldApplyLimit && ++kept > limit) { - CLY_LOG_D(@"Removing user property '%@' due to cache limit (%ld)", key, (long)limit); + CLY_LOG_D(@"%s user property will be removed due to cache limit [%ld], key: [%@]", __FUNCTION__, (long)limit, key); [properties removeObjectForKey:key]; } } } -- (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) + { + CLY_LOG_D(@"%s SDK is not started yet, pending events will not be flushed", __FUNCTION__); + 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 user property will be set, key: [%@], valueType: [%@]", __FUNCTION__, key, (value != nil) ? NSStringFromClass([value class]) : @"nil"); + + 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 { + if (data == nil) { + CLY_LOG_E(@"%s call will be ignored as provided data is nil", __FUNCTION__); + return; + } + + [self setPropertiesInternal:data]; +} + +- (void)clear { + CLY_LOG_I(@"%s all user details and modifications will be cleared", __FUNCTION__); + [self clearUserDetails]; +} @end diff --git a/ios/src/CountlyViewTrackingInternal.h b/ios/src/CountlyViewTrackingInternal.h index 4f342350..5a40a2a9 100644 --- a/ios/src/CountlyViewTrackingInternal.h +++ b/ios/src/CountlyViewTrackingInternal.h @@ -12,9 +12,9 @@ extern NSString* const kCountlyCurrentView; extern NSString* const kCountlyPreviousView; extern NSString* const kCountlyPreviousEventName; extern NSString* const kCountlyVTKeyVisit; +extern NSString* const kCountlyVTKeyName; -@interface CountlyViewTrackingInternal : NSObject -@property (nonatomic) BOOL isEnabledOnInitialConfig; +@interface CountlyViewTrackingInternal : NSObject @property (nonatomic) NSString* currentViewID; @property (nonatomic) NSString* previousViewID; @@ -26,11 +26,11 @@ extern NSString* const kCountlyVTKeyVisit; + (instancetype)sharedInstance; -#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV) +// Automatic view tracking is only implemented for iOS and tvOS, so the guard must not include +// visionOS: the methods below would be declared but have no definition there +#if (TARGET_OS_IOS || TARGET_OS_TV) - (void)startAutoViewTracking; - (void)stopAutoViewTracking; -- (void)addExceptionForAutoViewTracking:(NSString *)exception; -- (void)removeExceptionForAutoViewTracking:(NSString *)exception; @property (nonatomic) BOOL isAutoViewTrackingActive; #endif @@ -52,7 +52,7 @@ extern NSString* const kCountlyVTKeyVisit; - (void)addSegmentationToViewWithID:(NSString *)viewID segmentation:(NSDictionary *)segmentation; - (void)addSegmentationToViewWithName:(NSString *)viewName segmentation:(NSDictionary *)segmentation; -#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV) +#if (TARGET_OS_IOS || TARGET_OS_TV) - (void)addAutoViewTrackingExclutionList:(NSArray *)viewTrackingExclusionList; #endif @end diff --git a/ios/src/CountlyViewTrackingInternal.m b/ios/src/CountlyViewTrackingInternal.m index 8407db6d..3375f78d 100644 --- a/ios/src/CountlyViewTrackingInternal.m +++ b/ios/src/CountlyViewTrackingInternal.m @@ -11,6 +11,10 @@ @interface CountlyViewTrackingInternal () @property (nonatomic) NSMutableSet* automaticViewTrackingExclusionList; #endif @property (nonatomic, strong) NSMutableDictionary * viewDataDictionary; +//NOTE: Maps every view ID ever handed out to the host onto the ID its view is open under now. +// A view stopped on background is started again on foreground under a fresh ID, but the host +// still holds the ID it got from `startView`, so the ID based calls resolve through this. +@property (nonatomic, strong) NSMutableDictionary * restartedViewIDs; @property (nonatomic) NSMutableDictionary* viewSegmentation; @property (nonatomic) BOOL isFirstView; @end @@ -41,17 +45,30 @@ - (void)Countly_viewDidDisappear:(BOOL)animated; @implementation CountlyViewTrackingInternal +static CountlyViewTrackingInternal* s_sharedInstance = nil; +static dispatch_once_t onceToken; + + (instancetype)sharedInstance { if (!CountlyCommon.sharedInstance.hasStarted) return nil; - static CountlyViewTrackingInternal* s_sharedInstance = nil; - static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{s_sharedInstance = self.new;}); return s_sharedInstance; } +//NOTE: Dropping the singleton is what clears the view state and the one-way configuration +// flags (enablePreviousNameRecording, isManualViewRestartActive, isAutoViewTrackingActive); +// `init` re-establishes the defaults on the next start. +// The UIViewController swizzling is guarded by its own process-wide flag, so it is not +// applied twice. +- (void)resetInstance +{ + CLY_LOG_I(@"%s", __FUNCTION__); + onceToken = 0; + s_sharedInstance = nil; +} + - (instancetype)init { if (self = [super init]) @@ -60,6 +77,7 @@ - (instancetype)init self.automaticViewTrackingExclusionList = @[ @"CLYInternalViewController", + @"CountlyWebViewController", @"UINavigationController", @"UIAlertController", @"UIPageViewController", @@ -102,6 +120,7 @@ - (instancetype)init #endif self.viewDataDictionary = NSMutableDictionary.new; + self.restartedViewIDs = NSMutableDictionary.new; self.viewSegmentation = nil; self.isFirstView = YES; self.isManualViewRestartActive = YES; @@ -132,9 +151,14 @@ - (NSArray *)reservedViewTrackingSegmentationKeys - (void)setGlobalViewSegmentation:(NSMutableDictionary *)segmentation { - CLY_LOG_I(@"%s %@", __FUNCTION__, segmentation); + CLY_LOG_I(@"%s global view segmentation will be set, key count: [%lu], keys: [%@]", __FUNCTION__, (unsigned long)segmentation.count, segmentation.allKeys); + CLY_LOG_D(@"%s global view segmentation value detail, segmentation: [%@]", __FUNCTION__, segmentation); NSMutableDictionary *mutableSegmentation = segmentation.mutableCopy; [mutableSegmentation removeObjectsForKeys:self.reservedViewTrackingSegmentationKeys]; + if (mutableSegmentation.count != segmentation.count) + { + CLY_LOG_W(@"%s reserved keys will be dropped while setting global view segmentation, droppedKeyCount: [%lu], keptKeyCount: [%lu]", __FUNCTION__, (unsigned long)(segmentation.count - mutableSegmentation.count), (unsigned long)mutableSegmentation.count); + } NSDictionary *filteredSegmentation = mutableSegmentation.cly_filterSupportedDataTypes; self.viewSegmentation = filteredSegmentation.mutableCopy; @@ -142,23 +166,29 @@ - (void)setGlobalViewSegmentation:(NSMutableDictionary *)segmentation - (void)updateGlobalViewSegmentation:(NSDictionary *)segmentation { - CLY_LOG_I(@"%s %@", __FUNCTION__, segmentation); + CLY_LOG_I(@"%s global view segmentation will be updated, key count: [%lu], keys: [%@]", __FUNCTION__, (unsigned long)segmentation.count, segmentation.allKeys); + CLY_LOG_D(@"%s global view segmentation update value detail, segmentation: [%@]", __FUNCTION__, segmentation); if (!self.viewSegmentation) { self.viewSegmentation = NSMutableDictionary.new; } - + NSMutableDictionary *mutableSegmentation = segmentation.mutableCopy; [mutableSegmentation removeObjectsForKeys:self.reservedViewTrackingSegmentationKeys]; + if (mutableSegmentation.count != segmentation.count) + { + CLY_LOG_W(@"%s reserved keys will be dropped while updating global view segmentation, droppedKeyCount: [%lu], keptKeyCount: [%lu]", __FUNCTION__, (unsigned long)(segmentation.count - mutableSegmentation.count), (unsigned long)mutableSegmentation.count); + } NSDictionary *filteredSegmentation = mutableSegmentation.cly_filterSupportedDataTypes; [self.viewSegmentation addEntriesFromDictionary:filteredSegmentation]; } - (NSString *)startView:(NSString *)viewName segmentation:(NSDictionary *)segmentation { - CLY_LOG_I(@"%s %@ %@", __FUNCTION__, viewName, segmentation); + CLY_LOG_I(@"%s start view requested, name: [%@], segmentation key count: [%lu], segmentation keys: [%@]", __FUNCTION__, viewName, (unsigned long)segmentation.count, segmentation.allKeys); + CLY_LOG_D(@"%s start view segmentation value detail, name: [%@], segmentation: [%@]", __FUNCTION__, viewName, segmentation); #if (TARGET_OS_IOS || TARGET_OS_TV) if (self.isAutoViewTrackingActive) { - CLY_LOG_W(@"%s Manually start view tracking is not allowed when automatic tracking is enabled!", __FUNCTION__); + CLY_LOG_W(@"%s manual start view is not allowed while automatic view tracking is active, name: [%@] will not be started", __FUNCTION__, viewName); return nil; } #endif @@ -168,10 +198,11 @@ - (NSString *)startView:(NSString *)viewName segmentation:(NSDictionary *)segmen - (NSString *)startAutoStoppedView:(NSString *)viewName segmentation:(NSDictionary *)segmentation { - CLY_LOG_I(@"%s %@ %@", __FUNCTION__, viewName, segmentation); + CLY_LOG_I(@"%s start auto stopped view requested, name: [%@], segmentation key count: [%lu], segmentation keys: [%@]", __FUNCTION__, viewName, (unsigned long)segmentation.count, segmentation.allKeys); + CLY_LOG_D(@"%s start auto stopped view segmentation value detail, name: [%@], segmentation: [%@]", __FUNCTION__, viewName, segmentation); #if (TARGET_OS_IOS || TARGET_OS_TV) if (self.isAutoViewTrackingActive) { - CLY_LOG_W(@"%s Manually start view tracking is not allowed when automatic tracking is enabled!", __FUNCTION__); + CLY_LOG_W(@"%s manual start auto stopped view is not allowed while automatic view tracking is active, name: [%@] will not be started", __FUNCTION__, viewName); return nil; } #endif @@ -181,10 +212,11 @@ - (NSString *)startAutoStoppedView:(NSString *)viewName segmentation:(NSDictiona - (void)stopViewWithName:(NSString *)viewName segmentation:(NSDictionary *)segmentation { - CLY_LOG_I(@"%s %@ %@", __FUNCTION__, viewName, segmentation); + CLY_LOG_I(@"%s stop view by name requested, name: [%@], segmentation key count: [%lu], segmentation keys: [%@]", __FUNCTION__, viewName, (unsigned long)segmentation.count, segmentation.allKeys); + CLY_LOG_D(@"%s stop view by name segmentation value detail, name: [%@], segmentation: [%@]", __FUNCTION__, viewName, segmentation); #if (TARGET_OS_IOS || TARGET_OS_TV) if (self.isAutoViewTrackingActive) { - CLY_LOG_W(@"%s Manually stop view tracking is not allowed when automatic tracking is enabled!", __FUNCTION__); + CLY_LOG_W(@"%s manual stop view by name is not allowed while automatic view tracking is active, name: [%@] will not be stopped", __FUNCTION__, viewName); return; } #endif @@ -194,10 +226,11 @@ - (void)stopViewWithName:(NSString *)viewName segmentation:(NSDictionary *)segme - (void)stopViewWithID:(NSString *)viewID segmentation:(NSDictionary *)segmentation { - CLY_LOG_I(@"%s %@ %@", __FUNCTION__, viewID, segmentation); + CLY_LOG_I(@"%s stop view by ID requested, view ID: [%@], segmentation key count: [%lu], segmentation keys: [%@]", __FUNCTION__, viewID, (unsigned long)segmentation.count, segmentation.allKeys); + CLY_LOG_D(@"%s stop view by ID segmentation value detail, view ID: [%@], segmentation: [%@]", __FUNCTION__, viewID, segmentation); #if (TARGET_OS_IOS || TARGET_OS_TV) if (self.isAutoViewTrackingActive) { - CLY_LOG_W(@"%s Manually stop view tracking is not allowed when automatic tracking is enabled!", __FUNCTION__); + CLY_LOG_W(@"%s manual stop view by ID is not allowed while automatic view tracking is active, view ID: [%@] will not be stopped", __FUNCTION__, viewID); return; } #endif @@ -206,10 +239,10 @@ - (void)stopViewWithID:(NSString *)viewID segmentation:(NSDictionary *)segmentat - (void)pauseViewWithID:(NSString *)viewID { - CLY_LOG_I(@"%s %@", __FUNCTION__, viewID); + CLY_LOG_I(@"%s pause view requested, view ID: [%@]", __FUNCTION__, viewID); #if (TARGET_OS_IOS || TARGET_OS_TV) if (self.isAutoViewTrackingActive) { - CLY_LOG_W(@"%s Manually pause view tracking is not allowed when automatic tracking is enabled!", __FUNCTION__); + CLY_LOG_W(@"%s manual pause view is not allowed while automatic view tracking is active, view ID: [%@] will not be paused", __FUNCTION__, viewID); return; } #endif @@ -218,10 +251,10 @@ - (void)pauseViewWithID:(NSString *)viewID } - (void)resumeViewWithID:(NSString *)viewID { - CLY_LOG_I(@"%s %@", __FUNCTION__, viewID); + CLY_LOG_I(@"%s resume view requested, view ID: [%@]", __FUNCTION__, viewID); #if (TARGET_OS_IOS || TARGET_OS_TV) if (self.isAutoViewTrackingActive) { - CLY_LOG_W(@"%s Manually resume view tracking is not allowed when automatic tracking is enabled!", __FUNCTION__); + CLY_LOG_W(@"%s manual resume view is not allowed while automatic view tracking is active, view ID: [%@] will not be resumed", __FUNCTION__, viewID); return; } #endif @@ -230,10 +263,11 @@ - (void)resumeViewWithID:(NSString *)viewID - (void)stopAllViews:(NSDictionary *)segmentation { - CLY_LOG_I(@"%s %@", __FUNCTION__, segmentation); + CLY_LOG_I(@"%s stop all views requested, segmentation key count: [%lu], segmentation keys: [%@]", __FUNCTION__, (unsigned long)segmentation.count, segmentation.allKeys); + CLY_LOG_D(@"%s stop all views segmentation value detail, segmentation: [%@]", __FUNCTION__, segmentation); #if (TARGET_OS_IOS || TARGET_OS_TV) if (self.isAutoViewTrackingActive) { - CLY_LOG_W(@"%s Manually stop view tracking is not allowed when automatic tracking is enabled!", __FUNCTION__); + CLY_LOG_W(@"%s manual stop all views is not allowed while automatic view tracking is active, no view will be stopped", __FUNCTION__); return; } #endif @@ -244,28 +278,45 @@ - (void)stopAllViews:(NSDictionary *)segmentation #if (TARGET_OS_IOS || TARGET_OS_TV) - (void)addAutoViewTrackingExclutionList:(NSArray *)viewTrackingExclusionList { + CLY_LOG_I(@"%s auto view tracking exclusion list will be extended, incoming count: [%lu], current count: [%lu]", __FUNCTION__, (unsigned long)viewTrackingExclusionList.count, (unsigned long)self.automaticViewTrackingExclusionList.count); [self.automaticViewTrackingExclusionList addObjectsFromArray:viewTrackingExclusionList]; } - (void)startAutoViewTracking { - if (!self.isEnabledOnInitialConfig) + // Gated on the resolved 'avt' value (seeded from the developer config, overridable by the server), + // so the server can force-enable automatic view tracking even when the developer did not opt in + if (!CountlyServerConfig.sharedInstance.automaticViewTrackingEnabled) + { + CLY_LOG_D(@"%s automatic view tracking is not enabled by behavior settings, it will not be started", __FUNCTION__); return; - + } + if (!CountlyConsentManager.sharedInstance.consentForViewTracking) + { + CLY_LOG_V(@"%s no consent for views, automatic view tracking will not be started", __FUNCTION__); return; - + } + self.isAutoViewTrackingActive = YES; - + [self swizzleViewTrackingMethods]; - + UIViewController* topVC = CountlyCommon.sharedInstance.topViewController; - NSString* viewTitle = [self titleForViewController:topVC]; - [self startViewInternal:viewTitle customSegmentation:nil]; + if (!topVC) + { + CLY_LOG_D(@"%s automatic view tracking enabled, no screen is on top yet, the first one to appear will be started", __FUNCTION__); + return; + } + + CLY_LOG_D(@"%s automatic view tracking enabled, top view will be started automatically, name: [%@]", __FUNCTION__, [self titleForViewController:topVC]); + // the same path as a screen that appears later, so it is auto stopped by the next one and the exclusion list applies + [self performAutoViewTrackingForViewController:topVC]; } - (void)stopAutoViewTracking { + CLY_LOG_D(@"%s automatic view tracking will be disabled, running view count: [%lu]", __FUNCTION__, (unsigned long)self.viewDataDictionary.count); self.isAutoViewTrackingActive = NO; // self.currentView = nil; @@ -274,47 +325,99 @@ - (void)stopAutoViewTracking - (void)setIsAutoViewTrackingActive:(BOOL)isAutoViewTrackingActive { - if (!self.isEnabledOnInitialConfig) - return; - - if (!CountlyConsentManager.sharedInstance.consentForViewTracking) - return; + // Only the enabling transition is gated. Enabling needs the resolved 'avt' value (seeded from the + // developer config, overridable by the server) and view tracking consent, so a server or consent + // driven disable can not be undone by the developer setter while automatic views would not be + // recorded anyway. Disabling is always allowed, so a server force-enabled tracker can be turned off + // and a consent revocation (which flips consent before calling 'stopAutoViewTracking') actually + // clears the active state instead of leaving it stuck on. + if (isAutoViewTrackingActive) + { + if (!CountlyServerConfig.sharedInstance.automaticViewTrackingEnabled) + { + CLY_LOG_D(@"%s automatic view tracking is not enabled by behavior settings, active state change to [%@] will be ignored", __FUNCTION__, isAutoViewTrackingActive ? @"YES" : @"NO"); + return; + } + + if (!CountlyConsentManager.sharedInstance.consentForViewTracking) + { + CLY_LOG_V(@"%s no consent for views, automatic view tracking active state change will be ignored", __FUNCTION__); + return; + } + } + if (_isAutoViewTrackingActive != isAutoViewTrackingActive) { + CLY_LOG_D(@"%s automatic view tracking active state changing from [%@] to [%@], all running views will be stopped", __FUNCTION__, _isAutoViewTrackingActive ? @"YES" : @"NO", isAutoViewTrackingActive ? @"YES" : @"NO"); [self stopAllViewsInternal:nil]; } - + _isAutoViewTrackingActive = isAutoViewTrackingActive; } +#endif -#pragma mark - Public methods Deprecated +#pragma mark - Internal methods old -- (void)addExceptionForAutoViewTracking:(NSString *)exception +/** + * Resolves a view ID the host holds onto the ID its view is open under now. + * @param viewID a view ID handed out by a start view call, at any point in the view's life + * @return the ID the view is currently open under, or @c viewID itself when it still resolves to a view + */ +- (NSString *)currentIDForViewID:(NSString *)viewID { - if (!exception.length) - return; - - [self.automaticViewTrackingExclusionList addObject:exception]; + NSString* currentID = self.restartedViewIDs[viewID]; + if (!currentID) + return viewID; + + CLY_LOG_V(@"%s view ID belongs to a view that was started again, it will be resolved, given view ID: [%@], current view ID: [%@]", __FUNCTION__, viewID, currentID); + return currentID; } -- (void)removeExceptionForAutoViewTracking:(NSString *)exception +/** + * Records that a view which was open under @c previousID is now open under @c currentID. + * Every ID already resolving to @c previousID is repointed, so an ID survives any number of restarts. + * @param previousID the ID the view was open under before it was started again + * @param currentID the ID the view is open under now + */ +- (void)repointViewID:(NSString *)previousID toCurrentID:(NSString *)currentID { - [self.automaticViewTrackingExclusionList removeObject:exception]; + if (!previousID.length || !currentID.length) + return; + + NSArray* handedOutIDs = [self.restartedViewIDs allKeysForObject:previousID]; + for (NSString* handedOutID in handedOutIDs) + { + self.restartedViewIDs[handedOutID] = currentID; + } + + //NOTE: An ID something already resolves to was created by an earlier restart and never reached the host, so it needs no entry of its own. + if (!handedOutIDs.count) + self.restartedViewIDs[previousID] = currentID; } -#endif +/** + * Drops every handed out view ID that resolves to @c viewID, called when its view is really stopped. + * @param viewID the ID the stopped view was open under + */ +- (void)forgetViewID:(NSString *)viewID +{ + [self.restartedViewIDs removeObjectsForKeys:[self.restartedViewIDs allKeysForObject:viewID]]; + [self.restartedViewIDs removeObjectForKey:viewID]; +} -#pragma mark - Internal methods old - (void)stopViewWithNameInternal:(NSString *) viewName customSegmentation:(NSDictionary *)customSegmentation { if (!viewName || !viewName.length) { - CLY_LOG_D(@"%s View name should not be null or empty", __FUNCTION__); + CLY_LOG_E(@"%s view name is null or empty, stop view by name will be ignored", __FUNCTION__); return; } - + if (!CountlyConsentManager.sharedInstance.consentForViewTracking) + { + CLY_LOG_V(@"%s no consent for views, stop view by name will be ignored, name: [%@]", __FUNCTION__, viewName); return; + } __block NSString *viewID = nil; [self.viewDataDictionary enumerateKeysAndObjectsUsingBlock:^(NSString * key, CountlyViewData * viewData, BOOL * stop) { @@ -331,7 +434,7 @@ - (void)stopViewWithNameInternal:(NSString *) viewName customSegmentation:(NSDic [self stopViewWithIDInternal:viewID customSegmentation:customSegmentation]; } else { - CLY_LOG_D(@"%s No View exist with name: %@", __FUNCTION__, viewName); + CLY_LOG_E(@"%s no view exists with the given name, it will not be stopped, name: [%@]", __FUNCTION__, viewName); } } @@ -343,15 +446,23 @@ - (void)stopViewWithIDInternal:(NSString *) viewKey customSegmentation:(NSDictio - (void)stopViewWithIDInternal:(NSString *) viewKey customSegmentation:(NSDictionary *)customSegmentation autoPaused:(BOOL) autoPaused{ if (!viewKey || !viewKey.length) { - CLY_LOG_D(@"%s View ID should not be null or empty", __FUNCTION__); + CLY_LOG_E(@"%s view ID is null or empty, stop view by ID will be ignored", __FUNCTION__); return; } - + + viewKey = [self currentIDForViewID:viewKey]; + if (!CountlyConsentManager.sharedInstance.consentForViewTracking) + { + CLY_LOG_V(@"%s no consent for views, stop view by ID will be ignored, view ID: [%@]", __FUNCTION__, viewKey); return; - + } + if (!CountlyServerConfig.sharedInstance.viewTrackingEnabled) + { + CLY_LOG_D(@"%s view tracking is disabled by server config, stop view by ID will be ignored, view ID: [%@]", __FUNCTION__, viewKey); return; + } CountlyViewData* viewData = self.viewDataDictionary[viewKey]; if (viewData) @@ -372,6 +483,10 @@ - (void)stopViewWithIDInternal:(NSString *) viewKey customSegmentation:(NSDictio { NSMutableDictionary* mutableCustomSegmentation = customSegmentation.mutableCopy; [mutableCustomSegmentation removeObjectsForKeys:self.reservedViewTrackingSegmentationKeys]; + if (mutableCustomSegmentation.count != customSegmentation.count) + { + CLY_LOG_W(@"%s reserved keys will be dropped from the segmentation of the view being stopped, droppedKeyCount: [%lu], view ID: [%@]", __FUNCTION__, (unsigned long)(customSegmentation.count - mutableCustomSegmentation.count), viewKey); + } NSDictionary *filteredSegmentation = mutableCustomSegmentation.cly_filterSupportedDataTypes; [segmentation addEntriesFromDictionary:filteredSegmentation]; } @@ -385,13 +500,15 @@ - (void)stopViewWithIDInternal:(NSString *) viewKey customSegmentation:(NSDictio NSInteger duration = viewData.duration; [Countly.sharedInstance recordReservedEvent:kCountlyReservedEventView segmentation:segmentation count:1 sum:0 duration:duration ID:viewData.viewID timestamp:CountlyCommon.sharedInstance.uniqueTimestamp]; - CLY_LOG_D(@"%s View tracking ended: %@ duration: %ld", __FUNCTION__, viewData.viewName, (long)duration); + CLY_LOG_D(@"%s view stopped, name: [%@], view ID: [%@], duration: [%ld], segmentation key count: [%lu], autoPaused: [%@]", __FUNCTION__, viewData.viewName, viewData.viewID, (long)duration, (unsigned long)segmentation.count, autoPaused ? @"YES" : @"NO"); + CLY_LOG_D(@"%s stopped view segmentation value detail, view ID: [%@], segmentation: [%@]", __FUNCTION__, viewData.viewID, segmentation); if (!autoPaused) { [self.viewDataDictionary removeObjectForKey:viewKey]; + [self forgetViewID:viewKey]; } } else { - CLY_LOG_D(@"%s No View exist with ID: %@", __FUNCTION__, viewKey); + CLY_LOG_E(@"%s no view exists with the given ID, it will not be stopped, view ID: [%@]", __FUNCTION__, viewKey); } } @@ -404,22 +521,26 @@ - (NSString*)startViewInternal:(NSString *)viewName customSegmentation:(NSDictio { if (!viewName || !viewName.length) { - CLY_LOG_D(@"%s View name should not be null or empty", __FUNCTION__); + CLY_LOG_E(@"%s view name is null or empty, start view will be ignored", __FUNCTION__); return nil; } - + if (!CountlyConsentManager.sharedInstance.consentForViewTracking) + { + CLY_LOG_V(@"%s no consent for views, start view will be ignored, name: [%@]", __FUNCTION__, viewName); return nil; - + } + if (!CountlyServerConfig.sharedInstance.viewTrackingEnabled) + { + CLY_LOG_D(@"%s view tracking is disabled by server config, start view will be ignored, name: [%@]", __FUNCTION__, viewName); return nil; - + } + [self stopAutoStoppedView]; - + viewName = viewName.copy; - - CLY_LOG_D(@"%s View tracking started: %@", __FUNCTION__, viewName); - + viewName = [viewName cly_truncatedKey:@"View name"]; NSMutableDictionary* segmentation = NSMutableDictionary.new; @@ -433,6 +554,10 @@ - (NSString*)startViewInternal:(NSString *)viewName customSegmentation:(NSDictio { NSMutableDictionary* mutableCustomSegmentation = customSegmentation.mutableCopy; [mutableCustomSegmentation removeObjectsForKeys:self.reservedViewTrackingSegmentationKeys]; + if (mutableCustomSegmentation.count != customSegmentation.count) + { + CLY_LOG_W(@"%s reserved keys will be dropped from the segmentation of the view being started, droppedKeyCount: [%lu], name: [%@]", __FUNCTION__, (unsigned long)(customSegmentation.count - mutableCustomSegmentation.count), viewName); + } NSDictionary *filteredSegmentation = mutableCustomSegmentation.cly_filterSupportedDataTypes; [segmentation addEntriesFromDictionary:filteredSegmentation]; } @@ -463,7 +588,8 @@ - (NSString*)startViewInternal:(NSString *)viewName customSegmentation:(NSDictio [Countly.sharedInstance recordReservedEvent:kCountlyReservedEventView segmentation:segmentation ID:self.currentViewID]; - CLY_LOG_D(@"%s View name: %@ View ID: %@ isAutoStoppedView: %@", __FUNCTION__, viewName, self.currentViewID, isAutoStoppedView ? @"YES" : @"NO"); + CLY_LOG_D(@"%s view started, name: [%@], view ID: [%@], previous view ID: [%@], segmentation key count: [%lu], customSegmentationKeyCount: [%lu], isAutoStoppedView: [%@]", __FUNCTION__, viewName, self.currentViewID, self.previousViewID, (unsigned long)segmentation.count, (unsigned long)customSegmentation.count, isAutoStoppedView ? @"YES" : @"NO"); + CLY_LOG_D(@"%s started view segmentation value detail, view ID: [%@], segmentation: [%@], customSegmentation: [%@]", __FUNCTION__, self.currentViewID, segmentation, customSegmentation); return self.currentViewID; } @@ -472,23 +598,32 @@ - (void)pauseViewWithIDInternal:(NSString *) viewID { if (!viewID || !viewID.length) { - CLY_LOG_D(@"%s View ID should not be null or empty", __FUNCTION__); + CLY_LOG_E(@"%s view ID is null or empty, pause view will be ignored", __FUNCTION__); return; } - + + viewID = [self currentIDForViewID:viewID]; + if (!CountlyConsentManager.sharedInstance.consentForViewTracking) + { + CLY_LOG_V(@"%s no consent for views, pause view will be ignored, view ID: [%@]", __FUNCTION__, viewID); return; - + } + if (!CountlyServerConfig.sharedInstance.viewTrackingEnabled) + { + CLY_LOG_D(@"%s view tracking is disabled by server config, pause view will be ignored, view ID: [%@]", __FUNCTION__, viewID); return; - + } + CountlyViewData* viewData = self.viewDataDictionary[viewID]; if (viewData) { + CLY_LOG_D(@"%s view resolved for pausing, view ID: [%@], name: [%@]", __FUNCTION__, viewID, viewData.viewName); [self pauseViewInternal:viewData]; } else { - CLY_LOG_D(@"%s No View exist with ID: %@", __FUNCTION__, viewID); + CLY_LOG_E(@"%s no view exists with the given ID, it will not be paused, view ID: [%@]", __FUNCTION__, viewID); } } @@ -496,24 +631,33 @@ - (void)resumeViewWithIDInternal:(NSString *) viewID { if (!viewID || !viewID.length) { - CLY_LOG_D(@"%s View ID should not be null or empty", __FUNCTION__); + CLY_LOG_E(@"%s view ID is null or empty, resume view will be ignored", __FUNCTION__); return; } - + + viewID = [self currentIDForViewID:viewID]; + if (!CountlyConsentManager.sharedInstance.consentForViewTracking) + { + CLY_LOG_V(@"%s no consent for views, resume view will be ignored, view ID: [%@]", __FUNCTION__, viewID); return; - + } + if (!CountlyServerConfig.sharedInstance.viewTrackingEnabled) + { + CLY_LOG_D(@"%s view tracking is disabled by server config, resume view will be ignored, view ID: [%@]", __FUNCTION__, viewID); return; - + } + CountlyViewData* viewData = self.viewDataDictionary[viewID]; if (viewData) { + CLY_LOG_D(@"%s view resumed, view ID: [%@], name: [%@]", __FUNCTION__, viewID, viewData.viewName); [viewData resumeView]; } - + else { - CLY_LOG_D(@"%s No View exist with ID: %@", __FUNCTION__, viewID); + CLY_LOG_E(@"%s no view exists with the given ID, it will not be resumed, view ID: [%@]", __FUNCTION__, viewID); } } @@ -545,6 +689,7 @@ - (void)stopCurrentView - (void)stopRunningViewsInternal { + CLY_LOG_D(@"%s running views will be stopped automatically and marked for restart, view count: [%lu]", __FUNCTION__, (unsigned long)self.viewDataDictionary.count); [self.viewDataDictionary enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull key, CountlyViewData * _Nonnull viewData, BOOL * _Nonnull stop) { viewData.willStartAgain = YES; [self stopViewWithIDInternal:viewData.viewID customSegmentation:nil autoPaused:YES]; @@ -562,22 +707,35 @@ - (void)startStoppedViewsInternal // Create an array to store keys for views that need to be removed NSMutableArray *keysToRemove = [NSMutableArray array]; NSMutableArray *keysToStart = [NSMutableArray array]; - + NSMutableArray *autoStoppedKeysToStart = [NSMutableArray array]; + // Collect keys without modifying the dictionary [self.viewDataDictionary enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull key, CountlyViewData * _Nonnull viewData, BOOL * _Nonnull stop) { if (viewData.willStartAgain) { - [keysToStart addObject:key]; + if (viewData.isAutoStoppedView) + [autoStoppedKeysToStart addObject:key]; + else + [keysToStart addObject:key]; [keysToRemove addObject:viewData.viewID]; } }]; - + + // starting any view stops a running auto stopped view, so the auto stopped one is started last to keep running + [keysToStart addObjectsFromArray:autoStoppedKeysToStart]; + + CLY_LOG_D(@"%s previously stopped views will be started automatically, view count: [%lu]", __FUNCTION__, (unsigned long)keysToStart.count); + // Start the collected views after enumeration for (NSString *key in keysToStart) { CountlyViewData *viewData = self.viewDataDictionary[key]; NSString *viewID = [self startViewInternal:viewData.viewName customSegmentation:viewData.startSegmentation isAutoStoppedView:viewData.isAutoStoppedView]; - + + CLY_LOG_V(@"%s view started automatically after restart, name: [%@], new view ID: [%@], previous view ID: [%@]", __FUNCTION__, viewData.viewName, viewID, key); + + [self repointViewID:key toCurrentID:viewID]; + // Retrieve and update the newly created viewData CountlyViewData *viewDataNew = self.viewDataDictionary[viewID]; viewDataNew.segmentation = viewData.segmentation.mutableCopy; @@ -592,7 +750,12 @@ - (void)stopAllViewsInternal:(NSDictionary *)segmentation { // TODO: Should apply all the segmenation operations here at one place instead of doing it for individual view if (!CountlyConsentManager.sharedInstance.consentForViewTracking) + { + CLY_LOG_V(@"%s no consent for views, stop all views will be ignored", __FUNCTION__); return; + } + CLY_LOG_D(@"%s all views will be stopped, view count: [%lu], segmentation key count: [%lu]", __FUNCTION__, (unsigned long)self.viewDataDictionary.count, (unsigned long)segmentation.count); + CLY_LOG_D(@"%s stop all views internal segmentation value detail, segmentation: [%@]", __FUNCTION__, segmentation); [self.viewDataDictionary enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull key, CountlyViewData * _Nonnull viewData, BOOL * _Nonnull stop) { [self stopViewWithIDInternal:key customSegmentation:segmentation]; }]; @@ -602,10 +765,10 @@ - (void)addSegmentationToViewWithNameInternal:(NSString *) viewName segmentation { if (!viewName || !viewName.length) { - CLY_LOG_D(@"%s View name should not be null or empty", __FUNCTION__); + CLY_LOG_E(@"%s view name is null or empty, adding segmentation to view will be ignored", __FUNCTION__); return; } - + __block NSString *viewID = nil; [self.viewDataDictionary enumerateKeysAndObjectsUsingBlock:^(NSString * key, CountlyViewData * viewData, BOOL * stop) { @@ -622,25 +785,36 @@ - (void)addSegmentationToViewWithNameInternal:(NSString *) viewName segmentation [self addSegmentationToViewWithIDInternal:viewID segmentation:segmentation]; } else { - CLY_LOG_D(@"%s No View exist with name: %@", __FUNCTION__, viewName); + CLY_LOG_E(@"%s no view exists with the given name, segmentation will not be added, name: [%@]", __FUNCTION__, viewName); } } - (void)addSegmentationToViewWithIDInternal:(NSString *) viewID segmentation:(NSDictionary *)segmentation{ if (!viewID || !viewID.length) { - CLY_LOG_D(@"%s View ID should not be null or empty", __FUNCTION__); + CLY_LOG_E(@"%s view ID is null or empty, adding segmentation by view ID will be ignored", __FUNCTION__); return; } - + + viewID = [self currentIDForViewID:viewID]; + if (!CountlyConsentManager.sharedInstance.consentForViewTracking) + { + CLY_LOG_V(@"%s no consent for views, adding segmentation to view will be ignored, view ID: [%@]", __FUNCTION__, viewID); return; + } CountlyViewData* viewData = self.viewDataDictionary[viewID]; if (viewData) { NSMutableDictionary *mutableSegmentation = segmentation.mutableCopy; [mutableSegmentation removeObjectsForKeys:self.reservedViewTrackingSegmentationKeys]; + if (mutableSegmentation.count != segmentation.count) + { + CLY_LOG_W(@"%s reserved keys will be dropped while adding segmentation to a view, droppedKeyCount: [%lu], view ID: [%@]", __FUNCTION__, (unsigned long)(segmentation.count - mutableSegmentation.count), viewID); + } NSDictionary *filteredSegmentation = mutableSegmentation.cly_filterSupportedDataTypes; + CLY_LOG_D(@"%s segmentation will be added to view, view ID: [%@], name: [%@], key count: [%lu]", __FUNCTION__, viewID, viewData.viewName, (unsigned long)filteredSegmentation.count); + CLY_LOG_D(@"%s segmentation added to view value detail, view ID: [%@], segmentation: [%@]", __FUNCTION__, viewID, filteredSegmentation); if(filteredSegmentation) { if(!viewData.segmentation) { viewData.segmentation = NSMutableDictionary.new; @@ -650,20 +824,22 @@ - (void)addSegmentationToViewWithIDInternal:(NSString *) viewID segmentation:(NS [self.viewDataDictionary setObject:viewData forKey:viewID]; } else { - CLY_LOG_D(@"%s No View exist with ID: %@", __FUNCTION__, viewID); + CLY_LOG_E(@"%s no view exists with the given ID, segmentation will not be added, view ID: [%@]", __FUNCTION__, viewID); } } - (void)addSegmentationToViewWithID:(NSString *)viewID segmentation:(NSDictionary *)segmentation { - CLY_LOG_I(@"%s %@ %@", __FUNCTION__, viewID, segmentation); + CLY_LOG_I(@"%s add segmentation to view by ID requested, view ID: [%@], segmentation key count: [%lu], segmentation keys: [%@]", __FUNCTION__, viewID, (unsigned long)segmentation.count, segmentation.allKeys); + CLY_LOG_D(@"%s add segmentation to view by ID value detail, view ID: [%@], segmentation: [%@]", __FUNCTION__, viewID, segmentation); [self addSegmentationToViewWithIDInternal:viewID segmentation:segmentation]; } - (void)addSegmentationToViewWithName:(NSString *)viewName segmentation:(NSDictionary *)segmentation { - CLY_LOG_I(@"%s %@ %@", __FUNCTION__, viewName, segmentation); + CLY_LOG_I(@"%s add segmentation to view by name requested, name: [%@], segmentation key count: [%lu], segmentation keys: [%@]", __FUNCTION__, viewName, (unsigned long)segmentation.count, segmentation.allKeys); + CLY_LOG_D(@"%s add segmentation to view by name value detail, name: [%@], segmentation: [%@]", __FUNCTION__, viewName, segmentation); [self addSegmentationToViewWithNameInternal:viewName segmentation:segmentation]; } @@ -685,20 +861,38 @@ - (void)swizzleViewTrackingMethods O_method = class_getInstanceMethod(UIViewController.class, @selector(viewDidDisappear:)); C_method = class_getInstanceMethod(UIViewController.class, @selector(Countly_viewDidDisappear:)); method_exchangeImplementations(O_method, C_method); + + CLY_LOG_D(@"%s UIViewController appearance methods swizzled for automatic view tracking", __FUNCTION__); } - (void)performAutoViewTrackingForViewController:(UIViewController *)viewController { if (!self.isAutoViewTrackingActive) + { + CLY_LOG_V(@"%s automatic view tracking is not active, appeared view controller will be ignored", __FUNCTION__); return; - + } + + // Checked per view appearance so a runtime 'avt' = false from the server takes effect immediately + if (!CountlyServerConfig.sharedInstance.automaticViewTrackingEnabled) + { + CLY_LOG_D(@"%s automatic view tracking is disabled by behavior settings, appeared view controller will be ignored", __FUNCTION__); + return; + } + if (!CountlyConsentManager.sharedInstance.consentForViewTracking) + { + CLY_LOG_V(@"%s no consent for views, automatic view start will be ignored", __FUNCTION__); return; - + } + NSString* viewTitle = [self titleForViewController:viewController]; - + if (self.currentView && [self.currentView.viewName isEqualToString:viewTitle]) + { + CLY_LOG_V(@"%s appeared view controller is already the current view, it will not be started again, name: [%@]", __FUNCTION__, viewTitle); return; + } BOOL isException = NO; @@ -710,13 +904,14 @@ - (void)performAutoViewTrackingForViewController:(UIViewController *)viewControl if (isException) { - CLY_LOG_V(@"%s %@ is an exceptional view, so it will be ignored for view tracking.", __FUNCTION__, viewTitle); + CLY_LOG_V(@"%s view is in the automatic view tracking exclusion list, it will be ignored, name: [%@], exception: [%@]", __FUNCTION__, viewTitle, exception); break; } } - + + // auto stopped, so the next screen ends this one, as automatic views do on Android if (!isException) - [self startViewInternal:viewTitle customSegmentation:nil]; + [self startViewInternal:viewTitle customSegmentation:nil isAutoStoppedView:YES]; } @@ -729,7 +924,7 @@ - (NSString*)titleForViewController:(UIViewController *)viewController if ([viewController respondsToSelector:@selector(countlyAutoViewTrackingName)]) { - CLY_LOG_I(@"%s Viewcontroller conforms to CountlyAutoViewTrackingName protocol for custom auto view tracking name.", __FUNCTION__); + CLY_LOG_V(@"%s view controller conforms to the CountlyAutoViewTrackingName protocol, its custom auto view tracking name will be used", __FUNCTION__); title = [(id)viewController countlyAutoViewTrackingName]; } @@ -753,26 +948,31 @@ - (NSString*)titleForViewController:(UIViewController *)viewController #pragma mark - Public function for application state - (void)applicationWillEnterForeground { -#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV) +#if (TARGET_OS_IOS || TARGET_OS_TV) if (!self.isAutoViewTrackingActive && self.isManualViewRestartActive) { + CLY_LOG_D(@"%s app entered foreground with manual view restart active, stopped views will be started automatically", __FUNCTION__); [self startStoppedViewsInternal]; } #else if (self.isManualViewRestartActive) { + CLY_LOG_D(@"%s app entered foreground on a non UIKit platform, stopped views will be started automatically", __FUNCTION__); [self startStoppedViewsInternal]; } #endif } - (void)applicationDidEnterBackground { -#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV) +#if (TARGET_OS_IOS || TARGET_OS_TV) if (self.isAutoViewTrackingActive) { + CLY_LOG_D(@"%s app entered background while automatic view tracking is active, current view will be stopped automatically", __FUNCTION__); [self stopCurrentView]; } else if (self.isManualViewRestartActive) { + CLY_LOG_D(@"%s app entered background with manual view restart active, running views will be stopped automatically", __FUNCTION__); [self stopRunningViewsInternal]; } #else if (self.isManualViewRestartActive) { + CLY_LOG_D(@"%s app entered background on a non UIKit platform, running views will be stopped automatically", __FUNCTION__); [self stopRunningViewsInternal]; } #endif @@ -785,6 +985,7 @@ - (void)applicationWillTerminate { - (void)resetFirstView { + CLY_LOG_D(@"%s first view flag reset, the next started view will be marked as the session start view", __FUNCTION__); self.isFirstView = YES; } @@ -807,7 +1008,7 @@ - (void)Countly_viewDidAppear:(BOOL)animated // does not trigger `viewDidAppear` on presenting view controller when they are dismissed. // Also, `self.presentingViewController` property is nil in both `viewWillDisappear` and `viewDidDisappear`. // So, we store it here in modal's `viewDidAppear` to be used for view tracking later. - CLY_LOG_I(@"%s A modal view controller with PageSheet presentation style is presented on iOS 13+. Storing presenting view controller to be used later.", __FUNCTION__); + CLY_LOG_D(@"%s a modal view controller with PageSheet presentation style is presented, presenting view controller will be stored for later automatic view tracking", __FUNCTION__); UIViewController* presenting = self.presentingViewController; if ([presenting isKindOfClass:UINavigationController.class]) @@ -825,7 +1026,7 @@ - (void)Countly_viewDidDisappear:(BOOL)animated if (self.presentingVC) { - CLY_LOG_I(@"%s A modal view controller with PageSheet presentation style is dismissed on iOS 13+. Forcing auto view tracking with stored presenting view controller.", __FUNCTION__); + CLY_LOG_D(@"%s a modal view controller with PageSheet presentation style is dismissed, automatic view tracking will be forced with the stored presenting view controller", __FUNCTION__); [CountlyViewTrackingInternal.sharedInstance performAutoViewTrackingForViewController:self.presentingVC]; self.presentingVC = nil; } diff --git a/ios/src/CountlyWebViewController.h b/ios/src/CountlyWebViewController.h index 26b100cb..ae5f2f7d 100644 --- a/ios/src/CountlyWebViewController.h +++ b/ios/src/CountlyWebViewController.h @@ -4,16 +4,20 @@ // // Please visit www.count.ly for more information. -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) #import #endif #import "CountlyCommon.h" NS_ASSUME_NONNULL_BEGIN -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) @interface CountlyWebViewController : UIViewController @property(nonatomic, strong) UIView *contentView; + +/// Called once an interface size change has settled. The SDK's rotation hook. +@property(nonatomic, copy, nullable) void (^sizeChangeHandler)(CGSize newSize); + - (void)updatePlacementRespectToSafeAreas; @end #endif diff --git a/ios/src/CountlyWebViewController.m b/ios/src/CountlyWebViewController.m index eb96a9fa..41a61b2c 100644 --- a/ios/src/CountlyWebViewController.m +++ b/ios/src/CountlyWebViewController.m @@ -5,10 +5,11 @@ // Please visit www.count.ly for more information. // #import "CountlyWebViewController.h" +#import "CountlyCommon.h" #import "PassThroughBackgroundView.h" #import "TouchDelegatingView.h" -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) @implementation CountlyWebViewController { UIStatusBarStyle _cachedStatusBarStyle; @@ -41,38 +42,69 @@ - (UIStatusBarStyle)preferredStatusBarStyle return UIStatusBarStyleLightContent; } +// The view controller UIKit itself consults for this scene, or nil if unresolvable. This overlay +// can both widen and narrow what the whole scene allows (verified on iOS 18.4/26.0), so the mask +// must mirror the host: too wide rotates a locked app, too narrow force-rotates it out of +// landscape. Follows only window-filling presentations, and never descends into nav/tab children +// (UIKit asks the container) — which is why CountlyCommon.topViewController must not be reused. +- (UIViewController *)hostOrientationAuthority +{ + UIViewController *vc = [self getKeyWindow].rootViewController; + + if (!vc || vc == self) + { + return nil; + } + + // Modals can be stacked; UIKit honours the topmost one that fills the window. + while ([self fillsWindow:vc.presentedViewController] && vc.presentedViewController != self) + { + vc = vc.presentedViewController; + } + + return vc; +} + +// UIKit ignores the orientation preferences of sheet-style presentations. +- (BOOL)fillsWindow:(UIViewController *)viewController +{ + if (!viewController || viewController.isBeingDismissed) + { + return NO; + } + + return viewController.modalPresentationStyle == UIModalPresentationFullScreen + || viewController.modalPresentationStyle == UIModalPresentationOverFullScreen; +} + - (UIInterfaceOrientationMask)supportedInterfaceOrientations { - return UIInterfaceOrientationMaskAll; + UIViewController *authority = [self hostOrientationAuthority]; + + // No resolvable host: stay permissive rather than constrain a scene we know nothing about. + return authority ? [authority supportedInterfaceOrientations] : UIInterfaceOrientationMaskAll; } - (UIWindow *)getKeyWindow { - if (@available(iOS 13.0, *)) { - for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) { - if (![scene isKindOfClass:[UIWindowScene class]]) { - continue; - } - - UIWindowScene *windowScene = (UIWindowScene *)scene; - - if (windowScene.activationState == UISceneActivationStateForegroundActive) { - for (UIWindow *window in windowScene.windows) { - if (window.isKeyWindow) { - return window; - } - } - } - } - } else { - return UIApplication.sharedApplication.keyWindow; - } - - return nil; + // Unified key-window resolution (foreground-active scene, correct across multi-scene apps). + return CountlyCommon.keyWindow; } -- (BOOL)shouldAutorotate +// No shouldAutorotate override on purpose: UIViewController already defaults to YES, it is +// deprecated since iOS 16, and supportedInterfaceOrientations is what actually constrains rotation. + +- (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id)coordinator { - return YES; + [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator]; + + // Alongside the transition, not on completion: the view is already at the new size here, so the + // page re-lays out during the rotation animation rather than a beat after it finishes. + [coordinator animateAlongsideTransition:^(id context) { + if (self.sizeChangeHandler) + { + self.sizeChangeHandler(size); + } + } completion:nil]; } - (void)loadView @@ -81,7 +113,7 @@ - (void)loadView CGRect bounds = keyWindow.rootViewController.view.bounds; if (CGRectIsEmpty(bounds)) { - bounds = UIScreen.mainScreen.bounds; + bounds = CountlyCommon.screenBounds; } self.view = [[TouchDelegatingView alloc] initWithFrame:bounds]; @@ -142,7 +174,8 @@ - (void)updatePlacementRespectToSafeAreas if ([self.contentView isKindOfClass:PassThroughBackgroundView.self]) { PassThroughBackgroundView *content = (PassThroughBackgroundView *)self.contentView; - CGRect frame = content.webView.frame; + // From the unadjusted placement, never the live frame: the shifts below are additive. + CGRect frame = CGRectIsNull(content.baseWebViewFrame) ? content.webView.frame : content.baseWebViewFrame; if (CountlyContentBuilderInternal.sharedInstance.webViewDisplayOption == SAFE_AREA || [self hasTopNotch:safeArea]) { frame.origin.y += safeArea.top; // always respect notch if exists diff --git a/ios/src/CountlyWebViewManager.h b/ios/src/CountlyWebViewManager.h index 51a9105b..67eb5f62 100644 --- a/ios/src/CountlyWebViewManager.h +++ b/ios/src/CountlyWebViewManager.h @@ -4,14 +4,14 @@ // // Please visit www.count.ly for more information. -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) #import #import #endif #import "CountlyCommon.h" NS_ASSUME_NONNULL_BEGIN -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) typedef NS_ENUM(NSUInteger, AnimationType) { AnimationTypeSlideInFromBottom, AnimationTypeSlideInFromTop, @@ -30,6 +30,9 @@ typedef NS_ENUM(NSUInteger, AnimationType) { appearBlock:(void(^ __nullable)(void))appearBlock dismissBlock:(void(^ __nullable)(void))dismissBlock; +/// Tears down the web view and overlay window and invokes the dismiss block. Any thread, idempotent. +- (void)closeWebView; + @end diff --git a/ios/src/CountlyWebViewManager.m b/ios/src/CountlyWebViewManager.m index 45b892c4..56af8c75 100644 --- a/ios/src/CountlyWebViewManager.m +++ b/ios/src/CountlyWebViewManager.m @@ -8,8 +8,36 @@ #import "CountlyOverlayWindow.h" #import "CountlyWebViewController.h" #import "PassThroughBackgroundView.h" +#import "CountlyContentBuilderInternal.h" + +#if (TARGET_OS_IOS || TARGET_OS_VISION) +// Critical-resource load retries: how many times to reload the web view before +// giving up when a critical (JS/CSS) resource fails to load. A single transient +// network hiccup — e.g. a connection stalled under a burst of parallel asset +// requests against an HTTP/1.1 / rate-limited edge — should not tear down otherwise +// valid content. On reload, already-loaded resources are served from the in-session +// cache, so the retry re-fetches only what failed. Mirrors the more tolerant Android +// behavior (which never closes on a transient JS error event). +static const NSInteger kCLYMaxResourceRetries = 2; +static const NSTimeInterval kCLYResourceRetryBaseDelay = 0.6; +// Fallback stall timeout (seconds) used only when reload-on-stall is enabled but no +// timeout was configured (e.g. the SDK was not started via config in a unit test). The +// real value comes from CountlyContentConfig setContentReloadOnStallTimeout: (default +// 1000 ms). Kept short because a manual reload is observed to recover reliably: on reload +// the already-fetched assets come from cache and the rest reuse the warm connection, so +// the parallel-connection burst shrinks below the edge's limit. A stalled load fires no +// JS 'error' event, so this timer is what triggers the reload for that case. When the +// flag is off, the 60s safety-net timeout is used and the view closes on failure. +static const NSTimeInterval kCLYLoadStallTimeout = 1.0; +static const NSTimeInterval kCLYDefaultLoadTimeout = 60.0; +// Absolute deadline (seconds) by which the content must report [CLY]_content_shown, else the +// web view is closed. Armed ONCE when the web view is created and never reset by reloads, so it +// is a hard backstop the reload/stall machinery cannot defeat: no matter how the retry/verify +// logic behaves, a content that never actually shows is torn down within this window. This is +// the guaranteed replacement for the old fixed 60s close (which no longer applies once the +// per-navigation stall timer shrinks under reload-on-stall). +static const NSTimeInterval kCLYContentShownDeadline = 60.0; -#if (TARGET_OS_IOS) // TODO: improve logging, check edge cases @interface CountlyWebViewManager () @@ -20,32 +48,55 @@ @interface CountlyWebViewManager () @property(nonatomic, strong) NSDate *loadStartDate; @property(nonatomic) BOOL hasAppeared; @property(nonatomic) BOOL webViewClosed; +@property(nonatomic) NSInteger resourceRetryCount; +@property(nonatomic) NSTimeInterval loadTimeoutInterval; +// Non-nil exactly while a reload is scheduled but not yet fired. Single source of truth for +// "a retry is in progress"; cancellable so a load that succeeds first can cancel it. +@property(nonatomic, copy) dispatch_block_t pendingReloadBlock; +// Absolute [CLY]_content_shown deadline timer (see kCLYContentShownDeadline). Armed once at +// creation, cancelled when content_shown arrives, fires closeWebView otherwise. +@property(nonatomic, strong) NSTimer *contentShownDeadlineTimer; @property(nonatomic, strong) CountlyWebViewController *presentingController; @property(nonatomic, strong) CountlyOverlayWindow *window; +// Latched at creation; the manager is shared with feedback widgets, so content-only behaviour gates on it. +@property(nonatomic) BOOL isFeedbackWidget; @end @implementation CountlyWebViewManager - #if (TARGET_OS_IOS) + #if (TARGET_OS_IOS || TARGET_OS_VISION) - (void)createWebViewWithURL:(NSURL *)url frame:(CGRect)frame appearBlock:(void(^ __nullable)(void))appearBlock dismissBlock:(void(^ __nullable)(void))dismissBlock { + self.isFeedbackWidget = [self isFeedbackWidgetURL:url]; + BOOL pinnedToPortrait = !self.isFeedbackWidget && CountlyContentBuilderInternal.sharedInstance.disableRotation; + // Host and path only: the content and widget URL query strings carry the app key and the device ID. + CLY_LOG_I(@"%s web view presentation requested, host: [%@], path: [%@], isFeedbackWidget: [%@], pinnedToPortrait: [%@], appearCallbackProvided: [%@], dismissCallbackProvided: [%@]", __FUNCTION__, url.host, url.path, self.isFeedbackWidget ? @"YES" : @"NO", pinnedToPortrait ? @"YES" : @"NO", (appearBlock != nil) ? @"YES" : @"NO", (dismissBlock != nil) ? @"YES" : @"NO"); + CLY_LOG_D(@"%s web view presentation URL detail, url: [%@], isFeedbackWidget: [%@]", __FUNCTION__, url.absoluteString, self.isFeedbackWidget ? @"YES" : @"NO"); self.dismissBlock = dismissBlock; self.appearBlock = appearBlock; self.hasAppeared = NO; self.webViewClosed = NO; - // TODO: keyWindow deprecation fix + self.resourceRetryCount = 0; + self.pendingReloadBlock = nil; _window = [CountlyOverlayWindow new]; CountlyWebViewController *modal = [CountlyWebViewController new]; modal.modalPresentationStyle = UIModalPresentationOverFullScreen; modal.modalTransitionStyle = UIModalTransitionStyleCrossDissolve; _window.rootViewController = modal; - UIViewController *rootViewController = UIApplication.sharedApplication.keyWindow.rootViewController; + // The rotation hook: UIKit always delivers it, unlike the device-orientation notification. + __weak typeof(self) weakSelf = self; + modal.sizeChangeHandler = ^(CGSize newSize) { + [weakSelf handleInterfaceSizeChange:newSize]; + }; + UIViewController *rootViewController = CountlyCommon.keyWindow.rootViewController; modal.modalPresentationCapturesStatusBarAppearance = YES; CGRect backgroundFrame = rootViewController.view.bounds; + CLY_LOG_D(@"%s frames computed, webViewFrame: [%@], backgroundFrame: [%@]", __FUNCTION__, NSStringFromCGRect(frame), NSStringFromCGRect(backgroundFrame)); self.backgroundView = [[PassThroughBackgroundView alloc] initWithFrame:backgroundFrame]; self.backgroundView.backgroundColor = [UIColor clearColor]; self.backgroundView.hidden = YES; + self.backgroundView.reportPortraitSizeOnly = pinnedToPortrait; modal.contentView = self.backgroundView; _window.hidden = NO; @@ -90,6 +141,35 @@ - (void)createWebViewWithURL:(NSURL *)url forMainFrameOnly:NO]; [contentController addUserScript:resourceErrorScript]; + + // Opt-in (CountlyContentConfig disableZoom): prevent user zoom (pinch / double-tap) by + // enforcing the no-zoom viewport directives. Injected at document end so document.head + // exists. This PRESERVES the page's own width / initial-scale (only strips and re-adds + // maximum-scale / minimum-scale / user-scalable), so it disables zoom without changing + // the layout the content declared. The scroll-view pinch gesture is also disabled in + // configureWebView: as a native backstop. + if (CountlyContentBuilderInternal.sharedInstance.disableZoom) { + CLY_LOG_D(@"%s zoom is disabled by the config, injecting the no zoom viewport script", __FUNCTION__); + NSString *disableZoomJS = + @"(function(){" + "var m=document.querySelector('meta[name=viewport]');" + "if(m){" + "var kept=(m.content||'').split(',').map(function(s){return s.trim();}).filter(function(s){var l=s.toLowerCase();return s.length&&l.indexOf('maximum-scale')!==0&&l.indexOf('minimum-scale')!==0&&l.indexOf('user-scalable')!==0;});" + "kept.push('maximum-scale=1.0','minimum-scale=1.0','user-scalable=no');" + "m.content=kept.join(', ');" + "}else{" + "m=document.createElement('meta');m.name='viewport';" + "m.content='width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no';" + "(document.head||document.documentElement).appendChild(m);" + "}" + "})();"; + WKUserScript *disableZoomScript = + [[WKUserScript alloc] initWithSource:disableZoomJS + injectionTime:WKUserScriptInjectionTimeAtDocumentEnd + forMainFrameOnly:YES]; + [contentController addUserScript:disableZoomScript]; + } + [contentController addScriptMessageHandler:self name:@"resourceLoadError"]; [contentController addScriptMessageHandler:self name:@"resourceVerifyResult"]; @@ -101,18 +181,65 @@ - (void)createWebViewWithURL:(NSURL *)url if (@available(iOS 11.0, *)) { webView.scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever; } + // When SDK debug is enabled, expose the content web view to Safari Web Inspector + // (iOS 16.4+ requires this to be set explicitly). Lets you inspect the Network and + // Console tabs of the content web view live from a Mac. Off in production. + if (@available(iOS 16.4, *)) { + webView.inspectable = CountlyCommon.sharedInstance.enableDebug; + } [self configureWebView:webView]; NSURLRequest *request = [NSURLRequest requestWithURL:url cachePolicy:NSURLRequestReloadIgnoringLocalCacheData timeoutInterval:60]; [webView loadRequest:request]; + // Arm the absolute content-shown deadline ONCE (not per navigation, so reloads cannot + // extend it). If [CLY]_content_shown is not reported within kCLYContentShownDeadline, the + // web view is closed regardless of retry/stall/verify state. + // Only for CONTENT: feedback widgets (survey/NPS/rating) load a different SDK-built URL and + // never report [CLY]_content_shown, so arming the deadline for them would force-close a + // widget the user is still filling out. Skip it for feedback widget URLs. + if (!self.isFeedbackWidget) { + __weak typeof(self) deadlineSelf = self; + self.contentShownDeadlineTimer = [NSTimer scheduledTimerWithTimeInterval:kCLYContentShownDeadline repeats:NO block:^(NSTimer * _Nonnull timer) { + [deadlineSelf contentShownDeadlineReached]; + }]; + } + CLYButton *dismissButton = [CLYButton dismissAlertButton:@"X"]; [self configureDismissButton:dismissButton forWebView:webView]; self.backgroundView.webView = webView; + self.backgroundView.baseWebViewFrame = frame; self.backgroundView.dismissButton = dismissButton; } +// An interface size change (rotation, split view). The page is the authority on the new geometry. +- (void)handleInterfaceSizeChange:(CGSize)newSize { + if (self.webViewClosed || !self.backgroundView.webView) { + return; + } + + // Feedback widgets fill the window and have no resize channel of their own (widgetURLAction + // handles no commands), so they are re-placed natively. Measured the same way as at creation. + if (self.isFeedbackWidget) { + CGSize windowSize = [CountlyCommon.sharedInstance getWindowSize]; + CGRect frame = CGRectMake(0.0, 0.0, windowSize.width, windowSize.height); + CLY_LOG_D(@"%s re-placing the feedback widget after an interface size change, frame: [%@]", __FUNCTION__, NSStringFromCGRect(frame)); + self.backgroundView.baseWebViewFrame = frame; + self.backgroundView.webView.frame = frame; + [self.presentingController updatePlacementRespectToSafeAreas]; + [self.backgroundView updateWindowSize]; + return; + } + + // Prompted even when rotation is disabled: the page is told the portrait size either way (see + // reportPortraitSizeOnly), and re-asking is what corrects a page that laid out for the wrong + // orientation — e.g. content first shown while the device was already landscape. + + CLY_LOG_D(@"%s prompting the page for its size after an interface size change, size: [%@]", __FUNCTION__, NSStringFromCGSize(newSize)); + [self.backgroundView updateWindowSize]; +} + - (void)configureWebView:(WKWebView *)webView { webView.layer.shadowColor = UIColor.blackColor.CGColor; webView.layer.shadowOpacity = 0.5; @@ -120,6 +247,12 @@ - (void)configureWebView:(WKWebView *)webView { webView.layer.masksToBounds = NO; webView.opaque = NO; webView.scrollView.bounces = NO; + // Native backstop for the opt-in viewport zoom disable: turn off the scroll view's pinch + // gesture. Left alone (does not touch zoom scales, which interact with the page's + // initial-scale) unless disableZoom is enabled. + if (CountlyContentBuilderInternal.sharedInstance.disableZoom) { + webView.scrollView.pinchGestureRecognizer.enabled = NO; + } webView.navigationDelegate = self; [self.backgroundView addSubview:webView]; @@ -127,6 +260,7 @@ - (void)configureWebView:(WKWebView *)webView { - (void)configureDismissButton:(CLYButton *)dismissButton forWebView:(WKWebView *)webView { dismissButton.onClick = ^(id sender) { + CLY_LOG_I(@"%s the dismiss button was tapped, reason: [user-closed]", __FUNCTION__); if (self.dismissBlock) { dispatch_async(dispatch_get_main_queue(), ^{ [self.loadTimeoutTimer invalidate]; @@ -151,42 +285,68 @@ - (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigati NSString *url = navigationAction.request.URL.absoluteString; if (!url) { - CLY_LOG_I(@"%s Navigation action with nil URL (possible proxy tunnel), allowing", __FUNCTION__); + CLY_LOG_D(@"%s the navigation action carries no URL (possible proxy tunnel), allowing it", __FUNCTION__); decisionHandler(WKNavigationActionPolicyAllow); return; } if ([url containsString:@"cly_x_int=1"]) { - CLY_LOG_I(@"%s Opening url [%@] in external browser", __FUNCTION__, url); - [[UIApplication sharedApplication] openURL:navigationAction.request.URL options:@{} completionHandler:^(BOOL success) { - if (success) { - CLY_LOG_I(@"%s url [%@] opened in external browser", __FUNCTION__, url); - } - else { - CLY_LOG_I(@"%s unable to open url [%@] in external browser", __FUNCTION__, url); - } - }]; + // Host and path only: the link query string can carry identifiers. + CLY_LOG_I(@"%s the content asked to open an external URL, host: [%@], path: [%@]", __FUNCTION__, navigationAction.request.URL.host, navigationAction.request.URL.path); + CLY_LOG_D(@"%s external URL request detail, url: [%@]", __FUNCTION__, url); + // Routed through the app's content URL handler if one is set, else the system browser. + [self openExternalURL:navigationAction.request.URL]; decisionHandler(WKNavigationActionPolicyCancel); return; } if ([url hasPrefix:@"https://countly_action_event"]) { NSDictionary *queryParameters = [self parseQueryString:url]; + CLY_LOG_D(@"%s action event URL parsed, url: [%@], queryParameters: [%@]", __FUNCTION__, url, queryParameters); if([url containsString:@"cly_x_action_event=1"]){ [self contentURLAction:queryParameters]; } else if([url containsString:@"cly_widget_command=1"]){ + CLY_LOG_D(@"%s handling a widget command, paramCount: [%lu]", __FUNCTION__, (unsigned long)queryParameters.count); [self widgetURLAction:queryParameters]; } if ([queryParameters[@"close"] boolValue]) { + CLY_LOG_I(@"%s dismissing the web view, reason: [JS-requested]", __FUNCTION__); [self closeWebView]; } decisionHandler(WKNavigationActionPolicyCancel); - } else { - decisionHandler(WKNavigationActionPolicyAllow); + return; + } + + decisionHandler(WKNavigationActionPolicyAllow); +} + +// Opens an external URL from the content. If the host app has provided a content URL handler +// (CountlyContentConfig setContentURLHandler:), the URL is offered to it first so the app can +// route its own deep link (custom scheme or https) to the right screen; the handler returns +// YES if it took over. If there is no handler, or it returns NO, the SDK opens the URL in the +// system browser as before. +- (void)openExternalURL:(NSURL *)url { + if (!url) { + CLY_LOG_W(@"%s there is no valid URL to open, skipping", __FUNCTION__); + CLY_LOG_D(@"%s the URL to open was nil, no URL detail is available", __FUNCTION__); + return; } + + ContentURLHandler handler = CountlyContentBuilderInternal.sharedInstance.contentURLHandler; + if (handler && handler(url)) { + // Host and path only: the link query string can carry identifiers. + CLY_LOG_I(@"%s the URL was handled by the app's content URL handler, host: [%@], path: [%@]", __FUNCTION__, url.host, url.path); + CLY_LOG_D(@"%s the URL handed to the app's content URL handler, url: [%@]", __FUNCTION__, url.absoluteString); + return; + } + + [UIApplication.sharedApplication openURL:url options:@{} completionHandler:^(BOOL success) { + CLY_LOG_I(@"%s the URL was handed to the browser, host: [%@], path: [%@], opened: [%@]", __FUNCTION__, url.host, url.path, success ? @"YES" : @"NO"); + CLY_LOG_D(@"%s the URL handed to the browser, url: [%@], opened: [%@]", __FUNCTION__, url.absoluteString, success ? @"YES" : @"NO"); + }]; } - (void)webView:(WKWebView *)webView decidePolicyForNavigationResponse:(WKNavigationResponse *)navigationResponse decisionHandler:(void (^)(WKNavigationResponsePolicy))decisionHandler { @@ -200,10 +360,14 @@ - (void)webView:(WKWebView *)webView decidePolicyForNavigationResponse:(WKNaviga headers = http.allHeaderFields; } - CLY_LOG_I(@"%s Navigation response received: URL=%@, MIME=%@, status=%ld, headers=%@", __FUNCTION__, response.URL.absoluteString, mimeType, statusCode, headers); + // Host and path only: the URL query string can carry identifiers. + CLY_LOG_D(@"%s navigation response received, host: [%@], path: [%@], mimeType: [%@], status: [%ld], headerCount: [%lu]", __FUNCTION__, response.URL.host, response.URL.path, mimeType, statusCode, (unsigned long)headers.count); + + // header names only: a response header value can be a session cookie, and log gathering can upload any line + CLY_LOG_D(@"%s navigation response detail, url: [%@], status: [%ld], headerNames: [%@]", __FUNCTION__, response.URL.absoluteString, statusCode, headers.allKeys); if (statusCode >= 400) { - CLY_LOG_I(@"%s Cancelling navigation due to HTTP status code: %ld", __FUNCTION__, statusCode); + CLY_LOG_E(@"%s cancelling the navigation because of an HTTP error status, status: [%ld], path: [%@], dismissing the web view, reason: [http-error]", __FUNCTION__, statusCode, response.URL.path); decisionHandler(WKNavigationResponsePolicyCancel); dispatch_async(dispatch_get_main_queue(), ^{ [self closeWebView]; @@ -215,48 +379,53 @@ - (void)webView:(WKWebView *)webView decidePolicyForNavigationResponse:(WKNaviga } - (void)webView:(WKWebView *)webView didReceiveServerRedirectForProvisionalNavigation:(WKNavigation *)navigation { - CLY_LOG_I(@"%s Server redirect received for navigation: %@", __FUNCTION__, navigation); + CLY_LOG_D(@"%s a server redirect was received for the provisional navigation, host: [%@], path: [%@]", __FUNCTION__, webView.URL.host, webView.URL.path); + CLY_LOG_D(@"%s server redirect destination detail, url: [%@]", __FUNCTION__, webView.URL.absoluteString); } - (void)webView:(WKWebView *)webView didFailProvisionalNavigation:(WKNavigation *)navigation withError:(NSError *)error { [self.loadTimeoutTimer invalidate]; self.loadTimeoutTimer = nil; - CLY_LOG_I(@"%s Provisional navigation failed: %@ (%ld). Closing web view.", __FUNCTION__, error.localizedDescription, (long)error.code); + CLY_LOG_D(@"%s the provisional navigation failed, error: [%@], domain: [%@], code: [%ld], dismissing the web view, reason: [navigation-failure]", __FUNCTION__, error.localizedDescription, error.domain, (long)error.code); [self closeWebView]; } -- (void)webView:(WKWebView *)webView didCommitNavigation:(WKNavigation *)navigation { - CLY_LOG_I(@"%s Content started arriving (didCommitNavigation).", __FUNCTION__); -} - - (void)webView:(WKWebView *)webView didFailNavigation:(WKNavigation *)navigation withError:(NSError *)error { [self.loadTimeoutTimer invalidate]; self.loadTimeoutTimer = nil; - CLY_LOG_I(@"%s Navigation failed after commit: %@ (%ld). Closing web view.", __FUNCTION__, error.localizedDescription, (long)error.code); + CLY_LOG_D(@"%s the navigation failed after being committed, error: [%@], domain: [%@], code: [%ld], dismissing the web view, reason: [navigation-failure]", __FUNCTION__, error.localizedDescription, error.domain, (long)error.code); [self closeWebView]; } - (void)webView:(WKWebView *)webView didReceiveAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition disposition, NSURLCredential *credential))completionHandler { - CLY_LOG_I(@"%s Received authentication challenge for host: %@, protectionSpace: %@", __FUNCTION__, challenge.protectionSpace.host, challenge.protectionSpace.authenticationMethod); + CLY_LOG_D(@"%s an authentication challenge was received, host: [%@], authenticationMethod: [%@], performing the default handling", __FUNCTION__, challenge.protectionSpace.host, challenge.protectionSpace.authenticationMethod); // sth custom if needed later? completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, nil); } - (void)webViewWebContentProcessDidTerminate:(WKWebView *)webView { - CLY_LOG_I(@"%s Web content process terminated for URL: %@.", __FUNCTION__, webView.URL.absoluteString); + CLY_LOG_E(@"%s the web content process terminated, host: [%@], path: [%@]", __FUNCTION__, webView.URL.host, webView.URL.path); + CLY_LOG_D(@"%s the terminated web content process URL detail, url: [%@]", __FUNCTION__, webView.URL.absoluteString); // reload? } - (void)webView:(WKWebView *)webView didStartProvisionalNavigation:(WKNavigation *)navigation { - CLY_LOG_I(@"%s Web view has started loading", __FUNCTION__); [self.loadTimeoutTimer invalidate]; __weak typeof(self) weakSelf = self; - self.loadTimeoutTimer = [NSTimer scheduledTimerWithTimeInterval:60.0 repeats:NO block:^(NSTimer * _Nonnull timer) { + // Fast stall-detect (reload) when enabled, using the configurable stall timeout; + // otherwise a plain 60s safety-net close. + NSTimeInterval stall = CountlyContentBuilderInternal.sharedInstance.contentReloadOnStallTimeout; + if (stall <= 0) stall = kCLYLoadStallTimeout; // fallback default (e.g. SDK not started via config) + NSTimeInterval timeout = CountlyContentBuilderInternal.sharedInstance.enableContentReloadOnStall ? stall : kCLYDefaultLoadTimeout; + self.loadTimeoutInterval = timeout; + self.loadTimeoutTimer = [NSTimer scheduledTimerWithTimeInterval:timeout repeats:NO block:^(NSTimer * _Nonnull timer) { __strong typeof(weakSelf) strongSelf = weakSelf; if (!strongSelf) return; [strongSelf loadDidTimeout]; }]; self.loadStartDate = [NSDate date]; + CLY_LOG_D(@"%s the navigation started and the load timeout was armed, host: [%@], path: [%@], timeout: [%.1f] seconds, reloadOnStall: [%@]", __FUNCTION__, webView.URL.host, webView.URL.path, timeout, CountlyContentBuilderInternal.sharedInstance.enableContentReloadOnStall ? @"YES" : @"NO"); + CLY_LOG_D(@"%s the navigation started URL detail, url: [%@]", __FUNCTION__, webView.URL.absoluteString); } - (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation { @@ -266,12 +435,10 @@ - (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigat if (self.webViewClosed) return; - CLY_LOG_I(@"%s Web view has finished loading", __FUNCTION__); - if (self.loadStartDate) { - NSTimeInterval loadDuration = [[NSDate date] timeIntervalSinceDate:self.loadStartDate]; - CLY_LOG_I(@"%s Web view load duration: %.3f seconds", __FUNCTION__, loadDuration); - self.loadStartDate = nil; - } + NSTimeInterval loadDuration = self.loadStartDate ? [[NSDate date] timeIntervalSinceDate:self.loadStartDate] : 0; + self.loadStartDate = nil; + CLY_LOG_I(@"%s the navigation finished, host: [%@], path: [%@], duration: [%.3f] seconds", __FUNCTION__, webView.URL.host, webView.URL.path, loadDuration); + CLY_LOG_D(@"%s the navigation finished URL detail, url: [%@]", __FUNCTION__, webView.URL.absoluteString); [self verifyResourceStatuses:webView]; } @@ -303,7 +470,7 @@ - (void)verifyResourceStatuses:(WKWebView *)webView { [webView evaluateJavaScript:js completionHandler:^(id result, NSError *error) { if (error) { - CLY_LOG_I(@"%s Error injecting verify script: %@", __FUNCTION__, error.localizedDescription); + CLY_LOG_D(@"%s injecting the resource verification script failed, error: [%@], domain: [%@], code: [%ld], continuing without verification", __FUNCTION__, error.localizedDescription, error.domain, (long)error.code); [self notifyPageLoaded]; } }]; @@ -312,9 +479,16 @@ - (void)verifyResourceStatuses:(WKWebView *)webView { - (void)notifyPageLoaded { if (self.webViewClosed || self.hasAppeared) return; + // Reaching here means the load verified good (all resources OK, or verification could + // not run). A successful load wins over a retry still pending from an earlier failure + // in this cycle: cancel it, otherwise the stale reload would wipe the now-good page and + // re-fire the page's on-load [CLY]_content_shown. + [self cancelPendingReload]; + [self.loadTimeoutTimer invalidate]; self.loadTimeoutTimer = nil; + CLY_LOG_D(@"%s the load verified good, revealing the web view, resourceRetryCount: [%ld]", __FUNCTION__, (long)self.resourceRetryCount); [self.presentingController updatePlacementRespectToSafeAreas]; self.hasAppeared = YES; self.backgroundView.hidden = NO; @@ -323,6 +497,112 @@ - (void)notifyPageLoaded { } } +// A critical (JS/CSS) resource failed to load. Rather than closing the content +// immediately, reload the web view up to kCLYMaxResourceRetries times before giving +// up. This turns a transient network hiccup (e.g. a connection stalled under a burst +// of parallel asset requests against a rate-limited / HTTP-1.1 edge) into a recoverable +// event instead of a dismissed content. Multiple failures from the same load are +// coalesced into a single reload. Must be called on the main thread. +- (void)retryOrCloseWebViewForReason:(NSString *)reason { + if (self.webViewClosed) return; + + // Once the content is visible, a late resource failure must not reload or tear it + // down: the injected error listener / PerformanceObserver stay active for the whole + // page life, so a dynamically-loaded or lazy resource failing after appearance would + // otherwise re-run the page (flashing it, discarding scroll / in-progress survey + // input, and re-firing on-load analytics) or dismiss content the user is using. The + // retry mechanism only recovers failures during the initial load. + if (self.hasAppeared) { + CLY_LOG_D(@"%s a load failure arrived while the content is already visible, treating it as non-fatal, reason: [%@]", __FUNCTION__, reason); + return; + } + + // Reload-on-failure is opt-in. When disabled, keep the original behavior: close on failure. + if (!CountlyContentBuilderInternal.sharedInstance.enableContentReloadOnStall) { + CLY_LOG_D(@"%s reload on stall is disabled, dismissing the web view, reason: [resource-failure], detail: [%@]", __FUNCTION__, reason); + [self closeWebView]; + return; + } + + // A reload is already scheduled for this load cycle — coalesce further failures. + // pendingReloadBlock being non-nil is the single source of truth for "a reload is pending". + if (self.pendingReloadBlock) { + CLY_LOG_D(@"%s a reload is already scheduled for this load cycle, ignoring this failure, reason: [%@]", __FUNCTION__, reason); + return; + } + + if (self.resourceRetryCount >= kCLYMaxResourceRetries) { + CLY_LOG_W(@"%s the load retries are exhausted, retryCount: [%ld], maxRetries: [%ld], dismissing the web view, reason: [resource-failure-retries-exhausted], detail: [%@]", __FUNCTION__, (long)self.resourceRetryCount, (long)kCLYMaxResourceRetries, reason); + [self closeWebView]; + return; + } + + self.resourceRetryCount += 1; + // Cancel the in-flight stall timer: we have committed to a reload, and the reload's + // own didStartProvisionalNavigation: will arm a fresh one. Leaving the old timer live + // risks a spurious loadDidTimeout in the reload-delay window. + [self.loadTimeoutTimer invalidate]; + self.loadTimeoutTimer = nil; + NSTimeInterval delay = kCLYResourceRetryBaseDelay * self.resourceRetryCount; + CLY_LOG_D(@"%s scheduling a reload, retry: [%ld], maxRetries: [%ld], delay: [%.1f] seconds, reason: [%@]", __FUNCTION__, (long)self.resourceRetryCount, (long)kCLYMaxResourceRetries, delay, reason); + + __weak typeof(self) weakSelf = self; + // Cancellable so a load that succeeds during the delay window can cancel this reload + // (see cancelPendingReload / notifyPageLoaded). Otherwise a stale reload would wipe the + // now-good page and re-fire the page's on-load [CLY]_content_shown. + dispatch_block_t reloadBlock = dispatch_block_create(0, ^{ + __strong typeof(weakSelf) strongSelf = weakSelf; + if (!strongSelf) return; + strongSelf.pendingReloadBlock = nil; + // hasAppeared / webViewClosed are belt-and-suspenders: a successful load normally + // cancels this block outright, but never reload content that already loaded/closed. + if (strongSelf.webViewClosed || strongSelf.hasAppeared) return; + WKWebView *webView = strongSelf.backgroundView.webView; + if (!webView) { + CLY_LOG_D(@"%s the web view is gone when the reload fired, dismissing the web view, reason: [resource-failure]", __FUNCTION__); + [strongSelf closeWebView]; + return; + } + CLY_LOG_D(@"%s reloading the web view now, retry: [%ld], maxRetries: [%ld]", __FUNCTION__, (long)strongSelf.resourceRetryCount, (long)kCLYMaxResourceRetries); + [webView reload]; + }); + self.pendingReloadBlock = reloadBlock; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delay * NSEC_PER_SEC)), dispatch_get_main_queue(), reloadBlock); +} + +// Cancel a reload scheduled by retryOrCloseWebViewForReason: that has not fired yet. Called +// when a load succeeds (notifyPageLoaded) or the view closes, so a stale reload can't reload a +// page that already loaded. +- (void)cancelPendingReload { + if (self.pendingReloadBlock) { + dispatch_block_cancel(self.pendingReloadBlock); + self.pendingReloadBlock = nil; + } +} + +// The content never reported [CLY]_content_shown within kCLYContentShownDeadline. Close it, +// regardless of hasAppeared: if content_shown had arrived this timer would have been cancelled, +// so reaching here means nothing was ever actually shown (e.g. a blank-but-HTTP-200 page). +- (void)contentShownDeadlineReached { + if (self.webViewClosed) return; + CLY_LOG_W(@"%s the content never reported that it is shown, deadline: [%.0f] seconds, dismissing the web view, reason: [content-shown-deadline]", __FUNCTION__, kCLYContentShownDeadline); + [self closeWebView]; +} + +// A feedback widget is presented through the same web view manager but loads a URL the SDK +// builds in the feedback module as "/feedback/?...&widget_id=&..." (see +// CountlyFeedbackWidget generateWidgetURL), whereas content loads "/_external/content?...". +// Feedback widgets never report [CLY]_content_shown, so we must not arm the content-shown +// deadline for them. Recognized by the SDK's own "/feedback/" endpoint path segment +// (kCountlyEndpointFeedback), which content URLs never contain. +- (BOOL)isFeedbackWidgetURL:(NSURL *)url { + if (!url) return NO; + NSString *path = url.path; + if (!path) return NO; + NSString *feedbackSegment = [kCountlyEndpointFeedback stringByAppendingString:@"/"]; + return [path rangeOfString:feedbackSegment].location != NSNotFound; +} + - (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message { @@ -331,12 +611,18 @@ - (void)userContentController:(WKUserContentController *)userContentController if ([message.name isEqualToString:@"resourceLoadError"]) { NSDictionary *body = message.body; NSString *tag = body[@"tag"]; - NSString *url = body[@"url"]; + // Host and path only: a resource URL can carry a query string. The page controlled url is + // an untyped id, so it is type checked before NSURL sees it. + NSString *url = [body[@"url"] isKindOfClass:NSString.class] ? body[@"url"] : nil; + NSURL *failedResourceURL = url ? [NSURL URLWithString:url] : nil; + + CLY_LOG_D(@"%s a critical resource failed to load, resourceKind: [%@], host: [%@], path: [%@]", __FUNCTION__, tag, failedResourceURL.host, failedResourceURL.path); - CLY_LOG_I(@"%s Critical resource (%@) failed to load: [%@]. Closing web view.", __FUNCTION__, tag, url); + CLY_LOG_D(@"%s the failed critical resource detail, resourceKind: [%@], url: [%@], messageBody: [%@]", __FUNCTION__, tag, url, message.body); + NSString *reason = [NSString stringWithFormat:@"a critical resource failed to load, resourceKind: [%@]", tag]; dispatch_async(dispatch_get_main_queue(), ^{ - [self closeWebView]; + [self retryOrCloseWebViewForReason:reason]; }); } else if ([message.name isEqualToString:@"resourceVerifyResult"]) { @@ -344,16 +630,45 @@ - (void)userContentController:(WKUserContentController *)userContentController NSArray *results = body[@"results"]; if ([results isKindOfClass:[NSArray class]]) { + BOOL anyUnreachable = NO; for (NSDictionary *entry in results) { NSInteger status = [entry[@"status"] integerValue]; if (status >= 400) { - CLY_LOG_I(@"%s Critical resource (%@) returned HTTP %ld: [%@]. Closing web view.", - __FUNCTION__, entry[@"tag"], (long)status, entry[@"url"]); + // Resource kind and status only: a resource URL can carry a query string. + CLY_LOG_D(@"%s the post load verification found a critical resource with an HTTP error status, resourceKind: [%@], status: [%ld]", + __FUNCTION__, entry[@"tag"], (long)status); + CLY_LOG_D(@"%s the post load verification failing resource detail, status: [%ld], entry: [%@]", __FUNCTION__, (long)status, entry); + // This is the post-load HEAD verification (runs on didFinishNavigation): + // the page has already finished loading and run its on-load JS, so a + // reload from here would re-fire any analytics it recorded. Do NOT retry + // from this path. Retries are driven only by the during-load + // resourceLoadError path. Defer to an in-flight retry if one is already + // scheduled, never tear down already-visible content, otherwise close. + if (self.hasAppeared || self.pendingReloadBlock) { + CLY_LOG_D(@"%s deferring the verified resource failure, contentVisible: [%@], reloadPending: [%@]", __FUNCTION__, self.hasAppeared ? @"YES" : @"NO", (self.pendingReloadBlock != nil) ? @"YES" : @"NO"); + return; + } + CLY_LOG_E(@"%s a verified critical resource is broken, dismissing the web view, reason: [resource-http-error]", __FUNCTION__); dispatch_async(dispatch_get_main_queue(), ^{ [self closeWebView]; }); return; } + if (status == 0) { + // HEAD could not reach the resource (network error, not an HTTP status). + anyUnreachable = YES; + } + } + + // A critical resource is unreachable and reload-on-stall is enabled: the content is + // NOT verified good, so do NOT appear here. Appearing would also cancel a pending + // reload (see notifyPageLoaded), yet an unreachable resource is exactly what a + // reload recovers over the warm connection. Defer: an in-flight reload, or the + // still-running load-timeout timer, will drive the retry/close. When reload-on-stall + // is off there is no reload to protect, so keep the original show-anyway behavior. + if (anyUnreachable && !self.hasAppeared && CountlyContentBuilderInternal.sharedInstance.enableContentReloadOnStall) { + CLY_LOG_D(@"%s a critical resource is unreachable, resourceKind: [any], status: [0], deferring to a reload instead of appearing", __FUNCTION__); + return; } } @@ -399,6 +714,8 @@ - (void)animateView:(UIView *)view withAnimationType:(AnimationType)animationTyp - (void)contentURLAction:(NSDictionary *)queryParameters { NSString *action = queryParameters[@"action"]; if(action) { + CLY_LOG_D(@"%s a content action was received, action: [%@]", __FUNCTION__, action); + CLY_LOG_D(@"%s the content action detail, action: [%@], queryParameters: [%@]", __FUNCTION__, action, queryParameters); if ([action isEqualToString:@"event"]) { NSString *eventsJson = queryParameters[@"event"]; if(eventsJson) { @@ -422,29 +739,137 @@ - (void)widgetURLAction:(NSDictionary *)queryParameters { // none action yet } +// The action URL is percent-decoded once here (a malformed escape falls back to the raw string so +// the action is not dropped). Two params can carry a literal '&' in their value: "link" (sent +// unencoded, may hold its own query string) and a decoded "event"/"resize_me" JSON (segmentation +// strings can contain '&'). A plain '&' split would therefore mis-slice them, and the params can +// appear in any order. Instead we span the query from the END: at each step we take the right-most +// reserved marker ("&=") whose value VALIDATES for that key (event/resize_me = JSON, close = +// 0/1, action = a known verb, link = has a URI scheme), record it, and shrink the span to its left. +// A marker whose value does NOT validate is treated as ordinary text inside an enclosing value (so +// it is skipped and absorbed by an outer param). What remains at the front is the comm-url-adjacent +// identifier ("cly_x_action_event=1" / "cly_widget_command=1"), parsed verbatim. Reserved-name +// limitation (documented for integrators): a value that literally contains "&=" +// may be mis-split. - (NSDictionary *)parseQueryString:(NSString *)url { NSMutableDictionary *queryDict = [NSMutableDictionary dictionary]; - NSArray *urlComponents = [url componentsSeparatedByString:@"?"]; - if (urlComponents.count > 1) { - NSArray *queryItems = [urlComponents[1] componentsSeparatedByString:@"&"]; - - for (NSString *item in queryItems) { - NSArray *keyValue = [item componentsSeparatedByString:@"="]; - if (keyValue.count == 2) { - NSString *key = keyValue[0]; - NSString *value = keyValue[1]; - queryDict[key] = value; + NSString *decodedUrl = [url stringByRemovingPercentEncoding] ?: url; + + NSRange qMark = [decodedUrl rangeOfString:@"?"]; + if (qMark.location == NSNotFound) { + return queryDict; + } + NSString *query = [decodedUrl substringFromIndex:qMark.location + 1]; + + NSArray *reservedKeys = @[@"action", @"event", @"resize_me", @"close", @"link"]; + NSInteger end = (NSInteger)query.length; + + while (end > 0) { + NSInteger chosenIdx = -1; + NSString *chosenKey = nil; + NSString *chosenValue = nil; + + // Right-to-left, pick the first reserved marker whose value validates. Scanning from the + // right lets an inner (invalid) marker be absorbed into an outer, valid value. + NSInteger searchFrom = end; + while (searchFrom > 0) { + NSInteger marker = -1; + NSString *markerKey = nil; + for (NSString *key in reservedKeys) { + NSString *needle = [NSString stringWithFormat:@"&%@=", key]; + NSRange r = [query rangeOfString:needle options:NSBackwardsSearch range:NSMakeRange(0, searchFrom)]; + if (r.location != NSNotFound && (NSInteger)r.location > marker && (NSInteger)(r.location + needle.length) <= end) { + marker = (NSInteger)r.location; + markerKey = key; + } + } + if (marker < 0) { + break; + } + NSInteger valueStart = marker + (NSInteger)markerKey.length + 2; // "&" + key + "=" + NSString *value = [query substringWithRange:NSMakeRange(valueStart, end - valueStart)]; + if ([self isReservedValue:value validForKey:markerKey]) { + chosenIdx = marker; + chosenKey = markerKey; + chosenValue = value; + break; } + // Not a real param -> ordinary text; keep looking further left. + searchFrom = marker; + } + + if (chosenIdx < 0) { + break; + } + queryDict[chosenKey] = chosenValue; + end = chosenIdx; + } + + // Remaining prefix is the identifier param(s) adjacent to the comm URL. + NSString *head = [query substringToIndex:end]; + for (NSString *pair in [head componentsSeparatedByString:@"&"]) { + NSRange eq = [pair rangeOfString:@"="]; + if (eq.location != NSNotFound) { + queryDict[[pair substringToIndex:eq.location]] = [pair substringFromIndex:eq.location + 1]; } } return queryDict; } +// Validates a reserved param value. Returns NO if it does not validate (meaning the "&=" was +// actually text inside an enclosing value, not a real parameter). +- (BOOL)isReservedValue:(NSString *)value validForKey:(NSString *)key { + if ([key isEqualToString:@"event"]) { + return [self isValidJSONOfClass:[NSArray class] string:value]; + } else if ([key isEqualToString:@"resize_me"]) { + return [self isValidJSONOfClass:[NSDictionary class] string:value]; + } else if ([key isEqualToString:@"close"]) { + return [value isEqualToString:@"0"] || [value isEqualToString:@"1"]; + } else if ([key isEqualToString:@"action"]) { + return [value isEqualToString:@"event"] || [value isEqualToString:@"link"] || [value isEqualToString:@"resize_me"]; + } else if ([key isEqualToString:@"link"]) { + return [self hasURIScheme:value]; + } + return NO; +} + +- (BOOL)isValidJSONOfClass:(Class)klass string:(NSString *)value { + NSData *data = [value dataUsingEncoding:NSUTF8StringEncoding]; + if (!data) { + return NO; + } + id obj = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + return [obj isKindOfClass:klass]; +} + +// YES if value begins with a URI scheme (ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) ":"), covering +// http(s) URLs and custom-scheme deeplinks. The server prepends "https://" to schemeless links, so +// a valid link value always carries a scheme. +- (BOOL)hasURIScheme:(NSString *)value { + NSRange colon = [value rangeOfString:@":"]; + if (colon.location == NSNotFound || colon.location == 0) { + return NO; + } + for (NSUInteger i = 0; i < colon.location; i++) { + unichar c = [value characterAtIndex:i]; + BOOL ok; + if (i == 0) { + ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + } else { + ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '+' || c == '-' || c == '.'; + } + if (!ok) { + return NO; + } + } + return YES; +} + - (void)recordEventsWithJSONString:(NSString *)jsonString { - // Decode the URL-encoded JSON string - NSString *decodedString = [jsonString stringByRemovingPercentEncoding]; + // The value is already percent-decoded by parseQueryString; use it directly. + NSString *decodedString = jsonString; // Convert the decoded string to NSData NSData *data = [decodedString dataUsingEncoding:NSUTF8StringEncoding]; @@ -454,54 +879,74 @@ - (void)recordEventsWithJSONString:(NSString *)jsonString { NSArray *events = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; if (error) { - CLY_LOG_I(@"%s Error parsing JSON: %@", __FUNCTION__, error); - } else { - CLY_LOG_I(@"%s Parsed JSON: %@", __FUNCTION__, events); + CLY_LOG_D(@"%s parsing the content events JSON failed, error: [%@], domain: [%@], code: [%ld], jsonLength: [%lu]", __FUNCTION__, error.localizedDescription, error.domain, (long)error.code, (unsigned long)jsonString.length); } + // counts only, each event is logged below with the segmentation the behavior settings let through + CLY_LOG_D(@"%s content events JSON parsed, jsonLength: [%lu], eventCount: [%lu]", __FUNCTION__, (unsigned long)jsonString.length, [events isKindOfClass:NSArray.class] ? (unsigned long)events.count : 0UL); + if (!events || ![events isKindOfClass:[NSArray class]]) { - CLY_LOG_I(@"Events array should not be empty or nil, and should be of type NSArray"); + CLY_LOG_E(@"%s the content events payload is nil or not an array, ignoring the action", __FUNCTION__); return; } + NSUInteger skippedCount = 0; for (NSDictionary *event in events) { NSString *key = event[@"key"]; NSDictionary *segmentation = event[@"segmentation"]; NSDictionary *sg = event[@"sg"]; if(!key) { - CLY_LOG_I(@"Skipping the event due to key is empty or nil"); + CLY_LOG_V(@"%s skipping a content event, reason: [the event key is nil or empty]", __FUNCTION__); + skippedCount++; continue; } if(sg) { segmentation = sg; } if(!segmentation) { - CLY_LOG_I(@"Skipping the event due to missing segmentation"); + CLY_LOG_V(@"%s skipping a content event, reason: [the event has no segmentation], key: [%@]", __FUNCTION__, key); + skippedCount++; continue; } - [Countly.sharedInstance recordEvent:key segmentation:segmentation]; + // The page reported it is actually showing content: cancel the absolute + // content-shown deadline so a genuinely-displayed content is never torn down. + if ([key isEqualToString:@"[CLY]_content_shown"]) { + CLY_LOG_D(@"%s the page reported that the content is shown, cancelling the content shown deadline", __FUNCTION__); + [self.contentShownDeadlineTimer invalidate]; + self.contentShownDeadlineTimer = nil; + } + + CLY_LOG_D(@"%s recording a content event, key: [%@], segmentation: [%@]", __FUNCTION__, key, [Countly.sharedInstance loggableSegmentation:segmentation eventKey:key]); + + [Countly.sharedInstance recordEvent:key segmentation:[CountlyContentBuilderInternal.sharedInstance mergeGlobalContentSegmentationInto:segmentation]]; + } + + if (skippedCount) { + CLY_LOG_D(@"%s some content events were dropped, dropped: [%lu], recorded: [%lu]", __FUNCTION__, (unsigned long)skippedCount, (unsigned long)(events.count - skippedCount)); + } else { + CLY_LOG_I(@"%s content events recorded, count: [%lu]", __FUNCTION__, (unsigned long)events.count); } [CountlyConnectionManager.sharedInstance attemptToSendStoredRequests]; } - (void)openExternalLink:(NSString *)urlString { + CLY_LOG_D(@"%s an external link was requested by the content, link: [%@]", __FUNCTION__, urlString); NSURL *url = [NSURL URLWithString:urlString]; - if (url) { - [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:^(BOOL success) { - if (success) { - CLY_LOG_I(@"URL [%@] opened in external browser", urlString); - } else { - CLY_LOG_I(@"Unable to open URL [%@] in external browser", urlString); - } - }]; + if (!url) { + // The decoded link may contain characters NSURL rejects (e.g. a space from a decoded '%20'). + // Re-encode the illegal characters while preserving URL structure, then retry. + NSString *encoded = [urlString stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLFragmentAllowedCharacterSet]]; + url = encoded ? [NSURL URLWithString:encoded] : nil; } + // Prefers the app (Universal Link) when enableUniversalLinkHandling is on, else browser. + [self openExternalURL:url]; } - (void)resizeWebViewWithJSONString:(NSString *)jsonString { - // Decode the URL-encoded JSON string - NSString *decodedString = [jsonString stringByRemovingPercentEncoding]; + // The value is already percent-decoded by parseQueryString; use it directly. + NSString *decodedString = jsonString; // Convert the decoded string to NSData NSData *data = [decodedString dataUsingEncoding:NSUTF8StringEncoding]; @@ -511,63 +956,82 @@ - (void)resizeWebViewWithJSONString:(NSString *)jsonString { NSDictionary *resizeDict = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; if (!resizeDict) { - CLY_LOG_I(@"Resize dictionary should not be empty or nil. Error: %@", error); + CLY_LOG_E(@"%s parsing the resize JSON failed or it was empty, error: [%@], jsonLength: [%lu]", __FUNCTION__, error.localizedDescription, (unsigned long)jsonString.length); return; } // Ensure resizeDict is a dictionary if (![resizeDict isKindOfClass:[NSDictionary class]]) { - CLY_LOG_I(@"Resize dictionary should be of type NSDictionary"); + CLY_LOG_E(@"%s the resize payload is not a dictionary, ignoring the resize", __FUNCTION__); return; } + CLY_LOG_D(@"%s the resize payload parsed, json: [%@], resizeDict: [%@]", __FUNCTION__, jsonString, resizeDict); + // Retrieve portrait and landscape dimensions NSDictionary *portraitDimensions = resizeDict[@"p"]; NSDictionary *landscapeDimensions = resizeDict[@"l"]; if (!portraitDimensions && !landscapeDimensions) { - CLY_LOG_I(@"Resize dimensions should not be empty or nil"); + CLY_LOG_W(@"%s the resize payload carries neither portrait nor landscape dimensions, ignoring the resize", __FUNCTION__); return; } - // Determine the current orientation - UIInterfaceOrientation orientation = [UIApplication sharedApplication].statusBarOrientation; - BOOL isLandscape = UIInterfaceOrientationIsLandscape(orientation); + // Prefer the window the content is in over interfaceOrientation, which can disagree. + UIWindow *ownWindow = self.backgroundView.window; + BOOL isLandscape; + if (ownWindow && !CGRectIsEmpty(ownWindow.bounds)) { + isLandscape = ownWindow.bounds.size.width > ownWindow.bounds.size.height; + } else { + isLandscape = UIInterfaceOrientationIsLandscape([CountlyCommon.sharedInstance interfaceOrientation]); + } + + // Content pinned to portrait must stay portrait even if the page asks for landscape. + if (!self.isFeedbackWidget && CountlyContentBuilderInternal.sharedInstance.disableRotation) { + isLandscape = NO; + } // Select the appropriate dimensions based on orientation NSDictionary *dimensions = isLandscape ? landscapeDimensions : portraitDimensions; + if (!dimensions) { + dimensions = isLandscape ? portraitDimensions : landscapeDimensions; + } - // Get the dimension values - CGFloat x = [dimensions[@"x"] floatValue]; - CGFloat y = [dimensions[@"y"] floatValue]; - CGFloat width = [dimensions[@"w"] floatValue]; - CGFloat height = [dimensions[@"h"] floatValue]; + CGRect base = [self rectFromDimensions:dimensions]; // Animate the resizing of the web view [UIView animateWithDuration:0.3 animations:^{ - CGRect frame = self.backgroundView.webView.frame; - frame.origin.x = x; - frame.origin.y = y; - frame.size.width = width; - frame.size.height = height; - self.backgroundView.webView.frame = frame; + self.backgroundView.baseWebViewFrame = base; + self.backgroundView.webView.frame = base; [self.presentingController updatePlacementRespectToSafeAreas]; } completion:^(BOOL finished) { - CLY_LOG_I(@"%s, Resized web view to width: %f, height: %f", __FUNCTION__, width, height); + CLY_LOG_D(@"%s the web view was resized on the page's request, frame: [%@], finished: [%@]", __FUNCTION__, NSStringFromCGRect(base), finished ? @"YES" : @"NO"); }]; } +- (CGRect)rectFromDimensions:(NSDictionary *)dimensions { + return CGRectMake([dimensions[@"x"] floatValue], + [dimensions[@"y"] floatValue], + [dimensions[@"w"] floatValue], + [dimensions[@"h"] floatValue]); +} + - (void)closeWebView { dispatch_async(dispatch_get_main_queue(), ^{ if (!self.backgroundView.webView) { + CLY_LOG_D(@"%s the web view is already dismissed, skipping this dismissal", __FUNCTION__); return; } + CLY_LOG_I(@"%s dismissing the web view, hadAppeared: [%@], resourceRetryCount: [%ld], dismissCallbackProvided: [%@]", __FUNCTION__, self.hasAppeared ? @"YES" : @"NO", (long)self.resourceRetryCount, (self.dismissBlock != nil) ? @"YES" : @"NO"); self.webViewClosed = YES; self.window.hidden = YES; self.loadStartDate = nil; [self.loadTimeoutTimer invalidate]; self.loadTimeoutTimer = nil; + [self.contentShownDeadlineTimer invalidate]; + self.contentShownDeadlineTimer = nil; + [self cancelPendingReload]; if (self.backgroundView) { [self.backgroundView.webView stopLoading]; self.backgroundView.webView.navigationDelegate = nil; @@ -601,9 +1065,12 @@ - (void)closeWebView - (void)loadDidTimeout { if (self.hasAppeared || self.webViewClosed) return; - self.webViewClosed = YES; - CLY_LOG_I(@"%s Web view load timed out after 60s, closing", __FUNCTION__); - [self closeWebView]; + CLY_LOG_D(@"%s the web view load stalled, timeout: [%.1f] seconds, routing it through the retry path", __FUNCTION__, self.loadTimeoutInterval); + // A stalled load fires no JS 'error' event, so it never reaches the resource-error + // retry path. Route it through the same retry here: reload (observed to recover) + // up to the retry cap, then close. Do NOT set webViewClosed first — that would make + // retryOrCloseWebViewForReason: bail out before it can retry. + [self retryOrCloseWebViewForReason:@"the load stalled with no appearance"]; } #endif @end diff --git a/ios/src/Package.swift b/ios/src/Package.swift index 10bf0da8..0b22fd1f 100644 --- a/ios/src/Package.swift +++ b/ios/src/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), - .macOS(.v10_14), - .tvOS(.v10), - .watchOS(.v4) + .iOS(.v15), + .macOS(.v12), + .tvOS(.v15), + .watchOS(.v9), + .visionOS(.v1) ], products: @@ -42,17 +43,22 @@ let package = Package( "CountlyTests/" ], + resources: + [ + .copy("PrivacyInfo.xcprivacy") + ], + 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("WebKit", .when(platforms: [.iOS, .visionOS])), .linkedFramework("CoreTelephony", .when(platforms: [.iOS])), ]), .testTarget( diff --git a/ios/src/PassThroughBackgroundView.h b/ios/src/PassThroughBackgroundView.h index 6e363dae..2955d48f 100644 --- a/ios/src/PassThroughBackgroundView.h +++ b/ios/src/PassThroughBackgroundView.h @@ -5,7 +5,7 @@ // Please visit www.count.ly for more information. -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) #import #import #endif @@ -13,13 +13,27 @@ #import "CountlyCommon.h" NS_ASSUME_NONNULL_BEGIN -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) @interface PassThroughBackgroundView : UIView @property(nonatomic, strong) WKWebView *webView; @property(nonatomic, strong) CLYButton *dismissButton; +/// The web view's placement before safe-area adjustment. Those insets are additive, so every +/// placement pass must start here or they accumulate. CGRectNull means "not tracked". +@property(nonatomic) CGRect baseWebViewFrame; + +/// When set, the page is always told the PORTRAIT-oriented size, whatever the device is doing. +/// Set for content with disableRotation, so the page lays out for the portrait frame it is given. +@property(nonatomic) BOOL reportPortraitSizeOnly; + +/// Tells the page its available size via a `{type:'resize'}` postMessage. +- (void)updateWindowSize; + +/// Applies reportPortraitSizeOnly to a measured size. Exposed for tests. +- (CGSize)portraitAdjustedSize:(CGSize)size; + @end diff --git a/ios/src/PassThroughBackgroundView.m b/ios/src/PassThroughBackgroundView.m index d1b9ec49..2aee06bf 100644 --- a/ios/src/PassThroughBackgroundView.m +++ b/ios/src/PassThroughBackgroundView.m @@ -6,18 +6,22 @@ #import "PassThroughBackgroundView.h" -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) @implementation PassThroughBackgroundView @synthesize webView; - (instancetype)initWithFrame:(CGRect)frame { self = [super initWithFrame:frame]; + if (self) { + _baseWebViewFrame = CGRectNull; + } #if (TARGET_OS_IOS) - [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(handleScreenChange) name:UIDeviceOrientationDidChangeNotification object:nil]; + // No device-orientation observer: rotation is driven by the controller's transition callback, + // which fires once the new size has settled. This one fired mid-rotation and duplicated it. [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(handleScreenChange) name:UIScreenModeDidChangeNotification object:nil]; - [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(handleScreenChange) name:UIApplicationDidBecomeActiveNotification object:nil]; #endif + [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(handleScreenChange) name:UIApplicationDidBecomeActiveNotification object:nil]; return self; } @@ -54,8 +58,18 @@ - (void)handleScreenChange { }); } +// Swaps a landscape measurement to its portrait equivalent, matching how resolutionJson transposes +// the dimensions it sends the server. +- (CGSize)portraitAdjustedSize:(CGSize)size { + if (!self.reportPortraitSizeOnly || size.width <= size.height) { + return size; + } + + return CGSizeMake(size.height, size.width); +} + - (void)updateWindowSize { - CGSize size = [CountlyCommon.sharedInstance getWindowSize]; + CGSize size = [self portraitAdjustedSize:[CountlyCommon.sharedInstance getWindowSize]]; CGFloat width = size.width; CGFloat height = size.height; @@ -65,7 +79,7 @@ - (void)updateWindowSize { height]; [self.webView evaluateJavaScript:postMessage completionHandler:^(id result, NSError *err) { if (err != nil) { - CLY_LOG_E(@"%s updateWindowSize, %@", __FUNCTION__, err); + CLY_LOG_E(@"%s posting the resize message to the page failed, error: [%@], domain: [%@], code: [%ld]", __FUNCTION__, err.localizedDescription, err.domain, (long)err.code); } }]; } diff --git a/ios/src/PrivacyInfo.xcprivacy b/ios/src/PrivacyInfo.xcprivacy index c735f528..6dbdda36 100644 --- a/ios/src/PrivacyInfo.xcprivacy +++ b/ios/src/PrivacyInfo.xcprivacy @@ -59,6 +59,18 @@ NSPrivacyCollectedDataTypePurposeAnalytics + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeOtherDiagnosticData + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + NSPrivacyTrackingDomains diff --git a/ios/src/TouchDelegatingView.h b/ios/src/TouchDelegatingView.h index 72c09a0e..5c4e5c90 100644 --- a/ios/src/TouchDelegatingView.h +++ b/ios/src/TouchDelegatingView.h @@ -3,14 +3,14 @@ // This code is provided under the MIT License. // // Please visit www.count.ly for more information. -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) #import #endif #import "CountlyCommon.h" NS_ASSUME_NONNULL_BEGIN -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) @interface TouchDelegatingView : UIView @property(nonatomic, weak) UIView *touchDelegate; @end diff --git a/ios/src/TouchDelegatingView.m b/ios/src/TouchDelegatingView.m index 11110eb3..35a67ffe 100644 --- a/ios/src/TouchDelegatingView.m +++ b/ios/src/TouchDelegatingView.m @@ -6,7 +6,7 @@ // Copyright © 2026 Countly. All rights reserved. // #import "TouchDelegatingView.h" -#if (TARGET_OS_IOS) +#if (TARGET_OS_IOS || TARGET_OS_VISION) @implementation TouchDelegatingView - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event