diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index fbbde5b2..7a32fb45 100644 --- a/Mindbox.xcodeproj/project.pbxproj +++ b/Mindbox.xcodeproj/project.pbxproj @@ -1506,6 +1506,8 @@ 471D4C3B2FEED16200856EA5 /* TestPlans */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = TestPlans; sourceTree = ""; }; 57A1B3230000000000000203 /* EmbeddedBlocks */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = EmbeddedBlocks; sourceTree = ""; }; 57A1B3230000000000000300 /* EmbeddedBlocks */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = EmbeddedBlocks; sourceTree = ""; }; + 57A1B3230000000000000400 /* Handlers */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Handlers; sourceTree = ""; }; + 57A1B3230000000000000401 /* BridgeHandlers */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = BridgeHandlers; sourceTree = ""; }; A8C878878353491FA01AC096 /* WebViewPrewarmTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = WebViewPrewarmTests; sourceTree = ""; }; F385631E2DB6729000D91208 /* InappConfigurationDataFacade */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = InappConfigurationDataFacade; sourceTree = ""; }; F397DE1C2CFF568800B72DA9 /* JSONs */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = JSONs; sourceTree = ""; }; @@ -1600,6 +1602,7 @@ 7C8192B8B7043EF74D05B36B /* MotionServiceResolvePositionTests.swift */, 7C8192B8B7043EF74D05B36C /* MotionServiceShakeToEditTests.swift */, F0DB93A7997961CA7C2BE917 /* MotionServiceBehaviorTests.swift */, + 57A1B3230000000000000401 /* BridgeHandlers */, ); path = WebView; sourceTree = ""; @@ -3839,6 +3842,7 @@ F3BD9F812F273BCD00647BAF /* BridgeMessage.swift */, F3BDA0372F27444500647BAF /* MindboxWebBridge.swift */, F338028F2F29F3AF00F83D2F /* BridgeMessageDispatcher.swift */, + 57A1B3230000000000000400 /* Handlers */, ); path = Bridge; sourceTree = ""; @@ -3965,6 +3969,7 @@ ); fileSystemSynchronizedGroups = ( 57A1B3230000000000000300 /* EmbeddedBlocks */, + 57A1B3230000000000000400 /* Handlers */, ); name = Mindbox; productName = MindBox; @@ -3986,6 +3991,7 @@ ); fileSystemSynchronizedGroups = ( 57A1B3230000000000000203 /* EmbeddedBlocks */, + 57A1B3230000000000000401 /* BridgeHandlers */, A8C878878353491FA01AC096 /* WebViewPrewarmTests */, F385631E2DB6729000D91208 /* InappConfigurationDataFacade */, F397DE1C2CFF568800B72DA9 /* JSONs */, diff --git a/Mindbox/DI/Injections/InjectEmbeddedBlocks.swift b/Mindbox/DI/Injections/InjectEmbeddedBlocks.swift index 2d2a7bdb..90cdabe6 100644 --- a/Mindbox/DI/Injections/InjectEmbeddedBlocks.swift +++ b/Mindbox/DI/Injections/InjectEmbeddedBlocks.swift @@ -11,20 +11,15 @@ import Foundation extension MBContainer { func registerEmbeddedBlocks() -> Self { // The resolver is shared: its per-id cache and its queue of waiting blocks are what make - // several blocks with the same id resolve through a single load of the data. The action - // router is shared because it is stateless. Providers, in contrast, are made per block by - // the factory, so blocks stay independent of each other. + // several blocks with the same id resolve through a single load of the data. Providers, + // in contrast, are made per block by the factory, so blocks stay independent of each + // other — and so does the bridge session each of their pages runs. register(EmbeddedBlockResolving.self) { EmbeddedBlockResolver() } - register(EmbeddedBlockActionHandling.self) { - EmbeddedBlockActionRouter() - } - register(EmbeddedBlockContentProviderMaking.self) { - EmbeddedBlockContentProviderFactory(resolver: DI.injectOrFail(EmbeddedBlockResolving.self), - actionHandler: DI.injectOrFail(EmbeddedBlockActionHandling.self)) + EmbeddedBlockContentProviderFactory(resolver: DI.injectOrFail(EmbeddedBlockResolving.self)) } return self diff --git a/Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift b/Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift deleted file mode 100644 index 97aa344c..00000000 --- a/Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// EmbeddedBlockActionRouter.swift -// Mindbox -// -// Created by vailence on 06.08.2026. -// Copyright © 2026 Mindbox. All rights reserved. -// - -import UIKit -import MindboxLogger - -protocol EmbeddedBlockActionHandling: AnyObject { - func handle(_ action: EmbeddedBlockPageAction) -} - -final class EmbeddedBlockActionRouter: EmbeddedBlockActionHandling { - - private enum ActionType { - static let openUrl = "openUrl" - } - - func handle(_ action: EmbeddedBlockPageAction) { - switch action.type { - case ActionType.openUrl: - // TODO(MOBILE-328): open the url once blocks move to the shared in-app bridge. - Logger.common(message: "[EmbeddedBlock] openUrl is not handled yet, ignoring: \(action.type)", - category: .embeddedBlocks) - default: - Logger.common(message: "[EmbeddedBlock] Unknown page action: \(action.type)", - category: .embeddedBlocks) - } - } - - // TODO: - Will reuse webView route logic from inapps -} diff --git a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockContentProviderFactory.swift b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockContentProviderFactory.swift index da423e7e..b3807980 100644 --- a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockContentProviderFactory.swift +++ b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockContentProviderFactory.swift @@ -19,18 +19,14 @@ protocol EmbeddedBlockContentProviderMaking { final class EmbeddedBlockContentProviderFactory: EmbeddedBlockContentProviderMaking { private let resolver: EmbeddedBlockResolving - private let actionHandler: EmbeddedBlockActionHandling - init(resolver: EmbeddedBlockResolving, - actionHandler: EmbeddedBlockActionHandling) { + init(resolver: EmbeddedBlockResolving) { self.resolver = resolver - self.actionHandler = actionHandler } func makeProvider(id: String) -> EmbeddedBlockWebViewProvider { EmbeddedBlockWebViewProvider(id: id, resolver: resolver, - actionHandler: actionHandler, - makePage: { EmbeddedBlockWebViewPage(content: $0) }) + makePage: { EmbeddedBlockWebViewPage(id: $0, content: $1) }) } } diff --git a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift index 58ae4db5..676a498d 100644 --- a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift +++ b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift @@ -55,7 +55,7 @@ final class EmbeddedBlockResolver: EmbeddedBlockResolving { /// The stories feed page on static hosting. Hardcoded for now: once the admin panel config /// arrives, the address will come from there together with the id → content mapping. - private static let storiesPageURL = "https://mobile-static.mindbox.ru/beta/inapps/webview/content/stories.html" + private static let storiesPageURL = "https://mobile-static-staging.mindbox.ru/inapps/webview/content/stories.html" private let load: EmbeddedBlockContentLoading private let overrides: EmbeddedBlockContentOverriding diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageHosting.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageHosting.swift index ff573be8..3934372e 100644 --- a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageHosting.swift +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageHosting.swift @@ -10,29 +10,37 @@ import UIKit /// The embedded block page — everything the provider needs from the web view. /// -/// The single seam inside the block and the single place where WebKit lives: this is what lets the -/// translation of page messages into block states be tested without a real web view and without -/// the network. +/// The single seam inside the block and the single place where WebKit lives: this is what lets +/// the translation of page reports into block states be tested without a real web view and +/// without the network. protocol EmbeddedBlockPageHosting: AnyObject { /// The page view. The provider hands it to the container as the block content. var view: UIView { get } - /// Messages from the page. Delivered on the main thread. - var onMessage: ((EmbeddedBlockPageMessage) -> Void)? { get set } + /// The page rendered `count` pieces of content. Fires once per load. A count of `0` means + /// the page is alive and correct and has nothing to show. Delivered on the main thread. + var onContentRendered: ((Int) -> Void)? { get set } - /// The page failed to load — connection, domain, a cancelled navigation. That is all navigation - /// reports: whether the page is ready is decided by the page itself with its `ready`. + /// The page failed to load — connection, domain, a bad address. That is all navigation + /// reports: whether there is anything to show is decided by the page itself. /// Delivered on the main thread. var onLoadFailure: (() -> Void)? { get set } - /// The document has loaded. That does not mean the block is ready — the page may be empty or - /// broken — so the regular path ignores this signal: the only listener is the debug readiness - /// override for pages without the web contract. Delivered on the main thread. + /// The document has loaded. That does not mean the block has content — the page may be empty + /// or broken — so the regular path ignores this signal: the only listener is the debug + /// readiness override for pages without the web contract. Delivered on the main thread. var onLoadFinish: (() -> Void)? { get set } + /// Whether the block is on screen for the user. The provider keeps it current; the page's + /// bridge reads it before doing anything on the user's behalf, such as opening a link. + var isUserPresent: Bool { get set } + func load() + /// Starts the page over. Used when a block that already resolved is asked to load again. + func reload() + /// Stops the loading. The page and its bridge stay in place: the block may come back into the /// window, and then the already rendered page is shown again without a reload. func cancel() diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageMessage.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageMessage.swift deleted file mode 100644 index edc13a94..00000000 --- a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageMessage.swift +++ /dev/null @@ -1,94 +0,0 @@ -// -// EmbeddedBlockPageMessage.swift -// Mindbox -// -// Created by vailence on 03.08.2026. -// Copyright © 2026 Mindbox. All rights reserved. -// - -import CoreGraphics -import Foundation - -/// What the embedded block page reports to the native side. -/// -/// The core parses only the core layer — `ready`, `heightChanged` and `empty`, which every block -/// needs. Everything else with a valid envelope goes to the mechanic as an `action`: the core does -/// not know, and must not know, the vocabulary of a particular mechanic. -/// -/// The format is our own and minimal for now: the page sends `{"type": ..., ...}`. Converging with -/// the shared in-app JS bridge (`MindboxWebBridge`) is a separate task; until then this parsing does -/// not need to be touched. -enum EmbeddedBlockPageMessage: Equatable { - - /// The page has rendered and asks the container to become `height` points tall. - case ready(height: CGFloat) - - /// The page re-measured itself after it was shown — for example, more content was loaded. - case heightChanged(height: CGFloat) - - /// The page has nothing to show — for example, the block is turned off in the admin panel. This - /// is not an error. - case empty - - /// An action beyond the core layer — its meaning is known to the block mechanic. - case action(EmbeddedBlockPageAction) - - /// The message body arrives from WebKit as `Any`. A string is parsed as JSON, a dictionary is - /// taken as is: the page may send either one, and there is no reason to fail on the message - /// shape here. - init?(body: Any) { - let payload: [String: Any] - - if let dictionary = body as? [String: Any] { - payload = dictionary - } else if let json = body as? String, - let data = json.data(using: .utf8), - let decoded = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - payload = decoded - } else { - return nil - } - - guard let type = payload["type"] as? String else { - return nil - } - - switch type { - case "ready": - guard let height = EmbeddedBlockPageMessage.height(from: payload) else { return nil } - self = .ready(height: height) - case "heightChanged": - guard let height = EmbeddedBlockPageMessage.height(from: payload) else { return nil } - self = .heightChanged(height: height) - case "empty": - self = .empty - default: - self = .action(EmbeddedBlockPageAction(type: type, payload: payload)) - } - } - - /// JS gives a number as a `Double`, but whole values may also arrive as an `Int` — take both. - private static func height(from payload: [String: Any]) -> CGFloat? { - if let height = payload["height"] as? Double { - return CGFloat(height) - } - - if let height = payload["height"] as? Int { - return CGFloat(height) - } - - return nil - } -} - -/// The envelope of an action the core does not parse but passes to the mechanic: the type and the -/// whole message payload as is. -struct EmbeddedBlockPageAction: Equatable { - - let type: String - let payload: [String: Any] - - static func == (lhs: EmbeddedBlockPageAction, rhs: EmbeddedBlockPageAction) -> Bool { - lhs.type == rhs.type && (lhs.payload as NSDictionary).isEqual(to: rhs.payload) - } -} diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift index d079d848..089a8023 100644 --- a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift @@ -12,144 +12,229 @@ import MindboxLogger /// The embedded block page in a WKWebView. /// -/// The web view comes from `InAppWebViewFactory` — the same place where in-app web views are -/// configured: the block gets the same user agent and the same `WKWebsiteDataStore`, and therefore -/// a shared HTTP cache. +/// Speaks the same bridge as an in-app: same envelope, same handler name, same handler set. The +/// page therefore gets everything an in-app page has — logging, local state, links, operations — +/// without a line of block-specific code, and a page can be written once for both. +/// +/// The web view comes from `InAppWebViewFactory` — the same place in-app web views are +/// configured — so the block shares their user agent, their `WKWebsiteDataStore` and therefore +/// their HTTP cache. final class EmbeddedBlockWebViewPage: NSObject, EmbeddedBlockPageHosting { - /// The handler name is our own until blocks move to the shared in-app bridge. - private enum Constants { - static let handlerName = "mindboxEmbeddedBlock" - } - let webView: WKWebView var view: UIView { webView } - var onMessage: ((EmbeddedBlockPageMessage) -> Void)? + var onContentRendered: ((Int) -> Void)? var onLoadFailure: (() -> Void)? var onLoadFinish: (() -> Void)? - private let content: EmbeddedBlockWebContent + /// Set by the provider. A block that left the window keeps its page alive, and that page can + /// still deliver whatever its `setTimeout` scheduled — nothing the user did stands behind + /// such a message. + var isUserPresent = true - init(content: EmbeddedBlockWebContent, webView: WKWebView = InAppWebViewFactory.make()) { + private let id: String + private let content: EmbeddedBlockWebContent + private let bridge: MindboxWebBridge + private let actionRegistry: WebBridgeActionRegistry + + init(id: String, + content: EmbeddedBlockWebContent, + webView: WKWebView = InAppWebViewFactory.make(), + actionRegistry: WebBridgeActionRegistry + = WebBridgeActionRegistry(handlers: WebBridgeActionHandlerFactory.makeHandlers())) { + self.id = id self.content = content self.webView = webView + self.bridge = MindboxWebBridge(webView: webView) + self.actionRegistry = actionRegistry super.init() setUpWebView() - attachBridge() + bridge.messageDelegate = self + bridge.navigationDelegate = self + } + + deinit { + actionRegistry.tearDown() } func load() { switch content.source { case .url(let url): - webView.load(URLRequest(url: url)) + bridge.updateContentURL(url) + // The navigation has to be handed to the bridge: until this exact document commits, + // every script message is treated as a leftover from a previous owner of the web + // view. Without this the page's messages are dropped in silence and the block waits + // out its whole budget with no error anywhere. + bridge.expectContentNavigation(webView.load(URLRequest(url: url))) case .html(let html): - // A page supplied as markup has an about:blank origin, so it will have neither localStorage nor network requests to its own domain. This case will change or be removed entirely in (MOBILE-328) - webView.loadHTMLString(html, baseURL: nil) + bridge.updateContentURL(nil) + // Markup has an about:blank origin, so no localStorage and no requests to its own + // domain. Debug scenarios only. + bridge.expectContentNavigation(webView.loadHTMLString(html, baseURL: nil)) } } + func reload() { + bridge.expectContentNavigation(webView.reload()) + } + func cancel() { webView.stopLoading() } private func setUpWebView() { - webView.navigationDelegate = self - - // The background is transparent: the app background, not a white sheet, should show through - // the gaps in the content. + // The background is transparent: the app background, not a white sheet, should show + // through the gaps in the content. webView.isOpaque = false webView.backgroundColor = .clear webView.scrollView.backgroundColor = .clear - // The container height equals the content height, so there is nothing to scroll vertically — - // otherwise the block would bounce under the finger on every horizontal swipe. + // The container height equals the content height, so there is nothing to scroll + // vertically — otherwise the block would bounce under the finger on every horizontal + // swipe. webView.scrollView.bounces = false webView.scrollView.alwaysBounceVertical = false webView.scrollView.showsVerticalScrollIndicator = false webView.scrollView.contentInsetAdjustmentBehavior = .never } +} - private func attachBridge() { - let controller = webView.configuration.userContentController - // Idempotent: the web view may come from reuse and carry a handler with this name from its - // previous owner. - controller.removeScriptMessageHandler(forName: Constants.handlerName) - // WKUserContentController holds the handler strongly, so a weak proxy goes into it — - // otherwise the page and the web view would never be released. - controller.add(EmbeddedBlockWebViewMessageProxy(receiver: self), name: Constants.handlerName) - } +// MARK: - WebBridgeHost - fileprivate func receive(body: Any) { - guard let message = EmbeddedBlockPageMessage(body: body) else { - Logger.common(message: "[EmbeddedBlock] Unknown page message: \(body)", category: .embeddedBlocks) - return +extension EmbeddedBlockWebViewPage: WebBridgeHost { + + var contentId: String { id } + + var logCategory: LogCategory { .embeddedBlocks } + + /// A block has none: tags belong to an in-app show. + var tags: [String: String]? { nil } + + /// The block draws inside the host's own hierarchy and owns no controller, so the search starts + /// at the window's root — and does not stop there. Whatever is on top is what can present: a + /// root that already presents something refuses to present anything else, and the block sitting + /// inside a modal screen is exactly that case. Stopping at the root would leave the tap with no + /// Safari and the page with no answer. + var presentingViewController: UIViewController? { + guard var presenter = view.window?.rootViewController else { return nil } + + while let presented = presenter.presentedViewController { + presenter = presented } - onMessage?(message) + return presenter } -} -/// Navigation only judges its own business: the load failed or the document arrived. Block -/// readiness does not follow from that — it is declared by the page itself with its `ready`, and -/// the only listener of a loaded document is the debug readiness override. -extension EmbeddedBlockWebViewPage: WKNavigationDelegate { + func send(_ message: BridgeMessage) { + bridge.send(message) + } - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - onLoadFinish?() + func makeStartPayload() -> JSONValue { + WebViewStartPayloadBuilder(contentId: id, + operation: nil, + // The configuration entry goes here once the resolver reads + // one; today the block address is still hardcoded. + customParams: nil, + insetsSource: view, + logError: { [id] message in + Logger.common(message: "[EmbeddedBlock] Block '\(id)': \(message)", + level: .error, + category: .embeddedBlocks) + }).build() } +} + +// MARK: - WebBridgeContentHosting - func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { - reportLoadFailure(error, phase: "navigation") +extension EmbeddedBlockWebViewPage: WebBridgeContentHosting { + + func bridgeDidRenderContent(count: Int) { + onContentRendered?(count) } +} + +// MARK: - WebBridgeMessageDelegate - func webView(_ webView: WKWebView, - didFailProvisionalNavigation navigation: WKNavigation!, - withError error: Error) { - reportLoadFailure(error, phase: "provisional navigation") +extension EmbeddedBlockWebViewPage: WebBridgeMessageDelegate { + + func webBridge(_ bridge: MindboxWebBridge, didReceiveBridgeMessage message: BridgeMessage) { + guard message.type == .request else { return } + + // An action nobody owns is not an error: the web vocabulary is allowed to be newer than + // the SDK. + guard actionRegistry.handle(message, host: self) else { + Logger.common(message: "[EmbeddedBlock] Block '\(id)': unknown action '\(message.action)'", + category: .embeddedBlocks) + return + } } } -private extension EmbeddedBlockWebViewPage { +// MARK: - WebBridgeNavigationDelegate + +/// Navigation only judges its own business: the load failed or the document arrived. Block +/// readiness does not follow from that — the page declares it by reporting the content it +/// rendered. +extension EmbeddedBlockWebViewPage: WebBridgeNavigationDelegate { - /// A cancelled navigation is not a load failure, and passing it off as one is not allowed: the - /// block would collapse out of nowhere and stay a zero-height hole until the end of the screen's - /// life. WebKit returns `NSURLErrorCancelled` in two perfectly ordinary cases: the navigation - /// was superseded by the next one — a client-side redirect, the page will load on its own — and - /// the navigation was stopped by us, by calling `cancel()` on a block that went off screen. The - /// second case also arrives after the block is back in the window, so the provider will not - /// filter it out with its `isStarted`. - func reportLoadFailure(_ error: Error, phase: String) { + func webBridge(_ bridge: MindboxWebBridge, didStartProvisionalNavigation url: URL?) { + Logger.common(message: "[EmbeddedBlock] Block '\(id)': loading \(url?.absoluteString ?? "unknown")", + category: .embeddedBlocks) + } + + func webBridge(_ bridge: MindboxWebBridge, didFinishNavigation url: URL?) { + onLoadFinish?() + } + + /// A cancelled navigation is not a load failure, and passing it off as one is not allowed: + /// the block would collapse out of nowhere and stay a zero-height hole until the end of the + /// screen's life. WebKit returns `NSURLErrorCancelled` in two perfectly ordinary cases: the + /// navigation was superseded by the next one — a client-side redirect, the page will load on + /// its own — and the navigation was stopped by us, by calling `cancel()` on a block that went + /// off screen. The second case also arrives after the block is back in the window, so the + /// provider will not filter it out with its own state. + func webBridge(_ bridge: MindboxWebBridge, didFailProvisionalNavigation url: URL?, error: Error) { let error = error as NSError guard !(error.domain == NSURLErrorDomain && error.code == NSURLErrorCancelled) else { - Logger.common(message: "[EmbeddedBlock] Page \(phase) was cancelled, not a load failure", + Logger.common(message: "[EmbeddedBlock] Block '\(id)': navigation was cancelled, not a load failure", category: .embeddedBlocks) return } - Logger.common(message: "[EmbeddedBlock] Page \(phase) failed: \(error.localizedDescription)", + Logger.common(message: "[EmbeddedBlock] Block '\(id)': navigation failed: \(error.localizedDescription)", category: .embeddedBlocks) onLoadFailure?() } -} - -/// A weak layer between `WKUserContentController` and the page. -private final class EmbeddedBlockWebViewMessageProxy: NSObject, WKScriptMessageHandler { - private weak var receiver: EmbeddedBlockWebViewPage? - - init(receiver: EmbeddedBlockWebViewPage) { - self.receiver = receiver - super.init() + func webBridge(_ bridge: MindboxWebBridge, + decidePolicyFor url: URL?, + navigationType: WKNavigationType, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + switch navigationType { + case .other, .reload, .backForward, .formSubmitted, .formResubmitted: + decisionHandler(.allow) + case .linkActivated: + // A block is a piece of the host's own layout: letting a tap replace the feed with + // the destination page in place would be a dead end with no way back. Links belong + // in `openLink`, which opens them where a link should open. + Logger.common(message: "[EmbeddedBlock] Block '\(id)': blocked in-place navigation to \(url?.absoluteString ?? "unknown")", + category: .embeddedBlocks) + decisionHandler(.cancel) + @unknown default: + decisionHandler(.allow) + } } - func userContentController(_ userContentController: WKUserContentController, - didReceive message: WKScriptMessage) { - receiver?.receive(body: message.body) + func webBridge(_ bridge: MindboxWebBridge, didReceiveHTTPError url: String?) { + // Reported only. Healing a poisoned cache entry is the in-app path's job today; giving + // the block the same treatment is its own change. + Logger.common(message: "[EmbeddedBlock] Block '\(id)': subresource error for \(url ?? "nil")", + category: .embeddedBlocks) } } diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift index bc7a3050..19d11366 100644 --- a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift @@ -27,9 +27,8 @@ final class EmbeddedBlockWebViewProvider { private let id: String private let resolver: EmbeddedBlockResolving - private let actionHandler: EmbeddedBlockActionHandling private let readinessOverrides: EmbeddedBlockReadinessOverriding - private let makePage: (EmbeddedBlockWebContent) -> EmbeddedBlockPageHosting + private let makePage: (String, EmbeddedBlockWebContent) -> EmbeddedBlockPageHosting /// The page survives restarts: the container starts and stops the block by visibility, and /// there is no reason to recreate the web view on every return to the window. @@ -50,14 +49,15 @@ final class EmbeddedBlockWebViewProvider { /// reload — the number shows that the answer belongs to a past attempt and must be thrown away. private var loadGeneration = 0 + /// Whether the page has already reported its content for the current load. + private var didReportContent = false + init(id: String, resolver: EmbeddedBlockResolving, - actionHandler: EmbeddedBlockActionHandling, readinessOverrides: EmbeddedBlockReadinessOverriding = EmbeddedBlockReadinessOverrides.shared, - makePage: @escaping (EmbeddedBlockWebContent) -> EmbeddedBlockPageHosting) { + makePage: @escaping (String, EmbeddedBlockWebContent) -> EmbeddedBlockPageHosting) { self.id = id self.resolver = resolver - self.actionHandler = actionHandler self.readinessOverrides = readinessOverrides self.makePage = makePage @@ -79,6 +79,8 @@ final class EmbeddedBlockWebViewProvider { // The outcome is not reset: it is a property of the page, not of being in the window. // Otherwise every pass of the block across the screen would cost a full reload. loadGeneration += 1 + // The page stays alive off screen, so it has to be told that nobody is looking. + page?.isUserPresent = false page?.cancel() } @@ -89,7 +91,7 @@ final class EmbeddedBlockWebViewProvider { // The previous page is no longer relevant — detach it from us first so that its late // messages do not end up in the new attempt. - page?.onMessage = nil + page?.onContentRendered = nil page?.onLoadFailure = nil page?.onLoadFinish = nil page?.cancel() @@ -97,47 +99,47 @@ final class EmbeddedBlockWebViewProvider { isStarted = false outcome = nil + didReportContent = false loadGeneration += 1 start(forceRefresh: true) } - func handle(_ message: EmbeddedBlockPageMessage) { + /// The page reports what it rendered. Once per load: a repeat is a page bug, and acting on + /// it twice would let a block that already collapsed come back. + func handleContentRendered(count renderedCount: Int) { guard isStarted else { return } - switch message { - case .ready(let height): - apply(height: height) - case .heightChanged(let height): - // The host owns the height — the message stays in the page contract but affects - // nothing on the native side. - Logger.common(message: "[EmbeddedBlock] Ignored heightChanged(\(height)): the host owns the container height", category: .embeddedBlocks) - case .empty: - outcome = .empty - onStateChange?(.empty) - case .action(let action): - // The block is not on screen, but the page is alive and keeps working — for example, - // delivering what its `setTimeout` scheduled. Its actions must not run at this moment: - // not a single user touch stands behind an invisible block, and `openUrl` would take - // the user out of the app out of nowhere. - guard isShown else { - Logger.common(message: "[EmbeddedBlock] Block '\(id)': ignored action '\(action.type)' from a block that is not shown", - category: .embeddedBlocks) - return - } + guard !didReportContent else { + Logger.common(message: "[EmbeddedBlock] Block '\(id)': ignored a repeated contentRendered(\(renderedCount))", + category: .embeddedBlocks) + return + } + + didReportContent = true - actionHandler.handle(action) + // The number the page reported, not the size of a collection. + guard renderedCount > 0 else { + // Alive, correct, and with nothing to show. Not a failure — the block simply gives + // its space back. + Logger.common(message: "[EmbeddedBlock] Block '\(id)': page rendered nothing", + category: .embeddedBlocks) + finish(with: .empty) + return } + + Logger.common(message: "[EmbeddedBlock] Block '\(id)': page rendered \(renderedCount)", + category: .embeddedBlocks) + finish(with: .ready) } func handleLoadFailure() { guard isStarted else { return } - outcome = .failed - onStateChange?(.failed) + finish(with: .failed) } - /// While there is no outcome, the page is still loading — its messages belong to a live block. + /// While there is no outcome, the page is still loading — the user is looking at a live block. private var isShown: Bool { outcome == nil || outcome == .ready } @@ -147,11 +149,10 @@ final class EmbeddedBlockWebViewProvider { func handleLoadFinish() { guard isStarted, !isReady, readinessOverrides.treatsLoadedPageAsReady else { return } - Logger.common(message: "[EmbeddedBlock] Block '\(id)': debug readiness is ON, showing the loaded page without a 'ready' from it", + Logger.common(message: "[EmbeddedBlock] Block '\(id)': debug readiness is ON, showing the loaded page without a contentRendered from it", level: .default, category: .embeddedBlocks) - outcome = .ready - onStateChange?(.ready) + finish(with: .ready) } private func start(forceRefresh: Bool) { @@ -170,8 +171,10 @@ final class EmbeddedBlockWebViewProvider { onStateChange?(.loading) // A new attempt has started: how the previous one ended no longer matters — including for - // deciding whether to run the page's actions. + // deciding whether the page may act on the user's behalf. outcome = nil + didReportContent = false + page?.isUserPresent = true if let page { page.load() @@ -186,11 +189,11 @@ final class EmbeddedBlockWebViewProvider { case .empty: Logger.common(message: "[EmbeddedBlock] Block id '\(self.id)' resolved as empty", category: .embeddedBlocks) - self.onStateChange?(.empty) + self.finish(with: .empty) case .content(let content): - let page = self.makePage(content) - page.onMessage = { [weak self] message in - self?.handle(message) + let page = self.makePage(self.id, content) + page.onContentRendered = { [weak self] count in + self?.handleContentRendered(count: count) } page.onLoadFailure = { [weak self] in self?.handleLoadFailure() @@ -204,19 +207,11 @@ final class EmbeddedBlockWebViewProvider { } } - private func apply(height: CGFloat) { - // The page reports "nothing to show" with an explicit `empty`, so zero height means - // broken layout, that is, a failure. - guard height > 0 else { - Logger.common(message: "[EmbeddedBlock] Block '\(id)': page reported zero height, treating as broken", category: .embeddedBlocks) - outcome = .empty - onStateChange?(.failed) - return - } - - Logger.common(message: "[EmbeddedBlock] Block '\(id)': page is ready", category: .embeddedBlocks) - outcome = .ready - onStateChange?(.ready) + /// Records the outcome, tells the container, and keeps the page's view of the user in sync. + private func finish(with outcome: EmbeddedBlockState) { + self.outcome = outcome + page?.isUserPresent = isShown + onStateChange?(outcome) } } diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/BridgeMessage.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/BridgeMessage.swift index 8c224fac..d2a6abf5 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/BridgeMessage.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/BridgeMessage.swift @@ -288,7 +288,7 @@ public struct BridgeMessage: Codable { /// JS sends a message to native SDK logger. /// - /// Triggers ``WebViewAction/onLog(message:)``. + /// Handled by ``LogActionHandler``, which journals it under the host's own log category. /// /// - Payload: /// ```json @@ -300,6 +300,61 @@ public struct BridgeMessage: Codable { /// ``` case log + /// JS reports how much content it rendered, once per load. + /// + /// Handled by ``ContentRenderedActionHandler`` and delivered to hosts that listen for + /// content events. A count of `0` is a valid outcome, not a failure: the page is alive + /// and correct and has nothing to show, and a surface that reserves space for it should + /// give that space back. + /// + /// This is deliberately not ``init``, which is welded to presenting a window and has no + /// such outcome. + /// + /// - Payload: + /// ```json + /// { "count": 5 } + /// ``` + /// - Response: + /// ```json + /// { "success": true } + /// ``` + case contentRendered + + /// JS asks which of these in-apps currently pass targeting. + /// + /// Answered from targeting the SDK has already computed, without going to the network: + /// the page gives up on the answer after three seconds and renders nothing rather than + /// wait. The reply keeps the ids that pass, in the order they were asked about. + /// + /// - Payload: + /// ```json + /// { "inappIds": ["id-1", "id-2"] } + /// ``` + /// - Response: + /// ```json + /// { "inappIds": ["id-1"] } + /// ``` + case checkInappsTargeting + + /// JS asks for an in-app to be shown by id. + /// + /// `params` is merged into that in-app's start payload and overwrites whatever it + /// collides with. The SDK neither validates nor limits it: what the page sends is what + /// the page gets, and avoiding collisions is the page's business. + /// + /// `index` and `sourceInappId` describe where the request came from and are journalled, + /// not passed on — the page already puts everything it needs into `params`. + /// + /// - Payload: + /// ```json + /// { "inappId": "...", "index": 0, "sourceInappId": "...", "params": { ... } } + /// ``` + /// - Response: + /// ```json + /// { "success": true } + /// ``` + case showInApp + // MARK: JS → Native: Operations /// JS triggers an asynchronous Mindbox operation (fire-and-forget). @@ -557,6 +612,16 @@ public struct BridgeMessage: Codable { case .close, .`init`, .click, .hide, .log: return false + // Answers for itself so a payload without a usable count is refused outright. The + // blanket success would otherwise claim the SDK acted on a number it never got. + case .contentRendered: + return true + + // Both carry an answer the page acts on: one returns the ids that passed, the other + // reports whether the show was accepted. + case .checkInappsTargeting, .showInApp: + return true + // Operations case .asyncOperation, .syncOperation: return true @@ -603,6 +668,43 @@ public struct BridgeMessage: Codable { var parsedAction: Action? { Action(rawValue: action) } + /// The payload as an object, whether JS sent it as a JSON string — the ordinary case, + /// `"{\"data\":{...},\"version\":3}"` — or as an already-decoded object. + /// + /// `nil` means neither shape parsed, which is a malformed request rather than an empty one. + var payloadObject: [String: JSONValue]? { + if case .string(let string) = payload, + let data = string.data(using: .utf8), + let object = try? JSONDecoder().decode([String: JSONValue].self, from: data) { + return object + } + + if case .object(let object) = payload { + return object + } + + return nil + } + + /// The payload as the string a handler logs or forwards verbatim. + /// + /// JS sends it as a JSON string, so that is the ordinary case. A payload that arrived + /// already decoded is re-encoded rather than described, so what a handler sees does not + /// depend on which of the two shapes the page happened to send. + var payloadString: String { + if case .string(let value) = payload { + return value + } + + if let payload, + let data = try? JSONEncoder().encode(payload), + let string = String(data: data, encoding: .utf8) { + return string + } + + return "" + } + public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(version, forKey: .version) diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/BridgeURLOpening.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/BridgeURLOpening.swift new file mode 100644 index 00000000..82b97a8b --- /dev/null +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/BridgeURLOpening.swift @@ -0,0 +1,64 @@ +// +// BridgeURLOpening.swift +// Mindbox +// +// Created by Akylbek Utekeshev on 13.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import UIKit +import MindboxLogger + +/// Handing a URL to the system. +/// +/// A seam over `UIApplication.open`, which cannot be exercised in a test: without it the +/// decision of *which* way a link is opened could only be checked by actually leaving the app. +protocol BridgeURLOpening: AnyObject { + + func open(_ url: URL, universalLinksOnly: Bool, completion: @escaping (Bool) -> Void) +} + +final class SystemURLOpener: BridgeURLOpening { + + func open(_ url: URL, universalLinksOnly: Bool, completion: @escaping (Bool) -> Void) { + let options: [UIApplication.OpenExternalURLOptionsKey: Any] = universalLinksOnly + ? [.universalLinksOnly: true] + : [:] + + UIApplication.shared.open(url, options: options, completionHandler: completion) + } +} + +extension BridgeURLOpening { + + /// Opens through the system and answers the page with the outcome. + /// + /// Shared by `openLink` and by `settings.open`, which has always reached the system this + /// same way. The log still says "navigate" for both because that is the text it has always + /// carried — worth correcting, but not while the point of the change is that nothing moved. + func open(_ url: URL, answering message: BridgeMessage, host: WebBridgeHost) { + DispatchQueue.main.async { [weak host] in + self.open(url, universalLinksOnly: false) { success in + DispatchQueue.main.async { + guard let host else { return } + + if success { + Logger.common( + message: "[WebView] navigate: successfully opened \(url.absoluteString)", + level: .info, + category: host.logCategory + ) + host.respondSuccess(to: message) + } else { + Logger.common( + message: "[WebView] navigate: failed to open \(url.absoluteString)", + level: .default, + category: host.logCategory + ) + host.respondError("Failed to open URL: '\(url.absoluteString)'", to: message) + } + } + } + } + } +} diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/CheckInappsTargetingActionHandler.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/CheckInappsTargetingActionHandler.swift new file mode 100644 index 00000000..1756139e --- /dev/null +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/CheckInappsTargetingActionHandler.swift @@ -0,0 +1,51 @@ +// +// CheckInappsTargetingActionHandler.swift +// Mindbox +// +// Created by Akylbek Utekeshev on 13.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation +import MindboxLogger + +/// Answers which of the in-apps the page asked about currently pass targeting. +/// +/// **Not implemented yet: every id is let through.** The page can therefore render its whole +/// feed, which is what makes the rest of the contract testable, but nothing here is filtering +/// anything. +final class CheckInappsTargetingActionHandler: WebBridgeActionHandler { + + let actions: Set = [.checkInappsTargeting] + + func handle(_ message: BridgeMessage, host: WebBridgeHost) { + guard case .array(let requested)? = message.payloadObject?["inappIds"] else { + host.respondError("Invalid payload: missing 'inappIds' array", to: message) + return + } + + let ids = requested.compactMap { value -> String? in + guard case .string(let id) = value else { return nil } + return id + } + + // TODO: Answer from the targeting the SDK has already computed, without touching the + // network — the page gives up after three seconds and renders nothing rather than wait. + // - the ids are looked up in `inappFilterService.validInapps`; + // - `targetingChecker.check(targeting:)` is synchronous and does no I/O: it reads + // `checkedSegmentations` / `geoModels` that a previous pass already warmed; + // - a cold cache answers false, which is a feed that renders nothing. That is accepted + // rather than waited out, but it deserves a log of its own — silence here is + // indistinguishable from "nothing passed"; + // - an id absent from the config is excluded rather than let through, and named in the + // log: "why is my story missing" is the first question this will be asked; + // - ORDER MATTERS: the page maps the answer back onto its own cards. + // - THREAD SAFETY: `checkedSegmentations` is written from `InappMapper`'s queue, and + // this would be the first main-thread reader. Settle that together with the logic. + Logger.common(message: "[WebView] checkInappsTargeting is not implemented: letting all \(ids.count) id(s) through", + level: .default, + category: host.logCategory) + + host.respond(to: message, payload: .object(["inappIds": .array(ids.map { .string($0) })])) + } +} diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/ContentRenderedActionHandler.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/ContentRenderedActionHandler.swift new file mode 100644 index 00000000..374261b6 --- /dev/null +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/ContentRenderedActionHandler.swift @@ -0,0 +1,56 @@ +// +// ContentRenderedActionHandler.swift +// Mindbox +// +// Created by Akylbek Utekeshev on 13.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation +import MindboxLogger + +/// The page reports how much content it put on screen. +/// +/// Registered everywhere, like every handler. Only a surface that reserves space for content +/// listens today — a block gives its space back when the count is zero — but nothing here is +/// specific to one, so an in-app that wants the same signal conforms and starts receiving it. +final class ContentRenderedActionHandler: WebBridgeActionHandler { + + let actions: Set = [.contentRendered] + + func handle(_ message: BridgeMessage, host: WebBridgeHost) { + guard let renderedCount = Self.count(in: message) else { + host.respondError("Invalid payload: missing or non-numeric 'count'", to: message) + return + } + + // A count of items the page drew, not the size of a collection: a negative number is + // a page bug worth naming rather than clamping. + guard renderedCount >= 0 else { + host.respondError("Invalid payload: 'count' must not be negative, got \(renderedCount)", to: message) + return + } + + guard let content = host as? WebBridgeContentHosting else { + Logger.common(message: "[WebView] Bridge: contentRendered(\(renderedCount)) from '\(host.contentId)' has nobody listening here, ignoring", + category: host.logCategory) + host.respondSuccess(to: message) + return + } + + content.bridgeDidRenderContent(count: renderedCount) + host.respondSuccess(to: message) + } + + /// JS gives a number as a `Double`, but a whole value may also arrive as an `Int`. + private static func count(in message: BridgeMessage) -> Int? { + switch message.payloadObject?["count"] { + case .int(let count): + return count + case .double(let count): + return Int(exactly: count.rounded()) + default: + return nil + } + } +} diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/HapticActionHandler.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/HapticActionHandler.swift new file mode 100644 index 00000000..1fe70ed9 --- /dev/null +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/HapticActionHandler.swift @@ -0,0 +1,54 @@ +// +// HapticActionHandler.swift +// Mindbox +// +// Created by Akylbek Utekeshev on 13.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation + +/// Haptic feedback for the page. +/// +/// Owns the engine outright. It has to: the service is registered `.transient`, so anyone else +/// resolving it would get a second engine — and preparing one while playing on another silently +/// does nothing. +final class HapticActionHandler: WebBridgeActionHandler { + + let actions: Set = [.haptic] + + /// Whether the engine was ever asked for. Teardown must not build one just to stop it: most + /// shows never touch haptics, and every one of them ends. + private var isEngineInUse = false + + private lazy var service: HapticServiceProtocol = { + isEngineInUse = true + return makeService() + }() + + private let makeService: () -> HapticServiceProtocol + + init(makeService: @escaping () -> HapticServiceProtocol + = { DI.injectOrFail(HapticServiceProtocol.self) }) { + self.makeService = makeService + } + + func handle(_ message: BridgeMessage, host: WebBridgeHost) { + service.handle(message: message) + host.respondSuccess(to: message) + } + + /// Warms the engine up so the first tap does not pay for starting it. + /// + /// Called when the page reports it is up, which is a lifecycle event rather than an action — + /// hence the door in from outside a request. + func prepare() { + service.prepare() + } + + func tearDown() { + guard isEngineInUse else { return } + + service.stopPattern() + } +} diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/LifecycleActionHandler.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/LifecycleActionHandler.swift new file mode 100644 index 00000000..b41248bb --- /dev/null +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/LifecycleActionHandler.swift @@ -0,0 +1,47 @@ +// +// LifecycleActionHandler.swift +// Mindbox +// +// Created by Akylbek Utekeshev on 13.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation +import MindboxLogger + +/// What a page says about its own life: it booted, it was tapped, it wants to be hidden or +/// closed. +/// +/// Registered everywhere, like every other handler. Whether these mean anything depends on who +/// is hosting the page — a surface that has no window to close simply does not conform, and the +/// message is journalled and dropped rather than refused. That is what lets a surface pick one +/// of these up later by adding a conformance and nothing else. +final class LifecycleActionHandler: WebBridgeActionHandler { + + let actions: Set = [.close, .`init`, .click, .hide] + + func handle(_ message: BridgeMessage, host: WebBridgeHost) { + // None of these are deferred: `RequestMessageHandler` has already answered + // `{success: true}`, so this handler only acts and never replies. + guard let lifecycle = host as? WebBridgeLifecycleHosting else { + Logger.common(message: "[WebView] Bridge: '\(message.action)' from '\(host.contentId)' has no lifecycle to reach here, ignoring", + category: host.logCategory) + return + } + + switch message.parsedAction { + case .`init`: + lifecycle.bridgeDidInit() + case .close: + lifecycle.bridgeDidRequestClose() + case .hide: + lifecycle.bridgeDidRequestHide() + case .click: + // Forwarded verbatim: what a tap means is decided above the bridge. + lifecycle.bridgeDidClick(rawPayload: message.payloadString) + default: + // Unreachable: the registry routes only what `actions` claims. + break + } + } +} diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/LocalStateActionHandler.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/LocalStateActionHandler.swift new file mode 100644 index 00000000..bdf2828e --- /dev/null +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/LocalStateActionHandler.swift @@ -0,0 +1,173 @@ +// +// LocalStateActionHandler.swift +// Mindbox +// +// Created by Akylbek Utekeshev on 13.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation +import MindboxLogger + +/// On-device key-value storage for the page. +/// +/// All three actions are deferred: each answers with the state it read or wrote, so the +/// dispatcher's blanket `{success: true}` would say nothing useful. +final class LocalStateActionHandler: WebBridgeActionHandler { + + let actions: Set = [.localStateGet, .localStateSet, .localStateInit] + + /// Resolved on first use, not at construction: a handler set is built for every show, and + /// a page that never touches storage should not pull it out of the container. + private lazy var storage: WebViewLocalStateStorageProtocol = makeStorage() + + private let makeStorage: () -> WebViewLocalStateStorageProtocol + + init(makeStorage: @escaping () -> WebViewLocalStateStorageProtocol + = { DI.injectOrFail(WebViewLocalStateStorageProtocol.self) }) { + self.makeStorage = makeStorage + } + + func handle(_ message: BridgeMessage, host: WebBridgeHost) { + switch message.parsedAction { + case .localStateGet: + get(message, host: host) + case .localStateSet: + set(message, host: host) + case .localStateInit: + initialize(message, host: host) + default: + // Unreachable: the registry routes only what `actions` claims. + break + } + } +} + +// MARK: - Actions + +private extension LocalStateActionHandler { + + func get(_ message: BridgeMessage, host: WebBridgeHost) { + guard let payload = message.payloadObject else { + host.respondError("Invalid payload", to: message) + return + } + + let keys: [String] + if case .array(let requested) = payload["data"] { + keys = requested.compactMap { if case .string(let key) = $0 { return key } else { return nil } } + } else { + keys = [] + } + + let state = storage.get(keys: keys) + + Logger.common( + message: "[WebView] localState.get keys=\(keys) → \(state.data.count) entries, version=\(state.version)", + level: .info, + category: host.logCategory + ) + + // Asking for nothing means asking for everything; asking for a key that is not there + // answers null, so the page can tell "absent" from "never asked". + var stored: [String: JSONValue] = [:] + if keys.isEmpty { + for (key, value) in state.data { + stored[key] = .string(value) + } + } else { + for key in keys { + stored[key] = state.data[key].map { .string($0) } ?? .null + } + } + + host.respond(to: message, payload: .object([ + "data": .object(stored), + "version": .int(state.version) + ])) + } + + func set(_ message: BridgeMessage, host: WebBridgeHost) { + guard let payload = message.payloadObject, + case .object(let entries) = payload["data"] else { + host.respondError("Invalid payload: missing 'data' object", to: message) + return + } + + let data = Self.storable(entries) + let state = storage.set(data: data) + + Logger.common( + message: "[WebView] localState.set \(data.count) keys → version=\(state.version)", + level: .info, + category: host.logCategory + ) + + host.respond(to: message, payload: Self.payload(for: data, version: state.version)) + } + + func initialize(_ message: BridgeMessage, host: WebBridgeHost) { + guard let payload = message.payloadObject, + case .int(let version) = payload["version"], + case .object(let entries) = payload["data"] else { + host.respondError("Invalid payload: missing 'version' or 'data'", to: message) + return + } + + let data = Self.storable(entries) + + guard let state = storage.initialize(version: version, data: data) else { + host.respondError("Version must be a positive integer, got \(version)", to: message) + return + } + + Logger.common( + message: "[WebView] localState.init version=\(version), \(data.count) keys", + level: .info, + category: host.logCategory + ) + + host.respond(to: message, payload: Self.payload(for: data, version: state.version)) + } +} + +// MARK: - Conversion + +private extension LocalStateActionHandler { + + /// Storage keeps strings, so anything richer is kept as its JSON text rather than dropped — + /// the page gets back what it put in. `null` is an erase, and stays distinct from `""`. + static func storable(_ entries: [String: JSONValue]) -> [String: String?] { + var data: [String: String?] = [:] + + for (key, value) in entries { + switch value { + case .string(let string): + data[key] = string + case .null: + data[key] = nil as String? + default: + if let encoded = try? JSONEncoder().encode(value), + let string = String(data: encoded, encoding: .utf8) { + data[key] = string + } + } + } + + return data + } + + /// Echoes back the keys the request carried, not the whole store. + static func payload(for data: [String: String?], version: Int) -> JSONValue { + var stored: [String: JSONValue] = [:] + + for (key, value) in data { + stored[key] = value.map { .string($0) } ?? .null + } + + return .object([ + "data": .object(stored), + "version": .int(version) + ]) + } +} diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/LogActionHandler.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/LogActionHandler.swift new file mode 100644 index 00000000..b568168f --- /dev/null +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/LogActionHandler.swift @@ -0,0 +1,26 @@ +// +// LogActionHandler.swift +// Mindbox +// +// Created by Akylbek Utekeshev on 13.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation +import MindboxLogger + +/// Writes what the page says into the SDK log. +/// +/// The category comes from the host rather than from here: the same handler serves an in-app +/// popup and an embedded block, and each keeps its own trail. That is the whole reason a +/// handler is given a host instead of being told which surface it is on. +final class LogActionHandler: WebBridgeActionHandler { + + let actions: Set = [.log] + + func handle(_ message: BridgeMessage, host: WebBridgeHost) { + // `log` is not deferred: `RequestMessageHandler` has already answered `{success: true}`. + // Answering again would arrive against an id JS has closed. + Logger.common(message: "[JS] \(message.payloadString)", category: host.logCategory) + } +} diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/MotionActionHandler.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/MotionActionHandler.swift new file mode 100644 index 00000000..45d6a43d --- /dev/null +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/MotionActionHandler.swift @@ -0,0 +1,131 @@ +// +// MotionActionHandler.swift +// Mindbox +// +// Created by Akylbek Utekeshev on 13.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation + +/// Device motion gestures — shake and flip — for the page. +/// +/// The only handler that also speaks unprompted: a gesture arrives from the sensors with no +/// request in hand, so the page it belongs to is remembered from the request that started +/// monitoring. Weakly — a gesture must not be what keeps a finished show alive. +final class MotionActionHandler: WebBridgeActionHandler { + + let actions: Set = [.motionStart, .motionStop] + + private weak var host: WebBridgeHost? + + /// Whether the sensors were ever asked for. Teardown must not start a service just to stop + /// it, and every show ends whether or not it ever used motion. + private var isServiceInUse = false + + private lazy var service: MotionServiceProtocol = { + isServiceInUse = true + let service = makeService() + service.onGestureDetected = { [weak self] gesture, data in + self?.report(gesture: gesture, data: data) + } + return service + }() + + private let makeService: () -> MotionServiceProtocol + + init(makeService: @escaping () -> MotionServiceProtocol + = { DI.injectOrFail(MotionServiceProtocol.self) }) { + self.makeService = makeService + } + + func handle(_ message: BridgeMessage, host: WebBridgeHost) { + self.host = host + + switch message.parsedAction { + case .motionStart: + start(message, host: host) + case .motionStop: + service.stopMonitoring() + host.respondSuccess(to: message) + default: + // Unreachable: the registry routes only what `actions` claims. + break + } + } + + /// A shake is detected by the system and arrives at the view, not at the bridge. + func handleSystemShake() { + guard isServiceInUse else { return } + + service.handleSystemShake() + } + + func tearDown() { + guard isServiceInUse else { return } + + service.stopMonitoring() + } +} + +private extension MotionActionHandler { + + func start(_ message: BridgeMessage, host: WebBridgeHost) { + guard let payload = message.payloadObject else { + host.respondError("Invalid payload: missing 'gestures' array", to: message) + return + } + + guard case .array(let requested) = payload["gestures"] else { + host.respondError("Invalid payload: 'gestures' must be an array", to: message) + return + } + + var gestures = Set() + for item in requested { + if case .string(let name) = item, let gesture = MotionGesture(rawValue: name) { + gestures.insert(gesture) + } + } + + guard !gestures.isEmpty else { + host.respondError("No valid gestures provided. Available: shake, flip", to: message) + return + } + + let result = service.startMonitoring(gestures: gestures) + + guard !result.allUnavailable else { + host.respondError( + "No sensors available for requested gestures: \(result.unavailable.map(\.rawValue).joined(separator: ", "))", + to: message + ) + return + } + + // A partial start is still a start: the page is told which gestures it will not get + // rather than being refused everything it asked for. + var payloadBack: [String: JSONValue] = ["success": .bool(true)] + if !result.unavailable.isEmpty { + payloadBack["unavailable"] = .array(result.unavailable.map { .string($0.rawValue) }) + } + + host.respond(to: message, payload: .object(payloadBack)) + } + + /// Pushed as a request, not a response: nothing asked for this gesture, it just happened. + func report(gesture: MotionGesture, data: [String: Any]) { + guard let host else { return } + + var payload: [String: JSONValue] = ["gesture": .string(gesture.rawValue)] + for (key, value) in data { + if let jsonValue = JSONValue(any: value) { + payload[key] = jsonValue + } + } + + host.send(BridgeMessage(type: .request, + action: BridgeMessage.Action.motionEvent.rawValue, + payload: .object(payload))) + } +} diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/OpenLinkActionHandler.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/OpenLinkActionHandler.swift new file mode 100644 index 00000000..cbc5c5cd --- /dev/null +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/OpenLinkActionHandler.swift @@ -0,0 +1,103 @@ +// +// OpenLinkActionHandler.swift +// Mindbox +// +// Created by Akylbek Utekeshev on 13.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import UIKit +import SafariServices +import MindboxLogger + +/// Opens a link the page asked for. +/// +/// A web address is offered to the system as a universal link first, so an app that owns the +/// domain wins; only when nothing claims it does the link open in-app in Safari. Anything else — +/// `tel:`, `mailto:`, a deep link — goes straight to the system, which is the only thing that +/// knows what to do with it. +final class OpenLinkActionHandler: WebBridgeActionHandler { + + let actions: Set = [.openLink] + + private let urlOpener: BridgeURLOpening + + init(urlOpener: BridgeURLOpening = SystemURLOpener()) { + self.urlOpener = urlOpener + } + + func handle(_ message: BridgeMessage, host: WebBridgeHost) { + guard case .string(let urlString)? = message.payloadObject?["url"], !urlString.isEmpty else { + host.respondError("Invalid payload: missing or empty 'url' field", to: message) + return + } + + guard let url = URL(string: urlString) else { + host.respondError("Invalid URL: '\(urlString)' could not be parsed", to: message) + return + } + + switch url.scheme?.lowercased() { + case "http", "https": + Logger.common(message: "[WebView] navigate: trying universal link first for \(urlString)", + level: .info, + category: host.logCategory) + openAsUniversalLinkOrSafari(url: url, message: message, host: host) + default: + Logger.common(message: "[WebView] navigate: opening via UIApplication \(urlString)", + level: .info, + category: host.logCategory) + urlOpener.open(url, answering: message, host: host) + } + } +} + +private extension OpenLinkActionHandler { + + /// The opener is captured, not `self`: handing the URL to the system must not depend on the + /// handler still being around, exactly as it did not depend on the view before. The page is + /// held weakly for the opposite reason — a show that ended gets no answer, and waiting on + /// the system must not be what keeps it alive. + func openAsUniversalLinkOrSafari(url: URL, message: BridgeMessage, host: WebBridgeHost) { + let opener = urlOpener + + DispatchQueue.main.async { [weak host] in + opener.open(url, universalLinksOnly: true) { opened in + DispatchQueue.main.async { + guard let host else { return } + + guard !opened else { + Logger.common(message: "[WebView] navigate: opened as universal link \(url.absoluteString)", + level: .info, + category: host.logCategory) + host.respondSuccess(to: message) + return + } + + Logger.common(message: "[WebView] navigate: not a universal link, falling back to SFSafariViewController for \(url.absoluteString)", + level: .info, + category: host.logCategory) + Self.openInSafari(url: url, message: message, host: host) + } + } + } + } + + static func openInSafari(url: URL, message: BridgeMessage, host: WebBridgeHost) { + guard let presenter = host.presentingViewController else { + Logger.common(message: "[WebView] navigate: no presenting view controller found", + level: .default, + category: host.logCategory) + host.respondError("Failed to open URL: no presenting view controller", to: message) + return + } + + let safari = SFSafariViewController(url: url) + presenter.present(safari, animated: true) { + Logger.common(message: "[WebView] navigate: SFSafariViewController presented for \(url.absoluteString)", + level: .info, + category: host.logCategory) + host.respondSuccess(to: message) + } + } +} diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/OperationActionHandler.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/OperationActionHandler.swift new file mode 100644 index 00000000..c303795b --- /dev/null +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/OperationActionHandler.swift @@ -0,0 +1,186 @@ +// +// OperationActionHandler.swift +// Mindbox +// +// Created by Akylbek Utekeshev on 13.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation +import MindboxLogger + +/// Mindbox operations the page asks the SDK to send. +/// +/// Two shapes of the same thing: `asyncOperation` is queued and confirmed straight away, while +/// `syncOperation` waits for the backend and hands the raw body back — the page's own tracker +/// reads a `status` out of it that the SDK has no business interpreting. +final class OperationActionHandler: WebBridgeActionHandler { + + let actions: Set = [.asyncOperation, .syncOperation] + + private lazy var featureToggleManager: FeatureToggleManager = makeFeatureToggleManager() + private lazy var databaseRepository: DatabaseRepositoryProtocol = makeDatabaseRepository() + private lazy var eventRepository: EventRepository = makeEventRepository() + + private let makeFeatureToggleManager: () -> FeatureToggleManager + private let makeDatabaseRepository: () -> DatabaseRepositoryProtocol + private let makeEventRepository: () -> EventRepository + + init(featureToggleManager: @escaping @autoclosure () -> FeatureToggleManager + = DI.injectOrFail(FeatureToggleManager.self), + databaseRepository: @escaping @autoclosure () -> DatabaseRepositoryProtocol + = DI.injectOrFail(DatabaseRepositoryProtocol.self), + eventRepository: @escaping @autoclosure () -> EventRepository + = DI.injectOrFail(EventRepository.self)) { + self.makeFeatureToggleManager = featureToggleManager + self.makeDatabaseRepository = databaseRepository + self.makeEventRepository = eventRepository + } + + func handle(_ message: BridgeMessage, host: WebBridgeHost) { + guard let operation = operation(from: message, host: host) else { + host.respondError("Invalid payload: could not parse operation/body or encode the operation body", + to: message) + return + } + + switch message.parsedAction { + case .asyncOperation: + queue(operation, message: message, host: host) + case .syncOperation: + send(operation, message: message, host: host) + default: + // Unreachable: the registry routes only what `actions` claims. + break + } + } +} + +// MARK: - Actions + +private extension OperationActionHandler { + + func queue(_ operation: (name: String, body: String), message: BridgeMessage, host: WebBridgeHost) { + let customEvent = CustomEvent(name: operation.name, payload: operation.body) + let event = Event(type: .customEvent, body: BodyEncoder(encodable: customEvent).body) + + do { + try databaseRepository.create(event: event) + Logger.common(message: "[WebView] asyncOperation '\(operation.name)' queued", + level: .info, + category: host.logCategory) + } catch { + Logger.common(message: "[WebView] asyncOperation '\(operation.name)' failed: \(error)", + level: .error, + category: host.logCategory) + host.respondError("Failed to queue operation: \(error.localizedDescription)", to: message) + return + } + + host.respondSuccess(to: message) + } + + func send(_ operation: (name: String, body: String), message: BridgeMessage, host: WebBridgeHost) { + let customEvent = CustomEvent(name: operation.name, payload: operation.body) + let event = Event(type: .syncEvent, body: BodyEncoder(encodable: customEvent).body) + + Logger.common(message: "[WebView] syncOperation '\(operation.name)' sending", + level: .info, + category: host.logCategory) + + // HTTP 2xx → forward the raw body to JS as a Response so the JS Tracker + // can dispatch onSuccess / onValidationError by the body's `status`. + // 4xx, 5xx and network failures stay on the MindboxError → Error path. + eventRepository.sendRaw(event: event) { [weak host] result in + DispatchQueue.main.async { + guard let host else { return } + + let outgoing = OperationActionHandler.makeSyncOperationResponse( + result: result, + action: message.action, + id: message.id + ) + + switch outgoing.type { + case .response: + Logger.common(message: "[WebView] syncOperation '\(operation.name)' success", + level: .info, + category: host.logCategory) + case .error: + if case .failure(let error) = result { + Logger.common(message: "[WebView] syncOperation '\(operation.name)' failed: \(error)", + level: .error, + category: host.logCategory) + } else { + Logger.common(message: "[WebView] syncOperation '\(operation.name)' failed: non-UTF-8 response body", + level: .error, + category: host.logCategory) + } + default: + break + } + + host.send(outgoing) + } + } + } + + /// The operation name and its body, with the in-app tags merged in and encoded ready to send. + func operation(from message: BridgeMessage, host: WebBridgeHost) -> (name: String, body: String)? { + guard let payload = message.payloadObject, + case .string(let name)? = payload["operation"], + !name.isEmpty, + let body = payload["body"] else { + return nil + } + + let gatedTags = featureToggleManager.gatedTags(host.tags) + let mergedBody = JSONValue.mergingInAppTags(gatedTags, into: body) + + guard let data = try? JSONEncoder().encode(mergedBody), + let bodyString = String(data: data, encoding: .utf8) else { + return nil + } + + return (name, bodyString) + } +} + +// MARK: - Response mapping + +extension OperationActionHandler { + + /// Maps the raw `sendRaw` result of a `syncOperation` request to the outgoing + /// `BridgeMessage` sent back to JS. Pure function — no side effects — extracted + /// to keep the JS-bridge contract independently unit-testable. + static func makeSyncOperationResponse( + result: Result, + action: String, + id: UUID + ) -> BridgeMessage { + switch result { + case .success(let data): + guard let bodyString = String(data: data, encoding: .utf8) else { + return BridgeMessage( + type: .error, + action: action, + payload: .object(["error": .string("Response body is not valid UTF-8")]), + id: id + ) + } + return BridgeMessage( + type: .response, + action: action, + payload: .string(bodyString), + id: id + ) + case .failure(let error): + return BridgeMessage( + type: .error, + action: action, + payload: .string(error.createDataJSON()), + id: id + ) + } + } +} diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/PermissionActionHandler.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/PermissionActionHandler.swift new file mode 100644 index 00000000..51991f2e --- /dev/null +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/PermissionActionHandler.swift @@ -0,0 +1,87 @@ +// +// PermissionActionHandler.swift +// Mindbox +// +// Created by Akylbek Utekeshev on 13.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation +import MindboxLogger + +/// Asks the system for a permission on the page's behalf. +/// +/// The answer says both what the user's stance is and whether a dialog was actually shown, so +/// the page can tell a fresh refusal from a standing one and offer settings instead of asking +/// again into a void. +final class PermissionActionHandler: WebBridgeActionHandler { + + let actions: Set = [.permissionRequest] + + /// Resolved on first use: a handler set is built for every show, and most pages never ask + /// for a permission. + private lazy var registry: PermissionHandlerRegistryProtocol = makeRegistry() + + private let makeRegistry: () -> PermissionHandlerRegistryProtocol + private let infoPlistValue: (String) -> Any? + + init(makeRegistry: @escaping () -> PermissionHandlerRegistryProtocol + = { DI.injectOrFail(PermissionHandlerRegistryProtocol.self) }, + infoPlistValue: @escaping (String) -> Any? = { Bundle.main.object(forInfoDictionaryKey: $0) }) { + self.makeRegistry = makeRegistry + self.infoPlistValue = infoPlistValue + } + + func handle(_ message: BridgeMessage, host: WebBridgeHost) { + guard case .string(let typeString)? = message.payloadObject?["type"], !typeString.isEmpty else { + host.respondError("Invalid payload: missing or empty 'type' field", to: message) + return + } + + guard let type = PermissionType(rawValue: typeString) else { + host.respondError("Unknown permission type: '\(typeString)'", to: message) + return + } + + guard let handler = registry.handler(for: type) else { + host.respondError("No handler registered for permission type: '\(typeString)'", to: message) + return + } + + // Asking without the usage description in place would kill the host app rather than + // return a refusal, so the missing key is reported to the page instead. + for key in handler.requiredInfoPlistKeys where infoPlistValue(key) == nil { + host.respondError("Missing Info.plist key: \(key)", to: message) + return + } + + // The page is held weakly. A system dialog stands for as long as the user leaves it + // standing, and waiting on it must not be what keeps a finished show alive — with it + // would stay the whole handler set, an uncancelled ready checker and a live sensor + // subscription. A show that ended by the time the system answers gets no answer. + handler.request { [weak host] result in + DispatchQueue.main.async { + guard let host else { return } + + switch result { + case .granted(let dialogShown): + Self.respond("granted", dialogShown: dialogShown, to: message, host: host) + case .denied(let dialogShown): + Self.respond("denied", dialogShown: dialogShown, to: message, host: host) + case .error(let reason): + host.respondError(reason, to: message) + } + } + } + } + + private static func respond(_ result: String, + dialogShown: Bool, + to message: BridgeMessage, + host: WebBridgeHost) { + host.respond(to: message, payload: .object([ + "result": .string(result), + "dialogShown": .bool(dialogShown) + ])) + } +} diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/ReadyActionHandler.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/ReadyActionHandler.swift new file mode 100644 index 00000000..c578d890 --- /dev/null +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/ReadyActionHandler.swift @@ -0,0 +1,27 @@ +// +// ReadyActionHandler.swift +// Mindbox +// +// Created by Akylbek Utekeshev on 13.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation + +/// The page reports it can receive messages, and is answered with what it needs to configure +/// itself. +/// +/// Deferred, and deliberately so: the blanket `{success: true}` would tell the page nothing, +/// and this is the one answer it cannot start without. +/// +/// What goes into the payload belongs to the host — an in-app knows its operation, a block +/// knows its configuration entry — so this handler only decides *when* to answer, never *with +/// what*. +final class ReadyActionHandler: WebBridgeActionHandler { + + let actions: Set = [.ready] + + func handle(_ message: BridgeMessage, host: WebBridgeHost) { + host.respond(to: message, payload: host.makeStartPayload()) + } +} diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/SettingsActionHandler.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/SettingsActionHandler.swift new file mode 100644 index 00000000..cec98e34 --- /dev/null +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/SettingsActionHandler.swift @@ -0,0 +1,61 @@ +// +// SettingsActionHandler.swift +// Mindbox +// +// Created by Akylbek Utekeshev on 13.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import UIKit +import MindboxLogger + +/// Sends the user to the system settings the page asked for. +/// +/// Notification settings have their own route, because on newer systems they open the app's +/// notification screen directly rather than its top-level page. +final class SettingsActionHandler: WebBridgeActionHandler { + + let actions: Set = [.settingsOpen] + + private let urlOpener: BridgeURLOpening + private let openNotificationSettings: (@escaping (Bool) -> Void) -> Void + + init(urlOpener: BridgeURLOpening = SystemURLOpener(), + openNotificationSettings: @escaping (@escaping (Bool) -> Void) -> Void + = { PushPermissionHelper.openPushNotificationSettings(completion: $0) }) { + self.urlOpener = urlOpener + self.openNotificationSettings = openNotificationSettings + } + + func handle(_ message: BridgeMessage, host: WebBridgeHost) { + guard let target = SettingsRequestParser.parse(from: message) else { + host.respondError("Invalid or unknown settings type", to: message) + return + } + + Logger.common(message: "[WebView] openSettings: type='\(target.rawValue)'", + level: .info, + category: host.logCategory) + + switch target { + case .notifications: + // The outcome is not inspected: the page asked to be sent to settings, and it was. + // The page is held weakly, exactly as on the `.application` route below: a show that + // ended while the system was switching screens gets no answer. + openNotificationSettings { [weak host] _ in + DispatchQueue.main.async { + guard let host else { return } + + host.respondSuccess(to: message) + } + } + case .application: + guard let url = URL(string: UIApplication.openSettingsURLString) else { + host.respondError("Failed to create application settings URL", to: message) + return + } + + urlOpener.open(url, answering: message, host: host) + } + } +} diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/ShowInAppActionHandler.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/ShowInAppActionHandler.swift new file mode 100644 index 00000000..5a71dbc2 --- /dev/null +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/ShowInAppActionHandler.swift @@ -0,0 +1,61 @@ +// +// ShowInAppActionHandler.swift +// Mindbox +// +// Created by Akylbek Utekeshev on 13.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation +import MindboxLogger + +/// Shows the in-app the page asked for, by id. +/// +/// **Not implemented yet: the request is journalled and acknowledged, and no window opens.** The +/// page is answered so it can finish its own flow — un-highlighting the story it just handled — +/// rather than sit on a promise nothing will ever settle. +final class ShowInAppActionHandler: WebBridgeActionHandler { + + let actions: Set = [.showInApp] + + func handle(_ message: BridgeMessage, host: WebBridgeHost) { + guard let payload = message.payloadObject, + case .string(let inAppId)? = payload["inappId"], + !inAppId.isEmpty else { + host.respondError("Invalid payload: missing or empty 'inappId'", to: message) + return + } + + // TODO: Show the in-app with this id as a direct call. + // - it bypasses targeting, the shown-before dedup and every presentation limit, which + // is achieved by NOT entering `InappMapper` or `InappScheduleManager.scheduleInApp` + // and going straight to `InAppPresentationManager.present`; + // - the show is still tracked as on the ordinary path. Reuse what + // `InappScheduleManager.presentInapp` already does — it also raises + // `isPresentingInAppMessage`, which is what keeps ordinary in-apps and snackbars from + // appearing over stories; + // - the reverse is deliberate: this path must NOT consult `canPresentInApp`, so stories + // open over an in-app that is already showing, dismissing it first; + // - `params` merges into that in-app's start payload LAST and overwrites everything it + // collides with, service keys included. The SDK validates nothing and protects + // nothing: it is an opaque dictionary and collisions are the page's business; + // - `index` and `sourceInappId` stay in the log and out of the payload — the page + // already puts whatever it needs into `params`. + let index = payload["index"].flatMap { value -> Int? in + guard case .int(let index) = value else { return nil } + return index + } + let sourceInAppId: String? = { + guard case .string(let source)? = payload["sourceInappId"] else { return nil } + return source + }() + + Logger.common(message: """ + [WebView] showInApp is not implemented, nothing will be shown: \ + inappId=\(inAppId) index=\(index.map(String.init) ?? "nil") \ + sourceInappId=\(sourceInAppId ?? "nil") params=\(payload["params"].map { String(describing: $0.anyValue) } ?? "nil") + """, level: .default, category: host.logCategory) + + host.respondSuccess(to: message) + } +} diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeActionHandler.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeActionHandler.swift new file mode 100644 index 00000000..c5933854 --- /dev/null +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeActionHandler.swift @@ -0,0 +1,95 @@ +// +// WebBridgeActionHandler.swift +// Mindbox +// +// Created by Akylbek Utekeshev on 13.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation +import MindboxLogger + +/// One bridge action, or a family of them, handled apart from the view it arrived in. +/// +/// Handlers know nothing about which page is talking to them: everything they may need comes +/// through the `WebBridgeHost` handed to `handle`. That is what lets one set of them serve +/// every WebView the SDK shows. +protocol WebBridgeActionHandler: AnyObject { + + /// The actions this handler owns. Fixed for the life of the instance — the registry indexes + /// it once, so dispatch is a lookup rather than a walk down a list. + var actions: Set { get } + + /// Main thread. `message.type` is always `.request`, and its action is always one of + /// `actions`. + /// + /// An action that is `isDeferred` must answer exactly once through `host`. One that is not + /// must not answer at all: `RequestMessageHandler` has already sent `{success: true}` for it, + /// and a second answer would arrive against an id JS has closed. + func handle(_ message: BridgeMessage, host: WebBridgeHost) + + /// The session is over — the page is going away, or it asked to be closed. Whatever holds + /// the device (haptic engine, motion sensors) is released here. + func tearDown() +} + +extension WebBridgeActionHandler { + + /// Most handlers hold nothing that outlives a request. + func tearDown() {} +} + +/// Routes a bridge request to whoever owns its action. +/// +/// One registry per bridge session, built from handler instances of that same session: several +/// handlers keep state that belongs to one page — a prepared haptic engine, a motion +/// subscription — and it has to die with that page, not with the process. +final class WebBridgeActionRegistry { + + private let handlers: [WebBridgeActionHandler] + + private var owners: [BridgeMessage.Action: WebBridgeActionHandler] = [:] + + init(handlers: [WebBridgeActionHandler]) { + self.handlers = handlers + + for handler in handlers { + for action in handler.actions { + guard owners[action] == nil else { + // Two handlers claiming one action is a wiring mistake, not a runtime + // condition: keep the first so behaviour stays deterministic, and say it + // loudly enough to be found before release. + Logger.common(message: "[WebView] Bridge: action '\(action.rawValue)' is claimed by more than one handler, keeping the first", + level: .error, + category: .webViewInAppMessages) + continue + } + + owners[action] = handler + } + } + } + + /// - Returns: `false` when no handler owns the action. Not an error in itself — the web + /// vocabulary is allowed to be newer than the SDK — so how loudly to report it is the + /// caller's call. + @discardableResult + func handle(_ message: BridgeMessage, host: WebBridgeHost) -> Bool { + guard let action = message.parsedAction, let owner = owners[action] else { + return false + } + + owner.handle(message, host: host) + return true + } + + func tearDown() { + handlers.forEach { $0.tearDown() } + } + + /// The one door for a caller that must reach a handler outside a request: a system shake + /// arrives at the view, not at the bridge. + func handler(ofType type: T.Type) -> T? { + handlers.first { $0 is T } as? T + } +} diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeActionHandlerFactory.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeActionHandlerFactory.swift new file mode 100644 index 00000000..c65f0958 --- /dev/null +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeActionHandlerFactory.swift @@ -0,0 +1,38 @@ +// +// WebBridgeActionHandlerFactory.swift +// Mindbox +// +// Created by Akylbek Utekeshev on 13.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation + +/// Assembles the handler set a bridge session runs with. +/// +/// One set, the same for every WebView. There is no per-surface list on purpose: an action a +/// host does not listen for is journalled and dropped, so a page may speak the whole vocabulary +/// wherever it lives. Teaching a new surface an existing action is a conformance on that host, +/// not a new entry here. +enum WebBridgeActionHandlerFactory { + + /// Fresh instances every call: several handlers keep state belonging to one page — a + /// prepared haptic engine, a motion subscription — which has to die with that page. + static func makeHandlers() -> [WebBridgeActionHandler] { + [ + ReadyActionHandler(), + LogActionHandler(), + LocalStateActionHandler(), + OpenLinkActionHandler(), + SettingsActionHandler(), + PermissionActionHandler(), + HapticActionHandler(), + MotionActionHandler(), + OperationActionHandler(), + LifecycleActionHandler(), + ContentRenderedActionHandler(), + CheckInappsTargetingActionHandler(), + ShowInAppActionHandler() + ] + } +} diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeHost.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeHost.swift new file mode 100644 index 00000000..dbb00a0b --- /dev/null +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeHost.swift @@ -0,0 +1,111 @@ +// +// WebBridgeHost.swift +// Mindbox +// +// Created by Akylbek Utekeshev on 13.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import UIKit +import MindboxLogger + +/// The WebView an action runs in, as much of it as a handler is allowed to know. +/// +/// Deliberately narrow. A handler that needs more than this is not a shared handler: it wants +/// something only one kind of page can do, and that belongs behind a capability protocol below +/// rather than behind a check of which surface we are on. +protocol WebBridgeHost: AnyObject { + + /// The in-app id for a popup, the block id for an embedded block. Goes into the logs, and + /// into the start payload as `inAppId`. + var contentId: String { get } + + /// Where this host journals. Each surface keeps its own trail, so a handler shared by all + /// of them has to ask instead of hardcoding a category. + var logCategory: LogCategory { get } + + /// In-app tags, merged into operation bodies. Absent on pages that have none. + var tags: [String: String]? { get } + + /// What a handler presents from when it needs a controller of its own (`SFSafariViewController`). + /// `nil` means the handler falls back to the key window. + var presentingViewController: UIViewController? { get } + + /// Whether a user is looking at this page right now. + /// + /// An embedded block that left the window keeps its page alive, and that page still + /// delivers whatever its `setTimeout` scheduled. Not a single touch stands behind such a + /// message, so anything acting on the user's behalf — opening a link, showing a window — + /// must not run on it. + var isUserPresent: Bool { get } + + /// Native → JS. The only way out of a handler. + func send(_ message: BridgeMessage) + + /// The parameters this page needs to configure itself. + /// + /// Built by the host rather than by the handler: what goes in depends on what the page is — + /// an in-app knows its operation, a block knows its configuration entry — while answering + /// `ready` with it is the same everywhere. Snapshot at the moment of asking, so a page that + /// asks again is told what is true now. + func makeStartPayload() -> JSONValue +} + +// MARK: - Answering a request + +/// The three ways to answer. +/// +/// They take the request itself rather than an id and an action, so a response cannot drift +/// from what it answers — the mismatch is not expressible. +extension WebBridgeHost { + + func respond(to message: BridgeMessage, payload: JSONValue) { + send(BridgeMessage(type: .response, action: message.action, payload: payload, id: message.id)) + } + + func respondSuccess(to message: BridgeMessage) { + respond(to: message, payload: .object(["success": .bool(true)])) + } + + func respondError(_ reason: String, to message: BridgeMessage) { + Logger.common(message: "[WebView] Bridge: '\(message.action)' failed for '\(contentId)': \(reason)", + level: .error, + category: logCategory) + + send(BridgeMessage(type: .error, + action: message.action, + payload: .object(["error": .string(reason)]), + id: message.id)) + } +} + +// MARK: - Capabilities + +/// Hosts a page that steers its own life: it can report that it booted, ask to be closed or +/// hidden, and report a tap. +/// +/// A capability, not a layer. Any page may send these — whether they mean anything depends on +/// who hosts it. A page sending `close` to a host that does not conform is not an error: the +/// message is journalled and dropped. +protocol WebBridgeLifecycleHosting: AnyObject { + + func bridgeDidInit() + + func bridgeDidRequestClose() + + func bridgeDidRequestHide() + + /// The click payload is forwarded verbatim: what a tap means is decided above the bridge. + func bridgeDidClick(rawPayload: String) +} + +/// Hosts a page that reports how much content it rendered. +/// +/// Only the embedded block listens today. The day an in-app wants the same signal it conforms +/// here and the page simply starts sending `contentRendered` — nothing inside the bridge changes. +protocol WebBridgeContentHosting: AnyObject { + + /// - Parameter count: `0` means the page is alive and correct and has nothing to show. + /// That is an outcome, not a failure. + func bridgeDidRenderContent(count: Int) +} diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebViewStartPayloadBuilder.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebViewStartPayloadBuilder.swift new file mode 100644 index 00000000..de70d270 --- /dev/null +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebViewStartPayloadBuilder.swift @@ -0,0 +1,168 @@ +// +// WebViewStartPayloadBuilder.swift +// Mindbox +// +// Created by Akylbek Utekeshev on 13.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import UIKit +import MindboxLogger + +private enum PayloadKey { + static let sdkVersion = "sdkVersion" + static let sdkVersionNumeric = "sdkVersionNumeric" + static let endpointId = "endpointId" + static let deviceUuid = "deviceUUID" + static let userVisitCount = "userVisitCount" + + static let inAppId = "inAppId" + static let operationName = "operationName" + static let operationBody = "operationBody" + + static let trackVisitSource = "trackVisitSource" + static let trackVisitRequestUrl = "trackVisitRequestUrl" + + static let firstInitializationDateTime = "firstInitializationDateTime" + + static let permissions = "permissions" + static let localStateVersion = "localStateVersion" + + enum Insets { + static let key = "insets" + static let top = "top" + static let left = "left" + static let bottom = "bottom" + static let right = "right" + } +} + +/// Everything a page needs to configure itself, as answered to `ready`. +/// +/// Built fresh each time rather than once per show: safe-area insets, granted permissions and +/// the visit counters are all snapshots, and a page asking again should be told what is true +/// now. That also makes it the same builder the config-update push can reuse. +/// +/// > Note: the result is a JSON-encoded **string**, not an object — the bridge contract has JS +/// > calling `JSON.parse` on it. +struct WebViewStartPayloadBuilder { + + /// The in-app id for a popup, the block id for an embedded block. + let contentId: String + + /// The operation that led to this show, when there was one. + let operation: (name: String, body: String)? + + /// Whatever the content configuration carries for the page. Merged at the root. + let customParams: [String: JSONValue]? + + /// The view the safe-area insets are measured from. + let insetsSource: UIView? + + let logError: WebViewLogError + + func build() -> JSONValue { + let persistenceStorage = DI.injectOrFail(PersistenceStorage.self) + let systemInfoProvider = DI.injectOrFail(SystemInfoProvider.self) + + // The order is load-bearing: the configuration's own params are merged before the + // operation and track-visit fields, which therefore win over a colliding key. + var params = baseParams(persistenceStorage: persistenceStorage) + addSystemInfo(to: ¶ms, systemInfoProvider: systemInfoProvider) + mergeCustomParams(into: ¶ms) + addOperationParams(to: ¶ms) + addTrackVisitParams(to: ¶ms) + + return serialize(params) + } +} + +private extension WebViewStartPayloadBuilder { + + func baseParams(persistenceStorage: PersistenceStorage) -> [String: Any] { + var params: [String: Any] = [ + PayloadKey.sdkVersion: Mindbox.shared.sdkVersion, + PayloadKey.endpointId: persistenceStorage.configuration?.endpoint ?? "", + PayloadKey.deviceUuid: persistenceStorage.deviceUUID ?? "", + PayloadKey.userVisitCount: "\(persistenceStorage.userVisitCount ?? 0)", + PayloadKey.sdkVersionNumeric: "\(Constants.Versions.sdkVersionNumeric)", + PayloadKey.inAppId: contentId, + // Add localState version for WebView JS migration logic + PayloadKey.localStateVersion: persistenceStorage.webViewLocalStateVersion ?? Constants.WebViewLocalState.defaultVersion + ] + + if let firstInitDate = persistenceStorage.firstInitializationDateTime { + params[PayloadKey.firstInitializationDateTime] = firstInitDate.toString(withFormat: .utc) + } + + return params + } + + func addOperationParams(to params: inout [String: Any]) { + guard let operation else { return } + params[PayloadKey.operationName] = operation.name + params[PayloadKey.operationBody] = operation.body + } + + func addSystemInfo(to params: inout [String: Any], systemInfoProvider: SystemInfoProvider) { + params.merge(systemInfoProvider.getBasicSystemInfo()) { _, new in new } + + let insets = systemInfoProvider.getSafeAreaInsets(from: insetsSource) + params[PayloadKey.Insets.key] = [ + PayloadKey.Insets.top: insets.top, + PayloadKey.Insets.left: insets.left, + PayloadKey.Insets.bottom: insets.bottom, + PayloadKey.Insets.right: insets.right + ] + + let permissions = systemInfoProvider.getGrantedPermissions() + if !permissions.isEmpty { + params[PayloadKey.permissions] = permissions.mapValues { $0.toDictionary() } + } + } + + func mergeCustomParams(into params: inout [String: Any]) { + guard let customParams, !customParams.isEmpty else { return } + + for (key, value) in customParams { + params[key] = value.anyValue ?? NSNull() + } + } + + func addTrackVisitParams(to params: inout [String: Any]) { + guard let lastTrackVisit = SessionTemporaryStorage.shared.lastTrackVisit else { return } + + if let source = lastTrackVisit.source { + params[PayloadKey.trackVisitSource] = source.rawValue + } + if let requestUrl = lastTrackVisit.requestUrl { + params[PayloadKey.trackVisitRequestUrl] = requestUrl + } + } + + /// An empty object rather than a throw: a page that gets `{}` reports its own failure, while + /// a missing answer leaves it waiting on an id nothing will ever close. + func serialize(_ params: [String: Any]) -> JSONValue { + // Asked first because the failure below cannot be caught: `JSONSerialization` raises an + // Objective-C exception for a NaN or an infinity rather than throwing a Swift error, so + // the `catch` never runs and the host app goes down instead. Values reaching here are + // decoded from JSON today, which cannot express either — but this is the payload every + // surface is answered with, and a crash is a steep price for that assumption holding. + guard JSONSerialization.isValidJSONObject(params) else { + logError("[WebView] Start payload contains a value JSON cannot represent") + return .string("{}") + } + + do { + let data = try JSONSerialization.data(withJSONObject: params, options: []) + guard let jsonString = String(bytes: data, encoding: .utf8) else { + logError("[WebView] Failed to convert JSON data to UTF-8 string") + return .string("{}") + } + return .string(jsonString) + } catch { + logError("[WebView] Failed to encode start payload to JSON string: \(error)") + return .string("{}") + } + } +} diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Debug/MindboxWebViewFacade.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Debug/MindboxWebViewFacade.swift index 14dbc136..9a1bc89a 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/Debug/MindboxWebViewFacade.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Debug/MindboxWebViewFacade.swift @@ -10,34 +10,6 @@ import UIKit import WebKit import MindboxLogger -private enum PayloadKey { - static let sdkVersion = "sdkVersion" - static let sdkVersionNumeric = "sdkVersionNumeric" - static let endpointId = "endpointId" - static let deviceUuid = "deviceUUID" - static let userVisitCount = "userVisitCount" - - static let inAppId = "inAppId" - static let operationName = "operationName" - static let operationBody = "operationBody" - - static let trackVisitSource = "trackVisitSource" - static let trackVisitRequestUrl = "trackVisitRequestUrl" - - static let firstInitializationDateTime = "firstInitializationDateTime" - - static let permissions = "permissions" - static let localStateVersion = "localStateVersion" - - enum Insets { - static let key = "insets" - static let top = "top" - static let left = "left" - static let bottom = "bottom" - static let right = "right" - } -} - @_spi(Internal) public protocol InappWebViewFacadeProtocol: AnyObject { func makeView() -> UIView @@ -45,7 +17,7 @@ public protocol InappWebViewFacadeProtocol: AnyObject { func applyViewSettings(scrollViewDelegate: UIScrollViewDelegate?) func cleanWebView() - func sendReadyEvent(id: UUID) + func makeStartPayload() -> JSONValue func sendToJS(_ message: BridgeMessage) func evaluateJavaScript(_ script: String, completion: @escaping (Result) -> Void) func setBridgeMessageDelegate(_ delegate: WebBridgeMessageDelegate?) @@ -215,14 +187,12 @@ public final class MindboxWebViewFacade: MindboxInternalWebViewFacadeProtocol { webView.scrollView.contentInsetAdjustmentBehavior = .never } - public func sendReadyEvent(id: UUID) { - let message = BridgeMessage( - type: .response, - action: BridgeMessage.Action.ready, - payload: buildStartPayload(), - id: id - ) - bridge.send(message) + public func makeStartPayload() -> JSONValue { + WebViewStartPayloadBuilder(contentId: inAppId, + operation: operation, + customParams: params, + insetsSource: webView, + logError: logError).build() } public func sendToJS(_ message: BridgeMessage) { @@ -265,91 +235,6 @@ public final class MindboxWebViewFacade: MindboxInternalWebViewFacadeProtocol { } extension MindboxWebViewFacade { - private func buildStartPayload() -> JSONValue { - let persistenceStorage = DI.injectOrFail(PersistenceStorage.self) - let systemInfoProvider = DI.injectOrFail(SystemInfoProvider.self) - - var params = buildBaseParams(persistenceStorage: persistenceStorage) - addSystemInfo(to: ¶ms, systemInfoProvider: systemInfoProvider) - mergeCustomParams(into: ¶ms) - addOperationParams(to: ¶ms) - addTrackVisitParams(to: ¶ms) - - return serializeToJSONString(params) - } - - private func buildBaseParams(persistenceStorage: PersistenceStorage) -> [String: Any] { - var params: [String: Any] = [ - PayloadKey.sdkVersion: Mindbox.shared.sdkVersion, - PayloadKey.endpointId: persistenceStorage.configuration?.endpoint ?? "", - PayloadKey.deviceUuid: persistenceStorage.deviceUUID ?? "", - PayloadKey.userVisitCount: "\(persistenceStorage.userVisitCount ?? 0)", - PayloadKey.sdkVersionNumeric: "\(Constants.Versions.sdkVersionNumeric)", - PayloadKey.inAppId: inAppId, - // Add localState version for WebView JS migration logic - PayloadKey.localStateVersion: persistenceStorage.webViewLocalStateVersion ?? Constants.WebViewLocalState.defaultVersion - ] - - if let firstInitDate = persistenceStorage.firstInitializationDateTime { - params[PayloadKey.firstInitializationDateTime] = firstInitDate.toString(withFormat: .utc) - } - - return params - } - - private func addOperationParams(to params: inout [String: Any]) { - guard let operation else { return } - params[PayloadKey.operationName] = operation.name - params[PayloadKey.operationBody] = operation.body - } - - private func addSystemInfo(to params: inout [String: Any], systemInfoProvider: SystemInfoProvider) { - params.merge(systemInfoProvider.getBasicSystemInfo()) { _, new in new } - - let insets = systemInfoProvider.getSafeAreaInsets(from: webView) - params[PayloadKey.Insets.key] = [ - PayloadKey.Insets.top: insets.top, - PayloadKey.Insets.left: insets.left, - PayloadKey.Insets.bottom: insets.bottom, - PayloadKey.Insets.right: insets.right - ] - - let permissions = systemInfoProvider.getGrantedPermissions() - if !permissions.isEmpty { - params[PayloadKey.permissions] = permissions.mapValues { $0.toDictionary() } - } - } - - private func mergeCustomParams(into params: inout [String: Any]) { - guard let customParams = self.params, !customParams.isEmpty else { return } - for (key, value) in customParams { - params[key] = value.anyValue ?? NSNull() - } - } - - private func addTrackVisitParams(to params: inout [String: Any]) { - guard let lastTrackVisit = SessionTemporaryStorage.shared.lastTrackVisit else { return } - if let source = lastTrackVisit.source { - params[PayloadKey.trackVisitSource] = source.rawValue - } - if let requestUrl = lastTrackVisit.requestUrl { - params[PayloadKey.trackVisitRequestUrl] = requestUrl - } - } - - private func serializeToJSONString(_ params: [String: Any]) -> JSONValue { - do { - let data = try JSONSerialization.data(withJSONObject: params, options: []) - guard let jsonString = String(bytes: data, encoding: .utf8) else { - logError("[WebView] Failed to convert JSON data to UTF-8 string") - return .string("{}") - } - return .string(jsonString) - } catch { - logError("[WebView] Failed to encode start payload to JSON string: \(error)") - return .string("{}") - } - } private func fetchHTML(from urlString: String, completion: @escaping (String?) -> Void) { diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Settings/SettingsRequestParser.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Settings/SettingsRequestParser.swift index 6903055c..dfccf054 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/Settings/SettingsRequestParser.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Settings/SettingsRequestParser.swift @@ -20,22 +20,10 @@ enum SettingsRequestParser { } static func parse(from message: BridgeMessage) -> SettingsType? { - let dict = extractPayloadDict(from: message) - guard case .string(let typeString) = dict[PayloadKey.target], !typeString.isEmpty else { + guard case .string(let typeString)? = message.payloadObject?[PayloadKey.target], + !typeString.isEmpty else { return nil } return SettingsType(rawValue: typeString) } - - private static func extractPayloadDict(from message: BridgeMessage) -> [String: JSONValue] { - if case .string(let str) = message.payload, - let data = str.data(using: .utf8), - let dict = try? JSONDecoder().decode([String: JSONValue].self, from: data) { - return dict - } - if case .object(let dict) = message.payload { - return dict - } - return [:] - } } diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift index 15cb3be6..fc47ad0d 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift @@ -7,10 +7,8 @@ import UIKit import WebKit -import SafariServices import MindboxLogger -// swiftlint:disable file_length final class TransparentView: UIView { weak var delegate: WebVCDelegate? @@ -22,7 +20,11 @@ final class TransparentView: UIView { private var operation: (name: String, body: String)? private let userAgent: String private let inAppId: String - private let tags: [String: String]? + let tags: [String: String]? + + /// Handlers for the actions that no longer live in the switch below. Built per show, not + /// shared: handlers moving in here own state that belongs to one page. + private let actionRegistry: WebBridgeActionRegistry private var lastReadyCheckedUrl: String? private var readyChecker: WebViewReadyChecker? /// True when the page finished loading but the JS bridge never appeared within the @@ -36,23 +38,18 @@ final class TransparentView: UIView { private let noCacheRetryPolicy = WebViewNoCacheRetryPolicy { InAppWebViewDataStore.isCacheFeatureEnabled } - private lazy var localStateStorage: WebViewLocalStateStorageProtocol = DI.injectOrFail(WebViewLocalStateStorageProtocol.self) - private lazy var permissionHandlerRegistry = DI.injectOrFail(PermissionHandlerRegistryProtocol.self) - private lazy var hapticService: HapticServiceProtocol = DI.injectOrFail(HapticServiceProtocol.self) - lazy var featureToggleManager: FeatureToggleManager = DI.injectOrFail(FeatureToggleManager.self) - lazy var databaseRepository: DatabaseRepositoryProtocol = DI.injectOrFail(DatabaseRepositoryProtocol.self) - lazy var eventRepository: EventRepository = DI.injectOrFail(EventRepository.self) - private var isMotionServiceInitialized = false - private lazy var motionService: MotionServiceProtocol = { - isMotionServiceInitialized = true - let service = DI.injectOrFail(MotionServiceProtocol.self) - service.onGestureDetected = { [weak self] gesture, data in - self?.sendMotionEvent(gesture: gesture, data: data) - } - return service - }() - init(frame: CGRect, params: [String: JSONValue], userAgent: String, operation: (name: String, body: String)?, inAppId: String, tags: [String: String]?) { + /// - Parameter actionRegistry: The handlers this show runs with. Injectable so a test can + /// drive the real dispatch path with doubles behind it. + init(frame: CGRect, + params: [String: JSONValue], + userAgent: String, + operation: (name: String, body: String)?, + inAppId: String, + tags: [String: String]?, + actionRegistry: WebBridgeActionRegistry + = WebBridgeActionRegistry(handlers: WebBridgeActionHandlerFactory.makeHandlers())) { + self.actionRegistry = actionRegistry self.params = params self.operation = operation self.userAgent = userAgent @@ -63,6 +60,7 @@ final class TransparentView: UIView { } override init(frame: CGRect) { + self.actionRegistry = WebBridgeActionRegistry(handlers: WebBridgeActionHandlerFactory.makeHandlers()) self.params = nil self.operation = nil self.userAgent = "" @@ -73,6 +71,7 @@ final class TransparentView: UIView { } required init?(coder: NSCoder) { + self.actionRegistry = WebBridgeActionRegistry(handlers: WebBridgeActionHandlerFactory.makeHandlers()) self.params = nil self.operation = nil self.userAgent = "" @@ -84,7 +83,7 @@ final class TransparentView: UIView { deinit { readyChecker?.cancel() - if isMotionServiceInitialized { motionService.stopMonitoring() } + actionRegistry.tearDown() Logger.common(message: "[WebView] Deinit TransparentView", category: .webViewInAppMessages) } @@ -173,99 +172,83 @@ extension TransparentView { } } +// MARK: - WebBridgeHost + +extension TransparentView: WebBridgeHost { + + var contentId: String { inAppId } + + var logCategory: LogCategory { .webViewInAppMessages } + + var presentingViewController: UIViewController? { delegate as? UIViewController } + + /// A modal in-app exists only while it is on screen — unlike an embedded block, it has no + /// off-screen life in which its page could keep talking. + var isUserPresent: Bool { true } + + func send(_ message: BridgeMessage) { + facade?.sendToJS(message) + } + + func makeStartPayload() -> JSONValue { + facade?.makeStartPayload() ?? .string("{}") + } +} + +// MARK: - WebBridgeLifecycleHosting + +extension TransparentView: WebBridgeLifecycleHosting { + + func bridgeDidInit() { + // First, before anything else can read it: the no-cache retry policy decides whether a + // failed subresource is worth healing by asking whether the page ever booted, and a + // late flag silently disables that. + hasReceivedInit = true + quizInitTimeoutWorkItem?.cancel() + // The page has proven it can boot — drop the retained retry content (mirror of + // Android's handleInitAction cleanup; the policy's one-shot state stays). + facade?.releaseRetainedContent() + actionRegistry.handler(ofType: HapticActionHandler.self)?.prepare() + webViewAction?.onInit() + } + + func bridgeDidRequestClose() { + quizInitTimeoutWorkItem?.cancel() + // Whatever holds the device is released before the window goes: a haptic pattern + // playing into a closed show, or a sensor callback reaching a dead page, is the + // shape crashes come in. + actionRegistry.tearDown() + webViewAction?.onClose() + } + + func bridgeDidRequestHide() { + webViewAction?.onHide() + } + + func bridgeDidClick(rawPayload: String) { + webViewAction?.onCompleted(data: rawPayload) + } +} + extension TransparentView: WebBridgeMessageDelegate { func webBridge(_ bridge: MindboxWebBridge, didReceiveBridgeMessage message: BridgeMessage) { let action = message.action - let data: String - - if case .string(let stringValue) = message.payload { - data = stringValue - } else if let payload = message.payload, - let payloadData = try? JSONEncoder().encode(payload), - let payloadString = String(data: payloadData, encoding: .utf8) { - data = payloadString - } else { - data = "" - } + let data = message.payloadString Logger.common( message: "[WebView] Bridge: received \(action) \(data)", category: .webViewInAppMessages ) - - // TODO: - Create plugin-based handlers - guard let parsedAction = BridgeMessage.Action(rawValue: action) else { + // Every action a page can send is owned by a handler now. An action nobody owns is + // not an error: the web vocabulary is allowed to be newer than the SDK. + guard actionRegistry.handle(message, host: self) else { Logger.common( message: "[WebView] Unknown action: \(action) with \(data)", category: .webViewInAppMessages ) return } - - switch parsedAction { - - // Lifecycle - case .close: - quizInitTimeoutWorkItem?.cancel() - hapticService.stopPattern() - if isMotionServiceInitialized { motionService.stopMonitoring() } - webViewAction?.onClose() - case .`init`: - hasReceivedInit = true - quizInitTimeoutWorkItem?.cancel() - // The page has proven it can boot — drop the retained retry content (mirror of - // Android's handleInitAction cleanup; the policy's one-shot state stays). - facade?.releaseRetainedContent() - hapticService.prepare() - webViewAction?.onInit() - case .click: - webViewAction?.onCompleted(data: data) - case .hide: - webViewAction?.onHide() - case .ready: - facade?.sendReadyEvent(id: message.id) - - // Info - case .log: - webViewAction?.onLog(message: data) - - // Operations - case .asyncOperation: - handleAsyncOperation(message: message) - case .syncOperation: - handleSyncOperation(message: message) - - // Navigation, Settings & Permissions - case .openLink: - handleNavigate(message: message) - case .settingsOpen: - handleOpenSettings(message: message) - case .permissionRequest: - handlePermissionRequest(message: message) - - // Local State - case .localStateGet: - handleLocalStateGet(message: message) - case .localStateSet: - handleLocalStateSet(message: message) - case .localStateInit: - handleLocalStateInit(message: message) - - // Haptic - case .haptic: - handleHaptic(message: message) - - // Motion - case .motionStart: - handleMotionStart(message: message) - case .motionStop: - handleMotionStop(message: message) - - // Native → JS (not handled here) - case .navigationIntercepted, .motionEvent: - break - } } } @@ -398,498 +381,6 @@ extension TransparentView: WebBridgeNavigationDelegate { } } -// MARK: - Operation Handlers - -extension TransparentView { - - private func extractOperationParams(from message: BridgeMessage) -> (name: String, body: JSONValue)? { - guard case .string(let str) = message.payload, - let data = str.data(using: .utf8), - let dict = try? JSONDecoder().decode([String: JSONValue].self, from: data), - case .string(let operation) = dict["operation"], - !operation.isEmpty, - let body = dict["body"] else { - return nil - } - - return (operation, body) - } - - private func mergedOperationBodyString(_ body: JSONValue) -> String? { - let gatedTags = featureToggleManager.gatedTags(tags) - let mergedBody = JSONValue.mergingInAppTags(gatedTags, into: body) - - guard let bodyData = try? JSONEncoder().encode(mergedBody) else { - return nil - } - return String(data: bodyData, encoding: .utf8) - } - - private func sendBridgeSuccess(action: String, id: UUID) { - let response = BridgeMessage( - type: .response, - action: action, - payload: .object(["success": .bool(true)]), - id: id - ) - facade?.sendToJS(response) - } - - private func sendBridgeError(_ errorMessage: String, action: String, id: UUID) { - let errorPayload: JSONValue = .object(["error": .string(errorMessage)]) - let response = BridgeMessage(type: .error, action: action, payload: errorPayload, id: id) - facade?.sendToJS(response) - } - - private func handleAsyncOperation(message: BridgeMessage) { - guard let params = extractOperationParams(from: message), - let bodyString = mergedOperationBodyString(params.body) else { - sendBridgeError("Invalid payload: could not parse operation/body or encode the operation body", action: message.action, id: message.id) - return - } - - let customEvent = CustomEvent(name: params.name, payload: bodyString) - let event = Event(type: .customEvent, body: BodyEncoder(encodable: customEvent).body) - - do { - try databaseRepository.create(event: event) - Logger.common(message: "[WebView] asyncOperation '\(params.name)' queued", level: .info, category: .webViewInAppMessages) - } catch { - Logger.common(message: "[WebView] asyncOperation '\(params.name)' failed: \(error)", level: .error, category: .webViewInAppMessages) - sendBridgeError("Failed to queue operation: \(error.localizedDescription)", action: message.action, id: message.id) - return - } - - sendBridgeSuccess(action: message.action, id: message.id) - } - - private func handleSyncOperation(message: BridgeMessage) { - guard let params = extractOperationParams(from: message), - let bodyString = mergedOperationBodyString(params.body) else { - sendBridgeError("Invalid payload: could not parse operation/body or encode the operation body", action: message.action, id: message.id) - return - } - - let customEvent = CustomEvent(name: params.name, payload: bodyString) - let event = Event(type: .syncEvent, body: BodyEncoder(encodable: customEvent).body) - - Logger.common(message: "[WebView] syncOperation '\(params.name)' sending", level: .info, category: .webViewInAppMessages) - - // HTTP 2xx → forward the raw body to JS as a Response so the JS Tracker - // can dispatch onSuccess / onValidationError by the body's `status`. - // 4xx, 5xx and network failures stay on the MindboxError → Error path. - eventRepository.sendRaw(event: event) { [weak self] result in - DispatchQueue.main.async { - let outgoing = TransparentView.makeSyncOperationResponse( - result: result, - action: message.action, - id: message.id - ) - switch outgoing.type { - case .response: - Logger.common(message: "[WebView] syncOperation '\(params.name)' success", level: .info, category: .webViewInAppMessages) - case .error: - if case .failure(let error) = result { - Logger.common(message: "[WebView] syncOperation '\(params.name)' failed: \(error)", level: .error, category: .webViewInAppMessages) - } else { - Logger.common(message: "[WebView] syncOperation '\(params.name)' failed: non-UTF-8 response body", level: .error, category: .webViewInAppMessages) - } - default: - break - } - self?.facade?.sendToJS(outgoing) - } - } - } - - /// Maps the raw `sendRaw` result of a `syncOperation` request to the outgoing - /// `BridgeMessage` sent back to JS. Pure function — no side effects — extracted - /// to keep the JS-bridge contract independently unit-testable. - static func makeSyncOperationResponse( - result: Result, - action: String, - id: UUID - ) -> BridgeMessage { - switch result { - case .success(let data): - guard let bodyString = String(data: data, encoding: .utf8) else { - return BridgeMessage( - type: .error, - action: action, - payload: .object(["error": .string("Response body is not valid UTF-8")]), - id: id - ) - } - return BridgeMessage( - type: .response, - action: action, - payload: .string(bodyString), - id: id - ) - case .failure(let error): - return BridgeMessage( - type: .error, - action: action, - payload: .string(error.createDataJSON()), - id: id - ) - } - } -} - -// MARK: - LocalState Handlers - -extension TransparentView { - - private func handleLocalStateGet(message: BridgeMessage) { - guard let payload = extractLocalStatePayload(from: message) else { - sendBridgeError("Invalid payload", action: message.action, id: message.id) - return - } - - let keys: [String] - if case .array(let arr) = payload["data"] { - keys = arr.compactMap { if case .string(let s) = $0 { return s } else { return nil } } - } else { - keys = [] - } - - let storage = localStateStorage - let state = storage.get(keys: keys) - - Logger.common( - message: "[WebView] localState.get keys=\(keys) → \(state.data.count) entries, version=\(state.version)", - level: .info, - category: .webViewInAppMessages - ) - - // Build response data: found keys → value, missing keys → null - var dataObject: [String: JSONValue] = [:] - if keys.isEmpty { - for (key, value) in state.data { - dataObject[key] = .string(value) - } - } else { - for key in keys { - if let value = state.data[key] { - dataObject[key] = .string(value) - } else { - dataObject[key] = .null - } - } - } - - let responsePayload: JSONValue = .object([ - "data": .object(dataObject), - "version": .int(state.version) - ]) - - let response = BridgeMessage( - type: .response, - action: message.action, - payload: responsePayload, - id: message.id - ) - facade?.sendToJS(response) - } - - private func handleLocalStateSet(message: BridgeMessage) { - guard let payload = extractLocalStatePayload(from: message), - case .object(let dataDict) = payload["data"] else { - sendBridgeError("Invalid payload: missing 'data' object", action: message.action, id: message.id) - return - } - - var data: [String: String?] = [:] - for (key, value) in dataDict { - switch value { - case .string(let s): - data[key] = s - case .null: - data[key] = nil as String? - default: - if let encoded = try? JSONEncoder().encode(value), - let str = String(data: encoded, encoding: .utf8) { - data[key] = str - } - } - } - - let storage = localStateStorage - let state = storage.set(data: data) - - Logger.common( - message: "[WebView] localState.set \(data.count) keys → version=\(state.version)", - level: .info, - category: .webViewInAppMessages - ) - - let response = BridgeMessage( - type: .response, - action: message.action, - payload: localStateToPayload(data: data, version: state.version), - id: message.id - ) - facade?.sendToJS(response) - } - - private func handleLocalStateInit(message: BridgeMessage) { - guard let payload = extractLocalStatePayload(from: message), - case .int(let version) = payload["version"], - case .object(let dataDict) = payload["data"] else { - sendBridgeError("Invalid payload: missing 'version' or 'data'", action: message.action, id: message.id) - return - } - - var data: [String: String?] = [:] - for (key, value) in dataDict { - switch value { - case .string(let s): - data[key] = s - case .null: - data[key] = nil as String? - default: - if let encoded = try? JSONEncoder().encode(value), - let str = String(data: encoded, encoding: .utf8) { - data[key] = str - } - } - } - - let storage = localStateStorage - guard let state = storage.initialize(version: version, data: data) else { - sendBridgeError( - "Version must be a positive integer, got \(version)", - action: message.action, - id: message.id - ) - return - } - - Logger.common( - message: "[WebView] localState.init version=\(version), \(data.count) keys", - level: .info, - category: .webViewInAppMessages - ) - - let response = BridgeMessage( - type: .response, - action: message.action, - payload: localStateToPayload(data: data, version: state.version), - id: message.id - ) - facade?.sendToJS(response) - } - - // MARK: - LocalState Helpers - - private func extractLocalStatePayload(from message: BridgeMessage) -> [String: JSONValue]? { - // Payload arrives as a JSON string: "{\"data\":{...},\"version\":3}" - if case .string(let str) = message.payload, - let data = str.data(using: .utf8), - let dict = try? JSONDecoder().decode([String: JSONValue].self, from: data) { - return dict - } - // Payload is already a decoded object - if case .object(let dict) = message.payload { - return dict - } - return nil - } - - private func localStateToPayload(data: [String: String?], version: Int) -> JSONValue { - var dataObject: [String: JSONValue] = [:] - for (key, value) in data { - dataObject[key] = value.map { .string($0) } ?? .null - } - return .object([ - "data": .object(dataObject), - "version": .int(version) - ]) - } -} - -// MARK: - Navigate Handler - -extension TransparentView { - - private func handleNavigate(message: BridgeMessage) { - guard let urlString = extractNavigateURL(from: message) else { - sendBridgeError("Invalid payload: missing or empty 'url' field", action: message.action, id: message.id) - return - } - - guard let url = URL(string: urlString) else { - sendBridgeError("Invalid URL: '\(urlString)' could not be parsed", action: message.action, id: message.id) - return - } - - let scheme = url.scheme?.lowercased() - - if scheme == "http" || scheme == "https" { - Logger.common( - message: "[WebView] navigate: trying universal link first for \(urlString)", - level: .info, - category: .webViewInAppMessages - ) - openAsUniversalLinkOrSafari(url: url, message: message) - } else { - Logger.common( - message: "[WebView] navigate: opening via UIApplication \(urlString)", - level: .info, - category: .webViewInAppMessages - ) - openViaUIApplication(url: url, message: message) - } - } - - private func openAsUniversalLinkOrSafari(url: URL, message: BridgeMessage) { - DispatchQueue.main.async { [weak self] in - UIApplication.shared.open(url, options: [.universalLinksOnly: true]) { opened in - DispatchQueue.main.async { - if opened { - Logger.common( - message: "[WebView] navigate: opened as universal link \(url.absoluteString)", - level: .info, - category: .webViewInAppMessages - ) - self?.sendBridgeSuccess(action: message.action, id: message.id) - } else { - Logger.common( - message: "[WebView] navigate: not a universal link, falling back to SFSafariViewController for \(url.absoluteString)", - level: .info, - category: .webViewInAppMessages - ) - self?.openInSafariViewController(url: url, message: message) - } - } - } - } - } - - private func openInSafariViewController(url: URL, message: BridgeMessage) { - guard let presentingVC = delegate as? UIViewController else { - Logger.common( - message: "[WebView] navigate: no presenting view controller found", - level: .default, - category: .webViewInAppMessages - ) - sendBridgeError("Failed to open URL: no presenting view controller", action: message.action, id: message.id) - return - } - - let safariVC = SFSafariViewController(url: url) - presentingVC.present(safariVC, animated: true) { [weak self] in - Logger.common( - message: "[WebView] navigate: SFSafariViewController presented for \(url.absoluteString)", - level: .info, - category: .webViewInAppMessages - ) - self?.sendBridgeSuccess(action: message.action, id: message.id) - } - } - - private func openViaUIApplication(url: URL, message: BridgeMessage) { - DispatchQueue.main.async { [weak self] in - UIApplication.shared.open(url, options: [:]) { success in - DispatchQueue.main.async { - if success { - Logger.common( - message: "[WebView] navigate: successfully opened \(url.absoluteString)", - level: .info, - category: .webViewInAppMessages - ) - self?.sendBridgeSuccess(action: message.action, id: message.id) - } else { - Logger.common( - message: "[WebView] navigate: failed to open \(url.absoluteString)", - level: .default, - category: .webViewInAppMessages - ) - self?.sendBridgeError("Failed to open URL: '\(url.absoluteString)'", action: message.action, id: message.id) - } - } - } - } - } - - private func extractNavigateURL(from message: BridgeMessage) -> String? { - guard case .string(let str) = message.payload, - let data = str.data(using: .utf8), - let dict = try? JSONDecoder().decode([String: JSONValue].self, from: data), - case .string(let urlString) = dict["url"], - !urlString.isEmpty else { - return nil - } - return urlString - } -} - -// MARK: - Permission Request Handler - -extension TransparentView { - - private func handlePermissionRequest(message: BridgeMessage) { - guard let typeString = extractPermissionType(from: message) else { - sendBridgeError("Invalid payload: missing or empty 'type' field", action: message.action, id: message.id) - return - } - - guard let permissionType = PermissionType(rawValue: typeString) else { - sendBridgeError("Unknown permission type: '\(typeString)'", action: message.action, id: message.id) - return - } - - guard let handler = permissionHandlerRegistry.handler(for: permissionType) else { - sendBridgeError("No handler registered for permission type: '\(typeString)'", action: message.action, id: message.id) - return - } - - for key in handler.requiredInfoPlistKeys { - guard Bundle.main.object(forInfoDictionaryKey: key) != nil else { - sendBridgeError("Missing Info.plist key: \(key)", action: message.action, id: message.id) - return - } - } - - handler.request { [weak self] result in - DispatchQueue.main.async { - guard let self else { return } - - switch result { - case .granted(let dialogShown): - self.sendPermissionResponse("granted", dialogShown: dialogShown, action: message.action, id: message.id) - case .denied(let dialogShown): - self.sendPermissionResponse("denied", dialogShown: dialogShown, action: message.action, id: message.id) - case .error(let errorMessage): - self.sendBridgeError(errorMessage, action: message.action, id: message.id) - } - } - } - } - - private func sendPermissionResponse(_ resultValue: String, dialogShown: Bool, action: String, id: UUID) { - let response = BridgeMessage( - type: .response, - action: action, - payload: .object(["result": .string(resultValue), "dialogShown": .bool(dialogShown)]), - id: id - ) - facade?.sendToJS(response) - } - - private func extractPermissionType(from message: BridgeMessage) -> String? { - guard case .string(let str) = message.payload, - let data = str.data(using: .utf8), - let dict = try? JSONDecoder().decode([String: JSONValue].self, from: data), - case .string(let typeString) = dict["type"], - !typeString.isEmpty else { - return nil - } - return typeString - } -} - // MARK: - WKNavigationType String Representation private extension WKNavigationType { @@ -906,134 +397,14 @@ private extension WKNavigationType { } } -// MARK: - Haptic Handler - -extension TransparentView { - - private func handleHaptic(message: BridgeMessage) { - hapticService.handle(message: message) - sendBridgeSuccess(action: message.action, id: message.id) - } -} - -// MARK: - Open Settings Handler - -extension TransparentView { - - private func handleOpenSettings(message: BridgeMessage) { - guard let settingsType = SettingsRequestParser.parse(from: message) else { - sendBridgeError("Invalid or unknown settings type", action: message.action, id: message.id) - return - } - Logger.common(message: "[WebView] openSettings: type='\(settingsType.rawValue)'", level: .info, category: .webViewInAppMessages) - - switch settingsType { - case .notifications: - handleOpenNotificationSettings(message: message) - case .application: - guard let url = URL(string: UIApplication.openSettingsURLString) else { - sendBridgeError("Failed to create application settings URL", action: message.action, id: message.id) - return - } - openViaUIApplication(url: url, message: message) - } - } - - private func handleOpenNotificationSettings(message: BridgeMessage) { - PushPermissionHelper.openPushNotificationSettings { [weak self] _ in - DispatchQueue.main.async { - self?.sendBridgeSuccess(action: message.action, id: message.id) - } - } - } -} - -// MARK: - Motion Handlers +// MARK: - System shake extension TransparentView { + /// A shake is detected by the system and arrives at the view controller, so it is handed + /// down to whoever is monitoring motion for this show. func handleSystemShake() { - guard isMotionServiceInitialized else { return } - motionService.handleSystemShake() - } - - private func handleMotionStart(message: BridgeMessage) { - guard let payload = extractMotionPayload(from: message) else { - sendBridgeError("Invalid payload: missing 'gestures' array", action: message.action, id: message.id) - return - } - - guard case .array(let gestureArray) = payload["gestures"] else { - sendBridgeError("Invalid payload: 'gestures' must be an array", action: message.action, id: message.id) - return - } - - var gestures = Set() - for item in gestureArray { - if case .string(let name) = item, let gesture = MotionGesture(rawValue: name) { - gestures.insert(gesture) - } - } - - guard !gestures.isEmpty else { - sendBridgeError("No valid gestures provided. Available: shake, flip", action: message.action, id: message.id) - return - } - - let result = motionService.startMonitoring(gestures: gestures) - - if result.allUnavailable { - sendBridgeError( - "No sensors available for requested gestures: \(result.unavailable.map(\.rawValue).joined(separator: ", "))", - action: message.action, - id: message.id - ) - } else { - var payload: [String: JSONValue] = ["success": .bool(true)] - if !result.unavailable.isEmpty { - payload["unavailable"] = .array(result.unavailable.map { .string($0.rawValue) }) - } - let response = BridgeMessage( - type: .response, - action: message.action, - payload: .object(payload), - id: message.id - ) - facade?.sendToJS(response) - } - } - - private func handleMotionStop(message: BridgeMessage) { - motionService.stopMonitoring() - sendBridgeSuccess(action: message.action, id: message.id) - } - - private func sendMotionEvent(gesture: MotionGesture, data: [String: Any]) { - var payload: [String: JSONValue] = ["gesture": .string(gesture.rawValue)] - for (key, value) in data { - if let jsonValue = JSONValue(any: value) { - payload[key] = jsonValue - } - } - - let event = BridgeMessage( - type: .request, - action: BridgeMessage.Action.motionEvent.rawValue, - payload: .object(payload) - ) - facade?.sendToJS(event) - } - - private func extractMotionPayload(from message: BridgeMessage) -> [String: JSONValue]? { - if case .string(let str) = message.payload, - let data = str.data(using: .utf8), - let dict = try? JSONDecoder().decode([String: JSONValue].self, from: data) { - return dict - } - if case .object(let dict) = message.payload { - return dict - } - return nil + actionRegistry.handler(ofType: MotionActionHandler.self)?.handleSystemShake() } } @@ -1048,5 +419,4 @@ protocol WebViewAction: AnyObject { func onCompleted(data: String) func onClose() func onHide() - func onLog(message: String) } diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/WebViewController.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/WebViewController.swift index 586f0aac..542356ed 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/WebViewController.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/WebViewController.swift @@ -296,10 +296,6 @@ extension WebViewController: WebViewAction { } } } - - func onLog(message: String) { - Logger.common(message: "[JS] \(message)", category: .webViewInAppMessages) - } } private extension WebViewController { diff --git a/Mindbox/Utilities/Constants.swift b/Mindbox/Utilities/Constants.swift index 534f369e..ff7fc3a9 100644 --- a/Mindbox/Utilities/Constants.swift +++ b/Mindbox/Utilities/Constants.swift @@ -116,12 +116,17 @@ enum Constants { } enum EmbeddedBlock { - /// Сколько встроенный блок ждёт, пока страница объявит себя готовой, прежде чем свернуться. + /// How long an embedded block waits for the page to report its content before collapsing. /// - /// Бюджет свой, а не общий с инаппами, даже при совпадающем значении: блок стоит в вёрстке - /// хоста, и его терпение — самостоятельное продуктовое решение, а не следствие таймаута - /// инаппов. - static let readyTimeoutSeconds = 7 + /// Its own budget rather than the in-apps' one, even when the numbers agree: a block sits + /// in the host's layout, and how long it holds that space is a product decision of its + /// own, not a consequence of what a popup waits for. + /// + /// It covers strictly more than a popup's: the page has to load, boot its bridge, ask for + /// its start payload, run its own pipeline — which includes waiting up to three seconds + /// on a targeting answer before giving up on it — and only then report what it drew. + /// Roughly 3s to load, 1s to boot, 3s of targeting, 3s to render, and slack. + static let readyTimeoutSeconds = 12 } enum MagicNumbers { diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockContentProviderFactoryTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockContentProviderFactoryTests.swift index 8b2dd49a..5b191f98 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockContentProviderFactoryTests.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockContentProviderFactoryTests.swift @@ -55,8 +55,7 @@ struct EmbeddedBlockContentProviderFactoryTests { @Test("The provider asks the shared resolver for its own id") func providerAsksTheSharedResolver() { let resolver = EmbeddedBlockResolverMock(resolution: .empty) - let factory = EmbeddedBlockContentProviderFactory(resolver: resolver, - actionHandler: EmbeddedBlockActionHandlerMock()) + let factory = EmbeddedBlockContentProviderFactory(resolver: resolver) let provider = factory.makeProvider(id: "factory-shared-resolver") withExtendedLifetime(provider) { @@ -71,7 +70,6 @@ struct EmbeddedBlockContentProviderFactoryTests { /// The resolver answers "empty": no page is created for such a block, so the factory's tests /// need no real web view. private func makeFactory() -> EmbeddedBlockContentProviderFactory { - EmbeddedBlockContentProviderFactory(resolver: EmbeddedBlockResolverMock(resolution: .empty), - actionHandler: EmbeddedBlockActionHandlerMock()) + EmbeddedBlockContentProviderFactory(resolver: EmbeddedBlockResolverMock(resolution: .empty)) } } diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift index 6262910d..0922ee8c 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift @@ -16,35 +16,38 @@ extension EmbeddedBlockWebContent { static let other = EmbeddedBlockWebContent(url: URL(string: "https://mindbox.ru/another-block.html")!) } -extension EmbeddedBlockPageAction { - - static let openUrlStub = EmbeddedBlockPageAction(type: "openUrl", payload: ["url": "https://mindbox.ru"]) -} - /// A page without WebKit: tests decide what it tells the native side and when. final class EmbeddedBlockPageMock: EmbeddedBlockPageHosting { let view = UIView() - var onMessage: ((EmbeddedBlockPageMessage) -> Void)? + var onContentRendered: ((Int) -> Void)? var onLoadFailure: (() -> Void)? var onLoadFinish: (() -> Void)? + var isUserPresent = true + var loadCount = 0 + var reloadCount = 0 var cancelCount = 0 func load() { loadCount += 1 } + func reload() { + reloadCount += 1 + } + func cancel() { cancelCount += 1 } - func send(_ message: EmbeddedBlockPageMessage) { - onMessage?(message) + /// The page reporting what it rendered — the one thing the provider listens for. + func renderContent(count: Int) { + onContentRendered?(count) } func failLoad() { @@ -70,10 +73,12 @@ final class EmbeddedBlockPageFactoryMock { private(set) var pages: [EmbeddedBlockPageMock] = [] private(set) var contents: [EmbeddedBlockWebContent] = [] + private(set) var ids: [String] = [] var page: EmbeddedBlockPageMock? { pages.last } - func make(_ content: EmbeddedBlockWebContent) -> EmbeddedBlockPageHosting { + func make(_ id: String, _ content: EmbeddedBlockWebContent) -> EmbeddedBlockPageHosting { + ids.append(id) contents.append(content) let page = EmbeddedBlockPageMock() pages.append(page) @@ -118,15 +123,6 @@ final class EmbeddedBlockResolverMock: EmbeddedBlockResolving { } } -final class EmbeddedBlockActionHandlerMock: EmbeddedBlockActionHandling { - - private(set) var handledActions: [EmbeddedBlockPageAction] = [] - - func handle(_ action: EmbeddedBlockPageAction) { - handledActions.append(action) - } -} - /// A clock that moves only when asked to. Monotonic seconds, matching the timeout's clock seam; /// a negative `advance` models the backward jump a monotonic clock never makes. final class TestClock { @@ -208,7 +204,6 @@ final class EmbeddedBlockTimeoutBed { final class EmbeddedBlockTestBed { let resolver: EmbeddedBlockResolverMock - let actionHandler: EmbeddedBlockActionHandlerMock let readinessOverrides: EmbeddedBlockReadinessOverridesMock let pageFactory: EmbeddedBlockPageFactoryMock let provider: EmbeddedBlockWebViewProvider @@ -219,19 +214,16 @@ final class EmbeddedBlockTestBed { resolution: EmbeddedBlockResolution = .content(.stub), treatsLoadedPageAsReady: Bool = false) { let resolver = EmbeddedBlockResolverMock(resolution: resolution) - let actionHandler = EmbeddedBlockActionHandlerMock() let readinessOverrides = EmbeddedBlockReadinessOverridesMock(treatsLoadedPageAsReady: treatsLoadedPageAsReady) let pageFactory = EmbeddedBlockPageFactoryMock() self.resolver = resolver - self.actionHandler = actionHandler self.readinessOverrides = readinessOverrides self.pageFactory = pageFactory self.provider = EmbeddedBlockWebViewProvider(id: id, resolver: resolver, - actionHandler: actionHandler, readinessOverrides: readinessOverrides, - makePage: { pageFactory.make($0) }) + makePage: { pageFactory.make($0, $1) }) } } diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewPageTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewPageTests.swift index f8579a7c..4aaa909a 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewPageTests.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewPageTests.swift @@ -7,8 +7,9 @@ // import Testing +import UIKit import WebKit -@testable import Mindbox +@_spi(Internal) @testable import Mindbox /// The page only judges its own business: the load failed or the document arrived. It is exactly /// this separation that is checked here — and above all, that a cancelled navigation does not end up @@ -29,15 +30,6 @@ struct EmbeddedBlockWebViewPageTests { #expect(bed.failures == 0) } - @Test("A cancelled navigation is not a load failure either") - func cancelledNavigationIsNotAFailure() { - let bed = PageBed() - - bed.failNavigation(with: bed.cancellationError) - - #expect(bed.failures == 0) - } - /// A real network error, on the other hand, is exactly what the page must report. @Test("A real provisional navigation error is a load failure") func realProvisionalErrorIsAFailure() { @@ -48,15 +40,6 @@ struct EmbeddedBlockWebViewPageTests { #expect(bed.failures == 1) } - @Test("A real navigation error is a load failure") - func realNavigationErrorIsAFailure() { - let bed = PageBed() - - bed.failNavigation(with: bed.error(code: NSURLErrorTimedOut)) - - #expect(bed.failures == 1) - } - /// Cancelling one navigation must not mute the page: the next real error arrives as usual. @Test("A cancellation does not swallow the failure that comes after it") func cancellationDoesNotSwallowLaterFailures() { @@ -77,6 +60,214 @@ struct EmbeddedBlockWebViewPageTests { #expect(bed.finishes == 1) #expect(bed.failures == 0) } + + // MARK: - Speaking the shared bridge + + /// The bridge drops every script message until the load's own document commits, because a + /// reused web view can still deliver a previous owner's. Handing it the navigation is what + /// opens that gate — forget it and the page's messages vanish with no error anywhere. + /// + /// > Note: what the bridge was told is not observable from here — `contentLoadIssued` and + /// > `contentURL` are private to `MindboxWebBridge`, and a `WKScriptMessage` cannot be built in + /// > a test to drive the gate from the outside. So this pins the half that is visible: the load + /// > that the registration accompanies actually goes to the block's own address. + @Test("Loading a url block loads that url") + func loadingURLContentLoadsThatURL() { + let bed = PageBed() + guard case .url(let contentURL) = EmbeddedBlockWebContent.stub.source else { + Issue.record("the stub stands for a block with an address") + return + } + + bed.page.load() + + #expect(bed.page.webView.url == contentURL) + } + + /// Markup has no address of its own: it is loaded with a nil base URL on purpose, so the page + /// gets an `about:blank` origin rather than the privileges of some domain. + @Test("Loading html content gives the page no domain of its own") + func loadingHTMLContentHasNoOrigin() { + let bed = PageBed(content: EmbeddedBlockWebContent(html: "block")) + + bed.page.load() + + #expect(bed.page.webView.url == nil || bed.page.webView.url?.absoluteString == "about:blank") + } + + // MARK: - Being a host of the shared bridge + + /// The point of the move onto the shared bridge: a request from the page's document reaches the + /// same registry an in-app show uses, with the page itself as the host. + @Test("A request from the page is routed into the action registry") + func requestIsRoutedIntoTheRegistry() throws { + let owner = RecordingHandler(actions: [.openLink]) + let bed = PageBed(handlers: [owner]) + let message = BridgeMessage.request(.openLink) + + bed.deliver(message) + + #expect(owner.handled.map(\.id) == [message.id]) + #expect(owner.hosts.first === bed.page, "the page hosts its own requests") + } + + /// Responses and errors are answers to something the SDK asked, and the dispatcher matches them + /// to their pending request. Routing them as if they were requests would run a handler twice. + /// + /// Both kinds in one test rather than as arguments: `MessageType` crossing into a `@MainActor` + /// suite as a parameter is not `Sendable` enough for the Swift 6 language mode. + @Test("Anything that is not a request is not routed") + func nonRequestIsNotRouted() { + let owner = RecordingHandler(actions: [.openLink]) + let bed = PageBed(handlers: [owner]) + let action = BridgeMessage.Action.openLink.rawValue + + bed.deliver(BridgeMessage(type: .response, action: action, payload: nil)) + bed.deliver(BridgeMessage(type: .error, action: action, payload: nil)) + + #expect(owner.handled.isEmpty) + } + + /// A vocabulary newer than the SDK is allowed: an action nobody owns is journalled and dropped, + /// not treated as a failure of the block. + @Test("An action nobody owns is dropped without disturbing the block") + func unownedActionIsDropped() { + let bed = PageBed(handlers: []) + + bed.deliver(.request(.openLink)) + + #expect(bed.failures == 0) + #expect(bed.finishes == 0) + } + + @Test("The block identifies itself by its own id and journals under its own category") + func hostIdentityIsTheBlocks() { + let bed = PageBed() + + #expect(bed.page.contentId == "block-id") + #expect(bed.page.logCategory == .embeddedBlocks) + } + + /// Tags belong to an in-app show — they are what an operation is attributed to. A block has no + /// show behind it, so it contributes none. + @Test("A block carries no in-app tags") + func hostCarriesNoTags() { + let bed = PageBed() + + #expect(bed.page.tags == nil) + } + + /// The provider is what knows whether anyone is looking, and it says so through this. A page + /// starts out in front of the user because it is built when the block enters the window. + @Test("A fresh page starts out in front of the user") + func freshPageStartsPresent() { + let bed = PageBed() + + #expect(bed.page.isUserPresent) + } + + @Test("What the provider sets is what the bridge reads") + func presenceFollowsTheProvider() { + let bed = PageBed() + + bed.page.isUserPresent = false + + #expect(bed.page.isUserPresent == false) + } + + /// The block is a piece of the host's own layout, so a tap must not replace the feed with the + /// destination in place. Links belong in `openLink`. + @Test("An in-place link navigation is refused") + func inPlaceLinkNavigationIsRefused() async { + let bed = PageBed() + + let policy = await bed.decidePolicy(for: .linkActivated) + + #expect(policy == .cancel) + } + + @Test("The page's own loads are allowed", arguments: [WKNavigationType.other, .reload, .backForward]) + func ownNavigationIsAllowed(type: WKNavigationType) async { + let bed = PageBed() + + let policy = await bed.decidePolicy(for: type) + + #expect(policy == .allow) + } + + // MARK: - Presenting from the host's hierarchy + + /// A handler that needs a controller of its own gets the topmost one. The root is not it: a root + /// that already presents something refuses to present anything else, and UIKit refuses without + /// calling the completion the answer to the page is sent from. + @Test("The presenter is the topmost presented controller, not the window root") + func presenterIsTheTopmostController() { + let bed = PageBed() + let modal = StubPresentingController() + bed.putInWindow(presenting: modal) + + #expect(bed.page.presentingViewController === modal) + } + + /// A block inside a modal that itself presents a sheet is still the same question, one level + /// deeper — the walk does not stop at the first answer. + @Test("The walk goes through the whole presentation chain") + func presenterIsFoundThroughTheChain() { + let bed = PageBed() + let sheet = StubPresentingController() + let modal = StubPresentingController() + modal.stubbedPresented = sheet + bed.putInWindow(presenting: modal) + + #expect(bed.page.presentingViewController === sheet) + } + + @Test("With nothing presented the root is the presenter") + func rootIsThePresenterWhenNothingIsPresented() { + let bed = PageBed() + bed.putInWindow(presenting: nil) + + #expect(bed.page.presentingViewController === bed.root) + } + + /// Off the window there is nobody to present from, and the handler answers the page with an + /// error rather than waiting on a presentation that cannot happen. + @Test("Off the window there is no presenter") + func thereIsNoPresenterOffTheWindow() { + let bed = PageBed() + + #expect(bed.page.presentingViewController == nil) + } +} + +/// Records what the registry routed to it, and which host came with it. +private final class RecordingHandler: WebBridgeActionHandler { + + let actions: Set + + private(set) var handled: [BridgeMessage] = [] + private(set) var hosts: [WebBridgeHost] = [] + + init(actions: Set) { + self.actions = actions + } + + func handle(_ message: BridgeMessage, host: WebBridgeHost) { + handled.append(message) + hosts.append(host) + } +} + +/// Reports whatever the test put on top of it. +/// +/// `presentedViewController` is set by an actual presentation, which drags a visible window and a +/// transition into a unit test. What is checked here is the walk, not UIKit's animation. +@MainActor +private final class StubPresentingController: UIViewController { + + var stubbedPresented: UIViewController? + + override var presentedViewController: UIViewController? { stubbedPresented } } /// A real page with a real web view, but without the network: the tests call the navigation delegate @@ -86,17 +277,36 @@ private final class PageBed { let page: EmbeddedBlockWebViewPage + /// The controller the host's window is rooted at. + let root = StubPresentingController() + private(set) var failures = 0 private(set) var finishes = 0 var cancellationError: Error { error(code: NSURLErrorCancelled) } + /// Held on purpose: a window nobody retains takes the page's view out of the hierarchy with it, + /// and the page would look off screen again. + private var window: UIWindow? + /// Through the protocol rather than directly: the page has both a `webView` property and /// delegate methods with the same name, so they are better called where the name is unambiguous. - private var navigation: WKNavigationDelegate { page } + private var navigation: WebBridgeNavigationDelegate { page } - init() { - page = EmbeddedBlockWebViewPage(content: .stub, webView: WKWebView()) + private var messages: WebBridgeMessageDelegate { page } + + private let bridge: MindboxWebBridge + + /// - Parameter handlers: the registry the page routes into. Empty by default — a suite that does + /// not send anything has no use for the shipped set, and building it here would drag every + /// handler's dependencies into tests about navigation. + init(content: EmbeddedBlockWebContent = .stub, handlers: [WebBridgeActionHandler] = []) { + let webView = WKWebView() + bridge = MindboxWebBridge(webView: webView) + page = EmbeddedBlockWebViewPage(id: "block-id", + content: content, + webView: webView, + actionRegistry: WebBridgeActionRegistry(handlers: handlers)) page.onLoadFailure = { [weak self] in self?.failures += 1 } @@ -105,19 +315,41 @@ private final class PageBed { } } + /// Hands the page a message as the bridge would. + func deliver(_ message: BridgeMessage) { + messages.webBridge(bridge, didReceiveBridgeMessage: message) + } + + /// Puts the page where a real block lives — inside the host's own view hierarchy — with + /// `presented` standing on top of the window's root. + func putInWindow(presenting presented: UIViewController?) { + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + root.stubbedPresented = presented + window.rootViewController = root + // Straight onto the window rather than into the root's view: all the page needs is to have + // a window, and a test window belongs to no scene — it installs its root's view only when + // it is about to be shown. + window.addSubview(page.view) + self.window = window + } + func error(code: Int) -> Error { NSError(domain: NSURLErrorDomain, code: code) } func failProvisionalNavigation(with error: Error) { - navigation.webView?(page.webView, didFailProvisionalNavigation: nil, withError: error) + navigation.webBridge(bridge, didFailProvisionalNavigation: nil, error: error) } - func failNavigation(with error: Error) { - navigation.webView?(page.webView, didFail: nil, withError: error) + func finishNavigation() { + navigation.webBridge(bridge, didFinishNavigation: nil) } - func finishNavigation() { - navigation.webView?(page.webView, didFinish: nil) + func decidePolicy(for type: WKNavigationType) async -> WKNavigationActionPolicy { + await withCheckedContinuation { continuation in + navigation.webBridge(bridge, decidePolicyFor: nil, navigationType: type) { + continuation.resume(returning: $0) + } + } } } diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewProviderTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewProviderTests.swift index 29e7eb91..4dd19117 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewProviderTests.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewProviderTests.swift @@ -68,7 +68,7 @@ struct EmbeddedBlockWebViewProviderTests { bed.provider.onStateChange = { states.append($0) } bed.provider.start() - bed.page?.send(.ready(height: 104)) + bed.page?.renderContent(count: 3) #expect(states == [.loading, .ready]) #expect(bed.provider.contentView === bed.page?.view) @@ -89,46 +89,49 @@ struct EmbeddedBlockWebViewProviderTests { #expect(bed.provider.contentView == nil) } - /// A page with no content is more honest saying `empty`, so a zero height is broken layout. - @Test("Zero height in ready is a failure") - func zeroHeightIsFailure() { + /// Alive, correct, and with nothing to show. Not a failure — the block gives its space back + /// without an error screen, which is exactly the outcome popups have no equivalent of. + @Test("Rendering nothing collapses the block") + func renderingNothingCollapsesTheBlock() { let bed = EmbeddedBlockTestBed() var states: [EmbeddedBlockState] = [] bed.provider.onStateChange = { states.append($0) } bed.provider.start() - bed.page?.send(.ready(height: 0)) + bed.page?.renderContent(count: 0) - #expect(states.last == .failed) + #expect(states.last == .empty) #expect(bed.provider.contentView == nil) } - /// The host owns the height: the message exists in the contract, but it does not touch layout. - @Test("Height change leaves the state alone") - func heightChangeChangesNothing() { + /// Once per load. A page that reports twice must not be able to bring back a block that + /// already gave its space back. + @Test("A repeated report is ignored") + func repeatedReportIsIgnored() { let bed = EmbeddedBlockTestBed() bed.provider.start() var states: [EmbeddedBlockState] = [] bed.provider.onStateChange = { states.append($0) } - bed.page?.send(.ready(height: 104)) - bed.page?.send(.heightChanged(height: 132)) + bed.page?.renderContent(count: 0) + bed.page?.renderContent(count: 5) - #expect(states == [.ready]) + #expect(states == [.empty]) + #expect(bed.provider.contentView == nil) } - @Test("Page empty collapses the block") - func pageEmptyCollapsesTheBlock() { + @Test("A fresh load may report again") + func freshLoadReportsAgain() { let bed = EmbeddedBlockTestBed() bed.provider.start() + bed.page?.renderContent(count: 0) + + bed.provider.reload() var states: [EmbeddedBlockState] = [] bed.provider.onStateChange = { states.append($0) } + bed.page?.renderContent(count: 3) - bed.page?.send(.ready(height: 104)) - bed.page?.send(.empty) - - #expect(states == [.ready, .empty]) - #expect(bed.provider.contentView == nil) + #expect(states.last == .ready) } // MARK: - Debug readiness @@ -171,7 +174,7 @@ struct EmbeddedBlockWebViewProviderTests { bed.provider.onStateChange = { states.append($0) } bed.provider.start() - bed.page?.send(.ready(height: 104)) + bed.page?.renderContent(count: 3) bed.page?.finishLoad() #expect(states == [.loading, .ready]) @@ -187,7 +190,7 @@ struct EmbeddedBlockWebViewProviderTests { bed.provider.start() bed.page?.finishLoad() - bed.page?.send(.empty) + bed.page?.renderContent(count: 0) #expect(states == [.loading, .ready, .empty]) #expect(bed.provider.contentView == nil) @@ -253,96 +256,73 @@ struct EmbeddedBlockWebViewProviderTests { #expect(states.isEmpty) } - // MARK: - Page actions + // MARK: - Acting on the user's behalf - /// The core does not know the page's dictionary: everything above the core layer goes to the - /// handler as is and does not touch the container's state. - @Test("Page action is routed to the handler and changes no state") - func actionIsRouted() { + /// The page outlives the block's time on screen — it can still deliver whatever its + /// `setTimeout` scheduled — so the provider keeps it told whether a user is actually there. + /// The bridge reads that before doing anything on the user's behalf, such as opening a link. + @Test("A block being shown counts as the user being present") + func shownBlockHasUserPresent() { let bed = EmbeddedBlockTestBed() bed.provider.start() - var states: [EmbeddedBlockState] = [] - bed.provider.onStateChange = { states.append($0) } - let action = EmbeddedBlockPageAction(type: "openUrl", payload: ["url": "https://mindbox.ru"]) - bed.page?.send(.action(action)) + bed.page?.renderContent(count: 3) - #expect(bed.actionHandler.handledActions == [action]) - #expect(states.isEmpty) + #expect(bed.page?.isUserPresent == true) } - /// A stopped provider stays silent entirely — including not waking the action handler. - @Test("Actions after a stop do not reach the handler") - func actionsAfterStopAreIgnored() { + @Test("A block still loading counts as the user being present") + func loadingBlockHasUserPresent() { let bed = EmbeddedBlockTestBed() bed.provider.start() - bed.provider.stop() - bed.page?.send(.action(EmbeddedBlockPageAction(type: "openUrl", payload: [:]))) - #expect(bed.actionHandler.handledActions.isEmpty) + #expect(bed.page?.isUserPresent == true) } - @Test("Action from a shown block is routed") - func actionFromShownBlockIsRouted() { + @Test("A block that left the window does not") + func stoppedBlockHasNoUser() { let bed = EmbeddedBlockTestBed() bed.provider.start() - bed.page?.send(.ready(height: 104)) - bed.page?.send(.action(.openUrlStub)) + bed.provider.stop() - #expect(bed.actionHandler.handledActions == [.openUrlStub]) + #expect(bed.page?.isUserPresent == false) } - /// A collapsed block does not kill the page — it stays alive and may still deliver what it - /// scheduled. But there is no user touch behind an invisible block, and `openUrl` would take - /// the user out of the app for no reason. - @Test("Actions from a block collapsed as empty do not reach the handler") - func actionsAfterEmptyAreIgnored() { + /// Collapsing does not kill the page: it stays alive and may still deliver something, but + /// nothing the user did stands behind a block that is no longer on screen. + @Test("A block collapsed as empty does not") + func emptyBlockHasNoUser() { let bed = EmbeddedBlockTestBed() bed.provider.start() - bed.page?.send(.empty) - bed.page?.send(.action(.openUrlStub)) + bed.page?.renderContent(count: 0) - #expect(bed.actionHandler.handledActions.isEmpty) + #expect(bed.page?.isUserPresent == false) } - @Test("Actions from a failed block do not reach the handler") - func actionsAfterFailureAreIgnored() { + @Test("A failed block does not") + func failedBlockHasNoUser() { let bed = EmbeddedBlockTestBed() bed.provider.start() bed.page?.failLoad() - bed.page?.send(.action(.openUrlStub)) - - #expect(bed.actionHandler.handledActions.isEmpty) - } - - /// Broken layout is the same as a block not shown: its actions are not executed either. - @Test("Actions from a block broken by a zero height do not reach the handler") - func actionsAfterZeroHeightAreIgnored() { - let bed = EmbeddedBlockTestBed() - bed.provider.start() - - bed.page?.send(.ready(height: 0)) - bed.page?.send(.action(.openUrlStub)) - #expect(bed.actionHandler.handledActions.isEmpty) + #expect(bed.page?.isUserPresent == false) } /// The ban rests on the attempt's outcome, not on the page: a new attempt is live again. - @Test("A new attempt after a failure accepts actions again") - func retryAfterFailureAcceptsActions() { + @Test("A new attempt after a failure counts as present again") + func retryAfterFailureHasUserPresent() { let bed = EmbeddedBlockTestBed() bed.provider.start() bed.page?.failLoad() bed.provider.stop() bed.provider.start() - bed.page?.send(.action(.openUrlStub)) - #expect(bed.actionHandler.handledActions == [.openUrlStub]) + #expect(bed.page?.isUserPresent == true) } // MARK: - Stop and restart @@ -357,7 +337,7 @@ struct EmbeddedBlockWebViewProviderTests { bed.provider.onStateChange = { states.append($0) } bed.provider.stop() - bed.page?.send(.ready(height: 104)) + bed.page?.renderContent(count: 3) #expect(bed.page?.cancelCount == 1) #expect(states.isEmpty) @@ -373,7 +353,7 @@ struct EmbeddedBlockWebViewProviderTests { bed.provider.start() bed.provider.stop() bed.provider.start() - bed.page?.send(.ready(height: 104)) + bed.page?.renderContent(count: 3) #expect(bed.resolver.resolveCount == 1) #expect(bed.pageFactory.pages.count == 1) @@ -387,7 +367,7 @@ struct EmbeddedBlockWebViewProviderTests { func renderedPageIsShownAgainWithoutReload() { let bed = EmbeddedBlockTestBed() bed.provider.start() - bed.page?.send(.ready(height: 104)) + bed.page?.renderContent(count: 3) bed.provider.stop() var states: [EmbeddedBlockState] = [] bed.provider.onStateChange = { states.append($0) } @@ -466,8 +446,7 @@ struct EmbeddedBlockWebViewProviderTests { private func makeProvider(id: String) -> EmbeddedBlockWebViewProvider { EmbeddedBlockWebViewProvider(id: id, resolver: EmbeddedBlockResolverMock(), - actionHandler: EmbeddedBlockActionHandlerMock(), - makePage: { _ in EmbeddedBlockPageMock() }) + makePage: { _, _ in EmbeddedBlockPageMock() }) } /// The resolve may have arrived after the stop — then it belongs to the previous attempt. @@ -492,7 +471,7 @@ struct EmbeddedBlockWebViewProviderTests { func reloadRefetchesTheContent() { let bed = EmbeddedBlockTestBed() bed.provider.start() - bed.page?.send(.ready(height: 104)) + bed.page?.renderContent(count: 3) let firstPage = bed.page var states: [EmbeddedBlockState] = [] bed.provider.onStateChange = { states.append($0) } @@ -515,13 +494,13 @@ struct EmbeddedBlockWebViewProviderTests { func droppedPageIsSilenced() { let bed = EmbeddedBlockTestBed() bed.provider.start() - bed.page?.send(.ready(height: 104)) + bed.page?.renderContent(count: 3) let firstPage = bed.page bed.provider.reload() var states: [EmbeddedBlockState] = [] bed.provider.onStateChange = { states.append($0) } - firstPage?.send(.ready(height: 104)) + firstPage?.renderContent(count: 3) firstPage?.failLoad() #expect(states.isEmpty) @@ -546,10 +525,10 @@ struct EmbeddedBlockWebViewProviderTests { func reloadedBlockBecomesReady() { let bed = EmbeddedBlockTestBed() bed.provider.start() - bed.page?.send(.ready(height: 104)) + bed.page?.renderContent(count: 3) bed.provider.reload() - bed.page?.send(.ready(height: 104)) + bed.page?.renderContent(count: 3) #expect(bed.provider.contentView === bed.page?.view) } diff --git a/MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockViewTests.swift b/MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockViewTests.swift index 7eb03ac6..118e70b5 100644 --- a/MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockViewTests.swift +++ b/MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockViewTests.swift @@ -32,7 +32,7 @@ struct MindboxEmbeddedBlockViewTests { let block = BlockFixture() block.attachToWindow() - block.page?.send(.ready(height: 96)) + block.page?.renderContent(count: 3) #expect(block.view.intrinsicContentSize.height == 120) } @@ -112,7 +112,7 @@ struct MindboxEmbeddedBlockViewTests { block.view.errorView = UIView() block.attachToWindow() - block.page?.send(.empty) + block.page?.renderContent(count: 0) #expect(block.view.intrinsicContentSize.height == 0) } @@ -132,7 +132,7 @@ struct MindboxEmbeddedBlockViewTests { let block = BlockFixture() block.attachToWindow() - block.page?.send(.ready(height: 96)) + block.page?.renderContent(count: 3) let content = try #require(block.page?.view) #expect(content.superview === block.view) @@ -146,7 +146,7 @@ struct MindboxEmbeddedBlockViewTests { let block = BlockFixture() block.attachToWindow() - block.page?.send(.ready(height: 96)) + block.page?.renderContent(count: 3) let content = try #require(block.page?.view) block.page?.failLoad() @@ -154,14 +154,27 @@ struct MindboxEmbeddedBlockViewTests { #expect(block.view.intrinsicContentSize.height == 0) } - @Test("Empty content is detached") - func emptyContentIsDetached() throws { + /// A page that drew nothing has a view all the same — it just never goes on screen. + @Test("A block that renders nothing attaches no content") + func emptyBlockAttachesNoContent() throws { let block = BlockFixture() block.attachToWindow() - block.page?.send(.ready(height: 96)) + block.page?.renderContent(count: 0) + + let content = try #require(block.page?.view) + #expect(content.superview == nil) + } + + /// Content that did go on screen has to come off it again when the block stops showing. + @Test("Shown content is detached when the block collapses") + func shownContentIsDetachedOnCollapse() throws { + let block = BlockFixture() + block.attachToWindow() + block.page?.renderContent(count: 3) let content = try #require(block.page?.view) - block.page?.send(.empty) + + block.page?.failLoad() #expect(content.superview == nil) } @@ -171,7 +184,7 @@ struct MindboxEmbeddedBlockViewTests { func reloadDetachesOldContent() throws { let block = BlockFixture() block.attachToWindow() - block.page?.send(.ready(height: 96)) + block.page?.renderContent(count: 3) let oldContent = try #require(block.page?.view) block.view.reload() @@ -216,7 +229,7 @@ struct MindboxEmbeddedBlockViewTests { block.attachToWindow() await mainQueueTurn() - block.page?.send(.ready(height: 96)) + block.page?.renderContent(count: 3) await mainQueueTurn() #expect(delegate.events == [.loaded]) @@ -332,7 +345,7 @@ struct MindboxEmbeddedBlockViewTests { block.attachToWindow() await mainQueueTurn() - block.page?.send(.ready(height: 96)) + block.page?.renderContent(count: 3) await mainQueueTurn() block.page?.failLoad() await mainQueueTurn() @@ -352,8 +365,8 @@ struct MindboxEmbeddedBlockViewTests { var reported: [EmbeddedBlockPresentation] = [] block.view.onPresentationChange = { reported.append($0) } - block.page?.send(.ready(height: 96)) - block.page?.send(.empty) + block.page?.renderContent(count: 3) + block.page?.failLoad() #expect(reported == [EmbeddedBlockPresentation(layer: .content, height: 120), EmbeddedBlockPresentation(layer: .nothing, height: 0)]) @@ -393,7 +406,7 @@ struct MindboxEmbeddedBlockViewTests { func reloadReportsPlaceholderLayer() { let block = BlockFixture() block.attachToWindow() - block.page?.send(.ready(height: 96)) + block.page?.renderContent(count: 3) var reported: [EmbeddedBlockPresentation] = [] block.view.onPresentationChange = { reported.append($0) } @@ -428,7 +441,7 @@ struct MindboxEmbeddedBlockViewTests { block.view.delegate = delegate block.attachToWindow() await mainQueueTurn() - block.page?.send(.ready(height: 96)) + block.page?.renderContent(count: 3) await mainQueueTurn() let content = try #require(block.page?.view) @@ -483,7 +496,7 @@ struct MindboxEmbeddedBlockViewTests { block.removeFromWindow() block.attachToWindow() - block.page?.send(.ready(height: 96)) + block.page?.renderContent(count: 3) await mainQueueTurn() #expect(block.view.intrinsicContentSize.height == 120) @@ -546,7 +559,7 @@ struct MindboxEmbeddedBlockViewTests { block.view.delegate = delegate block.attachToWindow() - block.page?.send(.ready(height: 96)) + block.page?.renderContent(count: 3) // A shown block has disarmed the budget, so a declared "time is up" no longer concerns it. block.expireTimeout() await mainQueueTurn() @@ -628,12 +641,12 @@ struct MindboxEmbeddedBlockViewTests { block.view.delegate = delegate block.attachToWindow() await mainQueueTurn() - block.page?.send(.ready(height: 96)) + block.page?.renderContent(count: 3) await mainQueueTurn() block.view.reload() await mainQueueTurn() - block.page?.send(.ready(height: 96)) + block.page?.renderContent(count: 3) await mainQueueTurn() #expect(block.bed.resolver.forceRefreshHistory == [false, true]) @@ -661,7 +674,7 @@ struct MindboxEmbeddedBlockViewTests { let delegate = EmbeddedBlockViewDelegateMock() block.view.delegate = delegate block.attachToWindow() - block.page?.send(.ready(height: 96)) + block.page?.renderContent(count: 3) block.view.reload() block.expireTimeout() diff --git a/MindboxTests/InApp/Tests/TransparentViewSyncOperationResponseTests.swift b/MindboxTests/InApp/Tests/TransparentViewSyncOperationResponseTests.swift index cc3e80fd..199d3c53 100644 --- a/MindboxTests/InApp/Tests/TransparentViewSyncOperationResponseTests.swift +++ b/MindboxTests/InApp/Tests/TransparentViewSyncOperationResponseTests.swift @@ -7,7 +7,7 @@ import Testing import Foundation @_spi(Internal) @testable import Mindbox -@Suite("TransparentView.makeSyncOperationResponse") +@Suite("OperationActionHandler.makeSyncOperationResponse") struct TransparentViewSyncOperationResponseTests { private let action = "syncOperation" @@ -20,7 +20,7 @@ struct TransparentViewSyncOperationResponseTests { let rawBody = #"{"status":"ValidationError","validationMessages":[{"message":"Invalid email","location":"/customer/email"}]}"# let data = try #require(rawBody.data(using: .utf8)) - let outgoing = TransparentView.makeSyncOperationResponse( + let outgoing = OperationActionHandler.makeSyncOperationResponse( result: .success(data), action: action, id: requestId @@ -43,7 +43,7 @@ struct TransparentViewSyncOperationResponseTests { let rawBody = #"{"status":"Success","customer":{"email":"a@b.c"}}"# let data = try #require(rawBody.data(using: .utf8)) - let outgoing = TransparentView.makeSyncOperationResponse( + let outgoing = OperationActionHandler.makeSyncOperationResponse( result: .success(data), action: action, id: requestId @@ -64,7 +64,7 @@ struct TransparentViewSyncOperationResponseTests { let rawBody = "plain text body" let data = try #require(rawBody.data(using: .utf8)) - let outgoing = TransparentView.makeSyncOperationResponse( + let outgoing = OperationActionHandler.makeSyncOperationResponse( result: .success(data), action: action, id: requestId @@ -82,7 +82,7 @@ struct TransparentViewSyncOperationResponseTests { @Test("HTTP 200 with empty body becomes .response with empty string payload") func emptyBody_becomesResponseWithEmptyString() { - let outgoing = TransparentView.makeSyncOperationResponse( + let outgoing = OperationActionHandler.makeSyncOperationResponse( result: .success(Data()), action: action, id: requestId @@ -103,7 +103,7 @@ struct TransparentViewSyncOperationResponseTests { // Bytes that are not valid UTF-8: lone continuation byte 0xC3 + invalid follow-up let data = Data([0xC3, 0x28]) - let outgoing = TransparentView.makeSyncOperationResponse( + let outgoing = OperationActionHandler.makeSyncOperationResponse( result: .success(data), action: action, id: requestId @@ -135,7 +135,7 @@ struct TransparentViewSyncOperationResponseTests { @Test("Protocol error payload is the data contents: status, errorMessage, httpStatusCode, errorId") func protocolError_payloadIsDataContentsOnly() throws { let pe = ProtocolError(status: .protocolError, errorMessage: "Operation Test not found", httpStatusCode: 400, errorId: "error-id-1") - let outgoing = TransparentView.makeSyncOperationResponse( + let outgoing = OperationActionHandler.makeSyncOperationResponse( result: .failure(.protocolError(pe)), action: action, id: requestId @@ -151,7 +151,7 @@ struct TransparentViewSyncOperationResponseTests { @Test("Server error payload is the data contents with InternalServerError status") func serverError_payloadIsDataContentsOnly() throws { let pe = ProtocolError(status: .internalServerError, errorMessage: "Something went wrong", httpStatusCode: 500) - let outgoing = TransparentView.makeSyncOperationResponse( + let outgoing = OperationActionHandler.makeSyncOperationResponse( result: .failure(.serverError(pe)), action: action, id: requestId @@ -165,7 +165,7 @@ struct TransparentViewSyncOperationResponseTests { @Test("Connection failure payload is the data contents without the NetworkError envelope") func connectionError_payloadIsDataContentsOnly() throws { - let outgoing = TransparentView.makeSyncOperationResponse( + let outgoing = OperationActionHandler.makeSyncOperationResponse( result: .failure(.connectionError), action: action, id: requestId @@ -182,7 +182,7 @@ struct TransparentViewSyncOperationResponseTests { status: .validationError, validationMessages: [ValidationMessage(message: "Invalid email", location: "/customer/email")] ) - let outgoing = TransparentView.makeSyncOperationResponse( + let outgoing = OperationActionHandler.makeSyncOperationResponse( result: .failure(.validationError(ve)), action: action, id: requestId @@ -198,7 +198,7 @@ struct TransparentViewSyncOperationResponseTests { @Test("Internal error payload is the data contents with errorKey") func internalError_payloadIsDataContentsOnly() throws { - let outgoing = TransparentView.makeSyncOperationResponse( + let outgoing = OperationActionHandler.makeSyncOperationResponse( result: .failure(.internalError(InternalError(errorKey: .parsing, reason: "Broken body"))), action: action, id: requestId @@ -214,7 +214,7 @@ struct TransparentViewSyncOperationResponseTests { let url = try #require(URL(string: "https://api.mindbox.ru/v3/operations/sync")) let httpResponse = try #require(HTTPURLResponse(url: url, statusCode: 403, httpVersion: nil, headerFields: nil)) - let outgoing = TransparentView.makeSyncOperationResponse( + let outgoing = OperationActionHandler.makeSyncOperationResponse( result: .failure(.invalidResponse(httpResponse)), action: action, id: requestId @@ -229,7 +229,7 @@ struct TransparentViewSyncOperationResponseTests { func unknownError_payloadIsDataContentsOnly() throws { let underlying = NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "Something exploded"]) - let outgoing = TransparentView.makeSyncOperationResponse( + let outgoing = OperationActionHandler.makeSyncOperationResponse( result: .failure(.unknown(underlying)), action: action, id: requestId @@ -248,7 +248,7 @@ struct TransparentViewSyncOperationResponseTests { let specificId = UUID() let data = try #require("body".data(using: .utf8)) - let outgoing = TransparentView.makeSyncOperationResponse( + let outgoing = OperationActionHandler.makeSyncOperationResponse( result: .success(data), action: specificAction, id: specificId diff --git a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/BridgeHandlerDoubles.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/BridgeHandlerDoubles.swift new file mode 100644 index 00000000..22ebfcc0 --- /dev/null +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/BridgeHandlerDoubles.swift @@ -0,0 +1,63 @@ +// +// BridgeHandlerDoubles.swift +// MindboxTests +// +// Created by Akylbek Utekeshev on 13.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import UIKit +import MindboxLogger +@_spi(Internal) @testable import Mindbox + +/// A page that records what it was told to send. +/// +/// Everything a handler could want from a real page is inert here: these suites are about what +/// a handler does with a message, not about any particular surface. +final class HostSpy: WebBridgeHost { + + var contentId = "test-content-id" + var logCategory: LogCategory = .webViewInAppMessages + var tags: [String: String]? + var presentingViewController: UIViewController? { nil } + var isUserPresent = true + + /// What this page answers `ready` with. A plain stand-in: composing the real payload is + /// the builder's own subject, not something every handler suite should drag in. + var startPayload: JSONValue = .string("{}") + + private(set) var sent: [BridgeMessage] = [] + + func send(_ message: BridgeMessage) { + sent.append(message) + } + + func makeStartPayload() -> JSONValue { + startPayload + } +} + +extension BridgeMessage { + + /// A request as it arrives from JS: the action travels as a string, never as the enum. + static func request(_ action: Action, payload: JSONValue? = nil) -> BridgeMessage { + BridgeMessage(type: .request, action: action.rawValue, payload: payload) + } +} + +/// Lets the main queue run the work a handler scheduled on it, until `isDone` holds. +/// +/// Opening a link hops through the main queue more than once — the handler defers, the system +/// answers on its own turn, and the answer is delivered on another — and each hop is only +/// enqueued while the previous one runs. Waiting a single turn would sample the state before +/// the later hops exist, which is exactly the kind of flake that passes locally and fails in CI. +@MainActor +func drainMainQueue(until isDone: () -> Bool, turns: Int = 10) async { + for _ in 0.. JSONValue { + .string("{}") + } + + func bridgeDidRenderContent(count: Int) { + rendered.append(count) + } +} diff --git a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/HapticActionHandlerTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/HapticActionHandlerTests.swift new file mode 100644 index 00000000..76747768 --- /dev/null +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/HapticActionHandlerTests.swift @@ -0,0 +1,112 @@ +// +// HapticActionHandlerTests.swift +// MindboxTests +// +// Created by Akylbek Utekeshev on 13.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +@_spi(Internal) @testable import Mindbox + +@Suite("HapticActionHandler", .tags(.webView)) +struct HapticActionHandlerTests { + + private func makeSUT() -> (handler: HapticActionHandler, service: HapticServiceSpy, host: HostSpy) { + let service = HapticServiceSpy() + return (HapticActionHandler(makeService: { service }), service, HostSpy()) + } + + @Test("Owns the haptic action") + func ownsHaptic() { + #expect(HapticActionHandler().actions == [.haptic]) + } + + @Test("The request is handed to the engine and confirmed") + func requestReachesEngine() throws { + let (handler, service, host) = makeSUT() + let message = BridgeMessage.request(.haptic, payload: .object(["type": .string("selection")])) + + handler.handle(message, host: host) + + #expect(service.handled.count == 1) + let response = try #require(host.sent.first) + #expect(response.type == .response) + #expect(response.payload == .object(["success": .bool(true)])) + #expect(response.id == message.id) + } + + /// The engine is warmed up when the page reports it is up, so the first tap does not pay + /// for starting it. + @Test("Preparing warms the engine") + func prepareWarmsEngine() { + let (handler, service, _) = makeSUT() + + handler.prepare() + + #expect(service.prepareCount == 1) + } + + @Test("Teardown stops a pattern once the engine was in use") + func tearDownStopsPattern() { + let (handler, service, host) = makeSUT() + handler.handle(.request(.haptic, payload: .object(["type": .string("selection")])), host: host) + + handler.tearDown() + + #expect(service.stopCount == 1) + } + + /// Every show ends, and most never buzz: teardown must not be what builds an engine. + @Test("Teardown builds no engine when haptics were never used") + func tearDownBuildsNothingWhenUnused() { + var built = 0 + let handler = HapticActionHandler(makeService: { + built += 1 + return HapticServiceSpy() + }) + + handler.tearDown() + + #expect(built == 0) + } + + /// The service is registered transient, so a second resolution would be a second engine — + /// preparing one while playing on another silently does nothing. + @Test("One engine serves preparing, playing and stopping") + func oneEngineThroughout() { + var built = 0 + let service = HapticServiceSpy() + let handler = HapticActionHandler(makeService: { built += 1; return service }) + + handler.prepare() + handler.handle(.request(.haptic, payload: .object(["type": .string("selection")])), host: HostSpy()) + handler.tearDown() + + #expect(built == 1) + #expect(service.prepareCount == 1) + #expect(service.handled.count == 1) + #expect(service.stopCount == 1) + } +} + +// MARK: - Doubles + +final class HapticServiceSpy: HapticServiceProtocol { + + private(set) var prepareCount = 0 + private(set) var stopCount = 0 + private(set) var handled: [BridgeMessage] = [] + + func prepare() { + prepareCount += 1 + } + + func stopPattern() { + stopCount += 1 + } + + func handle(message: BridgeMessage) { + handled.append(message) + } +} diff --git a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/LifecycleActionHandlerTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/LifecycleActionHandlerTests.swift new file mode 100644 index 00000000..9bce9d0c --- /dev/null +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/LifecycleActionHandlerTests.swift @@ -0,0 +1,112 @@ +// +// LifecycleActionHandlerTests.swift +// MindboxTests +// +// Created by Akylbek Utekeshev on 13.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +import UIKit +import MindboxLogger +@_spi(Internal) @testable import Mindbox + +@Suite("LifecycleActionHandler", .tags(.webView)) +struct LifecycleActionHandlerTests { + + @Test("Owns the four lifecycle actions") + func ownsLifecycleActions() { + #expect(LifecycleActionHandler().actions == [.close, .`init`, .click, .hide]) + } + + @Test("Each action reaches its own callback", arguments: [ + (BridgeMessage.Action.`init`, "init"), + (.close, "close"), + (.hide, "hide") + ]) + func actionReachesItsCallback(action: BridgeMessage.Action, expected: String) { + let host = LifecycleHostSpy() + + LifecycleActionHandler().handle(.request(action), host: host) + + #expect(host.events == [expected]) + } + + /// What a tap means is decided above the bridge, so the payload travels untouched. + @Test("A click forwards its payload verbatim") + func clickForwardsPayload() { + let host = LifecycleHostSpy() + let payload = #"{"$type":"redirectUrl","value":"https://example.com"}"# + + LifecycleActionHandler().handle(.request(.click, payload: .string(payload)), host: host) + + #expect(host.events == ["click"]) + #expect(host.clickPayloads == [payload]) + } + + /// None of the four is deferred, so the dispatcher has already answered. Answering again + /// would arrive against an id JS has closed. + @Test("Never answers, whatever the action") + func neverAnswers() { + let host = LifecycleHostSpy() + + for action in [BridgeMessage.Action.`init`, .close, .hide, .click] { + LifecycleActionHandler().handle(.request(action), host: host) + } + + #expect(host.sent.isEmpty) + } + + /// The point of the capability design: a page may speak the whole vocabulary wherever it + /// lives, and a surface with no window to close simply does not listen. Not an error — + /// the day such a surface wants these, it conforms and nothing else changes. + @Test("A host without the capability drops the action instead of failing") + func hostWithoutCapabilityIgnoresAction() { + let host = HostSpy() + + LifecycleActionHandler().handle(.request(.close), host: host) + + #expect(host.sent.isEmpty) + } +} + +// MARK: - Doubles + +/// A page that also steers its own life. +private final class LifecycleHostSpy: WebBridgeHost, WebBridgeLifecycleHosting { + + var contentId = "test-content-id" + var logCategory: LogCategory = .webViewInAppMessages + var tags: [String: String]? + var presentingViewController: UIViewController? { nil } + var isUserPresent = true + + private(set) var sent: [BridgeMessage] = [] + private(set) var events: [String] = [] + private(set) var clickPayloads: [String] = [] + + func send(_ message: BridgeMessage) { + sent.append(message) + } + + func makeStartPayload() -> JSONValue { + .string("{}") + } + + func bridgeDidInit() { + events.append("init") + } + + func bridgeDidRequestClose() { + events.append("close") + } + + func bridgeDidRequestHide() { + events.append("hide") + } + + func bridgeDidClick(rawPayload: String) { + events.append("click") + clickPayloads.append(rawPayload) + } +} diff --git a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/LocalStateActionHandlerTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/LocalStateActionHandlerTests.swift new file mode 100644 index 00000000..fdacd553 --- /dev/null +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/LocalStateActionHandlerTests.swift @@ -0,0 +1,228 @@ +// +// LocalStateActionHandlerTests.swift +// MindboxTests +// +// Created by Akylbek Utekeshev on 13.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +@_spi(Internal) @testable import Mindbox + +@Suite("LocalStateActionHandler", .tags(.webView)) +struct LocalStateActionHandlerTests { + + private func makeSUT() -> (handler: LocalStateActionHandler, storage: LocalStateStorageSpy, host: HostSpy) { + let storage = LocalStateStorageSpy() + return (LocalStateActionHandler(makeStorage: { storage }), storage, HostSpy()) + } + + @Test("Owns all three local state actions") + func ownsLocalStateActions() { + #expect(LocalStateActionHandler().actions == [.localStateGet, .localStateSet, .localStateInit]) + } + + // MARK: - get + + @Test("Requested keys are read and answered with the stored version") + func getReadsRequestedKeys() throws { + let (handler, storage, host) = makeSUT() + storage.stored = ["seen": "1"] + + handler.handle(.request(.localStateGet, payload: .object(["data": .array([.string("seen")])])), host: host) + + #expect(storage.requestedKeys == ["seen"]) + let response = try #require(host.sent.first) + #expect(response.type == .response) + #expect(response.payload == .object(["data": .object(["seen": .string("1")]), "version": .int(7)])) + } + + /// The page has to tell "stored as empty" from "never stored", so a miss answers null + /// rather than being left out of the object. + @Test("A key that is not stored answers null instead of being omitted") + func getAnswersNullForMissingKey() throws { + let (handler, storage, host) = makeSUT() + storage.stored = ["seen": "1"] + + handler.handle(.request(.localStateGet, payload: .object(["data": .array([.string("seen"), .string("absent")])])), + host: host) + + let response = try #require(host.sent.first) + #expect(response.payload == .object([ + "data": .object(["seen": .string("1"), "absent": .null]), + "version": .int(7) + ])) + } + + @Test("Asking for no keys asks for everything") + func getWithoutKeysReadsEverything() throws { + let (handler, storage, host) = makeSUT() + storage.stored = ["a": "1", "b": "2"] + + handler.handle(.request(.localStateGet, payload: .object(["data": .array([])])), host: host) + + #expect(storage.requestedKeys == []) + let response = try #require(host.sent.first) + #expect(response.payload == .object([ + "data": .object(["a": .string("1"), "b": .string("2")]), + "version": .int(7) + ])) + } + + @Test("A payload that is neither a JSON string nor an object is refused") + func getRefusesMalformedPayload() throws { + let (handler, _, host) = makeSUT() + + handler.handle(.request(.localStateGet, payload: .string("not json")), host: host) + + let response = try #require(host.sent.first) + #expect(response.type == .error) + #expect(response.payload == .object(["error": .string("Invalid payload")])) + } + + // MARK: - set + + @Test("Values are written and echoed back with the new version") + func setWritesAndEchoes() throws { + let (handler, storage, host) = makeSUT() + + handler.handle(.request(.localStateSet, payload: .object(["data": .object(["seen": .string("1")])])), host: host) + + #expect(storage.written?["seen"] == "1") + let response = try #require(host.sent.first) + #expect(response.type == .response) + #expect(response.payload == .object(["data": .object(["seen": .string("1")]), "version": .int(7)])) + } + + @Test("A null value is an erase and stays distinct from an empty string") + func setTreatsNullAsErase() throws { + let (handler, storage, host) = makeSUT() + + handler.handle(.request(.localStateSet, payload: .object(["data": .object(["seen": .null])])), host: host) + + let written = try #require(storage.written) + #expect(written.keys.contains("seen")) + #expect(written["seen"] == .some(nil)) + #expect(host.sent.first?.payload == .object(["data": .object(["seen": .null]), "version": .int(7)])) + } + + /// Storage keeps strings, so a richer value is kept as its JSON text — the page gets back + /// what it put in instead of losing the key. + @Test("A non-string value is stored as its JSON text") + func setEncodesRicherValues() throws { + let (handler, storage, host) = makeSUT() + + handler.handle(.request(.localStateSet, payload: .object(["data": .object(["count": .int(3)])])), host: host) + + #expect(storage.written?["count"] == "3") + } + + @Test("A set without a data object is refused") + func setRefusesMissingData() throws { + let (handler, storage, host) = makeSUT() + + handler.handle(.request(.localStateSet, payload: .object(["version": .int(1)])), host: host) + + #expect(storage.written == nil) + let response = try #require(host.sent.first) + #expect(response.type == .error) + #expect(response.payload == .object(["error": .string("Invalid payload: missing 'data' object")])) + } + + // MARK: - init + + @Test("Initialization applies the version and the defaults") + func initAppliesVersionAndData() throws { + let (handler, storage, host) = makeSUT() + + handler.handle(.request(.localStateInit, payload: .object([ + "version": .int(3), + "data": .object(["seen": .string("0")]) + ])), host: host) + + #expect(storage.initializedVersion == 3) + #expect(storage.written?["seen"] == "0") + #expect(host.sent.first?.type == .response) + } + + @Test("A version the storage rejects is reported back rather than swallowed") + func initReportsRejectedVersion() throws { + let (handler, storage, host) = makeSUT() + storage.rejectsInitialize = true + + handler.handle(.request(.localStateInit, payload: .object([ + "version": .int(0), + "data": .object([:]) + ])), host: host) + + let response = try #require(host.sent.first) + #expect(response.type == .error) + #expect(response.payload == .object(["error": .string("Version must be a positive integer, got 0")])) + } + + @Test("An init without a version is refused") + func initRefusesMissingVersion() throws { + let (handler, _, host) = makeSUT() + + handler.handle(.request(.localStateInit, payload: .object(["data": .object([:])])), host: host) + + let response = try #require(host.sent.first) + #expect(response.type == .error) + #expect(response.payload == .object(["error": .string("Invalid payload: missing 'version' or 'data'")])) + } + + // MARK: - Envelope + + @Test("A payload sent as a JSON string is understood like a decoded one") + func acceptsStringifiedPayload() throws { + let (handler, storage, host) = makeSUT() + storage.stored = ["seen": "1"] + + handler.handle(.request(.localStateGet, payload: .string("{\"data\":[\"seen\"]}")), host: host) + + #expect(storage.requestedKeys == ["seen"]) + #expect(host.sent.first?.type == .response) + } + + @Test("Every answer carries the id of the request it answers") + func answersKeepRequestIdentity() throws { + let (handler, _, host) = makeSUT() + let message = BridgeMessage.request(.localStateGet, payload: .object(["data": .array([])])) + + handler.handle(message, host: host) + + #expect(host.sent.first?.id == message.id) + #expect(host.sent.first?.action == message.action) + } +} + +// MARK: - Doubles + +final class LocalStateStorageSpy: WebViewLocalStateStorageProtocol { + + var stored: [String: String] = [:] + var version = 7 + var rejectsInitialize = false + + private(set) var requestedKeys: [String]? + private(set) var written: [String: String?]? + private(set) var initializedVersion: Int? + + func get(keys: [String]) -> WebViewLocalState { + requestedKeys = keys + let data = keys.isEmpty ? stored : stored.filter { keys.contains($0.key) } + return WebViewLocalState(version: version, data: data) + } + + func set(data: [String: String?]) -> WebViewLocalState { + written = data + return WebViewLocalState(version: version, data: stored) + } + + func initialize(version: Int, data: [String: String?]) -> WebViewLocalState? { + guard !rejectsInitialize else { return nil } + initializedVersion = version + written = data + return WebViewLocalState(version: version, data: stored) + } +} diff --git a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/LogActionHandlerTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/LogActionHandlerTests.swift new file mode 100644 index 00000000..b07257a6 --- /dev/null +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/LogActionHandlerTests.swift @@ -0,0 +1,69 @@ +// +// LogActionHandlerTests.swift +// MindboxTests +// +// Created by Akylbek Utekeshev on 13.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +import MindboxLogger +@_spi(Internal) @testable import Mindbox + +@Suite("LogActionHandler", .tags(.webView)) +struct LogActionHandlerTests { + + @Test("Owns the log action and nothing else") + func ownsLogOnly() { + #expect(LogActionHandler().actions == [.log]) + } + + /// `log` is not deferred, so `RequestMessageHandler` has already sent `{success: true}` by + /// the time the handler runs. A second answer would arrive against an id JS has closed. + @Test("Never answers: the dispatcher already acknowledged this action") + func neverAnswers() { + let host = HostSpy() + + LogActionHandler().handle(.request(.log, payload: .string("hello from the page")), host: host) + + #expect(host.sent.isEmpty) + } + + @Test("Runs whatever shape the payload arrived in") + func toleratesEveryPayloadShape() { + let host = HostSpy() + let handler = LogActionHandler() + + handler.handle(.request(.log, payload: .string("a string")), host: host) + handler.handle(.request(.log, payload: .object(["message": .string("an object")])), host: host) + handler.handle(.request(.log, payload: nil), host: host) + + #expect(host.sent.isEmpty) + } +} + +@Suite("BridgeMessage payloadString", .tags(.webView)) +struct BridgeMessagePayloadStringTests { + + @Test("A string payload is taken as it is — the ordinary case from JS") + func stringPayloadPassesThrough() { + let message = BridgeMessage.request(.log, payload: .string("{\"message\":\"hi\"}")) + + #expect(message.payloadString == "{\"message\":\"hi\"}") + } + + @Test("An already-decoded payload is re-encoded, not described") + func objectPayloadIsReencoded() { + let message = BridgeMessage.request(.log, payload: .object(["message": .string("hi")])) + + // Re-encoded as JSON, so a handler sees the same text whichever shape the page sent. + #expect(message.payloadString == "{\"message\":\"hi\"}") + } + + @Test("A missing payload reads as empty rather than as a literal nil") + func missingPayloadIsEmpty() { + let message = BridgeMessage.request(.log, payload: nil) + + #expect(message.payloadString.isEmpty) + } +} diff --git a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/MotionActionHandlerTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/MotionActionHandlerTests.swift new file mode 100644 index 00000000..58a058ef --- /dev/null +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/MotionActionHandlerTests.swift @@ -0,0 +1,241 @@ +// +// MotionActionHandlerTests.swift +// MindboxTests +// +// Created by Akylbek Utekeshev on 13.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +@_spi(Internal) @testable import Mindbox + +@Suite("MotionActionHandler", .tags(.webView)) +@MainActor +struct MotionActionHandlerTests { + + private func makeSUT(started: Set = [.shake], + unavailable: Set = []) + -> (handler: MotionActionHandler, service: MotionServiceSpy, host: HostSpy) { + let service = MotionServiceSpy(result: MotionStartResult(started: started, unavailable: unavailable)) + return (MotionActionHandler(makeService: { service }), service, HostSpy()) + } + + private func startRequest(_ gestures: [String]) -> BridgeMessage { + .request(.motionStart, payload: .object(["gestures": .array(gestures.map { .string($0) })])) + } + + @Test("Owns both motion actions") + func ownsMotionActions() { + #expect(MotionActionHandler().actions == [.motionStart, .motionStop]) + } + + // MARK: - Starting + + @Test("Requested gestures are passed to the service and confirmed") + func startsRequestedGestures() throws { + let (handler, service, host) = makeSUT(started: [.shake, .flip]) + + handler.handle(startRequest(["shake", "flip"]), host: host) + + #expect(service.started == [.shake, .flip]) + #expect(host.sent.first?.payload == .object(["success": .bool(true)])) + } + + /// A partial start is still a start: the page is told what it will not get rather than + /// being refused everything it asked for. + @Test("A partial start succeeds and names what is unavailable") + func partialStartNamesUnavailable() throws { + let (handler, _, host) = makeSUT(started: [.shake], unavailable: [.flip]) + + handler.handle(startRequest(["shake", "flip"]), host: host) + + let response = try #require(host.sent.first) + #expect(response.type == .response) + #expect(response.payload == .object([ + "success": .bool(true), + "unavailable": .array([.string("flip")]) + ])) + } + + @Test("A start where nothing is available is an error") + func fullyUnavailableStartIsAnError() throws { + let (handler, _, host) = makeSUT(started: [], unavailable: [.flip]) + + handler.handle(startRequest(["flip"]), host: host) + + let response = try #require(host.sent.first) + #expect(response.type == .error) + #expect(response.payload == .object(["error": .string("No sensors available for requested gestures: flip")])) + } + + @Test("Unknown gesture names are dropped, and asking only for those is refused") + func unknownGesturesAreRefused() throws { + let (handler, service, host) = makeSUT() + + handler.handle(startRequest(["somersault"]), host: host) + + #expect(service.started == nil) + #expect(host.sent.first?.payload + == .object(["error": .string("No valid gestures provided. Available: shake, flip")])) + } + + @Test("A payload without gestures is refused") + func missingGesturesIsRefused() throws { + let (handler, service, host) = makeSUT() + + handler.handle(.request(.motionStart, payload: .object([:])), host: host) + + #expect(service.started == nil) + #expect(host.sent.first?.payload == .object(["error": .string("Invalid payload: 'gestures' must be an array")])) + } + + /// The first guard has its own wording, because there is a difference worth telling the page: + /// nothing arrived at all, as against something arrived in the wrong shape. + @Test("A request with no payload at all is refused before the shape is looked at") + func missingPayloadIsRefused() throws { + let (handler, service, host) = makeSUT() + + handler.handle(.request(.motionStart), host: host) + + #expect(service.started == nil) + #expect(host.sent.first?.payload == .object(["error": .string("Invalid payload: missing 'gestures' array")])) + } + + @Test("A payload that is not an object is refused the same way") + func nonObjectPayloadIsRefused() throws { + let (handler, service, host) = makeSUT() + + handler.handle(.request(.motionStart, payload: .array([.string("shake")])), host: host) + + #expect(service.started == nil) + #expect(host.sent.first?.payload == .object(["error": .string("Invalid payload: missing 'gestures' array")])) + } + + @Test("A gestures field that is not an array is refused") + func nonArrayGesturesIsRefused() throws { + let (handler, _, host) = makeSUT() + + handler.handle(.request(.motionStart, payload: .object(["gestures": .string("shake")])), host: host) + + #expect(host.sent.first?.payload == .object(["error": .string("Invalid payload: 'gestures' must be an array")])) + } + + // MARK: - Stopping + + @Test("Stopping reaches the service and is confirmed") + func stopIsConfirmed() { + let (handler, service, host) = makeSUT() + handler.handle(startRequest(["shake"]), host: host) + + handler.handle(.request(.motionStop), host: host) + + #expect(service.stopCount == 1) + #expect(host.sent.last?.payload == .object(["success": .bool(true)])) + } + + // MARK: - Events + + /// A gesture arrives from the sensors with no request behind it, so it is pushed as a + /// request of its own rather than as an answer. + @Test("A detected gesture is pushed to the page as a request") + func detectedGestureIsPushed() throws { + let (handler, service, host) = makeSUT() + handler.handle(startRequest(["flip"]), host: host) + + service.emit(.flip, data: ["from": "portrait", "to": "faceDown"]) + + let event = try #require(host.sent.last) + #expect(event.type == .request) + #expect(event.action == BridgeMessage.Action.motionEvent.rawValue) + #expect(event.payload == .object([ + "gesture": .string("flip"), + "from": .string("portrait"), + "to": .string("faceDown") + ])) + } + + @Test("A system shake is forwarded to the service") + func systemShakeIsForwarded() { + let (handler, service, host) = makeSUT() + handler.handle(startRequest(["shake"]), host: host) + + handler.handleSystemShake() + + #expect(service.systemShakeCount == 1) + } + + /// The sensors were never asked for, so there is nothing to forward to — and building a + /// service to tell it about a shake nobody subscribed to would be worse than ignoring it. + @Test("A system shake before any subscription builds no service") + func systemShakeWithoutSubscriptionIsIgnored() { + let service = MotionServiceSpy(result: MotionStartResult(started: [.shake], unavailable: [])) + var built = 0 + let handler = MotionActionHandler(makeService: { built += 1; return service }) + + handler.handleSystemShake() + + #expect(built == 0) + #expect(service.systemShakeCount == 0) + } + + // MARK: - Teardown + + @Test("Teardown stops monitoring once the sensors were in use") + func tearDownStopsMonitoring() { + let (handler, service, host) = makeSUT() + handler.handle(startRequest(["shake"]), host: host) + + handler.tearDown() + + #expect(service.stopCount == 1) + } + + /// Every show ends, and most never touch motion: teardown must not be what starts a + /// service, only what stops one. + @Test("Teardown builds no service when motion was never used") + func tearDownBuildsNothingWhenUnused() { + var built = 0 + let handler = MotionActionHandler(makeService: { + built += 1 + return MotionServiceSpy(result: MotionStartResult(started: [], unavailable: [])) + }) + + handler.tearDown() + + #expect(built == 0) + } +} + +// MARK: - Doubles + +final class MotionServiceSpy: MotionServiceProtocol { + + var onGestureDetected: ((MotionGesture, [String: Any]) -> Void)? + + private let result: MotionStartResult + + private(set) var started: Set? + private(set) var stopCount = 0 + private(set) var systemShakeCount = 0 + + init(result: MotionStartResult) { + self.result = result + } + + func startMonitoring(gestures: Set) -> MotionStartResult { + started = gestures + return result + } + + func stopMonitoring() { + stopCount += 1 + } + + func handleSystemShake() { + systemShakeCount += 1 + } + + func emit(_ gesture: MotionGesture, data: [String: Any]) { + onGestureDetected?(gesture, data) + } +} diff --git a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/OpenLinkActionHandlerTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/OpenLinkActionHandlerTests.swift new file mode 100644 index 00000000..6a961ba7 --- /dev/null +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/OpenLinkActionHandlerTests.swift @@ -0,0 +1,230 @@ +// +// OpenLinkActionHandlerTests.swift +// MindboxTests +// +// Created by Akylbek Utekeshev on 13.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +import Foundation +@_spi(Internal) @testable import Mindbox + +@Suite("OpenLinkActionHandler", .tags(.webView)) +@MainActor +struct OpenLinkActionHandlerTests { + + @Test("Owns the openLink action") + func ownsOpenLink() { + #expect(OpenLinkActionHandler().actions == [.openLink]) + } + + // MARK: - Routing by scheme + + /// A web address may belong to an installed app, so the system is asked first and only + /// falls back to opening it in-app. + @Test("A web address is offered as a universal link first", arguments: ["https://example.com", "http://example.com"]) + func webAddressTriesUniversalLinkFirst(urlString: String) async { + let opener = URLOpenerSpy() + let host = HostSpy() + + let handler = OpenLinkActionHandler(urlOpener: opener) + + handler.handle(.request(.openLink, payload: .object(["url": .string(urlString)])), host: host) + await drainMainQueue(until: { !opener.opened.isEmpty }) + + #expect(opener.opened.first?.universalLinksOnly == true) + } + + /// Anything that is not a web address means nothing to Safari — only the system knows + /// which app answers `tel:` or a deep link. + @Test("A non-web scheme goes straight to the system", + arguments: ["tel:+123456789", "mailto:a@b.c", "myapp://product/1"]) + func otherSchemesGoToSystem(urlString: String) async { + let opener = URLOpenerSpy() + let host = HostSpy() + + let handler = OpenLinkActionHandler(urlOpener: opener) + + handler.handle(.request(.openLink, payload: .object(["url": .string(urlString)])), host: host) + await drainMainQueue(until: { !opener.opened.isEmpty }) + + #expect(opener.opened.first?.universalLinksOnly == false) + } + + // MARK: - Outcomes + + @Test("An opened universal link is reported as a success") + func universalLinkSuccessIsReported() async throws { + let opener = URLOpenerSpy() + opener.result = true + let host = HostSpy() + + let handler = OpenLinkActionHandler(urlOpener: opener) + + handler.handle(.request(.openLink, payload: .object(["url": .string("https://example.com")])), host: host) + await drainMainQueue(until: { !host.sent.isEmpty }) + + let response = try #require(host.sent.first) + #expect(response.type == .response) + #expect(response.payload == .object(["success": .bool(true)])) + } + + @Test("A system open that fails is reported as an error") + func systemFailureIsReported() async throws { + let opener = URLOpenerSpy() + opener.result = false + let host = HostSpy() + + let handler = OpenLinkActionHandler(urlOpener: opener) + + handler.handle(.request(.openLink, payload: .object(["url": .string("myapp://nope")])), host: host) + await drainMainQueue(until: { !host.sent.isEmpty }) + + let response = try #require(host.sent.first) + #expect(response.type == .error) + #expect(response.payload == .object(["error": .string("Failed to open URL: 'myapp://nope'")])) + } + + // MARK: - Refusals + + @Test("A missing url is refused without reaching the system") + func missingURLIsRefused() throws { + let opener = URLOpenerSpy() + let host = HostSpy() + + let handler = OpenLinkActionHandler(urlOpener: opener) + + handler.handle(.request(.openLink, payload: .object([:])), host: host) + + #expect(opener.opened.isEmpty) + let response = try #require(host.sent.first) + #expect(response.type == .error) + #expect(response.payload == .object(["error": .string("Invalid payload: missing or empty 'url' field")])) + } + + @Test("An empty url is refused") + func emptyURLIsRefused() throws { + let host = HostSpy() + + let handler = OpenLinkActionHandler(urlOpener: URLOpenerSpy()) + + handler.handle(.request(.openLink, payload: .object(["url": .string("")])), host: host) + + #expect(host.sent.first?.type == .error) + } + + /// A string can be non-empty and still not be an address. It is refused by name rather than + /// handed to the system, which would answer a flat `false` and say nothing about why. + @Test("A url that cannot be parsed is refused without reaching the system", + arguments: ["http://exa mple.com", "ht tp://example.com"]) + func unparseableURLIsRefused(urlString: String) throws { + let opener = URLOpenerSpy() + let host = HostSpy() + + let handler = OpenLinkActionHandler(urlOpener: opener) + + handler.handle(.request(.openLink, payload: .object(["url": .string(urlString)])), host: host) + + #expect(opener.opened.isEmpty) + let response = try #require(host.sent.first) + #expect(response.type == .error) + #expect(response.payload == .object(["error": .string("Invalid URL: '\(urlString)' could not be parsed")])) + } + + /// A bare address parses, but with no scheme it is nothing Safari could show — only the system + /// might know what it means, so it goes there rather than down the universal-link route. + @Test("A url without a scheme goes to the system, not the universal-link route") + func schemelessURLGoesToSystem() async { + let opener = URLOpenerSpy() + let host = HostSpy() + + let handler = OpenLinkActionHandler(urlOpener: opener) + + handler.handle(.request(.openLink, payload: .object(["url": .string("example.com/promo")])), host: host) + await drainMainQueue(until: { !opener.opened.isEmpty }) + + #expect(opener.opened.map(\.universalLinksOnly) == [false]) + } + + /// The fallback needs a controller to present from, and off the window there is none. The page is + /// answered with an error rather than left waiting on a presentation that cannot happen. + @Test("A web address nobody claims and no presenter to fall back on is refused") + func safariFallbackWithoutPresenterIsRefused() async throws { + let opener = URLOpenerSpy() + opener.result = false + let host = HostSpy() + #expect(host.presentingViewController == nil, "the spy stands for a page that owns no controller") + + let handler = OpenLinkActionHandler(urlOpener: opener) + + handler.handle(.request(.openLink, payload: .object(["url": .string("https://example.com")])), host: host) + await drainMainQueue(until: { !host.sent.isEmpty }) + + let response = try #require(host.sent.first) + #expect(response.type == .error) + #expect(response.payload == .object(["error": .string("Failed to open URL: no presenting view controller")])) + #expect(host.sent.count == 1, "the universal-link attempt and the fallback answer once between them") + } + + /// The system takes its own time, and the show may end first. Waiting on it must not be what + /// keeps the page alive. + @Test("A page released while the system is deciding is not held by the request") + func pendingOpenDoesNotHoldThePage() async { + let opener = URLOpenerSpy() + let handler = OpenLinkActionHandler(urlOpener: opener) + weak var page: HostSpy? + + do { + let host = HostSpy() + page = host + handler.handle(.request(.openLink, payload: .object(["url": .string("https://example.com")])), host: host) + } + + await drainMainQueue(until: { false }, turns: 3) + + #expect(page == nil) + } + + @Test("A payload sent as a JSON string is understood too") + func acceptsStringifiedPayload() async { + let opener = URLOpenerSpy() + let host = HostSpy() + + let handler = OpenLinkActionHandler(urlOpener: opener) + + handler.handle(.request(.openLink, payload: .string("{\"url\":\"https://example.com\"}")), host: host) + await drainMainQueue(until: { !opener.opened.isEmpty }) + + #expect(opener.opened.count == 1) + } + + @Test("The answer carries the id of the request it answers") + func answerKeepsRequestIdentity() async throws { + let opener = URLOpenerSpy() + opener.result = true + let host = HostSpy() + let message = BridgeMessage.request(.openLink, payload: .object(["url": .string("myapp://x")])) + + let handler = OpenLinkActionHandler(urlOpener: opener) + + handler.handle(message, host: host) + await drainMainQueue(until: { !host.sent.isEmpty }) + + #expect(host.sent.first?.id == message.id) + } +} + +// MARK: - Doubles + +final class URLOpenerSpy: BridgeURLOpening { + + var result = false + + private(set) var opened: [(url: URL, universalLinksOnly: Bool)] = [] + + func open(_ url: URL, universalLinksOnly: Bool, completion: @escaping (Bool) -> Void) { + opened.append((url, universalLinksOnly)) + completion(result) + } +} diff --git a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/OperationActionHandlerTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/OperationActionHandlerTests.swift new file mode 100644 index 00000000..821e62fa --- /dev/null +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/OperationActionHandlerTests.swift @@ -0,0 +1,319 @@ +// +// OperationActionHandlerTests.swift +// MindboxTests +// +// Created by Akylbek Utekeshev on 14.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +import Foundation +@_spi(Internal) @testable import Mindbox + +/// What the handler does with an operation request: what it writes, what it answers, and what it +/// refuses. +/// +/// Two neighbours own the rest of this action deliberately. `TransparentViewSyncOperationResponseTests` +/// covers `makeSyncOperationResponse` on its own, as the pure mapping it is, so nothing here +/// re-checks the shape of every backend outcome. `TransparentViewJSBridgeTests` covers the tag merge +/// through the view, which is where the tags come from. This suite is the wiring in between: parse, +/// write, answer, and whose lifetime the answer depends on. +@Suite("OperationActionHandler", .tags(.webView)) +@MainActor +struct OperationActionHandlerTests { + + init() { + TestConfiguration.configure() + } + + private func makeSUT( + database: DatabaseRepositoryStub = DatabaseRepositoryStub(), + events: SyncOperationRepositoryStub = SyncOperationRepositoryStub() + ) -> (handler: OperationActionHandler, database: DatabaseRepositoryStub, events: SyncOperationRepositoryStub, host: HostSpy) { + let handler = OperationActionHandler(featureToggleManager: FeatureToggleManager(), + databaseRepository: database, + eventRepository: events) + return (handler, database, events, HostSpy()) + } + + private func request(_ action: BridgeMessage.Action, + operation: String = "Test.Operation", + body: JSONValue = .object(["field": .string("value")])) -> BridgeMessage { + .request(action, payload: .object(["operation": .string(operation), "body": body])) + } + + @Test("Owns both operation actions") + func ownsOperationActions() { + #expect(OperationActionHandler().actions == [.asyncOperation, .syncOperation]) + } + + // MARK: - asyncOperation + + @Test("An async operation is queued as a custom event and confirmed") + func asyncOperationIsQueuedAndConfirmed() throws { + let sut = makeSUT() + + sut.handler.handle(request(.asyncOperation, operation: "Test.Async"), host: sut.host) + + let event = try #require(sut.database.created.first) + #expect(event.type == .customEvent) + let customEvent = try #require(BodyDecoder(decodable: event.body)?.body) + #expect(customEvent.name == "Test.Async") + + let response = try #require(sut.host.sent.first) + #expect(response.type == .response) + #expect(response.payload == .object(["success": .bool(true)])) + } + + /// The queue is a database write, and a database can be full or broken. The page is told so + /// rather than being left to believe the operation is on its way. + @Test("A queue that fails is reported to the page instead of being confirmed") + func asyncOperationFailureIsReported() throws { + let database = DatabaseRepositoryStub() + database.createError = DatabaseRepositoryStub.StubError.full + let sut = makeSUT(database: database) + + sut.handler.handle(request(.asyncOperation), host: sut.host) + + let response = try #require(sut.host.sent.first) + #expect(response.type == .error) + #expect(sut.host.sent.count == 1, "a failed queue is answered once, not confirmed as well") + } + + @Test("A queued operation does not reach the network") + func asyncOperationDoesNotSend() { + let sut = makeSUT() + + sut.handler.handle(request(.asyncOperation), host: sut.host) + + #expect(sut.events.sentRaw.isEmpty) + } + + // MARK: - syncOperation + + @Test("A sync operation is sent as a sync event and its body is handed back untouched") + func syncOperationForwardsRawBody() async throws { + let sut = makeSUT() + let message = request(.syncOperation, operation: "Test.Sync") + + sut.handler.handle(message, host: sut.host) + sut.events.answer(.success(Data(#"{"status":"Success"}"#.utf8))) + await drainMainQueue(until: { !sut.host.sent.isEmpty }) + + let event = try #require(sut.events.sentRaw.first) + #expect(event.type == .syncEvent) + + let response = try #require(sut.host.sent.first) + #expect(response.type == .response) + #expect(response.payload == .string(#"{"status":"Success"}"#)) + #expect(response.id == message.id, "the answer belongs to the request that asked") + #expect(response.action == message.action) + } + + @Test("A sync operation that fails reaches the page as an error") + func syncOperationFailureReachesThePage() async throws { + let sut = makeSUT() + + sut.handler.handle(request(.syncOperation), host: sut.host) + sut.events.answer(.failure(.connectionError)) + await drainMainQueue(until: { !sut.host.sent.isEmpty }) + + let response = try #require(sut.host.sent.first) + #expect(response.type == .error) + } + + @Test("A sync operation is not written to the queue") + func syncOperationDoesNotQueue() async { + let sut = makeSUT() + + sut.handler.handle(request(.syncOperation), host: sut.host) + sut.events.answer(.success(Data())) + await drainMainQueue(until: { !sut.host.sent.isEmpty }) + + #expect(sut.database.created.isEmpty) + } + + /// A backend answer arrives whenever it arrives, and by then the show may be over. Waiting on + /// it must not be what keeps the page — and the whole handler set behind it — alive. + @Test("A request in flight does not hold the page alive") + func pendingSyncOperationDoesNotHoldThePage() { + let sut = makeSUT() + weak var page: HostSpy? + + do { + let host = HostSpy() + page = host + sut.handler.handle(request(.syncOperation), host: host) + } + + #expect(sut.events.pending != nil, "the request is still waiting for its answer") + #expect(page == nil, "the page must not be held by a request that has not answered yet") + } + + @Test("An answer that arrives after the page is gone is dropped") + func answerAfterThePageIsGoneIsDropped() async { + let sut = makeSUT() + weak var page: HostSpy? + + do { + let host = HostSpy() + page = host + sut.handler.handle(request(.syncOperation), host: host) + } + + sut.events.answer(.success(Data(#"{"status":"Success"}"#.utf8))) + // Nothing arrives to be waited for, so the queue is drained for its own sake: the answer + // has nowhere to go, and going there anyway is what this guards against. + await drainMainQueue(until: { false }, turns: 3) + + #expect(page == nil) + } + + // MARK: - Refusals + + @Test("A request without a payload is refused", arguments: [BridgeMessage.Action.asyncOperation, .syncOperation]) + func missingPayloadIsRefused(action: BridgeMessage.Action) throws { + let sut = makeSUT() + + sut.handler.handle(.request(action), host: sut.host) + + let response = try #require(sut.host.sent.first) + #expect(response.type == .error) + #expect(sut.database.created.isEmpty) + #expect(sut.events.sentRaw.isEmpty) + } + + @Test("A payload that is not an object at all is refused") + func nonObjectPayloadIsRefused() throws { + let sut = makeSUT() + + sut.handler.handle(.request(.asyncOperation, payload: .array([.string("nope")])), host: sut.host) + + #expect(try #require(sut.host.sent.first).type == .error) + #expect(sut.database.created.isEmpty) + } + + @Test("A request without an operation name is refused") + func missingOperationNameIsRefused() throws { + let sut = makeSUT() + + sut.handler.handle(.request(.asyncOperation, payload: .object(["body": .object([:])])), host: sut.host) + + #expect(try #require(sut.host.sent.first).type == .error) + #expect(sut.database.created.isEmpty) + } + + /// The name is what the operation *is*: an empty one would reach the backend as an anonymous + /// event nobody can act on. + @Test("An empty operation name is refused") + func emptyOperationNameIsRefused() throws { + let sut = makeSUT() + + sut.handler.handle(request(.asyncOperation, operation: ""), host: sut.host) + + #expect(try #require(sut.host.sent.first).type == .error) + #expect(sut.database.created.isEmpty) + } + + @Test("An operation name that is not a string is refused") + func nonStringOperationNameIsRefused() throws { + let sut = makeSUT() + let payload = JSONValue.object(["operation": .int(42), "body": .object([:])]) + + sut.handler.handle(.request(.asyncOperation, payload: payload), host: sut.host) + + #expect(try #require(sut.host.sent.first).type == .error) + #expect(sut.database.created.isEmpty) + } + + @Test("A request without a body is refused", arguments: [BridgeMessage.Action.asyncOperation, .syncOperation]) + func missingBodyIsRefused(action: BridgeMessage.Action) throws { + let sut = makeSUT() + + sut.handler.handle(.request(action, payload: .object(["operation": .string("Test.Op")])), host: sut.host) + + #expect(try #require(sut.host.sent.first).type == .error) + #expect(sut.database.created.isEmpty) + #expect(sut.events.sentRaw.isEmpty) + } + + /// An empty body is a body: an operation may legitimately carry nothing. + @Test("An empty body is accepted") + func emptyBodyIsAccepted() throws { + let sut = makeSUT() + + sut.handler.handle(request(.asyncOperation, body: .object([:])), host: sut.host) + + #expect(try #require(sut.host.sent.first).type == .response) + #expect(sut.database.created.count == 1) + } +} + +// MARK: - Doubles + +/// Records what was written and can refuse to write. +private final class DatabaseRepositoryStub: DatabaseRepositoryProtocol { + + enum StubError: Error { + case full + } + + var limit: Int = 0 + var lifeLimitDate: Date? + var deprecatedLimit: Int = 0 + var onObjectsDidChange: (() -> Void)? + + /// Set to make the next write fail. + var createError: Error? + + private(set) var created: [Event] = [] + + func create(event: Event) throws { + if let createError { + throw createError + } + + created.append(event) + } + + func readEvent(by transactionId: String) throws -> Event? { + created.first { $0.transactionId == transactionId } + } + + func update(event: Event) throws {} + func delete(event: Event) throws {} + func query(fetchLimit: Int, retryDeadline: TimeInterval) throws -> [Event] { [] } + func removeDeprecatedEventsIfNeeded() throws {} + func countDeprecatedEvents() throws -> Int { 0 } + func erase() throws { created.removeAll() } + func countEvents() throws -> Int { created.count } +} + +/// Holds its answer back until the test gives one. +/// +/// The pause is the point: while the request is in flight is the only moment in which what the SDK +/// keeps alive can be observed at all. +private final class SyncOperationRepositoryStub: EventRepository { + + private(set) var sentRaw: [Event] = [] + private(set) var pending: ((Result) -> Void)? + + func answer(_ result: Result) { + let completion = pending + pending = nil + completion?(result) + } + + func sendRaw(event: Event, completion: @escaping (Result) -> Void) { + sentRaw.append(event) + pending = completion + } + + func send(event: Event, completion: @escaping (Result) -> Void) { + completion(.success(())) + } + + func send(type: T.Type, event: Event, completion: @escaping (Result) -> Void) where T: Decodable {} + + func cancelAllRequests() {} +} diff --git a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/PermissionActionHandlerTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/PermissionActionHandlerTests.swift new file mode 100644 index 00000000..3a6eff1b --- /dev/null +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/PermissionActionHandlerTests.swift @@ -0,0 +1,237 @@ +// +// PermissionActionHandlerTests.swift +// MindboxTests +// +// Created by Akylbek Utekeshev on 13.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +@_spi(Internal) @testable import Mindbox + +@Suite("PermissionActionHandler", .tags(.webView)) +@MainActor +struct PermissionActionHandlerTests { + + private func makeSUT(result: PermissionRequestResult = .granted(dialogShown: true), + requiredKeys: [String] = [], + presentKeys: Set = [], + registered: Bool = true) + -> (handler: PermissionActionHandler, permission: PermissionHandlerSpy, host: HostSpy) { + let permission = PermissionHandlerSpy(result: result, requiredInfoPlistKeys: requiredKeys) + let registry = PermissionRegistrySpy(handler: registered ? permission : nil) + let handler = PermissionActionHandler(makeRegistry: { registry }, + infoPlistValue: { presentKeys.contains($0) ? "value" : nil }) + return (handler, permission, HostSpy()) + } + + private func pushRequest() -> BridgeMessage { + .request(.permissionRequest, payload: .object(["type": .string("pushNotifications")])) + } + + @Test("Owns the permission.request action") + func ownsPermissionRequest() { + #expect(PermissionActionHandler().actions == [.permissionRequest]) + } + + // MARK: - Outcomes + + /// The page is told both the stance and whether a dialog appeared, so it can tell a fresh + /// refusal from a standing one. + @Test("A grant answers with the result and whether a dialog was shown") + func grantIsReported() async throws { + let (handler, _, host) = makeSUT(result: .granted(dialogShown: true)) + + handler.handle(pushRequest(), host: host) + await drainMainQueue(until: { !host.sent.isEmpty }) + + let response = try #require(host.sent.first) + #expect(response.type == .response) + #expect(response.payload == .object(["result": .string("granted"), "dialogShown": .bool(true)])) + } + + @Test("A standing refusal answers denied without a dialog") + func standingDenialIsReported() async throws { + let (handler, _, host) = makeSUT(result: .denied(dialogShown: false)) + + handler.handle(pushRequest(), host: host) + await drainMainQueue(until: { !host.sent.isEmpty }) + + #expect(host.sent.first?.payload == .object(["result": .string("denied"), "dialogShown": .bool(false)])) + } + + @Test("A failure from the permission handler reaches the page as an error") + func handlerErrorIsReported() async throws { + let (handler, _, host) = makeSUT(result: .error("Something went wrong")) + + handler.handle(pushRequest(), host: host) + await drainMainQueue(until: { !host.sent.isEmpty }) + + let response = try #require(host.sent.first) + #expect(response.type == .error) + #expect(response.payload == .object(["error": .string("Something went wrong")])) + } + + // MARK: - Refusals + + @Test("A missing type is refused") + func missingTypeIsRefused() throws { + let (handler, permission, host) = makeSUT() + + handler.handle(.request(.permissionRequest, payload: .object([:])), host: host) + + #expect(permission.requestCount == 0) + #expect(host.sent.first?.payload == .object(["error": .string("Invalid payload: missing or empty 'type' field")])) + } + + @Test("A permission the SDK does not know is refused by name") + func unknownTypeIsRefused() throws { + let (handler, permission, host) = makeSUT() + + handler.handle(.request(.permissionRequest, payload: .object(["type": .string("camera")])), host: host) + + #expect(permission.requestCount == 0) + #expect(host.sent.first?.payload == .object(["error": .string("Unknown permission type: 'camera'")])) + } + + @Test("A known permission with no handler registered is refused") + func unregisteredTypeIsRefused() throws { + let (handler, _, host) = makeSUT(registered: false) + + handler.handle(pushRequest(), host: host) + + #expect(host.sent.first?.payload + == .object(["error": .string("No handler registered for permission type: 'pushNotifications'")])) + } + + /// Asking without the usage description in place would kill the host app rather than + /// return a refusal, so the missing key is reported instead of requested. + @Test("A missing Info.plist key is reported and the permission is never requested") + func missingInfoPlistKeyIsRefused() throws { + let (handler, permission, host) = makeSUT(requiredKeys: ["NSUserTrackingUsageDescription"]) + + handler.handle(pushRequest(), host: host) + + #expect(permission.requestCount == 0) + #expect(host.sent.first?.payload + == .object(["error": .string("Missing Info.plist key: NSUserTrackingUsageDescription")])) + } + + @Test("A required key that is present lets the request through") + func presentInfoPlistKeyAllowsRequest() async { + let (handler, permission, host) = makeSUT(requiredKeys: ["NSUserTrackingUsageDescription"], + presentKeys: ["NSUserTrackingUsageDescription"]) + + handler.handle(pushRequest(), host: host) + await drainMainQueue(until: { permission.requestCount > 0 }) + + #expect(permission.requestCount == 1) + } + + @Test("A payload sent as a JSON string is understood too") + func acceptsStringifiedPayload() async { + let (handler, permission, host) = makeSUT() + + handler.handle(.request(.permissionRequest, payload: .string("{\"type\":\"pushNotifications\"}")), host: host) + await drainMainQueue(until: { permission.requestCount > 0 }) + + #expect(permission.requestCount == 1) + } + + // MARK: - Lifetime + + /// A system dialog stands for as long as the user leaves it standing. Waiting on it must not + /// be what keeps the page alive: with the page would stay its whole handler set, its ready + /// checker and any sensor subscription the page started. + @Test("A page released while the dialog is up is not held by the pending request") + func pendingRequestDoesNotHoldPage() async { + let permission = PermissionHandlerSpy(result: .granted(dialogShown: true), + requiredInfoPlistKeys: [], + defersAnswer: true) + let registry = PermissionRegistrySpy(handler: permission) + let handler = PermissionActionHandler(makeRegistry: { registry }, infoPlistValue: { _ in "value" }) + + weak var page: HostSpy? + + do { + let host = HostSpy() + page = host + handler.handle(pushRequest(), host: host) + #expect(permission.requestCount == 1) + } + + #expect(page == nil) + } + + /// A show that ended gets no answer — and the answer arriving late is not a crash either. + @Test("An answer that arrives after the page is gone is dropped") + func lateAnswerIsDropped() async { + let permission = PermissionHandlerSpy(result: .granted(dialogShown: true), + requiredInfoPlistKeys: [], + defersAnswer: true) + let registry = PermissionRegistrySpy(handler: permission) + let handler = PermissionActionHandler(makeRegistry: { registry }, infoPlistValue: { _ in "value" }) + + var host: HostSpy? = HostSpy() + handler.handle(pushRequest(), host: host!) + host = nil + + permission.answer() + await drainMainQueue(until: { false }, turns: 3) + } +} + +// MARK: - Doubles + +final class PermissionHandlerSpy: PermissionHandler { + + let permissionType: PermissionType = .pushNotifications + let requiredInfoPlistKeys: [String] + + private let result: PermissionRequestResult + + /// Holds the answer back instead of giving it, standing in for a dialog the user has not + /// dismissed yet. That is the only window in which what the SDK holds onto is observable. + private let defersAnswer: Bool + + private var pendingAnswer: ((PermissionRequestResult) -> Void)? + + private(set) var requestCount = 0 + + init(result: PermissionRequestResult, requiredInfoPlistKeys: [String], defersAnswer: Bool = false) { + self.result = result + self.requiredInfoPlistKeys = requiredInfoPlistKeys + self.defersAnswer = defersAnswer + } + + func request(completion: @escaping (PermissionRequestResult) -> Void) { + requestCount += 1 + + guard defersAnswer else { + completion(result) + return + } + + pendingAnswer = completion + } + + /// The user finally answers the dialog. + func answer() { + let pendingAnswer = self.pendingAnswer + self.pendingAnswer = nil + pendingAnswer?(result) + } +} + +final class PermissionRegistrySpy: PermissionHandlerRegistryProtocol { + + private let stub: PermissionHandler? + + init(handler: PermissionHandler?) { + self.stub = handler + } + + func handler(for type: PermissionType) -> PermissionHandler? { + stub + } +} diff --git a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/ReadyActionHandlerTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/ReadyActionHandlerTests.swift new file mode 100644 index 00000000..1262f287 --- /dev/null +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/ReadyActionHandlerTests.swift @@ -0,0 +1,57 @@ +// +// ReadyActionHandlerTests.swift +// MindboxTests +// +// Created by Akylbek Utekeshev on 13.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +@_spi(Internal) @testable import Mindbox + +@Suite("ReadyActionHandler", .tags(.webView)) +struct ReadyActionHandlerTests { + + @Test("Owns the ready action") + func ownsReady() { + #expect(ReadyActionHandler().actions == [.ready]) + } + + /// `ready` is deferred: the blanket `{success: true}` would tell the page nothing, and this + /// is the one answer it cannot start without. + @Test("Answers with the page's own start payload") + func answersWithHostPayload() throws { + let host = HostSpy() + host.startPayload = .string(#"{"sdkVersion":"2.15.2"}"#) + let message = BridgeMessage.request(.ready) + + ReadyActionHandler().handle(message, host: host) + + let response = try #require(host.sent.first) + #expect(response.type == .response) + #expect(response.action == message.action) + #expect(response.id == message.id) + #expect(response.payload == .string(#"{"sdkVersion":"2.15.2"}"#)) + } + + /// Composing the payload belongs to the host — an in-app knows its operation, a block knows + /// its configuration entry — so the handler must not add to it or reshape it. + @Test("Passes the payload through untouched") + func passesPayloadThrough() { + let host = HostSpy() + host.startPayload = .object(["anything": .bool(true)]) + + ReadyActionHandler().handle(.request(.ready), host: host) + + #expect(host.sent.first?.payload == .object(["anything": .bool(true)])) + } + + @Test("Answers exactly once") + func answersOnce() { + let host = HostSpy() + + ReadyActionHandler().handle(.request(.ready), host: host) + + #expect(host.sent.count == 1) + } +} diff --git a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/SettingsActionHandlerTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/SettingsActionHandlerTests.swift new file mode 100644 index 00000000..1c86bdbb --- /dev/null +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/SettingsActionHandlerTests.swift @@ -0,0 +1,162 @@ +// +// SettingsActionHandlerTests.swift +// MindboxTests +// +// Created by Akylbek Utekeshev on 13.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +import UIKit +@_spi(Internal) @testable import Mindbox + +@Suite("SettingsActionHandler", .tags(.webView)) +@MainActor +struct SettingsActionHandlerTests { + + private func makeSUT(notificationsOpen: Bool = true) + -> (handler: SettingsActionHandler, opener: URLOpenerSpy, host: HostSpy, notifications: NotificationSettingsSpy) { + let opener = URLOpenerSpy() + opener.result = true + let notifications = NotificationSettingsSpy(result: notificationsOpen) + let handler = SettingsActionHandler(urlOpener: opener, + openNotificationSettings: notifications.open) + return (handler, opener, HostSpy(), notifications) + } + + @Test("Owns the settings.open action") + func ownsSettingsOpen() { + #expect(SettingsActionHandler().actions == [.settingsOpen]) + } + + /// Notification settings have their own route rather than the app's top-level page. + @Test("The notifications target takes the notification route, not the URL one") + func notificationsTargetUsesItsOwnRoute() async throws { + let (handler, opener, host, notifications) = makeSUT() + + handler.handle(.request(.settingsOpen, payload: .object(["target": .string("notifications")])), host: host) + await drainMainQueue(until: { !host.sent.isEmpty }) + + #expect(notifications.callCount == 1) + #expect(opener.opened.isEmpty) + #expect(host.sent.first?.payload == .object(["success": .bool(true)])) + } + + /// The page asked to be sent to settings and it was — whether the user then acts on it is + /// not something the page is told. + @Test("A notification route that reports failure is still answered as a success") + func notificationsAnswerSuccessRegardless() async { + let (handler, _, host, _) = makeSUT(notificationsOpen: false) + + handler.handle(.request(.settingsOpen, payload: .object(["target": .string("notifications")])), host: host) + await drainMainQueue(until: { !host.sent.isEmpty }) + + #expect(host.sent.first?.payload == .object(["success": .bool(true)])) + } + + @Test("The application target opens the app's settings page through the system") + func applicationTargetOpensSettingsURL() async throws { + let (handler, opener, host, notifications) = makeSUT() + + handler.handle(.request(.settingsOpen, payload: .object(["target": .string("application")])), host: host) + await drainMainQueue(until: { !host.sent.isEmpty }) + + #expect(notifications.callCount == 0) + #expect(opener.opened.first?.url.absoluteString == UIApplication.openSettingsURLString) + #expect(host.sent.first?.payload == .object(["success": .bool(true)])) + } + + @Test("An unknown target is refused without reaching the system") + func unknownTargetIsRefused() throws { + let (handler, opener, host, notifications) = makeSUT() + + handler.handle(.request(.settingsOpen, payload: .object(["target": .string("somewhere-else")])), host: host) + + #expect(opener.opened.isEmpty) + #expect(notifications.callCount == 0) + let response = try #require(host.sent.first) + #expect(response.type == .error) + #expect(response.payload == .object(["error": .string("Invalid or unknown settings type")])) + } + + @Test("A payload without a target is refused") + func missingTargetIsRefused() throws { + let (handler, _, host, _) = makeSUT() + + handler.handle(.request(.settingsOpen, payload: .object([:])), host: host) + + #expect(host.sent.first?.type == .error) + } + + @Test("A payload sent as a JSON string is understood too") + func acceptsStringifiedPayload() async { + let (handler, opener, host, _) = makeSUT() + + handler.handle(.request(.settingsOpen, payload: .string("{\"target\":\"application\"}")), host: host) + await drainMainQueue(until: { !opener.opened.isEmpty }) + + #expect(opener.opened.count == 1) + } + + // MARK: - Lifetime + + /// Both routes hold the page weakly, so a show that ended while the system was switching + /// screens is not kept alive by the trip to settings. + @Test("A page released mid-route is not held by the notification route") + func notificationRouteDoesNotHoldPage() { + let notifications = NotificationSettingsSpy(result: true, defersAnswer: true) + let handler = SettingsActionHandler(urlOpener: URLOpenerSpy(), + openNotificationSettings: notifications.open) + + weak var page: HostSpy? + + do { + let host = HostSpy() + page = host + handler.handle(.request(.settingsOpen, payload: .object(["target": .string("notifications")])), host: host) + #expect(notifications.callCount == 1) + } + + #expect(page == nil) + + // The late answer finds nobody and is dropped rather than crashing. + notifications.answer() + } +} + +// MARK: - Doubles + +final class NotificationSettingsSpy { + + private let result: Bool + + /// Holds the answer back instead of giving it, standing in for the moment the system is still + /// switching screens. That is the only window in which what the SDK holds onto is observable. + private let defersAnswer: Bool + + private var pendingAnswer: ((Bool) -> Void)? + + private(set) var callCount = 0 + + init(result: Bool, defersAnswer: Bool = false) { + self.result = result + self.defersAnswer = defersAnswer + } + + func open(_ completion: @escaping (Bool) -> Void) { + callCount += 1 + + guard defersAnswer else { + completion(result) + return + } + + pendingAnswer = completion + } + + func answer() { + let pendingAnswer = self.pendingAnswer + self.pendingAnswer = nil + pendingAnswer?(result) + } +} diff --git a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/TargetingAndShowStubTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/TargetingAndShowStubTests.swift new file mode 100644 index 00000000..f1dfbd43 --- /dev/null +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/TargetingAndShowStubTests.swift @@ -0,0 +1,137 @@ +// +// TargetingAndShowStubTests.swift +// MindboxTests +// +// Created by Akylbek Utekeshev on 13.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +@_spi(Internal) @testable import Mindbox + +/// Both actions are answered but not yet implemented. What these pin is the shape of the answer: +/// the page reads `payload.inappIds` and throws if it is absent, and it gives up after three +/// seconds — so an answer of the wrong shape and no answer at all look the same from the feed. +@Suite("CheckInappsTargetingActionHandler", .tags(.webView)) +struct CheckInappsTargetingActionHandlerTests { + + @Test("Owns the checkInappsTargeting action") + func ownsAction() { + #expect(CheckInappsTargetingActionHandler().actions == [.checkInappsTargeting]) + } + + /// Nothing is filtered yet, so the feed renders whole and the rest of the contract can be + /// exercised. + @Test("Every requested id is let through, in the order it was asked about") + func letsEveryIdThrough() throws { + let host = HostSpy() + let ids: [JSONValue] = [.string("id-1"), .string("id-2"), .string("id-3")] + + CheckInappsTargetingActionHandler().handle(.request(.checkInappsTargeting, + payload: .object(["inappIds": .array(ids)])), + host: host) + + let response = try #require(host.sent.first) + #expect(response.type == .response) + #expect(response.payload == .object(["inappIds": .array(ids)])) + } + + @Test("An empty request is answered with an empty list rather than an error") + func emptyRequestIsAnsweredEmpty() { + let host = HostSpy() + + CheckInappsTargetingActionHandler().handle(.request(.checkInappsTargeting, + payload: .object(["inappIds": .array([])])), + host: host) + + #expect(host.sent.first?.payload == .object(["inappIds": .array([])])) + } + + @Test("Non-string entries are dropped instead of breaking the answer") + func nonStringEntriesAreDropped() { + let host = HostSpy() + + CheckInappsTargetingActionHandler().handle( + .request(.checkInappsTargeting, payload: .object(["inappIds": .array([.string("id-1"), .int(7)])])), + host: host + ) + + #expect(host.sent.first?.payload == .object(["inappIds": .array([.string("id-1")])])) + } + + @Test("A payload without the array is refused") + func missingArrayIsRefused() throws { + let host = HostSpy() + + CheckInappsTargetingActionHandler().handle(.request(.checkInappsTargeting, payload: .object([:])), host: host) + + let response = try #require(host.sent.first) + #expect(response.type == .error) + #expect(response.payload == .object(["error": .string("Invalid payload: missing 'inappIds' array")])) + } + + @Test("A payload sent as a JSON string is understood too") + func acceptsStringifiedPayload() { + let host = HostSpy() + + CheckInappsTargetingActionHandler().handle(.request(.checkInappsTargeting, + payload: .string(#"{"inappIds":["id-1"]}"#)), + host: host) + + #expect(host.sent.first?.payload == .object(["inappIds": .array([.string("id-1")])])) + } +} + +@Suite("ShowInAppActionHandler", .tags(.webView)) +struct ShowInAppActionHandlerTests { + + @Test("Owns the showInApp action") + func ownsAction() { + #expect(ShowInAppActionHandler().actions == [.showInApp]) + } + + /// Nothing opens yet, but the page is answered so it can finish its own flow instead of + /// waiting on a promise that never settles. + @Test("A well-formed request is acknowledged") + func requestIsAcknowledged() throws { + let host = HostSpy() + let message = BridgeMessage.request(.showInApp, payload: .object([ + "inappId": .string("11111111-1111-1111-1111-111111111111"), + "index": .int(0), + "sourceInappId": .string("feed"), + "params": .object(["title": .string("Сториз 1")]) + ])) + + ShowInAppActionHandler().handle(message, host: host) + + let response = try #require(host.sent.first) + #expect(response.type == .response) + #expect(response.payload == .object(["success": .bool(true)])) + #expect(response.id == message.id) + } + + @Test("Only the id is required") + func onlyIdIsRequired() { + let host = HostSpy() + + ShowInAppActionHandler().handle(.request(.showInApp, payload: .object(["inappId": .string("some-id")])), + host: host) + + #expect(host.sent.first?.type == .response) + } + + @Test("A request without an id is refused", arguments: [ + JSONValue.object([:]), + .object(["inappId": .string("")]), + .object(["inappId": .int(1)]) + ]) + func missingIdIsRefused(payload: JSONValue) throws { + let host = HostSpy() + + ShowInAppActionHandler().handle(.request(.showInApp, payload: payload), host: host) + + let response = try #require(host.sent.first) + #expect(response.type == .error) + #expect(response.payload == .object(["error": .string("Invalid payload: missing or empty 'inappId'")])) + } +} diff --git a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/WebBridgeActionHandlerFactoryTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/WebBridgeActionHandlerFactoryTests.swift new file mode 100644 index 00000000..c24be092 --- /dev/null +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/WebBridgeActionHandlerFactoryTests.swift @@ -0,0 +1,70 @@ +// +// WebBridgeActionHandlerFactoryTests.swift +// MindboxTests +// +// Created by Akylbek Utekeshev on 13.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +@_spi(Internal) @testable import Mindbox + +/// Guards the one thing no other suite can see: that the shipped handler set is complete. +/// +/// Every other suite either builds a handler directly or injects a registry of its own, so a +/// handler written, tested and then left out of the factory would pass all of them while the +/// action silently did nothing in the app — and for a deferred action, "nothing" means the page +/// waits for an answer that never comes. +@Suite("WebBridgeActionHandlerFactory", .tags(.webView)) +struct WebBridgeActionHandlerFactoryTests { + + /// Actions the registry is not meant to own: both travel native → JS and never arrive as + /// a request. + private static let notOwnedByRegistry: Set = [ + .navigationIntercepted, + .motionEvent + ] + + @Test("Every action a page can send has an owner in the shipped set") + func everyIncomingActionIsOwned() { + let owned = WebBridgeActionHandlerFactory.makeHandlers().reduce(into: Set()) { + $0.formUnion($1.actions) + } + + let expected = Set(BridgeMessage.Action.allCases).subtracting(Self.notOwnedByRegistry) + + #expect(expected.subtracting(owned).isEmpty, "actions with no handler in the shipped set") + } + + @Test("The shipped set claims nothing that travels native to JS") + func claimsNothingOutgoing() { + let owned = WebBridgeActionHandlerFactory.makeHandlers().reduce(into: Set()) { + $0.formUnion($1.actions) + } + + #expect(owned.isDisjoint(with: [.navigationIntercepted, .motionEvent])) + } + + /// Two handlers claiming one action is resolved by the registry, but silently — the set + /// itself should not contain the collision in the first place. + @Test("No action is claimed twice") + func noActionIsClaimedTwice() { + let handlers = WebBridgeActionHandlerFactory.makeHandlers() + let claims = handlers.flatMap(\.actions) + + #expect(claims.count == Set(claims).count) + } + + /// Several handlers own state that belongs to one page — a prepared haptic engine, a motion + /// subscription — so a set shared between shows would leak one page's devices into another. + @Test("Each call builds a fresh set") + func buildsFreshInstances() { + let first = WebBridgeActionHandlerFactory.makeHandlers() + let second = WebBridgeActionHandlerFactory.makeHandlers() + + #expect(first.count == second.count) + for (lhs, rhs) in zip(first, second) { + #expect(lhs !== rhs) + } + } +} diff --git a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/WebBridgeActionRegistryTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/WebBridgeActionRegistryTests.swift new file mode 100644 index 00000000..5056ddad --- /dev/null +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/WebBridgeActionRegistryTests.swift @@ -0,0 +1,135 @@ +// +// WebBridgeActionRegistryTests.swift +// MindboxTests +// +// Created by Akylbek Utekeshev on 13.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +@_spi(Internal) @testable import Mindbox + +@Suite("WebBridgeActionRegistry", .tags(.webView)) +struct WebBridgeActionRegistryTests { + + // MARK: - Routing + + @Test("Routes a request to the handler that owns its action") + func routesToOwner() { + let log = HandlerSpy(actions: [.log]) + let haptic = HandlerSpy(actions: [.haptic]) + let registry = WebBridgeActionRegistry(handlers: [log, haptic]) + let message = BridgeMessage.request(.haptic) + + let didHandle = registry.handle(message, host: HostSpy()) + + #expect(didHandle) + #expect(haptic.handled.map(\.id) == [message.id]) + #expect(log.handled.isEmpty) + } + + @Test("A handler owning several actions receives all of them") + func routesEveryOwnedAction() { + let localState = HandlerSpy(actions: [.localStateGet, .localStateSet, .localStateInit]) + let registry = WebBridgeActionRegistry(handlers: [localState]) + let host = HostSpy() + + registry.handle(BridgeMessage.request(.localStateGet), host: host) + registry.handle(BridgeMessage.request(.localStateSet), host: host) + registry.handle(BridgeMessage.request(.localStateInit), host: host) + + #expect(localState.handled.count == 3) + } + + @Test("Reports an unowned action instead of swallowing it") + func reportsUnownedAction() { + let registry = WebBridgeActionRegistry(handlers: [HandlerSpy(actions: [.log])]) + + let didHandle = registry.handle(BridgeMessage.request(.haptic), host: HostSpy()) + + #expect(!didHandle) + } + + @Test("An action outside the known vocabulary is not handled") + func reportsUnknownAction() { + let registry = WebBridgeActionRegistry(handlers: [HandlerSpy(actions: [.log])]) + let message = BridgeMessage(type: .request, action: "someFutureAction", payload: nil) + + let didHandle = registry.handle(message, host: HostSpy()) + + #expect(!didHandle) + } + + @Test("When two handlers claim one action, the first keeps it") + func firstClaimWins() { + let first = HandlerSpy(actions: [.log]) + let second = HandlerSpy(actions: [.log]) + let registry = WebBridgeActionRegistry(handlers: [first, second]) + + registry.handle(BridgeMessage.request(.log), host: HostSpy()) + + #expect(first.handled.count == 1) + #expect(second.handled.isEmpty) + } + + // MARK: - Session teardown + + @Test("Teardown reaches every handler, including ones that never handled anything") + func tearDownReachesEveryHandler() { + let used = HandlerSpy(actions: [.log]) + let idle = HandlerSpy(actions: [.haptic]) + let registry = WebBridgeActionRegistry(handlers: [used, idle]) + registry.handle(BridgeMessage.request(.log), host: HostSpy()) + + registry.tearDown() + + #expect(used.tearDownCount == 1) + #expect(idle.tearDownCount == 1) + } + + // MARK: - Out-of-band lookup + + @Test("A handler can be found by type for events that arrive outside a request") + func findsHandlerByType() { + let registry = WebBridgeActionRegistry(handlers: [HandlerSpy(actions: [.log]), OtherHandlerSpy()]) + + #expect(registry.handler(ofType: OtherHandlerSpy.self) != nil) + } + + @Test("Looking up a type that was never registered returns nothing") + func missingHandlerTypeIsNil() { + let registry = WebBridgeActionRegistry(handlers: [HandlerSpy(actions: [.log])]) + + #expect(registry.handler(ofType: OtherHandlerSpy.self) == nil) + } +} + +// MARK: - Doubles + +private final class HandlerSpy: WebBridgeActionHandler { + + let actions: Set + + private(set) var handled: [BridgeMessage] = [] + private(set) var tearDownCount = 0 + + init(actions: Set) { + self.actions = actions + } + + func handle(_ message: BridgeMessage, host: WebBridgeHost) { + handled.append(message) + } + + func tearDown() { + tearDownCount += 1 + } +} + +/// A second concrete type, so `handler(ofType:)` has something to tell apart from `HandlerSpy`. +private final class OtherHandlerSpy: WebBridgeActionHandler { + + let actions: Set = [.motionStart] + + func handle(_ message: BridgeMessage, host: WebBridgeHost) {} +} diff --git a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/WebBridgeHostTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/WebBridgeHostTests.swift new file mode 100644 index 00000000..3af9c9db --- /dev/null +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/WebBridgeHostTests.swift @@ -0,0 +1,69 @@ +// +// WebBridgeHostTests.swift +// MindboxTests +// +// Created by Akylbek Utekeshev on 13.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +@_spi(Internal) @testable import Mindbox + +/// The answers a host builds for a handler. +/// +/// Every one of them is built from the request itself rather than from arguments a handler passes, +/// which is what keeps an answer from drifting away from the thing it answers. That is the whole +/// subject here: identity travels, and the envelope matches its kind. +@Suite("WebBridgeHost responses", .tags(.webView)) +struct WebBridgeHostResponseTests { + + @Test("A success response carries the request's own id and action") + func successKeepsIdentity() throws { + let host = HostSpy() + let message = BridgeMessage(type: .request, action: BridgeMessage.Action.log.rawValue, payload: nil) + + host.respondSuccess(to: message) + + let response = try #require(host.sent.first) + #expect(response.id == message.id) + #expect(response.action == message.action) + #expect(response.type == .response) + #expect(response.payload == .object(["success": .bool(true)])) + } + + @Test("An error response is typed as an error and carries the reason") + func errorCarriesReason() throws { + let host = HostSpy() + let message = BridgeMessage(type: .request, action: BridgeMessage.Action.haptic.rawValue, payload: nil) + + host.respondError("Invalid payload", to: message) + + let response = try #require(host.sent.first) + #expect(response.id == message.id) + #expect(response.action == message.action) + #expect(response.type == .error) + #expect(response.payload == .object(["error": .string("Invalid payload")])) + } + + @Test("A content response carries the payload it was given") + func responseCarriesPayload() { + let host = HostSpy() + let message = BridgeMessage(type: .request, action: BridgeMessage.Action.localStateGet.rawValue, payload: nil) + let payload = JSONValue.object(["data": .object(["key": .string("value")]), "version": .int(1)]) + + host.respond(to: message, payload: payload) + + #expect(host.sent.first?.payload == payload) + } + + /// One request, one answer: a handler that answers twice would be talking against an id JS has + /// already closed, so nothing in the envelope-building path may fan out on its own. + @Test("Answering once sends exactly one message") + func answeringOnceSendsOne() { + let host = HostSpy() + + host.respondSuccess(to: .request(.log)) + + #expect(host.sent.count == 1) + } +} diff --git a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/WebViewStartPayloadBuilderTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/WebViewStartPayloadBuilderTests.swift new file mode 100644 index 00000000..719ae7cc --- /dev/null +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/WebViewStartPayloadBuilderTests.swift @@ -0,0 +1,120 @@ +// +// WebViewStartPayloadBuilderTests.swift +// MindboxTests +// +// Created by Akylbek Utekeshev on 13.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +import UIKit +@_spi(Internal) @testable import Mindbox + +/// Pins what a page is told at start-up. +/// +/// The payload was assembled inside the WebView facade and moved out wholesale; the failure to +/// guard against is a field quietly going missing on the way, which no other suite would see — +/// the page would simply configure itself wrong. +@Suite("WebViewStartPayloadBuilder", .tags(.webView)) +@MainActor +struct WebViewStartPayloadBuilderTests { + + init() { + TestConfiguration.configure() + } + + private func build(contentId: String = "content-1", + operation: (name: String, body: String)? = nil, + customParams: [String: JSONValue]? = nil) throws -> [String: JSONValue] { + let payload = WebViewStartPayloadBuilder(contentId: contentId, + operation: operation, + customParams: customParams, + insetsSource: UIView(), + logError: { _ in }).build() + + // The contract has JS calling JSON.parse on it, so a string is the shape, not a detail. + guard case .string(let json) = payload else { + throw BuilderTestError.payloadIsNotAString + } + + let data = try #require(json.data(using: .utf8)) + return try JSONDecoder().decode([String: JSONValue].self, from: data) + } + + @Test("Always carries the fields a page cannot configure itself without") + func carriesRequiredFields() throws { + let payload = try build() + + for key in ["sdkVersion", "sdkVersionNumeric", "endpointId", "deviceUUID", + "userVisitCount", "inAppId", "localStateVersion", "insets"] { + #expect(payload[key] != nil, "'\(key)' is missing from the start payload") + } + } + + @Test("The content id travels as inAppId — the key the contract already uses") + func contentIdTravelsAsInAppId() throws { + let payload = try build(contentId: "block-42") + + #expect(payload["inAppId"] == .string("block-42")) + } + + @Test("Insets are reported as four named edges") + func insetsAreNamedEdges() throws { + let payload = try build() + + guard case .object(let insets)? = payload["insets"] else { + throw BuilderTestError.insetsAreNotAnObject + } + + #expect(Set(insets.keys) == ["top", "left", "bottom", "right"]) + } + + @Test("The operation is included only when there is one") + func operationIsOptional() throws { + let without = try build() + #expect(without["operationName"] == nil) + #expect(without["operationBody"] == nil) + + let with = try build(operation: (name: "Test.Operation", body: #"{"a":1}"#)) + #expect(with["operationName"] == .string("Test.Operation")) + #expect(with["operationBody"] == .string(#"{"a":1}"#)) + } + + @Test("Configuration params are merged at the root, not nested") + func customParamsMergeAtRoot() throws { + let payload = try build(customParams: ["catalogEntry": .string("stories-feed")]) + + #expect(payload["catalogEntry"] == .string("stories-feed")) + } + + /// The order the fields are applied in is load-bearing: the configuration's own params are + /// merged before the operation, so a collision resolves towards the operation. + @Test("A configuration param cannot displace the operation") + func operationWinsOverCustomParams() throws { + let payload = try build(operation: (name: "Real.Operation", body: "{}"), + customParams: ["operationName": .string("from-config")]) + + #expect(payload["operationName"] == .string("Real.Operation")) + } + + /// A page that receives `{}` reports its own failure; a page that receives nothing waits on + /// an id that will never be closed. + @Test("An unencodable payload degrades to an empty object rather than to silence") + func unencodablePayloadDegradesToEmptyObject() { + var reported: [String] = [] + let payload = WebViewStartPayloadBuilder(contentId: "content-1", + operation: nil, + // Not representable in JSON. + customParams: ["bad": .double(.nan)], + insetsSource: nil, + logError: { reported.append($0) }).build() + + #expect(payload == .string("{}")) + #expect(reported.count == 1) + } +} + +private enum BuilderTestError: Error { + case payloadIsNotAString + case insetsAreNotAnObject +} diff --git a/MindboxTests/InApp/Tests/WebView/TransparentViewJSBridgeTests.swift b/MindboxTests/InApp/Tests/WebView/TransparentViewJSBridgeTests.swift index ee3f10a8..ef54c950 100644 --- a/MindboxTests/InApp/Tests/WebView/TransparentViewJSBridgeTests.swift +++ b/MindboxTests/InApp/Tests/WebView/TransparentViewJSBridgeTests.swift @@ -114,18 +114,21 @@ final class TransparentViewJSBridgeTests { // MARK: - Helpers private func makeView(tags: [String: String]?) -> TransparentView { + // The real dispatch path, with doubles behind the handler: this suite is about what + // reaches the repositories, and it should keep proving the view routes there at all. + let handler = OperationActionHandler(featureToggleManager: self.featureToggleManager, + databaseRepository: self.databaseRepository, + eventRepository: self.eventRepository) let view = TransparentView( frame: .zero, params: [:], userAgent: "", operation: nil, inAppId: "inapp-1", - tags: tags + tags: tags, + actionRegistry: WebBridgeActionRegistry(handlers: [handler]) ) view.facade = facade - view.featureToggleManager = featureToggleManager - view.databaseRepository = databaseRepository - view.eventRepository = eventRepository return view } @@ -161,7 +164,7 @@ private final class WebViewFacadeSpy: InappWebViewFacadeProtocol { func loadHTML(baseUrl: String, contentUrl: String, onFailure: @escaping () -> Void) {} func applyViewSettings(scrollViewDelegate: UIScrollViewDelegate?) {} func cleanWebView() {} - func sendReadyEvent(id: UUID) {} + func makeStartPayload() -> JSONValue { .string("{}") } func sendToJS(_ message: BridgeMessage) { sentMessages.append(message) } func evaluateJavaScript(_ script: String, completion: @escaping (Result) -> Void) {} func setBridgeMessageDelegate(_ delegate: WebBridgeMessageDelegate?) {}