From 460377ef1e7392491b49b1392c92d4d4e6cfbb11 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 7 Jul 2026 14:44:59 +0300 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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];