From 68118a436210b69f8d26b1b5a73fe02f95be4dec Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 2 Jul 2026 15:49:06 +0300 Subject: [PATCH 01/14] refactor: improve content link comm --- CHANGELOG.md | 3 + CountlyTests/CountlyWebViewManagerTests.swift | 151 ++++++++++++++++++ CountlyWebViewManager.m | 142 ++++++++++++++-- 3 files changed, 282 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ba01a52..81c6dfa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## XX.XX.XX +* 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. diff --git a/CountlyTests/CountlyWebViewManagerTests.swift b/CountlyTests/CountlyWebViewManagerTests.swift index 9d8c7cd0..3033e23c 100644 --- a/CountlyTests/CountlyWebViewManagerTests.swift +++ b/CountlyTests/CountlyWebViewManagerTests.swift @@ -73,6 +73,157 @@ class CountlyWebViewManagerTests: XCTestCase { 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() { diff --git a/CountlyWebViewManager.m b/CountlyWebViewManager.m index 45b892c4..f98d7f1b 100644 --- a/CountlyWebViewManager.m +++ b/CountlyWebViewManager.m @@ -422,29 +422,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]; @@ -487,6 +595,12 @@ - (void)recordEventsWithJSONString:(NSString *)jsonString { - (void)openExternalLink:(NSString *)urlString { NSURL *url = [NSURL URLWithString: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; + } if (url) { [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:^(BOOL success) { if (success) { @@ -500,8 +614,8 @@ - (void)openExternalLink:(NSString *)urlString { - (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]; From 460377ef1e7392491b49b1392c92d4d4e6cfbb11 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 7 Jul 2026 14:44:59 +0300 Subject: [PATCH 02/14] fix: retry contents --- CountlyTests/CountlyWebViewManager+Tests.h | 3 + CountlyTests/CountlyWebViewManagerTests.swift | 135 ++++++++++++++++-- CountlyWebViewManager.m | 84 ++++++++++- 3 files changed, 204 insertions(+), 18 deletions(-) diff --git a/CountlyTests/CountlyWebViewManager+Tests.h b/CountlyTests/CountlyWebViewManager+Tests.h index d6bc7337..fc7decc3 100644 --- a/CountlyTests/CountlyWebViewManager+Tests.h +++ b/CountlyTests/CountlyWebViewManager+Tests.h @@ -6,6 +6,8 @@ @property(nonatomic) BOOL webViewClosed; @property(nonatomic) BOOL hasAppeared; +@property(nonatomic) NSInteger resourceRetryCount; +@property(nonatomic) BOOL retryInProgress; @property(nonatomic, strong) NSTimer *loadTimeoutTimer; @property(nonatomic, strong) NSDate *loadStartDate; @property(nonatomic, copy) void (^dismissBlock)(void); @@ -16,6 +18,7 @@ - (void)notifyPageLoaded; - (void)loadDidTimeout; - (void)closeWebView; +- (void)retryOrCloseWebViewForReason:(NSString *)reason; @end #endif diff --git a/CountlyTests/CountlyWebViewManagerTests.swift b/CountlyTests/CountlyWebViewManagerTests.swift index 9d8c7cd0..72c50ca1 100644 --- a/CountlyTests/CountlyWebViewManagerTests.swift +++ b/CountlyTests/CountlyWebViewManagerTests.swift @@ -235,6 +235,8 @@ class CountlyWebViewManagerTests: XCTestCase { } 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 @@ -245,16 +247,12 @@ class CountlyWebViewManagerTests: XCTestCase { 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() - } + manager.dismissBlock = { dismissExpectation.fulfill() } let js = """ window.webkit.messageHandlers.resourceVerifyResult.postMessage({ @@ -269,6 +267,48 @@ class CountlyWebViewManagerTests: XCTestCase { waitForExpectations(timeout: 3.0) 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. + manager.webViewClosed = false + manager.hasAppeared = false + manager.retryInProgress = 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 + + 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: 2.0) + + XCTAssertFalse(manager.webViewClosed) // deferred to the in-flight retry + XCTAssertFalse(dismissCalled) + + contentController.removeScriptMessageHandler(forName: "resourceLoadError") + contentController.removeScriptMessageHandler(forName: "resourceVerifyResult") } func testDidReceiveScriptMessage_resourceVerifyResult_emptyResultsShowsView() { @@ -301,9 +341,51 @@ class CountlyWebViewManagerTests: XCTestCase { contentController.removeScriptMessageHandler(forName: "resourceVerifyResult") } - func testDidReceiveScriptMessage_resourceLoadError_closesWebView() { + 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 } + + let js = """ + window.webkit.messageHandlers.resourceLoadError.postMessage({ + tag: "SCRIPT", + url: "https://example.com/broken.js" + }); + """ + webView.evaluateJavaScript(js, completionHandler: nil) + + let settle = expectation(description: "settle") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { settle.fulfill() } + waitForExpectations(timeout: 2.0) + + XCTAssertFalse(manager.webViewClosed) + XCTAssertFalse(dismissCalled) + XCTAssertEqual(manager.resourceRetryCount, 1) + XCTAssertTrue(manager.retryInProgress) + + 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 @@ -318,9 +400,7 @@ class CountlyWebViewManagerTests: XCTestCase { manager.backgroundView = bgView let dismissExpectation = expectation(description: "Dismiss block called") - manager.dismissBlock = { - dismissExpectation.fulfill() - } + manager.dismissBlock = { dismissExpectation.fulfill() } let js = """ window.webkit.messageHandlers.resourceLoadError.postMessage({ @@ -334,6 +414,32 @@ class CountlyWebViewManagerTests: XCTestCase { 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: 2.0) + + XCTAssertFalse(manager.webViewClosed) + XCTAssertFalse(dismissCalled) + XCTAssertFalse(manager.retryInProgress) + XCTAssertEqual(manager.resourceRetryCount, 0) + } + func testDidReceiveScriptMessage_ignoredWhenWebViewClosed() { manager.webViewClosed = true manager.hasAppeared = false @@ -376,6 +482,7 @@ class CountlyWebViewManagerTests: XCTestCase { } func testDidReceiveScriptMessage_http404ClosesWebView() { + // Post-load verification 404 closes without retrying. manager.webViewClosed = false manager.hasAppeared = false @@ -385,16 +492,12 @@ class CountlyWebViewManagerTests: XCTestCase { 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() - } + manager.dismissBlock = { dismissExpectation.fulfill() } let js = """ window.webkit.messageHandlers.resourceVerifyResult.postMessage({ @@ -408,6 +511,10 @@ class CountlyWebViewManagerTests: XCTestCase { waitForExpectations(timeout: 3.0) 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() { diff --git a/CountlyWebViewManager.m b/CountlyWebViewManager.m index 45b892c4..151d935a 100644 --- a/CountlyWebViewManager.m +++ b/CountlyWebViewManager.m @@ -10,6 +10,16 @@ #import "PassThroughBackgroundView.h" #if (TARGET_OS_IOS) +// 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; + // TODO: improve logging, check edge cases @interface CountlyWebViewManager () @@ -20,6 +30,8 @@ @interface CountlyWebViewManager () @property(nonatomic, strong) NSDate *loadStartDate; @property(nonatomic) BOOL hasAppeared; @property(nonatomic) BOOL webViewClosed; +@property(nonatomic) NSInteger resourceRetryCount; +@property(nonatomic) BOOL retryInProgress; @property(nonatomic, strong) CountlyWebViewController *presentingController; @property(nonatomic, strong) CountlyOverlayWindow *window; @end @@ -310,7 +322,9 @@ - (void)verifyResourceStatuses:(WKWebView *)webView { } - (void)notifyPageLoaded { - if (self.webViewClosed || self.hasAppeared) return; + // Suppress appearance while a retry reload is pending, so the failing load + // never flashes broken content before the reload replaces it. + if (self.webViewClosed || self.hasAppeared || self.retryInProgress) return; [self.loadTimeoutTimer invalidate]; self.loadTimeoutTimer = nil; @@ -323,6 +337,58 @@ - (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_I(@"%s %@ — content already visible, treating as non-fatal.", __FUNCTION__, reason); + return; + } + + // A reload is already scheduled for this load cycle — coalesce further failures. + if (self.retryInProgress) { + CLY_LOG_I(@"%s %@ — retry already scheduled, ignoring.", __FUNCTION__, reason); + return; + } + + if (self.resourceRetryCount >= kCLYMaxResourceRetries) { + CLY_LOG_I(@"%s %@ — retries exhausted (%ld/%ld). Closing web view.", __FUNCTION__, reason, (long)self.resourceRetryCount, (long)kCLYMaxResourceRetries); + [self closeWebView]; + return; + } + + self.resourceRetryCount += 1; + self.retryInProgress = YES; + NSTimeInterval delay = kCLYResourceRetryBaseDelay * self.resourceRetryCount; + CLY_LOG_I(@"%s %@ — retrying load (%ld/%ld) in %.1fs.", __FUNCTION__, reason, (long)self.resourceRetryCount, (long)kCLYMaxResourceRetries, delay); + + __weak typeof(self) weakSelf = self; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delay * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + __strong typeof(weakSelf) strongSelf = weakSelf; + if (!strongSelf || strongSelf.webViewClosed) return; + strongSelf.retryInProgress = NO; + WKWebView *webView = strongSelf.backgroundView.webView; + if (!webView) { + [strongSelf closeWebView]; + return; + } + CLY_LOG_I(@"%s Reloading web view (retry %ld/%ld).", __FUNCTION__, (long)strongSelf.resourceRetryCount, (long)kCLYMaxResourceRetries); + [webView reload]; + }); +} + - (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message { @@ -333,10 +399,11 @@ - (void)userContentController:(WKUserContentController *)userContentController NSString *tag = body[@"tag"]; NSString *url = body[@"url"]; - CLY_LOG_I(@"%s Critical resource (%@) failed to load: [%@]. Closing web view.", __FUNCTION__, tag, url); + CLY_LOG_I(@"%s Critical resource (%@) failed to load: [%@].", __FUNCTION__, tag, url); + NSString *reason = [NSString stringWithFormat:@"Critical resource (%@) failed to load: [%@]", tag, url]; dispatch_async(dispatch_get_main_queue(), ^{ - [self closeWebView]; + [self retryOrCloseWebViewForReason:reason]; }); } else if ([message.name isEqualToString:@"resourceVerifyResult"]) { @@ -347,8 +414,17 @@ - (void)userContentController:(WKUserContentController *)userContentController 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.", + CLY_LOG_I(@"%s Critical resource (%@) returned HTTP %ld: [%@].", __FUNCTION__, entry[@"tag"], (long)status, entry[@"url"]); + // 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.retryInProgress) { + return; + } dispatch_async(dispatch_get_main_queue(), ^{ [self closeWebView]; }); From d8d580f1d39cf101a25a1fac401c30ac68296e4f Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Wed, 8 Jul 2026 08:19:00 +0300 Subject: [PATCH 03/14] feat: reload stall --- CHANGELOG.md | 3 + Countly.m | 2 + CountlyContentBuilderInternal.h | 2 + CountlyContentConfig.h | 20 +++++++ CountlyContentConfig.m | 27 ++++++++- CountlyTests/CountlyWebViewManagerTests.swift | 59 ++++++++++++++++++- CountlyWebViewManager.m | 34 +++++++++-- 7 files changed, 140 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ba01a52..6df7b7d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## 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). + ## 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 06873cd1..89f30759 100644 --- a/Countly.m +++ b/Countly.m @@ -295,6 +295,8 @@ - (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; #endif [CountlyPerformanceMonitoring.sharedInstance startWithConfig:config.apm]; diff --git a/CountlyContentBuilderInternal.h b/CountlyContentBuilderInternal.h index 7983a27d..a0460a45 100644 --- a/CountlyContentBuilderInternal.h +++ b/CountlyContentBuilderInternal.h @@ -16,6 +16,8 @@ NS_ASSUME_NONNULL_BEGIN @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) int contentInitialDelay; + (instancetype)sharedInstance; diff --git a/CountlyContentConfig.h b/CountlyContentConfig.h index 9aa29b12..55c59113 100644 --- a/CountlyContentConfig.h +++ b/CountlyContentConfig.h @@ -59,6 +59,26 @@ typedef void (^ContentCallback)(ContentStatus contentStatus, NSDictionary Date: Wed, 8 Jul 2026 10:22:37 +0300 Subject: [PATCH 04/14] fix: review again --- Countly-PL.podspec | 2 +- Countly.podspec | 2 +- Countly.xcodeproj/project.pbxproj | 4 +-- CountlyCommon.m | 2 +- CountlyTests/CountlyContentBuilderTests.swift | 36 +++++++++++++++++++ CountlyTests/TestUtils.swift | 2 +- CountlyWebViewManager.m | 11 +++++- 7 files changed, 52 insertions(+), 7 deletions(-) diff --git a/Countly-PL.podspec b/Countly-PL.podspec index 7e969545..dd12bb77 100644 --- a/Countly-PL.podspec +++ b/Countly-PL.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'Countly-PL' - s.version = '26.1.2' + s.version = '26.1.3' s.license = { :type => 'MIT', :file => 'LICENSE' } s.summary = 'Countly is an innovative, real-time, open source mobile analytics platform.' s.homepage = 'https://github.com/Countly/countly-sdk-ios' diff --git a/Countly.podspec b/Countly.podspec index 827d97fa..8820baba 100644 --- a/Countly.podspec +++ b/Countly.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'Countly' - s.version = '26.1.2' + s.version = '26.1.3' s.license = { :type => 'MIT', :file => 'LICENSE' } s.summary = 'Countly is an innovative, real-time, open source mobile analytics platform.' s.homepage = 'https://github.com/Countly/countly-sdk-ios' diff --git a/Countly.xcodeproj/project.pbxproj b/Countly.xcodeproj/project.pbxproj index 7c73122b..f3775586 100644 --- a/Countly.xcodeproj/project.pbxproj +++ b/Countly.xcodeproj/project.pbxproj @@ -838,7 +838,7 @@ "@loader_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.14; - MARKETING_VERSION = 26.1.2; + MARKETING_VERSION = 26.1.3; PRODUCT_BUNDLE_IDENTIFIER = ly.count.CountlyiOSSDK; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -870,7 +870,7 @@ "@loader_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.14; - MARKETING_VERSION = 26.1.2; + MARKETING_VERSION = 26.1.3; PRODUCT_BUNDLE_IDENTIFIER = ly.count.CountlyiOSSDK; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/CountlyCommon.m b/CountlyCommon.m index 13adc0a2..ea4f19d0 100644 --- a/CountlyCommon.m +++ b/CountlyCommon.m @@ -29,7 +29,7 @@ @interface CountlyCommon () #endif @end -NSString* const kCountlySDKVersion = @"26.1.2"; +NSString* const kCountlySDKVersion = @"26.1.3"; NSString* const kCountlySDKName = @"objc-native-ios"; NSString* const kCountlyErrorDomain = @"ly.count.ErrorDomain"; diff --git a/CountlyTests/CountlyContentBuilderTests.swift b/CountlyTests/CountlyContentBuilderTests.swift index 683a2255..3d848e4c 100644 --- a/CountlyTests/CountlyContentBuilderTests.swift +++ b/CountlyTests/CountlyContentBuilderTests.swift @@ -20,6 +20,9 @@ class CountlyContentBuilderTests: CountlyBaseTestCase { override func tearDown() { CountlyContentBuilderInternal.sharedInstance().exitContentZone() + // Reset reload-on-stall state so it can't leak into later tests. + CountlyContentBuilderInternal.sharedInstance().enableContentReloadOnStall = false + CountlyContentBuilderInternal.sharedInstance().contentReloadOnStallTimeout = 0 Countly.sharedInstance().halt(true) MockURLProtocol.requestHandler = nil super.tearDown() @@ -287,5 +290,38 @@ class CountlyContentBuilderTests: CountlyBaseTestCase { 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) + } } #endif diff --git a/CountlyTests/TestUtils.swift b/CountlyTests/TestUtils.swift index 0fba71b2..a7b03c55 100644 --- a/CountlyTests/TestUtils.swift +++ b/CountlyTests/TestUtils.swift @@ -13,7 +13,7 @@ class TestUtils { static let commonDeviceId: String = "deviceId" static let commonAppKey: String = "appkey" static let host: String = "https://YOUR_SERVER" - static let SDK_VERSION = "26.1.2" + static let SDK_VERSION = "26.1.3" static let SDK_NAME = "objc-native-ios" static func cleanup() { diff --git a/CountlyWebViewManager.m b/CountlyWebViewManager.m index 910417dd..5e360d75 100644 --- a/CountlyWebViewManager.m +++ b/CountlyWebViewManager.m @@ -43,6 +43,7 @@ @interface CountlyWebViewManager () @property(nonatomic) BOOL webViewClosed; @property(nonatomic) NSInteger resourceRetryCount; @property(nonatomic) BOOL retryInProgress; +@property(nonatomic) NSTimeInterval loadTimeoutInterval; @property(nonatomic, strong) CountlyWebViewController *presentingController; @property(nonatomic, strong) CountlyOverlayWindow *window; @end @@ -57,6 +58,8 @@ - (void)createWebViewWithURL:(NSURL *)url self.appearBlock = appearBlock; self.hasAppeared = NO; self.webViewClosed = NO; + self.resourceRetryCount = 0; + self.retryInProgress = NO; // TODO: keyWindow deprecation fix _window = [CountlyOverlayWindow new]; CountlyWebViewController *modal = [CountlyWebViewController new]; @@ -279,6 +282,7 @@ - (void)webView:(WKWebView *)webView didStartProvisionalNavigation:(WKNavigation 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; @@ -394,6 +398,11 @@ - (void)retryOrCloseWebViewForReason:(NSString *)reason { self.resourceRetryCount += 1; self.retryInProgress = YES; + // 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_I(@"%s %@ — retrying load (%ld/%ld) in %.1fs.", __FUNCTION__, reason, (long)self.resourceRetryCount, (long)kCLYMaxResourceRetries, delay); @@ -700,7 +709,7 @@ - (void)closeWebView - (void)loadDidTimeout { if (self.hasAppeared || self.webViewClosed) return; - CLY_LOG_I(@"%s Web view load stalled after %.0fs.", __FUNCTION__, kCLYLoadStallTimeout); + CLY_LOG_I(@"%s Web view load stalled after %.1fs.", __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 From 0b3e3aa97889cf6291e1440f38c3fcdd6ea39116 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 9 Jul 2026 11:36:45 +0300 Subject: [PATCH 05/14] fix: double show --- CHANGELOG.md | 2 + CountlyContentBuilderInternal.m | 77 ++++++++++++++++--- .../CountlyContentBuilderInternal+Tests.h | 13 ++++ CountlyTests/CountlyContentBuilderTests.swift | 32 ++++++++ CountlyTests/CountlyTests-Bridging-Header.h | 1 + CountlyTests/CountlyWebViewManager+Tests.h | 2 + CountlyTests/CountlyWebViewManagerTests.swift | 47 +++++++++++ CountlyWebViewManager.m | 42 ++++++++-- 8 files changed, 199 insertions(+), 17 deletions(-) create mode 100644 CountlyTests/CountlyContentBuilderInternal+Tests.h diff --git a/CHANGELOG.md b/CHANGELOG.md index 6df7b7d2..4e49676c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ ## 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). +* 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/CountlyContentBuilderInternal.m b/CountlyContentBuilderInternal.m index 9f8c07d3..84590d9e 100644 --- a/CountlyContentBuilderInternal.m +++ b/CountlyContentBuilderInternal.m @@ -63,18 +63,63 @@ - (void)setRequestQueueLockedThreadSafe:(BOOL)locked { }); } +// Atomically claim the single "content is shown" slot. Returns YES if the caller acquired +// it (it was free), NO if content is already shown or a racing fetch already claimed it, in +// which case the caller MUST NOT present another web view. The flag is read at fetch-decision +// time but was previously only set 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 on the serial content queue. +- (BOOL)tryBeginContentPresentation { + if (!_contentQueue) { + if (_isCurrentlyContentShown) return NO; + _isCurrentlyContentShown = YES; + return YES; + } + __block BOOL acquired = NO; + dispatch_sync(_contentQueue, ^{ + if (!self->_isCurrentlyContentShown) { + self->_isCurrentlyContentShown = YES; + acquired = YES; + } + }); + return acquired; +} + +// Release the content slot (called when the shown web view is dismissed) so the next zone +// cycle can present again. +- (void)endContentPresentation { + if (!_contentQueue) { + _isCurrentlyContentShown = NO; + return; + } + dispatch_async(_contentQueue, ^{ + self->_isCurrentlyContentShown = NO; + }); +} + +- (BOOL)isContentShownThreadSafe { + if (!_contentQueue) { + return _isCurrentlyContentShown; + } + __block BOOL shown = NO; + dispatch_sync(_contentQueue, ^{ + shown = self->_isCurrentlyContentShown; + }); + return shown; +} + - (void)enterContentZone { - - if(_isCurrentlyContentShown){ + + if([self isContentShownThreadSafe]){ CLY_LOG_I(@"%s a content is already shown, skipping" ,__FUNCTION__); return; } - + [self enterContentZone:@[]]; } - (void)enterContentZone:(NSArray *)tags { - if(_isCurrentlyContentShown){ + if([self isContentShownThreadSafe]){ CLY_LOG_I(@"%s a content is already shown, skipping" ,__FUNCTION__); return; } @@ -128,11 +173,11 @@ - (void)refreshContentZone { { return; } - if(_isCurrentlyContentShown){ + if([self isContentShownThreadSafe]){ CLY_LOG_I(@"%s a content is already shown, skipping" ,__FUNCTION__); return; } - + [CountlyConnectionManager.sharedInstance addQueueFlushRunnable:^{ CLY_LOG_I(@"%s queue flueshed, will re-fetch contents" ,__FUNCTION__); [self exitContentZone]; @@ -147,7 +192,7 @@ - (void)refreshContentZoneJTE { CLY_LOG_D(@"%s, refresh content zone is disabled, skipping JTE content refresh", __FUNCTION__); return; } - if(_isCurrentlyContentShown){ + if([self isContentShownThreadSafe]){ CLY_LOG_I(@"%s a content is already shown, skipping JTE content refresh" ,__FUNCTION__); return; } @@ -210,11 +255,11 @@ - (void)fetchContents:(void (^)(void))failureCallback contentId:(NSString *)cont return; } - if(_isCurrentlyContentShown){ + if([self isContentShownThreadSafe]){ CLY_LOG_I(@"%s a content is already shown, skipping" ,__FUNCTION__); return; } - + if ([self isRequestQueueLockedThreadSafe]) { return; } @@ -327,7 +372,14 @@ - (void)showContentWithHtmlPath:(NSString *)urlString placementCoordinates:(NSDi 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_I(@"%s a content is already shown, skipping duplicate presentation", __FUNCTION__); + return; + } + dispatch_async(dispatch_get_main_queue(), ^ { // Detect screen orientation UIInterfaceOrientation orientation = [UIApplication sharedApplication].statusBarOrientation; @@ -352,7 +404,7 @@ - (void)showContentWithHtmlPath:(NSString *)urlString placementCoordinates:(NSDi } dismissBlock:^ { CLY_LOG_I(@"%s webview dismissed", __FUNCTION__); - self->_isCurrentlyContentShown = NO; + [self endContentPresentation]; self->_minuteTimer = [NSTimer scheduledTimerWithTimeInterval:self->_zoneTimerInterval target:self selector:@selector(enterContentZone) @@ -363,7 +415,8 @@ - (void)showContentWithHtmlPath:(NSString *)urlString placementCoordinates:(NSDi } }]; 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/CountlyTests/CountlyContentBuilderInternal+Tests.h b/CountlyTests/CountlyContentBuilderInternal+Tests.h new file mode 100644 index 00000000..00e0f854 --- /dev/null +++ b/CountlyTests/CountlyContentBuilderInternal+Tests.h @@ -0,0 +1,13 @@ +#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; + +@end +#endif diff --git a/CountlyTests/CountlyContentBuilderTests.swift b/CountlyTests/CountlyContentBuilderTests.swift index 3d848e4c..67d7cb30 100644 --- a/CountlyTests/CountlyContentBuilderTests.swift +++ b/CountlyTests/CountlyContentBuilderTests.swift @@ -323,5 +323,37 @@ class CountlyContentBuilderTests: CountlyBaseTestCase { XCTAssertTrue(CountlyContentBuilderInternal.sharedInstance().enableContentReloadOnStall) XCTAssertEqual(CountlyContentBuilderInternal.sharedInstance().contentReloadOnStallTimeout, 2.5, accuracy: 0.0001) } + + /** + *
+     * 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()) + } } #endif diff --git a/CountlyTests/CountlyTests-Bridging-Header.h b/CountlyTests/CountlyTests-Bridging-Header.h index 4c7fa203..45d92dd3 100644 --- a/CountlyTests/CountlyTests-Bridging-Header.h +++ b/CountlyTests/CountlyTests-Bridging-Header.h @@ -3,6 +3,7 @@ #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/CountlyTests/CountlyWebViewManager+Tests.h b/CountlyTests/CountlyWebViewManager+Tests.h index fc7decc3..1d6206c5 100644 --- a/CountlyTests/CountlyWebViewManager+Tests.h +++ b/CountlyTests/CountlyWebViewManager+Tests.h @@ -8,6 +8,7 @@ @property(nonatomic) BOOL hasAppeared; @property(nonatomic) NSInteger resourceRetryCount; @property(nonatomic) BOOL retryInProgress; +@property(nonatomic, copy) dispatch_block_t pendingReloadBlock; @property(nonatomic, strong) NSTimer *loadTimeoutTimer; @property(nonatomic, strong) NSDate *loadStartDate; @property(nonatomic, copy) void (^dismissBlock)(void); @@ -19,6 +20,7 @@ - (void)loadDidTimeout; - (void)closeWebView; - (void)retryOrCloseWebViewForReason:(NSString *)reason; +- (void)cancelPendingReload; @end #endif diff --git a/CountlyTests/CountlyWebViewManagerTests.swift b/CountlyTests/CountlyWebViewManagerTests.swift index 35ea1222..bdbd3961 100644 --- a/CountlyTests/CountlyWebViewManagerTests.swift +++ b/CountlyTests/CountlyWebViewManagerTests.swift @@ -495,6 +495,53 @@ class CountlyWebViewManagerTests: XCTestCase { 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") + XCTAssertTrue(manager.retryInProgress) + XCTAssertNotNil(manager.pendingReloadBlock) + XCTAssertEqual(manager.resourceRetryCount, 1) + + // The load then verifies good / appears. + manager.notifyPageLoaded() + XCTAssertTrue(manager.hasAppeared) + XCTAssertFalse(manager.retryInProgress) // retry cancelled + 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: 2.0) + XCTAssertFalse(manager.webViewClosed) + XCTAssertTrue(manager.hasAppeared) + } + + func testCancelPendingReload_clearsScheduledRetry() { + manager.webViewClosed = false + manager.hasAppeared = false + + manager.retryOrCloseWebView(forReason: "stall") + XCTAssertTrue(manager.retryInProgress) + XCTAssertNotNil(manager.pendingReloadBlock) + + manager.cancelPendingReload() + XCTAssertFalse(manager.retryInProgress) + 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: 2.0) + XCTAssertFalse(manager.webViewClosed) + XCTAssertFalse(manager.hasAppeared) + } + func testDidReceiveScriptMessage_ignoredWhenWebViewClosed() { manager.webViewClosed = true manager.hasAppeared = false diff --git a/CountlyWebViewManager.m b/CountlyWebViewManager.m index 5e360d75..2e2be15c 100644 --- a/CountlyWebViewManager.m +++ b/CountlyWebViewManager.m @@ -44,6 +44,7 @@ @interface CountlyWebViewManager () @property(nonatomic) NSInteger resourceRetryCount; @property(nonatomic) BOOL retryInProgress; @property(nonatomic) NSTimeInterval loadTimeoutInterval; +@property(nonatomic, copy) dispatch_block_t pendingReloadBlock; @property(nonatomic, strong) CountlyWebViewController *presentingController; @property(nonatomic, strong) CountlyOverlayWindow *window; @end @@ -127,6 +128,12 @@ - (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]; @@ -342,9 +349,13 @@ - (void)verifyResourceStatuses:(WKWebView *)webView { } - (void)notifyPageLoaded { - // Suppress appearance while a retry reload is pending, so the failing load - // never flashes broken content before the reload replaces it. - if (self.webViewClosed || self.hasAppeared || self.retryInProgress) return; + 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; @@ -407,9 +418,16 @@ - (void)retryOrCloseWebViewForReason:(NSString *)reason { CLY_LOG_I(@"%s %@ — retrying load (%ld/%ld) in %.1fs.", __FUNCTION__, reason, (long)self.resourceRetryCount, (long)kCLYMaxResourceRetries, delay); __weak typeof(self) weakSelf = self; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delay * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + // 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 || strongSelf.webViewClosed) return; + 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; strongSelf.retryInProgress = NO; WKWebView *webView = strongSelf.backgroundView.webView; if (!webView) { @@ -419,6 +437,19 @@ - (void)retryOrCloseWebViewForReason:(NSString *)reason { CLY_LOG_I(@"%s Reloading web view (retry %ld/%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, and +// clear the in-progress flag. 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; + } + self.retryInProgress = NO; } - (void)userContentController:(WKUserContentController *)userContentController @@ -676,6 +707,7 @@ - (void)closeWebView self.loadStartDate = nil; [self.loadTimeoutTimer invalidate]; self.loadTimeoutTimer = nil; + [self cancelPendingReload]; if (self.backgroundView) { [self.backgroundView.webView stopLoading]; self.backgroundView.webView.navigationDelegate = nil; From 28a8d91ca3a56a981dda599ec687397a4558fec5 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 9 Jul 2026 13:38:11 +0300 Subject: [PATCH 06/14] fix: concurreny --- CountlyContentBuilderInternal.m | 108 +++++++++++------- CountlyTests/CountlyContentBuilderTests.swift | 27 +++++ CountlyTests/CountlyWebViewManager+Tests.h | 1 - CountlyTests/CountlyWebViewManagerTests.swift | 54 +++++++-- CountlyWebViewManager.m | 35 ++++-- 5 files changed, 162 insertions(+), 63 deletions(-) diff --git a/CountlyContentBuilderInternal.m b/CountlyContentBuilderInternal.m index 84590d9e..db771855 100644 --- a/CountlyContentBuilderInternal.m +++ b/CountlyContentBuilderInternal.m @@ -13,6 +13,7 @@ @implementation CountlyContentBuilderInternal { BOOL _isRequestQueueLocked; BOOL _isCurrentlyContentShown; + BOOL _refreshRunnablePending; NSTimer *_requestTimer; NSTimer *_minuteTimer; dispatch_queue_t _contentQueue; @@ -42,70 +43,73 @@ - (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 claim the single "content is shown" slot. Returns YES if the caller acquired -// it (it was free), NO if content is already shown or a racing fetch already claimed it, in -// which case the caller MUST NOT present another web view. The flag is read at fetch-decision -// time but was previously only set 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 on the serial content queue. -- (BOOL)tryBeginContentPresentation { +// 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 (_isCurrentlyContentShown) return NO; - _isCurrentlyContentShown = YES; - return YES; + if (!*flag) { *flag = YES; acquired = YES; } + return acquired; } - __block BOOL acquired = NO; dispatch_sync(_contentQueue, ^{ - if (!self->_isCurrentlyContentShown) { - self->_isCurrentlyContentShown = YES; - acquired = YES; - } + if (!*flag) { *flag = YES; acquired = YES; } }); return acquired; } -// Release the content slot (called when the shown web view is dismissed) so the next zone -// cycle can present again. +- (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 { + return [self testAndSetFlag:&_isCurrentlyContentShown]; +} + +// 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 { - if (!_contentQueue) { - _isCurrentlyContentShown = NO; - return; - } - dispatch_async(_contentQueue, ^{ - self->_isCurrentlyContentShown = NO; - }); + [self writeFlag:&_isCurrentlyContentShown value:NO]; } - (BOOL)isContentShownThreadSafe { - if (!_contentQueue) { - return _isCurrentlyContentShown; - } - __block BOOL shown = NO; - dispatch_sync(_contentQueue, ^{ - shown = self->_isCurrentlyContentShown; - }); - return shown; + return [self readFlag:&_isCurrentlyContentShown]; } - (void)enterContentZone { @@ -178,10 +182,25 @@ - (void)refreshContentZone { 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_I(@"%s a content refresh is already pending, skipping duplicate" ,__FUNCTION__); + return; + } + + __weak typeof(self) weakSelf = self; [CountlyConnectionManager.sharedInstance addQueueFlushRunnable:^{ + __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_I(@"%s queue flueshed, will re-fetch contents" ,__FUNCTION__); - [self exitContentZone]; - [self enterContentZone]; + [strongSelf exitContentZone]; + [strongSelf enterContentZone]; }]; [CountlyConnectionManager.sharedInstance attemptToSendStoredRequests]; } @@ -235,7 +254,10 @@ - (void)clearContentState { - (void)resetInstance { CLY_LOG_I(@"%s", __FUNCTION__); [self clearContentState]; - _isCurrentlyContentShown = NO; + // Clear the shown flag through the serial content queue (not a raw write) so it cannot + // race a concurrent tryBeginContentPresentation / read on the network-completion thread. + [self endContentPresentation]; + [self writeFlag:&_refreshRunnablePending value:NO]; } - (void)fetchContents { diff --git a/CountlyTests/CountlyContentBuilderTests.swift b/CountlyTests/CountlyContentBuilderTests.swift index 67d7cb30..5308104d 100644 --- a/CountlyTests/CountlyContentBuilderTests.swift +++ b/CountlyTests/CountlyContentBuilderTests.swift @@ -355,5 +355,32 @@ class CountlyContentBuilderTests: CountlyBaseTestCase { 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() + } } #endif diff --git a/CountlyTests/CountlyWebViewManager+Tests.h b/CountlyTests/CountlyWebViewManager+Tests.h index 1d6206c5..a4092707 100644 --- a/CountlyTests/CountlyWebViewManager+Tests.h +++ b/CountlyTests/CountlyWebViewManager+Tests.h @@ -7,7 +7,6 @@ @property(nonatomic) BOOL webViewClosed; @property(nonatomic) BOOL hasAppeared; @property(nonatomic) NSInteger resourceRetryCount; -@property(nonatomic) BOOL retryInProgress; @property(nonatomic, copy) dispatch_block_t pendingReloadBlock; @property(nonatomic, strong) NSTimer *loadTimeoutTimer; @property(nonatomic, strong) NSDate *loadStartDate; diff --git a/CountlyTests/CountlyWebViewManagerTests.swift b/CountlyTests/CountlyWebViewManagerTests.swift index 079b7a29..203a7e24 100644 --- a/CountlyTests/CountlyWebViewManagerTests.swift +++ b/CountlyTests/CountlyWebViewManagerTests.swift @@ -294,7 +294,7 @@ class CountlyWebViewManagerTests: XCTestCase { // A stalled load now triggers a retry (reload) instead of an immediate close. XCTAssertFalse(manager.webViewClosed) XCTAssertEqual(manager.resourceRetryCount, 1) - XCTAssertTrue(manager.retryInProgress) + XCTAssertNotNil(manager.pendingReloadBlock) } func testLoadDidTimeout_closesAfterRetriesExhausted() { @@ -449,10 +449,11 @@ class CountlyWebViewManagerTests: XCTestCase { 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. + // verification failure must defer to it, not close. A non-nil pendingReloadBlock marks + // a scheduled reload. manager.webViewClosed = false manager.hasAppeared = false - manager.retryInProgress = true + manager.pendingReloadBlock = { } let config = WKWebViewConfiguration() let contentController = config.userContentController @@ -515,6 +516,45 @@ class CountlyWebViewManagerTests: XCTestCase { 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: 2.0) + + 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 @@ -549,7 +589,7 @@ class CountlyWebViewManagerTests: XCTestCase { XCTAssertFalse(manager.webViewClosed) XCTAssertFalse(dismissCalled) XCTAssertEqual(manager.resourceRetryCount, 1) - XCTAssertTrue(manager.retryInProgress) + XCTAssertNotNil(manager.pendingReloadBlock) contentController.removeScriptMessageHandler(forName: "resourceLoadError") contentController.removeScriptMessageHandler(forName: "resourceVerifyResult") @@ -642,7 +682,7 @@ class CountlyWebViewManagerTests: XCTestCase { XCTAssertFalse(manager.webViewClosed) XCTAssertFalse(dismissCalled) - XCTAssertFalse(manager.retryInProgress) + XCTAssertNil(manager.pendingReloadBlock) XCTAssertEqual(manager.resourceRetryCount, 0) } @@ -655,14 +695,12 @@ class CountlyWebViewManagerTests: XCTestCase { // A stall/resource failure schedules a retry. manager.retryOrCloseWebView(forReason: "stall") - XCTAssertTrue(manager.retryInProgress) XCTAssertNotNil(manager.pendingReloadBlock) XCTAssertEqual(manager.resourceRetryCount, 1) // The load then verifies good / appears. manager.notifyPageLoaded() XCTAssertTrue(manager.hasAppeared) - XCTAssertFalse(manager.retryInProgress) // retry cancelled 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. @@ -678,11 +716,9 @@ class CountlyWebViewManagerTests: XCTestCase { manager.hasAppeared = false manager.retryOrCloseWebView(forReason: "stall") - XCTAssertTrue(manager.retryInProgress) XCTAssertNotNil(manager.pendingReloadBlock) manager.cancelPendingReload() - XCTAssertFalse(manager.retryInProgress) XCTAssertNil(manager.pendingReloadBlock) // The reload must not fire after cancellation. diff --git a/CountlyWebViewManager.m b/CountlyWebViewManager.m index f2baaaa8..173c4385 100644 --- a/CountlyWebViewManager.m +++ b/CountlyWebViewManager.m @@ -42,8 +42,9 @@ @interface CountlyWebViewManager () @property(nonatomic) BOOL hasAppeared; @property(nonatomic) BOOL webViewClosed; @property(nonatomic) NSInteger resourceRetryCount; -@property(nonatomic) BOOL retryInProgress; @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; @property(nonatomic, strong) CountlyWebViewController *presentingController; @property(nonatomic, strong) CountlyOverlayWindow *window; @@ -60,7 +61,7 @@ - (void)createWebViewWithURL:(NSURL *)url self.hasAppeared = NO; self.webViewClosed = NO; self.resourceRetryCount = 0; - self.retryInProgress = NO; + self.pendingReloadBlock = nil; // TODO: keyWindow deprecation fix _window = [CountlyOverlayWindow new]; CountlyWebViewController *modal = [CountlyWebViewController new]; @@ -396,7 +397,8 @@ - (void)retryOrCloseWebViewForReason:(NSString *)reason { } // A reload is already scheduled for this load cycle — coalesce further failures. - if (self.retryInProgress) { + // pendingReloadBlock being non-nil is the single source of truth for "a reload is pending". + if (self.pendingReloadBlock) { CLY_LOG_I(@"%s %@ — retry already scheduled, ignoring.", __FUNCTION__, reason); return; } @@ -408,7 +410,6 @@ - (void)retryOrCloseWebViewForReason:(NSString *)reason { } self.resourceRetryCount += 1; - self.retryInProgress = YES; // 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. @@ -428,7 +429,6 @@ - (void)retryOrCloseWebViewForReason:(NSString *)reason { // 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; - strongSelf.retryInProgress = NO; WKWebView *webView = strongSelf.backgroundView.webView; if (!webView) { [strongSelf closeWebView]; @@ -441,15 +441,14 @@ - (void)retryOrCloseWebViewForReason:(NSString *)reason { 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, and -// clear the in-progress flag. Called when a load succeeds (notifyPageLoaded) or the view -// closes, so a stale reload can't reload a page that already loaded. +// 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; } - self.retryInProgress = NO; } - (void)userContentController:(WKUserContentController *)userContentController @@ -474,6 +473,7 @@ - (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) { @@ -485,7 +485,7 @@ - (void)userContentController:(WKUserContentController *)userContentController // 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.retryInProgress) { + if (self.hasAppeared || self.pendingReloadBlock) { return; } dispatch_async(dispatch_get_main_queue(), ^{ @@ -493,6 +493,21 @@ - (void)userContentController:(WKUserContentController *)userContentController }); 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_I(@"%s A critical resource is unreachable (status 0); deferring to reload instead of appearing.", __FUNCTION__); + return; } } From b757455403f6e3127ec39d624775f5a71e91f0f1 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 9 Jul 2026 14:30:54 +0300 Subject: [PATCH 07/14] feat: disable zoom --- CHANGELOG.md | 1 + Countly.m | 1 + CountlyContentBuilderInternal.h | 1 + CountlyContentConfig.h | 9 +++++ CountlyContentConfig.m | 11 ++++++ CountlyTests/CountlyContentBuilderTests.swift | 30 +++++++++++++++- CountlyWebViewManager.m | 34 +++++++++++++++++++ 7 files changed, 86 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6bdef70..07da5573 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## 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". * 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. diff --git a/Countly.m b/Countly.m index 89f30759..be94aeae 100644 --- a/Countly.m +++ b/Countly.m @@ -297,6 +297,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; #endif [CountlyPerformanceMonitoring.sharedInstance startWithConfig:config.apm]; diff --git a/CountlyContentBuilderInternal.h b/CountlyContentBuilderInternal.h index a0460a45..66b91682 100644 --- a/CountlyContentBuilderInternal.h +++ b/CountlyContentBuilderInternal.h @@ -18,6 +18,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, assign) WebViewDisplayOption webViewDisplayOption; @property (nonatomic, assign) BOOL enableContentReloadOnStall; @property (nonatomic, assign) NSTimeInterval contentReloadOnStallTimeout; // seconds +@property (nonatomic, assign) BOOL disableZoom; @property (nonatomic, assign) int contentInitialDelay; + (instancetype)sharedInstance; diff --git a/CountlyContentConfig.h b/CountlyContentConfig.h index 55c59113..16235740 100644 --- a/CountlyContentConfig.h +++ b/CountlyContentConfig.h @@ -79,6 +79,15 @@ typedef void (^ContentCallback)(ContentStatus contentStatus, NSDictionary + * 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) + } + /** *
      * The single-content presentation latch rejects a second concurrent presentation.
