From c9ce7ce70d2ae72acf51197c7eb50afeb5456215 Mon Sep 17 00:00:00 2001 From: Vailence Date: Thu, 13 Aug 2026 13:22:27 +0500 Subject: [PATCH 01/16] MOBILE-328: Add the web bridge action handler registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bridge action handling lives inside TransparentView today, in one switch on a 1000-line view, so nothing but the modal popup can reach log, localState or openLink. This is the seam that lets any WebView reuse them. A handler knows nothing about the page talking to it: everything it may need arrives through WebBridgeHost. Surfaces differ by what they conform to, not by a type the handlers switch on — a page sending an action its host does not listen for is journalled and dropped, never an error. That is what lets an in-app pick up contentRendered later by adding one conformance. The registry indexes actions to owners at construction. Not for speed at this size, but so that two handlers claiming one action is caught while the set is assembled and reported, instead of resolving silently by array order. Nothing is wired yet: TransparentView is untouched and behaviour is unchanged. The Handlers folders are synchronized groups, so the handlers moving in next need no project file edits. --- Mindbox.xcodeproj/project.pbxproj | 6 + .../Handlers/WebBridgeActionHandler.swift | 95 ++++++++ .../Bridge/Handlers/WebBridgeHost.swift | 108 +++++++++ .../WebBridgeActionRegistryTests.swift | 205 ++++++++++++++++++ 4 files changed, 414 insertions(+) create mode 100644 Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeActionHandler.swift create mode 100644 Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeHost.swift create mode 100644 MindboxTests/InApp/Tests/WebView/BridgeHandlers/WebBridgeActionRegistryTests.swift 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/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/WebBridgeHost.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeHost.swift new file mode 100644 index 00000000..7e8dc0ca --- /dev/null +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeHost.swift @@ -0,0 +1,108 @@ +// +// WebBridgeHost.swift +// Mindbox +// +// Created by Akylbek Utekeshev on 13.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import UIKit +import WebKit +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 } + + /// Geometry only — the safe-area insets that go into the start payload. Navigating it is + /// not a handler's business: navigation belongs to the bridge. + var webView: WKWebView? { 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) +} + +// 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/MindboxTests/InApp/Tests/WebView/BridgeHandlers/WebBridgeActionRegistryTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/WebBridgeActionRegistryTests.swift new file mode 100644 index 00000000..4675710c --- /dev/null +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/WebBridgeActionRegistryTests.swift @@ -0,0 +1,205 @@ +// +// WebBridgeActionRegistryTests.swift +// MindboxTests +// +// Created by Akylbek Utekeshev on 13.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +import UIKit +import WebKit +import MindboxLogger +@_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 = Self.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(Self.request(.localStateGet), host: host) + registry.handle(Self.request(.localStateSet), host: host) + registry.handle(Self.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(Self.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(Self.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(Self.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: - Helpers + + private static func request(_ action: BridgeMessage.Action) -> BridgeMessage { + BridgeMessage(type: .request, action: action.rawValue, payload: nil) + } +} + +@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) + } +} + +// MARK: - Doubles + +/// Records what it was asked to send. Everything a handler could want from a page is inert: +/// these tests are about routing and envelopes, not about any one page. +private final class HostSpy: WebBridgeHost { + + var contentId = "test-content-id" + var logCategory: LogCategory = .webViewInAppMessages + var tags: [String: String]? + var webView: WKWebView? { nil } + var presentingViewController: UIViewController? { nil } + var isUserPresent = true + + private(set) var sent: [BridgeMessage] = [] + + func send(_ message: BridgeMessage) { + sent.append(message) + } +} + +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) {} +} From af3ae0a0848448f6d9d0766beaf382d76f7f7044 Mon Sep 17 00:00:00 2001 From: Vailence Date: Thu, 13 Aug 2026 13:31:53 +0500 Subject: [PATCH 02/16] MOBILE-328: Move the log action into the handler registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First action out of the switch, and the first time the registry runs on the real path: TransparentView now asks it before falling through to what has not moved yet. log is the cheapest action there is, which is the point — the risky part of this step is the wiring, not the behaviour, so it is proved on something that cannot hide a regression. The handler takes its category from the host instead of naming one. For the popup that resolves to the same category it always logged under; an embedded block will get its own without the handler knowing either exists. WebViewAction.onLog and its implementation go with it: the call that was moved was the only one. payloadString replaces the inline payload unwrapping, same logic, now shared with the handler. WKWebView leaves WebBridgeHost. It was there for safe-area insets, but those belong to the start payload, which the host builds and which already holds the web view — no handler ever needed it. --- .../Views/WebView/Bridge/BridgeMessage.swift | 21 +++++- .../Bridge/Handlers/LogActionHandler.swift | 26 +++++++ .../WebBridgeActionHandlerFactory.swift | 26 +++++++ .../Bridge/Handlers/WebBridgeHost.swift | 5 -- .../Views/WebView/TransparentView.swift | 52 +++++++++----- .../Views/WebView/WebViewController.swift | 4 -- .../BridgeHandlers/BridgeHandlerDoubles.swift | 38 ++++++++++ .../LogActionHandlerTests.swift | 69 +++++++++++++++++++ .../WebBridgeActionRegistryTests.swift | 41 ++--------- 9 files changed, 221 insertions(+), 61 deletions(-) create mode 100644 Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/LogActionHandler.swift create mode 100644 Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeActionHandlerFactory.swift create mode 100644 MindboxTests/InApp/Tests/WebView/BridgeHandlers/BridgeHandlerDoubles.swift create mode 100644 MindboxTests/InApp/Tests/WebView/BridgeHandlers/LogActionHandlerTests.swift diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/BridgeMessage.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/BridgeMessage.swift index 8c224fac..0381bbef 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 @@ -603,6 +603,25 @@ public struct BridgeMessage: Codable { var parsedAction: Action? { Action(rawValue: action) } + /// 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/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/WebBridgeActionHandlerFactory.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeActionHandlerFactory.swift new file mode 100644 index 00000000..33e11b61 --- /dev/null +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeActionHandlerFactory.swift @@ -0,0 +1,26 @@ +// +// 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] { + [ + LogActionHandler() + ] + } +} diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeHost.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeHost.swift index 7e8dc0ca..e4f15f4c 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeHost.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeHost.swift @@ -7,7 +7,6 @@ // import UIKit -import WebKit import MindboxLogger /// The WebView an action runs in, as much of it as a handler is allowed to know. @@ -28,10 +27,6 @@ protocol WebBridgeHost: AnyObject { /// In-app tags, merged into operation bodies. Absent on pages that have none. var tags: [String: String]? { get } - /// Geometry only — the safe-area insets that go into the start payload. Navigating it is - /// not a handler's business: navigation belongs to the bridge. - var webView: WKWebView? { 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 } diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift index 15cb3be6..29ce26d6 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift @@ -22,7 +22,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(handlers: WebBridgeActionHandlerFactory.makeHandlers()) private var lastReadyCheckedUrl: String? private var readyChecker: WebViewReadyChecker? /// True when the page finished loading but the JS bridge never appeared within the @@ -84,6 +88,7 @@ final class TransparentView: UIView { deinit { readyChecker?.cancel() + actionRegistry.tearDown() if isMotionServiceInitialized { motionService.stopMonitoring() } Logger.common(message: "[WebView] Deinit TransparentView", category: .webViewInAppMessages) } @@ -173,27 +178,40 @@ 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) + } +} + 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 + + // Whatever the registry owns is fully handled there and never reaches the switch below. + // The rest still lives here and moves out a group at a time. + if actionRegistry.handle(message, host: self) { + return + } guard let parsedAction = BridgeMessage.Action(rawValue: action) else { Logger.common( @@ -226,9 +244,10 @@ extension TransparentView: WebBridgeMessageDelegate { case .ready: facade?.sendReadyEvent(id: message.id) - // Info + // Handled by the action registry above. Listed only to keep this switch exhaustive + // while the remaining actions move out; unreachable. case .log: - webViewAction?.onLog(message: data) + break // Operations case .asyncOperation: @@ -1048,5 +1067,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/MindboxTests/InApp/Tests/WebView/BridgeHandlers/BridgeHandlerDoubles.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/BridgeHandlerDoubles.swift new file mode 100644 index 00000000..3fe5b5ad --- /dev/null +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/BridgeHandlerDoubles.swift @@ -0,0 +1,38 @@ +// +// 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 + + private(set) var sent: [BridgeMessage] = [] + + func send(_ message: BridgeMessage) { + sent.append(message) + } +} + +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) + } +} 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/WebBridgeActionRegistryTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/WebBridgeActionRegistryTests.swift index 4675710c..4a2baf0b 100644 --- a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/WebBridgeActionRegistryTests.swift +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/WebBridgeActionRegistryTests.swift @@ -7,9 +7,6 @@ // import Testing -import UIKit -import WebKit -import MindboxLogger @_spi(Internal) @testable import Mindbox @Suite("WebBridgeActionRegistry", .tags(.webView)) @@ -22,7 +19,7 @@ struct WebBridgeActionRegistryTests { let log = HandlerSpy(actions: [.log]) let haptic = HandlerSpy(actions: [.haptic]) let registry = WebBridgeActionRegistry(handlers: [log, haptic]) - let message = Self.request(.haptic) + let message = BridgeMessage.request(.haptic) let didHandle = registry.handle(message, host: HostSpy()) @@ -37,9 +34,9 @@ struct WebBridgeActionRegistryTests { let registry = WebBridgeActionRegistry(handlers: [localState]) let host = HostSpy() - registry.handle(Self.request(.localStateGet), host: host) - registry.handle(Self.request(.localStateSet), host: host) - registry.handle(Self.request(.localStateInit), host: host) + 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) } @@ -48,7 +45,7 @@ struct WebBridgeActionRegistryTests { func reportsUnownedAction() { let registry = WebBridgeActionRegistry(handlers: [HandlerSpy(actions: [.log])]) - let didHandle = registry.handle(Self.request(.haptic), host: HostSpy()) + let didHandle = registry.handle(BridgeMessage.request(.haptic), host: HostSpy()) #expect(!didHandle) } @@ -69,7 +66,7 @@ struct WebBridgeActionRegistryTests { let second = HandlerSpy(actions: [.log]) let registry = WebBridgeActionRegistry(handlers: [first, second]) - registry.handle(Self.request(.log), host: HostSpy()) + registry.handle(BridgeMessage.request(.log), host: HostSpy()) #expect(first.handled.count == 1) #expect(second.handled.isEmpty) @@ -82,7 +79,7 @@ struct WebBridgeActionRegistryTests { let used = HandlerSpy(actions: [.log]) let idle = HandlerSpy(actions: [.haptic]) let registry = WebBridgeActionRegistry(handlers: [used, idle]) - registry.handle(Self.request(.log), host: HostSpy()) + registry.handle(BridgeMessage.request(.log), host: HostSpy()) registry.tearDown() @@ -105,12 +102,6 @@ struct WebBridgeActionRegistryTests { #expect(registry.handler(ofType: OtherHandlerSpy.self) == nil) } - - // MARK: - Helpers - - private static func request(_ action: BridgeMessage.Action) -> BridgeMessage { - BridgeMessage(type: .request, action: action.rawValue, payload: nil) - } } @Suite("WebBridgeHost responses", .tags(.webView)) @@ -158,24 +149,6 @@ struct WebBridgeHostResponseTests { // MARK: - Doubles -/// Records what it was asked to send. Everything a handler could want from a page is inert: -/// these tests are about routing and envelopes, not about any one page. -private final class HostSpy: WebBridgeHost { - - var contentId = "test-content-id" - var logCategory: LogCategory = .webViewInAppMessages - var tags: [String: String]? - var webView: WKWebView? { nil } - var presentingViewController: UIViewController? { nil } - var isUserPresent = true - - private(set) var sent: [BridgeMessage] = [] - - func send(_ message: BridgeMessage) { - sent.append(message) - } -} - private final class HandlerSpy: WebBridgeActionHandler { let actions: Set From 4cb188a6568e64574ddc07b3274819431f882e42 Mon Sep 17 00:00:00 2001 From: Vailence Date: Thu, 13 Aug 2026 13:54:02 +0500 Subject: [PATCH 03/16] MOBILE-328: Move the local state actions into the handler registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit localState.get/set/init leave the switch, taking their two helpers with them. The storage property goes too: nothing in the view referenced it any more. Envelopes are unchanged. sendBridgeError produced the same shape respondError does, so the error texts, the log formats and the .info level all survive the move verbatim; only the log category now comes from the host, which for the popup resolves to the one it always used. Storage resolves lazily and through a seam. A handler set is built for every show, so a page that never touches storage should not reach into the container for it — and the tests get a double instead of the real UserDefaults suite. payloadObject moves onto BridgeMessage: the same unwrapping is about to be needed by motion, navigate, permission and the operations. set and init carried two identical copies of the JSONValue to [String: String?] conversion. They are now one function — a string stays a string, null stays an erase distinct from empty, anything richer keeps its JSON text. --- .../Views/WebView/Bridge/BridgeMessage.swift | 18 ++ .../Handlers/LocalStateActionHandler.swift | 173 +++++++++++++ .../WebBridgeActionHandlerFactory.swift | 3 +- .../Views/WebView/TransparentView.swift | 183 +------------- .../LocalStateActionHandlerTests.swift | 228 ++++++++++++++++++ 5 files changed, 422 insertions(+), 183 deletions(-) create mode 100644 Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/LocalStateActionHandler.swift create mode 100644 MindboxTests/InApp/Tests/WebView/BridgeHandlers/LocalStateActionHandlerTests.swift diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/BridgeMessage.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/BridgeMessage.swift index 0381bbef..09402dce 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/BridgeMessage.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/BridgeMessage.swift @@ -603,6 +603,24 @@ 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 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/WebBridgeActionHandlerFactory.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeActionHandlerFactory.swift index 33e11b61..ecaf7a1d 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeActionHandlerFactory.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeActionHandlerFactory.swift @@ -20,7 +20,8 @@ enum WebBridgeActionHandlerFactory { /// prepared haptic engine, a motion subscription — which has to die with that page. static func makeHandlers() -> [WebBridgeActionHandler] { [ - LogActionHandler() + LogActionHandler(), + LocalStateActionHandler() ] } } diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift index 29ce26d6..e3942891 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift @@ -40,7 +40,6 @@ 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) @@ -246,7 +245,7 @@ extension TransparentView: WebBridgeMessageDelegate { // Handled by the action registry above. Listed only to keep this switch exhaustive // while the remaining actions move out; unreachable. - case .log: + case .log, .localStateGet, .localStateSet, .localStateInit: break // Operations @@ -263,14 +262,6 @@ extension TransparentView: WebBridgeMessageDelegate { 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) @@ -556,178 +547,6 @@ extension TransparentView { } } -// 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 { 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) + } +} From c4cf74fc5615fda2139488fd5c7fa8ed9f3cf223 Mon Sep 17 00:00:00 2001 From: Vailence Date: Thu, 13 Aug 2026 14:06:48 +0500 Subject: [PATCH 04/16] MOBILE-328: Move the link, settings and permission actions into the registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These three travel together because they already did: settings.open has always reached the system through the navigate handler's URL opening, so that path becomes a small shared seam both handlers hold rather than a copy each. The seam is also what makes the routing testable. Which way a link opens could previously only be checked by actually leaving the app; now the decision is observable and the fallback to Safari is not. Two behaviours are corrected rather than carried: The universal-link check no longer depends on the handler being alive. The original called UIApplication.open unconditionally and only guarded the answer with self?; translating that to self?.urlOpener would have silently dropped the open itself if the show ended mid-check. Nothing captures self now, and the page is held weakly — a show that ended gets no answer, and waiting on the system is not what keeps it alive. openLink and permission.request now accept an object payload, not only a JSON string. localState and settings.open always accepted both; the difference read as an accident, and it meant a page whose settings.open worked would be refused on openLink for the same shape. SettingsRequestParser drops the fourth copy of the payload unwrapping. The navigate prefix in the shared opener's log is left as it was, including for settings.open, which has always logged under it. It deserves fixing, but not in a change whose point is that nothing moved. --- .../Bridge/Handlers/BridgeURLOpening.swift | 64 +++++ .../Handlers/OpenLinkActionHandler.swift | 103 ++++++++ .../Handlers/PermissionActionHandler.swift | 81 +++++++ .../Handlers/SettingsActionHandler.swift | 57 +++++ .../WebBridgeActionHandlerFactory.swift | 5 +- .../Settings/SettingsRequestParser.swift | 16 +- .../Views/WebView/TransparentView.swift | 226 +----------------- .../BridgeHandlers/BridgeHandlerDoubles.swift | 17 ++ .../OpenLinkActionHandlerTests.swift | 158 ++++++++++++ .../PermissionActionHandlerTests.swift | 175 ++++++++++++++ .../SettingsActionHandlerTests.swift | 118 +++++++++ 11 files changed, 781 insertions(+), 239 deletions(-) create mode 100644 Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/BridgeURLOpening.swift create mode 100644 Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/OpenLinkActionHandler.swift create mode 100644 Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/PermissionActionHandler.swift create mode 100644 Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/SettingsActionHandler.swift create mode 100644 MindboxTests/InApp/Tests/WebView/BridgeHandlers/OpenLinkActionHandlerTests.swift create mode 100644 MindboxTests/InApp/Tests/WebView/BridgeHandlers/PermissionActionHandlerTests.swift create mode 100644 MindboxTests/InApp/Tests/WebView/BridgeHandlers/SettingsActionHandlerTests.swift 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/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/PermissionActionHandler.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/PermissionActionHandler.swift new file mode 100644 index 00000000..cbb51f7b --- /dev/null +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/PermissionActionHandler.swift @@ -0,0 +1,81 @@ +// +// 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 + } + + handler.request { result in + DispatchQueue.main.async { + 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/SettingsActionHandler.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/SettingsActionHandler.swift new file mode 100644 index 00000000..65875d72 --- /dev/null +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/SettingsActionHandler.swift @@ -0,0 +1,57 @@ +// +// 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. + openNotificationSettings { _ in + DispatchQueue.main.async { + 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/WebBridgeActionHandlerFactory.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeActionHandlerFactory.swift index ecaf7a1d..83a19bde 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeActionHandlerFactory.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeActionHandlerFactory.swift @@ -21,7 +21,10 @@ enum WebBridgeActionHandlerFactory { static func makeHandlers() -> [WebBridgeActionHandler] { [ LogActionHandler(), - LocalStateActionHandler() + LocalStateActionHandler(), + OpenLinkActionHandler(), + SettingsActionHandler(), + PermissionActionHandler() ] } } 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 e3942891..9deb21ef 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift @@ -7,7 +7,6 @@ import UIKit import WebKit -import SafariServices import MindboxLogger // swiftlint:disable file_length @@ -40,7 +39,6 @@ final class TransparentView: UIView { private let noCacheRetryPolicy = WebViewNoCacheRetryPolicy { InAppWebViewDataStore.isCacheFeatureEnabled } - 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) @@ -245,7 +243,8 @@ extension TransparentView: WebBridgeMessageDelegate { // Handled by the action registry above. Listed only to keep this switch exhaustive // while the remaining actions move out; unreachable. - case .log, .localStateGet, .localStateSet, .localStateInit: + case .log, .localStateGet, .localStateSet, .localStateInit, + .openLink, .settingsOpen, .permissionRequest: break // Operations @@ -254,14 +253,6 @@ extension TransparentView: WebBridgeMessageDelegate { case .syncOperation: handleSyncOperation(message: message) - // Navigation, Settings & Permissions - case .openLink: - handleNavigate(message: message) - case .settingsOpen: - handleOpenSettings(message: message) - case .permissionRequest: - handlePermissionRequest(message: message) - // Haptic case .haptic: handleHaptic(message: message) @@ -547,187 +538,6 @@ extension TransparentView { } } -// 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 { @@ -754,38 +564,6 @@ extension TransparentView { } } -// 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 extension TransparentView { diff --git a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/BridgeHandlerDoubles.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/BridgeHandlerDoubles.swift index 3fe5b5ad..e23dec6e 100644 --- a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/BridgeHandlerDoubles.swift +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/BridgeHandlerDoubles.swift @@ -36,3 +36,20 @@ extension 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.. Void) { + opened.append((url, universalLinksOnly)) + completion(result) + } +} diff --git a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/PermissionActionHandlerTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/PermissionActionHandlerTests.swift new file mode 100644 index 00000000..93aa6c80 --- /dev/null +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/PermissionActionHandlerTests.swift @@ -0,0 +1,175 @@ +// +// 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: - Doubles + +final class PermissionHandlerSpy: PermissionHandler { + + let permissionType: PermissionType = .pushNotifications + let requiredInfoPlistKeys: [String] + + private let result: PermissionRequestResult + + private(set) var requestCount = 0 + + init(result: PermissionRequestResult, requiredInfoPlistKeys: [String]) { + self.result = result + self.requiredInfoPlistKeys = requiredInfoPlistKeys + } + + func request(completion: @escaping (PermissionRequestResult) -> Void) { + requestCount += 1 + completion(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/SettingsActionHandlerTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/SettingsActionHandlerTests.swift new file mode 100644 index 00000000..89f0ee6b --- /dev/null +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/SettingsActionHandlerTests.swift @@ -0,0 +1,118 @@ +// +// 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: - Doubles + +final class NotificationSettingsSpy { + + private let result: Bool + + private(set) var callCount = 0 + + init(result: Bool) { + self.result = result + } + + func open(_ completion: @escaping (Bool) -> Void) { + callCount += 1 + completion(result) + } +} From 5485316a90f4016ab61ac8b98d4787d9e382e3e0 Mon Sep 17 00:00:00 2001 From: Vailence Date: Thu, 13 Aug 2026 14:15:17 +0500 Subject: [PATCH 05/16] MOBILE-328: Move the haptic and motion actions into the registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both services are registered transient, so a second resolution is a second engine. Leaving one in the view for init/close while the handler owned another would have preparing, playing and stopping land on three different objects and silently do nothing — so the device state lives in the handlers alone, and the view reaches them through the registry. close now tears the registry down in the position it used to stop the pattern and the sensors: devices are released before the window goes, because a pattern playing into a closed show and a sensor callback reaching a dead page are the shape crashes come in. The lazy-initialisation guard is carried over for motion and added for haptics, which never had one: today every close builds a haptic engine just to stop a pattern that was never started. Stopping an engine that does not exist is a no-op, so this is the same behaviour minus an allocation on every show. A gesture arrives from the sensors with no request behind it, so the page is remembered from the request that started monitoring — weakly, since a gesture must not be what keeps a finished show alive. handleSystemShake stays on the view because the system delivers a shake to the responder, not to the bridge. The file_length exemption goes: the file no longer needs it, and leaving it raises a new warning. --- .../Bridge/Handlers/HapticActionHandler.swift | 54 +++++ .../Bridge/Handlers/MotionActionHandler.swift | 131 +++++++++++ .../WebBridgeActionHandlerFactory.swift | 4 +- .../Views/WebView/TransparentView.swift | 129 +---------- .../HapticActionHandlerTests.swift | 112 +++++++++ .../MotionActionHandlerTests.swift | 219 ++++++++++++++++++ 6 files changed, 530 insertions(+), 119 deletions(-) create mode 100644 Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/HapticActionHandler.swift create mode 100644 Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/MotionActionHandler.swift create mode 100644 MindboxTests/InApp/Tests/WebView/BridgeHandlers/HapticActionHandlerTests.swift create mode 100644 MindboxTests/InApp/Tests/WebView/BridgeHandlers/MotionActionHandlerTests.swift 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/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/WebBridgeActionHandlerFactory.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeActionHandlerFactory.swift index 83a19bde..9aae102b 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeActionHandlerFactory.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeActionHandlerFactory.swift @@ -24,7 +24,9 @@ enum WebBridgeActionHandlerFactory { LocalStateActionHandler(), OpenLinkActionHandler(), SettingsActionHandler(), - PermissionActionHandler() + PermissionActionHandler(), + HapticActionHandler(), + MotionActionHandler() ] } } diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift index 9deb21ef..ec789d61 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift @@ -9,7 +9,6 @@ import UIKit import WebKit import MindboxLogger -// swiftlint:disable file_length final class TransparentView: UIView { weak var delegate: WebVCDelegate? @@ -39,19 +38,9 @@ final class TransparentView: UIView { private let noCacheRetryPolicy = WebViewNoCacheRetryPolicy { InAppWebViewDataStore.isCacheFeatureEnabled } - 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]?) { self.params = params @@ -86,7 +75,6 @@ final class TransparentView: UIView { deinit { readyChecker?.cancel() actionRegistry.tearDown() - if isMotionServiceInitialized { motionService.stopMonitoring() } Logger.common(message: "[WebView] Deinit TransparentView", category: .webViewInAppMessages) } @@ -223,8 +211,10 @@ extension TransparentView: WebBridgeMessageDelegate { // Lifecycle case .close: quizInitTimeoutWorkItem?.cancel() - hapticService.stopPattern() - if isMotionServiceInitialized { motionService.stopMonitoring() } + // 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() case .`init`: hasReceivedInit = true @@ -232,7 +222,7 @@ extension TransparentView: WebBridgeMessageDelegate { // 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() + actionRegistry.handler(ofType: HapticActionHandler.self)?.prepare() webViewAction?.onInit() case .click: webViewAction?.onCompleted(data: data) @@ -244,7 +234,8 @@ extension TransparentView: WebBridgeMessageDelegate { // Handled by the action registry above. Listed only to keep this switch exhaustive // while the remaining actions move out; unreachable. case .log, .localStateGet, .localStateSet, .localStateInit, - .openLink, .settingsOpen, .permissionRequest: + .openLink, .settingsOpen, .permissionRequest, + .haptic, .motionStart, .motionStop: break // Operations @@ -253,16 +244,6 @@ extension TransparentView: WebBridgeMessageDelegate { case .syncOperation: handleSyncOperation(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 @@ -554,102 +535,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: - 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() } } 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/MotionActionHandlerTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/MotionActionHandlerTests.swift new file mode 100644 index 00000000..b6a3969c --- /dev/null +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/MotionActionHandlerTests.swift @@ -0,0 +1,219 @@ +// +// 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")])) + } + + @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) + } +} From 90492dfbccefd1d7dd9abb33a2bd8951afd4e75c Mon Sep 17 00:00:00 2001 From: Vailence Date: Thu, 13 Aug 2026 14:23:23 +0500 Subject: [PATCH 06/16] MOBILE-328 Move async operations --- .../Handlers/OperationActionHandler.swift | 186 ++++++++++++++++++ .../Views/WebView/TransparentView.swift | 167 ++-------------- ...parentViewSyncOperationResponseTests.swift | 28 +-- .../TransparentViewJSBridgeTests.swift | 11 +- 4 files changed, 223 insertions(+), 169 deletions(-) create mode 100644 Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/OperationActionHandler.swift 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/TransparentView.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift index ec789d61..29716e9b 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift @@ -24,7 +24,7 @@ final class TransparentView: UIView { /// 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(handlers: WebBridgeActionHandlerFactory.makeHandlers()) + 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 @@ -38,11 +38,18 @@ final class TransparentView: UIView { private let noCacheRetryPolicy = WebViewNoCacheRetryPolicy { InAppWebViewDataStore.isCacheFeatureEnabled } - lazy var featureToggleManager: FeatureToggleManager = DI.injectOrFail(FeatureToggleManager.self) - lazy var databaseRepository: DatabaseRepositoryProtocol = DI.injectOrFail(DatabaseRepositoryProtocol.self) - lazy var eventRepository: EventRepository = DI.injectOrFail(EventRepository.self) - 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 @@ -53,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 = "" @@ -63,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 = "" @@ -235,15 +244,10 @@ extension TransparentView: WebBridgeMessageDelegate { // while the remaining actions move out; unreachable. case .log, .localStateGet, .localStateSet, .localStateInit, .openLink, .settingsOpen, .permissionRequest, - .haptic, .motionStart, .motionStop: + .haptic, .motionStart, .motionStop, + .asyncOperation, .syncOperation: break - // Operations - case .asyncOperation: - handleAsyncOperation(message: message) - case .syncOperation: - handleSyncOperation(message: message) - // Native → JS (not handled here) case .navigationIntercepted, .motionEvent: break @@ -380,145 +384,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: - WKNavigationType String Representation private extension WKNavigationType { 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/TransparentViewJSBridgeTests.swift b/MindboxTests/InApp/Tests/WebView/TransparentViewJSBridgeTests.swift index ee3f10a8..09a97714 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 } From 62146982540c113daba3c60361b585461c7f5e40 Mon Sep 17 00:00:00 2001 From: Vailence Date: Thu, 13 Aug 2026 14:31:13 +0500 Subject: [PATCH 07/16] MOBILE-328: Register the operation handler in the shipped set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit moved asyncOperation and syncOperation into a handler but never added it to the factory, so nothing owned them: the switch cases were gone, the registry answered that it had no owner, and both actions are deferred — meaning RequestMessageHandler had not answered either. The page would wait for a response that was never coming. It went unnoticed because the suite that covers these actions injects a registry of its own, so it exercised the handler without ever consulting the set the app actually ships. A guard against exactly this follows with the next commit, once the last actions have left the switch and the shipped set is supposed to be complete. --- .../Bridge/Handlers/WebBridgeActionHandlerFactory.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeActionHandlerFactory.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeActionHandlerFactory.swift index 9aae102b..816119dc 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeActionHandlerFactory.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeActionHandlerFactory.swift @@ -26,7 +26,8 @@ enum WebBridgeActionHandlerFactory { SettingsActionHandler(), PermissionActionHandler(), HapticActionHandler(), - MotionActionHandler() + MotionActionHandler(), + OperationActionHandler() ] } } From 346cfdc0d69309318ea4a448f48702ce6bb22462 Mon Sep 17 00:00:00 2001 From: Vailence Date: Thu, 13 Aug 2026 14:31:29 +0500 Subject: [PATCH 08/16] MOBILE-328: Move the lifecycle actions into the registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit close, init, click and hide were the last actions in the switch, and with them gone it collapses to a single guard for ready, which waits on its payload builder. They are also the first use of what the capability protocols are for. The handler is registered everywhere like any other, and a host that has no window to close simply does not conform: the message is journalled and dropped rather than refused. That is the whole mechanism by which a surface picks one of these up later — a conformance, and nothing else changes. bridgeDidInit sets hasReceivedInit before it does anything else. The no-cache retry policy decides whether a failed subresource is worth healing by asking whether the page ever booted, and a flag set late silently disables the healing with no other symptom. The factory guard arrives here: every action a page can send must have an owner in the shipped set, save ready and the two that only travel native to JS. It was written against the omission fixed in the previous commit and does fail on it, naming both actions. --- .../Handlers/LifecycleActionHandler.swift | 47 ++++++++ .../WebBridgeActionHandlerFactory.swift | 3 +- .../Views/WebView/TransparentView.swift | 77 +++++++------ .../LifecycleActionHandlerTests.swift | 108 ++++++++++++++++++ .../WebBridgeActionHandlerFactoryTests.swift | 73 ++++++++++++ 5 files changed, 270 insertions(+), 38 deletions(-) create mode 100644 Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/LifecycleActionHandler.swift create mode 100644 MindboxTests/InApp/Tests/WebView/BridgeHandlers/LifecycleActionHandlerTests.swift create mode 100644 MindboxTests/InApp/Tests/WebView/BridgeHandlers/WebBridgeActionHandlerFactoryTests.swift 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/WebBridgeActionHandlerFactory.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeActionHandlerFactory.swift index 816119dc..47d11d4e 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeActionHandlerFactory.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeActionHandlerFactory.swift @@ -27,7 +27,8 @@ enum WebBridgeActionHandlerFactory { PermissionActionHandler(), HapticActionHandler(), MotionActionHandler(), - OperationActionHandler() + OperationActionHandler(), + LifecycleActionHandler() ] } } diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift index 29716e9b..c12447d1 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift @@ -191,6 +191,41 @@ extension TransparentView: WebBridgeHost { } } +// 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 @@ -215,43 +250,11 @@ extension TransparentView: WebBridgeMessageDelegate { return } - switch parsedAction { - - // Lifecycle - case .close: - 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() - 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() - actionRegistry.handler(ofType: HapticActionHandler.self)?.prepare() - webViewAction?.onInit() - case .click: - webViewAction?.onCompleted(data: data) - case .hide: - webViewAction?.onHide() - case .ready: - facade?.sendReadyEvent(id: message.id) - - // Handled by the action registry above. Listed only to keep this switch exhaustive - // while the remaining actions move out; unreachable. - case .log, .localStateGet, .localStateSet, .localStateInit, - .openLink, .settingsOpen, .permissionRequest, - .haptic, .motionStart, .motionStop, - .asyncOperation, .syncOperation: - break - - // Native → JS (not handled here) - case .navigationIntercepted, .motionEvent: - break - } + // `ready` is the last action still waiting for a handler of its own. Everything else + // is either owned by the registry above, or is native → JS and never arrives here. + guard parsedAction == .ready else { return } + + facade?.sendReadyEvent(id: message.id) } } diff --git a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/LifecycleActionHandlerTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/LifecycleActionHandlerTests.swift new file mode 100644 index 00000000..954792a6 --- /dev/null +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/LifecycleActionHandlerTests.swift @@ -0,0 +1,108 @@ +// +// 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 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/WebBridgeActionHandlerFactoryTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/WebBridgeActionHandlerFactoryTests.swift new file mode 100644 index 00000000..7bc4b0ac --- /dev/null +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/WebBridgeActionHandlerFactoryTests.swift @@ -0,0 +1,73 @@ +// +// 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. + /// + /// `ready` is answered by the view until its payload builder is extracted; the other two + /// travel native → JS and never arrive as a request. + private static let notOwnedByRegistry: Set = [ + .ready, + .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) + } + } +} From bd6ebfbfddac56dc1f71f41d23c5acbe51e7389b Mon Sep 17 00:00:00 2001 From: Vailence Date: Thu, 13 Aug 2026 14:44:28 +0500 Subject: [PATCH 09/16] MOBILE-328: Move ready and its start payload out of the view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last action leaves the switch, and with it the switch itself: dispatch is now the registry and nothing else. Composing the payload moves into a builder of its own. Three callers need it rather than one — ready, the config-update push, and shortly the embedded block — and the assembly was private to the WebView facade, which is neither the right owner nor reachable from a block. What goes in belongs to the host, not the handler: an in-app knows its operation, a block will know its configuration entry, while answering ready with it is the same everywhere. The facade hands the payload over instead of sending it, and sendReadyEvent goes with it. The order the fields are applied in is preserved and now pinned: the configuration's own params are merged before the operation and the track-visit fields, so a colliding key resolves towards the operation. Serialization is guarded before it runs. JSONSerialization raises an Objective-C exception for a NaN or an infinity instead of throwing, so the existing catch could never have run and the host app would have gone down — the empty-object fallback the code already promised was not reachable. Values arriving here are decoded from JSON today, which can express neither, but this is the payload every surface is answered with and the assumption is not worth a crash. Found by the test that pins the fallback. --- .../Bridge/Handlers/ReadyActionHandler.swift | 27 +++ .../WebBridgeActionHandlerFactory.swift | 1 + .../Bridge/Handlers/WebBridgeHost.swift | 8 + .../Handlers/WebViewStartPayloadBuilder.swift | 168 ++++++++++++++++++ .../WebView/Debug/MindboxWebViewFacade.swift | 129 +------------- .../Views/WebView/TransparentView.swift | 20 +-- .../BridgeHandlers/BridgeHandlerDoubles.swift | 8 + .../LifecycleActionHandlerTests.swift | 4 + .../ReadyActionHandlerTests.swift | 57 ++++++ .../WebBridgeActionHandlerFactoryTests.swift | 7 +- .../WebViewStartPayloadBuilderTests.swift | 120 +++++++++++++ .../TransparentViewJSBridgeTests.swift | 2 +- 12 files changed, 410 insertions(+), 141 deletions(-) create mode 100644 Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/ReadyActionHandler.swift create mode 100644 Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebViewStartPayloadBuilder.swift create mode 100644 MindboxTests/InApp/Tests/WebView/BridgeHandlers/ReadyActionHandlerTests.swift create mode 100644 MindboxTests/InApp/Tests/WebView/BridgeHandlers/WebViewStartPayloadBuilderTests.swift 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/WebBridgeActionHandlerFactory.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeActionHandlerFactory.swift index 47d11d4e..6f46f8d1 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeActionHandlerFactory.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeActionHandlerFactory.swift @@ -20,6 +20,7 @@ enum WebBridgeActionHandlerFactory { /// prepared haptic engine, a motion subscription — which has to die with that page. static func makeHandlers() -> [WebBridgeActionHandler] { [ + ReadyActionHandler(), LogActionHandler(), LocalStateActionHandler(), OpenLinkActionHandler(), diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeHost.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeHost.swift index e4f15f4c..dbb00a0b 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeHost.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeHost.swift @@ -41,6 +41,14 @@ protocol WebBridgeHost: AnyObject { /// 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 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/TransparentView.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift index c12447d1..fc47ad0d 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift @@ -189,6 +189,10 @@ extension TransparentView: WebBridgeHost { func send(_ message: BridgeMessage) { facade?.sendToJS(message) } + + func makeStartPayload() -> JSONValue { + facade?.makeStartPayload() ?? .string("{}") + } } // MARK: - WebBridgeLifecycleHosting @@ -236,25 +240,15 @@ extension TransparentView: WebBridgeMessageDelegate { category: .webViewInAppMessages ) - // Whatever the registry owns is fully handled there and never reaches the switch below. - // The rest still lives here and moves out a group at a time. - if actionRegistry.handle(message, host: self) { - return - } - - 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 } - - // `ready` is the last action still waiting for a handler of its own. Everything else - // is either owned by the registry above, or is native → JS and never arrives here. - guard parsedAction == .ready else { return } - - facade?.sendReadyEvent(id: message.id) } } diff --git a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/BridgeHandlerDoubles.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/BridgeHandlerDoubles.swift index e23dec6e..22ebfcc0 100644 --- a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/BridgeHandlerDoubles.swift +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/BridgeHandlerDoubles.swift @@ -22,11 +22,19 @@ final class HostSpy: WebBridgeHost { 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 { diff --git a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/LifecycleActionHandlerTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/LifecycleActionHandlerTests.swift index 954792a6..9bce9d0c 100644 --- a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/LifecycleActionHandlerTests.swift +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/LifecycleActionHandlerTests.swift @@ -89,6 +89,10 @@ private final class LifecycleHostSpy: WebBridgeHost, WebBridgeLifecycleHosting { sent.append(message) } + func makeStartPayload() -> JSONValue { + .string("{}") + } + func bridgeDidInit() { events.append("init") } 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/WebBridgeActionHandlerFactoryTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/WebBridgeActionHandlerFactoryTests.swift index 7bc4b0ac..c24be092 100644 --- a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/WebBridgeActionHandlerFactoryTests.swift +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/WebBridgeActionHandlerFactoryTests.swift @@ -18,12 +18,9 @@ import Testing @Suite("WebBridgeActionHandlerFactory", .tags(.webView)) struct WebBridgeActionHandlerFactoryTests { - /// Actions the registry is not meant to own. - /// - /// `ready` is answered by the view until its payload builder is extracted; the other two - /// travel native → JS and never arrive as a request. + /// Actions the registry is not meant to own: both travel native → JS and never arrive as + /// a request. private static let notOwnedByRegistry: Set = [ - .ready, .navigationIntercepted, .motionEvent ] 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 09a97714..ef54c950 100644 --- a/MindboxTests/InApp/Tests/WebView/TransparentViewJSBridgeTests.swift +++ b/MindboxTests/InApp/Tests/WebView/TransparentViewJSBridgeTests.swift @@ -164,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?) {} From 09bef1964aac2a4ef6bb5e16c8d1374859821810 Mon Sep 17 00:00:00 2001 From: Vailence Date: Thu, 13 Aug 2026 15:02:16 +0500 Subject: [PATCH 10/16] MOBILE-328: Move the embedded block onto the shared bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The block spoke a dialect of its own: its own handler name, its own envelope, one stub action, and no way for the SDK to say anything back. It now speaks what in-apps speak, which is the whole point — a page written once works in either surface, and the block gets logging, local state, links and operations without a line of block-specific code. Readiness comes from contentRendered instead of a height. The page reports how much it drew, once per load: nothing drawn is a valid outcome and collapses the block without an error screen, which is precisely why this is not init — that one is welded to presenting a window and has no such ending. height and heightChanged go with it. The host has always owned the block's height; the page never had a say, and the site will not be sending a number. Two files are deleted rather than adapted. EmbeddedBlockPageMessage was the whole ad-hoc envelope, and EmbeddedBlockActionRouter knew a single action which it only logged — the shared openLink replaces it outright. The load's navigation is handed to the bridge. Its staleness gate drops every script message until that exact document commits, so a reused web view cannot deliver a previous owner's — and without this the page's messages would vanish in silence while the block waited out its whole budget. Navigation policy comes with the bridge too: a tap on a link is refused in place. A block is a piece of the host's own layout, and replacing the feed with the destination there is a dead end with no way back; links belong in openLink. Whether a user is actually looking is now the page's business to know. The provider keeps it current, and the bridge reads it before acting on the user's behalf — a block off screen keeps a live page that can still deliver whatever its setTimeout scheduled. The waiting budget goes to twelve seconds. It now covers strictly more than before: load, bridge boot, the start payload, the page's own pipeline including up to three seconds of waiting on targeting, and only then the report. --- .../DI/Injections/InjectEmbeddedBlocks.swift | 13 +- .../Actions/EmbeddedBlockActionRouter.swift | 35 --- .../EmbeddedBlockContentProviderFactory.swift | 8 +- .../WebView/EmbeddedBlockPageHosting.swift | 28 ++- .../WebView/EmbeddedBlockPageMessage.swift | 94 -------- .../WebView/EmbeddedBlockWebViewPage.swift | 216 ++++++++++++------ .../EmbeddedBlockWebViewProvider.swift | 100 ++++---- .../Views/WebView/Bridge/BridgeMessage.swift | 25 ++ .../ContentRenderedActionHandler.swift | 57 +++++ .../WebBridgeActionHandlerFactory.swift | 3 +- Mindbox/Utilities/Constants.swift | 15 +- ...ddedBlockContentProviderFactoryTests.swift | 6 +- .../EmbeddedBlocks/EmbeddedBlockMocks.swift | 38 ++- .../EmbeddedBlockWebViewPageTests.swift | 76 +++--- .../EmbeddedBlockWebViewProviderTests.swift | 141 +++++------- .../MindboxEmbeddedBlockViewTests.swift | 53 +++-- .../ContentRenderedActionHandlerTests.swift | 146 ++++++++++++ 17 files changed, 617 insertions(+), 437 deletions(-) delete mode 100644 Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift delete mode 100644 Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageMessage.swift create mode 100644 Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/ContentRenderedActionHandler.swift create mode 100644 MindboxTests/InApp/Tests/WebView/BridgeHandlers/ContentRenderedActionHandlerTests.swift 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/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..c4ac5161 100644 --- a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift @@ -12,144 +12,218 @@ 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 + +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 } + + var presentingViewController: UIViewController? { + view.window?.rootViewController } - fileprivate func receive(body: Any) { - guard let message = EmbeddedBlockPageMessage(body: body) else { - Logger.common(message: "[EmbeddedBlock] Unknown page message: \(body)", category: .embeddedBlocks) - return - } + func send(_ message: BridgeMessage) { + bridge.send(message) + } - onMessage?(message) + 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() } } -/// 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 { +// MARK: - WebBridgeContentHosting - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - onLoadFinish?() - } +extension EmbeddedBlockWebViewPage: WebBridgeContentHosting { - func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { - reportLoadFailure(error, phase: "navigation") + func bridgeDidRenderContent(count: Int) { + onContentRendered?(count) } +} + +// MARK: - WebBridgeMessageDelegate + +extension EmbeddedBlockWebViewPage: WebBridgeMessageDelegate { - func webView(_ webView: WKWebView, - didFailProvisionalNavigation navigation: WKNavigation!, - withError error: Error) { - reportLoadFailure(error, phase: "provisional navigation") + 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 { + + 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 `isStarted`. - func reportLoadFailure(_ error: Error, phase: String) { + /// 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..88d39d9a 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,48 @@ 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: 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(\(count))", + category: .embeddedBlocks) + return + } + + didReportContent = true - actionHandler.handle(action) + // The number the page reported, not the size of a collection. + // swiftlint:disable:next empty_count + guard count > 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 \(count)", + 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 +150,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 +172,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 +190,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 +208,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 09402dce..ed518b26 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/BridgeMessage.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/BridgeMessage.swift @@ -300,6 +300,26 @@ 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 + // MARK: JS → Native: Operations /// JS triggers an asynchronous Mindbox operation (fire-and-forget). @@ -557,6 +577,11 @@ 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 + // Operations case .asyncOperation, .syncOperation: return true 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..3137f52c --- /dev/null +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/ContentRenderedActionHandler.swift @@ -0,0 +1,57 @@ +// +// 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 count = 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 — isEmpty has no meaning + // here, and a negative number is a page bug worth naming rather than clamping. + // swiftlint:disable:next empty_count + guard count >= 0 else { + host.respondError("Invalid payload: 'count' must not be negative, got \(count)", to: message) + return + } + + guard let content = host as? WebBridgeContentHosting else { + Logger.common(message: "[WebView] Bridge: contentRendered(\(count)) from '\(host.contentId)' has nobody listening here, ignoring", + category: host.logCategory) + host.respondSuccess(to: message) + return + } + + content.bridgeDidRenderContent(count: count) + 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/WebBridgeActionHandlerFactory.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeActionHandlerFactory.swift index 6f46f8d1..89460d3e 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeActionHandlerFactory.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeActionHandlerFactory.swift @@ -29,7 +29,8 @@ enum WebBridgeActionHandlerFactory { HapticActionHandler(), MotionActionHandler(), OperationActionHandler(), - LifecycleActionHandler() + LifecycleActionHandler(), + ContentRenderedActionHandler() ] } } 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..953c7734 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewPageTests.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewPageTests.swift @@ -8,7 +8,7 @@ import Testing 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 +29,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 +39,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 +59,40 @@ 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. + @Test("Loading hands the navigation to the bridge") + func loadRegistersItsNavigation() { + let bed = PageBed() + + bed.page.load() + + #expect(bed.page.webView.url != nil) + } + + /// 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) + } } /// A real page with a real web view, but without the network: the tests call the navigation delegate @@ -93,10 +109,14 @@ private final class PageBed { /// 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 } + + private let bridge: MindboxWebBridge init() { - page = EmbeddedBlockWebViewPage(content: .stub, webView: WKWebView()) + let webView = WKWebView() + bridge = MindboxWebBridge(webView: webView) + page = EmbeddedBlockWebViewPage(id: "block-id", content: .stub, webView: webView) page.onLoadFailure = { [weak self] in self?.failures += 1 } @@ -110,14 +130,18 @@ private final class PageBed { } 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/WebView/BridgeHandlers/ContentRenderedActionHandlerTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/ContentRenderedActionHandlerTests.swift new file mode 100644 index 00000000..7e9716cf --- /dev/null +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/ContentRenderedActionHandlerTests.swift @@ -0,0 +1,146 @@ +// +// ContentRenderedActionHandlerTests.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("ContentRenderedActionHandler", .tags(.webView)) +struct ContentRenderedActionHandlerTests { + + @Test("Owns the contentRendered action") + func ownsContentRendered() { + #expect(ContentRenderedActionHandler().actions == [.contentRendered]) + } + + @Test("A count reaches the host and is confirmed") + func countReachesHost() throws { + let host = ContentHostSpy() + let message = BridgeMessage.request(.contentRendered, payload: .object(["count": .int(5)])) + + ContentRenderedActionHandler().handle(message, host: host) + + #expect(host.rendered == [5]) + let response = try #require(host.sent.first) + #expect(response.type == .response) + #expect(response.id == message.id) + } + + /// Alive, correct, and with nothing to show. The handler passes it on as an outcome rather + /// than turning it into an error — deciding what to do with it belongs to the surface. + @Test("Zero is delivered like any other count") + func zeroIsDelivered() { + let host = ContentHostSpy() + + ContentRenderedActionHandler().handle(.request(.contentRendered, payload: .object(["count": .int(0)])), + host: host) + + #expect(host.rendered == [0]) + #expect(host.sent.first?.type == .response) + } + + /// JS has one number type, so a whole value may arrive either way. + @Test("A count sent as a JS number is understood") + func doubleCountIsUnderstood() { + let host = ContentHostSpy() + + ContentRenderedActionHandler().handle(.request(.contentRendered, payload: .object(["count": .double(3)])), + host: host) + + #expect(host.rendered == [3]) + } + + @Test("A payload sent as a JSON string is understood too") + func acceptsStringifiedPayload() { + let host = ContentHostSpy() + + ContentRenderedActionHandler().handle(.request(.contentRendered, payload: .string(#"{"count":2}"#)), + host: host) + + #expect(host.rendered == [2]) + } + + /// Deferred precisely so this can be refused: the blanket success would claim the SDK acted + /// on a number it never received. + @Test("A payload without a usable count is refused and never reaches the host") + func missingCountIsRefused() throws { + let host = ContentHostSpy() + + ContentRenderedActionHandler().handle(.request(.contentRendered, payload: .object([:])), host: host) + + #expect(host.rendered.isEmpty) + let response = try #require(host.sent.first) + #expect(response.type == .error) + #expect(response.payload == .object(["error": .string("Invalid payload: missing or non-numeric 'count'")])) + } + + @Test("A non-numeric count is refused") + func nonNumericCountIsRefused() { + let host = ContentHostSpy() + + ContentRenderedActionHandler().handle(.request(.contentRendered, payload: .object(["count": .string("many")])), + host: host) + + #expect(host.rendered.isEmpty) + #expect(host.sent.first?.type == .error) + } + + @Test("A negative count is refused rather than clamped") + func negativeCountIsRefused() { + let host = ContentHostSpy() + + ContentRenderedActionHandler().handle(.request(.contentRendered, payload: .object(["count": .int(-1)])), + host: host) + + #expect(host.rendered.isEmpty) + #expect(host.sent.first?.payload + == .object(["error": .string("Invalid payload: 'count' must not be negative, got -1")])) + } + + /// The action is registered on every surface. One that reserves no space for content simply + /// does not listen — the report is journalled and acknowledged, never refused, so a page can + /// send it wherever it lives. + @Test("A host that listens for no content acknowledges anyway") + func hostWithoutCapabilityAcknowledges() throws { + let host = HostSpy() + + ContentRenderedActionHandler().handle(.request(.contentRendered, payload: .object(["count": .int(4)])), + host: host) + + let response = try #require(host.sent.first) + #expect(response.type == .response) + #expect(response.payload == .object(["success": .bool(true)])) + } +} + +// MARK: - Doubles + +private final class ContentHostSpy: WebBridgeHost, WebBridgeContentHosting { + + var contentId = "test-content-id" + var logCategory: LogCategory = .embeddedBlocks + var tags: [String: String]? + var presentingViewController: UIViewController? { nil } + var isUserPresent = true + + private(set) var sent: [BridgeMessage] = [] + private(set) var rendered: [Int] = [] + + func send(_ message: BridgeMessage) { + sent.append(message) + } + + func makeStartPayload() -> JSONValue { + .string("{}") + } + + func bridgeDidRenderContent(count: Int) { + rendered.append(count) + } +} From b6fe04da71d4ba21975f055040ccb56e6b88a939 Mon Sep 17 00:00:00 2001 From: Vailence Date: Thu, 13 Aug 2026 21:11:06 +0500 Subject: [PATCH 11/16] MOBILE-328: Add the targeting check and show-in-app actions as stubs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are answered but neither is implemented: the web page already calls checkInappsTargeting, and until something owns it the registry reports no owner, the page waits out its three seconds and renders an empty feed. Answering is what makes the rest of the contract reachable. checkInappsTargeting lets every id through, in the order it was asked about — the page maps the answer back onto its own cards. showInApp journals the request and acknowledges it, so the page can finish its own flow instead of sitting on a promise nothing will settle. The shapes are taken from the page rather than from the ticket: it reads payload.inappIds and throws when that key is absent, so a reply of the wrong shape and no reply at all look identical from the feed. The tests pin those shapes for the same reason. What each will actually do is written down where it will be written, as comments: the targeting source and its cold-cache behaviour, the thread the segmentation cache is written from, and for the show — bypassing the gates by not entering the pipeline at all, tracking the show anyway, and merging params last so it overwrites everything including service keys. The block address moves to the staging host, which is where the page that implements this contract is published. --- .../Resolver/EmbeddedBlockResolver.swift | 2 +- .../Views/WebView/Bridge/BridgeMessage.swift | 40 +++++ .../CheckInappsTargetingActionHandler.swift | 51 +++++++ .../Handlers/ShowInAppActionHandler.swift | 61 ++++++++ .../WebBridgeActionHandlerFactory.swift | 4 +- .../TargetingAndShowStubTests.swift | 137 ++++++++++++++++++ 6 files changed, 293 insertions(+), 2 deletions(-) create mode 100644 Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/CheckInappsTargetingActionHandler.swift create mode 100644 Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/ShowInAppActionHandler.swift create mode 100644 MindboxTests/InApp/Tests/WebView/BridgeHandlers/TargetingAndShowStubTests.swift 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/InAppMessages/Presentation/Views/WebView/Bridge/BridgeMessage.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/BridgeMessage.swift index ed518b26..d2a6abf5 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/BridgeMessage.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/BridgeMessage.swift @@ -320,6 +320,41 @@ public struct BridgeMessage: Codable { /// ``` 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). @@ -582,6 +617,11 @@ public struct BridgeMessage: Codable { 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 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/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/WebBridgeActionHandlerFactory.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeActionHandlerFactory.swift index 89460d3e..c65f0958 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeActionHandlerFactory.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeActionHandlerFactory.swift @@ -30,7 +30,9 @@ enum WebBridgeActionHandlerFactory { MotionActionHandler(), OperationActionHandler(), LifecycleActionHandler(), - ContentRenderedActionHandler() + ContentRenderedActionHandler(), + CheckInappsTargetingActionHandler(), + ShowInAppActionHandler() ] } } 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'")])) + } +} From 71811d612c3aa35a17e1255d119d0251b5dd5698 Mon Sep 17 00:00:00 2001 From: Vailence Date: Fri, 14 Aug 2026 14:00:56 +0500 Subject: [PATCH 12/16] MOBILE-328: Hold the page weakly while the system answers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The base captured the view weakly here — handler.request { [weak self] … } — and the move into a handler turned that into a strong capture of the host. A system dialog stands for as long as the user leaves it standing, so a request that actually shows one pinned TransparentView and with it the whole handler set: an uncancelled ready checker, and a live sensor subscription if the page had asked for motion. settings.open had the same asymmetry inside one switch: the .application route reached the system through BridgeURLOpening with [weak host], the .notifications route did not. The hold there is short — the seconds of a screen change — but a rule that holds on one branch of a switch and not the other is not a rule. Both routes now answer only a page that is still around, which is what the base did. The spies learned to hold their answer back: that pause is the only window in which what the SDK keeps alive is observable at all. --- .../Handlers/PermissionActionHandler.swift | 8 ++- .../Handlers/SettingsActionHandler.swift | 6 +- .../BridgeHandlers/BridgeHandlerDoubles.swift | 15 +++++ .../PermissionActionHandlerTests.swift | 65 ++++++++++++++++++- .../SettingsActionHandlerTests.swift | 47 +++++++++++++- 5 files changed, 135 insertions(+), 6 deletions(-) diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/PermissionActionHandler.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/PermissionActionHandler.swift index cbb51f7b..51991f2e 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/PermissionActionHandler.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/PermissionActionHandler.swift @@ -55,8 +55,14 @@ final class PermissionActionHandler: WebBridgeActionHandler { return } - handler.request { result in + // 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) diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/SettingsActionHandler.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/SettingsActionHandler.swift index 65875d72..cec98e34 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/SettingsActionHandler.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/SettingsActionHandler.swift @@ -40,8 +40,12 @@ final class SettingsActionHandler: WebBridgeActionHandler { switch target { case .notifications: // The outcome is not inspected: the page asked to be sent to settings, and it was. - openNotificationSettings { _ in + // 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) } } diff --git a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/BridgeHandlerDoubles.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/BridgeHandlerDoubles.swift index 22ebfcc0..56c3350e 100644 --- a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/BridgeHandlerDoubles.swift +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/BridgeHandlerDoubles.swift @@ -45,6 +45,21 @@ extension BridgeMessage { } } +/// Watches whether the object handed to it has been released. +/// +/// The observation belongs in a box rather than in a `weak` local: a weak local that is only ever +/// read raises "never mutated", and `weak let` does not exist to answer it with. +final class ReleaseWatch { + + private(set) weak var object: Object? + + var isReleased: Bool { object == nil } + + init(_ object: Object) { + self.object = object + } +} + /// 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 diff --git a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/PermissionActionHandlerTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/PermissionActionHandlerTests.swift index 93aa6c80..0045860f 100644 --- a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/PermissionActionHandlerTests.swift +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/PermissionActionHandlerTests.swift @@ -137,6 +137,47 @@ struct PermissionActionHandlerTests { #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" }) + + var host: HostSpy? = HostSpy() + let watch = ReleaseWatch(host!) + + handler.handle(pushRequest(), host: host!) + #expect(permission.requestCount == 1) + + host = nil + + #expect(watch.isReleased) + } + + /// 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 @@ -148,16 +189,36 @@ final class PermissionHandlerSpy: PermissionHandler { 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]) { + 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 - completion(result) + + 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) } } diff --git a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/SettingsActionHandlerTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/SettingsActionHandlerTests.swift index 89f0ee6b..598d2da8 100644 --- a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/SettingsActionHandlerTests.swift +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/SettingsActionHandlerTests.swift @@ -97,6 +97,30 @@ struct SettingsActionHandlerTests { #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) + + var host: HostSpy? = HostSpy() + let watch = ReleaseWatch(host!) + + handler.handle(.request(.settingsOpen, payload: .object(["target": .string("notifications")])), host: host!) + #expect(notifications.callCount == 1) + + host = nil + + #expect(watch.isReleased) + + // The late answer finds nobody and is dropped rather than crashing. + notifications.answer() + } } // MARK: - Doubles @@ -105,14 +129,33 @@ 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) { + init(result: Bool, defersAnswer: Bool = false) { self.result = result + self.defersAnswer = defersAnswer } func open(_ completion: @escaping (Bool) -> Void) { callCount += 1 - completion(result) + + guard defersAnswer else { + completion(result) + return + } + + pendingAnswer = completion + } + + func answer() { + let pendingAnswer = self.pendingAnswer + self.pendingAnswer = nil + pendingAnswer?(result) } } From a1ee493bd409e260177af5e751844a9c8dae7f3b Mon Sep 17 00:00:00 2001 From: Vailence Date: Fri, 14 Aug 2026 14:01:04 +0500 Subject: [PATCH 13/16] MOBILE-328: Present a block's Safari from the topmost controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openLink answers the page from the presentation completion, and UIKit does not call that completion when the presentation does not happen. A controller that is already presenting refuses to present anything else — and the block asked the window's root, which is exactly the controller that is already presenting whenever anything modal stands above it: the block inside a bottom sheet, an alert, a share sheet, another in-app from this same SDK. The tap opened nothing, nothing was journalled, and the promise in JS never settled. The presenter is now the topmost of the presentation chain, the same walk the snackbar strategy makes. The change is in the block's own answer to "what do I present from" rather than in the handler, so the in-app surface stays as it is in develop: for TransparentView the presenter is still its own view controller. Presenting for real in a test would drag a visible window and a transition into it, so the controllers report what the test put on top of them — what is checked is the walk, not UIKit's animation. The answer still leaves from the completion, so a presenter caught mid-dismissal can still swallow it. That is the remaining half of this finding, not of this commit. --- .../WebView/EmbeddedBlockWebViewPage.swift | 13 +++- .../EmbeddedBlockWebViewPageTests.swift | 77 +++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift index c4ac5161..089a8023 100644 --- a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift @@ -115,8 +115,19 @@ extension EmbeddedBlockWebViewPage: WebBridgeHost { /// 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? { - view.window?.rootViewController + guard var presenter = view.window?.rootViewController else { return nil } + + while let presented = presenter.presentedViewController { + presenter = presented + } + + return presenter } func send(_ message: BridgeMessage) { diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewPageTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewPageTests.swift index 953c7734..d0a2e27e 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewPageTests.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewPageTests.swift @@ -7,6 +7,7 @@ // import Testing +import UIKit import WebKit @_spi(Internal) @testable import Mindbox @@ -93,6 +94,62 @@ struct EmbeddedBlockWebViewPageTests { #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) + } +} + +/// 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 @@ -102,11 +159,18 @@ 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: WebBridgeNavigationDelegate { page } @@ -125,6 +189,19 @@ private final class PageBed { } } + /// 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) } From 5b6e11b742c87134a5d712081f7e18636fab8eb1 Mon Sep 17 00:00:00 2001 From: Vailence Date: Fri, 14 Aug 2026 14:32:51 +0500 Subject: [PATCH 14/16] MOBILE-328: Test the operation handler and the block as a bridge host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OperationActionHandler was the largest handler of the move with no file of its own. Its two neighbours keep what they already cover — makeSyncOperationResponse as the pure mapping it is, and the tag merge through the view — so the new suite takes the wiring between them: the queue write and the branch where it fails, the answer to a sync operation in both outcomes, the shapes of payload it refuses, and that a request in flight does not hold the page alive. Its event repository holds its answer back, because that pause is the only window in which what the SDK keeps alive is observable at all. The block speaking the shared bridge was the point of 09bef196 and had no test at all. Now a request from its document reaches the registry with the page as the host, an answer is not routed as if it were a request, an action nobody owns is dropped without disturbing the block, and the identity the page lends a handler is its own. BridgeURLOpening gets the half that has a decision in it: which outcome becomes which answer. SystemURLOpener stays out, as its own documentation says it must. WebBridgeHostResponseTests moves out of the registry's file, where it was a stranger. Three branches of moved code were reachable but unasserted: a url that does not parse, a url with no scheme, and the Safari fallback with no presenter to fall back on — the last being the path the presenter walk was changed for. Motion's first guard is covered too, where nothing arrived at all as against something in the wrong shape. loadRegistersItsNavigation still does not prove what it is named for. contentLoadIssued and contentURL are private to MindboxWebBridge, and a WKScriptMessage cannot be built in a test to drive the gate from outside, so it pins the visible half — the load goes to the block's own address — and says so where it stands. --- .../EmbeddedBlockWebViewPageTests.swift | 141 +++++++- .../BridgeURLOpeningTests.swift | 98 ++++++ .../MotionActionHandlerTests.swift | 22 ++ .../OpenLinkActionHandlerTests.swift | 72 ++++ .../OperationActionHandlerTests.swift | 319 ++++++++++++++++++ .../WebBridgeActionRegistryTests.swift | 43 --- .../BridgeHandlers/WebBridgeHostTests.swift | 69 ++++ 7 files changed, 716 insertions(+), 48 deletions(-) create mode 100644 MindboxTests/InApp/Tests/WebView/BridgeHandlers/BridgeURLOpeningTests.swift create mode 100644 MindboxTests/InApp/Tests/WebView/BridgeHandlers/OperationActionHandlerTests.swift create mode 100644 MindboxTests/InApp/Tests/WebView/BridgeHandlers/WebBridgeHostTests.swift diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewPageTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewPageTests.swift index d0a2e27e..4aaa909a 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewPageTests.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewPageTests.swift @@ -66,13 +66,113 @@ struct EmbeddedBlockWebViewPageTests { /// 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. - @Test("Loading hands the navigation to the bridge") - func loadRegistersItsNavigation() { + /// + /// > 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) + #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 @@ -140,6 +240,24 @@ struct EmbeddedBlockWebViewPageTests { } } +/// 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 @@ -175,12 +293,20 @@ private final class PageBed { /// delegate methods with the same name, so they are better called where the name is unambiguous. private var navigation: WebBridgeNavigationDelegate { page } + private var messages: WebBridgeMessageDelegate { page } + private let bridge: MindboxWebBridge - init() { + /// - 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: .stub, webView: webView) + page = EmbeddedBlockWebViewPage(id: "block-id", + content: content, + webView: webView, + actionRegistry: WebBridgeActionRegistry(handlers: handlers)) page.onLoadFailure = { [weak self] in self?.failures += 1 } @@ -189,6 +315,11 @@ 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?) { diff --git a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/BridgeURLOpeningTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/BridgeURLOpeningTests.swift new file mode 100644 index 00000000..a9dfd2dd --- /dev/null +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/BridgeURLOpeningTests.swift @@ -0,0 +1,98 @@ +// +// BridgeURLOpeningTests.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 + +/// The answer half of the seam: `open(_:answering:host:)`, shared by `openLink` and `settings.open`. +/// +/// `SystemURLOpener` itself stays out — its whole body is the call to `UIApplication.open`, which the +/// protocol's own documentation says cannot be exercised in a test. What is checked here is the part +/// that has a decision in it: which outcome becomes which answer, and whose lifetime it depends on. +@Suite("BridgeURLOpening.open(answering:)", .tags(.webView)) +@MainActor +struct BridgeURLOpeningTests { + + private let url = URL(string: "myapp://product/1")! + + @Test("A successful open is answered as a success") + func successIsAnswered() async throws { + let opener = URLOpenerSpy() + opener.result = true + let host = HostSpy() + let message = BridgeMessage.request(.openLink) + + opener.open(url, answering: message, 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)])) + #expect(response.id == message.id) + #expect(response.action == message.action) + } + + @Test("A refused open is answered as an error naming the URL") + func failureIsAnswered() async throws { + let opener = URLOpenerSpy() + opener.result = false + let host = HostSpy() + let message = BridgeMessage.request(.openLink) + + opener.open(url, answering: message, 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://product/1'")])) + #expect(response.id == message.id) + } + + /// This route never asks for universal links: the caller that wants them asks for them itself, + /// and reaching the system through here means the decision has already been made. + @Test("The system is asked without the universal-link restriction") + func doesNotRestrictToUniversalLinks() async { + let opener = URLOpenerSpy() + let host = HostSpy() + + opener.open(url, answering: .request(.openLink), host: host) + await drainMainQueue(until: { !opener.opened.isEmpty }) + + #expect(opener.opened.map(\.universalLinksOnly) == [false]) + } + + @Test("The URL reaches the system unchanged") + func urlReachesTheSystemUnchanged() async { + let opener = URLOpenerSpy() + let host = HostSpy() + + opener.open(url, answering: .request(.settingsOpen), host: host) + await drainMainQueue(until: { !opener.opened.isEmpty }) + + #expect(opener.opened.first?.url == url) + } + + /// The system takes its own time to answer, and by then the show may be over. 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 watch: ReleaseWatch + + do { + let host = HostSpy() + watch = ReleaseWatch(host) + opener.open(url, answering: .request(.openLink), host: host) + } + + await drainMainQueue(until: { false }, turns: 3) + + #expect(watch.isReleased) + } +} diff --git a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/MotionActionHandlerTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/MotionActionHandlerTests.swift index b6a3969c..58a058ef 100644 --- a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/MotionActionHandlerTests.swift +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/MotionActionHandlerTests.swift @@ -89,6 +89,28 @@ struct MotionActionHandlerTests { #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() diff --git a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/OpenLinkActionHandlerTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/OpenLinkActionHandlerTests.swift index c18182e7..ce5d0f78 100644 --- a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/OpenLinkActionHandlerTests.swift +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/OpenLinkActionHandlerTests.swift @@ -114,6 +114,78 @@ struct OpenLinkActionHandlerTests { #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) + let watch: ReleaseWatch + + do { + let host = HostSpy() + watch = ReleaseWatch(host) + handler.handle(.request(.openLink, payload: .object(["url": .string("https://example.com")])), host: host) + } + + await drainMainQueue(until: { false }, turns: 3) + + #expect(watch.isReleased) + } + @Test("A payload sent as a JSON string is understood too") func acceptsStringifiedPayload() async { let opener = URLOpenerSpy() diff --git a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/OperationActionHandlerTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/OperationActionHandlerTests.swift new file mode 100644 index 00000000..e7a3ce2d --- /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() + let watch: ReleaseWatch + + do { + let host = HostSpy() + watch = ReleaseWatch(host) + sut.handler.handle(request(.syncOperation), host: host) + } + + #expect(sut.events.pending != nil, "the request is still waiting for its answer") + #expect(watch.isReleased, "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() + let watch: ReleaseWatch + + do { + let host = HostSpy() + watch = ReleaseWatch(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(watch.isReleased) + } + + // 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/WebBridgeActionRegistryTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/WebBridgeActionRegistryTests.swift index 4a2baf0b..5056ddad 100644 --- a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/WebBridgeActionRegistryTests.swift +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/WebBridgeActionRegistryTests.swift @@ -104,49 +104,6 @@ struct WebBridgeActionRegistryTests { } } -@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) - } -} - // MARK: - Doubles private final class HandlerSpy: WebBridgeActionHandler { 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) + } +} From 1763b173c103cc773ad710c995d8dbc02e28cbdd Mon Sep 17 00:00:00 2001 From: Vailence Date: Fri, 14 Aug 2026 14:37:00 +0500 Subject: [PATCH 15/16] MOBILE-328: Drop the release watch for a plain weak local A weak local declared empty and assigned later carries no "never mutated" warning, so watching a page's release needs no box to hold the reference. --- .../BridgeHandlers/BridgeHandlerDoubles.swift | 15 --------------- .../BridgeHandlers/BridgeURLOpeningTests.swift | 6 +++--- .../OpenLinkActionHandlerTests.swift | 6 +++--- .../OperationActionHandlerTests.swift | 12 ++++++------ .../PermissionActionHandlerTests.swift | 15 ++++++++------- .../SettingsActionHandlerTests.swift | 15 ++++++++------- 6 files changed, 28 insertions(+), 41 deletions(-) diff --git a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/BridgeHandlerDoubles.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/BridgeHandlerDoubles.swift index 56c3350e..22ebfcc0 100644 --- a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/BridgeHandlerDoubles.swift +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/BridgeHandlerDoubles.swift @@ -45,21 +45,6 @@ extension BridgeMessage { } } -/// Watches whether the object handed to it has been released. -/// -/// The observation belongs in a box rather than in a `weak` local: a weak local that is only ever -/// read raises "never mutated", and `weak let` does not exist to answer it with. -final class ReleaseWatch { - - private(set) weak var object: Object? - - var isReleased: Bool { object == nil } - - init(_ object: Object) { - self.object = object - } -} - /// 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 diff --git a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/BridgeURLOpeningTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/BridgeURLOpeningTests.swift index a9dfd2dd..c2cf9390 100644 --- a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/BridgeURLOpeningTests.swift +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/BridgeURLOpeningTests.swift @@ -83,16 +83,16 @@ struct BridgeURLOpeningTests { @Test("A page released while the system is deciding is not held by the request") func pendingOpenDoesNotHoldThePage() async { let opener = URLOpenerSpy() - let watch: ReleaseWatch + weak var page: HostSpy? do { let host = HostSpy() - watch = ReleaseWatch(host) + page = host opener.open(url, answering: .request(.openLink), host: host) } await drainMainQueue(until: { false }, turns: 3) - #expect(watch.isReleased) + #expect(page == nil) } } diff --git a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/OpenLinkActionHandlerTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/OpenLinkActionHandlerTests.swift index ce5d0f78..6a961ba7 100644 --- a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/OpenLinkActionHandlerTests.swift +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/OpenLinkActionHandlerTests.swift @@ -173,17 +173,17 @@ struct OpenLinkActionHandlerTests { func pendingOpenDoesNotHoldThePage() async { let opener = URLOpenerSpy() let handler = OpenLinkActionHandler(urlOpener: opener) - let watch: ReleaseWatch + weak var page: HostSpy? do { let host = HostSpy() - watch = ReleaseWatch(host) + page = host handler.handle(.request(.openLink, payload: .object(["url": .string("https://example.com")])), host: host) } await drainMainQueue(until: { false }, turns: 3) - #expect(watch.isReleased) + #expect(page == nil) } @Test("A payload sent as a JSON string is understood too") diff --git a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/OperationActionHandlerTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/OperationActionHandlerTests.swift index e7a3ce2d..821e62fa 100644 --- a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/OperationActionHandlerTests.swift +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/OperationActionHandlerTests.swift @@ -138,26 +138,26 @@ struct OperationActionHandlerTests { @Test("A request in flight does not hold the page alive") func pendingSyncOperationDoesNotHoldThePage() { let sut = makeSUT() - let watch: ReleaseWatch + weak var page: HostSpy? do { let host = HostSpy() - watch = ReleaseWatch(host) + page = host sut.handler.handle(request(.syncOperation), host: host) } #expect(sut.events.pending != nil, "the request is still waiting for its answer") - #expect(watch.isReleased, "the page must not be held by a request that has not answered yet") + #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() - let watch: ReleaseWatch + weak var page: HostSpy? do { let host = HostSpy() - watch = ReleaseWatch(host) + page = host sut.handler.handle(request(.syncOperation), host: host) } @@ -166,7 +166,7 @@ struct OperationActionHandlerTests { // has nowhere to go, and going there anyway is what this guards against. await drainMainQueue(until: { false }, turns: 3) - #expect(watch.isReleased) + #expect(page == nil) } // MARK: - Refusals diff --git a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/PermissionActionHandlerTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/PermissionActionHandlerTests.swift index 0045860f..3a6eff1b 100644 --- a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/PermissionActionHandlerTests.swift +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/PermissionActionHandlerTests.swift @@ -151,15 +151,16 @@ struct PermissionActionHandlerTests { let registry = PermissionRegistrySpy(handler: permission) let handler = PermissionActionHandler(makeRegistry: { registry }, infoPlistValue: { _ in "value" }) - var host: HostSpy? = HostSpy() - let watch = ReleaseWatch(host!) - - handler.handle(pushRequest(), host: host!) - #expect(permission.requestCount == 1) + weak var page: HostSpy? - host = nil + do { + let host = HostSpy() + page = host + handler.handle(pushRequest(), host: host) + #expect(permission.requestCount == 1) + } - #expect(watch.isReleased) + #expect(page == nil) } /// A show that ended gets no answer — and the answer arriving late is not a crash either. diff --git a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/SettingsActionHandlerTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/SettingsActionHandlerTests.swift index 598d2da8..1c86bdbb 100644 --- a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/SettingsActionHandlerTests.swift +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/SettingsActionHandlerTests.swift @@ -108,15 +108,16 @@ struct SettingsActionHandlerTests { let handler = SettingsActionHandler(urlOpener: URLOpenerSpy(), openNotificationSettings: notifications.open) - var host: HostSpy? = HostSpy() - let watch = ReleaseWatch(host!) + weak var page: HostSpy? - handler.handle(.request(.settingsOpen, payload: .object(["target": .string("notifications")])), host: host!) - #expect(notifications.callCount == 1) - - host = nil + do { + let host = HostSpy() + page = host + handler.handle(.request(.settingsOpen, payload: .object(["target": .string("notifications")])), host: host) + #expect(notifications.callCount == 1) + } - #expect(watch.isReleased) + #expect(page == nil) // The late answer finds nobody and is dropped rather than crashing. notifications.answer() From fa53e71e6447ac59973ad7bbd2cfb8ed6e7b8203 Mon Sep 17 00:00:00 2001 From: Vailence Date: Fri, 14 Aug 2026 18:14:29 +0500 Subject: [PATCH 16/16] MOBILE-328: Rename the rendered count out of the linter's way MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two `swiftlint:disable empty_count` directives read as superfluous to SwiftLint 0.65, while older versions do flag those lines — dropping the directives alone would only trade one error for the other. Naming the value `renderedCount` leaves neither version anything to say. --- .../WebView/EmbeddedBlockWebViewProvider.swift | 9 ++++----- .../Handlers/ContentRenderedActionHandler.swift | 15 +++++++-------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift index 88d39d9a..19d11366 100644 --- a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift @@ -107,11 +107,11 @@ final class EmbeddedBlockWebViewProvider { /// 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: Int) { + func handleContentRendered(count renderedCount: Int) { guard isStarted else { return } guard !didReportContent else { - Logger.common(message: "[EmbeddedBlock] Block '\(id)': ignored a repeated contentRendered(\(count))", + Logger.common(message: "[EmbeddedBlock] Block '\(id)': ignored a repeated contentRendered(\(renderedCount))", category: .embeddedBlocks) return } @@ -119,8 +119,7 @@ final class EmbeddedBlockWebViewProvider { didReportContent = true // The number the page reported, not the size of a collection. - // swiftlint:disable:next empty_count - guard count > 0 else { + 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", @@ -129,7 +128,7 @@ final class EmbeddedBlockWebViewProvider { return } - Logger.common(message: "[EmbeddedBlock] Block '\(id)': page rendered \(count)", + Logger.common(message: "[EmbeddedBlock] Block '\(id)': page rendered \(renderedCount)", category: .embeddedBlocks) finish(with: .ready) } diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/ContentRenderedActionHandler.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/ContentRenderedActionHandler.swift index 3137f52c..374261b6 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/ContentRenderedActionHandler.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/ContentRenderedActionHandler.swift @@ -19,27 +19,26 @@ final class ContentRenderedActionHandler: WebBridgeActionHandler { let actions: Set = [.contentRendered] func handle(_ message: BridgeMessage, host: WebBridgeHost) { - guard let count = Self.count(in: message) else { + 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 — isEmpty has no meaning - // here, and a negative number is a page bug worth naming rather than clamping. - // swiftlint:disable:next empty_count - guard count >= 0 else { - host.respondError("Invalid payload: 'count' must not be negative, got \(count)", to: message) + // 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(\(count)) from '\(host.contentId)' has nobody listening here, ignoring", + 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: count) + content.bridgeDidRenderContent(count: renderedCount) host.respondSuccess(to: message) }