From 2f82df65ef107ba175dd73e6e9fd719e0b9da44b Mon Sep 17 00:00:00 2001 From: Konstantin Antipochkin Date: Mon, 6 Oct 2025 15:43:40 -0400 Subject: [PATCH 1/6] Inapppresenter simplifications --- .../Optimobile/InApp/InAppPresenter.swift | 112 ++++++++---------- 1 file changed, 48 insertions(+), 64 deletions(-) diff --git a/OptimoveSDK/Sources/Classes/Optimobile/InApp/InAppPresenter.swift b/OptimoveSDK/Sources/Classes/Optimobile/InApp/InAppPresenter.swift index 53b09d15..c4a7182e 100644 --- a/OptimoveSDK/Sources/Classes/Optimobile/InApp/InAppPresenter.swift +++ b/OptimoveSDK/Sources/Classes/Optimobile/InApp/InAppPresenter.swift @@ -15,7 +15,6 @@ enum InAppAction: String { } final class InAppPresenter: NSObject, WKScriptMessageHandler, WKNavigationDelegate { - private let messageQueueLock = DispatchSemaphore(value: 1) private var webView: WKWebView? private var loadingSpinner: UIActivityIndicatorView? @@ -45,32 +44,30 @@ final class InAppPresenter: NSObject, WKScriptMessageHandler, WKNavigationDelega } func setDisplayMode(_ mode: InAppDisplayMode) { - runOnMainThreadSync { - let resumed = mode != displayMode && mode != .paused - - displayMode = mode + ensureMain { self.setDisplayMode_onMain(mode) } + } - if resumed { - presentFromQueue() - } + private func setDisplayMode_onMain(_ mode: InAppDisplayMode) { + assertOnMainThread() + let resumed = mode != displayMode && mode != .paused + displayMode = mode + if resumed { + presentFromQueue_onMain() } } func getDisplayMode() -> InAppDisplayMode { - var mode: InAppDisplayMode = .automatic - - runOnMainThreadSync { - mode = displayMode - } - - return mode + return displayMode } func queueMessagesForPresentation(messages: [InAppMessage], tickleIds: NSOrderedSet) { - messageQueueLock.wait() + ensureMain { self.queueMessagesForPresentation_onMain(messages: messages, tickleIds: tickleIds) } + } + + private func queueMessagesForPresentation_onMain(messages: [InAppMessage], tickleIds: NSOrderedSet) { + assertOnMainThread() if messages.count == 0 && messageQueue.count == 0 { - messageQueueLock.signal() return } @@ -78,7 +75,6 @@ final class InAppPresenter: NSObject, WKScriptMessageHandler, WKNavigationDelega if messageQueue.contains(message) { continue } - messageQueue.add(message) } @@ -116,47 +112,37 @@ final class InAppPresenter: NSObject, WKScriptMessageHandler, WKNavigationDelega } let notShowingCurrentTickle = currentMessage != nil + && messageQueue.count > 0 && currentMessage!.id != (messageQueue[0] as! InAppMessage).id - && (messageQueue[0] as! InAppMessage).id == pendingTickleIds[0] as! Int64 + && (messageQueue[0] as! InAppMessage).id == pendingTickleIds.firstObject as? Int64 let queueNotEmptyAndNotShowingAnything = currentMessage == nil && messageQueue.count > 0 let shouldShowSomething = notShowingCurrentTickle || queueNotEmptyAndNotShowingAnything - messageQueueLock.signal() - if shouldShowSomething { - DispatchQueue.main.async { - self.presentFromQueue() - } + presentFromQueue_onMain() } } func presentFromQueue() { - messageQueueLock.wait() - defer { - messageQueueLock.signal() - } + ensureMain { self.presentFromQueue_onMain() } + } - if messageQueue.count == 0 || displayMode == .paused { - DispatchQueue.main.async { - self.destroyViews() - } + private func presentFromQueue_onMain() { + assertOnMainThread() + if messageQueue.count == 0 || displayMode == .paused { + self.destroyViews() return } currentMessage = (messageQueue[0] as! InAppMessage) - var ready = false - - runOnMainThreadSync { - initViews() - self.loadingSpinner?.startAnimating() - ready = self.webViewReady - } + initViews() + self.loadingSpinner?.startAnimating() - guard ready else { + guard self.webViewReady else { return } @@ -167,6 +153,7 @@ final class InAppPresenter: NSObject, WKScriptMessageHandler, WKNavigationDelega } func handleMessageClosed() { + assertOnMainThread() guard let message = currentMessage else { return } @@ -178,8 +165,6 @@ final class InAppPresenter: NSObject, WKScriptMessageHandler, WKNavigationDelega PendingNotificationHelper.remove(identifier: tickleNotificationId) } - messageQueueLock.wait() - messageQueue.removeObject(at: 0) pendingTickleIds.remove(message.id) currentMessage = nil @@ -188,28 +173,24 @@ final class InAppPresenter: NSObject, WKScriptMessageHandler, WKNavigationDelega pendingTickleIds.removeAllObjects() } - messageQueueLock.signal() - - presentFromQueue() + presentFromQueue_onMain() } func cancelCurrentPresentationQueue(waitForViewCleanup: Bool) { - messageQueueLock.wait() + ensureMain { self.cancelCurrentPresentationQueue_onMain(waitForViewCleanup: waitForViewCleanup) } + } + + private func cancelCurrentPresentationQueue_onMain(waitForViewCleanup: Bool) { + assertOnMainThread() messageQueue.removeAllObjects() pendingTickleIds.removeAllObjects() currentMessage = nil - messageQueueLock.signal() - if waitForViewCleanup == true { - runOnMainThreadSync { - self.destroyViews() - } + self.destroyViews() } else { - DispatchQueue.main.async { - self.destroyViews() - } + self.destroyViews() } } @@ -327,6 +308,7 @@ final class InAppPresenter: NSObject, WKScriptMessageHandler, WKNavigationDelega } func postClientMessage(type: String, data: Any?) { + assertOnMainThread() guard let webView = webView else { return } @@ -353,11 +335,9 @@ final class InAppPresenter: NSObject, WKScriptMessageHandler, WKNavigationDelega let type = body["type"] as! String if type == "READY" { - runOnMainThreadSync { - self.webViewReady = true - } + self.webViewReady = true - presentFromQueue() + presentFromQueue_onMain() } else if type == "MESSAGE_OPENED" { loadingSpinner?.stopAnimating() Optimobile.sharedInstance.inAppManager.handleMessageOpened(message: currentMessage!) @@ -382,12 +362,12 @@ final class InAppPresenter: NSObject, WKScriptMessageHandler, WKNavigationDelega func webView(_: WKWebView, didFail _: WKNavigation!, withError _: Error) { // Handles transfer errors after starting load - cancelCurrentPresentationQueue(waitForViewCleanup: false) + cancelCurrentPresentationQueue_onMain(waitForViewCleanup: false) } func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError _: Error) { // Handles connection/timeout errors for the main frame load - cancelCurrentPresentationQueue(waitForViewCleanup: false) + cancelCurrentPresentationQueue_onMain(waitForViewCleanup: false) } func webView(_: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { @@ -398,7 +378,7 @@ final class InAppPresenter: NSObject, WKScriptMessageHandler, WKNavigationDelega { if url.absoluteString.starts(with: baseUrl.absoluteString), httpResponse.statusCode >= 400 { decisionHandler(.cancel) - cancelCurrentPresentationQueue(waitForViewCleanup: false) + cancelCurrentPresentationQueue_onMain(waitForViewCleanup: false) return } } @@ -407,7 +387,7 @@ final class InAppPresenter: NSObject, WKScriptMessageHandler, WKNavigationDelega } func webViewWebContentProcessDidTerminate(_: WKWebView) { - cancelCurrentPresentationQueue(waitForViewCleanup: false) + cancelCurrentPresentationQueue_onMain(waitForViewCleanup: false) } func handleActions(actions: [NSDictionary]) { @@ -485,11 +465,15 @@ final class InAppPresenter: NSObject, WKScriptMessageHandler, WKNavigationDelega } } - private func runOnMainThreadSync(_ work: () -> Void) { + private func ensureMain(_ work: @escaping () -> Void) { if Thread.isMainThread { work() } else { - DispatchQueue.main.sync(execute: work) + DispatchQueue.main.async(execute: work) } } + + private func assertOnMainThread(_ message: String = "Must be on main thread") { + assert(Thread.isMainThread, message) + } } From 4205179b28a011b912a8720543814557b5dc4197 Mon Sep 17 00:00:00 2001 From: Konstantin Antipochkin Date: Mon, 6 Oct 2025 16:10:05 -0400 Subject: [PATCH 2/6] Removed setDisplayMode_onMain --- .../Classes/Optimobile/InApp/InAppPresenter.swift | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/OptimoveSDK/Sources/Classes/Optimobile/InApp/InAppPresenter.swift b/OptimoveSDK/Sources/Classes/Optimobile/InApp/InAppPresenter.swift index c4a7182e..258abc1c 100644 --- a/OptimoveSDK/Sources/Classes/Optimobile/InApp/InAppPresenter.swift +++ b/OptimoveSDK/Sources/Classes/Optimobile/InApp/InAppPresenter.swift @@ -44,15 +44,10 @@ final class InAppPresenter: NSObject, WKScriptMessageHandler, WKNavigationDelega } func setDisplayMode(_ mode: InAppDisplayMode) { - ensureMain { self.setDisplayMode_onMain(mode) } - } - - private func setDisplayMode_onMain(_ mode: InAppDisplayMode) { - assertOnMainThread() - let resumed = mode != displayMode && mode != .paused - displayMode = mode - if resumed { - presentFromQueue_onMain() + ensureMain { + let resumed = mode != displayMode && mode != .paused + displayMode = mode + if resumed { presentFromQueue() } // safe: runs inline when already on main } } From d4135d8ed74c6e2bd595b9fa3f8e91b003382974 Mon Sep 17 00:00:00 2001 From: Konstantin Antipochkin Date: Mon, 6 Oct 2025 16:29:12 -0400 Subject: [PATCH 3/6] Fixed syntax --- .../Sources/Classes/Optimobile/InApp/InAppPresenter.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/OptimoveSDK/Sources/Classes/Optimobile/InApp/InAppPresenter.swift b/OptimoveSDK/Sources/Classes/Optimobile/InApp/InAppPresenter.swift index 258abc1c..536959f7 100644 --- a/OptimoveSDK/Sources/Classes/Optimobile/InApp/InAppPresenter.swift +++ b/OptimoveSDK/Sources/Classes/Optimobile/InApp/InAppPresenter.swift @@ -45,9 +45,9 @@ final class InAppPresenter: NSObject, WKScriptMessageHandler, WKNavigationDelega func setDisplayMode(_ mode: InAppDisplayMode) { ensureMain { - let resumed = mode != displayMode && mode != .paused - displayMode = mode - if resumed { presentFromQueue() } // safe: runs inline when already on main + let resumed = mode != self.displayMode && mode != .paused + self.displayMode = mode + if resumed { self.presentFromQueue() } } } From f11e7707978f1e0603475e2b58cf103be924faca Mon Sep 17 00:00:00 2001 From: Konstantin Antipochkin Date: Fri, 17 Oct 2025 12:44:29 -0400 Subject: [PATCH 4/6] Added in app interception --- .../Optimobile/InApp/InAppPresenter.swift | 159 +++++++++++++++--- .../Classes/Optimobile/OptimoveInApp.swift | 29 ++++ 2 files changed, 168 insertions(+), 20 deletions(-) diff --git a/OptimoveSDK/Sources/Classes/Optimobile/InApp/InAppPresenter.swift b/OptimoveSDK/Sources/Classes/Optimobile/InApp/InAppPresenter.swift index 536959f7..9d24ee1c 100644 --- a/OptimoveSDK/Sources/Classes/Optimobile/InApp/InAppPresenter.swift +++ b/OptimoveSDK/Sources/Classes/Optimobile/InApp/InAppPresenter.swift @@ -21,6 +21,7 @@ final class InAppPresenter: NSObject, WKScriptMessageHandler, WKNavigationDelega private var frame: UIView? private var window: UIWindow? private var webViewReady = false + private var interceptionInProgress = false private var contentController: WKUserContentController? @@ -32,6 +33,40 @@ final class InAppPresenter: NSObject, WKScriptMessageHandler, WKNavigationDelega let urlBuilder: UrlBuilder + private class InterceptionDecision: InAppMessageInterceptorDecision { + private var resolved: Bool = false + private let onShow: () -> Void + private let onSuppress: () -> Void + private var cancelTimeout: (() -> Void)? + + init(onShow: @escaping () -> Void, onSuppress: @escaping () -> Void) { + self.onShow = onShow + self.onSuppress = onSuppress + } + + func setCancelTimeout(_ cancel: @escaping () -> Void) { + self.cancelTimeout = cancel + } + + func show() { + DispatchQueue.main.async { + guard !self.resolved else { return } + self.resolved = true + self.cancelTimeout?() + self.onShow() + } + } + + func suppress() { + DispatchQueue.main.async { + guard !self.resolved else { return } + self.resolved = true + self.cancelTimeout?() + self.onSuppress() + } + } + } + init(displayMode: InAppDisplayMode, urlBuilder: UrlBuilder) { messageQueue = NSMutableOrderedSet(capacity: 5) pendingTickleIds = NSMutableOrderedSet(capacity: 2) @@ -132,7 +167,17 @@ final class InAppPresenter: NSObject, WKScriptMessageHandler, WKNavigationDelega return } - currentMessage = (messageQueue[0] as! InAppMessage) + let head = (messageQueue[0] as! InAppMessage) + + if interceptionInProgress { + return + } + + if let interceptor = OptimoveInApp.getInAppMessageInterceptor(), currentMessage?.id != head.id { + interceptionInProgress = true + applyMessageInterception(head, interceptor: interceptor) + return + } initViews() self.loadingSpinner?.startAnimating() @@ -141,10 +186,7 @@ final class InAppPresenter: NSObject, WKScriptMessageHandler, WKNavigationDelega return } - let content = NSMutableDictionary(dictionary: currentMessage!.content) - content["region"] = Optimobile.sharedInstance.config.region.rawValue - - postClientMessage(type: "PRESENT_MESSAGE", data: content) + showMessage(head) } func handleMessageClosed() { @@ -153,22 +195,8 @@ final class InAppPresenter: NSObject, WKScriptMessageHandler, WKNavigationDelega return } - if #available(iOS 10, *) { - let tickleNotificationId = "k-in-app-message:\(message.id)" - UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [tickleNotificationId]) - - PendingNotificationHelper.remove(identifier: tickleNotificationId) - } - messageQueue.removeObject(at: 0) - pendingTickleIds.remove(message.id) - currentMessage = nil - - if messageQueue.count == 0 { - pendingTickleIds.removeAllObjects() - } - - presentFromQueue_onMain() + cleanupMessageAndAdvance(message) } func cancelCurrentPresentationQueue(waitForViewCleanup: Bool) { @@ -423,6 +451,97 @@ final class InAppPresenter: NSObject, WKScriptMessageHandler, WKNavigationDelega } } + private func showMessage(_ message: InAppMessage) { + assertOnMainThread() + + currentMessage = message + + let content = NSMutableDictionary(dictionary: message.content) + content["region"] = Optimobile.sharedInstance.config.region.rawValue + + postClientMessage(type: "PRESENT_MESSAGE", data: content) + } + + private func applyMessageInterception(_ message: InAppMessage, interceptor: InAppMessageInterceptor) { + assertOnMainThread() + + let decision = InterceptionDecision( + onShow: { [weak self] in + self?.handleShowDecision(for: message) + }, + onSuppress: { [weak self] in + self?.handleSuppressDecision(for: message) + } + ) + + let timeoutMs = max(0, interceptor.getTimeoutMs()) + let timeoutItem = DispatchWorkItem { [weak decision] in + decision?.suppress() + } + + decision.setCancelTimeout { + timeoutItem.cancel() + } + + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(timeoutMs), execute: timeoutItem) + + let data = message.data as? [String: Any] + + do { + interceptor.processMessage(data: data, decision: decision) + } catch { + Logger.error("Error in message interceptor: \(error.localizedDescription)") + decision.suppress() + } + } + + private func handleShowDecision(for message: InAppMessage) { + assertOnMainThread() + + currentMessage = message + interceptionInProgress = false + + initViews() + loadingSpinner?.startAnimating() + if webViewReady { + showMessage(message) + } + } + + private func handleSuppressDecision(for message: InAppMessage) { + assertOnMainThread() + + interceptionInProgress = false + + Optimobile.sharedInstance.inAppManager.markMessageDismissed(message: message) + + let idx = messageQueue.index(of: message) + if idx != NSNotFound { + messageQueue.removeObject(at: idx) + } + + cleanupMessageAndAdvance(message) + } + + private func cleanupMessageAndAdvance(_ message: InAppMessage) { + assertOnMainThread() + + if #available(iOS 10, *) { + let tickleNotificationId = "k-in-app-message:\(message.id)" + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [tickleNotificationId]) + PendingNotificationHelper.remove(identifier: tickleNotificationId) + } + + pendingTickleIds.remove(message.id) + currentMessage = nil + + if messageQueue.count == 0 { + pendingTickleIds.removeAllObjects() + } + + presentFromQueue_onMain() + } + func handleUserAction(message: InAppMessage, userAction: NSDictionary) { let type = userAction["type"] as! String diff --git a/OptimoveSDK/Sources/Classes/Optimobile/OptimoveInApp.swift b/OptimoveSDK/Sources/Classes/Optimobile/OptimoveInApp.swift index 68f9f556..9bb4316f 100644 --- a/OptimoveSDK/Sources/Classes/Optimobile/OptimoveInApp.swift +++ b/OptimoveSDK/Sources/Classes/Optimobile/OptimoveInApp.swift @@ -77,6 +77,7 @@ public typealias InboxSummaryBlock = (InAppInboxSummary?) -> Void public enum OptimoveInApp { private static var _inboxUpdatedHandlerBlock: InboxUpdatedHandlerBlock? + private static var _inAppMessageInterceptor: InAppMessageInterceptor? public static func updateConsent(forUser consentGiven: Bool) { if Optimobile.inAppConsentStrategy != InAppConsentStrategy.explicitByUser { @@ -188,4 +189,32 @@ public enum OptimoveInApp { } } } + + // MARK: Interceptor API + + public static func setInAppMessageInterceptor(_ interceptor: InAppMessageInterceptor) { + _inAppMessageInterceptor = interceptor + } + + static func getInAppMessageInterceptor() -> InAppMessageInterceptor? { + return _inAppMessageInterceptor + } +} + +// MARK: - Interceptor Protocols + +public protocol InAppMessageInterceptor { + // Async decision, call either decision.show() or decision.suppress() + func processMessage(data: [String: Any]?, decision: InAppMessageInterceptorDecision) + + func getTimeoutMs() -> Int +} + +public protocol InAppMessageInterceptorDecision { + func show() + func suppress() +} + +public extension InAppMessageInterceptor { + func getTimeoutMs() -> Int { 5000 } } From 6dfd799a90285963518cada82f1ad959b8c0ace4 Mon Sep 17 00:00:00 2001 From: Konstantin Antipochkin Date: Mon, 20 Oct 2025 12:50:41 -0400 Subject: [PATCH 5/6] Removed redundant changes --- .../Sources/Classes/Optimobile/InApp/InAppPresenter.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/OptimoveSDK/Sources/Classes/Optimobile/InApp/InAppPresenter.swift b/OptimoveSDK/Sources/Classes/Optimobile/InApp/InAppPresenter.swift index 536959f7..77d44318 100644 --- a/OptimoveSDK/Sources/Classes/Optimobile/InApp/InAppPresenter.swift +++ b/OptimoveSDK/Sources/Classes/Optimobile/InApp/InAppPresenter.swift @@ -107,9 +107,8 @@ final class InAppPresenter: NSObject, WKScriptMessageHandler, WKNavigationDelega } let notShowingCurrentTickle = currentMessage != nil - && messageQueue.count > 0 && currentMessage!.id != (messageQueue[0] as! InAppMessage).id - && (messageQueue[0] as! InAppMessage).id == pendingTickleIds.firstObject as? Int64 + && (messageQueue[0] as! InAppMessage).id == pendingTickleIds[0] as! Int64 let queueNotEmptyAndNotShowingAnything = currentMessage == nil && messageQueue.count > 0 From c10c48ecce4cfce6e8753b8601e944674f8f264b Mon Sep 17 00:00:00 2001 From: Konstantin Antipochkin Date: Mon, 20 Oct 2025 18:34:24 -0400 Subject: [PATCH 6/6] 6.3 version --- CHANGELOG.md | 4 ++++ OptimoveCore.podspec | 2 +- OptimoveCore/Sources/Classes/Constants/SDKVersion.swift | 2 +- OptimoveNotificationServiceExtension.podspec | 2 +- OptimoveSDK.podspec | 2 +- 5 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 707072e6..ed2562eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 6.3.0 + +- Add In-App Message Interceptor API `OptimoveInApp.setInAppMessageInterceptor(_:)` to allow apps to control when in-app messages are shown or suppressed based on custom logic. If no decision is made within the timeout (default 5s), the message is automatically suppressed. + ## 6.2.6 - Remove semaphore wait in session end to avoid QoS inversion during background flush diff --git a/OptimoveCore.podspec b/OptimoveCore.podspec index c352b26c..b79594dc 100644 --- a/OptimoveCore.podspec +++ b/OptimoveCore.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'OptimoveCore' - s.version = '6.2.6' + s.version = '6.3.0' s.summary = 'Official Optimove SDK for iOS. Core framework.' s.description = 'The core framework is used to share code-base between other Optimove frameworks.' s.homepage = 'https://github.com/optimove-tech/Optimove-SDK-iOS' diff --git a/OptimoveCore/Sources/Classes/Constants/SDKVersion.swift b/OptimoveCore/Sources/Classes/Constants/SDKVersion.swift index 1bf420ce..dde4560f 100644 --- a/OptimoveCore/Sources/Classes/Constants/SDKVersion.swift +++ b/OptimoveCore/Sources/Classes/Constants/SDKVersion.swift @@ -1,3 +1,3 @@ // Copyright © 2019 Optimove. All rights reserved. -public let SDKVersion = "6.2.6" +public let SDKVersion = "6.3.0" diff --git a/OptimoveNotificationServiceExtension.podspec b/OptimoveNotificationServiceExtension.podspec index 5a1821f3..753fb19d 100644 --- a/OptimoveNotificationServiceExtension.podspec +++ b/OptimoveNotificationServiceExtension.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'OptimoveNotificationServiceExtension' - s.version = '6.2.6' + s.version = '6.3.0' s.summary = 'Official Optimove SDK for iOS. Notification service extension framework.' s.description = 'The notification service extension is used for handling additional content in push notifications.' s.homepage = 'https://github.com/optimove-tech/Optimove-SDK-iOS' diff --git a/OptimoveSDK.podspec b/OptimoveSDK.podspec index ce2d04a6..0142b777 100644 --- a/OptimoveSDK.podspec +++ b/OptimoveSDK.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'OptimoveSDK' - s.version = '6.2.6' + s.version = '6.3.0' s.summary = 'Official Optimove SDK for iOS.' s.description = 'The Optimove SDK framework is used for reporting events and receive push notifications.' s.homepage = 'https://github.com/optimove-tech/Optimove-SDK-iOS'