From 9dffb1add859417d254a41cd6f3cecd3346e6d15 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 9 Jul 2026 15:26:29 +0300 Subject: [PATCH 1/4] feat: direct content shown timer --- CHANGELOG.md | 2 - CountlyTests/CountlyWebViewManager+Tests.h | 3 ++ CountlyTests/CountlyWebViewManagerTests.swift | 46 +++++++++++++++++++ CountlyWebViewManager.m | 36 +++++++++++++++ 4 files changed, 85 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07da5573..bfbd3602 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,6 @@ * Added a content configuration option to disable pinch zoom, disabled via "disableZoom". * Improved link handling for content and feedback widgets, so links that carry their own query parameters, such as deep links, are parsed correctly. -* Mitigated an issue where two content web views could be presented at the same time when content fetches completed concurrently. - ## 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. diff --git a/CountlyTests/CountlyWebViewManager+Tests.h b/CountlyTests/CountlyWebViewManager+Tests.h index a4092707..236a6edd 100644 --- a/CountlyTests/CountlyWebViewManager+Tests.h +++ b/CountlyTests/CountlyWebViewManager+Tests.h @@ -9,6 +9,7 @@ @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); @@ -20,6 +21,8 @@ - (void)closeWebView; - (void)retryOrCloseWebViewForReason:(NSString *)reason; - (void)cancelPendingReload; +- (void)contentShownDeadlineReached; +- (void)recordEventsWithJSONString:(NSString *)jsonString; @end #endif diff --git a/CountlyTests/CountlyWebViewManagerTests.swift b/CountlyTests/CountlyWebViewManagerTests.swift index 203a7e24..2412a67e 100644 --- a/CountlyTests/CountlyWebViewManagerTests.swift +++ b/CountlyTests/CountlyWebViewManagerTests.swift @@ -729,6 +729,52 @@ class CountlyWebViewManagerTests: XCTestCase { 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: 3.0) + 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 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 diff --git a/CountlyWebViewManager.m b/CountlyWebViewManager.m index 8cc00319..f8d9fe7f 100644 --- a/CountlyWebViewManager.m +++ b/CountlyWebViewManager.m @@ -30,6 +30,13 @@ // 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; // TODO: improve logging, check edge cases @interface CountlyWebViewManager () @@ -46,6 +53,9 @@ @interface CountlyWebViewManager () // 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; @end @@ -168,6 +178,14 @@ - (void)createWebViewWithURL:(NSURL *)url 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. + __weak typeof(self) weakSelf = self; + self.contentShownDeadlineTimer = [NSTimer scheduledTimerWithTimeInterval:kCLYContentShownDeadline repeats:NO block:^(NSTimer * _Nonnull timer) { + [weakSelf contentShownDeadlineReached]; + }]; + CLYButton *dismissButton = [CLYButton dismissAlertButton:@"X"]; [self configureDismissButton:dismissButton forWebView:webView]; @@ -485,6 +503,15 @@ - (void)cancelPendingReload { } } +// 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_I(@"%s [CLY]_content_shown not received within %.0fs; closing web view.", __FUNCTION__, kCLYContentShownDeadline); + [self closeWebView]; +} + - (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message { @@ -775,6 +802,13 @@ - (void)recordEventsWithJSONString:(NSString *)jsonString { continue; } + // 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"]) { + [self.contentShownDeadlineTimer invalidate]; + self.contentShownDeadlineTimer = nil; + } + [Countly.sharedInstance recordEvent:key segmentation:segmentation]; } @@ -870,6 +904,8 @@ - (void)closeWebView self.loadStartDate = nil; [self.loadTimeoutTimer invalidate]; self.loadTimeoutTimer = nil; + [self.contentShownDeadlineTimer invalidate]; + self.contentShownDeadlineTimer = nil; [self cancelPendingReload]; if (self.backgroundView) { [self.backgroundView.webView stopLoading]; From 423821b44d1fc4d5929e69ca035043a6d3941ce8 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 9 Jul 2026 15:40:28 +0300 Subject: [PATCH 2/4] feat: direct content shown timer: test --- CountlyTests/CountlyWebViewManagerTests.swift | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/CountlyTests/CountlyWebViewManagerTests.swift b/CountlyTests/CountlyWebViewManagerTests.swift index 2412a67e..cc1f840f 100644 --- a/CountlyTests/CountlyWebViewManagerTests.swift +++ b/CountlyTests/CountlyWebViewManagerTests.swift @@ -574,17 +574,20 @@ class CountlyWebViewManagerTests: XCTestCase { var dismissCalled = false manager.dismissBlock = { dismissCalled = true } - let js = """ - window.webkit.messageHandlers.resourceLoadError.postMessage({ - tag: "SCRIPT", - url: "https://example.com/broken.js" - }); + // 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.evaluateJavaScript(js, completionHandler: nil) + webView.loadHTMLString(html, baseURL: URL(string: "https://example.com")) - let settle = expectation(description: "settle") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { settle.fulfill() } - waitForExpectations(timeout: 2.0) + let scheduled = XCTNSPredicateExpectation(predicate: NSPredicate(block: { [weak manager] _, _ in + manager?.pendingReloadBlock != nil + }), object: nil) + wait(for: [scheduled], timeout: 5.0) XCTAssertFalse(manager.webViewClosed) XCTAssertFalse(dismissCalled) From 61eb4a0a64d91d89ea905d0ebfa929e763757743 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 9 Jul 2026 15:53:09 +0300 Subject: [PATCH 3/4] feat: universal https handling --- CHANGELOG.md | 1 + Countly.m | 1 + CountlyContentBuilderInternal.h | 1 + CountlyContentConfig.h | 10 +++++++ CountlyContentConfig.m | 11 +++++++ CountlyTests/CountlyContentBuilderTests.swift | 25 ++++++++++++++++ CountlyWebViewManager.m | 30 +++++++++++++++++-- 7 files changed, 77 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfbd3602..0c049cdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## 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 open content web view links in the app (Universal Links) or the system browser instead of the content overlay, enabled via "enableUniversalLinkHandling". * 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 diff --git a/Countly.m b/Countly.m index be94aeae..9d7d72ea 100644 --- a/Countly.m +++ b/Countly.m @@ -298,6 +298,7 @@ - (void)startWithConfig:(CountlyConfig *)config CountlyContentBuilderInternal.sharedInstance.enableContentReloadOnStall = config.content.getEnableContentReloadOnStall; CountlyContentBuilderInternal.sharedInstance.contentReloadOnStallTimeout = config.content.getContentReloadOnStallTimeout / 1000.0; CountlyContentBuilderInternal.sharedInstance.disableZoom = config.content.getDisableZoom; + CountlyContentBuilderInternal.sharedInstance.enableUniversalLinkHandling = config.content.getEnableUniversalLinkHandling; #endif [CountlyPerformanceMonitoring.sharedInstance startWithConfig:config.apm]; diff --git a/CountlyContentBuilderInternal.h b/CountlyContentBuilderInternal.h index 66b91682..c78d5410 100644 --- a/CountlyContentBuilderInternal.h +++ b/CountlyContentBuilderInternal.h @@ -19,6 +19,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, assign) BOOL enableContentReloadOnStall; @property (nonatomic, assign) NSTimeInterval contentReloadOnStallTimeout; // seconds @property (nonatomic, assign) BOOL disableZoom; +@property (nonatomic, assign) BOOL enableUniversalLinkHandling; @property (nonatomic, assign) int contentInitialDelay; + (instancetype)sharedInstance; diff --git a/CountlyContentConfig.h b/CountlyContentConfig.h index 16235740..207a165a 100644 --- a/CountlyContentConfig.h +++ b/CountlyContentConfig.h @@ -88,6 +88,16 @@ typedef void (^ContentCallback)(ContentStatus contentStatus, NSDictionary + * enableUniversalLinkHandling is a one-way config switch that plumbs to the internal builder. + * + * 1- A fresh CountlyContentConfig has it disabled by default + * 2- enableUniversalLinkHandling() turns it on and round-trips on the config + * 3- After start, the internal builder reflects it + * + */ + func test_universalLinkHandling_configDefaultsAndPlumbing() { + XCTAssertFalse(CountlyContentConfig().getEnableUniversalLinkHandling()) + + let config = createContentTestConfig() + config.content().enableUniversalLinkHandling() + XCTAssertTrue(config.content().getEnableUniversalLinkHandling()) + + 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().enableUniversalLinkHandling) + } + /** *
      * The single-content presentation latch rejects a second concurrent presentation.
