diff --git a/CHANGELOG.md b/CHANGELOG.md index b0efab3b..d0ecd6af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ ## XX.XX.XX +* Added support for SDK behavior settings that control the SDK's automatic session tracking, automatic view tracking, automatic crash reporting, and Journey Trigger Views. * 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. diff --git a/Countly.m b/Countly.m index 1259d534..30e9289e 100644 --- a/Countly.m +++ b/Countly.m @@ -193,7 +193,8 @@ - (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]; @@ -233,10 +234,11 @@ - (void)startWithConfig:(CountlyConfig *)config if ([config.features containsObject:CLYCrashReporting]) { CountlyCrashReporter.sharedInstance.isEnabledOnInitialConfig = YES; - if (CountlyServerConfig.sharedInstance.crashReportingEnabled) - { + } + // 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 startCrashReporting]; - } } #if (TARGET_OS_IOS || TARGET_OS_TV ) @@ -244,10 +246,11 @@ - (void)startWithConfig:(CountlyConfig *)config { // Print deprecation flag for feature CountlyViewTrackingInternal.sharedInstance.isEnabledOnInitialConfig = YES; - if (CountlyServerConfig.sharedInstance.viewTrackingEnabled) - { - [CountlyViewTrackingInternal.sharedInstance startAutoViewTracking]; - } + } + // 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]; } if (config.automaticViewTrackingExclusionList) { [CountlyViewTrackingInternal.sharedInstance addAutoViewTrackingExclutionList:config.automaticViewTrackingExclusionList]; @@ -394,11 +397,11 @@ - (void)onTimer:(NSTimer *)timer if (isSuspended) return; - if (!CountlyCommon.sharedInstance.manualSessionHandling) + if (CountlyServerConfig.sharedInstance.automaticSessionTrackingEnabled) { [CountlyConnectionManager.sharedInstance updateSession]; } - // this condtion is called only when both manual session handling and hybrid mode is enabled. + // this condtion is called only when automatic session tracking is not active and hybrid mode is enabled. else if (CountlyCommon.sharedInstance.enableManualSessionControlHybridMode) { [CountlyConnectionManager.sharedInstance updateSession]; @@ -427,9 +430,9 @@ - (void)suspend [CountlyConnectionManager.sharedInstance sendEventsWithSaveIfNeeded]; - if (!CountlyCommon.sharedInstance.manualSessionHandling) + if (CountlyServerConfig.sharedInstance.automaticSessionTrackingEnabled) [CountlyConnectionManager.sharedInstance endSession]; - + [CountlyPersistency.sharedInstance saveToFile]; } @@ -455,9 +458,9 @@ - (void)resume CLY_LOG_D(@"%s manualSessions: [%d]", __FUNCTION__, CountlyCommon.sharedInstance.manualSessionHandling); - if (!CountlyCommon.sharedInstance.manualSessionHandling) + if (CountlyServerConfig.sharedInstance.automaticSessionTrackingEnabled) [CountlyConnectionManager.sharedInstance beginSession]; - + [CountlyViewTrackingInternal.sharedInstance applicationWillEnterForeground]; isSuspended = NO; @@ -615,28 +618,41 @@ - (void)addCustomNetworkRequestHeaders:(NSDictionary *_N - (void)beginSession { CLY_LOG_I(@"%s", __FUNCTION__); - - if (CountlyCommon.sharedInstance.manualSessionHandling) - [CountlyConnectionManager.sharedInstance beginSession]; + + if (CountlyServerConfig.sharedInstance.automaticSessionTrackingEnabled) + { + CLY_LOG_W(@"%s 'beginSession' will be ignored since automatic session tracking is active", __FUNCTION__); + return; + } + + [CountlyConnectionManager.sharedInstance beginSession]; } - (void)updateSession { CLY_LOG_I(@"%s", __FUNCTION__); - - if (CountlyCommon.sharedInstance.manualSessionHandling) - [CountlyConnectionManager.sharedInstance updateSession]; + + if (CountlyServerConfig.sharedInstance.automaticSessionTrackingEnabled) + { + CLY_LOG_W(@"%s 'updateSession' will be ignored since automatic session tracking is active", __FUNCTION__); + return; + } + + [CountlyConnectionManager.sharedInstance updateSession]; } - (void)endSession { CLY_LOG_I(@"%s", __FUNCTION__); - - if (CountlyCommon.sharedInstance.manualSessionHandling) + + if (CountlyServerConfig.sharedInstance.automaticSessionTrackingEnabled) { - [CountlyConnectionManager.sharedInstance sendEventsWithSaveIfNeeded]; - [CountlyConnectionManager.sharedInstance endSession]; + CLY_LOG_W(@"%s 'endSession' will be ignored since automatic session tracking is active", __FUNCTION__); + return; } + + [CountlyConnectionManager.sharedInstance sendEventsWithSaveIfNeeded]; + [CountlyConnectionManager.sharedInstance endSession]; } @@ -1022,7 +1038,28 @@ - (void)recordEvent:(NSString *)key segmentation:(NSDictionary *)segmentation co { event.key = key; event.segmentation = [self processSegmentation:filteredSegmentations eventKey:key]; - [CountlyPersistency.sharedInstance recordEvent:event]; + id callback = nil; + // Journey trigger views mirror the journey trigger events behavior: a matching view name force-flushes + // the event queue and refreshes the content zone when the request succeeds. 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 = ^(NSString *response, BOOL success) { + if (success) + { + #if (TARGET_OS_IOS) + dispatch_async(dispatch_get_main_queue(), ^{ + [CountlyContentBuilderInternal.sharedInstance refreshContentZoneJTE]; + }); + #endif + } + }; + } + } + [CountlyPersistency.sharedInstance recordEvent:event callback:callback]; } } diff --git a/CountlyConnectionManager.m b/CountlyConnectionManager.m index d1ef9ac8..73b11a51 100644 --- a/CountlyConnectionManager.m +++ b/CountlyConnectionManager.m @@ -596,17 +596,17 @@ - (void)beginSession } #if TARGET_OS_IOS || TARGET_OS_TV - if (!CountlyCommon.sharedInstance.manualSessionHandling && [UIApplication sharedApplication].applicationState == UIApplicationStateBackground) { + if (CountlyServerConfig.sharedInstance.automaticSessionTrackingEnabled && [UIApplication sharedApplication].applicationState == UIApplicationStateBackground) { CLY_LOG_W(@"%s App is in the background, 'beginSession' will be ignored", __FUNCTION__); return; } #elif TARGET_OS_OSX - if (!CountlyCommon.sharedInstance.manualSessionHandling && ![NSApplication sharedApplication].isActive) { + if (CountlyServerConfig.sharedInstance.automaticSessionTrackingEnabled && ![NSApplication sharedApplication].isActive) { CLY_LOG_W(@"%s App is not active, 'beginSession' will be ignored", __FUNCTION__); return; } #elif TARGET_OS_WATCH - if (!CountlyCommon.sharedInstance.manualSessionHandling && [WKExtension sharedExtension].applicationState == WKApplicationStateBackground) { + if (CountlyServerConfig.sharedInstance.automaticSessionTrackingEnabled && [WKExtension sharedExtension].applicationState == WKApplicationStateBackground) { CLY_LOG_W(@"%s App is in the background, 'beginSession' will be ignored", __FUNCTION__); return; } @@ -823,7 +823,7 @@ - (void)sendCrashReport:(NSString *)report immediately:(BOOL)immediately; [self sendEventsWithSaveIfNeeded]; - if (!CountlyCommon.sharedInstance.manualSessionHandling) + if (CountlyServerConfig.sharedInstance.automaticSessionTrackingEnabled) [self endSession]; if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) diff --git a/CountlyConsentManager.m b/CountlyConsentManager.m index ef9ca76c..f6e9795a 100644 --- a/CountlyConsentManager.m +++ b/CountlyConsentManager.m @@ -273,7 +273,7 @@ - (void)setConsentForSessions:(BOOL)consentForSessions { CLY_LOG_D(@"Consent for Session is given."); - if (!CountlyCommon.sharedInstance.manualSessionHandling) + if (CountlyServerConfig.sharedInstance.automaticSessionTrackingEnabled) [CountlyConnectionManager.sharedInstance beginSession]; } else diff --git a/CountlyCrashReporter.m b/CountlyCrashReporter.m index ef1d697d..32b62b87 100644 --- a/CountlyCrashReporter.m +++ b/CountlyCrashReporter.m @@ -52,6 +52,9 @@ @interface CountlyCrashReporter () +// Tracks whether the unhandled crash handlers have been installed, so they are installed only once +// and can be removed even when they were force-enabled by the server instead of the developer config +@property (nonatomic) BOOL unhandledCrashHandlerInstalled; @property (nonatomic) NSMutableArray* customCrashLogs; @property (nonatomic) NSDateFormatter* dateFormatter; @property (nonatomic) NSString* buildUUID; @@ -91,12 +94,22 @@ - (instancetype)init - (void)startCrashReporting { - if (!self.isEnabledOnInitialConfig) + // For the default handler path the live handler is checked instead of only the flag, since the + // global handler can be replaced externally while the singleton (and its flag) lives on + BOOL alreadyInstalled = self.unhandledCrashHandlerInstalled && (self.shouldUsePLCrashReporter || NSGetUncaughtExceptionHandler() == &CountlyUncaughtExceptionHandler); + if (alreadyInstalled) + return; + + // 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) return; if (!CountlyConsentManager.sharedInstance.consentForCrashReporting) return; + self.unhandledCrashHandlerInstalled = YES; + if (self.shouldUsePLCrashReporter) { #ifdef COUNTLY_PLCRASHREPORTER_EXISTS @@ -123,9 +136,11 @@ - (void)startCrashReporting - (void)stopCrashReporting { - if (!self.isEnabledOnInitialConfig) + if (!self.unhandledCrashHandlerInstalled) return; + self.unhandledCrashHandlerInstalled = NO; + NSSetUncaughtExceptionHandler(NULL); #if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV || TARGET_OS_OSX) @@ -246,7 +261,14 @@ void CountlyExceptionHandler(NSException *exception, bool isFatal, bool isAutoDe { 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; + + NSArray* stackTrace = exception.userInfo[kCountlyExceptionUserInfoBacktraceKey]; if (!stackTrace) stackTrace = exception.callStackSymbols; diff --git a/CountlyServerConfig.h b/CountlyServerConfig.h index 75a142d0..dd5052b9 100644 --- a/CountlyServerConfig.h +++ b/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,6 +56,7 @@ 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; @end diff --git a/CountlyServerConfig.m b/CountlyServerConfig.m index 829ccf3e..50834a98 100644 --- a/CountlyServerConfig.m +++ b/CountlyServerConfig.m @@ -12,6 +12,9 @@ @interface CountlyServerConfig () { @property (nonatomic) BOOL trackingEnabled; @property (nonatomic) BOOL networkingEnabled; @property (nonatomic) BOOL crashReportingEnabled; +@property (nonatomic) BOOL automaticSessionTracking; +@property (nonatomic) BOOL automaticViewTracking; +@property (nonatomic) BOOL automaticCrashReporting; @property (nonatomic) BOOL loggingEnabled; @property (nonatomic) BOOL customEventTrackingEnabled; @property (nonatomic) BOOL viewTrackingEnabled; @@ -53,6 +56,7 @@ @interface CountlyServerConfig () { @property (nonatomic) NSDictionary *> *eventSegmentationFilterMap; @property (nonatomic) BOOL eventSegmentationFilterIsWhitelist; @property (nonatomic) NSSet *journeyTriggerEvents; +@property (nonatomic) NSSet *journeyTriggerViews; @property (nonatomic) NSInteger version; @property (nonatomic) long long timestamp; @@ -91,6 +95,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,6 +114,7 @@ @interface CountlyServerConfig () { NSString *const kREventSegmentationBlacklist = @"esb"; NSString *const kREventSegmentationWhitelist = @"esw"; NSString *const kRJourneyTriggerEvents = @"jte"; +NSString *const kRJourneyTriggerViews = @"jtv"; static CountlyServerConfig *s_sharedInstance = nil; static dispatch_once_t onceToken; @@ -161,6 +169,16 @@ - (void)resetInstance - (void)retrieveServerConfigFromStorage:(CountlyConfig *)config { + // 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. + _automaticSessionTracking = !config.manualSessionHandling; +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV) + _automaticViewTracking = config.enableAutomaticViewTracking || [config.features containsObject:CLYAutoViewTracking]; +#endif + _automaticCrashReporting = [config.features containsObject:CLYCrashReporting]; + NSMutableDictionary *persistentBehaviorSettings = [CountlyPersistency.sharedInstance retrieveServerConfig]; if (persistentBehaviorSettings.count == 0 && config.sdkBehaviorSettings) { @@ -346,6 +364,9 @@ - (void)populateServerConfig:(NSMutableDictionary *)serverConfig withConfig:(Cou kRReqQueueSize, kREventQueueSize, kRCrashReporting, + kRAutomaticSessionTracking, + kRAutomaticViewTracking, + kRAutomaticCrashReporting, kRSessionTracking, kRLogging, kRLimitKeyLength, @@ -377,7 +398,8 @@ - (void)populateServerConfig:(NSMutableDictionary *)serverConfig withConfig:(Cou kRSegmentationWhitelist, kREventSegmentationBlacklist, kREventSegmentationWhitelist, - kRJourneyTriggerEvents + kRJourneyTriggerEvents, + kRJourneyTriggerViews ]]; // Remove unknown keys @@ -396,6 +418,9 @@ - (void)populateServerConfig:(NSMutableDictionary *)serverConfig withConfig:(Cou [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:&_automaticSessionTracking fromDictionary:dictionary key:kRAutomaticSessionTracking logString:logString]; + [self setBoolProperty:&_automaticViewTracking fromDictionary:dictionary key:kRAutomaticViewTracking logString:logString]; + [self setBoolProperty:&_automaticCrashReporting 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]; @@ -532,6 +557,28 @@ - (void)notifySdkConfigChange:(CountlyConfig *)config if(_backoffMechanism && config.disableBackoffMechanism){ _backoffMechanism = NO; } + +#if (TARGET_OS_IOS || TARGET_OS_VISION || TARGET_OS_TV) + BOOL shouldAutoTrackViews = _viewTrackingEnabled && _automaticViewTracking; + dispatch_async(dispatch_get_main_queue(), ^{ + if (shouldAutoTrackViews && !CountlyViewTrackingInternal.sharedInstance.isAutoViewTrackingActive) + { + [CountlyViewTrackingInternal.sharedInstance startAutoViewTracking]; + } + else if (!shouldAutoTrackViews && CountlyViewTrackingInternal.sharedInstance.isAutoViewTrackingActive) + { + [CountlyViewTrackingInternal.sharedInstance stopAutoViewTracking]; + } + }); +#endif + + if (_crashReportingEnabled && _automaticCrashReporting) + { + // Reactive install: lets the server force-enable automatic crash reporting at runtime. + // 'startCrashReporting' is idempotent and checks consent internally. A runtime 'acr' = false + // does not uninstall the handler; the handler no-ops through its own runtime check instead. + [CountlyCrashReporter.sharedInstance startCrashReporting]; + } } - (void)fetchServerConfigTimer:(NSTimer *)timer @@ -652,6 +699,9 @@ - (void)setDefaultValues { _trackingEnabled = YES; _networkingEnabled = YES; _crashReportingEnabled = YES; + _automaticSessionTracking = YES; + _automaticViewTracking = NO; + _automaticCrashReporting = NO; _customEventTrackingEnabled = YES; _enterContentZone = NO; _locationTracking = YES; @@ -688,6 +738,7 @@ - (void)setDefaultValues { _eventSegmentationFilterMap = @{}; _eventSegmentationFilterIsWhitelist = NO; _journeyTriggerEvents = [NSSet set]; + _journeyTriggerViews = [NSSet set]; } - (void)disableSDKBehaviourSettings { @@ -724,6 +775,21 @@ - (BOOL)crashReportingEnabled return _crashReportingEnabled; } +- (BOOL)automaticSessionTrackingEnabled +{ + return _automaticSessionTracking; +} + +- (BOOL)automaticViewTrackingEnabled +{ + return _automaticViewTracking; +} + +- (BOOL)automaticCrashReportingEnabled +{ + return _automaticCrashReporting; +} + - (BOOL)sessionTrackingEnabled { return _sessionTrackingEnabled; @@ -976,6 +1042,16 @@ - (void)updateListingFilters:(NSMutableDictionary *)dictionary logString:(NSMuta if (jte) [dictionary removeObjectForKey:kRJourneyTriggerEvents]; } + + // Journey trigger views (jtv) + NSArray *jtv = dictionary[kRJourneyTriggerViews]; + if ([jtv isKindOfClass:NSArray.class]) { + _journeyTriggerViews = [NSSet setWithArray:jtv]; + [logString appendFormat:@"%@: %@, ", kRJourneyTriggerViews, jtv]; + } else { + if (jtv) + [dictionary removeObjectForKey:kRJourneyTriggerViews]; + } } - (BOOL)shouldRecordEvent:(NSString *)eventKey @@ -1024,4 +1100,9 @@ - (BOOL)isJourneyTriggerEvent:(NSString *)eventKey return [_journeyTriggerEvents containsObject:eventKey]; } +- (BOOL)isJourneyTriggerView:(NSString *)viewName +{ + return [_journeyTriggerViews containsObject:viewName]; +} + @end diff --git a/CountlyTests/CountlyServerConfigTests.swift b/CountlyTests/CountlyServerConfigTests.swift index c717c0f7..40b3d695 100644 --- a/CountlyTests/CountlyServerConfigTests.swift +++ b/CountlyTests/CountlyServerConfigTests.swift @@ -1512,6 +1512,267 @@ class CountlyServerConfigTests: CountlyBaseTestCase { // MARK: - All Features with Listing Filters + // 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 + */ + 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 + */ + 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. + */ + 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) + } + + /** + * 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) + */ + 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. + */ + 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. diff --git a/CountlyTests/ServerConfigBuilder.swift b/CountlyTests/ServerConfigBuilder.swift index ce8dcc51..657b3ef5 100644 --- a/CountlyTests/ServerConfigBuilder.swift +++ b/CountlyTests/ServerConfigBuilder.swift @@ -33,6 +33,9 @@ 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" @@ -44,6 +47,7 @@ class ServerConfigBuilder { static let eventSegmentationBlacklist = "esb" static let eventSegmentationWhitelist = "esw" static let journeyTriggerEvents = "jte" + static let journeyTriggerViews = "jtv" } // MARK: - Feature Flags @@ -72,6 +76,24 @@ class ServerConfigBuilder { 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 @@ -164,6 +186,12 @@ class ServerConfigBuilder { return this } + @discardableResult + func journeyTriggerViews(_ names: [String]) -> ServerConfigBuilder { + config[Keys.journeyTriggerViews] = names + return this + } + // MARK: - Intervals and Sizes @discardableResult @@ -335,6 +363,15 @@ class ServerConfigBuilder { 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()) } @@ -422,6 +459,14 @@ class ServerConfigBuilder { } 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 diff --git a/CountlyViewTrackingInternal.h b/CountlyViewTrackingInternal.h index 4f342350..bc9d3947 100644 --- a/CountlyViewTrackingInternal.h +++ b/CountlyViewTrackingInternal.h @@ -12,6 +12,7 @@ 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; diff --git a/CountlyViewTrackingInternal.m b/CountlyViewTrackingInternal.m index 8407db6d..b2fa3a66 100644 --- a/CountlyViewTrackingInternal.m +++ b/CountlyViewTrackingInternal.m @@ -249,9 +249,11 @@ - (void)addAutoViewTrackingExclutionList:(NSArray *)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) return; - + if (!CountlyConsentManager.sharedInstance.consentForViewTracking) return; @@ -274,9 +276,11 @@ - (void)stopAutoViewTracking - (void)setIsAutoViewTrackingActive:(BOOL)isAutoViewTrackingActive { - if (!self.isEnabledOnInitialConfig) + // Allowed when the developer enabled automatic view tracking, when the resolved 'avt' value enables it, + // or when it is currently active (so a server force-enabled tracker can still be turned off) + if (!self.isEnabledOnInitialConfig && !CountlyServerConfig.sharedInstance.automaticViewTrackingEnabled && !_isAutoViewTrackingActive) return; - + if (!CountlyConsentManager.sharedInstance.consentForViewTracking) return; if (_isAutoViewTrackingActive != isAutoViewTrackingActive) { @@ -691,7 +695,11 @@ - (void)performAutoViewTrackingForViewController:(UIViewController *)viewControl { if (!self.isAutoViewTrackingActive) return; - + + // Checked per view appearance so a runtime 'avt' = false from the server takes effect immediately + if (!CountlyServerConfig.sharedInstance.automaticViewTrackingEnabled) + return; + if (!CountlyConsentManager.sharedInstance.consentForViewTracking) return;