Fix AdMob history banner and refine Felica history mapping

This commit is contained in:
2026-04-24 21:32:56 +07:00
parent 8f0b001501
commit 7882f77a27
6 changed files with 289 additions and 38 deletions

View File

@ -623,7 +623,7 @@
CODE_SIGN_ENTITLEMENTS = "Emoney Info/Emoney Info.entitlements"; CODE_SIGN_ENTITLEMENTS = "Emoney Info/Emoney Info.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10; CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = 6S5573WXX4; DEVELOPMENT_TEAM = 6S5573WXX4;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@ -643,7 +643,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.1; MARKETING_VERSION = 1.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.iiyh.emoneyinfo; PRODUCT_BUNDLE_IDENTIFIER = com.iiyh.emoneyinfo;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@ -666,7 +666,7 @@
CODE_SIGN_ENTITLEMENTS = "Emoney Info/Emoney Info.entitlements"; CODE_SIGN_ENTITLEMENTS = "Emoney Info/Emoney Info.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10; CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = 6S5573WXX4; DEVELOPMENT_TEAM = 6S5573WXX4;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@ -686,7 +686,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.1; MARKETING_VERSION = 1.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.iiyh.emoneyinfo; PRODUCT_BUNDLE_IDENTIFIER = com.iiyh.emoneyinfo;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";

View File