diff --git a/CountlyWebViewManager.m b/CountlyWebViewManager.m
index f8d9fe7f..36ad7260 100644
--- a/CountlyWebViewManager.m
+++ b/CountlyWebViewManager.m
@@ -270,9 +270,35 @@ - (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigati
         }
 
         decisionHandler(WKNavigationActionPolicyCancel);
-    } else {
-        decisionHandler(WKNavigationActionPolicyAllow);
+        return;
+    }
+
+    // Opt-in (CountlyContentConfig enableUniversalLinkHandling): hand a user-tapped http(s)
+    // link to the OS instead of rendering it in the content overlay. If it matches one of the
+    // app's associated domains it opens the app (Universal Link / deep link); otherwise it
+    // opens in the system browser. Only link activations are routed, so the initial content
+    // load, sub-resources, and reloads still load normally in the web view.
+    if (CountlyContentBuilderInternal.sharedInstance.enableUniversalLinkHandling
+        && navigationAction.navigationType == WKNavigationTypeLinkActivated
+        && ([url hasPrefix:@"https://"] || [url hasPrefix:@"http://"])) {
+        NSURL *linkURL = navigationAction.request.URL;
+        [[UIApplication sharedApplication] openURL:linkURL
+                                           options:@{UIApplicationOpenURLOptionUniversalLinksOnly: @YES}
+                                 completionHandler:^(BOOL openedInApp) {
+            if (openedInApp) {
+                CLY_LOG_I(@"%s Tapped link [%@] opened in the app via Universal Link.", __FUNCTION__, url);
+                return;
+            }
+            // Not a registered Universal Link: fall back to the system browser.
+            [[UIApplication sharedApplication] openURL:linkURL options:@{} completionHandler:^(BOOL openedInBrowser) {
+                CLY_LOG_I(@"%s Tapped link [%@] is not a Universal Link; opened in browser: %@.", __FUNCTION__, url, openedInBrowser ? @"YES" : @"NO");
+            }];
+        }];
+        decisionHandler(WKNavigationActionPolicyCancel);
+        return;
     }
