Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
390d459
docs(meetings): impl-notes log for #1024 banner-accessory session
wei18 Sep 7, 2026
b527c3d
feat(shell): add generic tabViewBottomAccessory param to RootShellView
wei18 Sep 7, 2026
637b18e
feat(monetization): host the banner in tabViewBottomAccessory (#1024)
wei18 Sep 8, 2026
6dfaed1
wip(sudoku): retire practice-hub inline banner closure (#1024 phase 3…
wei18 Sep 8, 2026
debad99
feat(monetization): retire in-shell inline banner call sites (#1024)
wei18 Sep 8, 2026
c702895
test(monetization): pin ATT-primer-before-ad-load order on the sim (#…
wei18 Sep 8, 2026
fb10202
docs(monetization): mark design.md §2.4/§2.4.1 as-built, re-anchor AT…
wei18 Sep 8, 2026
18a69b9
fix(monetization): letterbox the accessory banner instead of black sl…
wei18 Sep 8, 2026
4fe8be2
docs(comments): update two stale TodayTabHost banner-slot comments (#…
wei18 Sep 8, 2026
52891a1
test(shell): render-prove RootShellView's bottomAccessory wiring (#1024)
wei18 Sep 8, 2026
7b423da
feat(uitest): add -uitest-open-ad-gate DEBUG-only launch arg (#1024)
wei18 Sep 8, 2026
68f840a
merge(main): rebase the #1024 banner accessory onto the #1062 session…
wei18 Sep 15, 2026
8ac8aec
feat(monetization): raise the iOS floor to 26.1 and drive the banner …
wei18 Sep 15, 2026
6d549fe
test(monetization): pin the shared banner accessory and re-point stal…
wei18 Sep 15, 2026
f929c73
fix(monetization): own the banner accessory's slot lease in GameRoot …
wei18 Sep 15, 2026
a7ae575
test(monetization): pin the accessory's no-fallback guarantee, not ju…
wei18 Sep 15, 2026
c1d72fa
test(snapshots): restore the #1024 view-structure baselines for the M…
wei18 Sep 15, 2026
0a6739d
merge(main): bring in #1092 and #1093 before opening the #1080 PR
wei18 Sep 15, 2026
6d3b740
refactor(monetization): delete the unused banner fallback and apply P…
wei18 Sep 15, 2026
9799f96
refactor(ui): drop the orphaned banner slot from the Practice and Set…
wei18 Sep 15, 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
4 changes: 2 additions & 2 deletions Packages/AppMonetizationKit/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ let testTargets: [Target] = [
let package = Package(
name: "AppMonetizationKit",
platforms: [
.iOS(.v26),
.iOS("26.1"),
.macOS(.v26),
],
products: [
Expand All @@ -116,7 +116,7 @@ let package = Package(
// Pinned to 13.x — first major exposing Swift-native API names
// (`MobileAds.shared`, `ConsentForm`, `ConsentInformation`). v13.0
// raised the minimum deployment target and removed a swathe of
// deprecated ObjC-prefixed surface; our `.iOS(.v26)` floor exceeds the
// deprecated ObjC-prefixed surface; our `.iOS("26.1")` floor exceeds the
// v13 min iOS 13 requirement. Bridge seam (`AdMobBridge`) shields the
// rest of the package from future API churn.
.package(
Expand Down
18 changes: 18 additions & 0 deletions Packages/AppMonetizationKit/Sources/AdsAdMob/LiveAdMobBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ internal final class LiveAdMobBridge: AdMobBridge {
let view = BannerView(adSize: AdSizeBanner)
view.adUnitID = bannerAdUnitID
view.rootViewController = rootVC
// #1024 (B-6 bonus finding, #1029): the SDK's own opaque fill
// shows through as black side slivers when the accessory
// stretches this view past the fixed 320×50 creative's own
// bounds (`.expanded` capsule). Clear lets the SwiftUI-side
// themed background (`BannerAccessoryView`) letterbox it instead.
view.backgroundColor = .clear
return view
}

Expand Down Expand Up @@ -283,6 +289,18 @@ internal final class BannerLoadDelegate: NSObject, BannerViewDelegate, @unchecke
internal struct BannerViewRepresentable: UIViewRepresentable {
let bannerView: BannerView

// #1080: a `tabViewBottomAccessory` placement that hands this same
// `bannerView` a `BannerSlotLease` owned by a longer-lived host
// (`GameAppKit.GameRoot`) can have this representable re-hosted by UIKit
// — the SwiftUI content is torn down and rebuilt, but the retained
// `BannerView` (and its lease) survives. Measured invariant (probe,
// #1080): on each re-host the NEW host's `makeUIView`/`updateUIView`
// adopts the view (via `addSubview`, done by SwiftUI internally) BEFORE
// the OLD host's `dismantleUIView` runs. This type deliberately has no
// `dismantleUIView` today. If one is ever added, it must NEVER call
// `removeFromSuperview()` or otherwise reset `uiView` — that would strip
// the view back out of the new host that already adopted it, not just
// clean up the old one.
func makeUIView(context: Context) -> BannerView {
bannerView
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// BannerSlotBandLayout — the Layout that places `BannerSlotView`'s visible
// band background and its creative + ✕ pair (#1084).
//
// Extracted out of BannerSlotView.swift (#1080): adding the externally-owned-
// lease init there pushed that file to 402 lines, over the repo's
// `file_length` ceiling (400, SwiftLint `--strict`) — same move CLAUDE.md
// documents for `Live.swift`-style files ("extract Live+Feature.swift
// instead of growing it"). Pure move, no behavior change: `BannerSlotBandRole`,
// `BannerSlotBandRoleKey` and `BannerSlotBandLayout` go from `private` (file-
// scoped) to `internal` (module-scoped) ONLY because `BannerSlotView.swift`'s
// `banner(session:id:)` constructs `BannerSlotBandLayout` and tags subviews
// with `BannerSlotBandRoleKey` — neither becomes `public`.

import SwiftUI

/// Which of `BannerSlotBandLayout`'s two subviews a child is — tagged via
/// `.layoutValue(key:value:)` since `placeSubviews` must propose the band a
/// different width than the content (#1084 review fix 1 and 2: the band must
/// stop at the padded-in width, not bleed into the horizontal inset or fill
/// the Layout's full un-padded width).
enum BannerSlotBandRole: Sendable {
/// The visible band background — proposed exactly the padded-in width
/// (`bounds.width − 2×padding`), so its `.slot` anchor reads that rect.
case band
/// The creative + ✕ pair — proposed `.unspecified` so each keeps its own
/// declared, fixed size.
case content
}

struct BannerSlotBandRoleKey: LayoutValueKey {
static let defaultValue: BannerSlotBandRole = .content
}

/// Arranges the visible band background and the creative + ✕ pair at a
/// horizontal inset that is `nominalPadding` while `width ≥ needed +
/// nominalPadding` (380pt at the shipped constants: 320 + 44 + 16), shrinking
/// symmetrically (never below 0) below that. The constraint PM ruled on is
/// "the ✕ stays fully on-screen", not "the ✕ stays inside the band" — see
/// `padding(for:)`. A custom `Layout`, not `onGeometryChange` + `@State`: the
/// state-loop approach draws one frame at the wrong padding before
/// correcting on the next layout pass (PM ruling, #1084).
///
/// `sizeThatFits` always returns a concrete, finite size — including for an
/// unconstrained ("ideal") proposal — which is also what keeps
/// `NSHostingView.fittingSize` (the pre-existing `BannerSlotViewTests`
/// `measuredHeight` / `pauseKeepsLease` measurement path) from degenerating
/// to a zero height the way a bare `.frame(maxWidth: .infinity)` chain does.
struct BannerSlotBandLayout: Layout {
let nominalPadding: CGFloat
let creativeWidth: CGFloat
let dismissTargetSize: CGFloat
let bannerHeight: CGFloat

/// The creative and the ✕ side by side, with no gap between them.
private var needed: CGFloat { creativeWidth + dismissTargetSize }

/// `nominalPadding` while `width ≥ needed + nominalPadding` (PM's final
/// ruling, #1084: the constraint is "the ✕ stays fully on-screen", not
/// "the ✕ stays inside the band" — a ONE-SIDED comfort check, not
/// `needed + 2×nominalPadding`. At 393pt (the most common iPhone width)
/// this keeps the full 16pt left padding instead of shrinking to 14.5pt,
/// which would visibly mismatch 402pt's 16pt for no reason; the ✕ then
/// sits a few points past the band's own trailing edge but still well
/// inside the screen. Below `needed + nominalPadding`, the padding
/// shrinks symmetrically, clamped at 0.
private func padding(for width: CGFloat) -> CGFloat {
let comfortable = needed + nominalPadding
guard width < comfortable else { return nominalPadding }
return max(0, (width - needed) / 2)
}

/// The comfortable width at the full nominal padding on both sides —
/// `sizeThatFits`'s ideal, and the fallback `resolvedBannerBandWidth`
/// substitutes for a `nil` or non-finite proposal.
private var idealWidth: CGFloat { needed + 2 * nominalPadding }

func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
CGSize(width: resolvedBannerBandWidth(proposal: proposal.width, ideal: idealWidth), height: bannerHeight)
}

func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let padding = padding(for: bounds.width)
let origin = CGPoint(x: bounds.minX + padding, y: bounds.minY)
for subview in subviews {
switch subview[BannerSlotBandRoleKey.self] {
case .band:
// Stops at the padded-in width — never the Layout's full,
// un-padded width (#1084 review fix 1 and 2).
let bandWidth = max(0, bounds.width - 2 * padding)
subview.place(
at: origin,
anchor: .topLeading,
proposal: ProposedViewSize(width: bandWidth, height: bannerHeight)
)
case .content:
subview.place(at: origin, anchor: .topLeading, proposal: .unspecified)
}
}
}
}

/// Resolves a proposed width to a concrete, finite value: `proposal` itself
/// when it's present and finite, `ideal` otherwise — covering both a `nil`
/// proposal (an unconstrained/"ideal" query) and a non-finite one (e.g.
/// `.infinity`, which a horizontal `ScrollView` can propose to its content
/// along the scroll axis). Without this, `BannerSlotBandLayout.sizeThatFits`
/// would propagate `.infinity`/`NaN`, contradicting its own "always a
/// concrete, finite size" doc comment (#1084 review fix 3).
///
/// `internal`, not `private`, purely as a test seam: `BannerSlotBandLayout`
/// itself stays `internal` (not `public`, #1080's file split), but this pure
/// function is unit-tested directly with a literal `.infinity` argument,
/// since no host inside `BannerSlotDismissPlacementTests`'s headless macOS
/// harness actually produces a literal `.infinity` proposal (confirmed
/// empirically — a horizontal `ScrollView` and `.fixedSize(horizontal:)`
/// both resolve to a concrete or `nil` proposal there instead).
internal func resolvedBannerBandWidth(proposal: CGFloat?, ideal: CGFloat) -> CGFloat {
guard let proposal, proposal.isFinite else { return ideal }
return proposal
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,26 +44,41 @@ extension BannerSessionModel {
// loudly if that ever stops being true.
struct BannerSlotRegistration: DynamicProperty {
@Environment(\.bannerSession) private var environmentSession
@StateObject private var lease: BannerSlotLease
@StateObject private var ownLease: BannerSlotLease
// #1080: an externally owned lease, when supplied, is used INSTEAD of
// `ownLease` — `ownLease` is still constructed (a `@StateObject`'s
// storage exists unconditionally) but is never attached, so its
// `isolated deinit` finds no session and unregisters nothing. This is
// what lets a host that outlives this view's own identity (`GameRoot`,
// for the `tabViewBottomAccessory` placement that gets natively
// re-hosted) keep one lease alive across that re-hosting instead of
// every remount registering a fresh one. Do not "simplify" this to a
// single lease — `ownLease` existing-but-inert while `external` is set
// is the point, not an oversight.
private let external: BannerSlotLease?

@MainActor
init() {
_lease = StateObject(wrappedValue: BannerSlotLease())
init(lease: BannerSlotLease? = nil) {
_ownLease = StateObject(wrappedValue: BannerSlotLease())
external = lease
}

@MainActor
private var effective: BannerSlotLease { external ?? ownLease }

@MainActor
var session: BannerSessionModel? { environmentSession }

@MainActor
var id: BannerSlotID { lease.id }
var id: BannerSlotID { effective.id }

nonisolated func update() {
MainActor.assumeIsolated {
guard let session = environmentSession else {
BannerSessionModel.onMissingSession()
return
}
lease.attach(to: session)
effective.attach(to: session)
}
}
}
Expand All @@ -72,11 +87,19 @@ struct BannerSlotRegistration: DynamicProperty {

/// One slot view's registration with the session: created once per view
/// identity, released with it.
///
/// `public` (#1080): a host that outlives a placement's own view identity
/// (`GameAppKit.GameRoot`, for the `tabViewBottomAccessory` capsule) needs to
/// construct one of these itself and hand it to `BannerSlotView(lease:...)`.
/// `id` and `attach(to:)` stay `internal` — a host only ever needs to own the
/// lease's lifetime, never its registration mechanics.
@MainActor
final class BannerSlotLease: ObservableObject {
public final class BannerSlotLease: ObservableObject {
let id = BannerSlotID()
private weak var session: BannerSessionModel?

public init() {}

/// Idempotent. `update()` runs many times per mount, so a repeat call with
/// the same session must do nothing — re-registering would cancel and
/// restart the slot's load. A different session (a re-injected
Expand Down
133 changes: 25 additions & 108 deletions Packages/AppMonetizationKit/Sources/MonetizationUI/BannerSlotView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,31 @@ public struct BannerSlotView: View {
self.verticalPadding = verticalPadding
}

/// Joins the session with a lease OWNED BY THE CALLER instead of a fresh
/// `@StateObject` one. Only for a placement whose native host rebuilds
/// this view's SwiftUI content (`tabViewBottomAccessory`, #1080) — the
/// caller must keep this lease alive across that re-hosting, or
/// registration churns exactly like it would without this init.
public init(
lease: BannerSlotLease,
isSuppressed: Bool,
backgroundColor: Color = .clear,
progressTint: Color = .accentColor,
captionColor: Color = .secondary,
dismissTint: Color = .secondary,
horizontalPadding: CGFloat = 0,
verticalPadding: CGFloat = 0
) {
self.registration = BannerSlotRegistration(lease: lease)
self.isSuppressed = isSuppressed
self.backgroundColor = backgroundColor
self.progressTint = progressTint
self.captionColor = captionColor
self.dismissTint = dismissTint
self.horizontalPadding = horizontalPadding
self.verticalPadding = verticalPadding
}

public var body: some View {
if let session = registration.session, session.isVisible, !isSuppressed {
// Horizontal inset is `BannerSlotBandLayout`'s job now (#1084) —
Expand Down Expand Up @@ -226,114 +251,6 @@ public struct BannerSlotView: View {
}
}

// MARK: - BannerSlotBandLayout (#1084)

/// Which of `BannerSlotBandLayout`'s two subviews a child is — tagged via
/// `.layoutValue(key:value:)` since `placeSubviews` must propose the band a
/// different width than the content (#1084 review fix 1 and 2: the band must
/// stop at the padded-in width, not bleed into the horizontal inset or fill
/// the Layout's full un-padded width).
private enum BannerSlotBandRole: Sendable {
/// The visible band background — proposed exactly the padded-in width
/// (`bounds.width − 2×padding`), so its `.slot` anchor reads that rect.
case band
/// The creative + ✕ pair — proposed `.unspecified` so each keeps its own
/// declared, fixed size.
case content
}

private struct BannerSlotBandRoleKey: LayoutValueKey {
static let defaultValue: BannerSlotBandRole = .content
}

/// Arranges the visible band background and the creative + ✕ pair at a
/// horizontal inset that is `nominalPadding` while `width ≥ needed +
/// nominalPadding` (380pt at the shipped constants: 320 + 44 + 16), shrinking
/// symmetrically (never below 0) below that. The constraint PM ruled on is
/// "the ✕ stays fully on-screen", not "the ✕ stays inside the band" — see
/// `padding(for:)`. A custom `Layout`, not `onGeometryChange` + `@State`: the
/// state-loop approach draws one frame at the wrong padding before
/// correcting on the next layout pass (PM ruling, #1084).
///
/// `sizeThatFits` always returns a concrete, finite size — including for an
/// unconstrained ("ideal") proposal — which is also what keeps
/// `NSHostingView.fittingSize` (the pre-existing `BannerSlotViewTests`
/// `measuredHeight` / `pauseKeepsLease` measurement path) from degenerating
/// to a zero height the way a bare `.frame(maxWidth: .infinity)` chain does.
private struct BannerSlotBandLayout: Layout {
let nominalPadding: CGFloat
let creativeWidth: CGFloat
let dismissTargetSize: CGFloat
let bannerHeight: CGFloat

/// The creative and the ✕ side by side, with no gap between them.
private var needed: CGFloat { creativeWidth + dismissTargetSize }

/// `nominalPadding` while `width ≥ needed + nominalPadding` (PM's final
/// ruling, #1084: the constraint is "the ✕ stays fully on-screen", not
/// "the ✕ stays inside the band" — a ONE-SIDED comfort check, not
/// `needed + 2×nominalPadding`. At 393pt (the most common iPhone width)
/// this keeps the full 16pt left padding instead of shrinking to 14.5pt,
/// which would visibly mismatch 402pt's 16pt for no reason; the ✕ then
/// sits a few points past the band's own trailing edge but still well
/// inside the screen. Below `needed + nominalPadding`, the padding
/// shrinks symmetrically, clamped at 0.
private func padding(for width: CGFloat) -> CGFloat {
let comfortable = needed + nominalPadding
guard width < comfortable else { return nominalPadding }
return max(0, (width - needed) / 2)
}

/// The comfortable width at the full nominal padding on both sides —
/// `sizeThatFits`'s ideal, and the fallback `resolvedBannerBandWidth`
/// substitutes for a `nil` or non-finite proposal.
private var idealWidth: CGFloat { needed + 2 * nominalPadding }

func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
CGSize(width: resolvedBannerBandWidth(proposal: proposal.width, ideal: idealWidth), height: bannerHeight)
}

func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let padding = padding(for: bounds.width)
let origin = CGPoint(x: bounds.minX + padding, y: bounds.minY)
for subview in subviews {
switch subview[BannerSlotBandRoleKey.self] {
case .band:
// Stops at the padded-in width — never the Layout's full,
// un-padded width (#1084 review fix 1 and 2).
let bandWidth = max(0, bounds.width - 2 * padding)
subview.place(
at: origin,
anchor: .topLeading,
proposal: ProposedViewSize(width: bandWidth, height: bannerHeight)
)
case .content:
subview.place(at: origin, anchor: .topLeading, proposal: .unspecified)
}
}
}
}

/// Resolves a proposed width to a concrete, finite value: `proposal` itself
/// when it's present and finite, `ideal` otherwise — covering both a `nil`
/// proposal (an unconstrained/"ideal" query) and a non-finite one (e.g.
/// `.infinity`, which a horizontal `ScrollView` can propose to its content
/// along the scroll axis). Without this, `BannerSlotBandLayout.sizeThatFits`
/// would propagate `.infinity`/`NaN`, contradicting its own "always a
/// concrete, finite size" doc comment (#1084 review fix 3).
///
/// `internal`, not `private`, purely as a test seam: `BannerSlotBandLayout`
/// itself stays `private`, but this pure function is unit-tested directly
/// with a literal `.infinity` argument, since no host inside
/// `BannerSlotDismissPlacementTests`'s headless macOS harness actually
/// produces a literal `.infinity` proposal (confirmed empirically — a
/// horizontal `ScrollView` and `.fixedSize(horizontal:)` both resolve to a
/// concrete or `nil` proposal there instead).
internal func resolvedBannerBandWidth(proposal: CGFloat?, ideal: CGFloat) -> CGFloat {
guard let proposal, proposal.isFinite else { return ideal }
return proposal
}

// MARK: - Geometry preference (#1084, test-only)

/// The three named parts `BannerSlotGeometryKey` reports bounds for.
Expand Down
Loading
Loading