diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ba01a52..b50620fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +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 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 * ! 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-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.m b/Countly.m index 06873cd1..18c34838 100644 --- a/Countly.m +++ b/Countly.m @@ -295,6 +295,10 @@ - (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; + CountlyContentBuilderInternal.sharedInstance.disableZoom = config.content.getDisableZoom; + CountlyContentBuilderInternal.sharedInstance.contentURLHandler = config.content.getContentURLHandler; #endif [CountlyPerformanceMonitoring.sharedInstance startWithConfig:config.apm]; 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/CountlyContentBuilderInternal.h b/CountlyContentBuilderInternal.h index 7983a27d..8314d2b6 100644 --- a/CountlyContentBuilderInternal.h +++ b/CountlyContentBuilderInternal.h @@ -16,6 +16,10 @@ 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) BOOL disableZoom; +@property (nonatomic, copy, nullable) ContentURLHandler contentURLHandler; @property (nonatomic, assign) int contentInitialDelay; + (instancetype)sharedInstance; diff --git a/CountlyContentBuilderInternal.m b/CountlyContentBuilderInternal.m index 9f8c07d3..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,39 +43,87 @@ - (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 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 (!*flag) { *flag = YES; acquired = YES; } + return acquired; + } + dispatch_sync(_contentQueue, ^{ + if (!*flag) { *flag = YES; acquired = YES; } }); + return acquired; +} + +- (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 { + [self writeFlag:&_isCurrentlyContentShown value:NO]; +} + +- (BOOL)isContentShownThreadSafe { + return [self readFlag:&_isCurrentlyContentShown]; } - (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,15 +177,30 @@ - (void)refreshContentZone { { return; } - if(_isCurrentlyContentShown){ + if([self isContentShownThreadSafe]){ CLY_LOG_I(@"%s a content is already shown, skipping" ,__FUNCTION__); 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]; } @@ -147,7 +211,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; } @@ -190,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 { @@ -210,11 +277,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 +394,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 +426,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 +437,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/CountlyContentConfig.h b/CountlyContentConfig.h index 9aa29b12..b07f5209 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 @@ -59,6 +65,47 @@ typedef void (^ContentCallback)(ContentStatus contentStatus, NSDictionary + * 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) + } + + /** + *
+     * 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) + } + + /** + *
+     * A content URL handler set on the config plumbs through to the internal builder.
+     *
+     * 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_contentURLHandler_configDefaultsAndPlumbing() { + XCTAssertNil(CountlyContentConfig().getContentURLHandler()) + + let config = createContentTestConfig() + 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) + XCTAssertNotNil(CountlyContentBuilderInternal.sharedInstance().contentURLHandler) + } + + /** + *
+     * 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()) + } + + /** + *
+     * 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/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 d6bc7337..236a6edd 100644 --- a/CountlyTests/CountlyWebViewManager+Tests.h +++ b/CountlyTests/CountlyWebViewManager+Tests.h @@ -6,7 +6,10 @@ @property(nonatomic) BOOL webViewClosed; @property(nonatomic) BOOL hasAppeared; +@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); @@ -16,6 +19,10 @@ - (void)notifyPageLoaded; - (void)loadDidTimeout; - (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 9d8c7cd0..cc1f840f 100644 --- a/CountlyTests/CountlyWebViewManagerTests.swift +++ b/CountlyTests/CountlyWebViewManagerTests.swift @@ -19,10 +19,13 @@ class CountlyWebViewManagerTests: XCTestCase { override func setUp() { super.setUp() manager = CountlyWebViewManager() + // Retry-on-failure is opt-in; enable it so the retry-path tests exercise it. + CountlyContentBuilderInternal.sharedInstance().enableContentReloadOnStall = true } override func tearDown() { manager = nil + CountlyContentBuilderInternal.sharedInstance().enableContentReloadOnStall = false super.tearDown() } @@ -73,6 +76,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() { @@ -131,13 +285,33 @@ class CountlyWebViewManagerTests: XCTestCase { // MARK: - loadDidTimeout tests - func testLoadDidTimeout_setsWebViewClosedFlag() { + func testLoadDidTimeout_schedulesRetry() { manager.webViewClosed = false manager.hasAppeared = false manager.loadDidTimeout() - // loadDidTimeout sets webViewClosed = YES synchronously before calling closeWebView + // A stalled load now triggers a retry (reload) instead of an immediate close. + XCTAssertFalse(manager.webViewClosed) + XCTAssertEqual(manager.resourceRetryCount, 1) + XCTAssertNotNil(manager.pendingReloadBlock) + } + + func testLoadDidTimeout_closesAfterRetriesExhausted() { + manager.webViewClosed = false + manager.hasAppeared = false + manager.resourceRetryCount = 99 // retries already used up + + let bgView = PassThroughBackgroundView(frame: .zero) + bgView.webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) + manager.backgroundView = bgView + + let dismissExpectation = expectation(description: "Dismiss block called") + manager.dismissBlock = { dismissExpectation.fulfill() } + + manager.loadDidTimeout() + + waitForExpectations(timeout: 3.0) XCTAssertTrue(manager.webViewClosed) } @@ -235,6 +409,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 +421,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 +441,49 @@ 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. A non-nil pendingReloadBlock marks + // a scheduled reload. + manager.webViewClosed = false + manager.hasAppeared = false + manager.pendingReloadBlock = { } + + 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,10 +516,126 @@ class CountlyWebViewManagerTests: XCTestCase { contentController.removeScriptMessageHandler(forName: "resourceVerifyResult") } - func testDidReceiveScriptMessage_resourceLoadError_closesWebView() { + 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 + + 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 } + + // 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.loadHTMLString(html, baseURL: URL(string: "https://example.com")) + + 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) + XCTAssertEqual(manager.resourceRetryCount, 1) + XCTAssertNotNil(manager.pendingReloadBlock) + + contentController.removeScriptMessageHandler(forName: "resourceLoadError") + contentController.removeScriptMessageHandler(forName: "resourceVerifyResult") + } + + func testDidReceiveScriptMessage_resourceLoadError_closesWhenReloadDisabled() { + // With enableContentReloadOnStall off, a critical-resource failure closes (original behavior). + CountlyContentBuilderInternal.sharedInstance().enableContentReloadOnStall = false + 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) + let bgView = PassThroughBackgroundView(frame: .zero) + bgView.webView = webView + manager.backgroundView = bgView + + let dismissExpectation = expectation(description: "Dismiss block called") + manager.dismissBlock = { dismissExpectation.fulfill() } + + let js = """ + window.webkit.messageHandlers.resourceLoadError.postMessage({tag: "SCRIPT", url: "https://example.com/broken.js"}); + """ + webView.evaluateJavaScript(js, completionHandler: nil) + + waitForExpectations(timeout: 3.0) + XCTAssertTrue(manager.webViewClosed) + XCTAssertEqual(manager.resourceRetryCount, 0) // did not retry + + 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 contentController.add(manager, name: "resourceLoadError") @@ -318,9 +649,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 +663,121 @@ 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) + XCTAssertNil(manager.pendingReloadBlock) + 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") + XCTAssertNotNil(manager.pendingReloadBlock) + XCTAssertEqual(manager.resourceRetryCount, 1) + + // The load then verifies good / appears. + manager.notifyPageLoaded() + XCTAssertTrue(manager.hasAppeared) + 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") + XCTAssertNotNil(manager.pendingReloadBlock) + + manager.cancelPendingReload() + 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 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 @@ -376,6 +820,7 @@ class CountlyWebViewManagerTests: XCTestCase { } func testDidReceiveScriptMessage_http404ClosesWebView() { + // Post-load verification 404 closes without retrying. manager.webViewClosed = false manager.hasAppeared = false @@ -385,16 +830,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 +849,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/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 45b892c4..a5105048 100644 --- a/CountlyWebViewManager.m +++ b/CountlyWebViewManager.m @@ -8,8 +8,36 @@ #import "CountlyOverlayWindow.h" #import "CountlyWebViewController.h" #import "PassThroughBackgroundView.h" +#import "CountlyContentBuilderInternal.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; +// Fallback stall timeout (seconds) used only when reload-on-stall is enabled but no +// timeout was configured (e.g. the SDK was not started via config in a unit test). The +// real value comes from CountlyContentConfig setContentReloadOnStallTimeout: (default +// 1000 ms). Kept short because a manual reload is observed to recover reliably: on reload +// the already-fetched assets come from cache and the rest reuse the warm connection, so +// the parallel-connection burst shrinks below the edge's limit. A stalled load fires no +// JS 'error' event, so this timer is what triggers the reload for that case. When the +// 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 () @@ -20,6 +48,14 @@ @interface CountlyWebViewManager () @property(nonatomic, strong) NSDate *loadStartDate; @property(nonatomic) BOOL hasAppeared; @property(nonatomic) BOOL webViewClosed; +@property(nonatomic) NSInteger resourceRetryCount; +@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; +// 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 @@ -34,6 +70,8 @@ - (void)createWebViewWithURL:(NSURL *)url self.appearBlock = appearBlock; self.hasAppeared = NO; self.webViewClosed = NO; + self.resourceRetryCount = 0; + self.pendingReloadBlock = nil; // TODO: keyWindow deprecation fix _window = [CountlyOverlayWindow new]; CountlyWebViewController *modal = [CountlyWebViewController new]; @@ -90,6 +128,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"]; @@ -101,11 +167,25 @@ - (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]; [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]; @@ -120,6 +200,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]; @@ -157,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); + // Routed through the app's content URL handler if one is set, else the system browser. + [self openExternalURL:navigationAction.request.URL]; decisionHandler(WKNavigationActionPolicyCancel); return; } @@ -184,9 +264,29 @@ - (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigati } decisionHandler(WKNavigationActionPolicyCancel); - } else { - decisionHandler(WKNavigationActionPolicyAllow); + return; } + + decisionHandler(WKNavigationActionPolicyAllow); +} + +// Opens an external URL from the content. If the host app has provided a content URL handler +// (CountlyContentConfig setContentURLHandler:), the URL is offered to it first so the app can +// route its own deep link (custom scheme or https) to the right screen; the handler returns +// YES if it took over. If there is no handler, or it returns NO, the SDK opens the URL in the +// system browser as before. +- (void)openExternalURL:(NSURL *)url { + if (!url) return; + + ContentURLHandler handler = CountlyContentBuilderInternal.sharedInstance.contentURLHandler; + if (handler && handler(url)) { + CLY_LOG_I(@"%s URL [%@] handled by the app's content URL handler.", __FUNCTION__, url.absoluteString); + return; + } + + [UIApplication.sharedApplication 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 { @@ -251,7 +351,13 @@ - (void)webView:(WKWebView *)webView didStartProvisionalNavigation:(WKNavigation CLY_LOG_I(@"%s Web view has started loading", __FUNCTION__); [self.loadTimeoutTimer invalidate]; __weak typeof(self) weakSelf = self; - self.loadTimeoutTimer = [NSTimer scheduledTimerWithTimeInterval:60.0 repeats:NO block:^(NSTimer * _Nonnull timer) { + // Fast stall-detect (reload) when enabled, using the configurable stall timeout; + // otherwise a plain 60s safety-net close. + 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; [strongSelf loadDidTimeout]; @@ -312,6 +418,12 @@ - (void)verifyResourceStatuses:(WKWebView *)webView { - (void)notifyPageLoaded { 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; @@ -323,6 +435,97 @@ - (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; + } + + // Reload-on-failure is opt-in. When disabled, keep the original behavior: close on failure. + if (!CountlyContentBuilderInternal.sharedInstance.enableContentReloadOnStall) { + CLY_LOG_I(@"%s %@ — reload-on-stall disabled, closing web view.", __FUNCTION__, reason); + [self closeWebView]; + return; + } + + // A reload is already scheduled for this load cycle — coalesce further failures. + // 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; + } + + 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; + // 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); + + __weak typeof(self) weakSelf = self; + // 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) 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; + 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]; + }); + 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. 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; + } +} + +// 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 { @@ -333,10 +536,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"]) { @@ -344,16 +548,41 @@ - (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) { - 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.pendingReloadBlock) { + return; + } dispatch_async(dispatch_get_main_queue(), ^{ [self closeWebView]; }); 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; } } @@ -422,29 +651,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]; @@ -479,6 +816,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]; } @@ -487,21 +831,20 @@ - (void)recordEventsWithJSONString:(NSString *)jsonString { - (void)openExternalLink:(NSString *)urlString { NSURL *url = [NSURL URLWithString:urlString]; - 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); - } - }]; + 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; } + // Prefers the app (Universal Link) when enableUniversalLinkHandling is on, else browser. + [self openExternalURL:url]; } - (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]; @@ -568,6 +911,9 @@ - (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]; self.backgroundView.webView.navigationDelegate = nil; @@ -601,9 +947,12 @@ - (void)closeWebView - (void)loadDidTimeout { if (self.hasAppeared || self.webViewClosed) return; - self.webViewClosed = YES; - CLY_LOG_I(@"%s Web view load timed out after 60s, closing", __FUNCTION__); - [self closeWebView]; + 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 + // retryOrCloseWebViewForReason: bail out before it can retry. + [self retryOrCloseWebViewForReason:@"load stalled (no appearance)"]; } #endif @end