@ -229,25 +229,12 @@ public class UnifiedNfcApi {
if (uid == 0){ if (uid == 0){
normal = false normal = false
} }
if (data.count > 10){ if data.count >= 13 {
let type = data[10] let transactionKind = self.felicaTransactionKind(for: [UInt8](data))
debugLog(type) riwayat.setProsesTipe(transactionKind.prosesTipe)
switch type { riwayat.setTitle(transactionKind.title.localizeString(string: self.langCode!))
case 0x01: debugLog("signature: \(transactionKind.signature)")
riwayat.setProsesTipe(1) debugLog(transactionKind.logLabel)
riwayat.setTitle("payment".localizeString(string: self.langCode!))
debugLog("Pembayaran")
case 0x00, 0x03:
riwayat.setProsesTipe(0)
riwayat.setTitle("topup".localizeString(string: self.langCode!))
debugLog("Topup")
default:
riwayat.setProsesTipe(1)
riwayat.setTitle("payment".localizeString(string: self.langCode!))
debugLog("Other")
}
} }
if let station = self.stationMap[uid]{ if let station = self.stationMap[uid]{
debugLog("station", station.name) debugLog("station", station.name)
@ -391,6 +378,33 @@ public class UnifiedNfcApi {
return Int(bytes.withUnsafeBytes { $0.load(as: Int32.self).bigEndian }) return Int(bytes.withUnsafeBytes { $0.load(as: Int32.self).bigEndian })
} }
} }
private func felicaTransactionKind(for bytes: [UInt8]) -> (prosesTipe: Int, title: String, logLabel: String, signature: String) {
let signatureBytes = Array(bytes[10...12])
let signature = signatureBytes.map { String(format: "%02X", $0) }.joined(separator: " ")
switch signatureBytes {
case [0x00, 0x02, 0x00],
[0x03, 0x01, 0x00]:
return (0, "topup", "Topup", signature)
case [0x01, 0x01, 0x01],
[0x01, 0x58, 0x01],
[0x03, 0x61, 0x01]:
return (1, "payment", "Pembayaran", signature)
default:
let type = bytes[10]
switch type {
case 0x00:
return (0, "topup", "Topup (fallback)", signature)
case 0x01:
return (1, "payment", "Pembayaran (fallback)", signature)
case 0x03:
return (1, "payment", "Pembayaran (fallback 0x03)", signature)
default:
return (1, "payment", "Other", signature)
}
}
}
public func stopCheckCard(message : String){ public func stopCheckCard(message : String){
apduRunner.sessionEx?.invalidate(errorMessage: message) apduRunner.sessionEx?.invalidate(errorMessage: message)

View File

@ -223,38 +223,133 @@ private struct BannerAdView: UIViewRepresentable {
func makeCoordinator() -> Coordinator { Coordinator(self) } func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeUIView(context: Context) -> GADBannerView { func makeUIView(context: Context) -> HistoryBannerView {
let banner = GADBannerView(adSize: adSize) let banner = HistoryBannerView(adSize: adSize)
banner.adUnitID = adUnitID banner.adUnitID = adUnitID
banner.delegate = context.coordinator banner.delegate = context.coordinator
banner.rootViewController = context.coordinator.findRootViewController() banner.onDidMoveToWindow = { [weak coordinator = context.coordinator] bannerView in
banner.load(GADRequest()) coordinator?.handleBannerDidMoveToWindow(bannerView)
}
return banner return banner
} }
func updateUIView(_ uiView: GADBannerView, context: Context) {} func updateUIView(_ uiView: HistoryBannerView, context: Context) {
context.coordinator.attachRootViewControllerIfNeeded(to: uiView)
context.coordinator.loadBannerIfNeeded(uiView, reason: "updateUIView")
}
// MARK: Coordinator // MARK: Coordinator
final class Coordinator: NSObject, GADBannerViewDelegate { final class Coordinator: NSObject, GADBannerViewDelegate {
let parent: BannerAdView let parent: BannerAdView
private var retryWorkItem: DispatchWorkItem?
private var isAdLoaded = false
private var isLoadInFlight = false
private let retryDelay: TimeInterval = 20
init(_ parent: BannerAdView) { self.parent = parent } init(_ parent: BannerAdView) { self.parent = parent }
deinit {
retryWorkItem?.cancel()
}
func bannerViewDidReceiveAd(_ bannerView: GADBannerView) { func bannerViewDidReceiveAd(_ bannerView: GADBannerView) {
retryWorkItem?.cancel()
isLoadInFlight = false
isAdLoaded = true
log("banner loaded", bannerView: bannerView)
DispatchQueue.main.async { self.parent.onAdLoaded?() } DispatchQueue.main.async { self.parent.onAdLoaded?() }
} }
func bannerView(_ bannerView: GADBannerView, didFailToReceiveAdWithError error: Error) { func bannerView(_ bannerView: GADBannerView, didFailToReceiveAdWithError error: Error) {
isLoadInFlight = false
isAdLoaded = false
log("banner failed", bannerView: bannerView, error: error)
scheduleRetry(for: bannerView)
DispatchQueue.main.async { self.parent.onAdFailed?() } DispatchQueue.main.async { self.parent.onAdFailed?() }
} }
func findRootViewController() -> UIViewController? { func handleBannerDidMoveToWindow(_ bannerView: GADBannerView) {
UIApplication.shared.connectedScenes attachRootViewControllerIfNeeded(to: bannerView)
.compactMap { $0 as? UIWindowScene } loadBannerIfNeeded(bannerView, reason: "didMoveToWindow")
.flatMap { $0.windows }
.first { $0.isKeyWindow }?
.rootViewController
} }
func attachRootViewControllerIfNeeded(to bannerView: GADBannerView) {
guard let rootViewController = findNearestViewController(from: bannerView) else {
log("root view controller not ready", bannerView: bannerView)
return
}
if bannerView.rootViewController !== rootViewController {
bannerView.rootViewController = rootViewController
log("attached root view controller", bannerView: bannerView)
}
}
func loadBannerIfNeeded(_ bannerView: GADBannerView, reason: String) {
guard !isAdLoaded, !isLoadInFlight else { return }
guard bannerView.window != nil else {
log("skip load: banner is not in window", bannerView: bannerView, reason: reason)
return
}
guard bannerView.rootViewController != nil else {
log("skip load: root view controller missing", bannerView: bannerView, reason: reason)
return
}
retryWorkItem?.cancel()
isLoadInFlight = true
log("loading banner", bannerView: bannerView, reason: reason)
bannerView.load(GADRequest())
}
private func scheduleRetry(for bannerView: GADBannerView) {
retryWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self, weak bannerView] in
guard let self, let bannerView else { return }
self.attachRootViewControllerIfNeeded(to: bannerView)
self.loadBannerIfNeeded(bannerView, reason: "retry")
}
retryWorkItem = workItem
log("scheduling retry in \(Int(retryDelay))s", bannerView: bannerView)
DispatchQueue.main.asyncAfter(deadline: .now() + retryDelay, execute: workItem)
}
private func log(_ message: String, bannerView: GADBannerView, reason: String? = nil, error: Error? = nil) {
var parts = ["[AdMob][History]", message]
if let reason {
parts.append("reason=\(reason)")
}
parts.append("adUnitID=\(bannerView.adUnitID ?? "-")")
parts.append("visible=\(bannerView.window != nil)")
parts.append("loaded=\(isAdLoaded)")
parts.append("inFlight=\(isLoadInFlight)")
parts.append("rootVC=\(String(describing: type(of: bannerView.rootViewController)))")
if let nsError = error as NSError? {
parts.append("errorDomain=\(nsError.domain)")
parts.append("errorCode=\(nsError.code)")
parts.append("error=\(nsError.localizedDescription)")
}
debugLog(parts.joined(separator: " | "))
}
private func findNearestViewController(from view: UIView) -> UIViewController? {
sequence(first: view.next, next: { $0?.next })
.first { $0 is UIViewController } as? UIViewController
}
}
}
private final class HistoryBannerView: GADBannerView {
var onDidMoveToWindow: ((HistoryBannerView) -> Void)?
override func didMoveToWindow() {
super.didMoveToWindow()
onDidMoveToWindow?(self)
} }
} }
@ -400,11 +495,19 @@ final class HistoryHostingController: UIViewController {
} }
private func loadInterstitial() { private func loadInterstitial() {
debugLog("[AdMob][HistoryInterstitial] loading interstitial | adUnitID=\(interstitialAdUnitID)")
GADInterstitialAd.load( GADInterstitialAd.load(
withAdUnitID: interstitialAdUnitID, withAdUnitID: interstitialAdUnitID,
request: GADRequest() request: GADRequest()
) { [weak self] ad, _ in ) { [weak self] ad, error in
// Store ad if loaded; ignore error fallback to direct export if let error = error as NSError? {
debugLog(
"[AdMob][HistoryInterstitial] failed | adUnitID=\(self?.interstitialAdUnitID ?? "-") | errorDomain=\(error.domain) | errorCode=\(error.code) | error=\(error.localizedDescription)"
)
} else {
debugLog("[AdMob][HistoryInterstitial] loaded | adUnitID=\(self?.interstitialAdUnitID ?? "-")")
}
self?.interstitial = ad self?.interstitial = ad
self?.interstitial?.fullScreenContentDelegate = self self?.interstitial?.fullScreenContentDelegate = self
} }
@ -645,6 +748,7 @@ extension HistoryHostingController: GADFullScreenContentDelegate {
// Called when the interstitial is dismissed proceed with export // Called when the interstitial is dismissed proceed with export
func adDidDismissFullScreenContent(_ ad: GADFullScreenPresentingAd) { func adDidDismissFullScreenContent(_ ad: GADFullScreenPresentingAd) {
debugLog("[AdMob][HistoryInterstitial] dismissed")
interstitial = nil interstitial = nil
loadInterstitial() // pre-load for the next export attempt loadInterstitial() // pre-load for the next export attempt
exportPDF() exportPDF()
@ -652,6 +756,10 @@ extension HistoryHostingController: GADFullScreenContentDelegate {
// Called when the interstitial fails to present fall back to direct export // Called when the interstitial fails to present fall back to direct export
func ad(_ ad: GADFullScreenPresentingAd, didFailToPresentFullScreenContentWithError error: Error) { func ad(_ ad: GADFullScreenPresentingAd, didFailToPresentFullScreenContentWithError error: Error) {
let nsError = error as NSError
debugLog(
"[AdMob][HistoryInterstitial] present failed | errorDomain=\(nsError.domain) | errorCode=\(nsError.code) | error=\(nsError.localizedDescription)"
)
interstitial = nil interstitial = nil
exportPDF() exportPDF()
} }

View File

@ -63,6 +63,10 @@ final class HomeViewController: UIViewController {
// Ads // Ads
private let promoCard = UIView() private let promoCard = UIView()
private var bannerView = GADBannerView() private var bannerView = GADBannerView()
private var bannerRetryWorkItem: DispatchWorkItem?
private var isBannerAdLoaded = false
private var isBannerLoadInFlight = false
private let bannerRetryDelay: TimeInterval = 20
// Dynamic layout: toggle based on ad load state // Dynamic layout: toggle based on ad load state
private var lastTxTopNoAd: NSLayoutConstraint! private var lastTxTopNoAd: NSLayoutConstraint!
@ -106,6 +110,7 @@ final class HomeViewController: UIViewController {
name: Notification.Name("refreshScreen"), name: Notification.Name("refreshScreen"),
object: nil object: nil
) )
loadBannerAdIfNeeded(reason: "viewDidAppear")
} }
override func viewDidDisappear(_ animated: Bool) { override func viewDidDisappear(_ animated: Bool) {
@ -113,6 +118,10 @@ final class HomeViewController: UIViewController {
NotificationCenter.default.removeObserver(self, name: Notification.Name("refreshScreen"), object: nil) NotificationCenter.default.removeObserver(self, name: Notification.Name("refreshScreen"), object: nil)
} }
deinit {
bannerRetryWorkItem?.cancel()
}
override func viewDidLayoutSubviews() { override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews() super.viewDidLayoutSubviews()
cardGradient.frame = cardView.bounds cardGradient.frame = cardView.bounds
@ -266,9 +275,55 @@ final class HomeViewController: UIViewController {
} }
func loadBannerAd() { func loadBannerAd() {
loadBannerAdIfNeeded(reason: "external")
}
private func loadBannerAdIfNeeded(reason: String) {
guard !isBannerAdLoaded, !isBannerLoadInFlight else { return }
guard isViewLoaded, view.window != nil else {
logAdMob("skip load: view is not visible", reason: reason)
return
}
bannerRetryWorkItem?.cancel()
bannerView.rootViewController = self
isBannerLoadInFlight = true
logAdMob("loading banner", reason: reason)
bannerView.load(GADRequest()) bannerView.load(GADRequest())
} }
private func scheduleBannerRetry(after delay: TimeInterval = 20) {
bannerRetryWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in
self?.loadBannerAdIfNeeded(reason: "retry")
}
bannerRetryWorkItem = workItem
logAdMob("scheduling retry in \(Int(delay))s")
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
}
private func logAdMob(_ message: String, reason: String? = nil, error: Error? = nil) {
var parts = ["[AdMob][Home]", message]
if let reason {
parts.append("reason=\(reason)")
}
parts.append("adUnitID=\(bannerView.adUnitID ?? "-")")
parts.append("visible=\(viewIfLoaded?.window != nil)")
parts.append("loaded=\(isBannerAdLoaded)")
parts.append("inFlight=\(isBannerLoadInFlight)")
parts.append("rootVC=\(String(describing: type(of: bannerView.rootViewController)))")
if let nsError = error as NSError? {
parts.append("errorDomain=\(nsError.domain)")
parts.append("errorCode=\(nsError.code)")
parts.append("error=\(nsError.localizedDescription)")
}
debugLog(parts.joined(separator: " | "))
}
private func setupLastTransaction() { private func setupLastTransaction() {
lastTxHeader.text = L10n.lastTransaction lastTxHeader.text = L10n.lastTransaction
lastTxHeader.font = Theme.Font.subtitle(weight: .bold) lastTxHeader.font = Theme.Font.subtitle(weight: .bold)
@ -562,6 +617,10 @@ struct LastTransactionItem {
extension HomeViewController: GADBannerViewDelegate { extension HomeViewController: GADBannerViewDelegate {
func bannerViewDidReceiveAd(_ bannerView: GADBannerView) { func bannerViewDidReceiveAd(_ bannerView: GADBannerView) {
bannerRetryWorkItem?.cancel()
isBannerLoadInFlight = false
isBannerAdLoaded = true
logAdMob("banner loaded")
lastTxTopNoAd.isActive = false lastTxTopNoAd.isActive = false
lastTxTopWithAd.isActive = true lastTxTopWithAd.isActive = true
UIView.animate(withDuration: 0.3) { UIView.animate(withDuration: 0.3) {
@ -571,12 +630,16 @@ extension HomeViewController: GADBannerViewDelegate {
} }
func bannerView(_ bannerView: GADBannerView, didFailToReceiveAdWithError error: Error) { func bannerView(_ bannerView: GADBannerView, didFailToReceiveAdWithError error: Error) {
isBannerLoadInFlight = false
isBannerAdLoaded = false
logAdMob("banner failed", error: error)
lastTxTopWithAd.isActive = false lastTxTopWithAd.isActive = false
lastTxTopNoAd.isActive = true lastTxTopNoAd.isActive = true
UIView.animate(withDuration: 0.3) { UIView.animate(withDuration: 0.3) {
self.promoCard.isHidden = true self.promoCard.isHidden = true
self.view.layoutIfNeeded() self.view.layoutIfNeeded()
} }
scheduleBannerRetry(after: bannerRetryDelay)
} }
} }

View File

@ -156,7 +156,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
private func startAdMobSDKIfNeeded() { private func startAdMobSDKIfNeeded() {
guard !hasStartedAdMob else { return } guard !hasStartedAdMob else { return }
hasStartedAdMob = true hasStartedAdMob = true
// GADMobileAds.sharedInstance().requestConfiguration.testDeviceIdentifiers = ["7b42e513861d6b0c9f07529b748930c0"] // GADMobileAds.sharedInstance().requestConfiguration.testDeviceIdentifiers = ["ae454ddf2186e5ac7871bf705de41098"]
GADMobileAds.sharedInstance().start { [weak self] _ in GADMobileAds.sharedInstance().start { [weak self] _ in
DispatchQueue.main.async { DispatchQueue.main.async {
self?.homeVC?.loadBannerAd() self?.homeVC?.loadBannerAd()

View File

@ -34,6 +34,10 @@ final class SettingsViewController: UIViewController {
// Ad banner // Ad banner
private let adContainer = UIView() private let adContainer = UIView()
private var bannerView = GADBannerView() private var bannerView = GADBannerView()
private var bannerRetryWorkItem: DispatchWorkItem?
private var isBannerAdLoaded = false
private var isBannerLoadInFlight = false
private let bannerRetryDelay: TimeInterval = 20
// Dynamic constraints toggled on ad load/fail // Dynamic constraints toggled on ad load/fail
private var generalTopNoAd: NSLayoutConstraint! private var generalTopNoAd: NSLayoutConstraint!
@ -59,6 +63,15 @@ final class SettingsViewController: UIViewController {
setupFooter() setupFooter()
} }
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
loadBannerAdIfNeeded(reason: "viewDidAppear")
}
deinit {
bannerRetryWorkItem?.cancel()
}
// MARK: - ScrollView // MARK: - ScrollView
private func setupScrollView() { private func setupScrollView() {
@ -132,10 +145,55 @@ final class SettingsViewController: UIViewController {
bannerView.heightAnchor.constraint(equalToConstant: CGFloat(adSize.size.height)) bannerView.heightAnchor.constraint(equalToConstant: CGFloat(adSize.size.height))
]) ])
bannerView.load(GADRequest())
lastBottomAnchor = adContainer.bottomAnchor lastBottomAnchor = adContainer.bottomAnchor
} }
private func loadBannerAdIfNeeded(reason: String) {
guard !isBannerAdLoaded, !isBannerLoadInFlight else { return }
guard isViewLoaded, view.window != nil else {
logAdMob("skip load: view is not visible", reason: reason)
return
}
bannerRetryWorkItem?.cancel()
bannerView.rootViewController = self
isBannerLoadInFlight = true
logAdMob("loading banner", reason: reason)
bannerView.load(GADRequest())
}
private func scheduleBannerRetry(after delay: TimeInterval = 20) {
bannerRetryWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in
self?.loadBannerAdIfNeeded(reason: "retry")
}
bannerRetryWorkItem = workItem
logAdMob("scheduling retry in \(Int(delay))s")
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
}
private func logAdMob(_ message: String, reason: String? = nil, error: Error? = nil) {
var parts = ["[AdMob][Settings]", message]
if let reason {
parts.append("reason=\(reason)")
}
parts.append("adUnitID=\(bannerView.adUnitID ?? "-")")
parts.append("visible=\(viewIfLoaded?.window != nil)")
parts.append("loaded=\(isBannerAdLoaded)")
parts.append("inFlight=\(isBannerLoadInFlight)")
parts.append("rootVC=\(String(describing: type(of: bannerView.rootViewController)))")
if let nsError = error as NSError? {
parts.append("errorDomain=\(nsError.domain)")
parts.append("errorCode=\(nsError.code)")
parts.append("error=\(nsError.localizedDescription)")
}
debugLog(parts.joined(separator: " | "))
}
// MARK: - General Section // MARK: - General Section
private func setupGeneralSection() { private func setupGeneralSection() {
@ -291,6 +349,10 @@ final class SettingsViewController: UIViewController {
extension SettingsViewController: GADBannerViewDelegate { extension SettingsViewController: GADBannerViewDelegate {
func bannerViewDidReceiveAd(_ bannerView: GADBannerView) { func bannerViewDidReceiveAd(_ bannerView: GADBannerView) {
bannerRetryWorkItem?.cancel()
isBannerLoadInFlight = false
isBannerAdLoaded = true
logAdMob("banner loaded")
generalTopNoAd.isActive = false generalTopNoAd.isActive = false
generalTopWithAd.isActive = true generalTopWithAd.isActive = true
UIView.animate(withDuration: 0.3) { UIView.animate(withDuration: 0.3) {
@ -300,12 +362,16 @@ extension SettingsViewController: GADBannerViewDelegate {
} }
func bannerView(_ bannerView: GADBannerView, didFailToReceiveAdWithError error: Error) { func bannerView(_ bannerView: GADBannerView, didFailToReceiveAdWithError error: Error) {
isBannerLoadInFlight = false
isBannerAdLoaded = false
logAdMob("banner failed", error: error)
generalTopWithAd.isActive = false generalTopWithAd.isActive = false
generalTopNoAd.isActive = true generalTopNoAd.isActive = true
UIView.animate(withDuration: 0.3) { UIView.animate(withDuration: 0.3) {
self.adContainer.isHidden = true self.adContainer.isHidden = true
self.view.layoutIfNeeded() self.view.layoutIfNeeded()
} }
scheduleBannerRetry(after: bannerRetryDelay)
} }
} }