Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
## 26.1.3
* Added a content configuration option to reload the content web view when its load stalls, enabled via "enableContentReloadOnStall" on "CountlyContentConfig", with a configurable stall timeout "setContentReloadOnStallTimeout:" in milliseconds (default 1000).
* Added a content configuration option to disable pinch zoom, disabled via "disableZoom".
* Added a content configuration option to open external content links as Universal Links (app deep links) when possible instead of always forcing them into the system browser, enabled via "enableUniversalLinkHandling".
* Improved link handling for content and feedback widgets, so links that carry their own query parameters, such as deep links, are parsed correctly.

* Mitigated an issue where two content web views could be presented at the same time when content fetches completed concurrently.

## 26.1.2
* ! Minor breaking change ! Raised the minimum supported deployment target to iOS 12 and tvOS 12 (previously iOS 10 and tvOS 10) for compatibility with Xcode 26 and the iOS 26 SDK. Apps targeting iOS 10/11 or tvOS 10/11 are no longer supported.

Expand Down
1 change: 1 addition & 0 deletions Countly.m
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
1 change: 1 addition & 0 deletions CountlyContentBuilderInternal.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions CountlyContentConfig.h
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,16 @@ typedef void (^ContentCallback)(ContentStatus contentStatus, NSDictionary<NSStri
*/
- (void)disableZoom;
- (BOOL)getDisableZoom;

/**
* This is an experimental feature and it can have breaking changes.
* Routes user-tapped http(s) links in the content web view to the operating system instead of
* rendering them inside the content overlay: a link that matches one of the app's associated
* domains opens the app (Universal Link / deep link), and any other link opens in the system
* browser. This is a one-way switch: once enabled it stays enabled. Disabled by default.
*/
- (void)enableUniversalLinkHandling;
- (BOOL)getEnableUniversalLinkHandling;
#endif

NS_ASSUME_NONNULL_END
Expand Down
11 changes: 11 additions & 0 deletions CountlyContentConfig.m
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ @interface CountlyContentConfig ()
@property (nonatomic) BOOL contentReloadOnStallEnabled;
@property (nonatomic) NSUInteger contentReloadOnStallTimeoutMs;
@property (nonatomic) BOOL zoomDisabled;
@property (nonatomic) BOOL universalLinkHandlingEnabled;
#endif
@end

Expand Down Expand Up @@ -94,6 +95,16 @@ - (BOOL)getDisableZoom
{
return _zoomDisabled;
}

- (void)enableUniversalLinkHandling
{
_universalLinkHandlingEnabled = YES;
}

- (BOOL)getEnableUniversalLinkHandling
{
return _universalLinkHandlingEnabled;
}
#endif

@end
25 changes: 25 additions & 0 deletions CountlyTests/CountlyContentBuilderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class CountlyContentBuilderTests: CountlyBaseTestCase {
CountlyContentBuilderInternal.sharedInstance().enableContentReloadOnStall = false
CountlyContentBuilderInternal.sharedInstance().contentReloadOnStallTimeout = 0
CountlyContentBuilderInternal.sharedInstance().disableZoom = false
CountlyContentBuilderInternal.sharedInstance().enableUniversalLinkHandling = false
Countly.sharedInstance().halt(true)
MockURLProtocol.requestHandler = nil
super.tearDown()
Expand Down Expand Up @@ -352,6 +353,30 @@ class CountlyContentBuilderTests: CountlyBaseTestCase {
XCTAssertTrue(CountlyContentBuilderInternal.sharedInstance().disableZoom)
}

/**
* <pre>
* 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
* </pre>
*/
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)
}

