From 2cc4050cdb7f5e2b6e503ab8a07c3af1581a7f58 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Tue, 1 Sep 2026 21:25:56 +0200 Subject: [PATCH] Keep asking for a banner after one does not fill didFailToReceiveAdWithError hid the banner and nothing asked again for the life of the controller. The ad unit only refreshes a banner that is on screen, and a hidden one is not, so a single refusal left the slot silent until the next document. It is the same hole droid#643 closes on Android; this is the iOS half. A failure now schedules its own ask, rescheduling before each request so one that never answers cannot end the chain, and a refresh that does not fill keeps the ad already on screen instead of hiding a live one. A suspended app cannot fire the work item, so backgrounding needs no guard. --- Ads/AdSlot.swift | 51 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/Ads/AdSlot.swift b/Ads/AdSlot.swift index a3a2993..2a1bf07 100644 --- a/Ads/AdSlot.swift +++ b/Ads/AdSlot.swift @@ -19,6 +19,23 @@ final class AdSlot: NSObject, BannerViewDelegate { /// makes the first request. private var requestedWidth: CGFloat? + /// Whether a banner has ever filled. + private var hasAd = false + + /// The pending ask for a slot that did not fill. + private var retry: DispatchWorkItem? + + private var retryDelay = AdSlot.firstRetryDelay + + /// 10s, 20s, 40s, then the 60s the unit refreshes at. Not shorter: the sdk throttles a burst + /// of failed requests itself. + private static let firstRetryDelay: TimeInterval = 10 + private static let maxRetryDelay: TimeInterval = 60 + + deinit { + retry?.cancel() + } + /// Puts the banner in `slot` and gathers consent. Once per controller: the form is modal, so a /// second call on reappearance would bring it back. func start(in slot: UIView, from viewController: UIViewController) { @@ -84,9 +101,33 @@ final class AdSlot: NSObject, BannerViewDelegate { requestedWidth = width bannerView.adSize = currentOrientationAnchoredAdaptiveBanner(width: width) + + retry?.cancel() + retryDelay = AdSlot.firstRetryDelay + bannerView.load(Request()) } + /// Asks again for a hidden banner, which the unit will not refresh. Reschedules itself before + /// it asks: a request that never comes back must not end the chain. A suspended app cannot + /// fire the work item, so backgrounding needs no guard. + private func scheduleRetry() { + retry?.cancel() + + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + + self.retryDelay = min(self.retryDelay * 2, AdSlot.maxRetryDelay) + self.scheduleRetry() + + self.bannerView.load(Request()) + } + + retry = work + + DispatchQueue.main.asyncAfter(deadline: .now() + retryDelay, execute: work) + } + private func reportNoAd() { bannerView.isHidden = true @@ -94,10 +135,20 @@ final class AdSlot: NSObject, BannerViewDelegate { } func bannerView(_ bannerView: BannerView, didFailToReceiveAdWithError error: Error) { + CrashManager.shared.log("ad failed to load: \(error.localizedDescription)") + + // the sdk keeps refreshing an ad that is up; only an empty slot needs asking + guard !hasAd else { return } + reportNoAd() + scheduleRetry() } func bannerViewDidReceiveAd(_ bannerView: BannerView) { + retry?.cancel() + retryDelay = AdSlot.firstRetryDelay + hasAd = true + bannerView.isHidden = false onAd?()