Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
c273bd4
MOBILE-323: Add the embeddedBlocks log category
Aug 10, 2026
2629d8b
MOBILE-323: Add the embedded block web contract
Aug 10, 2026
1db4715
MOBILE-323: Add the embedded block content resolver
Aug 10, 2026
057b276
MOBILE-323: Add the embedded block page in WKWebView
Aug 10, 2026
6c35062
MOBILE-323: Add the embedded block page action router
Aug 10, 2026
348eeb0
MOBILE-323: Add the debug content override SPI
Aug 10, 2026
415603a
MOBILE-323: Add test infrastructure for embedded blocks
Aug 10, 2026
d29eac9
MOBILE-323: Add tests for the embedded block content resolver
Aug 10, 2026
ef79d28
MOBILE-323: Add tests for the embedded block page
Aug 10, 2026
51956ac
MOBILE-323: Add tests for the page action router
Aug 10, 2026
9758078
MOBILE-323: Add the embedded block content provider
Aug 10, 2026
36c9368
MOBILE-323: Add the content provider factory
Aug 10, 2026
3b3e726
MOBILE-323: Add test doubles for the block content provider
Aug 10, 2026
0093141
MOBILE-323: Add tests for the embedded block content provider
Aug 10, 2026
9b6037b
MOBILE-323: Add tests for the content provider factory
Aug 10, 2026
e79ebf2
MOBILE-323: Trim duplicated comments in the content provider
Aug 10, 2026
42b04e2
Potential fix for pull request finding
Vailence Aug 10, 2026
0820c45
MOBILE-323: Add the block layer host
Aug 10, 2026
e15ed40
MOBILE-323: Add the block ready timeout
Aug 10, 2026
6e775e8
MOBILE-323: Add the default loading shimmer
Aug 10, 2026
be5b4fe
MOBILE-323: Add the UIKit embedded block container
Aug 10, 2026
db6a73d
MOBILE-323: Register embedded blocks in DI
Aug 10, 2026
daea6fb
MOBILE-323: Add tests for the block layer host
Aug 10, 2026
52aa966
MOBILE-323: Add tests for the block ready timeout
Aug 10, 2026
e110415
MOBILE-323: Add tests for the UIKit embedded block container
Aug 10, 2026
de08d91
Potential fix for pull request finding
Vailence Aug 11, 2026
250447c
MOBILE-323 Less comments
Aug 11, 2026
1b750bb
MOBILE-323: Translate embedded block comments to English
Aug 11, 2026
2900d77
MOBILE-323: Fix the broken applyColors signature in the shimmer
Aug 11, 2026
e29768e
MOBILE-323: Split the nothing-to-show branch out of the layer host
Aug 11, 2026
da5a0a8
MOBILE-323: Make the ready timeout testable and report a zero height
Aug 11, 2026
03633dd
Update Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift
Vailence Aug 11, 2026
e7590ab
MOBILE-323 PR Fix
Aug 11, 2026
ac03b30
MOBILE-323: Embedded block content provider (#754)
Vailence Aug 12, 2026
e03edcf
Potential fix for pull request finding
Vailence Aug 10, 2026
eba5501
MOBILE-323 Less comments
Aug 11, 2026
7c974da
MOBILE-323: Translate embedded block comments to English
Aug 11, 2026
19a089d
Update Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift
Vailence Aug 11, 2026
e1bd012
MOBILE-323 PR Fix
Aug 11, 2026
f50758a
MOBILE-323: Embedded block content provider (#754)
Vailence Aug 12, 2026
2355147
MOBILE-323 Unused tests removed
Aug 12, 2026
6cbdaf9
Merge remote-tracking branch 'origin/feature/MOBILE-323-block-foundat…
Aug 12, 2026
0a4b51a
MOBILE-323: Add the SwiftUI embedded block (#756)
Vailence Aug 12, 2026
4410e96
MOBILE-323 Remove Identity
Aug 12, 2026
afa5cc0
MOBILE-323 isDetached
Aug 12, 2026
89bbe9a
MOBILE-323: Translate embedded block comments and restore test coverage
Aug 12, 2026
751451b
Update Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbe…
Vailence Aug 12, 2026
621fd8e
Update Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbe…
Vailence Aug 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 89 additions & 75 deletions Mindbox.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions Mindbox/DI/Injections/InjectEmbeddedBlocks.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//
// InjectEmbeddedBlocks.swift
// Mindbox
//
// Created by vailence on 03.08.2026.
// Copyright © 2026 Mindbox. All rights reserved.
//

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.
register(EmbeddedBlockResolving.self) {
EmbeddedBlockResolver()
}

register(EmbeddedBlockActionHandling.self) {
EmbeddedBlockActionRouter()
}

register(EmbeddedBlockContentProviderMaking.self) {
EmbeddedBlockContentProviderFactory(resolver: DI.injectOrFail(EmbeddedBlockResolving.self),
actionHandler: DI.injectOrFail(EmbeddedBlockActionHandling.self))
}

return self
}
}
1 change: 1 addition & 0 deletions Mindbox/DI/MBInject.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ enum MBInject {
.registerReplaceableUtilities()
.registerInappTools()
.registerInappPresentation()
.registerEmbeddedBlocks()
}

public static var buildTestContainer: () -> MBContainer = {
Expand Down
35 changes: 35 additions & 0 deletions Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//
// 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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// EmbeddedBlockContentProviderFactory.swift
// Mindbox
//
// Created by vailence on 06.08.2026.
// Copyright © 2026 Mindbox. All rights reserved.
//

import Foundation

/// Builds a content provider for a specific block.
///
/// A provider belongs to one container, so it is created anew for every block — this is what
/// makes blocks with the same id independent.
protocol EmbeddedBlockContentProviderMaking {
func makeProvider(id: String) -> EmbeddedBlockWebViewProvider
}

final class EmbeddedBlockContentProviderFactory: EmbeddedBlockContentProviderMaking {

private let resolver: EmbeddedBlockResolving
private let actionHandler: EmbeddedBlockActionHandling

init(resolver: EmbeddedBlockResolving,
actionHandler: EmbeddedBlockActionHandling) {
self.resolver = resolver
self.actionHandler = actionHandler
}

func makeProvider(id: String) -> EmbeddedBlockWebViewProvider {
EmbeddedBlockWebViewProvider(id: id,
resolver: resolver,
actionHandler: actionHandler,
makePage: { EmbeddedBlockWebViewPage(content: $0) })
}
}
53 changes: 53 additions & 0 deletions Mindbox/EmbeddedBlocks/Container/EmbeddedBlockLayerHost.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
//
// EmbeddedBlockLayerHost.swift
// Mindbox
//
// Created by vailence on 10.08.2026.
// Copyright © 2026 Mindbox. All rights reserved.
//

import UIKit

/// Holds exactly one view in the container, stretched to its edges.
///
/// The block's layers — placeholder, content, error screen — are mutually exclusive: showing a new
/// one means removing the previous one. The shown view is remembered separately from the
/// container's properties, because the host can swap it at any moment, and the view to remove is
/// the one that is actually attached, not the one sitting in a property right now.
final class EmbeddedBlockLayerHost {

/// The owner holds the host, so the back reference must not count — or the container never dies.
private unowned let container: UIView

private var attachedView: UIView?

init(container: UIView) {
self.container = container
}

/// Shows the view in place of the one attached now. `nil` — show nothing.
func show(_ view: UIView?) {
// "Show nothing" is simply removing the current view: there is nothing to compare here, and
// the shared condition below, made to cover nil, would read as something it is not.
guard let view else {
attachedView?.removeFromSuperview()
attachedView = nil
return
}

// The same view, and it really is attached to us — no reason to rebuild its constraints.
guard attachedView !== view || view.superview !== container else { return }

attachedView?.removeFromSuperview()
attachedView = view

view.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(view)
NSLayoutConstraint.activate([
view.topAnchor.constraint(equalTo: container.topAnchor),
view.leadingAnchor.constraint(equalTo: container.leadingAnchor),
view.trailingAnchor.constraint(equalTo: container.trailingAnchor),
view.bottomAnchor.constraint(equalTo: container.bottomAnchor)
])
}
}
162 changes: 162 additions & 0 deletions Mindbox/EmbeddedBlocks/Container/EmbeddedBlockReadyTimeout.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
//
// EmbeddedBlockReadyTimeout.swift
// Mindbox
//
// Created by vailence on 10.08.2026.
// Copyright © 2026 Mindbox. All rights reserved.
//

import UIKit
import QuartzCore
import MindboxLogger

/// How long a block is given to show up — and the accounting of that time.
///
/// The budget belongs to the container, not to the content: whatever the block turns out to be,
/// the host layout does not wait for it forever. A block that misses the budget is collapsed by
/// the container.
///
/// What is counted is the user's waiting time, not calendar time: while nobody is waiting for the
/// block — the app is in the background, the container is out of the window — the count stands
/// still. Otherwise the user would come back to a block that gave up while nobody was looking.
///
/// Stands still, but does not start over: what was spent is remembered, and the attempt continues
/// its budget from where it was interrupted. A pause that handed the full budget back would never
/// end — a user switching between apps every five seconds would extend the wait indefinitely, and
/// the host layout would wait forever. Exactly what the budget exists to prevent. Only a new
/// attempt — `reset()` — gets the full budget again.
///
/// Loading is not affected by the pause: it runs its own course; in the background the system
/// throttles it, not the SDK.

/// Runs the work when the given remainder of the budget expires.
typealias EmbeddedBlockTimeoutScheduling = (TimeInterval, DispatchWorkItem) -> Void

final class EmbeddedBlockReadyTimeout {

/// Whether the count is needed right now: the outcome is still unknown and the block is
/// visible. Asked again on every arm, because both may have changed while paused.
var isNeeded: () -> Bool = { false }

/// Time is up. Called on the main thread.
var onExpire: () -> Void = {}

var isRunning: Bool { workItem != nil }

private let blockId: String
private let duration: TimeInterval

/// The clock is its own seam: spent time cannot be counted without it, and tests cannot wait
/// out the budget for real — they need a way to say that time has passed. The clock is
/// monotonic, not `Date`: an NTP correction or a manual clock change would make the spent
/// delta negative and stretch the wait past the budget.
private let now: () -> TimeInterval

/// Notifications are their own seam for the same reason as the clock: going to the background
/// cannot be tested on the global center — a test notification would reach the blocks of tests
/// running next to it.
private let notificationCenter: NotificationCenter

/// The scheduler is the same kind of seam for the same reason: a hard-wired queue would force
/// budget tests to wait it out in real time — sleeping on every check and flaking on a busy
/// machine.
private let schedule: EmbeddedBlockTimeoutScheduling

private var workItem: DispatchWorkItem?

/// How much of the budget past waiting stretches have consumed.
private var consumed: TimeInterval = 0

/// When the current stretch started. `nil` — the count is not running.
private var resumedAt: TimeInterval?

private var remaining: TimeInterval { max(0, duration - consumed) }

init(blockId: String,
duration: TimeInterval,
now: @escaping () -> TimeInterval = { CACurrentMediaTime() },
notificationCenter: NotificationCenter = .default,
schedule: @escaping EmbeddedBlockTimeoutScheduling = { delay, work in
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
}) {
self.blockId = blockId
self.duration = duration
self.now = now
self.notificationCenter = notificationCenter
self.schedule = schedule

notificationCenter.addObserver(self,
selector: #selector(applicationDidEnterBackground),
name: UIApplication.didEnterBackgroundNotification,
object: nil)
notificationCenter.addObserver(self,
selector: #selector(applicationWillEnterForeground),
name: UIApplication.willEnterForegroundNotification,
object: nil)
}

deinit {
notificationCenter.removeObserver(self)
workItem?.cancel()
}

/// Arms the count for the remainder of the budget, if it is needed and not already running.
/// Call as often as convenient: extra calls do nothing, so entering the window, returning from
/// the background, and reloading all share the same call.
func armIfNeeded() {
guard workItem == nil, isNeeded() else { return }

let work = DispatchWorkItem { [weak self] in
guard let self else { return }

self.workItem = nil
self.resumedAt = nil
// The budget is spent in full: if the block is somehow armed again, there is nothing
// left to wait for.
self.consumed = self.duration

Logger.common(message: "[EmbeddedBlock] Block '\(self.blockId)' timed out after \(self.duration)s of waiting",
category: .embeddedBlocks)
self.onExpire()
}

// The work is stored in the property before it is scheduled: the scheduler is free to run
// it right away, and it must find the budget in a consistent state.
resumedAt = now()
workItem = work
schedule(remaining, work)
}

/// Stops the count, remembering what was spent. The attempt is not cancelled: `armIfNeeded()`
/// continues it from the remainder.
func pause() {
guard let resumedAt else { return }

consumed += max(0, now() - resumedAt)
self.resumedAt = nil
workItem?.cancel()
workItem = nil
}

/// Stops the count and restores the full budget: the previous attempt is over — with an
/// outcome, or because the next one started — and its remainder has nothing to do with the
/// new one.
func reset() {
pause()
consumed = 0
}

@objc
private func applicationDidEnterBackground() {
guard isRunning else { return }

Logger.common(message: "[EmbeddedBlock] Block '\(blockId)' went to the background while loading, pausing the timeout",
category: .embeddedBlocks)
pause()
}

@objc
private func applicationWillEnterForeground() {
armIfNeeded()
}
}
Loading