/**
* <pre>
* The single-content presentation latch rejects a second concurrent presentation.
Expand Down
3 changes: 3 additions & 0 deletions CountlyTests/CountlyWebViewManager+Tests.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -20,6 +21,8 @@
- (void)closeWebView;
- (void)retryOrCloseWebViewForReason:(NSString *)reason;
- (void)cancelPendingReload;
- (void)contentShownDeadlineReached;
- (void)recordEventsWithJSONString:(NSString *)jsonString;

@end
#endif
67 changes: 58 additions & 9 deletions CountlyTests/CountlyWebViewManagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """
<html><body><script>
window.webkit.messageHandlers.resourceLoadError.postMessage({tag:"SCRIPT", url:"https://example.com/broken.js"});
</script></body></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)
Expand Down Expand Up @@ -729,6 +732,52 @@ class CountlyWebViewManagerTests: XCTestCase {
XCTAssertFalse(manager.hasAppeared)
}

func testContentShownDeadlineReached_closesWebView() {
// The absolute deadline closes the web view when content_shown never arrived, even if
// the view had (blankly) "appeared" - content_shown would otherwise have cancelled it.
manager.webViewClosed = false
manager.hasAppeared = true

let webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration())
let bgView = PassThroughBackgroundView(frame: .zero)
bgView.webView = webView
manager.backgroundView = bgView

let dismissExpectation = expectation(description: "Dismiss block called")
manager.dismissBlock = { dismissExpectation.fulfill() }

manager.contentShownDeadlineReached()

waitForExpectations(timeout: 3.0)
XCTAssertTrue(manager.webViewClosed)
}

func testContentShownEvent_cancelsDeadlineTimer() {
// Receiving a [CLY]_content_shown event cancels the absolute deadline, so genuinely
// shown content is never torn down by it.
let timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: false) { _ in }
manager.contentShownDeadlineTimer = timer

let json = "[{\"key\":\"[CLY]_content_shown\",\"segmentation\":{\"content_id\":\"abc\"}}]"
manager.recordEvents(withJSONString: json)

XCTAssertNil(manager.contentShownDeadlineTimer)
XCTAssertFalse(timer.isValid)
}

func testNonContentShownEvent_leavesDeadlineTimerRunning() {
// A different event must NOT cancel the deadline.
let timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: false) { _ in }
manager.contentShownDeadlineTimer = timer

let json = "[{\"key\":\"some_other_event\",\"segmentation\":{\"a\":\"b\"}}]"
manager.recordEvents(withJSONString: json)

XCTAssertNotNil(manager.contentShownDeadlineTimer)
XCTAssertTrue(timer.isValid)
timer.invalidate()
}

func testDidReceiveScriptMessage_ignoredWhenWebViewClosed() {
manager.webViewClosed = true
manager.hasAppeared = false
Expand Down
94 changes: 74 additions & 20 deletions CountlyWebViewManager.m
Original file line number Diff line number Diff line change
Expand Up @@ -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 ()
Expand All @@ -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
Expand Down Expand Up @@ -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];

Expand Down Expand Up @@ -225,15 +243,9 @@ - (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigati
}

if ([url containsString:@"cly_x_int=1"]) {
CLY_LOG_I(@"%s Opening url [%@] in external browser", __FUNCTION__, url);
[[UIApplication sharedApplication] openURL:navigationAction.request.URL options:@{} completionHandler:^(BOOL success) {
if (success) {
CLY_LOG_I(@"%s url [%@] opened in external browser", __FUNCTION__, url);
}
else {
CLY_LOG_I(@"%s unable to open url [%@] in external browser", __FUNCTION__, url);
}
}];
CLY_LOG_I(@"%s Opening external url [%@]", __FUNCTION__, url);
// Prefers the app (Universal Link) when enableUniversalLinkHandling is on, else browser.
[self openExternalURL:navigationAction.request.URL];
decisionHandler(WKNavigationActionPolicyCancel);
return;
}
Expand All @@ -252,9 +264,40 @@ - (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigati
}

decisionHandler(WKNavigationActionPolicyCancel);
} else {
decisionHandler(WKNavigationActionPolicyAllow);
return;
}

decisionHandler(WKNavigationActionPolicyAllow);
}

// Opens an external URL from the content. When universal-link handling is enabled
// (CountlyContentConfig enableUniversalLinkHandling), the URL is first offered to the OS as a
// Universal Link (UIApplicationOpenURLOptionUniversalLinksOnly), so a link matching the host
// app's own associated domains opens the app (deep link) rather than being forced into Safari
// -- which is what a plain openURL: does for an app's own Universal Link. Only if it is not a
// Universal Link does it fall back to the system browser. When the option is off, it opens in
// the browser as before.
- (void)openExternalURL:(NSURL *)url {
if (!url) return;
UIApplication *application = UIApplication.sharedApplication;

if (CountlyContentBuilderInternal.sharedInstance.enableUniversalLinkHandling) {
[application openURL:url options:@{UIApplicationOpenURLOptionUniversalLinksOnly: @YES} completionHandler:^(BOOL openedInApp) {
if (openedInApp) {
CLY_LOG_I(@"%s URL [%@] opened in the app via Universal Link.", __FUNCTION__, url.absoluteString);
return;
}
// Not a registered Universal Link: fall back to the system browser.
[application openURL:url options:@{} completionHandler:^(BOOL openedInBrowser) {
CLY_LOG_I(@"%s URL [%@] is not a Universal Link; opened in browser: %@.", __FUNCTION__, url.absoluteString, openedInBrowser ? @"YES" : @"NO");
}];
}];
return;
}

[application openURL:url options:@{} completionHandler:^(BOOL success) {
CLY_LOG_I(@"%s URL [%@] opened in browser: %@.", __FUNCTION__, url.absoluteString, success ? @"YES" : @"NO");
}];
}

- (void)webView:(WKWebView *)webView decidePolicyForNavigationResponse:(WKNavigationResponse *)navigationResponse decisionHandler:(void (^)(WKNavigationResponsePolicy))decisionHandler {
Expand Down Expand Up @@ -485,6 +528,15 @@ - (void)cancelPendingReload {
}
}

// The content never reported [CLY]_content_shown within kCLYContentShownDeadline. Close it,
// regardless of hasAppeared: if content_shown had arrived this timer would have been cancelled,
// so reaching here means nothing was ever actually shown (e.g. a blank-but-HTTP-200 page).
- (void)contentShownDeadlineReached {
if (self.webViewClosed) return;
CLY_LOG_I(@"%s [CLY]_content_shown not received within %.0fs; closing web view.", __FUNCTION__, kCLYContentShownDeadline);
[self closeWebView];
}

- (void)userContentController:(WKUserContentController *)userContentController
didReceiveScriptMessage:(WKScriptMessage *)message
{
Expand Down Expand Up @@ -775,6 +827,13 @@ - (void)recordEventsWithJSONString:(NSString *)jsonString {
continue;
}

// The page reported it is actually showing content: cancel the absolute
// content-shown deadline so a genuinely-displayed content is never torn down.
if ([key isEqualToString:@"[CLY]_content_shown"]) {
[self.contentShownDeadlineTimer invalidate];
self.contentShownDeadlineTimer = nil;
}

[Countly.sharedInstance recordEvent:key segmentation:segmentation];
}

Expand All @@ -789,15 +848,8 @@ - (void)openExternalLink:(NSString *)urlString {
NSString *encoded = [urlString stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLFragmentAllowedCharacterSet]];
url = encoded ? [NSURL URLWithString:encoded] : nil;
}
if (url) {
[[UIApplication sharedApplication] openURL:url options:@{} completionHandler:^(BOOL success) {
if (success) {
CLY_LOG_I(@"URL [%@] opened in external browser", urlString);
} else {
CLY_LOG_I(@"Unable to open URL [%@] in external browser", urlString);
}
}];
}
// Prefers the app (Universal Link) when enableUniversalLinkHandling is on, else browser.
[self openExternalURL:url];
}

- (void)resizeWebViewWithJSONString:(NSString *)jsonString {
Expand Down Expand Up @@ -870,6 +922,8 @@ - (void)closeWebView
self.loadStartDate = nil;
[self.loadTimeoutTimer invalidate];
self.loadTimeoutTimer = nil;
[self.contentShownDeadlineTimer invalidate];
self.contentShownDeadlineTimer = nil;
[self cancelPendingReload];
if (self.backgroundView) {
[self.backgroundView.webView stopLoading];
Expand Down
Loading