diff --git a/CountlyWebViewManager.m b/CountlyWebViewManager.m
index 173c4385..8cc00319 100644
--- a/CountlyWebViewManager.m
+++ b/CountlyWebViewManager.m
@@ -118,6 +118,34 @@ - (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) {
+           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"];
 
@@ -154,6 +182,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];

From 9dffb1add859417d254a41cd6f3cecd3346e6d15 Mon Sep 17 00:00:00 2001
From: Arif Burak Demiray 
Date: Thu, 9 Jul 2026 15:26:29 +0300
Subject: [PATCH 08/14] 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 09/14] 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 10/14] 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 11/14] 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 {

From 9701844e7bd0f379308506ca63710323a56b56ad Mon Sep 17 00:00:00 2001
From: Arif Burak Demiray 
Date: Thu, 9 Jul 2026 16:44:59 +0300
Subject: [PATCH 12/14] feat: content URL handler

---
 CHANGELOG.md                    |  2 +-
 Countly.m                       |  2 +-
 CountlyContentBuilderInternal.h |  2 +-
 CountlyContentConfig.h          | 20 ++++++++++++++------
 CountlyContentConfig.m          | 10 +++++-----
 CountlyWebViewManager.m         | 29 +++++++++--------------------
 6 files changed, 31 insertions(+), 34 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7bdb4b0f..b50620fc 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 external content links as Universal Links (app deep links) when possible instead of always forcing them into the system browser, enabled via "enableUniversalLinkHandling".
+* 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
diff --git a/Countly.m b/Countly.m
index 9d7d72ea..18c34838 100644
--- a/Countly.m
+++ b/Countly.m
@@ -298,7 +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;
+    CountlyContentBuilderInternal.sharedInstance.contentURLHandler = config.content.getContentURLHandler;
 #endif
     
     [CountlyPerformanceMonitoring.sharedInstance startWithConfig:config.apm];
diff --git a/CountlyContentBuilderInternal.h b/CountlyContentBuilderInternal.h
index c78d5410..8d7d5612 100644
--- a/CountlyContentBuilderInternal.h
+++ b/CountlyContentBuilderInternal.h
@@ -19,7 +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, copy) ContentURLHandler contentURLHandler;
 @property (nonatomic, assign) int contentInitialDelay;
 
 + (instancetype)sharedInstance;