+
+    decisionHandler(WKNavigationActionPolicyAllow);
 }
 
 - (void)webView:(WKWebView *)webView decidePolicyForNavigationResponse:(WKNavigationResponse *)navigationResponse decisionHandler:(void (^)(WKNavigationResponsePolicy))decisionHandler {

From 400202eabe9c6a02b3b5cd6be397fa2c6f908429 Mon Sep 17 00:00:00 2001
From: Arif Burak Demiray 
Date: Thu, 9 Jul 2026 16:06:12 +0300
Subject: [PATCH 4/4] feat: universal https handling

---
 CHANGELOG.md            |  2 +-
 CountlyWebViewManager.m | 62 ++++++++++++++++++-----------------------
 2 files changed, 28 insertions(+), 36 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0c049cdd..7bdb4b0f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,7 +1,7 @@
 ## 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 open content web view links in the app (Universal Links) or the system browser instead of the content overlay, enabled via "enableUniversalLinkHandling".
+* Added a content configuration option to open external content links as Universal Links (app deep links) when possible instead of always forcing them into the system browser, enabled via "enableUniversalLinkHandling".
 * 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
diff --git a/CountlyWebViewManager.m b/CountlyWebViewManager.m
index 36ad7260..ca4e1676 100644
--- a/CountlyWebViewManager.m
+++ b/CountlyWebViewManager.m
@@ -243,15 +243,9 @@ - (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigati
     }
 
     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);
-            }
-        }];
+        CLY_LOG_I(@"%s Opening external url [%@]", __FUNCTION__, url);
+        // Prefers the app (Universal Link) when enableUniversalLinkHandling is on, else browser.
+        [self openExternalURL:navigationAction.request.URL];
         decisionHandler(WKNavigationActionPolicyCancel);
         return;
     }
