diff --git a/CHANGELOG.md b/CHANGELOG.md index 07da5573..7bdb4b0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,9 @@ ## 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 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. -* 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/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/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..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)
@@ -729,6 +732,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..ca4e1676 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];
 
@@ -225,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;
     }
@@ -252,9 +264,40 @@ - (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigati
         }
 
         decisionHandler(WKNavigationActionPolicyCancel);
-    } else {
-        decisionHandler(WKNavigationActionPolicyAllow);
+        return;
     }
+
+    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 URL [%@] opened in the app via Universal Link.", __FUNCTION__, url.absoluteString);
+                return;
+            }
+            // Not a registered Universal Link: fall back to the system browser.
+            [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");
+            }];
+        }];
+        return;
+    }
+
+    [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 {
@@ -485,6 +528,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 +827,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];
     }
 
@@ -789,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 {
@@ -870,6 +922,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];