diff --git a/CountlyContentConfig.h b/CountlyContentConfig.h
index 207a165a..fef983a9 100644
--- a/CountlyContentConfig.h
+++ b/CountlyContentConfig.h
@@ -22,6 +22,12 @@ 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
@@ -91,13 +97,15 @@ typedef void (^ContentCallback)(ContentStatus contentStatus, NSDictionary
Date: Thu, 9 Jul 2026 16:48:53 +0300
Subject: [PATCH 13/14] feat: content URL handler: test

---
 CountlyTests/CountlyContentBuilderTests.swift | 21 ++++++++++---------
 CountlyWebViewManager.m                       |  2 +-
 2 files changed, 12 insertions(+), 11 deletions(-)

diff --git a/CountlyTests/CountlyContentBuilderTests.swift b/CountlyTests/CountlyContentBuilderTests.swift
index 09b3fbeb..4c465dfd 100644
--- a/CountlyTests/CountlyContentBuilderTests.swift
+++ b/CountlyTests/CountlyContentBuilderTests.swift
@@ -24,7 +24,7 @@ class CountlyContentBuilderTests: CountlyBaseTestCase {
         CountlyContentBuilderInternal.sharedInstance().enableContentReloadOnStall = false
         CountlyContentBuilderInternal.sharedInstance().contentReloadOnStallTimeout = 0
         CountlyContentBuilderInternal.sharedInstance().disableZoom = false
-        CountlyContentBuilderInternal.sharedInstance().enableUniversalLinkHandling = false
+        CountlyContentBuilderInternal.sharedInstance().contentURLHandler = nil
         Countly.sharedInstance().halt(true)
         MockURLProtocol.requestHandler = nil
         super.tearDown()
@@ -355,26 +355,27 @@ class CountlyContentBuilderTests: CountlyBaseTestCase {
 
     /**
      * 
-     * enableUniversalLinkHandling is a one-way config switch that plumbs to the internal builder.
+     * A content URL handler set on the config plumbs through 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
+     * 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_universalLinkHandling_configDefaultsAndPlumbing() { - XCTAssertFalse(CountlyContentConfig().getEnableUniversalLinkHandling()) + func test_contentURLHandler_configDefaultsAndPlumbing() { + XCTAssertNil(CountlyContentConfig().getContentURLHandler()) let config = createContentTestConfig() - config.content().enableUniversalLinkHandling() - XCTAssertTrue(config.content().getEnableUniversalLinkHandling()) + 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) - XCTAssertTrue(CountlyContentBuilderInternal.sharedInstance().enableUniversalLinkHandling) + XCTAssertNotNil(CountlyContentBuilderInternal.sharedInstance().contentURLHandler) } /** diff --git a/CountlyWebViewManager.m b/CountlyWebViewManager.m index eb4c0b47..a5105048 100644 --- a/CountlyWebViewManager.m +++ b/CountlyWebViewManager.m @@ -244,7 +244,7 @@ - (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigati if ([url containsString:@"cly_x_int=1"]) { CLY_LOG_I(@"%s Opening external url [%@]", __FUNCTION__, url); - // Prefers the app (Universal Link) when enableUniversalLinkHandling is on, else browser. + // Routed through the app's content URL handler if one is set, else the system browser. [self openExternalURL:navigationAction.request.URL]; decisionHandler(WKNavigationActionPolicyCancel); return; From 54bcde3c8e1021d9c65e0bf723b3a86846dfce96 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 9 Jul 2026 16:51:31 +0300 Subject: [PATCH 14/14] feat: content URL handler: test --- CountlyContentBuilderInternal.h | 2 +- CountlyContentConfig.h | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CountlyContentBuilderInternal.h b/CountlyContentBuilderInternal.h index 8d7d5612..8314d2b6 100644 --- a/CountlyContentBuilderInternal.h +++ b/CountlyContentBuilderInternal.h @@ -19,7 +19,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, assign) BOOL enableContentReloadOnStall; @property (nonatomic, assign) NSTimeInterval contentReloadOnStallTimeout; // seconds @property (nonatomic, assign) BOOL disableZoom; -@property (nonatomic, copy) ContentURLHandler contentURLHandler; +@property (nonatomic, copy, nullable) ContentURLHandler contentURLHandler; @property (nonatomic, assign) int contentInitialDelay; + (instancetype)sharedInstance; diff --git a/CountlyContentConfig.h b/CountlyContentConfig.h index fef983a9..b07f5209 100644 --- a/CountlyContentConfig.h +++ b/CountlyContentConfig.h @@ -104,8 +104,8 @@ typedef BOOL (^ContentURLHandler)(NSURL *url); * returning NO (or not setting a handler) makes the SDK open the URL in the system browser as * before. Called on the main thread. */ -- (void)setContentURLHandler:(ContentURLHandler)handler; -- (ContentURLHandler)getContentURLHandler; +- (void)setContentURLHandler:(nullable ContentURLHandler)handler; +- (nullable ContentURLHandler)getContentURLHandler; #endif NS_ASSUME_NONNULL_END