@@ -273,32 +267,37 @@ - (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigati
         return;
     }
 
-    // Opt-in (CountlyContentConfig enableUniversalLinkHandling): hand a user-tapped http(s)
-    // link to the OS instead of rendering it in the content overlay. If it matches one of the
-    // app's associated domains it opens the app (Universal Link / deep link); otherwise it
-    // opens in the system browser. Only link activations are routed, so the initial content
-    // load, sub-resources, and reloads still load normally in the web view.
-    if (CountlyContentBuilderInternal.sharedInstance.enableUniversalLinkHandling
-        && navigationAction.navigationType == WKNavigationTypeLinkActivated
-        && ([url hasPrefix:@"https://"] || [url hasPrefix:@"http://"])) {
-        NSURL *linkURL = navigationAction.request.URL;
-        [[UIApplication sharedApplication] openURL:linkURL
-                                           options:@{UIApplicationOpenURLOptionUniversalLinksOnly: @YES}
-                                 completionHandler:^(BOOL openedInApp) {
+    decisionHandler(WKNavigationActionPolicyAllow);
+}
+
+// Opens an external URL from the content. When universal-link handling is enabled
+// (CountlyContentConfig enableUniversalLinkHandling), the URL is first offered to the OS as a
+// Universal Link (UIApplicationOpenURLOptionUniversalLinksOnly), so a link matching the host
+// app's own associated domains opens the app (deep link) rather than being forced into Safari
+// -- which is what a plain openURL: does for an app's own Universal Link. Only if it is not a
+// Universal Link does it fall back to the system browser. When the option is off, it opens in
+// the browser as before.
+- (void)openExternalURL:(NSURL *)url {
+    if (!url) return;
+    UIApplication *application = UIApplication.sharedApplication;
+
+    if (CountlyContentBuilderInternal.sharedInstance.enableUniversalLinkHandling) {
+        [application openURL:url options:@{UIApplicationOpenURLOptionUniversalLinksOnly: @YES} completionHandler:^(BOOL openedInApp) {
             if (openedInApp) {
-                CLY_LOG_I(@"%s Tapped link [%@] opened in the app via Universal Link.", __FUNCTION__, url);
+                CLY_LOG_I(@"%s URL [%@] opened in the app via Universal Link.", __FUNCTION__, url.absoluteString);
                 return;
             }
             // Not a registered Universal Link: fall back to the system browser.
-            [[UIApplication sharedApplication] openURL:linkURL options:@{} completionHandler:^(BOOL openedInBrowser) {
-                CLY_LOG_I(@"%s Tapped link [%@] is not a Universal Link; opened in browser: %@.", __FUNCTION__, url, openedInBrowser ? @"YES" : @"NO");
+            [application openURL:url options:@{} completionHandler:^(BOOL openedInBrowser) {
+                CLY_LOG_I(@"%s URL [%@] is not a Universal Link; opened in browser: %@.", __FUNCTION__, url.absoluteString, openedInBrowser ? @"YES" : @"NO");
             }];
         }];
-        decisionHandler(WKNavigationActionPolicyCancel);
         return;
     }
 
-    decisionHandler(WKNavigationActionPolicyAllow);
+    [application openURL:url options:@{} completionHandler:^(BOOL success) {
+        CLY_LOG_I(@"%s URL [%@] opened in browser: %@.", __FUNCTION__, url.absoluteString, success ? @"YES" : @"NO");
+    }];
 }
 
 - (void)webView:(WKWebView *)webView decidePolicyForNavigationResponse:(WKNavigationResponse *)navigationResponse decisionHandler:(void (^)(WKNavigationResponsePolicy))decisionHandler {
@@ -849,15 +848,8 @@ - (void)openExternalLink:(NSString *)urlString {
         NSString *encoded = [urlString stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLFragmentAllowedCharacterSet]];
         url = encoded ? [NSURL URLWithString:encoded] : nil;
     }
-    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);
-            }
-        }];
-    }
+    // Prefers the app (Universal Link) when enableUniversalLinkHandling is on, else browser.
+    [self openExternalURL:url];
 }
 
 - (void)resizeWebViewWithJSONString:(NSString *)jsonString {