From d6ee7b337ba4cc73b5fc4bf6143e11352af06066 Mon Sep 17 00:00:00 2001 From: Anton Urankar <124867229+antonurankar-moloco@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:42:52 +0200 Subject: [PATCH 01/16] Give CloudX a first look on every inline opportunity; cut First Look to two formats Banner readiness was tracked with two sticky booleans that were set on load and never cleared, so the first successful fill from either source froze the slot for the whole screen visit. After a CloudX no-fill the AdMob fallback owned the placement until the scene was destroyed, and CloudX was never asked again - the opposite of what First Look promises. The screen's backoff retry and the LoadBanner() inside ToggleBanner were both unreachable in that state. An inline ad has no consumption event the way a fullscreen one does, so displaying it is now what spends the pass: a load into an already-visible view renders immediately, which makes "on screen" the one honest moment to treat a fill as used. ShowSource clears both flags and raises PassSpent; the screen schedules the next pass PassCooldownSeconds later, and that pass starts at CloudX again. Reloading is in place rather than a recreate, so a visible ad is replaced only once the new one has filled and the slot never blanks. The cycle turns only while an ad is on screen - hiding cancels the pending pass - and a fill the AdMob console refreshed on its own does not count as a pass, or an AdMob unit with Automatic refresh enabled would postpone CloudX's next first look on every refresh. The screen now covers interstitial and banner only. Those are the two shapes the rule has to handle; rewarded repeats the interstitial and MREC repeats the banner, and the General screen already demonstrates all four formats. With two formats left, each family base had exactly one subclass, so the bases are gone and each controller is one self-contained file a publisher can copy on its own alongside FirstLookSource.cs. That duplicates about fifty lines of ad-unit and dispose bookkeeping between the two files, on purpose, so neither drags a shared base into someone else's project. AdScreenUi closes the portrait column when a screen hides buttons, so the two remaining ones sit together instead of leaving a hole. It is a no-op when nothing is hidden, which is the General screen. Verified on the Android emulator and the iOS simulator, on the CloudX path and the forced no-fill path: CloudX re-asked on every pass, a 30 s cadence that follows the constant rather than the SDK's own inert refresh timer, zero requests while hidden, no blank frame on swap, and no drift across rotations. --- Assets/Scripts/AdScreenUi.cs | 82 ++++ .../FirstLook/FirstLookAdController.cs | 78 --- .../FirstLook/FirstLookAdController.cs.meta | 2 - .../FirstLook/FirstLookBannerController.cs | 450 ++++++++++++++++-- Assets/Scripts/FirstLook/FirstLookConfig.cs | 18 +- .../FirstLookFullscreenController.cs | 182 ------- .../FirstLookFullscreenController.cs.meta | 2 - .../FirstLook/FirstLookInlineController.cs | 251 ---------- .../FirstLookInlineController.cs.meta | 2 - .../FirstLookInterstitialController.cs | 280 +++++++++-- .../FirstLook/FirstLookMrecController.cs | 107 ----- .../FirstLook/FirstLookMrecController.cs.meta | 2 - .../FirstLook/FirstLookRewardedController.cs | 129 ----- .../FirstLookRewardedController.cs.meta | 2 - Assets/Scripts/FirstLook/FirstLookScreen.cs | 156 ++---- README.md | 91 ++-- docs/images/first-look-inline.png | Bin 33522 -> 27735 bytes docs/images/first-look-screen.png | Bin 30085 -> 22717 bytes 18 files changed, 837 insertions(+), 997 deletions(-) delete mode 100644 Assets/Scripts/FirstLook/FirstLookAdController.cs delete mode 100644 Assets/Scripts/FirstLook/FirstLookAdController.cs.meta delete mode 100644 Assets/Scripts/FirstLook/FirstLookFullscreenController.cs delete mode 100644 Assets/Scripts/FirstLook/FirstLookFullscreenController.cs.meta delete mode 100644 Assets/Scripts/FirstLook/FirstLookInlineController.cs delete mode 100644 Assets/Scripts/FirstLook/FirstLookInlineController.cs.meta delete mode 100644 Assets/Scripts/FirstLook/FirstLookMrecController.cs delete mode 100644 Assets/Scripts/FirstLook/FirstLookMrecController.cs.meta delete mode 100644 Assets/Scripts/FirstLook/FirstLookRewardedController.cs delete mode 100644 Assets/Scripts/FirstLook/FirstLookRewardedController.cs.meta diff --git a/Assets/Scripts/AdScreenUi.cs b/Assets/Scripts/AdScreenUi.cs index b192b7c..49b3ed5 100644 --- a/Assets/Scripts/AdScreenUi.cs +++ b/Assets/Scripts/AdScreenUi.cs @@ -139,6 +139,87 @@ public void SetButtonVisible(Button button, bool visible) if (_controlsParentedToLandscape) PlaceLandscapeStacks(); + else + CompactPortraitColumn(); + } + + /* + * Closes the portrait column when the screen hid some buttons: the visible + * ones move up into the topmost scene slots, in their original order, so a + * hidden button leaves no gap in the middle. Each per-button status moves by + * the same delta so it stays on its button's row. No-op when nothing is + * hidden, which is every screen that uses all four buttons. + */ + private void CompactPortraitColumn() + { + if (_hidden.Count == 0 || _portraitSnapshots == null) + return; + + /* + * The scene places these controls with anchors, not anchoredPosition, + * so a slot is the pair of anchor y values and moving a button means + * giving it another slot's pair. A status keeps its own height and + * offset by shifting the same distance its button moved. + */ + var slots = new List(); + var visible = new List(); + foreach (var snap in ButtonSnapshotsTopFirst()) + { + slots.Add(new Vector2(snap.AnchorMin.y, snap.AnchorMax.y)); + if (!_hidden.Contains(snap.Rect)) + visible.Add(snap); + } + + for (var i = 0; i < visible.Count; i++) + { + var slot = slots[i]; + var rect = visible[i].Rect; + var shift = slot.x - visible[i].AnchorMin.y; + + rect.anchorMin = new Vector2(rect.anchorMin.x, slot.x); + rect.anchorMax = new Vector2(rect.anchorMax.x, slot.y); + + /* + * Shift the status from its scene anchors, not its current ones, so + * running this again lands it in the same place instead of drifting + * one slot further each time. + */ + var status = StatusForButton(rect); + if (status != null && TryGetSnapshot(status, out var statusSnap)) + { + status.anchorMin = new Vector2(status.anchorMin.x, statusSnap.AnchorMin.y + shift); + status.anchorMax = new Vector2(status.anchorMax.x, statusSnap.AnchorMax.y + shift); + } + } + } + + private bool TryGetSnapshot(RectTransform rect, out ControlSnapshot snapshot) + { + foreach (var snap in _portraitSnapshots) + { + if (snap.Rect == rect) + { + snapshot = snap; + return true; + } + } + + snapshot = default; + return false; + } + + /* The four main buttons in scene order, topmost first. */ + private List ButtonSnapshotsTopFirst() + { + var buttons = new List(); + foreach (var snap in _portraitSnapshots) + { + if (Array.IndexOf(_landscapeMainLeft, snap.Rect) >= 0) + buttons.Add(snap); + } + + buttons.Sort((left, right) => right.AnchorMax.y.CompareTo(left.AnchorMax.y)); + return buttons; } public void SetInitializationStatus(string text) @@ -512,6 +593,7 @@ private void RestorePortraitLayout() snap.Rect.gameObject.SetActive(true); } RestorePortraitTextStyles(); + CompactPortraitColumn(); _controlsParentedToLandscape = false; Log("Portrait layout restored"); } diff --git a/Assets/Scripts/FirstLook/FirstLookAdController.cs b/Assets/Scripts/FirstLook/FirstLookAdController.cs deleted file mode 100644 index 459f720..0000000 --- a/Assets/Scripts/FirstLook/FirstLookAdController.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System; - -/* - * Shared base for the First Look controllers. Every format follows the same - * rule - CloudX gets the first chance to fill, AdMob is the lazy fallback that - * loads only after CloudX fails - so the CloudX/AdMob bookkeeping, the load - * events common to all formats, and the dispose sequence live here once. The - * two families split below: fullscreen (interstitial, rewarded) in - * FirstLookFullscreenController, inline (banner, MREC) in - * FirstLookInlineController. Each concrete format is then a thin subclass that - * only supplies the format-specific SDK calls. - * - * A publisher copying this into an app takes this base plus the one family base - * and the one concrete they need. - */ -public abstract class FirstLookAdController : IDisposable -{ - public event Action AdLoaded; - public event Action AdLoadFailed; - public event Action AdClicked; - - protected string CloudXAdUnitId { get; } - protected string AdMobAdUnitId { get; } - - /* - * When CloudX initialization failed, its load callbacks may never fire, so - * the controller skips the CloudX leg and goes straight to the fallback. - */ - protected bool CloudXAvailable { get; } - - protected bool IsDisposed { get; private set; } - protected bool IsLoadingCloudX { get; set; } - protected bool IsLoadingAdMob { get; set; } - - protected FirstLookAdController(string cloudXAdUnitId, string adMobAdUnitId, bool cloudXAvailable) - { - CloudXAdUnitId = cloudXAdUnitId; - AdMobAdUnitId = adMobAdUnitId; - CloudXAvailable = cloudXAvailable; - } - - /* The source a show right now would use; null when no ad is ready. */ - public abstract FirstLookSource? ReadySource { get; } - - public abstract void Load(); - - protected void RaiseAdLoaded(FirstLookSource source) => AdLoaded?.Invoke(source); - protected void RaiseAdLoadFailed(FirstLookSource source, string message) => AdLoadFailed?.Invoke(source, message); - protected void RaiseAdClicked(FirstLookSource source) => AdClicked?.Invoke(source); - - /* - * Subscribe/unsubscribe the CloudX callbacks. Called from the concrete - * constructor (not here) so the subclass is fully constructed first. - */ - protected abstract void SubscribeCloudXCallbacks(); - protected abstract void UnsubscribeCloudXCallbacks(); - protected abstract void DestroyCloudXAd(); - protected abstract void DestroyAdMobAd(); - - public void Dispose() - { - if (IsDisposed) - { - return; - } - - IsDisposed = true; - - UnsubscribeCloudXCallbacks(); - - if (CloudXAvailable) - { - DestroyCloudXAd(); - } - - DestroyAdMobAd(); - } -} diff --git a/Assets/Scripts/FirstLook/FirstLookAdController.cs.meta b/Assets/Scripts/FirstLook/FirstLookAdController.cs.meta deleted file mode 100644 index eeb0047..0000000 --- a/Assets/Scripts/FirstLook/FirstLookAdController.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: fce1c04095e52412da5c43e8e3d33114 \ No newline at end of file diff --git a/Assets/Scripts/FirstLook/FirstLookBannerController.cs b/Assets/Scripts/FirstLook/FirstLookBannerController.cs index d6683bd..74054ce 100644 --- a/Assets/Scripts/FirstLook/FirstLookBannerController.cs +++ b/Assets/Scripts/FirstLook/FirstLookBannerController.cs @@ -1,57 +1,333 @@ +using System; using CloudX; using GoogleMobileAds.Api; using GoogleMobileAds.Common; /* - * First Look banner. Shared flow lives in FirstLookInlineController; this class - * only supplies the banner SDK calls. Top banner on both SDKs (see - * FirstLookScreen). Auto-refresh is kept off - see the FirstLookInlineController - * class note; the crucial call is StopBannerAutoRefresh before create. + * First Look banner: CloudX gets the first chance to fill, AdMob loads lazily + * as the fallback only after CloudX fails. Same rule as + * FirstLookInterstitialController, but a banner stays on screen instead of + * being shown once, which changes two things. + * + * This file is the whole flow, top to bottom, so it can be copied into an app + * on its own (plus FirstLookSource.cs for the enum). Reading order: state, the + * Load/Show/Hide entry points, the pass cycle, then each SDK's callbacks. + * + * 1. THE PASS CYCLE. A fullscreen ad is consumed by being shown, so the SDKs' + * own "is an ad ready" answers go false and the next Load() naturally starts + * at CloudX again. Inline ads have no such event - CloudX banners report + * only load and click, no show or close - so this controller tracks a loaded + * flag per source, and something has to clear them or the first fill wins the + * placement forever. + * + * One pass = one ad opportunity: CloudX asked first, AdMob only if CloudX + * fails, winner displayed. Putting the winner on screen spends the pass, + * because a load into an already-visible view renders immediately - so "on + * screen" is the one moment this code can treat as "this fill has been + * used". ShowSource therefore clears both flags and raises PassSpent, and the + * host schedules the next Load() one cooldown later + * (FirstLookConfig.PassCooldownSeconds), which starts at CloudX again. An + * immediate reload would be a request loop, since the new fill would render + * and spend the pass at once. + * + * The host owns the other half of that contract: it must cancel the pending + * pass when it calls Hide(), or a hidden slot keeps requesting. See + * FirstLookScreen.ToggleBanner and ScheduleNextPass. + * + * 2. AUTO-REFRESH STAYS OFF. CloudX banner auto-refresh is opt-out: showing a + * banner starts it automatically unless the ad unit was first passed to + * StopBannerAutoRefresh, which also gates LoadBanner. CloudXCreateAndLoad + * below therefore calls it before create and nothing here ever calls + * StartBannerAutoRefresh - the pass cycle owns reloading, so an SDK refresh + * timer would compete with it and could swap the ad out from under the First + * Look source decision. (GeneralScreen restarts refresh on focus; First Look + * deliberately does not.) + * + * AdMob is the half this code cannot control: the Google Mobile Ads Unity + * plugin has no refresh API at all. Whether a BannerView refreshes is decided + * solely by the ad unit's Automatic refresh setting in the AdMob console, and + * publishers MUST set that to Disabled on every unit used as a First Look + * fallback. Google's test units do refresh, so this controller ignores a fill + * it did not ask for when counting passes - see OnAdMobLoaded. */ -public sealed class FirstLookBannerController : FirstLookInlineController +public sealed class FirstLookBannerController : IDisposable { private const CloudXAdViewConfiguration.AdViewPosition CloudXPosition = CloudXAdViewConfiguration.AdViewPosition.TopCenter; + public event Action AdLoaded; + public event Action AdLoadFailed; + public event Action AdShown; + public event Action AdClicked; + + /* + * Raised when a display spent a First Look pass, i.e. it showed a fill this + * controller asked for. The host uses it to time the next pass. It is + * deliberately not raised for an ad AdMob refreshed on its own schedule: + * that is outside the cycle, and letting it re-arm the cooldown would push + * CloudX's next first look back every time - forever, if AdMob's refresh + * interval is shorter than the cooldown. + */ + public event Action PassSpent; + + private readonly string _cloudXAdUnitId; + private readonly string _adMobAdUnitId; + + /* + * When CloudX initialization failed, its load callbacks may never fire, so + * the controller skips the CloudX leg and goes straight to the fallback. + */ + private readonly bool _cloudXAvailable; + private BannerView _adMobBanner; + /* An unspent fill, per source. Cleared when one goes on screen. */ + private bool _cloudXLoaded; + private bool _adMobLoaded; + + /* Whether each native view exists yet: first pass creates, later ones reload. */ + private bool _cloudXCreated; + private bool _adMobCreated; + + private bool _isLoadingCloudX; + private bool _isLoadingAdMob; + private bool _wantShown; + private bool _isShown; + private bool _isDisposed; + + /* + * The source whose native view currently holds a creative. Unlike the loaded + * flags it survives a show, so Hide() followed by Show() puts the same ad + * back up instead of leaving the slot blank until the next pass fills. + */ + private FirstLookSource? _shownSource; + public FirstLookBannerController( string cloudXAdUnitId, string adMobAdUnitId, bool cloudXAvailable) - : base(cloudXAdUnitId, adMobAdUnitId, cloudXAvailable) { - SubscribeCloudXCallbacks(); - } + _cloudXAdUnitId = cloudXAdUnitId; + _adMobAdUnitId = adMobAdUnitId; + _cloudXAvailable = cloudXAvailable; - protected override void SubscribeCloudXCallbacks() - { CloudXAdsCallbacks.Banner.OnAdLoadSuccess += CloudXOnLoadSuccess; CloudXAdsCallbacks.Banner.OnAdLoadFailed += CloudXOnLoadFailed; CloudXAdsCallbacks.Banner.OnAdClicked += CloudXOnClicked; } - protected override void UnsubscribeCloudXCallbacks() + public bool IsShown => _isShown; + + /* The source of an unspent fill; null once the current pass was displayed. */ + public FirstLookSource? ReadySource + { + get + { + if (_isDisposed) + { + return null; + } + + if (_cloudXAvailable && _cloudXLoaded) + { + return FirstLookSource.CloudX; + } + + if (_adMobLoaded) + { + return FirstLookSource.AdMob; + } + + return null; + } + } + + /* + * Starts a pass by asking CloudX, or resumes the one already running. The + * AdMob fallback is loaded only if this CloudX load fails, from + * CloudXOnLoadFailed. The first CloudX load has to create the view; later + * passes reload the existing one, which swaps the creative in place with no + * gap under a visible banner. + */ + public void Load() + { + if (_isDisposed || _isLoadingCloudX || _isLoadingAdMob || ReadySource != null) + { + return; + } + + if (!_cloudXAvailable) + { + LoadAdMobFallback(); + return; + } + + _isLoadingCloudX = true; + + if (_cloudXCreated) + { + /* Permitted because StopBannerAutoRefresh already ran for this unit. */ + CloudXSdk.LoadBanner(_cloudXAdUnitId); + return; + } + + _cloudXCreated = true; + CloudXCreateAndLoad(); + } + + /* + * Shows the ready source now, or remembers the intent so the next load to + * complete shows itself. Returns whether an ad was on screen immediately. + */ + public bool Show() { + if (_isDisposed) + { + return false; + } + + _wantShown = true; + + /* + * An unspent fill wins; otherwise re-show whatever is already in a + * native view (the Hide-then-Show case). + */ + var source = ReadySource ?? _shownSource; + if (source == null) + { + return false; + } + + ShowSource(source.Value, spendsPass: true); + return true; + } + + public void Hide() + { + if (_isDisposed) + { + return; + } + + _wantShown = false; + _isShown = false; + + HideCloudX(); + HideAdMob(); + } + + public void Dispose() + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + CloudXAdsCallbacks.Banner.OnAdLoadSuccess -= CloudXOnLoadSuccess; CloudXAdsCallbacks.Banner.OnAdLoadFailed -= CloudXOnLoadFailed; CloudXAdsCallbacks.Banner.OnAdClicked -= CloudXOnClicked; + + /* Leave the CloudX SDK alone when its init failed. */ + if (_cloudXAvailable) + { + CloudXSdk.DestroyBanner(_cloudXAdUnitId); + } + + DestroyAdMobAd(); + } + + /* + * The pass cycle + */ + + private void ShowSource(FirstLookSource source, bool spendsPass) + { + if (source == FirstLookSource.CloudX) + { + CloudXSdk.ShowBanner(_cloudXAdUnitId); + HideAdMob(); + } + else + { + _adMobBanner.Show(); + HideCloudX(); + } + + _isShown = true; + _shownSource = source; + + /* + * The fill is on screen, so it is no longer available to show. Both + * flags clear, not just the winner's: the loser's fill is from the pass + * that just ended, and leaving it set would let it win the next Show() + * without CloudX having been asked again. + */ + _cloudXLoaded = false; + _adMobLoaded = false; + + AdShown?.Invoke(source); + + if (spendsPass) + { + PassSpent?.Invoke(); + } + } + + private void ShowIfWanted(FirstLookSource source, bool spendsPass) + { + if (!_wantShown) + { + return; + } + + /* + * A fill from a pass legitimately replaces the ad the previous pass put + * up, so there is no _isShown check. A fill that is not part of a pass + * is different: letting AdMob's own refresh take the slot from CloudX + * would undo the source decision this pass made, so it only re-shows the + * source that is already up. + */ + if (!spendsPass && _shownSource != null && _shownSource != source) + { + return; + } + + ShowSource(source, spendsPass); + } + + private void HideCloudX() + { + if (_cloudXAvailable && _cloudXCreated) + { + CloudXSdk.HideBanner(_cloudXAdUnitId); + } + } + + private void HideAdMob() + { + if (_adMobCreated) + { + _adMobBanner?.Hide(); + } } - protected override void CloudXCreateAndLoad() + /* + * CloudX side + */ + + private void CloudXCreateAndLoad() { - CloudXSdk.DestroyBanner(CloudXAdUnitId); + CloudXSdk.DestroyBanner(_cloudXAdUnitId); /* - * Required, not optional: CloudX banner auto-refresh is opt-out, so - * without this the first ShowBanner would start a background reload that - * could swap the ad out from under the First Look source decision. It - * goes before CreateBanner: the native layer registers the ad unit as - * refresh-disabled even with no view yet, then creates the view with - * refresh already off, so no timer ever runs. (Destroy clears that - * registration, hence this order.) + * Required, not optional, and it must come before CreateBanner: the + * native layer registers the ad unit as refresh-disabled even with no + * view yet, then creates the view with refresh already off, so no timer + * ever runs. (Destroy clears that registration, hence this order.) */ - CloudXSdk.StopBannerAutoRefresh(CloudXAdUnitId); + CloudXSdk.StopBannerAutoRefresh(_cloudXAdUnitId); /* * Placement and custom data must be set before CreateBanner so they are @@ -59,45 +335,135 @@ protected override void CloudXCreateAndLoad() * OnAdLoadSuccess / OnAdLoadFailed callbacks that drive the source and * the fallback come from here - no separate LoadBanner call. */ - CloudXSdk.SetBannerPlacement(CloudXAdUnitId, "first_look_screen"); - CloudXSdk.SetBannerCustomData(CloudXAdUnitId, "first_look_banner_data"); - CloudXSdk.CreateBanner(CloudXAdUnitId, new CloudXAdViewConfiguration(CloudXPosition)); + CloudXSdk.SetBannerPlacement(_cloudXAdUnitId, "first_look_screen"); + CloudXSdk.SetBannerCustomData(_cloudXAdUnitId, "first_look_banner_data"); + CloudXSdk.CreateBanner(_cloudXAdUnitId, new CloudXAdViewConfiguration(CloudXPosition)); } - protected override void CloudXShow() => CloudXSdk.ShowBanner(CloudXAdUnitId); - protected override void CloudXHide() => CloudXSdk.HideBanner(CloudXAdUnitId); - protected override void DestroyCloudXAd() => CloudXSdk.DestroyBanner(CloudXAdUnitId); - - protected override void AdMobCreateAndLoad() + private void CloudXOnLoadSuccess(CloudXAd ad) { - DestroyAdMobAd(); + if (ad.AdUnitId != _cloudXAdUnitId) + { + return; + } /* - * A BannerView loads once; there is no refresh API to turn off here. Its - * refresh is the ad unit's Automatic refresh setting in the AdMob console, - * which MUST be Disabled for this unit - otherwise AdMob replaces the ad - * on its own schedule behind First Look's back. Google Mobile Ads raises - * its callbacks off the Unity main thread; ExecuteInUpdate moves them - * back on. + * Only a load this controller issued sets _isLoadingCloudX, so it tells + * a pass result apart from an unsolicited reload. CloudX auto-refresh is + * off, so in practice this is always true; the check keeps the two + * sources reading the same way. */ - _adMobBanner = new BannerView(AdMobAdUnitId, AdSize.Banner, AdPosition.Top); + var spendsPass = _isLoadingCloudX; + + _isLoadingCloudX = false; + _cloudXLoaded = true; + AdLoaded?.Invoke(FirstLookSource.CloudX); + ShowIfWanted(FirstLookSource.CloudX, spendsPass); + } + + private void CloudXOnLoadFailed(string adUnitId, CloudXError _) + { + if (adUnitId != _cloudXAdUnitId) + { + return; + } + + /* The one place the fallback is triggered: CloudX had its first look. */ + _isLoadingCloudX = false; + LoadAdMobFallback(); + } + + private void CloudXOnClicked(CloudXAd ad) + { + if (ad.AdUnitId == _cloudXAdUnitId) + { + AdClicked?.Invoke(FirstLookSource.CloudX); + } + } + + /* + * AdMob side. Google Mobile Ads raises its callbacks off the Unity main + * thread, so every body goes through ExecuteInUpdate: controller state and + * the events subscribers use for UI then both stay on one thread, like the + * CloudX callbacks. Each body checks _isDisposed first, because a callback + * queued before Dispose still arrives afterwards. + */ + + private void LoadAdMobFallback() + { + if (_isDisposed || _isLoadingAdMob || _adMobLoaded) + { + return; + } + + _isLoadingAdMob = true; + + if (_adMobCreated) + { + _adMobBanner.LoadAd(new AdRequest()); + return; + } + + _adMobCreated = true; + AdMobCreateAndLoad(); + } + + private void AdMobCreateAndLoad() + { + DestroyAdMobAd(); + + _adMobBanner = new BannerView(_adMobAdUnitId, AdSize.Banner, AdPosition.Top); _adMobBanner.OnBannerAdLoaded += () => MobileAdsEventExecutor.ExecuteInUpdate(OnAdMobLoaded); _adMobBanner.OnBannerAdLoadFailed += error => MobileAdsEventExecutor.ExecuteInUpdate(() => - OnAdMobLoadFailed(error.GetMessage())); + { + _isLoadingAdMob = false; + + if (!_isDisposed) + { + AdLoadFailed?.Invoke(FirstLookSource.AdMob, error.GetMessage()); + } + }); - _adMobBanner.OnAdClicked += () => MobileAdsEventExecutor.ExecuteInUpdate(OnAdMobClicked); + _adMobBanner.OnAdClicked += () => MobileAdsEventExecutor.ExecuteInUpdate(() => + { + if (!_isDisposed) + { + AdClicked?.Invoke(FirstLookSource.AdMob); + } + }); /* Created hidden; Show()/Hide() drive visibility. */ _adMobBanner.Hide(); _adMobBanner.LoadAd(new AdRequest()); } - protected override void AdMobShow() => _adMobBanner.Show(); - protected override void AdMobHide() => _adMobBanner?.Hide(); + private void OnAdMobLoaded() + { + /* + * A load this controller issued sets _isLoadingAdMob first, so a fill + * arriving without it is one the AdMob console's Automatic refresh + * produced. It still goes on screen - AdMob has already rendered it - + * but it does not count as a pass, so the pending pass keeps its + * original schedule. + */ + var spendsPass = _isLoadingAdMob; + + _isLoadingAdMob = false; + + if (_isDisposed) + { + DestroyAdMobAd(); + return; + } + + _adMobLoaded = true; + AdLoaded?.Invoke(FirstLookSource.AdMob); + ShowIfWanted(FirstLookSource.AdMob, spendsPass); + } - protected override void DestroyAdMobAd() + private void DestroyAdMobAd() { _adMobBanner?.Destroy(); _adMobBanner = null; diff --git a/Assets/Scripts/FirstLook/FirstLookConfig.cs b/Assets/Scripts/FirstLook/FirstLookConfig.cs index 178e074..243d6d9 100644 --- a/Assets/Scripts/FirstLook/FirstLookConfig.cs +++ b/Assets/Scripts/FirstLook/FirstLookConfig.cs @@ -3,27 +3,29 @@ * come from DemoConfig; these are Google's official AdMob TEST ad unit ids. * Replace them with your own AdMob ad units in a real integration. * - * When you do, set Automatic refresh to Disabled on the banner and MREC units - * in the AdMob console. The Unity plugin cannot control it, and a refreshing - * AdMob banner would replace the ad that won the First Look pass. + * When you do, set Automatic refresh to Disabled on the banner unit in the + * AdMob console. The Unity plugin cannot control it, and a refreshing AdMob + * banner would replace the ad that won the First Look pass. */ public static class FirstLookConfig { #if UNITY_IOS public const string AdMobInterstitialAdUnitId = "ca-app-pub-3940256099942544/4411468910"; - public const string AdMobRewardedAdUnitId = "ca-app-pub-3940256099942544/1712485313"; public const string AdMobBannerAdUnitId = "ca-app-pub-3940256099942544/2934735716"; #else public const string AdMobInterstitialAdUnitId = "ca-app-pub-3940256099942544/1033173712"; - public const string AdMobRewardedAdUnitId = "ca-app-pub-3940256099942544/5224354917"; public const string AdMobBannerAdUnitId = "ca-app-pub-3940256099942544/6300978111"; #endif /* - * Google has no dedicated MREC test unit; its banner test unit returns a - * test ad at whatever AdSize is requested, so it serves the 300x250 MREC too. + * How long a displayed banner stays up before the next First Look pass + * starts. Displaying an ad spends the pass (see FirstLookBannerController), + * and a fill into a visible view renders immediately, so reloading without + * a cooldown would be a request loop. Treat it like a banner refresh + * interval - 30s matches the usual default; anything very short both burns + * requests and hurts CPM. */ - public const string AdMobMrecAdUnitId = AdMobBannerAdUnitId; + public const float PassCooldownSeconds = 30f; /* * Flip to true to exercise the AdMob fallback path: CloudX is asked to fill diff --git a/Assets/Scripts/FirstLook/FirstLookFullscreenController.cs b/Assets/Scripts/FirstLook/FirstLookFullscreenController.cs deleted file mode 100644 index 36d42c6..0000000 --- a/Assets/Scripts/FirstLook/FirstLookFullscreenController.cs +++ /dev/null @@ -1,182 +0,0 @@ -using System; -using CloudX; - -/* - * Fullscreen First Look controllers (interstitial, rewarded). CloudX gets the - * first chance to fill; AdMob loads lazily as the fallback only after CloudX - * fails to load. Show() shows CloudX if it is ready, otherwise AdMob, and - * returns false when neither source has an ad - the caller just carries on with - * the game. Mirrors docs.cloudx.io -> Integrations -> First Look. - * - * The CloudX callback handlers below filter by ad unit id and drive the shared - * state; the concrete subclass only routes the right CloudXAdsCallbacks group - * into them (SubscribeCloudXCallbacks) and supplies the format's SDK calls. - */ -public abstract class FirstLookFullscreenController : FirstLookAdController -{ - public event Action AdShown; - public event Action AdShowFailed; - public event Action AdClosed; - - protected FirstLookFullscreenController( - string cloudXAdUnitId, - string adMobAdUnitId, - bool cloudXAvailable) - : base(cloudXAdUnitId, adMobAdUnitId, cloudXAvailable) - { - } - - public override FirstLookSource? ReadySource - { - get - { - if (IsDisposed) - { - return null; - } - - if (CloudXAvailable && CloudXIsReady()) - { - return FirstLookSource.CloudX; - } - - if (AdMobCanShow()) - { - return FirstLookSource.AdMob; - } - - return null; - } - } - - public override void Load() - { - if (IsDisposed || IsLoadingCloudX || IsLoadingAdMob || ReadySource != null) - { - return; - } - - if (!CloudXAvailable) - { - LoadAdMobFallback(); - return; - } - - IsLoadingCloudX = true; - CloudXLoad(); - } - - public bool Show() - { - if (IsDisposed) - { - return false; - } - - if (CloudXAvailable && CloudXIsReady()) - { - CloudXShow(); - return true; - } - - return ShowAdMobFallback(); - } - - protected void LoadAdMobFallback() - { - if (IsDisposed || IsLoadingAdMob || AdMobCanShow()) - { - return; - } - - IsLoadingAdMob = true; - DestroyAdMobAd(); - AdMobLoad(); - } - - protected bool ShowAdMobFallback() - { - if (!AdMobCanShow()) - { - return false; - } - - AdMobShow(); - return true; - } - - protected void RaiseAdShown(FirstLookSource source) => AdShown?.Invoke(source); - protected void RaiseAdShowFailed(FirstLookSource source, string message) => AdShowFailed?.Invoke(source, message); - protected void RaiseAdClosed(FirstLookSource source) => AdClosed?.Invoke(source); - - /* - * CloudX callback handlers, shared by both fullscreen formats. The concrete - * subscribes the matching CloudXAdsCallbacks group to these. - */ - protected void CloudXOnLoadSuccess(CloudXAd ad) - { - if (ad.AdUnitId != CloudXAdUnitId) - { - return; - } - - IsLoadingCloudX = false; - RaiseAdLoaded(FirstLookSource.CloudX); - } - - protected void CloudXOnLoadFailed(string adUnitId, CloudXError _) - { - if (adUnitId != CloudXAdUnitId) - { - return; - } - - IsLoadingCloudX = false; - LoadAdMobFallback(); - } - - protected void CloudXOnShowSuccess(CloudXAd ad) - { - if (ad.AdUnitId == CloudXAdUnitId) - { - RaiseAdShown(FirstLookSource.CloudX); - } - } - - protected void CloudXOnShowFailed(CloudXAd ad, CloudXError error) - { - if (ad.AdUnitId != CloudXAdUnitId) - { - return; - } - - if (!ShowAdMobFallback()) - { - RaiseAdShowFailed(FirstLookSource.CloudX, error.Message); - } - } - - protected void CloudXOnHidden(CloudXAd ad) - { - if (ad.AdUnitId == CloudXAdUnitId) - { - RaiseAdClosed(FirstLookSource.CloudX); - } - } - - protected void CloudXOnClicked(CloudXAd ad) - { - if (ad.AdUnitId == CloudXAdUnitId) - { - RaiseAdClicked(FirstLookSource.CloudX); - } - } - - /* Format-specific SDK calls. */ - protected abstract bool CloudXIsReady(); - protected abstract void CloudXLoad(); - protected abstract void CloudXShow(); - protected abstract bool AdMobCanShow(); - protected abstract void AdMobLoad(); - protected abstract void AdMobShow(); -} diff --git a/Assets/Scripts/FirstLook/FirstLookFullscreenController.cs.meta b/Assets/Scripts/FirstLook/FirstLookFullscreenController.cs.meta deleted file mode 100644 index c0ea13b..0000000 --- a/Assets/Scripts/FirstLook/FirstLookFullscreenController.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 4bf4974b1edec45158707f546bfba163 \ No newline at end of file diff --git a/Assets/Scripts/FirstLook/FirstLookInlineController.cs b/Assets/Scripts/FirstLook/FirstLookInlineController.cs deleted file mode 100644 index 65e1f2a..0000000 --- a/Assets/Scripts/FirstLook/FirstLookInlineController.cs +++ /dev/null @@ -1,251 +0,0 @@ -using System; -using CloudX; - -/* - * Inline First Look controllers (banner, MREC). Same rule as the fullscreen - * ones - CloudX first, AdMob as the lazy fallback - but inline ads stay on - * screen instead of being shown once, so this base exposes Show()/Hide() and - * tracks which source is up. CloudX inline ads report only load and click (no - * show/close callbacks), so readiness is tracked with a loaded flag per source. - * - * Auto-refresh is deliberately kept OFF. CloudX banner/MREC auto-refresh is - * opt-out: showing an inline ad starts it automatically unless the ad unit was - * first passed to Stop*AutoRefresh, which also gates Load*. The concrete's - * CloudXCreateAndLoad therefore calls Stop*AutoRefresh before create, and - * nothing here ever calls Start*AutoRefresh - so a background reload never - * overrides the First Look source decision. (GeneralScreen restarts refresh on - * focus; First Look intentionally does not.) - * - * AdMob is the half this code cannot handle: the Google Mobile Ads Unity plugin - * has no refresh API at all. A BannerView loads once, and whether it refreshes - * afterwards is decided solely by the ad unit's Automatic refresh setting in - * the AdMob console. Publishers MUST set that to Disabled on every banner and - * MREC unit used as a First Look fallback; otherwise AdMob swaps the creative - * on its own schedule and silently replaces the ad that won the First Look - * pass. - */ -public abstract class FirstLookInlineController : FirstLookAdController -{ - public event Action AdShown; - - private bool _cloudXLoaded; - private bool _adMobLoaded; - private bool _wantShown; - private bool _isShown; - - protected FirstLookInlineController( - string cloudXAdUnitId, - string adMobAdUnitId, - bool cloudXAvailable) - : base(cloudXAdUnitId, adMobAdUnitId, cloudXAvailable) - { - } - - public bool IsShown => _isShown; - - public override FirstLookSource? ReadySource - { - get - { - if (IsDisposed) - { - return null; - } - - if (CloudXAvailable && _cloudXLoaded) - { - return FirstLookSource.CloudX; - } - - if (_adMobLoaded) - { - return FirstLookSource.AdMob; - } - - return null; - } - } - - public override void Load() - { - if (IsDisposed || IsLoadingCloudX || IsLoadingAdMob || ReadySource != null) - { - return; - } - - if (!CloudXAvailable) - { - LoadAdMobFallback(); - return; - } - - IsLoadingCloudX = true; - CloudXCreateAndLoad(); - } - - /* - * Shows the ready source now, or remembers the intent so the next load to - * complete shows itself. Returns whether an ad was on screen immediately. - */ - public bool Show() - { - if (IsDisposed) - { - return false; - } - - _wantShown = true; - - var source = ReadySource; - if (source == null) - { - return false; - } - - ShowSource(source.Value); - return true; - } - - public void Hide() - { - if (IsDisposed) - { - return; - } - - _wantShown = false; - _isShown = false; - - /* Like Dispose: leave the CloudX SDK alone when its init failed. */ - if (CloudXAvailable) - { - CloudXHide(); - } - - AdMobHide(); - } - - protected void LoadAdMobFallback() - { - if (IsDisposed || IsLoadingAdMob || _adMobLoaded) - { - return; - } - - IsLoadingAdMob = true; - AdMobCreateAndLoad(); - } - - private void ShowSource(FirstLookSource source) - { - if (source == FirstLookSource.CloudX) - { - CloudXShow(); - } - else - { - AdMobShow(); - } - - _isShown = true; - AdShown?.Invoke(source); - } - - private void ShowIfWanted(FirstLookSource source) - { - if (_wantShown && !_isShown) - { - ShowSource(source); - } - } - - /* - * CloudX callback handlers, shared by both inline formats. The concrete - * subscribes the matching CloudXAdsCallbacks group to these. - */ - protected void CloudXOnLoadSuccess(CloudXAd ad) - { - if (ad.AdUnitId != CloudXAdUnitId) - { - return; - } - - IsLoadingCloudX = false; - _cloudXLoaded = true; - RaiseAdLoaded(FirstLookSource.CloudX); - ShowIfWanted(FirstLookSource.CloudX); - } - - protected void CloudXOnLoadFailed(string adUnitId, CloudXError _) - { - if (adUnitId != CloudXAdUnitId) - { - return; - } - - IsLoadingCloudX = false; - LoadAdMobFallback(); - } - - protected void CloudXOnClicked(CloudXAd ad) - { - if (ad.AdUnitId == CloudXAdUnitId) - { - RaiseAdClicked(FirstLookSource.CloudX); - } - } - - /* - * AdMob results, reported by the concrete on the Unity main thread. These - * can arrive after Dispose (the callback was already queued), so they check - * IsDisposed first and never raise into a screen that is gone - the same - * order the fullscreen AdMob load callbacks use. - */ - protected void OnAdMobLoaded() - { - IsLoadingAdMob = false; - - if (IsDisposed) - { - DestroyAdMobAd(); - return; - } - - _adMobLoaded = true; - RaiseAdLoaded(FirstLookSource.AdMob); - ShowIfWanted(FirstLookSource.AdMob); - } - - protected void OnAdMobLoadFailed(string message) - { - IsLoadingAdMob = false; - - if (IsDisposed) - { - return; - } - - RaiseAdLoadFailed(FirstLookSource.AdMob, message); - } - - protected void OnAdMobClicked() - { - if (!IsDisposed) - { - RaiseAdClicked(FirstLookSource.AdMob); - } - } - - /* - * Format-specific SDK calls. CloudXCreateAndLoad must Stop*AutoRefresh (see - * the class note), set placement/custom data, then create the view - it must - * not Start*AutoRefresh. Recreate cleanly so a retry after a failure does - * not leave a stale native view. - */ - protected abstract void CloudXCreateAndLoad(); - protected abstract void CloudXShow(); - protected abstract void CloudXHide(); - protected abstract void AdMobCreateAndLoad(); - protected abstract void AdMobShow(); - protected abstract void AdMobHide(); -} diff --git a/Assets/Scripts/FirstLook/FirstLookInlineController.cs.meta b/Assets/Scripts/FirstLook/FirstLookInlineController.cs.meta deleted file mode 100644 index 1723018..0000000 --- a/Assets/Scripts/FirstLook/FirstLookInlineController.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 40528659705d945cf976cd972a2b93d9 \ No newline at end of file diff --git a/Assets/Scripts/FirstLook/FirstLookInterstitialController.cs b/Assets/Scripts/FirstLook/FirstLookInterstitialController.cs index a41163d..54faa48 100644 --- a/Assets/Scripts/FirstLook/FirstLookInterstitialController.cs +++ b/Assets/Scripts/FirstLook/FirstLookInterstitialController.cs @@ -1,26 +1,61 @@ +using System; using CloudX; using GoogleMobileAds.Api; using GoogleMobileAds.Common; /* - * First Look interstitial. Shared flow lives in FirstLookFullscreenController; - * this class only supplies the interstitial SDK calls for each side. + * First Look interstitial: CloudX gets the first chance to fill, AdMob loads + * lazily as the fallback only after CloudX fails. Show() shows CloudX if it is + * ready, otherwise AdMob, and returns false when neither has an ad - the caller + * just carries on with the game. Mirrors docs.cloudx.io -> Integrations -> + * First Look. + * + * This file is the whole flow, top to bottom, so it can be copied into an app + * on its own (plus FirstLookSource.cs for the enum). Reading order: state, the + * Load/Show entry points, then each SDK's callbacks. FirstLookBannerController + * repeats the same ~50 lines of bookkeeping for the inline case on purpose - + * each file stays a self-contained example rather than the two of them sharing + * a base a publisher would also have to copy. + * + * Fullscreen ads are consumed by being shown, so readiness is asked of the SDKs + * directly (CloudXSdk.IsInterstitialReady / InterstitialAd.CanShowAd) rather + * than cached. Showing therefore makes both answers false on their own, and the + * next Load() starts at CloudX again. The inline formats have no such + * consumption event, which is why FirstLookBannerController needs an explicit + * pass cycle. */ -public sealed class FirstLookInterstitialController : FirstLookFullscreenController +public sealed class FirstLookInterstitialController : IDisposable { + public event Action AdLoaded; + public event Action AdLoadFailed; + public event Action AdShown; + public event Action AdShowFailed; + public event Action AdClosed; + public event Action AdClicked; + + private readonly string _cloudXAdUnitId; + private readonly string _adMobAdUnitId; + + /* + * When CloudX initialization failed, its load callbacks may never fire, so + * the controller skips the CloudX leg and goes straight to the fallback. + */ + private readonly bool _cloudXAvailable; + private InterstitialAd _adMobInterstitial; + private bool _isLoadingCloudX; + private bool _isLoadingAdMob; + private bool _isDisposed; public FirstLookInterstitialController( string cloudXAdUnitId, string adMobAdUnitId, bool cloudXAvailable) - : base(cloudXAdUnitId, adMobAdUnitId, cloudXAvailable) { - SubscribeCloudXCallbacks(); - } + _cloudXAdUnitId = cloudXAdUnitId; + _adMobAdUnitId = adMobAdUnitId; + _cloudXAvailable = cloudXAvailable; - protected override void SubscribeCloudXCallbacks() - { CloudXAdsCallbacks.Interstitial.OnAdLoadSuccess += CloudXOnLoadSuccess; CloudXAdsCallbacks.Interstitial.OnAdLoadFailed += CloudXOnLoadFailed; CloudXAdsCallbacks.Interstitial.OnAdShowSuccess += CloudXOnShowSuccess; @@ -29,39 +64,187 @@ protected override void SubscribeCloudXCallbacks() CloudXAdsCallbacks.Interstitial.OnAdClicked += CloudXOnClicked; } - protected override void UnsubscribeCloudXCallbacks() + /* The source a show right now would use; null when no ad is ready. */ + public FirstLookSource? ReadySource + { + get + { + if (_isDisposed) + { + return null; + } + + if (_cloudXAvailable && CloudXSdk.IsInterstitialReady(_cloudXAdUnitId)) + { + return FirstLookSource.CloudX; + } + + if (AdMobCanShow()) + { + return FirstLookSource.AdMob; + } + + return null; + } + } + + /* + * Asks CloudX first. The AdMob fallback is not loaded here - it is loaded + * only if this CloudX load fails, from CloudXOnLoadFailed, so it costs + * nothing when CloudX fills. + */ + public void Load() + { + if (_isDisposed || _isLoadingCloudX || _isLoadingAdMob || ReadySource != null) + { + return; + } + + if (!_cloudXAvailable) + { + LoadAdMobFallback(); + return; + } + + _isLoadingCloudX = true; + CloudXSdk.LoadInterstitial(_cloudXAdUnitId); + } + + /* Returns whether an ad was shown. False means the game just carries on. */ + public bool Show() { + if (_isDisposed) + { + return false; + } + + if (_cloudXAvailable && CloudXSdk.IsInterstitialReady(_cloudXAdUnitId)) + { + CloudXSdk.ShowInterstitial(_cloudXAdUnitId); + return true; + } + + return ShowAdMobFallback(); + } + + public void Dispose() + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + CloudXAdsCallbacks.Interstitial.OnAdLoadSuccess -= CloudXOnLoadSuccess; CloudXAdsCallbacks.Interstitial.OnAdLoadFailed -= CloudXOnLoadFailed; CloudXAdsCallbacks.Interstitial.OnAdShowSuccess -= CloudXOnShowSuccess; CloudXAdsCallbacks.Interstitial.OnAdShowFailed -= CloudXOnShowFailed; CloudXAdsCallbacks.Interstitial.OnAdHidden -= CloudXOnHidden; CloudXAdsCallbacks.Interstitial.OnAdClicked -= CloudXOnClicked; + + /* Leave the CloudX SDK alone when its init failed. */ + if (_cloudXAvailable) + { + CloudXSdk.DestroyInterstitial(_cloudXAdUnitId); + } + + DestroyAdMobAd(); } - protected override bool CloudXIsReady() => CloudXSdk.IsInterstitialReady(CloudXAdUnitId); - protected override void CloudXLoad() => CloudXSdk.LoadInterstitial(CloudXAdUnitId); - protected override void CloudXShow() => CloudXSdk.ShowInterstitial(CloudXAdUnitId); - protected override void DestroyCloudXAd() => CloudXSdk.DestroyInterstitial(CloudXAdUnitId); + /* + * CloudX side + */ + + private void CloudXOnLoadSuccess(CloudXAd ad) + { + if (ad.AdUnitId != _cloudXAdUnitId) + { + return; + } - protected override bool AdMobCanShow() => _adMobInterstitial != null && _adMobInterstitial.CanShowAd(); + _isLoadingCloudX = false; + AdLoaded?.Invoke(FirstLookSource.CloudX); + } + + private void CloudXOnLoadFailed(string adUnitId, CloudXError _) + { + if (adUnitId != _cloudXAdUnitId) + { + return; + } + + /* The one place the fallback is triggered: CloudX had its first look. */ + _isLoadingCloudX = false; + LoadAdMobFallback(); + } - protected override void AdMobLoad() + private void CloudXOnShowSuccess(CloudXAd ad) { - /* - * Google Mobile Ads raises its callbacks off the Unity main thread. - * ExecuteInUpdate moves the whole body onto it, so controller state and - * the events subscribers use for UI both stay on one thread, like the - * CloudX callbacks. - */ + if (ad.AdUnitId == _cloudXAdUnitId) + { + AdShown?.Invoke(FirstLookSource.CloudX); + } + } + + private void CloudXOnShowFailed(CloudXAd ad, CloudXError error) + { + if (ad.AdUnitId != _cloudXAdUnitId) + { + return; + } + + /* A CloudX ad that fails to show still leaves the placement to fill. */ + if (!ShowAdMobFallback()) + { + AdShowFailed?.Invoke(FirstLookSource.CloudX, error.Message); + } + } + + private void CloudXOnHidden(CloudXAd ad) + { + if (ad.AdUnitId == _cloudXAdUnitId) + { + AdClosed?.Invoke(FirstLookSource.CloudX); + } + } + + private void CloudXOnClicked(CloudXAd ad) + { + if (ad.AdUnitId == _cloudXAdUnitId) + { + AdClicked?.Invoke(FirstLookSource.CloudX); + } + } + + /* + * AdMob side. Google Mobile Ads raises its callbacks off the Unity main + * thread, so every body goes through ExecuteInUpdate: controller state and + * the events subscribers use for UI then both stay on one thread, like the + * CloudX callbacks. Each body checks _isDisposed first, because a callback + * queued before Dispose still arrives afterwards. + */ + + private bool AdMobCanShow() => _adMobInterstitial != null && _adMobInterstitial.CanShowAd(); + + private void LoadAdMobFallback() + { + if (_isDisposed || _isLoadingAdMob || AdMobCanShow()) + { + return; + } + + _isLoadingAdMob = true; + DestroyAdMobAd(); + InterstitialAd.Load( - AdMobAdUnitId, + _adMobAdUnitId, new AdRequest(), (ad, error) => MobileAdsEventExecutor.ExecuteInUpdate(() => { - IsLoadingAdMob = false; + _isLoadingAdMob = false; - if (IsDisposed) + if (_isDisposed) { ad?.Destroy(); return; @@ -69,7 +252,7 @@ protected override void AdMobLoad() if (error != null || ad == null) { - RaiseAdLoadFailed( + AdLoadFailed?.Invoke( FirstLookSource.AdMob, error?.GetMessage() ?? "AdMob returned no ad"); return; @@ -77,36 +260,63 @@ protected override void AdMobLoad() _adMobInterstitial = ad; RegisterAdMobEvents(ad); - RaiseAdLoaded(FirstLookSource.AdMob); + AdLoaded?.Invoke(FirstLookSource.AdMob); })); } - protected override void AdMobShow() => _adMobInterstitial.Show(); - - protected override void DestroyAdMobAd() + private bool ShowAdMobFallback() { - _adMobInterstitial?.Destroy(); - _adMobInterstitial = null; + if (!AdMobCanShow()) + { + return false; + } + + _adMobInterstitial.Show(); + return true; } private void RegisterAdMobEvents(InterstitialAd ad) { ad.OnAdFullScreenContentOpened += () => MobileAdsEventExecutor.ExecuteInUpdate(() => - RaiseAdShown(FirstLookSource.AdMob)); + { + if (!_isDisposed) + { + AdShown?.Invoke(FirstLookSource.AdMob); + } + }); ad.OnAdFullScreenContentClosed += () => MobileAdsEventExecutor.ExecuteInUpdate(() => { DestroyAdMobAd(); - RaiseAdClosed(FirstLookSource.AdMob); + + if (!_isDisposed) + { + AdClosed?.Invoke(FirstLookSource.AdMob); + } }); ad.OnAdFullScreenContentFailed += error => MobileAdsEventExecutor.ExecuteInUpdate(() => { DestroyAdMobAd(); - RaiseAdShowFailed(FirstLookSource.AdMob, error.GetMessage()); + + if (!_isDisposed) + { + AdShowFailed?.Invoke(FirstLookSource.AdMob, error.GetMessage()); + } }); ad.OnAdClicked += () => MobileAdsEventExecutor.ExecuteInUpdate(() => - RaiseAdClicked(FirstLookSource.AdMob)); + { + if (!_isDisposed) + { + AdClicked?.Invoke(FirstLookSource.AdMob); + } + }); + } + + private void DestroyAdMobAd() + { + _adMobInterstitial?.Destroy(); + _adMobInterstitial = null; } } diff --git a/Assets/Scripts/FirstLook/FirstLookMrecController.cs b/Assets/Scripts/FirstLook/FirstLookMrecController.cs deleted file mode 100644 index a54eee3..0000000 --- a/Assets/Scripts/FirstLook/FirstLookMrecController.cs +++ /dev/null @@ -1,107 +0,0 @@ -using CloudX; -using GoogleMobileAds.Api; -using GoogleMobileAds.Common; - -/* - * First Look MREC (300x250). Same as FirstLookBannerController with the MREC SDK - * calls: bottom-center on both SDKs, MREC size on AdMob. MREC takes an - * AdViewPosition only (a vertical config throws). Auto-refresh is - * kept off - see the FirstLookInlineController class note; the crucial call is - * StopMrecAutoRefresh before create. - */ -public sealed class FirstLookMrecController : FirstLookInlineController -{ - private const CloudXAdViewConfiguration.AdViewPosition CloudXPosition = - CloudXAdViewConfiguration.AdViewPosition.BottomCenter; - - private BannerView _adMobMrec; - - public FirstLookMrecController( - string cloudXAdUnitId, - string adMobAdUnitId, - bool cloudXAvailable) - : base(cloudXAdUnitId, adMobAdUnitId, cloudXAvailable) - { - SubscribeCloudXCallbacks(); - } - - protected override void SubscribeCloudXCallbacks() - { - CloudXAdsCallbacks.Mrec.OnAdLoadSuccess += CloudXOnLoadSuccess; - CloudXAdsCallbacks.Mrec.OnAdLoadFailed += CloudXOnLoadFailed; - CloudXAdsCallbacks.Mrec.OnAdClicked += CloudXOnClicked; - } - - protected override void UnsubscribeCloudXCallbacks() - { - CloudXAdsCallbacks.Mrec.OnAdLoadSuccess -= CloudXOnLoadSuccess; - CloudXAdsCallbacks.Mrec.OnAdLoadFailed -= CloudXOnLoadFailed; - CloudXAdsCallbacks.Mrec.OnAdClicked -= CloudXOnClicked; - } - - protected override void CloudXCreateAndLoad() - { - CloudXSdk.DestroyMrec(CloudXAdUnitId); - - /* - * Required, not optional: CloudX MREC auto-refresh is opt-out, so without - * this the first ShowMrec would start a background reload that could swap - * the ad out from under the First Look source decision. It goes before - * CreateMrec: the native layer registers the ad unit as refresh-disabled - * even with no view yet, then creates the view with refresh already off, - * so no timer ever runs. (Destroy clears that registration, hence this - * order.) - */ - CloudXSdk.StopMrecAutoRefresh(CloudXAdUnitId); - - /* - * Placement and custom data must be set before CreateMrec so they are on - * the first request. CreateMrec also issues the first load, so the - * OnAdLoadSuccess / OnAdLoadFailed callbacks that drive the source and - * the fallback come from here - no separate LoadMrec call. Note the - * capital-R setter names against the lowercase-r lifecycle methods. - */ - CloudXSdk.SetMRecPlacement(CloudXAdUnitId, "first_look_screen"); - CloudXSdk.SetMRecCustomData(CloudXAdUnitId, "first_look_mrec_data"); - CloudXSdk.CreateMrec(CloudXAdUnitId, new CloudXAdViewConfiguration(CloudXPosition)); - } - - protected override void CloudXShow() => CloudXSdk.ShowMrec(CloudXAdUnitId); - protected override void CloudXHide() => CloudXSdk.HideMrec(CloudXAdUnitId); - protected override void DestroyCloudXAd() => CloudXSdk.DestroyMrec(CloudXAdUnitId); - - protected override void AdMobCreateAndLoad() - { - DestroyAdMobAd(); - - /* - * A BannerView loads once; there is no refresh API to turn off here. Its - * refresh is the ad unit's Automatic refresh setting in the AdMob console, - * which MUST be Disabled for this unit - otherwise AdMob replaces the ad - * on its own schedule behind First Look's back. Google Mobile Ads raises - * its callbacks off the Unity main thread; ExecuteInUpdate moves them - * back on. - */ - _adMobMrec = new BannerView(AdMobAdUnitId, AdSize.MediumRectangle, AdPosition.Bottom); - - _adMobMrec.OnBannerAdLoaded += () => MobileAdsEventExecutor.ExecuteInUpdate(OnAdMobLoaded); - - _adMobMrec.OnBannerAdLoadFailed += error => MobileAdsEventExecutor.ExecuteInUpdate(() => - OnAdMobLoadFailed(error.GetMessage())); - - _adMobMrec.OnAdClicked += () => MobileAdsEventExecutor.ExecuteInUpdate(OnAdMobClicked); - - /* Created hidden; Show()/Hide() drive visibility. */ - _adMobMrec.Hide(); - _adMobMrec.LoadAd(new AdRequest()); - } - - protected override void AdMobShow() => _adMobMrec.Show(); - protected override void AdMobHide() => _adMobMrec?.Hide(); - - protected override void DestroyAdMobAd() - { - _adMobMrec?.Destroy(); - _adMobMrec = null; - } -} diff --git a/Assets/Scripts/FirstLook/FirstLookMrecController.cs.meta b/Assets/Scripts/FirstLook/FirstLookMrecController.cs.meta deleted file mode 100644 index b81171b..0000000 --- a/Assets/Scripts/FirstLook/FirstLookMrecController.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 3c1f46fd36a5749eea85daa770f20dcd \ No newline at end of file diff --git a/Assets/Scripts/FirstLook/FirstLookRewardedController.cs b/Assets/Scripts/FirstLook/FirstLookRewardedController.cs deleted file mode 100644 index 1d27a93..0000000 --- a/Assets/Scripts/FirstLook/FirstLookRewardedController.cs +++ /dev/null @@ -1,129 +0,0 @@ -using System; -using CloudX; -using GoogleMobileAds.Api; -using GoogleMobileAds.Common; - -/* - * First Look rewarded. Same as FirstLookInterstitialController plus the reward - * callback of both SDKs surfaced through RewardEarned. - */ -public sealed class FirstLookRewardedController : FirstLookFullscreenController -{ - public event Action RewardEarned; - - private RewardedAd _adMobRewarded; - - public FirstLookRewardedController( - string cloudXAdUnitId, - string adMobAdUnitId, - bool cloudXAvailable) - : base(cloudXAdUnitId, adMobAdUnitId, cloudXAvailable) - { - SubscribeCloudXCallbacks(); - } - - protected override void SubscribeCloudXCallbacks() - { - CloudXAdsCallbacks.Rewarded.OnAdLoadSuccess += CloudXOnLoadSuccess; - CloudXAdsCallbacks.Rewarded.OnAdLoadFailed += CloudXOnLoadFailed; - CloudXAdsCallbacks.Rewarded.OnAdShowSuccess += CloudXOnShowSuccess; - CloudXAdsCallbacks.Rewarded.OnAdShowFailed += CloudXOnShowFailed; - CloudXAdsCallbacks.Rewarded.OnAdHidden += CloudXOnHidden; - CloudXAdsCallbacks.Rewarded.OnAdClicked += CloudXOnClicked; - CloudXAdsCallbacks.Rewarded.OnAdRewarded += CloudXOnRewarded; - } - - protected override void UnsubscribeCloudXCallbacks() - { - CloudXAdsCallbacks.Rewarded.OnAdLoadSuccess -= CloudXOnLoadSuccess; - CloudXAdsCallbacks.Rewarded.OnAdLoadFailed -= CloudXOnLoadFailed; - CloudXAdsCallbacks.Rewarded.OnAdShowSuccess -= CloudXOnShowSuccess; - CloudXAdsCallbacks.Rewarded.OnAdShowFailed -= CloudXOnShowFailed; - CloudXAdsCallbacks.Rewarded.OnAdHidden -= CloudXOnHidden; - CloudXAdsCallbacks.Rewarded.OnAdClicked -= CloudXOnClicked; - CloudXAdsCallbacks.Rewarded.OnAdRewarded -= CloudXOnRewarded; - } - - protected override bool CloudXIsReady() => CloudXSdk.IsRewardedReady(CloudXAdUnitId); - protected override void CloudXLoad() => CloudXSdk.LoadRewarded(CloudXAdUnitId); - protected override void CloudXShow() => CloudXSdk.ShowRewarded(CloudXAdUnitId); - protected override void DestroyCloudXAd() => CloudXSdk.DestroyRewarded(CloudXAdUnitId); - - protected override bool AdMobCanShow() => _adMobRewarded != null && _adMobRewarded.CanShowAd(); - - protected override void AdMobLoad() - { - /* - * Google Mobile Ads raises its callbacks off the Unity main thread. - * ExecuteInUpdate moves the whole body onto it, so controller state and - * the events subscribers use for UI both stay on one thread, like the - * CloudX callbacks. - */ - RewardedAd.Load( - AdMobAdUnitId, - new AdRequest(), - (ad, error) => MobileAdsEventExecutor.ExecuteInUpdate(() => - { - IsLoadingAdMob = false; - - if (IsDisposed) - { - ad?.Destroy(); - return; - } - - if (error != null || ad == null) - { - RaiseAdLoadFailed( - FirstLookSource.AdMob, - error?.GetMessage() ?? "AdMob returned no ad"); - return; - } - - _adMobRewarded = ad; - RegisterAdMobEvents(ad); - RaiseAdLoaded(FirstLookSource.AdMob); - })); - } - - protected override void AdMobShow() - { - _adMobRewarded.Show(reward => MobileAdsEventExecutor.ExecuteInUpdate(() => - RewardEarned?.Invoke(FirstLookSource.AdMob, $"{reward.Amount} {reward.Type}"))); - } - - protected override void DestroyAdMobAd() - { - _adMobRewarded?.Destroy(); - _adMobRewarded = null; - } - - private void CloudXOnRewarded(CloudXAd ad, CloudXReward reward) - { - if (ad.AdUnitId == CloudXAdUnitId) - { - RewardEarned?.Invoke(FirstLookSource.CloudX, $"{reward.Amount} {reward.Label}"); - } - } - - private void RegisterAdMobEvents(RewardedAd ad) - { - ad.OnAdFullScreenContentOpened += () => MobileAdsEventExecutor.ExecuteInUpdate(() => - RaiseAdShown(FirstLookSource.AdMob)); - - ad.OnAdFullScreenContentClosed += () => MobileAdsEventExecutor.ExecuteInUpdate(() => - { - DestroyAdMobAd(); - RaiseAdClosed(FirstLookSource.AdMob); - }); - - ad.OnAdFullScreenContentFailed += error => MobileAdsEventExecutor.ExecuteInUpdate(() => - { - DestroyAdMobAd(); - RaiseAdShowFailed(FirstLookSource.AdMob, error.GetMessage()); - }); - - ad.OnAdClicked += () => MobileAdsEventExecutor.ExecuteInUpdate(() => - RaiseAdClicked(FirstLookSource.AdMob)); - } -} diff --git a/Assets/Scripts/FirstLook/FirstLookRewardedController.cs.meta b/Assets/Scripts/FirstLook/FirstLookRewardedController.cs.meta deleted file mode 100644 index 31aeb4a..0000000 --- a/Assets/Scripts/FirstLook/FirstLookRewardedController.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: df11b4c9980bb42f7aa23d0432e6d446 \ No newline at end of file diff --git a/Assets/Scripts/FirstLook/FirstLookScreen.cs b/Assets/Scripts/FirstLook/FirstLookScreen.cs index 6a69eca..f51de7d 100644 --- a/Assets/Scripts/FirstLook/FirstLookScreen.cs +++ b/Assets/Scripts/FirstLook/FirstLookScreen.cs @@ -7,14 +7,19 @@ /* * First Look demo entry point and integration template: CloudX gets the first - * chance to fill each placement and AdMob is the lazy fallback. The flow lives - * entirely in this folder (screen + a shared controller base + one controller - * per format) so it can be copied into a publisher app as-is; AdScreenUi is - * demo-only layout and is kept out on purpose. Covers all four formats - - * interstitial, rewarded, banner and MREC. Banner and MREC keep CloudX - * auto-refresh off (it is opt-out) so a background reload never overrides the - * First Look source decision; GeneralScreen restarts refresh on focus, this - * screen deliberately does not. + * chance to fill each placement and AdMob is the lazy fallback. + * + * Two formats, deliberately: an interstitial and a banner. They are the two + * shapes the rule has to handle - a fullscreen ad that is consumed by being + * shown, and an inline ad that stays on screen and therefore needs an explicit + * pass cycle. Rewarded follows the interstitial exactly and MREC follows the + * banner exactly, so adding them here would only repeat a pattern; the General + * screen already shows the SDK calls for all four formats. + * + * The flow lives entirely in this folder, and each controller is one + * self-contained file, so integrating a format means copying two files: that + * controller and FirstLookSource.cs. AdScreenUi is demo-only layout and is kept + * out on purpose; this screen hides the two buttons it does not use. */ [RequireComponent(typeof(AdScreenUi))] public class FirstLookScreen : MonoBehaviour @@ -33,14 +38,10 @@ public class FirstLookScreen : MonoBehaviour private AdScreenUi _ui; private FirstLookInterstitialController _interstitial; - private FirstLookRewardedController _rewarded; private FirstLookBannerController _banner; - private FirstLookMrecController _mrec; private bool _cloudXInitAnswered; private int _interstitialRetries; - private int _rewardedRetries; private int _bannerRetries; - private int _mrecRetries; private string _cloudXStatus = "CloudX: Initializing"; private string _adMobStatus = "AdMob: Initializing"; @@ -58,12 +59,16 @@ IEnumerator Start() _ui.Bind(new AdScreenUi.Actions { ShowBanner = ToggleBanner, - ToggleMrec = ToggleMrec, ShowInterstitial = ShowInterstitial, - ShowRewarded = ShowRewarded, + /* This screen covers interstitial and banner only. */ + ToggleMrec = () => { }, + ShowRewarded = () => { }, /* The banner stays at the top in both orientations, so nothing to reflow. */ OnOrientationChanged = _ => { }, }); + _ui.SetButtonVisible(_ui.showMrecButton, false); + _ui.SetButtonVisible(_ui.showRewardedButton, false); + _ui.SetRewardedStatus(string.Empty); _ui.SetActionsInteractable(false); #if UNITY_IOS && !UNITY_EDITOR PublishInitializationStatus("Requesting tracking permission"); @@ -94,12 +99,8 @@ void OnDestroy() _interstitial?.Dispose(); _interstitial = null; - _rewarded?.Dispose(); - _rewarded = null; _banner?.Dispose(); _banner = null; - _mrec?.Dispose(); - _mrec = null; } /* @@ -148,7 +149,7 @@ private void OnCloudXInitialized(CloudXSdkConfiguration _) { _cloudXInitAnswered = true; - if (_interstitial != null || _rewarded != null) + if (_interstitial != null) { /* * The watchdog already gave up on CloudX and built AdMob-only @@ -202,7 +203,7 @@ private IEnumerator ReleaseActionsIfInitStalls() private void CreateControllers(bool cloudXAvailable) { - if (_interstitial != null || _rewarded != null) + if (_interstitial != null) { return; } @@ -237,40 +238,6 @@ private void CreateControllers(bool cloudXAvailable) }; _interstitial.AdClicked += source => Log($"Interstitial clicked ({source})"); - _rewarded = new FirstLookRewardedController( - FirstLookConfig.CloudXAdUnitOrInvalid(DemoConfig.RewardedAdUnitId), - FirstLookConfig.AdMobRewardedAdUnitId, - cloudXAvailable); - _rewarded.AdLoaded += source => - { - _rewardedRetries = 0; - SetRewardedStatus($"Loaded ({source})"); - }; - _rewarded.AdLoadFailed += (source, message) => - { - var delay = NextRetryDelay(ref _rewardedRetries); - SetRewardedStatus($"Load failed ({source}): {message}\nRetrying in {delay:0}s..."); - Invoke(nameof(LoadRewarded), delay); - }; - _rewarded.AdShown += source => SetRewardedStatus($"Showing ({source})"); - _rewarded.AdShowFailed += (source, message) => - { - var delay = NextRetryDelay(ref _rewardedRetries); - SetRewardedStatus($"Show failed ({source}): {message}\nRetrying in {delay:0}s..."); - Invoke(nameof(LoadRewarded), delay); - }; - _rewarded.AdClosed += source => - { - SetRewardedStatus($"Closed ({source})"); - LoadRewarded(); - }; - _rewarded.AdClicked += source => Log($"Rewarded clicked ({source})"); - _rewarded.RewardEarned += (source, reward) => - { - Log($"Reward earned ({source}): {reward}"); - SetRewardedStatus($"Reward: {reward} ({source})"); - }; - _banner = new FirstLookBannerController( FirstLookConfig.CloudXAdUnitOrInvalid(DemoConfig.BannerAdUnitId), FirstLookConfig.AdMobBannerAdUnitId, @@ -291,34 +258,11 @@ private void CreateControllers(bool cloudXAvailable) Invoke(nameof(LoadBanner), delay); }; _banner.AdShown += source => _ui.SetBannerButtonLabel($"Hide Banner ({source})"); + _banner.PassSpent += ScheduleNextBannerPass; _banner.AdClicked += source => Log($"Banner clicked ({source})"); - _mrec = new FirstLookMrecController( - FirstLookConfig.CloudXAdUnitOrInvalid(DemoConfig.MrecAdUnitId), - FirstLookConfig.AdMobMrecAdUnitId, - cloudXAvailable); - _mrec.AdLoaded += source => - { - _mrecRetries = 0; - Log($"MREC loaded ({source})"); - if (!_mrec.IsShown) - { - _ui.SetMrecButtonLabel("Show MREC"); - } - }; - _mrec.AdLoadFailed += (source, message) => - { - var delay = NextRetryDelay(ref _mrecRetries); - Log($"MREC load failed ({source}): {message}; retrying in {delay:0}s"); - Invoke(nameof(LoadMrec), delay); - }; - _mrec.AdShown += source => _ui.SetMrecButtonLabel($"Hide MREC ({source})"); - _mrec.AdClicked += source => Log($"MREC clicked ({source})"); - LoadInterstitial(); - LoadRewarded(); LoadBanner(); - LoadMrec(); _ui.SetActionsInteractable(true); } @@ -344,25 +288,13 @@ private void ShowInterstitial() LoadInterstitial(); } - private void ShowRewarded() - { - var source = _rewarded.ReadySource; - - if (_rewarded.Show()) - { - Log($"Showing the rewarded ad ({source})"); - return; - } - - SetRewardedStatus("No ad ready; reloading"); - LoadRewarded(); - } - private void ToggleBanner() { if (_banner.IsShown) { _banner.Hide(); + /* Nothing on screen, so the pass cycle stops until the next Show. */ + CancelInvoke(nameof(LoadBanner)); _ui.SetBannerButtonLabel("Show Banner"); return; } @@ -375,20 +307,18 @@ private void ToggleBanner() } } - private void ToggleMrec() + /* + * Banner only: putting one on screen spends its First Look pass, so the + * next pass is scheduled a cooldown later. Cancelling first collapses a + * pending backoff retry into this one - both end up calling LoadBanner, and + * two pending invokes would arbitrate the placement twice. Showing again + * after a Hide raises PassSpent too, which restarts the cooldown from that + * moment. + */ + private void ScheduleNextBannerPass() { - if (_mrec.IsShown) - { - _mrec.Hide(); - _ui.SetMrecButtonLabel("Show MREC"); - return; - } - - if (!_mrec.Show()) - { - _ui.SetMrecButtonLabel("MREC: loading..."); - LoadMrec(); - } + CancelInvoke(nameof(LoadBanner)); + Invoke(nameof(LoadBanner), FirstLookConfig.PassCooldownSeconds); } private static float NextRetryDelay(ref int retries) @@ -407,21 +337,11 @@ private void LoadInterstitial() _interstitial?.Load(); } - private void LoadRewarded() - { - _rewarded?.Load(); - } - private void LoadBanner() { _banner?.Load(); } - private void LoadMrec() - { - _mrec?.Load(); - } - /* * Status plumbing */ @@ -436,10 +356,4 @@ private void SetInterstitialStatus(string text) Log($"Interstitial: {text.Replace('\n', ' ')}"); _ui.SetInterstitialStatus($"Inter: {text}"); } - - private void SetRewardedStatus(string text) - { - Log($"Rewarded: {text.Replace('\n', ' ')}"); - _ui.SetRewardedStatus($"Rewarded: {text}"); - } } diff --git a/README.md b/README.md index dea491e..cbcaf23 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Our complete CloudX Unity SDK integration guide is available on our docs site, [ This repository is also a runnable Unity demo project. It shows a working CloudX integration for banner, MREC (the 300x250 medium rectangle), interstitial and rewarded ads, plus a First Look flow -that falls back to AdMob. +that gives CloudX the first chance and falls back to AdMob. Requirements: @@ -89,21 +89,27 @@ failure than letting a tester poke the not-ready paths. ### First Look screen -First Look screen with all four format buttons +First Look screen with the interstitial and banner buttons First Look gives CloudX the first chance to fill a placement and falls back to AdMob only when CloudX cannot. The full pattern is documented at [https://docs.cloudx.io/en/unity/integrations/first-look](https://docs.cloudx.io/en/unity/integrations/first-look); this screen is a working copy of it, meant to be lifted into a publisher app. +It covers **two formats: interstitial and banner.** Those are the two shapes the rule has to handle - +a fullscreen ad that is consumed by being shown, and an inline ad that stays on screen and therefore +needs an explicit pass cycle. Rewarded follows the interstitial exactly and MREC follows the banner +exactly, so the screen would only repeat itself; the General screen already shows the SDK calls for +all four formats. This screen is about the controller, not about format coverage. + The rules the controllers implement: - CloudX is asked first. AdMob is loaded **lazily**, only after CloudX reports a load failure. - The two are never loaded in parallel, so the fallback costs nothing when CloudX fills. - `Show()` prefers a ready CloudX ad over a ready AdMob one, and returns `false` when neither is - ready. For interstitial and rewarded the caller just carries on with the game; the demo says so and - reloads. For banner and MREC a `Show()` with nothing ready is remembered, and the ad appears as soon - as either source loads; `Hide()` cancels that. + ready. For the interstitial the caller just carries on with the game; the demo says so and reloads. + For the banner a `Show()` with nothing ready is remembered, and the ad appears as soon as either + source loads; `Hide()` cancels that. - If CloudX initialization fails outright, the controllers skip the CloudX leg and serve AdMob directly, rather than waiting for load callbacks that a failed init never delivers. - A failed load or show is retried with a capped backoff (2 s, 4 s, 8 s ... up to 60 s), reset by the @@ -120,52 +126,69 @@ The status text names which SDK won, so you can see the pattern working: Left: CloudX filled. Right: the same button after CloudX no-filled, showing Google's test creative. Everything the flow needs lives in `Assets/Scripts/FirstLook`, and none of it calls into the General -screen, so the folder can be copied out whole: +screen: | File | Role | | --- | --- | +| `FirstLookInterstitialController.cs` | The whole interstitial flow, self-contained. | +| `FirstLookBannerController.cs` | The whole banner flow, self-contained, including the pass cycle. | | `FirstLookSource.cs` | The `CloudX` / `AdMob` enum every event reports. | -| `FirstLookAdController.cs` | Shared base: the CloudX/AdMob bookkeeping, load events, and dispose. | -| `FirstLookFullscreenController.cs` | Base for the fullscreen formats (interstitial, rewarded). | -| `FirstLookInlineController.cs` | Base for the inline formats (banner, MREC), including refresh-off. | -| `FirstLookInterstitialController.cs` | The interstitial SDK calls. | -| `FirstLookRewardedController.cs` | The rewarded SDK calls, plus the reward callback. | -| `FirstLookBannerController.cs` | The banner SDK calls. | -| `FirstLookMrecController.cs` | The MREC SDK calls. | -| `FirstLookConfig.cs` | AdMob ad unit ids, and the fallback test switch below. | +| `FirstLookConfig.cs` | AdMob ad unit ids, the banner pass cooldown, and the fallback test switch below. | | `FirstLookScreen.cs` | Initializes both SDKs, wires the controllers to the buttons. | -Each format is a thin subclass over a shared base, so the fallback rule is written once. To integrate -one format, take four files: `FirstLookSource.cs`, `FirstLookAdController.cs`, the family base -(`FirstLookFullscreenController.cs` for interstitial or rewarded, `FirstLookInlineController.cs` for -banner or MREC) and that format's controller. The bases are small and format-agnostic. +**To integrate one format, copy two files:** that format's controller and `FirstLookSource.cs`. Each +controller is one file you can read top to bottom - state, the entry points, then each SDK's +callbacks - with no base class to chase. The two controllers repeat about fifty lines of ad-unit and +dispose bookkeeping between them; that is deliberate, so neither file drags a shared base along into +your project. To see the fallback path yourself, set `ForceCloudXNoFill = true` in `FirstLookConfig.cs` and rebuild. It points CloudX at an unknown ad unit, so every CloudX load fails and AdMob serves instead. -First Look covers all four formats. Banner and MREC toggle Show/Hide, and the button label names the -SDK that filled (e.g. `Hide Banner (CloudX)`). The banner sits at the top in both orientations; the -MREC is a 300x250 at the bottom. +The banner toggles Show/Hide and the button label names the SDK that filled (e.g. +`Hide Banner (CloudX)`). It sits at the top in both orientations. + +First Look screen with the CloudX banner at the top + +#### The banner pass cycle + +A banner is not consumed the way a fullscreen ad is, so it needs one thing the interstitial does not. +One pass is one ad opportunity: CloudX is asked first, AdMob only if CloudX fails, and the winner goes +on screen. Putting an ad on screen **spends** the pass - CloudX inline ads report only load and click, +and a load into a view that is already visible renders straight away, so that is the one moment the +code can treat as "this fill has been used". The screen then schedules the next pass +`FirstLookConfig.PassCooldownSeconds` later (30 s by default), and that pass starts at CloudX again. + +Without the cycle the first fill would latch: after one CloudX no-fill the AdMob fallback would own +the placement until the scene was destroyed, and CloudX would never get another first look. -First Look screen with the CloudX banner at the top and the CloudX MREC at the bottom +Three details worth copying as they are: -Both inline ads shown at once, filled by CloudX; the labels read `Hide Banner (CloudX)` and -`Hide MREC (CloudX)`. +- **Reloading is in place, not a recreate** - `LoadBanner` on the existing view, allowed because + refresh was stopped for that ad unit - so a visible ad is replaced only once the new one has filled, + and the slot never blanks. +- **The cycle only turns while an ad is on screen.** Hiding cancels the pending pass, so a hidden slot + never keeps requesting in the background, and showing it again puts the same ad back up and restarts + the cooldown from that tap. The screen still preloads once before the first tap, so an ad is ready + when the user asks for it. +- **An ad the AdMob console refreshed on its own does not count as a pass.** Only a fill the + controller asked for spends one, so an AdMob unit that still has Automatic refresh enabled cannot + keep postponing CloudX's next first look - which it otherwise would, on every refresh. The demo's + Google test units do refresh, so this path is live even here. -Banner and MREC keep auto-refresh **off** so a background reload never overrides the First Look -source decision. CloudX inline auto-refresh is opt-out - showing an inline ad starts it unless the ad -unit was first passed to `Stop*AutoRefresh` - so the controllers call `StopBannerAutoRefresh` / -`StopMrecAutoRefresh` before create and never call the `Start*` counterparts. (GeneralScreen -restarts refresh on focus; First Look deliberately does not.) +Auto-refresh itself stays **off** on the CloudX side: the pass cycle owns reloading, so an SDK refresh +timer would compete with it and could swap the ad out from under the First Look source decision. +CloudX banner auto-refresh is opt-out - showing a banner starts it unless the ad unit was first passed +to `StopBannerAutoRefresh` - so the controller calls that before create and never calls +`StartBannerAutoRefresh`. (GeneralScreen restarts refresh on focus; First Look deliberately does not.) -> **Disable automatic refresh on your AdMob banner and MREC ad units.** +> **Disable automatic refresh on your AdMob banner ad unit.** > > This is the one step the code cannot do for you. The Google Mobile Ads Unity plugin has no > refresh API: a `BannerView` loads once, and whether it refreshes afterwards is decided solely by > the ad unit's **Automatic refresh** setting in the AdMob console, in the settings of each banner -> and MREC ad unit. If that setting is on, AdMob swaps the creative on its own schedule, and -> every swap silently replaces the ad that won the First Look pass - CloudX never gets asked again -> for that slot. Set it to **Disabled** on every AdMob unit you use as a First Look fallback. +> ad unit. If that setting is on, AdMob swaps the creative on its own schedule, outside the pass +> cycle. Set it to **Disabled** on every AdMob unit you use as a First Look fallback. > > The demo's Google test units are configured by Google, not by this project, so treat them only > as a way to see the fallback render; the setting above is about the units you replace them with. @@ -196,7 +219,7 @@ app or the SDK gets no fill. The AdMob ad units in `FirstLookConfig.cs` are Google's official test units and stay valid as they are; replace them with your own AdMob units when you take this into production, and set -**Automatic refresh** to Disabled on the banner and MREC ones (see the First Look section for why). +**Automatic refresh** to Disabled on the banner one (see the First Look section for why). ### iOS target SDK diff --git a/docs/images/first-look-inline.png b/docs/images/first-look-inline.png index e672f0b357fcc7dcdb765b682139ffe058a94bd5..729f3cb1c52b7ec3b11753521342d50199309ffe 100644 GIT binary patch literal 27735 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 F{~u2qEENC% literal 33522 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 ~ydm2zCT%0BPO;weuw|nx%_+Hc`Y}!u=(!{ke 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 diff --git a/docs/images/first-look-screen.png b/docs/images/first-look-screen.png index 0d1162e00386e9ee271cd7fa83865606b0eb20cd..e0b9541fd42e40c9af94e351d49acb1fb19337af 100644 GIT binary patch literal 22717 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

D$$TVss$dHm=0oO&*7gZ= zM^N920q<~Cs+ob0z7DtI!*d^xVmmmUPm+L%LEdvJc+S6&{T=o@3jeXZHRfIEVuKp}Ck40vW zj2BxVq3w($IUs68snD-{_;P|`Br$X6`G=^b3_yiMs_W@0xAMNLI33$r$f1xE+u95R zX{Gx4>espp_{-_{8g&J;%^w(Ok5~Q^fT?;tF&;H^Nrs%31n#b2xz`it@3=XQdY{o_ zKijK08jj~Nm~mn7)i37!z}lOAf2&^`Tq!qGQ(~&X_V!`5jd6FiVvbd3N=qoZi|U0h zlh_7nS3`B~`GdgJG+$C$SLZvz%@)x{i`YtB-h*!_Nk?J1gdb#=CJu%Y*8|-332!l- zEcf1dVxg10InHcr*xxNpOC26rUDD%fU6kytN4A9XD!a6Gy5|?(BRohWCZMz#e^eKo zp5fs3xgtYg>D(yRq2+0c?R=Z$-M2O2$2NXTr&E%h!8!avgiu1ZsCWR`ZE|9-YVGQ) zK+6h<^)ZAe@F%7&z%FaOKHscxxFnb4{u*Os_SsDq)>XV^2fdwxlZndc*g^sCxpC8< zr`I)@0K5jt2f0UV)cW5a=chnG%R`=-6Omgu3A8Jo%-5MX&bHb{#UYYOOfKjdmIT5F zy2LOJ?6$&^bNKcTeYBq{ec9zI{ESuih8PI3(_L_rDeYg0aFMw=9{)r?{|#PFuO0h* zK7Ckx>dCGo<`ftci#}St+5X3Nhx#G`W!8q7g=?(qQqmkv<)ZuOW{6V*HJZmA7{zT+ zES$elLLF@XQTR_JuH-sR{aknvs(f;xnJKmH5|e}HAaN*=VKPpW`xb|QNG-jqr+Aq- zA!=hT0D3?5;!lY?snCHnAIHVrw~bXFb3V;%<*ppfR(qj1P^u@BTw<_bIE?(Byp3lm z)+i$J$f2ok>l53^Wx85UXKh;-KKV7?+lJzYR^M&);)>8wt|GnF$XBiM&PwH+s@b*Y zfPngk@{-ddDRhA=`dXH|?PL|$*iMdn`ilTpr9sToCx5+zS(hULX}{}ZfEt}hBNzzc z-3}=831`+8Lh%u@igpPu?*5#H}MSyC|UekGbXYoR`fFnOvt=c0;OtA&J5C zHZF#jk-$9x<)z@(FR+o>+J#ga%*8bt;f7Dup{2-bAOv?;P3-Y_su0e)7o$c*%>pNV5+o;sv{Y3&@C^Szg&tPs2qBp6Xu3z2T!SE;#`-3Azn ze>ll({{HaGKlLS&xJ>A4VG){hNUE6YD-jzq{2oe`k@yrj!<&7~LOae`m9HY8LVXA} zQmxJn!Jt3V-w&-$1ItHElQwAy^+9LcMfPCnvrCnQ6zxdWfpx@eC!D^ORgQ*h3r{d( zxXgX;>Fg2#_-Dzr-iik6z;YQh?c|CDdNblaTbdxRmr=5@zV+w(UT%un-nA?uTqP48 zhvO@W!oRso;mC}Zpb(2%KLl~sf`7r)-kWpq%g9>AE#ob#gG0K5cSYkWrc^7VTw%dA za<<1fslQx0R5t54Z#3b%Kf9`oFh=cC&q&snPQDzrx{9;A%Y1*8+9>AdJ31mjaG-f* z(qq#XNfL7l%KN)n>zct5(-pSoiLJv=2`7Q5D7lt*blexcc%Y;sy^tOHcCbXnd`MJ|CF%)}GkP+`sP{>HnTy#hbjAZ|2B8`=J=tdbo)zh_5sL9awt*!MjAe1BEXXQDR|<7Pm;{gaQ>?1nxQm#O=1`^Z0d_8J?sLo zrv@H*)Qi`-O8i9N6m7WD5W?kdIKRnjex#vDdQv}gPrzPX7J*KjED+gJn&&_9Pq$zT zY2JfRg5933-8QSH|2jpO4ELwsal|Yz#h1N+*9$@UruGZcNRYyZ{KG=$T12G1GAxI? z_5xvdO~L17lY3tw_oRl$7Cnit@SL8aM4NTg!lqp^h4Z_oR_Tu*_Di~WT5~Vtdql0G zjr9PiR4My>txUV?~Vz^eI|9=$ij2moS|%1ULjd9Z=Zv+5}tf@Cmj z@J<%36+jsGxcrO=p2V@*!% zPz}PeTa}1#?65=_bTyIo5%#R95Z~5WQn~55gCXb@GY1{3rVpFn8b+){o;YQC3caDm z6&8r86^61uoO$vCQ7Om=Dq`bjor~4#f6kU+Q0L3^8>6x0=sFvs<@b|4>DOB(STEIx z{leAQN*dI};dMRH#1rtWb3U4tN@cfjeA7he6CWRsMRYnt$;+!PREh6!jkhO^#^-tx zt&}H@XM@jSS*H-m=XMtV*{EMLBaO?hed2KviCB)$`G6EDoFMhgutTf1P0wu%d-`0x z11hQ`wHiz1@X&S?HnT8du&t4zYO|aTzHpl{xiOhbH7?fT7#Ak|kg1UQr>#j=#nAWR zRM+FE0>^QiJkPic4IZ9?5kLjD#QO)Pqc-M8+bex0>LYgNsb7_&Lq<|EN+t7as;Bkc z&IBHBMWGk3ys4)CspI~bA3nf1_Nq8i+>^3m$LJE+R|@)l&>On5D_tx^e`QgTK!{<)we7$Y=>1rbBYMf6Y)8|%8N=`1}a(iIJ@Ac6jh23J>S9$+w z`}MKoHLiA?cLXseXjh(SdwV+=NJ<@)blzaI!myIUW;Tqp-Td@;-XT8o7L$sQW?89in{OV5p1=8_TH?kTNK{7OJXAk$38hpV z-7!602v|DSNUy6$uz@YSzn84uAJE`&I;LE=A5tg$A#gr4*EAJS^ipB>O)VMRcd2CZ zI|x1J(__^%J(f82aw|1Gx-Nj<4c$}Gp|n{sHOV)w{>by1n6<05)-Lr>f^C$aQSvBh z0rm}A?#nkD7+zj*m5eG49riL9AHIEDL>o=9=Dvtp(D#ySe>~$WRx3@^bsVBpt}^^0 zB`Z4{#|Swd80(>+qS{NAmPEwutadq`2WjYTyu3V~J3bs1j&ufq(DCo=KRrQ4m=n(% zH+-#z=rk%AlJq@`b=^0E#nHi5cV}z)ey=aL?XoGXw=Y}a%!yxvk%_8`E-vgxf~@32 zSX}%!!CO6-WgGHxD;5|WS1pSV0#*RTBEXEeT2SHWDk;b>y;7MkOG+Q?fuPnz^5@dH zSXofL%I+c{s0G;pNPVU}%^_YFah_C?aW|OUV^J2NlKFe|1NhR!*AF|3I1ptHghfabom6dIv$IKPfg^i^&A9&xJ zpz_ERmo+`^5WBd}muW4(&D}}0s))HgT^X@yTr(vV?+d>r1_VI8KR=LDkdbvk=(X$2 zsl@O^4-RyTH!e1N7=4SC^Nn`~W8mz^dG-ml8f`NYz7TKsN7U{YX1J!#-NBj}lY(Ry z)NDCdQjFZJ=lgu4VWo38ozFOyprKD&Yc`Q6^m5-bY&Tbu5YM1Bt!fE*hV{p>9bqEd z<+@_Ui3EBLZ9#MR_|MjhEEY2b5$v|Bi--b85j&aO4!HnNU!z=MuJv8<&c_z#J{-K4 z0p)~f3s&9y<7HGGHHc-J1alI&d?kMaw-ziBDvh``pbuRD(@${O$R{AkV90sqNtX5* zDzo)CBr0^yk0PYOKjNgdn(M~5uKT6-@l<+o;IUihctgf?wQ6lk)2E7Sc-DQVSPRFO zNnvo1!px=k(^Zmn;<>LDL%0UTFo-!ap%nWsrD{I0kWEG^pj0mCpad56v2;-g(6vPh zq>ve8Fo9U(pGGBKo&)4Cby#!}+ve0t)aV>FQGE__LU&3l+ha@AO0`4tuDo0~SL`|% zpMVr6hhQ)Ttwu$olp z<&H&Nfy^ifaWDeHe&KYIe!#^|(%RRtYg6-mftq<7mp7tN^n%_?wxSYnE}rw9{SmR8 zEh4*Hb&5)mYo9gi^any7jw{o^6)J_1*2{H6h(FxcJtRt0i@)E%ffu(?Xuy73Vbmzs z=42?6b+2uA>$MHhb~i_JyVV24aU;wfy7Ih-6ip9DrMv51=k<3e@+c^;&plvp^j}+A0ph5d>bv&(0Zetfd_Oq38C4*ZOtX+R4|?-Jdtt`q46cxUv6VxmIdHX zAx3B9=IKf|kvq#b%GQrDjv=$K@y&8z+U{c+FcLS)tmtpfe&i*S-kejQ_(B8Qq-if1 zlLVh&xg*vs~;u*tFe^n8UZLC9X3o%XRIJ4U(d=`sYmpCz->U}FM>?6Mm5qofG>GzS!Z2||SQ z;TjCW`m9|x{1bpcs?OueG(0r^6Eh%)om^nLMJ9>K3&_J4o67+xK_c;<6b8MIsWJze ze@KRJ^Sp_5+wfVaLpcfy0d^AmTq}XD&9Zz84LVzlXSXrSAFBtaTIyQd6$_*2P!P-04q!2T!=V z_5lItYN=?tjkbD^B9!?(OuBz2VQ!ltKDeU;bfG6wdi64vG)yTTWi_KSB^4k>2|eIz z1kwf4I{+TK*FYo@s<5OJp{k@MJK~W>ndax2kBZQ@C^*Te@BmpT82DRKVSTgue7-!b zcy_Tp2nn4tn*1iFVMnIPWF)b^Ar|+LmP9Sf6SxK09T_>AEA_Aep2E*P!@6QT!aJPg zy24tJFK*;N=S!$0S3#yCC`SH?Y$;mW*_4+oK%USupL`|Bl{bs0~+`y(dwZt-)_2MK%V9{?DWRX=bKO5xFs{rus6(MSe?xmHl)>|zs z8;TIuflfQ$&3;Ho>e`*tFsKjsZNbePnN*3p(V~q0b^cCYz`*m6GKJOT*Y)wj1R^T9 zEvbKmoU5X!Rg!;ET<22vm|4ZdjkehRTxzU`R>-JX@@lG8sYj{y5Tj@Us#fE5#EZel z6$rW_Q%s0_*@;s~H9F3V5@bbZMav`>@cgI=B=ZCO=$!Kk0gGiIlojRiOTcZZc0bAt zi08&JW0Z-7<0J|Jz`6xpRXVkZU@i}j43e-T1YVlErx^5GzF6E!Hqd(&3GV9X$yw|H zBK%(Z%GU1~^D@V<{2MX(%_#J{%s_wtGRgGE1iZsv#r}9-wU5LNNkv@xp<;Nj&SzxW zX75PWHl;u5{E^0C-oqCd6+$f6)7m)7?76U4OyARc@e)RPe;=^g^ZgQCV`C zot7Nrte=Ff33yTb+Pu6aOOk`Aklv(^T%_T72@wT3kI{*lJQrZOnD!+nd1lD_?e;_0 zzfqRl_(BxvjzhUwSf#1ACeD@g+9E=Ivq=oU@{)xCKtz$yJ-+kS8UKE^&cJdM;iduK zUj1_A{9givGu!50=T7r(Id2QNPPe3nVMF|Frl~M0jeP^~{kj?IPB8MQZ~#8)$LOv2 zAwZYctfz4JFBzZY{!h&5CbJ1uqJ@nBoZHLd!c0q-Zux;6F~a3Of56ZE@eC_K+w<+p zm@1%b5-~g1vC(>Ix(>PZJ{e>7-6?43cWBy^VxB{@lETxQ)}F^G>a>TiW=Fpg-HU zjxPv8m1lJMQOutIYOO+z1se+y)C7t(JFHp)>vwCDnn!aCrtkO!eVb}!Q|gtL*%`^1 z1s25-UzyKso_TvHYzhs%?NAVPwjv)I3>JKgZu`q zq8bzSP-TuhoHijZom!AvGqxRyvBwNEzQKgM7bYubX@>mPF2EQg7F@GX5m-1>&1Ei&NwyoRSV>=C?u*+qYc zGE47lVIFH+WJtW6mddN>gMOC*y4qA8?GtjK%@#IE4TUb-thgo<=`@Oeu1!n%^*sPl zxc6J1Q^D^uPuDt|=FV|bGRa>BB0fkLMH}VLGrf{}H6Cjs4nQO9&1AUQW@G;IMhYxWF7qF1Pt1obd(*DT4Y946)}%?+*({; zoD(Cu)E>bijJX3Gx%O;4Bl0i~9-X4dhBLp!L=}kS-M31@+tJET}R_p3k{gwgzn(2#pzpyVgzh?z&CV=Jdxs z@)rYm3cY@hFi$A^3-lUMmBl>N|5|Do5Un?O&1jZ?QMyT{R0p8^uI`ILMQ6k6RdGIL zKhIU&?FT*ITm+|F^|{w5tt_Dpu@h%Hq32ohu1HsvmjGM&3=5{f+Lg->^H2MOVH`zBGTw8Uj-D9$o07?Y3!7xNCF3)O2UT2sL zj16HXxmD~%!g(o?Y8tA0No7Rtd8~#bpY>~Cb=IO}U4BKPg;5T7oW=t1&;zRNL|GOe z^dUm$_XM6<(0x*60(``Xu%M~_)FEP)4RRi-w{$&6hn{4>+U-4gr zM&pHE9sF-)sL^bYyn{O~c+ANZbx0$cN-PWW4?Y)e9`95f^GV)~qk=VK0$c9d_MW!x zvR#U{r}R&@<=zglJ~iS&j=abzdD`Md`+H5fB_kz*BUMtmK(!yVx#A10<87o$Z_>Bn z9yByH_Wly_$?Mwp5o04P9@d%a<=W)_WKvbn{mwl~=01vYfLx{5fAMn=uEs(puq<37 zRP(~!PZcn%66QjPh8`8O_}%2Dj4@IZnTidBY}%Yl49)!=6{)sIa2~V-gkaG`{bBuO zT95-bJ^%l^<*K|uD(SNf#yyw)LtT&r={lB-Bw7%|ihe-tdMj(Gc zZTyYN<+K@OO^qxE_s(>jsREYZHP@9JhNhYcDBp;FGgLetlBYcv{m|b3$9WeA@3Utx z!<3aRkxANu1(75R$hiIc#l@JM&4Q}FS#0S>j#9S5IWNR}BaGF0r$!i#*PfHj(j~9C zZx`yN!$V4fFD{>~X>Ary zjQnFn>YjxD?28M37Urvpnjy*tOwdngP)1R~%disUbCdcA zr3>OqH`jTDz~McuB@F!)Sxa8)*JX?J}pfejYp7p-_8vCDsObr9btJnqMxK^8Gf|>p)DqP(~#@6vD22( zIeOg07$c3LAm$hHm7Abpf3)HViW~pXGWefRm(RcSZq_2N^$PmgI^;wtEFU5>=r(T{ zSf2?QRI*y>l^C>PQH9lp3zY%HOn8&TVvt(zShZM}%woL?EOmK(N;lNw<_Gf=Xl-_S zC&N2UDL5ma#G+oV?5yt(3=(N@#G0Z?O9v#sX}axhMlMpWG48{8=6*nF+E49g-D#vm zY@qjCNTk*$Vs2|utEH_R)0y5G9ap=~es=b_Ee}&JFa|bh3LL~dFz(+WjmI{?79&+A zGmr>m{uKFd#y_W^vw2CGb{a#}>YS>jI_-Cm$X4R1a*5_|eh&#JkMj{N zR)_uvpLSFqmSH5tF~N^gt6oAXfG|8<0$P}uPL;6|pM?7$9~)UdGBUTn5Yy3LIlr;H z15>Ujq=mA5Z5K+W^NdUm7fV`;clI;&9nxN-lg99>2a#h0$vcIjQQ5ezjBA#ZbH-(Nn1+r-?+>D*Yt zTfxP=PZ4m=w}I{++C&Pll`jznSEO&GVYb7RKvtM}QhL4K66rh^=1YZaW-m2sS7pPZ zq%ZS!(lOGz?+!H-b154P!8|GgwAzYuHyu=EoQ3mMQC_J()Kzo~Ce~=S3o7DD=;})? z@Bpd6Kz_f+Cx*k~X+JCJW>p#;Ma1?wHCVu9H2q*-j9}_~N)7)qK_05(AwW%+J4#|vRM3zL z6DdtxuJtyz?@Og9*2pUYddxMKE6WLNx%VyDF47I@%#5Ki70O8MGf2O0UBn*7iEW+> zUoJrBycpF*=B5#E1=UrHv|z_#bZM9C82r0E3#RlZrdhs2=oFv3Yr$;&cc@c$a?!EhQ;-hMq10!*) z7n+#VvfFHPqQ0_zF-Q*N-90DLgj*QsmF|D~^JMwAdihrWwl0N!sg_IQ9I7!HiQk0g zODE7N|FQojB_$Pex9+8ofph5CgUThD^EDWae1JS$TI=%iG9|31yPJZENuEzkbFa4b z!l?VlTgaN*hEymT5!h;>B2+D(!)pFt63)CkQtEVcREkc28t@6E{_+MeqcaK2u9=9m#M z^xFE5hgYUa3IS%6Mg z8k6GWd8JH8lgXm^AwLxtS}{I-`UGR3{pK!zTmloPrlxpcL8JDGVW%qv(0;3nbM791 zM>-T1Dk`YnCABA#&0cb|HMvr`uq3XUH@O4wyWGw7&^{( zzuZE+|0KqmZY?=Y;`gMaUa8LrBXfx`?kodqC2Des$oQpNvk@3V`~*Wv@k4RcBa78W z`7k^(-C(1m)$CmSbUmY>T&~T*b6PiZTWrzy3)?1ovyZWH^}7Smsa(7M#{()JYcB*W zxS#1&A@I7JWbos``D9Vaa-qUzSIKs!KrSDaxw87bc;kKk{sZ;>Y7{<)Gz{+A&G4HG zxgddyCNZhy>zrHn_0l+P%Fte)uA)?x*o z3zmVS5O9tHWC?0Y+H#hyWX>$)&784r-@b;jfIbUAPbWXoV8 zBl{A9PQpaMX&nvY{;9k=^_FTk$pypDcN=!3*6XcJvRQ&_fa@XZtmLs**zM3sl_2q% zl?~nSeH@!L?0kgLilKkVv9jZ(S?V#A*i%w*6G*iOe=3rJw9{*(nEUklv@gVJ|5w=K zsJIa21jX6>ea2~(BWIayWw@-rfzrg4bdN_S7@zG`VxCzd~r(Eb6qkiK-K)lb8k?TI%c<* zAmm#dhvHpZ>V4^{5!Z@(oEY7%(3o_Wbxw-D>RuB7eIbxf>^i@RHaj0$d=HFf=FV%r z_GxJQ=i1g&RbBvJzY0t)Z>{;r<&me-3#JzEra|8qFO;a!L;nV$?}RhANB?6-?*T|e zE`=2+!ueEabgUVfGuMi4~q#b8jT>} z!U1G)L<=c@v;lY#QO6bjVusPb&X8rl?Fg>ac(G+AwDoVob;d&&^q921>mK_lqE9fk zdcuQ&F9#pDImWet#r_VFa=97S2BRe`|C(Y<7G8M7KL97FIigoQxFFM<3A`b%J{vg+SNy=7% zYGX1FllqqUKfmME=3ug!emdmpKW;wND)7an$Do9HPbKFo6spBY%T5LJRIqvE(0{gy zs9o6kYRB{RB?Qe08mgm>B2g&x%baY%1gJ3zO1keEye=r6&eM~^1NlLi7sgvpIVx-s zKSV`>dx`rpDM{eg&GwMNK@pyYTHFrDOZwm5ES-F@Y4yi}&7 zc!8U~FVsY3PRZeI^4nR2Vo9y|q0Vjrmn%es<=+o4!}OJd1a__8PIDsQ6`pTSv?*8( zMV(T3T`W!0id!V!6+i&YM;yw8H>tb}n2-^k!V$$O6suxBvjG6Dze8mSc zfZq#?%Tbe##iSOs7Bv8>rQ)cj4d(2@_A@L-S-z&+p^?x|G4jJGCX%24SF;S zOZJPW$M=~}s4ce#{4MZ-pLL*M7B<}+fKBw)r(nIh6vxY66*IVrVi0@Rs$n(KT^&iB zznS~w6tZNLc)(RufdB16sm6qPXqG<$xBI1(hyWUOuiZM80jf?BCp~?|?N4z|<|=2$ z=yn3wjtHRh`qmf(Bs|O3Cy`JLuyHu@Ceo5#0wBjPuVk^N(u-Ni!e?fd!t1h#i2wrtkXLY90u zA3NIIALtf>RBD|vdMo@}MS%c(y7FRz3F|OZNwMl+5s+KT#8-Wfs}X%&2xQA6DBzB% zvC_-S-m!%}sUuIAI9s^LxPW)nYa+UYyE&2~+jLu!yhw2zMbaEjq$>0>(e&d4s0La zp^{(%Y^w|r7`9n4u7V;M<)xB_ij}&DO^38d4pSS zIDyH0|5r6>eu(F2WyhaP-TVQ}&*nSg_MA?(igUXu)U^I?Ejo|C)EhlyI7f^c*%Rm| zO35p|S=2bG^UpG!#upn=6yJ$TNU8`_`HS3N*yAeuJ6!-IP!pIijsEuY>FX?5IWfGC z#YI{ktfsfANOkJqCb4dkS916ZBw!|P%TMvn$-Jh>Aihes;Spm%+Dp#`k5Esj2y^IJ zRJ;>=`PitOi|sDJr#4^Pj5A_IqA*uK|BdSNf8E?3bcz*OFiNg7zfP?Cphd&!AG)J6Q^ ze3PnCNk|=MT9NeR3AU9-EHvGx;Ly|eNQGN055DPfByfpL&i=DJ5_03Jm?PadOPP*p ziJhd+^-M9*%JYRuu(lwcNlDnoU`fEQ+_lQHm3flfnOatdI)jhE^%I`bkc@avg8}}w3!7br9F?A?Pgu3sjH&lImkkTbbyIH@GeV4e#j?w2#c=I zfugj}!=i=r*w!u!qG~PkJIvkbfOgrdw>5n0gJfcBr`MP-x6Tf8Tqd2^vls5>RHF z%DK&7bvg$A>{6~(s|_e(nL~J02E5>h*P7S(qqsm52VU8$JVN(@{O?C?OD6rF(<-LZ z|MsD(*m3y$?0NeYbnAA&#@x;|Ae-7Kl=cmS$BG9Q?~k3gSvj-wWB@FbtYRAqjw+JI zsD*9xu%qJPF1WOy=^6MVY>_u_i6()%t`4+9+iU9_n_BbHYqgWytpmW2_z5Zx^I7s2 zA_f-v6ve2J7tZ6KZ}%|ve2`I8UX~+^pH*G_I@c7g3BA(SI-`wJti)l2_DA@y-|=># z6EWY&3-&08VEyt3+{OPPc!qqFX6^t0d)>ch0W6IU>lMIHU-b>GDWD}by;~U4D&(4` z4eKtDM~Hy>S}LKG)kbamVbRguXHv3EABn}hQQE7X& zKN(S&te9%m(5WF^t~$x?xSF9kzwF6Pvo&>xOY4Sl&$Z^ly)}Li7VeUr!EPPCFAB^L zE?FFSTZv9??QPSe+e_*Eg(HPxOk&va;=cS2vN6^p#d_yt)-;_#vkfP zxR-3$0|$ie542$q9GJCUKq5qV`U<2@dG3~4i0p*9YI!d7IzMzW!`zm)@2K_o;1pmC zz0(aS0q9U9oeUS}UJkB&Z_nF%xs0=)6zR+l+wUMxVYC@FFkml_{MP&pL-Nx3aE`UW zwSB`d_H|1akN1g%@Z-#9D^6;+s?1D!Y6GU3tJ2U2U8-7~-gB3&fzPFi#56R~hI`4$ z?>yBT*;0NTb?q?S2f^WHsHx~5aV-h%9}%r=MG7 z5QH}ApT@Wsjzx<-#%I~=4MVI6&5832XzjcFu>318xPFo;8k%DAZM@rB5oWqif`c*L zo^#TF@b)xv^XLFs0E0tv6ir%Zli3?>`F7qgAZ6V;$OYzk%=ULz;7jNL8arHcmg$;G z_rL}KX;4SkwgaH+WJCT$8^;>Pw~^9bD{cgHVQm_fe3**y-#rEIxS(oV<6Y?M;x(Sm zQ{#H7O{)z4Ia{uyk(-xivRr2Y=|UorELAVxiIrg*8lyAbBO)PTe*5-qtdO5?noc1x z4C2&0j!K?|+QIaW^q?|$eg%P>_}_#)%CJ9*PoKIwpI~;9k%yb(T^OTDl6;;$sD`_O z8M1a^sp&#~#Aa+}CXs9O+7IDTBblwnsQczk7Y$cr_l3CY#O? zLE?G7o-u&|AgT{Blj+8R#_*#9bm08_AsELf7rZ%MNcsmKV32|gmXpS682H?1s`Vr| z{r=By_CvqMI4qz6W+eLO^jIRXB?!%rFkbJUOEfRuC zV5;o%czZfsrp3l{-g$h=c4RZsW_M*g9g{dA|u z*d(`U4)K(%1dz91cKT zDFXp&#rAg;7Pg4Liy*%QdW}Nau|#I(b>P<9YtB=+f6H|nRkMuuamee zr^Q&>jyZ^{hrIzXE0Ooa?Ay_LThq=XKysyxhG>kp?au z3TxHl>F&a5#j0rJi$w-Cz<4zd%kFf&zUa{?rJ6PZsh6j(Zmv!C>u_4_KyRuJP#78^ zA%LI&2rKb#oxdA3i@$j98|zTm2p|BX*NY(5v(Cp#CQzq7P$OV3 zrrx_L7UL{HQ&uVj?2k7wOHN%qHUN(PON*0PPh!VmX<0cZV6$<9S&K#A!{Y$5fVQc&osN!6^DXwiSgx5CR)GGH%Fq8aMuv>NAyHX* zPbOjTLbKJysttzA?XFy}Nx9K>`Qyn-1E*LVrTWVoU8e~^i){+awPs4K9|J~Utpu#+ z=;FQ=*IGhvCvkfm@?&8i{Jc78STu}N&Jai`jf$sz?M zsB|HXn#HQ(9?CwJf`xEcWYbuPzZ8WKJ|4SJHCs4kM`)I6jxdfIIQzd@ZDOBT!vNkk zIq=|3lj1NNmF@Wse}apJZh_OY>MJ)y`whOhI1d#{*2QK3ltfZ=)oQA9aKcm7O-`A&U0|Hg^l9C*H@R9hX=ywq-^aLIgSK4 z#jLP!y2y5Y7oW>MGsB9k{6cAw1YQOtxr`aN0R0lX;rF@@h43?4{Y?}F7{;+IzZa|D zT4cpBI*OnR?c7gD8f`UcG1;5LO#w{;luGEJ)SjiC z8ns#kG*aXTDh7!8v6=uM)aGy0|Qn==t?VJ7(g;vh|w@-54ONpBNgusgyA>! zKpPG9_bysZrvt<->mXUlOEVf;+Htv}_+zr&AQuKChy zaYvdD#1Q(HP7c~kho?Bd_iuGKxK>~OkJGBAhI!fd6Q0BJygBy?U(UvXJ47>Lg4qI#}CO*yJCX5v?^~6pF ztW32RT^(Z9Tj^0*T{+PgTj1bCgvEFtgVFHLZlky0Ug49sc#C8&d4y3vROSZB3?jTA zu4eld%b&M#%JE|;p_u-lM9FKkb$cs$fE7R7TK;Aa!~ngKnNlEFAc*Vh}z4jRLRCKk)(nJBXV!h zcc^*^6G&p*p?k8-XK~&Jgk0St@rAzaU!eckqHRKsA5+{2Cod6eAYBP8(k`i2`MBBM zT(sarI#n==)IPg}3lhGvDe`&4P+gm5?Be58{1hT<^f%J#Zf3AO3LrA=&p4079M>b(OxWl`{}0e^JRzJj%;_W=8@rQ68A-Q__2%@TPlToI6| zOaRJuyP^pa3zUXqAirm`jI=%3%42~h+L>CgJKua{czFDj$fIKC^NpSYpt>iG064SF zwQikx#G3EpNo;hq(d-@IHFArI+-(N-3i>PDJ32bPRZ(B`51)Xy`)F@43TMEt{t7_j z^9IyWEMj6tlcC6jx1@skR3+f^Ty_V?JLjnHO38NQ)M&ioEgOg2D6kAQpN@lC&zoWNwqiLM~J z0@J!Gg`Up)-fH|3P*HUC8}`J&O~)Ux`+Y#M6VY&4#|0{Xg3qevO18ySbrXx8Ow zcOu1Z6VVm^pMSjgUmzh6L#>c#`aP!A5Fo>8Vmf+)Th-ob?GZrQWSWcOC>a8-u`>E0 z!m=&&_Mc}KA`o@o1Esd?MyJn&Gy>n7ndoX83P%Ekw2Iq6jPwO4OZl(oGu~6dIxuKnUX2Dc1~d*LV9J{MwvmWVGX%Vzl~RmR~1Sr};$c2wt8n0ld*tmOt)@(AWNV&H~rR%fIuv zV{y+v*wskUDy35WE>hvOAclpRWDvtAqN_9R`#~f3`g{u_6!NI%AE*wFsm%!x_$Xde z2hFAa2>8_rEC#K|JJo$;2<3d!P@-nBk^cY_`Lj0bM)~zcW}D5VDsc8#>^Ez(6wmmh z$~^&2gF=_@v*M_7GFwk~sAi#wcG>5N;|ZsoQM1C0X2(4K@HYe3|5NH8KFP~J(blVt z70a!%&w%BqM8s=vd&m*l2sm^-VnK70EUM;zdV?o^475E1Edq*Jll8nctAA`FkI|dG z_sU&@t~!>)>M+%QiwXtwDzzo^vX5p+fSN-g-4WP|iILiy7NacFpoPiTASP?W(%iYXt9 zCyMJ(VH7>t#V9U>iFs_UR3l>6mimA;je`QrW4F*eR|a@aP6q`^soOqR6TC{XzeQlK zhf;pXodbc3Wf0JHpndhIFX1KgISdXO3KKRR#;CimOWZk)%7g(QeU`}rZ8~S?{mxM{ zhW-mgXd-B2d8oLZD~g9?>8JSNf41zQpKsE6ops!fk4SwL3#1IKM@9AsdA-|0ch={& zTbZNcgY5<=_X<>@-ECYVDBe=GDWIpc*-=HGGZ#BbI74Hygn5cVn14xg=wuJk_sbyf z0{Sr9YFWVAe{+v#<04zO17H#5-#Sl#GvM%pruczZAJEFFbG<*)2X&HwtE+PRI1I3J z-rMzKJk$V^5$5#(?90@?Q04IYek5tcK{8+9v_ehyF9+~@a}Z|)?EVGD74eATrZCh8 z2S^+`^f{YWQ2YSqfF{O8t-RSOG?g;Pocp2kUWR+BHgO1E;3%-O?bPwlbg#|(oSgtc5v@###E_4O4U>*vaa-LQF zfnVF$2hNPso*Xl*rZQydx@DDf|j%|YhAjJ@%zG{)wPAbdmW-kINGj`e*#%VPQ_ctNq|Mzk@)baZu>I^}*!`k8S zgxI0<&B|{5LjOzGkjm)5DRC30?Cx|BVMcm7!`Ns0*|S@O(u0=p?DjZeZ-Yh-$Q^1| zAP&r8-fvg*-e{2df$lzOtMLb;wHwc=A6e(~cWq~e*jY`6yp4CA!C#072yyAVA`L%~ zzbBwvG+a=2XLJ;bJk3NNhtx2^UG!B%ju7l>)G=o^rJ*^@=~ZyCgNkG4C~7a z9SQT187ud+*;ylt$hSTC*!nz$-XV}|*DYk&!6V*DETZLm=i2!RuM|r*NjCe2^X%Y| zA)<*^VRjc6D^pbID@tvW`;^}8QGYI!5hS-W`d>zjI$h=WASs+V1ULnTzM5{i-EjL- zL^!%W6#OdtY+!CAnAJg=1IDdb(s$xJPa>4?uy6ls#>?on`nv6YoG_C1oSv! zbsOA}|9teHZ{mMrPcR*C)Ho;Q7oFi$8p>|eXs9<9C;(GbA$cTi>Uf?Gi;TvLl?FHX zx?*lq5*y$y?vmsBe~`Fmkh}FGFzoc*;xP;mjf;Q3JcM2L+Bipg^I&vuo;1{xd9qZ) zsQ2(25qq{7CNn{XC9TM_b14&948;J8kuC<)xRx4_q&z~niVgJNKsHsc%)xja)68AkKSHchA4e{F{Gm_e(8})_7^mrKgA)hD3`T?( zc@p1J4J8>GsIb*`sfjb~#BybE3JMXUj+d?*zs;ugiWE)`rO-YJz3yh%&%W*`tC!2x zAJ0u@7>IEg`l$)ucY@scn{2o*sLb3PHMmdT$l9h%Yg%3dw^+GN7HM`&+TP^X_!*hG6l0(D#_^Gnq-Ka8x_{g73%|wR}$xA6*-7zTuva%(sb7343NAl0TeOF`qNM$>V zGmNI&&04yz83@BYXRb-QTA{M35MZd9@&;=X=tb8aE6Qbir-85)(~@`U9tC}j)h`lc zb|3lRp1{VYLu4n)^@kN_b9#7;HNJ71_+b*(dVw!~g0qSxAI0e%y3>}GV8*|~u|tKF zSa{RO(qBzN7fDQNT9u|3mL(;#UfWArPG)B-;Fw>Nu9tGusK@YHxk{-mhSK3*j7b|L zQohfM&o63~-T*Nc`4TN|WnC@?=Bp&CVGsCsIT~rmv%sXaFU>A>I1Ct8Q&1v)tet{i zQqwin1sWMx9Yoiai5BQTyejHalB1l!|^RqptXb)l>+Ki z$`~+_*QCa<3&Ii!vyvG$P(gG^rMeFCvRV{X@c@Ht?KAcToDFUDk@9NnAz~GEznwN4 z_iX~}+`)vvG}@y{$#`(yX>Ch<;b@Yle>Ow z-x>iNI2$S~`fj4+4bEm5xbY8`l}!YuFBKdbpwyLlA}oc3>wI(<=3LR-DLS>Sv|1dV zj!`JE3XJW2*6za_B}YCbW)YFgoCC8`w6sI9z0FN6+8|*CrfZ5TYl%6?FBRTmD@OEV zy3c#MDoRP0Le7U};hldlpX~JD(kmItUL%_!r<{8pE6^R3*4mBc{eI->YNzX-bA6ga! z16SO_D=Z+d+;xeVA^;9dpDzflw^5s%lb@ZgAw&>zC2(!E)UP+v$kok-cIo(w=u8xx zDBeQ6r6DiaT(6?xWl(9FiWn?PB3Ug9bx10+`t^ zT)S(sBS6heYRX+-f9iWJzWdTCFhb_{Frn{Uxn97fUj@6kJ{1`QZW#BN$Fpz!M@3&6 z$!{4n`m)csmER||QHEDbLpgQ);{I+bUp||f^X4KkpX*4|zuvPiwv?!A@YVGigWGD?qR_d?B3M$)pBlNLoXX_V#zqYScAiCE4=R(tl9o(Lw|B$mI^ z+{bKgJh$ucl$u`-MBmTKJ>!4S8m%1e7G2f*2NzDV@W(|L0XM}z1fQEg@1CiFbi*UJ zf}#8`P42_z3W72>1Hux`%IJFSUy5Q69zJb~zhD;ViRbC<&j|Rcx#d7D`sJQg zj8=$e<&EX|)s&C>Tq;*T3T&&JcLPLC17XUBN)ig(Z>?!^juu&HfPm)U=r{vzhxbh@ z!va(lv<}D=Q5XTiQru{C^tW;|FJjK@Ru8cX&+j~&Eb@E+=kO@lToG+2XiqNeHSrDg zI!1hNK=Is_Nm-!KKWt7arV)RNWZk!Mvkh{(U#C-hFc2oJ-Yk|P!`02X<+ubcytoRg{tS9d29H-|*OckSchGB~N(vVJM8I&?F6 z$`1D#Mx_5`IV%GhBV9C#zR+E?($xaIdf>a&f5P}i2(v7B}&Vx2CR9uObc^lR-Y#W36I+^~sN*VrZ_}0Ri*OX|6%6S@fdGPKS?* zU@(4}hAQ0dizKWWpZ{5o>?VE$1-n|vg|x|5LD3lpl7Q0uzWXchZz^Qc_{s73`Pl?L zMeD!!2p>G`5YY_iSi3}}8cRkq@=JHZThhiU!<%l`p*bh;$a&2wm;hQi&cePLI)EeP z#Zw#cM+0$VYF$OV{D(P!st0|P^aEK#r8Lp??D~`tTOTDO*_3y}`svfza2XV@qc*Ef zl+Z$4PAw*)n!ts9`*(5mPe{KP(-a3rct0OWt+gE9Ny3VgX60*`dUStLygZw0;4zTa zgJSCR4>-)%ZDgR&BVms6wnFigRN=xu^rt0RP3Ni0Hf>DB-{w>s8wV}S_Gqwz+?eqK4Y&Vu8WYdm2`GEYg%LHCC) zgqsMwJN$<=ynA69Kb7H)bv%RDN7fiO&-1hP^mDIr)i2jz@eWxtonS)gW~1xXh!EQd zjCM5s{6ec7K}*l4g|`|RigPioQ`)m=!3w!7roA^iX@{gUUU)nuCi+o$ z>w%|hxc`{_JlU!J4l-9#rlPgGIzOTGN_R~%wJrLsc1 zYO57_=E57Z@C8k!@cAX4#+A~BntHV~)rN?#a3(#7A{W8A#pQUI_~PRDcy1O=d+s3) zg5hC;W9x_Omt9D8S9hn+lTW8lU5|H@{Kr-}|9lWhprx;v^}^gLRC=-uT5=wHxi&jx zMTyF-+(dgdHON_^&2bbjQj9HKZqP=e@Vh2yV4h{Ni>PH^8$`}^S3Dhg&yUGf@#+Hl z2j3GbJsUKWE3PgJU%c3NiLRJf5EC!As~`Bm5T#J2T-JS(4ac&bSa?wOP=Wuj^-27R zW0LZd%m*Q%3A%FVWvL=IAKn^G^Z|XrhyOr!rFAY3Cv!)_!sw?@e$CPT-<{w7e zV&7wJ3>tRB+e&N$W z*TRlYE|b3g1A8Ci>$&h^EI&HuY5kin%7BcxJ?1;q6QTkMTg}*sh}YlFFNSv)>I4nL zg^vD3Lp^$jK_>Ef+}}5LuQ6bLYX&_K;*(jqoXhrg#HrP{W7NtWJ8dl)-{gaxxWTD; z-@9Z+GY&MQ5zp!uDg=w5CAQnwbktnW z6fG|6-ah^9^}l;})lV8{SuL}sP-fPbxQPyGaFC-8yi864Fvgz(w}PJ*~er6a@TxPNoSW9)l^I8;&A5>Eek zlyCbG7RYZI75{#Jde94fZ7PTmvcxXNa<#OV=fg$CQM|lPfW+oe;Hi=P11lM0P}1)% zD!r130@?arD#j6oNX+@sSnaVzl5w6?61->1u>v61i(4=ZV@U7d-@@kBTxim968tRTi-doE=M^(5UpDkB2R3 z?>1mtALQ`DtW2CnOtXj#;W{QpWd~iZw{4@P`3;qBnkY#?J9#?3#?dhag;!mF*;~h# zruAJ)Fe^c{#5j~eh)rR!T*G-iFiv>7OTpTb_Zp9h5c#U?Tw_~X=QANkfq&?{E zG9P*m)o`Gjil9Z##QnuH&)`{B|MB|*ZU558DRobD3eJ>n8dT{r9^#`E`7ln6QLfw7% zzq&f_9TFnT8$~P$%ti{&oEU!+k9a^SX*W>G?DVLlCeZiDN>tyW5p9rTm4gK_$(|6WPXAk_UzZR{TCFk zkK4}7(&g?IAa70J@SA&>94`u&iZX)?_0brVi=<+-%C?ezzosuD5nYzPQ|97tB7jE< z`GlH(TwvN1(2L?{^snPFfVY=D{4h%RH^|w7CB4PsTYzr=#;{5U6xIOUd+i#6-50Lz+`_-jm zdr@U+%I~^4!|%S&xbI8=B+b<_aV2Cn8`0E(p=;6)IkpO=r=m37$ev-P>vs(KtBua0 z)AYDM?k==pfj-XCOboSp{8wE-Cu$yRdRU*iq;@MAA+`<~dfnx56vIUYOX!o!7A|KW zbMcnY9#_tHs@=9(lb~hIk4;?ED7wQ~2yCfwEI(tj%g3sQ+1g{ri3BB9pmfC_K&udLRTl>uV<_wZmiFLVm>rr{=#QD*Vjwv z!CV@E|EXt_QnBZe+h_A~rzmU9cjdg*JK(!;D9M3!9jNU%XZr8#d}gD5AoIo_DC34Pmve)`SLtB$|XyHkU>IKj4WHOEG+li@oo zAjXXY{MhgPYl~T?w-?Cu`_eN_v}?3dRN<9B=@^$E`|m#zxY=XU7vS9L$yB zpD@7=di_AEU|pb|xv~4ok>f@(3d&IQYgi)&(~uK-T7=lbW;(B8%rlkeR8{};mYaRY z{o@lWsbX1R(`w{Y3VbzBS1)H08!(?2;`8UT9*@n4?eC34B+FC-;j)a#Pe#HCqqmrP zjnX@wp5mxGB~FY-p9iHZKAO6xsrx<_wlfC>2g=;vGpw>SQQ80~cFxLs4Xdy)b%>n< zx-z9;QyNlA>(1VlwfK4alvK&n(>w5uBeqXcd%P2Y1)=oi=$V2+!Xc9E_ zR7zt=Nf%>AiR-;OHM0U@BsWnCqB7_i^ksBkpTFa^>*0G=chZ69pIan?T#Zowcm0^r z$y;|Njqj)=#l9*RGMfJU&qx3HCjPT0{_`lj9SZ;Cv&+gBB})aeYcod8ZOW{r5P$i< zJLU<j^|z zm{F@gfhgOFoV_~z@r11A%Z{(l{rd#>kR(d^(Ti{OXpaiye@Ggkvq}qj_3;kVW*h&LXnG^~|=W zfqC_06Lx4RREzjHD38c?V)caQtIp()_k^Z)b`>J3__p3#bgOsvWp=(@^DK|R)-|sTU40m9fc*bqBckt@+`%gO?<8wD7zR^yUy?}|Dq?;gX~#%s~57I zfs)k_d2_b?A zQ1hJ5{tmoD9-@>sUi}pOXFSh0djysLO4{Cx56G1?%z_PgmJn*N7Yts&wl|**3=D4S ehq_*&87DBei5kkrfuA&ml9N*YR4HNf{l5U&U+qEw From 0ed156e9b1b6ea24c51e58260dc7927742dc680e Mon Sep 17 00:00:00 2001 From: Anton Urankar <124867229+antonurankar-moloco@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:00:35 +0200 Subject: [PATCH 02/16] Forget an inline fill nobody asked for, so it cannot gate the next pass OnAdMobLoaded set _adMobLoaded unconditionally, including for a fill the AdMob console refreshed on its own. When that fill was not displayed - which happens exactly when CloudX is the shown source, because ShowIfWanted refuses to let a non-pass fill take the slot from CloudX - the flag stayed set. ReadySource then reported AdMob, the scheduled Load() returned early, and since nothing went on screen no PassSpent fired and no further pass was ever scheduled. The cycle stalled for good with CloudX on screen and a stale AdMob fill banked: the original latch, re-entered through the refresh path. A fill from a pass may sit unspent until the slot is shown - that is what banks an ad for the first tap - but a fill nobody asked for may not. ShowIfWanted now reports whether it displayed, and both load handlers keep the loaded flag only for an unshown fill that came from a pass. Nothing is lost by forgetting the rest: the native view keeps the creative and the next pass reloads that side. Found by Copilot on PR #8. Re-verified on the Android emulator and the iOS simulator, both fill paths: 30 s cadence held (Android CloudX 30.2/30.3 s, forced no-fill 30.6/30.5/30.6 s across four passes), an unsolicited AdMob refresh at 14:55:46 did not move the next pass at 14:55:50, the preloaded fill was still banked and shown on the first tap with no new load, and hide then show re-showed with zero new requests. --- .../FirstLook/FirstLookBannerController.cs | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/Assets/Scripts/FirstLook/FirstLookBannerController.cs b/Assets/Scripts/FirstLook/FirstLookBannerController.cs index 74054ce..49fab25 100644 --- a/Assets/Scripts/FirstLook/FirstLookBannerController.cs +++ b/Assets/Scripts/FirstLook/FirstLookBannerController.cs @@ -47,8 +47,9 @@ * plugin has no refresh API at all. Whether a BannerView refreshes is decided * solely by the ad unit's Automatic refresh setting in the AdMob console, and * publishers MUST set that to Disabled on every unit used as a First Look - * fallback. Google's test units do refresh, so this controller ignores a fill - * it did not ask for when counting passes - see OnAdMobLoaded. + * fallback. Google's test units do refresh, so a fill this controller did not + * ask for neither spends a pass nor counts as an unspent one - see + * OnAdMobLoaded and KeepsUnspentFill. */ public sealed class FirstLookBannerController : IDisposable { @@ -275,11 +276,12 @@ private void ShowSource(FirstLookSource source, bool spendsPass) } } - private void ShowIfWanted(FirstLookSource source, bool spendsPass) + /* Returns whether the fill went on screen. */ + private bool ShowIfWanted(FirstLookSource source, bool spendsPass) { if (!_wantShown) { - return; + return false; } /* @@ -291,12 +293,25 @@ private void ShowIfWanted(FirstLookSource source, bool spendsPass) */ if (!spendsPass && _shownSource != null && _shownSource != source) { - return; + return false; } ShowSource(source, spendsPass); + return true; } + /* + * A fill from a pass may sit here unspent until the slot is shown - that is + * what banks an ad for the first tap. A fill nobody asked for may not: if it + * is not on screen it has to be forgotten, because ReadySource would + * otherwise report it, Load() would skip the next pass, and CloudX would + * never be asked again - the very latch this cycle exists to prevent. + * Nothing is lost by forgetting it; the native view keeps the creative and + * the next pass reloads that side anyway. + */ + private static bool KeepsUnspentFill(bool spendsPass, bool wentOnScreen) => + spendsPass && !wentOnScreen; + private void HideCloudX() { if (_cloudXAvailable && _cloudXCreated) @@ -358,7 +373,9 @@ private void CloudXOnLoadSuccess(CloudXAd ad) _isLoadingCloudX = false; _cloudXLoaded = true; AdLoaded?.Invoke(FirstLookSource.CloudX); - ShowIfWanted(FirstLookSource.CloudX, spendsPass); + + var wentOnScreen = ShowIfWanted(FirstLookSource.CloudX, spendsPass); + _cloudXLoaded = KeepsUnspentFill(spendsPass, wentOnScreen); } private void CloudXOnLoadFailed(string adUnitId, CloudXError _) @@ -460,7 +477,9 @@ private void OnAdMobLoaded() _adMobLoaded = true; AdLoaded?.Invoke(FirstLookSource.AdMob); - ShowIfWanted(FirstLookSource.AdMob, spendsPass); + + var wentOnScreen = ShowIfWanted(FirstLookSource.AdMob, spendsPass); + _adMobLoaded = KeepsUnspentFill(spendsPass, wentOnScreen); } private void DestroyAdMobAd() From 8e479ca930d186aebc04417d579f87ed767b2990 Mon Sep 17 00:00:00 2001 From: Anton Urankar <124867229+antonurankar-moloco@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:58:26 +0200 Subject: [PATCH 03/16] Trim the First Look headers now that the docs page links to these files The docs page used to carry its own copy of both controllers and now links here instead, which makes these files the thing a publisher actually reads. The 48-line banner header was written for the other arrangement: it re-explained the whole pass cycle, which the page already does, and it cited things a publisher copying the file does not have - FirstLookScreen.ToggleBanner, GeneralScreen, FirstLookConfig.PassCooldownSeconds. What each header keeps is what someone reading this file needs and cannot get from the code: what to copy, the reading order, why an inline ad needs a pass cycle when a fullscreen one does not, the two things the host must do or the cycle stalls, and the AdMob console setting no code can apply. Everything else now points at the page. The reasoning per rule stays where it always was, in the comments at the lines it governs. The CloudX placement and custom data strings say they are this demo's and are the caller's to replace, which nothing said before. Comments only - no logic changed, verified by diffing out comment lines - so the device verification from the earlier commits still stands. --- .../FirstLook/FirstLookBannerController.cs | 68 ++++++++----------- .../FirstLookInterstitialController.cs | 26 ++++--- 2 files changed, 40 insertions(+), 54 deletions(-) diff --git a/Assets/Scripts/FirstLook/FirstLookBannerController.cs b/Assets/Scripts/FirstLook/FirstLookBannerController.cs index 49fab25..7abfd04 100644 --- a/Assets/Scripts/FirstLook/FirstLookBannerController.cs +++ b/Assets/Scripts/FirstLook/FirstLookBannerController.cs @@ -5,51 +5,35 @@ /* * First Look banner: CloudX gets the first chance to fill, AdMob loads lazily - * as the fallback only after CloudX fails. Same rule as - * FirstLookInterstitialController, but a banner stays on screen instead of - * being shown once, which changes two things. + * as the fallback only after CloudX fails. Copy this file and FirstLookSource.cs + * into your project; it is the whole flow, top to bottom, with no base class to + * bring along. Reading order: state, the Load/Show/Hide entry points, the pass + * cycle, then each SDK's callbacks. * - * This file is the whole flow, top to bottom, so it can be copied into an app - * on its own (plus FirstLookSource.cs for the enum). Reading order: state, the - * Load/Show/Hide entry points, the pass cycle, then each SDK's callbacks. + * A banner is not the interstitial with different method names. A fullscreen ad + * is consumed by being shown, so the SDKs' own readiness answers go false and + * the next Load() starts at CloudX again. An inline ad is never consumed - + * CloudX banners report load and click, with no show or close callback - so + * this controller tracks a loaded flag per source and spends them when an ad + * goes on screen. Without that, the first fill owns the placement until the + * scene is destroyed and one CloudX no-fill hands the slot to the fallback for + * the rest of the session. * - * 1. THE PASS CYCLE. A fullscreen ad is consumed by being shown, so the SDKs' - * own "is an ad ready" answers go false and the next Load() naturally starts - * at CloudX again. Inline ads have no such event - CloudX banners report - * only load and click, no show or close - so this controller tracks a loaded - * flag per source, and something has to clear them or the first fill wins the - * placement forever. + * Two things the host has to do, or the cycle stalls: * - * One pass = one ad opportunity: CloudX asked first, AdMob only if CloudX - * fails, winner displayed. Putting the winner on screen spends the pass, - * because a load into an already-visible view renders immediately - so "on - * screen" is the one moment this code can treat as "this fill has been - * used". ShowSource therefore clears both flags and raises PassSpent, and the - * host schedules the next Load() one cooldown later - * (FirstLookConfig.PassCooldownSeconds), which starts at CloudX again. An - * immediate reload would be a request loop, since the new fill would render - * and spend the pass at once. + * 1. Start the next pass on PassSpent, after a cooldown of your choosing. + * Reloading immediately is a request loop, because the new fill renders + * into the visible view and spends the next pass at once. + * 2. Cancel that pending pass when it calls Hide(), or a hidden slot keeps + * requesting. Show() starts the cycle again. * - * The host owns the other half of that contract: it must cancel the pending - * pass when it calls Hide(), or a hidden slot keeps requesting. See - * FirstLookScreen.ToggleBanner and ScheduleNextPass. + * Set Automatic refresh to Disabled on the AdMob ad unit you use as the + * fallback. The Google Mobile Ads Unity plugin has no refresh API, so that + * console setting is the only thing controlling it, and a refreshing BannerView + * swaps creatives outside this cycle. * - * 2. AUTO-REFRESH STAYS OFF. CloudX banner auto-refresh is opt-out: showing a - * banner starts it automatically unless the ad unit was first passed to - * StopBannerAutoRefresh, which also gates LoadBanner. CloudXCreateAndLoad - * below therefore calls it before create and nothing here ever calls - * StartBannerAutoRefresh - the pass cycle owns reloading, so an SDK refresh - * timer would compete with it and could swap the ad out from under the First - * Look source decision. (GeneralScreen restarts refresh on focus; First Look - * deliberately does not.) - * - * AdMob is the half this code cannot control: the Google Mobile Ads Unity - * plugin has no refresh API at all. Whether a BannerView refreshes is decided - * solely by the ad unit's Automatic refresh setting in the AdMob console, and - * publishers MUST set that to Disabled on every unit used as a First Look - * fallback. Google's test units do refresh, so a fill this controller did not - * ask for neither spends a pass nor counts as an unspent one - see - * OnAdMobLoaded and KeepsUnspentFill. + * Background and the reasoning behind each rule: + * https://docs.cloudx.io/en/unity/integrations/first-look */ public sealed class FirstLookBannerController : IDisposable { @@ -349,6 +333,10 @@ private void CloudXCreateAndLoad() * on the first request. CreateBanner also issues the first load, so the * OnAdLoadSuccess / OnAdLoadFailed callbacks that drive the source and * the fallback come from here - no separate LoadBanner call. + * + * Both strings are this demo's. Replace them with your own placement + * name, and with whatever custom data you report - or drop the custom + * data line if you report none. */ CloudXSdk.SetBannerPlacement(_cloudXAdUnitId, "first_look_screen"); CloudXSdk.SetBannerCustomData(_cloudXAdUnitId, "first_look_banner_data"); diff --git a/Assets/Scripts/FirstLook/FirstLookInterstitialController.cs b/Assets/Scripts/FirstLook/FirstLookInterstitialController.cs index 54faa48..921e3c4 100644 --- a/Assets/Scripts/FirstLook/FirstLookInterstitialController.cs +++ b/Assets/Scripts/FirstLook/FirstLookInterstitialController.cs @@ -7,22 +7,20 @@ * First Look interstitial: CloudX gets the first chance to fill, AdMob loads * lazily as the fallback only after CloudX fails. Show() shows CloudX if it is * ready, otherwise AdMob, and returns false when neither has an ad - the caller - * just carries on with the game. Mirrors docs.cloudx.io -> Integrations -> - * First Look. + * just carries on with the game. Copy this file and FirstLookSource.cs into + * your project; it is the whole flow, top to bottom, with no base class to + * bring along. Reading order: state, the Load/Show entry points, then each + * SDK's callbacks. * - * This file is the whole flow, top to bottom, so it can be copied into an app - * on its own (plus FirstLookSource.cs for the enum). Reading order: state, the - * Load/Show entry points, then each SDK's callbacks. FirstLookBannerController - * repeats the same ~50 lines of bookkeeping for the inline case on purpose - - * each file stays a self-contained example rather than the two of them sharing - * a base a publisher would also have to copy. - * - * Fullscreen ads are consumed by being shown, so readiness is asked of the SDKs + * A fullscreen ad is consumed by being shown, so readiness is asked of the SDKs * directly (CloudXSdk.IsInterstitialReady / InterstitialAd.CanShowAd) rather - * than cached. Showing therefore makes both answers false on their own, and the - * next Load() starts at CloudX again. The inline formats have no such - * consumption event, which is why FirstLookBannerController needs an explicit - * pass cycle. + * than cached. Showing makes both answers false on their own, so the next + * Load() starts at CloudX again with nothing for this class to reset. Rewarded + * works the same way; the banner does not, which is why + * FirstLookBannerController carries an explicit pass cycle. + * + * Background and the reasoning behind each rule: + * https://docs.cloudx.io/en/unity/integrations/first-look */ public sealed class FirstLookInterstitialController : IDisposable { From 87df8f3d907816be3919cb845a43599736ad8792 Mon Sep 17 00:00:00 2001 From: Anton Urankar <124867229+antonurankar-moloco@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:00:35 +0200 Subject: [PATCH 04/16] Point every First Look file at the docs page The two controllers already carried the link. The other three did not, and one of them is a file the page tells publishers to copy: FirstLookSource.cs. Anyone who lands in it from a repo browse or a copy-paste had no way back to the explanation. All five now carry it, and the round trip closes: the page links to the files, the files link to the page. FirstLookScreen.cs gets one extra line, because it is the only place that shows the half of the banner contract the controller cannot keep on its own - ScheduleNextBannerPass starting the next pass a cooldown after PassSpent, and ToggleBanner cancelling it on hide. That is what a reader is looking for when the page tells them the host owns the clock. Comments only, verified by diffing out comment lines. The URL returns 200. --- Assets/Scripts/FirstLook/FirstLookConfig.cs | 2 ++ Assets/Scripts/FirstLook/FirstLookScreen.cs | 6 ++++++ Assets/Scripts/FirstLook/FirstLookSource.cs | 5 ++++- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Assets/Scripts/FirstLook/FirstLookConfig.cs b/Assets/Scripts/FirstLook/FirstLookConfig.cs index 243d6d9..c229634 100644 --- a/Assets/Scripts/FirstLook/FirstLookConfig.cs +++ b/Assets/Scripts/FirstLook/FirstLookConfig.cs @@ -6,6 +6,8 @@ * When you do, set Automatic refresh to Disabled on the banner unit in the * AdMob console. The Unity plugin cannot control it, and a refreshing AdMob * banner would replace the ad that won the First Look pass. + * + * https://docs.cloudx.io/en/unity/integrations/first-look */ public static class FirstLookConfig { diff --git a/Assets/Scripts/FirstLook/FirstLookScreen.cs b/Assets/Scripts/FirstLook/FirstLookScreen.cs index f51de7d..a7e8f66 100644 --- a/Assets/Scripts/FirstLook/FirstLookScreen.cs +++ b/Assets/Scripts/FirstLook/FirstLookScreen.cs @@ -20,6 +20,12 @@ * self-contained file, so integrating a format means copying two files: that * controller and FirstLookSource.cs. AdScreenUi is demo-only layout and is kept * out on purpose; this screen hides the two buttons it does not use. + * + * This screen is also the reference for the half of the banner contract the + * controller cannot keep for you: ScheduleNextBannerPass starts the next pass a + * cooldown after PassSpent, and ToggleBanner cancels it on hide. + * + * https://docs.cloudx.io/en/unity/integrations/first-look */ [RequireComponent(typeof(AdScreenUi))] public class FirstLookScreen : MonoBehaviour diff --git a/Assets/Scripts/FirstLook/FirstLookSource.cs b/Assets/Scripts/FirstLook/FirstLookSource.cs index 0a8ff4a..613fca8 100644 --- a/Assets/Scripts/FirstLook/FirstLookSource.cs +++ b/Assets/Scripts/FirstLook/FirstLookSource.cs @@ -1,6 +1,9 @@ /* * Which SDK served an ad in the First Look flow. Shared by both controllers so - * a single handler can take events from either format. + * a single handler can take events from either format. Copy this file with + * whichever controller you take. + * + * https://docs.cloudx.io/en/unity/integrations/first-look */ public enum FirstLookSource { From e5d181e333140682a7f8fa95527f606c2a524cc6 Mon Sep 17 00:00:00 2001 From: Anton Urankar <124867229+antonurankar-moloco@users.noreply.github.com> Date: Sun, 6 Sep 2026 12:16:15 +0200 Subject: [PATCH 05/16] Stop the First Look banner retrying a slot the player hid FirstLookScreen had two places arming the same banner retry and only one knew about the hide. ToggleBanner cancelled the pending pass with CancelInvoke, which empties Unity's invoke queue and nothing else; the AdLoadFailed handler armed a new load unconditionally. A load already out on the network is not in that queue, so it failed after the hide and armed a fresh request against an off-screen slot. Load() never consults _wantShown, so the request went out, and with both sources no-filling the backoff climbed to its 60s cap and repeated until the scene was destroyed. Reproduced on a Pixel 6 emulator (API 35) against the unmodified parent commit: hide at 09:30:12.87, the in-flight load failed 2.5s later and armed a retry, then kept requesting at 09:30:58, 09:32:09 and 09:33:21 with the slot empty. The guard is the one the docs page already publishes in its host snippet: a flag survives a callback that arrives after the hide, where CancelInvoke cannot. It starts true on purpose - IsShown is not a substitute, because it is also false during the preload before the first Show(), where a retry is still wanted. The demo now matches its own documented snippet. A fill arriving while hidden was never part of the defect: KeepsUnspentFill banks it, ReadySource goes non-null and Load() early-returns, so that path already terminated. Also removes ForceCloudXNoFill and its ad-unit helper. It is an internal test switch and does not belong in the public sample; the README now points at DemoConfig for the same effect, as the docs page already does. Verified after the fix: "not retrying while hidden" then zero ad requests for 3m17s; preload retry intact (5 retries with the banner never shown); pass cadence 30.17/30.43s on Android and 31/30s on iOS; hide for 120s gives zero requests on both platforms and re-show returns the banked ad with no new load; interstitial unchanged. The loop itself was not reproduced on the iOS simulator, which has no network lever - iOS covers the fixed behaviour only. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01441wbqMjaRA3DKBBZp52tC --- Assets/Scripts/FirstLook/FirstLookConfig.cs | 11 -------- Assets/Scripts/FirstLook/FirstLookScreen.cs | 31 +++++++++++++++++++-- README.md | 18 ++++++------ 3 files changed, 38 insertions(+), 22 deletions(-) diff --git a/Assets/Scripts/FirstLook/FirstLookConfig.cs b/Assets/Scripts/FirstLook/FirstLookConfig.cs index c229634..f5f1e4d 100644 --- a/Assets/Scripts/FirstLook/FirstLookConfig.cs +++ b/Assets/Scripts/FirstLook/FirstLookConfig.cs @@ -28,15 +28,4 @@ public static class FirstLookConfig * requests and hurts CPM. */ public const float PassCooldownSeconds = 30f; - - /* - * Flip to true to exercise the AdMob fallback path: CloudX is asked to fill - * an unknown ad unit, fails to load, and the controllers fall back to AdMob. - */ - public const bool ForceCloudXNoFill = false; - - private const string InvalidCloudXAdUnitId = "first-look-invalid-unit"; - - public static string CloudXAdUnitOrInvalid(string realAdUnitId) => - ForceCloudXNoFill ? InvalidCloudXAdUnitId : realAdUnitId; } diff --git a/Assets/Scripts/FirstLook/FirstLookScreen.cs b/Assets/Scripts/FirstLook/FirstLookScreen.cs index a7e8f66..0d53199 100644 --- a/Assets/Scripts/FirstLook/FirstLookScreen.cs +++ b/Assets/Scripts/FirstLook/FirstLookScreen.cs @@ -23,7 +23,9 @@ * * This screen is also the reference for the half of the banner contract the * controller cannot keep for you: ScheduleNextBannerPass starts the next pass a - * cooldown after PassSpent, and ToggleBanner cancels it on hide. + * cooldown after PassSpent, ToggleBanner cancels it on hide, and the load-failure + * retry is gated on the banner still being wanted - a load already in flight at + * the hide fails afterwards, where CancelInvoke can no longer reach it. * * https://docs.cloudx.io/en/unity/integrations/first-look */ @@ -51,6 +53,13 @@ public class FirstLookScreen : MonoBehaviour private string _cloudXStatus = "CloudX: Initializing"; private string _adMobStatus = "AdMob: Initializing"; + /* + * Whether the banner slot should hold an ad at all. IsShown is not enough: + * it is also false during the preload before the first Show(), when a retry + * is still wanted. + */ + private bool _bannerWanted = true; + private static void Log(string message) => Debug.Log($"[{TAG}][FirstLook] {message}"); void Awake() @@ -215,7 +224,7 @@ private void CreateControllers(bool cloudXAvailable) } _interstitial = new FirstLookInterstitialController( - FirstLookConfig.CloudXAdUnitOrInvalid(DemoConfig.InterstitialAdUnitId), + DemoConfig.InterstitialAdUnitId, FirstLookConfig.AdMobInterstitialAdUnitId, cloudXAvailable); _interstitial.AdLoaded += source => @@ -245,7 +254,7 @@ private void CreateControllers(bool cloudXAvailable) _interstitial.AdClicked += source => Log($"Interstitial clicked ({source})"); _banner = new FirstLookBannerController( - FirstLookConfig.CloudXAdUnitOrInvalid(DemoConfig.BannerAdUnitId), + DemoConfig.BannerAdUnitId, FirstLookConfig.AdMobBannerAdUnitId, cloudXAvailable); _banner.AdLoaded += source => @@ -259,6 +268,19 @@ private void CreateControllers(bool cloudXAvailable) }; _banner.AdLoadFailed += (source, message) => { + /* + * A load already in flight when the player hides the banner still + * fails afterwards, and CancelInvoke cannot reach it - it is out on + * the network, not sitting in the invoke queue. Retrying then would + * put requests back on a slot that is off screen, and nothing would + * stop it. Show() starts the cycle again. + */ + if (!_bannerWanted) + { + Log($"Banner load failed ({source}): {message}; not retrying while hidden"); + return; + } + var delay = NextRetryDelay(ref _bannerRetries); Log($"Banner load failed ({source}): {message}; retrying in {delay:0}s"); Invoke(nameof(LoadBanner), delay); @@ -298,6 +320,7 @@ private void ToggleBanner() { if (_banner.IsShown) { + _bannerWanted = false; _banner.Hide(); /* Nothing on screen, so the pass cycle stops until the next Show. */ CancelInvoke(nameof(LoadBanner)); @@ -305,6 +328,8 @@ private void ToggleBanner() return; } + _bannerWanted = true; + /* AdShown updates the label once a source actually shows. */ if (!_banner.Show()) { diff --git a/README.md b/README.md index cbcaf23..77bdb38 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,8 @@ The status text names which SDK won, so you can see the pattern working:

Left: CloudX filled. Right: the same button after CloudX no-filled, showing Google's test creative. +To see the fallback yourself, point the CloudX ad unit ids in `DemoConfig.cs` at a string that is not +in your dashboard, so every CloudX load fails and AdMob has to serve. Everything the flow needs lives in `Assets/Scripts/FirstLook`, and none of it calls into the General screen: @@ -133,7 +135,7 @@ screen: | `FirstLookInterstitialController.cs` | The whole interstitial flow, self-contained. | | `FirstLookBannerController.cs` | The whole banner flow, self-contained, including the pass cycle. | | `FirstLookSource.cs` | The `CloudX` / `AdMob` enum every event reports. | -| `FirstLookConfig.cs` | AdMob ad unit ids, the banner pass cooldown, and the fallback test switch below. | +| `FirstLookConfig.cs` | AdMob ad unit ids and the banner pass cooldown. | | `FirstLookScreen.cs` | Initializes both SDKs, wires the controllers to the buttons. | **To integrate one format, copy two files:** that format's controller and `FirstLookSource.cs`. Each @@ -142,9 +144,6 @@ callbacks - with no base class to chase. The two controllers repeat about fifty dispose bookkeeping between them; that is deliberate, so neither file drags a shared base along into your project. -To see the fallback path yourself, set `ForceCloudXNoFill = true` in `FirstLookConfig.cs` and rebuild. -It points CloudX at an unknown ad unit, so every CloudX load fails and AdMob serves instead. - The banner toggles Show/Hide and the button label names the SDK that filled (e.g. `Hide Banner (CloudX)`). It sits at the top in both orientations. @@ -167,10 +166,13 @@ Three details worth copying as they are: - **Reloading is in place, not a recreate** - `LoadBanner` on the existing view, allowed because refresh was stopped for that ad unit - so a visible ad is replaced only once the new one has filled, and the slot never blanks. -- **The cycle only turns while an ad is on screen.** Hiding cancels the pending pass, so a hidden slot - never keeps requesting in the background, and showing it again puts the same ad back up and restarts - the cooldown from that tap. The screen still preloads once before the first tap, so an ad is ready - when the user asks for it. +- **The cycle only turns while an ad is on screen.** Hiding cancels the pending pass and stops the + screen retrying a load that was already in flight, so a hidden slot never keeps requesting in the + background; showing it again puts the same ad back up and restarts the cooldown from that tap. + Cancelling alone is not enough - a request already out on the network fails after the hide, long + after `CancelInvoke` had anything to cancel, which is why the screen also tracks whether the banner + is still wanted. The screen still preloads once before the first tap, so an ad is ready when the + user asks for it. - **An ad the AdMob console refreshed on its own does not count as a pass.** Only a fill the controller asked for spends one, so an AdMob unit that still has Automatic refresh enabled cannot keep postponing CloudX's next first look - which it otherwise would, on every refresh. The demo's From a82e78fef9d8759e9732f0e73c90137319929a58 Mon Sep 17 00:00:00 2001 From: Anton Urankar <124867229+antonurankar-moloco@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:26:09 +0200 Subject: [PATCH 06/16] Move the banner pass cycle into a FirstLookBannerHud component The docs page had to paste a host snippet, because the half of the banner contract the controller cannot keep - the clock - only existed inside FirstLookScreen, mixed in with SDK init, the ATT gate, the interstitial and UI plumbing. A pasted snippet is a second copy, and it drifted: it declared its handler as FirstLookBannerController.Source, a type that does not exist, because nothing ever compiled it. FirstLookBannerHud is that clock as a MonoBehaviour, in one file. It owns the cooldown, the retry backoff, the wanted flag and the show/hide toggle, and it depends on nothing but FirstLookBannerController and FirstLookSource, so the docs can link it instead of copying it. Its three rules are the ones an integration gets wrong, and they are stated at the top of the file. FirstLookScreen keeps only what a demo screen should: initialization, button binding and status text. It drops from 390 lines to 332. The pass cooldown moves with the cycle it paces, so FirstLookConfig is now just the AdMob fallback ad unit ids. Verified on a Pixel 6 emulator (API 35) and an iPhone 17 Pro simulator (iOS 26.2). Cadence 30.17/30.13s Android and 31/30s iOS; hide gives zero ad requests for 24 minutes on Android and 120s on iOS, and re-show returns the banked ad with no new load; a load in flight at the hide still logs "not retrying while hidden" and starts nothing; the preload retry still runs with the banner never shown; interstitial unchanged on both. One small loss: the demo used to log the retry delay ("retrying in 8s") and now logs only whether it retried, because the backoff moved inside the hud. The two outcomes are still distinguishable via FirstLookBannerHud.IsWanted. --- .../Scripts/FirstLook/FirstLookBannerHud.cs | 177 ++++++++++++++++++ .../FirstLook/FirstLookBannerHud.cs.meta | 2 + Assets/Scripts/FirstLook/FirstLookConfig.cs | 17 +- Assets/Scripts/FirstLook/FirstLookScreen.cs | 124 ++++-------- README.md | 22 ++- 5 files changed, 229 insertions(+), 113 deletions(-) create mode 100644 Assets/Scripts/FirstLook/FirstLookBannerHud.cs create mode 100644 Assets/Scripts/FirstLook/FirstLookBannerHud.cs.meta diff --git a/Assets/Scripts/FirstLook/FirstLookBannerHud.cs b/Assets/Scripts/FirstLook/FirstLookBannerHud.cs new file mode 100644 index 0000000..4afeffc --- /dev/null +++ b/Assets/Scripts/FirstLook/FirstLookBannerHud.cs @@ -0,0 +1,177 @@ +using System; +using UnityEngine; + +/* + * The host half of the First Look banner contract, in one file. + * + * FirstLookBannerController decides which SDK fills a pass. It cannot decide + * when the next pass starts, because it is a plain class with no clock - no + * Update, no coroutine, no Invoke. That is what this MonoBehaviour adds, and + * it is the whole of what a scene has to contribute: + * + * 1. Start the next pass on PassSpent, after a cooldown. Reloading + * immediately is a request loop, because the new fill renders into the + * visible view and spends the next pass at once. + * 2. Cancel that pending pass on Hide, so a hidden slot stops requesting. + * 3. Do not retry a failed load while the banner is hidden. Cancelling is + * not enough on its own: a load already out on the network when the + * player hides still fails afterwards, long after CancelInvoke had + * anything to cancel, and its retry would start the requests up again. + * + * Copy this file together with FirstLookBannerController.cs and + * FirstLookSource.cs. In your own project the ad unit ids would come from + * wherever you keep them - a serialized field, your remote config - instead of + * being passed to Begin by a demo screen. + * + * Background and the reasoning behind each rule: + * https://docs.cloudx.io/en/unity/integrations/first-look + */ +public sealed class FirstLookBannerHud : MonoBehaviour +{ + /* + * How long a displayed banner stays up before the next First Look pass + * starts. Displaying an ad spends the pass (see FirstLookBannerController), + * and a fill into a visible view renders immediately, so reloading without + * a cooldown would be a request loop. Treat it like a banner refresh + * interval - 30s matches the usual default; anything very short both burns + * requests and hurts CPM. + */ + private const float PassCooldownSeconds = 30f; + + /* + * Retry policy after a failed load: 2s, 4s, 8s ... capped, and reset once a + * load succeeds. A fixed short delay turns sustained no-fill into a tight + * request loop against the fallback network, which ad networks penalise. + */ + private const float RetryBaseDelaySeconds = 2f; + private const float RetryMaxDelaySeconds = 60f; + + public event Action AdLoaded; + public event Action AdLoadFailed; + public event Action AdShown; + public event Action AdClicked; + + /* The banner left the screen because Toggle hid it. */ + public event Action AdHidden; + + /* Toggle wanted to show, but no source had an ad yet; a load is running. */ + public event Action ShowPending; + + private FirstLookBannerController _banner; + private int _retries; + + /* + * Whether the slot should hold an ad at all. IsShown is not enough: it is + * also false during the preload before the first Toggle, when a retry is + * still wanted. + */ + private bool _wanted = true; + + public bool IsShown => _banner != null && _banner.IsShown; + + /* + * Whether the slot is meant to hold an ad. Read it from an AdLoadFailed + * handler to tell a failure that will be retried from one that will not, + * because the player hid the banner while the load was still running. + */ + public bool IsWanted => _wanted; + + /* + * Creates the controller and preloads one pass, so an ad is ready the first + * time the player asks for one. Call once, after both SDKs have answered. + */ + public void Begin(string cloudXAdUnitId, string adMobAdUnitId, bool cloudXAvailable) + { + if (_banner != null) + { + return; + } + + _banner = new FirstLookBannerController(cloudXAdUnitId, adMobAdUnitId, cloudXAvailable); + _banner.AdLoaded += OnAdLoaded; + _banner.AdLoadFailed += OnAdLoadFailed; + _banner.AdShown += source => AdShown?.Invoke(source); + _banner.AdClicked += source => AdClicked?.Invoke(source); + _banner.PassSpent += ScheduleNextPass; + + LoadBanner(); + } + + /* Shows the banner if it is hidden, hides it if it is up. */ + public void Toggle() + { + if (_banner == null) + { + return; + } + + if (_banner.IsShown) + { + _wanted = false; + _banner.Hide(); + /* Nothing on screen, so the pass cycle stops until the next show. */ + CancelInvoke(nameof(LoadBanner)); + AdHidden?.Invoke(); + return; + } + + _wanted = true; + + /* AdShown fires once a source actually goes on screen. */ + if (!_banner.Show()) + { + ShowPending?.Invoke(); + LoadBanner(); + } + } + + private void OnAdLoaded(FirstLookSource source) + { + _retries = 0; + AdLoaded?.Invoke(source); + } + + private void OnAdLoadFailed(FirstLookSource source, string message) + { + AdLoadFailed?.Invoke(source, message); + + /* Rule 3: a load that fails after the hide must not revive the slot. */ + if (!_wanted) + { + return; + } + + Invoke(nameof(LoadBanner), NextRetryDelay()); + } + + /* + * Rule 1. Cancelling first collapses a pending backoff retry into this one - + * both end up calling LoadBanner, and two pending invokes would arbitrate + * the placement twice. Showing again after a hide raises PassSpent too, + * which restarts the cooldown from that moment. + */ + private void ScheduleNextPass() + { + CancelInvoke(nameof(LoadBanner)); + Invoke(nameof(LoadBanner), PassCooldownSeconds); + } + + private float NextRetryDelay() + { + var delay = Mathf.Min(RetryBaseDelaySeconds * Mathf.Pow(2f, _retries), RetryMaxDelaySeconds); + _retries++; + return delay; + } + + /* Named so Invoke(nameof(...)) can reach it. */ + private void LoadBanner() + { + _banner?.Load(); + } + + private void OnDestroy() + { + _banner?.Dispose(); + _banner = null; + } +} diff --git a/Assets/Scripts/FirstLook/FirstLookBannerHud.cs.meta b/Assets/Scripts/FirstLook/FirstLookBannerHud.cs.meta new file mode 100644 index 0000000..2ec45fd --- /dev/null +++ b/Assets/Scripts/FirstLook/FirstLookBannerHud.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8c44f9a7815024a5f93fc019485654e7 \ No newline at end of file diff --git a/Assets/Scripts/FirstLook/FirstLookConfig.cs b/Assets/Scripts/FirstLook/FirstLookConfig.cs index f5f1e4d..c72f578 100644 --- a/Assets/Scripts/FirstLook/FirstLookConfig.cs +++ b/Assets/Scripts/FirstLook/FirstLookConfig.cs @@ -1,12 +1,15 @@ /* - * Fallback-side configuration for the First Look demo. The CloudX ad unit ids - * come from DemoConfig; these are Google's official AdMob TEST ad unit ids. + * The AdMob ad unit ids the First Look demo falls back to. The CloudX ad unit + * ids come from DemoConfig; these are Google's official AdMob TEST ad unit ids. * Replace them with your own AdMob ad units in a real integration. * * When you do, set Automatic refresh to Disabled on the banner unit in the * AdMob console. The Unity plugin cannot control it, and a refreshing AdMob * banner would replace the ad that won the First Look pass. * + * The banner pass cooldown is not here; it lives in FirstLookBannerHud, with + * the cycle it paces. + * * https://docs.cloudx.io/en/unity/integrations/first-look */ public static class FirstLookConfig @@ -18,14 +21,4 @@ public static class FirstLookConfig public const string AdMobInterstitialAdUnitId = "ca-app-pub-3940256099942544/1033173712"; public const string AdMobBannerAdUnitId = "ca-app-pub-3940256099942544/6300978111"; #endif - - /* - * How long a displayed banner stays up before the next First Look pass - * starts. Displaying an ad spends the pass (see FirstLookBannerController), - * and a fill into a visible view renders immediately, so reloading without - * a cooldown would be a request loop. Treat it like a banner refresh - * interval - 30s matches the usual default; anything very short both burns - * requests and hurts CPM. - */ - public const float PassCooldownSeconds = 30f; } diff --git a/Assets/Scripts/FirstLook/FirstLookScreen.cs b/Assets/Scripts/FirstLook/FirstLookScreen.cs index 0d53199..0020ddf 100644 --- a/Assets/Scripts/FirstLook/FirstLookScreen.cs +++ b/Assets/Scripts/FirstLook/FirstLookScreen.cs @@ -16,16 +16,16 @@ * banner exactly, so adding them here would only repeat a pattern; the General * screen already shows the SDK calls for all four formats. * - * The flow lives entirely in this folder, and each controller is one - * self-contained file, so integrating a format means copying two files: that - * controller and FirstLookSource.cs. AdScreenUi is demo-only layout and is kept - * out on purpose; this screen hides the two buttons it does not use. + * The flow lives entirely in this folder. Integrating the interstitial means + * copying two files, FirstLookInterstitialController.cs and FirstLookSource.cs; + * the banner adds FirstLookBannerHud.cs for the clock. AdScreenUi is demo-only + * layout and is kept out on purpose; this screen hides the two buttons it does + * not use. * - * This screen is also the reference for the half of the banner contract the - * controller cannot keep for you: ScheduleNextBannerPass starts the next pass a - * cooldown after PassSpent, ToggleBanner cancels it on hide, and the load-failure - * retry is gated on the banner still being wanted - a load already in flight at - * the hide fails afterwards, where CancelInvoke can no longer reach it. + * The banner needs a second half the controller cannot provide - a clock, to + * time the next pass and to stop requesting once the slot is hidden. That half + * is FirstLookBannerHud, kept in its own file so it can be copied alongside the + * controller; this screen only binds it to buttons and status text. * * https://docs.cloudx.io/en/unity/integrations/first-look */ @@ -36,30 +36,23 @@ public class FirstLookScreen : MonoBehaviour private const float InitializationUiTimeoutSeconds = 15f; /* - * Retry policy after a load or show failure: 2s, 4s, 8s ... capped, and - * reset once a load succeeds. A fixed short delay turns sustained no-fill - * into a tight request loop against the fallback network, which ad - * networks penalise. + * Interstitial retry policy after a load or show failure: 2s, 4s, 8s ... + * capped, and reset once a load succeeds. A fixed short delay turns + * sustained no-fill into a tight request loop against the fallback network, + * which ad networks penalise. The banner runs the same backoff inside + * FirstLookBannerHud, so that file stands alone. */ private const float RetryBaseDelaySeconds = 2f; private const float RetryMaxDelaySeconds = 60f; private AdScreenUi _ui; private FirstLookInterstitialController _interstitial; - private FirstLookBannerController _banner; + private FirstLookBannerHud _banner; private bool _cloudXInitAnswered; private int _interstitialRetries; - private int _bannerRetries; private string _cloudXStatus = "CloudX: Initializing"; private string _adMobStatus = "AdMob: Initializing"; - /* - * Whether the banner slot should hold an ad at all. IsShown is not enough: - * it is also false during the preload before the first Show(), when a retry - * is still wanted. - */ - private bool _bannerWanted = true; - private static void Log(string message) => Debug.Log($"[{TAG}][FirstLook] {message}"); void Awake() @@ -114,8 +107,7 @@ void OnDestroy() _interstitial?.Dispose(); _interstitial = null; - _banner?.Dispose(); - _banner = null; + /* _banner is a component on this GameObject; its OnDestroy disposes it. */ } /* @@ -253,44 +245,32 @@ private void CreateControllers(bool cloudXAvailable) }; _interstitial.AdClicked += source => Log($"Interstitial clicked ({source})"); - _banner = new FirstLookBannerController( - DemoConfig.BannerAdUnitId, - FirstLookConfig.AdMobBannerAdUnitId, - cloudXAvailable); + /* + * The hud is added here rather than sitting in the scene because the ad + * unit ids are only settled once initialization has answered. + */ + _banner = gameObject.AddComponent(); _banner.AdLoaded += source => { - _bannerRetries = 0; Log($"Banner loaded ({source})"); if (!_banner.IsShown) { _ui.SetBannerButtonLabel("Show Banner"); } }; - _banner.AdLoadFailed += (source, message) => - { - /* - * A load already in flight when the player hides the banner still - * fails afterwards, and CancelInvoke cannot reach it - it is out on - * the network, not sitting in the invoke queue. Retrying then would - * put requests back on a slot that is off screen, and nothing would - * stop it. Show() starts the cycle again. - */ - if (!_bannerWanted) - { - Log($"Banner load failed ({source}): {message}; not retrying while hidden"); - return; - } - - var delay = NextRetryDelay(ref _bannerRetries); - Log($"Banner load failed ({source}): {message}; retrying in {delay:0}s"); - Invoke(nameof(LoadBanner), delay); - }; + _banner.AdLoadFailed += (source, message) => Log( + $"Banner load failed ({source}): {message}" + + (_banner.IsWanted ? "; retrying" : "; not retrying while hidden")); _banner.AdShown += source => _ui.SetBannerButtonLabel($"Hide Banner ({source})"); - _banner.PassSpent += ScheduleNextBannerPass; + _banner.AdHidden += () => _ui.SetBannerButtonLabel("Show Banner"); + _banner.ShowPending += () => _ui.SetBannerButtonLabel("Banner: loading..."); _banner.AdClicked += source => Log($"Banner clicked ({source})"); LoadInterstitial(); - LoadBanner(); + _banner.Begin( + DemoConfig.BannerAdUnitId, + FirstLookConfig.AdMobBannerAdUnitId, + cloudXAvailable); _ui.SetActionsInteractable(true); } @@ -318,38 +298,8 @@ private void ShowInterstitial() private void ToggleBanner() { - if (_banner.IsShown) - { - _bannerWanted = false; - _banner.Hide(); - /* Nothing on screen, so the pass cycle stops until the next Show. */ - CancelInvoke(nameof(LoadBanner)); - _ui.SetBannerButtonLabel("Show Banner"); - return; - } - - _bannerWanted = true; - - /* AdShown updates the label once a source actually shows. */ - if (!_banner.Show()) - { - _ui.SetBannerButtonLabel("Banner: loading..."); - LoadBanner(); - } - } - - /* - * Banner only: putting one on screen spends its First Look pass, so the - * next pass is scheduled a cooldown later. Cancelling first collapses a - * pending backoff retry into this one - both end up calling LoadBanner, and - * two pending invokes would arbitrate the placement twice. Showing again - * after a Hide raises PassSpent too, which restarts the cooldown from that - * moment. - */ - private void ScheduleNextBannerPass() - { - CancelInvoke(nameof(LoadBanner)); - Invoke(nameof(LoadBanner), FirstLookConfig.PassCooldownSeconds); + /* The hud owns the cycle; the label follows from its events. */ + _banner.Toggle(); } private static float NextRetryDelay(ref int retries) @@ -359,20 +309,12 @@ private static float NextRetryDelay(ref int retries) return delay; } - /* - * Named methods so terminal failures can retry via Invoke(nameof(...)). - */ - + /* Named so terminal interstitial failures can retry via Invoke(nameof(...)). */ private void LoadInterstitial() { _interstitial?.Load(); } - private void LoadBanner() - { - _banner?.Load(); - } - /* * Status plumbing */ diff --git a/README.md b/README.md index 77bdb38..b3f28b3 100644 --- a/README.md +++ b/README.md @@ -133,13 +133,15 @@ screen: | File | Role | | --- | --- | | `FirstLookInterstitialController.cs` | The whole interstitial flow, self-contained. | -| `FirstLookBannerController.cs` | The whole banner flow, self-contained, including the pass cycle. | +| `FirstLookBannerController.cs` | Which SDK fills a banner pass, self-contained. | +| `FirstLookBannerHud.cs` | When the next pass starts: the clock the controller has no way to keep. | | `FirstLookSource.cs` | The `CloudX` / `AdMob` enum every event reports. | -| `FirstLookConfig.cs` | AdMob ad unit ids and the banner pass cooldown. | +| `FirstLookConfig.cs` | The AdMob fallback ad unit ids. | | `FirstLookScreen.cs` | Initializes both SDKs, wires the controllers to the buttons. | -**To integrate one format, copy two files:** that format's controller and `FirstLookSource.cs`. Each -controller is one file you can read top to bottom - state, the entry points, then each SDK's +**To integrate the interstitial, copy two files:** `FirstLookInterstitialController.cs` and +`FirstLookSource.cs`. The banner adds a third, `FirstLookBannerHud.cs`, because a plain class has no +clock. Each is one file you can read top to bottom - state, the entry points, then each SDK's callbacks - with no base class to chase. The two controllers repeat about fifty lines of ad-unit and dispose bookkeeping between them; that is deliberate, so neither file drags a shared base along into your project. @@ -155,8 +157,8 @@ A banner is not consumed the way a fullscreen ad is, so it needs one thing the i One pass is one ad opportunity: CloudX is asked first, AdMob only if CloudX fails, and the winner goes on screen. Putting an ad on screen **spends** the pass - CloudX inline ads report only load and click, and a load into a view that is already visible renders straight away, so that is the one moment the -code can treat as "this fill has been used". The screen then schedules the next pass -`FirstLookConfig.PassCooldownSeconds` later (30 s by default), and that pass starts at CloudX again. +code can treat as "this fill has been used". `FirstLookBannerHud` then schedules the next pass +`PassCooldownSeconds` later (30 s by default), and that pass starts at CloudX again. Without the cycle the first fill would latch: after one CloudX no-fill the AdMob fallback would own the placement until the scene was destroyed, and CloudX would never get another first look. @@ -167,12 +169,12 @@ Three details worth copying as they are: refresh was stopped for that ad unit - so a visible ad is replaced only once the new one has filled, and the slot never blanks. - **The cycle only turns while an ad is on screen.** Hiding cancels the pending pass and stops the - screen retrying a load that was already in flight, so a hidden slot never keeps requesting in the + hud retrying a load that was already in flight, so a hidden slot never keeps requesting in the background; showing it again puts the same ad back up and restarts the cooldown from that tap. Cancelling alone is not enough - a request already out on the network fails after the hide, long - after `CancelInvoke` had anything to cancel, which is why the screen also tracks whether the banner - is still wanted. The screen still preloads once before the first tap, so an ad is ready when the - user asks for it. + after `CancelInvoke` had anything to cancel, which is why the hud also tracks whether the banner is + still wanted. It still preloads once before the first tap, so an ad is ready when the user asks for + it. - **An ad the AdMob console refreshed on its own does not count as a pass.** Only a fill the controller asked for spends one, so an AdMob unit that still has Automatic refresh enabled cannot keep postponing CloudX's next first look - which it otherwise would, on every refresh. The demo's From 909ff12410655dc4e72bae55962ba341cbef16ce Mon Sep 17 00:00:00 2001 From: Anton Urankar <124867229+antonurankar-moloco@users.noreply.github.com> Date: Sun, 6 Sep 2026 19:39:36 +0200 Subject: [PATCH 07/16] Correct the hide rule and drop a ref parameter with one caller Copilot's review point on the docs PR applies here too: the README said a load in flight when the player hides "fails after the hide", as if that were certain. It is not. Hiding does not invalidate a load - a fill is banked for the next show, which is exactly the behaviour the pass cycle relies on. Only a load that then fails can re-arm the retry, so the bullet now says that. NextRetryDelay took its counter by ref back when the banner and the interstitial shared it. The banner's backoff moved into FirstLookBannerHud, so both remaining call sites pass the same field; it is now an instance method over _interstitialRetries. Same arithmetic, same counter. Also names the hud in the file table row for FirstLookScreen. --- Assets/Scripts/FirstLook/FirstLookScreen.cs | 12 +++++++----- README.md | 9 +++++---- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/Assets/Scripts/FirstLook/FirstLookScreen.cs b/Assets/Scripts/FirstLook/FirstLookScreen.cs index 0020ddf..5fd252d 100644 --- a/Assets/Scripts/FirstLook/FirstLookScreen.cs +++ b/Assets/Scripts/FirstLook/FirstLookScreen.cs @@ -226,14 +226,14 @@ private void CreateControllers(bool cloudXAvailable) }; _interstitial.AdLoadFailed += (source, message) => { - var delay = NextRetryDelay(ref _interstitialRetries); + var delay = NextInterstitialRetryDelay(); SetInterstitialStatus($"Load failed ({source}): {message}\nRetrying in {delay:0}s..."); Invoke(nameof(LoadInterstitial), delay); }; _interstitial.AdShown += source => SetInterstitialStatus($"Showing ({source})"); _interstitial.AdShowFailed += (source, message) => { - var delay = NextRetryDelay(ref _interstitialRetries); + var delay = NextInterstitialRetryDelay(); SetInterstitialStatus($"Show failed ({source}): {message}\nRetrying in {delay:0}s..."); Invoke(nameof(LoadInterstitial), delay); }; @@ -302,10 +302,12 @@ private void ToggleBanner() _banner.Toggle(); } - private static float NextRetryDelay(ref int retries) + private float NextInterstitialRetryDelay() { - var delay = Mathf.Min(RetryBaseDelaySeconds * Mathf.Pow(2f, retries), RetryMaxDelaySeconds); - retries++; + var delay = Mathf.Min( + RetryBaseDelaySeconds * Mathf.Pow(2f, _interstitialRetries), + RetryMaxDelaySeconds); + _interstitialRetries++; return delay; } diff --git a/README.md b/README.md index b3f28b3..ffb4e45 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ screen: | `FirstLookBannerHud.cs` | When the next pass starts: the clock the controller has no way to keep. | | `FirstLookSource.cs` | The `CloudX` / `AdMob` enum every event reports. | | `FirstLookConfig.cs` | The AdMob fallback ad unit ids. | -| `FirstLookScreen.cs` | Initializes both SDKs, wires the controllers to the buttons. | +| `FirstLookScreen.cs` | Initializes both SDKs, wires the controller and the hud to the buttons. | **To integrate the interstitial, copy two files:** `FirstLookInterstitialController.cs` and `FirstLookSource.cs`. The banner adds a third, `FirstLookBannerHud.cs`, because a plain class has no @@ -171,9 +171,10 @@ Three details worth copying as they are: - **The cycle only turns while an ad is on screen.** Hiding cancels the pending pass and stops the hud retrying a load that was already in flight, so a hidden slot never keeps requesting in the background; showing it again puts the same ad back up and restarts the cooldown from that tap. - Cancelling alone is not enough - a request already out on the network fails after the hide, long - after `CancelInvoke` had anything to cancel, which is why the hud also tracks whether the banner is - still wanted. It still preloads once before the first tap, so an ad is ready when the user asks for + Cancelling alone is not enough - a request already out on the network completes after the hide, + long after `CancelInvoke` had anything to cancel. A fill is harmless, because it is banked for the + next show; a failure is not, because its retry would start the requests up again. That is why the + hud also tracks whether the banner is still wanted. It still preloads once before the first tap, so an ad is ready when the user asks for it. - **An ad the AdMob console refreshed on its own does not count as a pass.** Only a fill the controller asked for spends one, so an AdMob unit that still has Automatic refresh enabled cannot From a3306b777b11993475b8b4f53c2ebd9f1326c1e8 Mon Sep 17 00:00:00 2001 From: Anton Urankar <124867229+antonurankar-moloco@users.noreply.github.com> Date: Sun, 6 Sep 2026 21:33:55 +0200 Subject: [PATCH 08/16] Rename FirstLookBannerHud to FirstLookBannerCycle A HUD is a heads-up display. This class has no UI at all - no UnityEngine.UI, no Text, Button, Canvas or Rect - and what it does hold is the pass cooldown, the retry backoff, ScheduleNextPass and the show/hide toggle. That is the pass cycle, which is the term the README, the docs page and the controller comments already use for it. The name also collided with the file that really is the display: AdScreenUi owns the labels and buttons. Having AdScreenUi draw while something called Hud kept time was backwards. The name came from the old docs snippet, which was called BannerHud; it was carried over when the file was extracted rather than chosen. Rule 3 in the file header still said a load in flight "still fails afterwards" - the same overstatement corrected elsewhere after Copilot's review point, missed here. A fill is banked for the next show; only a failure re-arms the retry. The meta guid is unchanged, so nothing that referenced the file loses it. --- ...rstLookBannerHud.cs => FirstLookBannerCycle.cs} | 8 +++++--- ...nerHud.cs.meta => FirstLookBannerCycle.cs.meta} | 0 Assets/Scripts/FirstLook/FirstLookConfig.cs | 4 ++-- Assets/Scripts/FirstLook/FirstLookScreen.cs | 14 +++++++------- README.md | 12 ++++++------ 5 files changed, 20 insertions(+), 18 deletions(-) rename Assets/Scripts/FirstLook/{FirstLookBannerHud.cs => FirstLookBannerCycle.cs} (95%) rename Assets/Scripts/FirstLook/{FirstLookBannerHud.cs.meta => FirstLookBannerCycle.cs.meta} (100%) diff --git a/Assets/Scripts/FirstLook/FirstLookBannerHud.cs b/Assets/Scripts/FirstLook/FirstLookBannerCycle.cs similarity index 95% rename from Assets/Scripts/FirstLook/FirstLookBannerHud.cs rename to Assets/Scripts/FirstLook/FirstLookBannerCycle.cs index 4afeffc..e4d8a21 100644 --- a/Assets/Scripts/FirstLook/FirstLookBannerHud.cs +++ b/Assets/Scripts/FirstLook/FirstLookBannerCycle.cs @@ -15,8 +15,10 @@ * 2. Cancel that pending pass on Hide, so a hidden slot stops requesting. * 3. Do not retry a failed load while the banner is hidden. Cancelling is * not enough on its own: a load already out on the network when the - * player hides still fails afterwards, long after CancelInvoke had - * anything to cancel, and its retry would start the requests up again. + * player hides completes afterwards, long after CancelInvoke had + * anything to cancel. A fill is harmless - it is banked for the next + * Show. A failure is not, because its retry would start the requests + * up again. * * Copy this file together with FirstLookBannerController.cs and * FirstLookSource.cs. In your own project the ad unit ids would come from @@ -26,7 +28,7 @@ * Background and the reasoning behind each rule: * https://docs.cloudx.io/en/unity/integrations/first-look */ -public sealed class FirstLookBannerHud : MonoBehaviour +public sealed class FirstLookBannerCycle : MonoBehaviour { /* * How long a displayed banner stays up before the next First Look pass diff --git a/Assets/Scripts/FirstLook/FirstLookBannerHud.cs.meta b/Assets/Scripts/FirstLook/FirstLookBannerCycle.cs.meta similarity index 100% rename from Assets/Scripts/FirstLook/FirstLookBannerHud.cs.meta rename to Assets/Scripts/FirstLook/FirstLookBannerCycle.cs.meta diff --git a/Assets/Scripts/FirstLook/FirstLookConfig.cs b/Assets/Scripts/FirstLook/FirstLookConfig.cs index c72f578..c2f6aef 100644 --- a/Assets/Scripts/FirstLook/FirstLookConfig.cs +++ b/Assets/Scripts/FirstLook/FirstLookConfig.cs @@ -7,8 +7,8 @@ * AdMob console. The Unity plugin cannot control it, and a refreshing AdMob * banner would replace the ad that won the First Look pass. * - * The banner pass cooldown is not here; it lives in FirstLookBannerHud, with - * the cycle it paces. + * The banner pass cooldown is not here; it lives in FirstLookBannerCycle, + * which paces it. * * https://docs.cloudx.io/en/unity/integrations/first-look */ diff --git a/Assets/Scripts/FirstLook/FirstLookScreen.cs b/Assets/Scripts/FirstLook/FirstLookScreen.cs index 5fd252d..100d26d 100644 --- a/Assets/Scripts/FirstLook/FirstLookScreen.cs +++ b/Assets/Scripts/FirstLook/FirstLookScreen.cs @@ -18,13 +18,13 @@ * * The flow lives entirely in this folder. Integrating the interstitial means * copying two files, FirstLookInterstitialController.cs and FirstLookSource.cs; - * the banner adds FirstLookBannerHud.cs for the clock. AdScreenUi is demo-only + * the banner adds FirstLookBannerCycle.cs for the clock. AdScreenUi is demo-only * layout and is kept out on purpose; this screen hides the two buttons it does * not use. * * The banner needs a second half the controller cannot provide - a clock, to * time the next pass and to stop requesting once the slot is hidden. That half - * is FirstLookBannerHud, kept in its own file so it can be copied alongside the + * is FirstLookBannerCycle, kept in its own file so it can be copied alongside the * controller; this screen only binds it to buttons and status text. * * https://docs.cloudx.io/en/unity/integrations/first-look @@ -40,14 +40,14 @@ public class FirstLookScreen : MonoBehaviour * capped, and reset once a load succeeds. A fixed short delay turns * sustained no-fill into a tight request loop against the fallback network, * which ad networks penalise. The banner runs the same backoff inside - * FirstLookBannerHud, so that file stands alone. + * FirstLookBannerCycle, so that file stands alone. */ private const float RetryBaseDelaySeconds = 2f; private const float RetryMaxDelaySeconds = 60f; private AdScreenUi _ui; private FirstLookInterstitialController _interstitial; - private FirstLookBannerHud _banner; + private FirstLookBannerCycle _banner; private bool _cloudXInitAnswered; private int _interstitialRetries; private string _cloudXStatus = "CloudX: Initializing"; @@ -246,10 +246,10 @@ private void CreateControllers(bool cloudXAvailable) _interstitial.AdClicked += source => Log($"Interstitial clicked ({source})"); /* - * The hud is added here rather than sitting in the scene because the ad + * The cycle is added here rather than sitting in the scene because the ad * unit ids are only settled once initialization has answered. */ - _banner = gameObject.AddComponent(); + _banner = gameObject.AddComponent(); _banner.AdLoaded += source => { Log($"Banner loaded ({source})"); @@ -298,7 +298,7 @@ private void ShowInterstitial() private void ToggleBanner() { - /* The hud owns the cycle; the label follows from its events. */ + /* The cycle owns the timing; the label follows from its events. */ _banner.Toggle(); } diff --git a/README.md b/README.md index ffb4e45..1594f6f 100644 --- a/README.md +++ b/README.md @@ -134,13 +134,13 @@ screen: | --- | --- | | `FirstLookInterstitialController.cs` | The whole interstitial flow, self-contained. | | `FirstLookBannerController.cs` | Which SDK fills a banner pass, self-contained. | -| `FirstLookBannerHud.cs` | When the next pass starts: the clock the controller has no way to keep. | +| `FirstLookBannerCycle.cs` | When the next pass starts: the clock the controller has no way to keep. | | `FirstLookSource.cs` | The `CloudX` / `AdMob` enum every event reports. | | `FirstLookConfig.cs` | The AdMob fallback ad unit ids. | -| `FirstLookScreen.cs` | Initializes both SDKs, wires the controller and the hud to the buttons. | +| `FirstLookScreen.cs` | Initializes both SDKs, wires the controller and the cycle to the buttons. | **To integrate the interstitial, copy two files:** `FirstLookInterstitialController.cs` and -`FirstLookSource.cs`. The banner adds a third, `FirstLookBannerHud.cs`, because a plain class has no +`FirstLookSource.cs`. The banner adds a third, `FirstLookBannerCycle.cs`, because a plain class has no clock. Each is one file you can read top to bottom - state, the entry points, then each SDK's callbacks - with no base class to chase. The two controllers repeat about fifty lines of ad-unit and dispose bookkeeping between them; that is deliberate, so neither file drags a shared base along into @@ -157,7 +157,7 @@ A banner is not consumed the way a fullscreen ad is, so it needs one thing the i One pass is one ad opportunity: CloudX is asked first, AdMob only if CloudX fails, and the winner goes on screen. Putting an ad on screen **spends** the pass - CloudX inline ads report only load and click, and a load into a view that is already visible renders straight away, so that is the one moment the -code can treat as "this fill has been used". `FirstLookBannerHud` then schedules the next pass +code can treat as "this fill has been used". `FirstLookBannerCycle` then schedules the next pass `PassCooldownSeconds` later (30 s by default), and that pass starts at CloudX again. Without the cycle the first fill would latch: after one CloudX no-fill the AdMob fallback would own @@ -169,12 +169,12 @@ Three details worth copying as they are: refresh was stopped for that ad unit - so a visible ad is replaced only once the new one has filled, and the slot never blanks. - **The cycle only turns while an ad is on screen.** Hiding cancels the pending pass and stops the - hud retrying a load that was already in flight, so a hidden slot never keeps requesting in the + cycle retrying a load that was already in flight, so a hidden slot never keeps requesting in the background; showing it again puts the same ad back up and restarts the cooldown from that tap. Cancelling alone is not enough - a request already out on the network completes after the hide, long after `CancelInvoke` had anything to cancel. A fill is harmless, because it is banked for the next show; a failure is not, because its retry would start the requests up again. That is why the - hud also tracks whether the banner is still wanted. It still preloads once before the first tap, so an ad is ready when the user asks for + cycle also tracks whether the banner is still wanted. It still preloads once before the first tap, so an ad is ready when the user asks for it. - **An ad the AdMob console refreshed on its own does not count as a pass.** Only a fill the controller asked for spends one, so an AdMob unit that still has Automatic refresh enabled cannot From 07ff0b5177663291b4b92937ae4e9049df394d18 Mon Sep 17 00:00:00 2001 From: Anton Urankar <124867229+antonurankar-moloco@users.noreply.github.com> Date: Sun, 6 Sep 2026 21:53:55 +0200 Subject: [PATCH 09/16] Hide also ends the pass in flight, so CloudX cannot hand over to the fallback Copilot's review point on #9, confirmed on device. The retry guard in FirstLookBannerCycle only runs after AdLoadFailed, and the controller raises that only when the AdMob leg fails. Hiding while the CloudX leg was still in flight left CloudXOnLoadFailed free to call LoadAdMobFallback, so one AdMob request went out on a slot that was already off screen. That contradicts the "a hidden slot never keeps requesting" claim in the README and on the docs page, and my earlier zero-request measurements did not cover it: they were taken from after the failure had resolved. _wantShown cannot be the gate, because it is also false during the preload before the first Show, and the preload has to be allowed to reach the fallback - that is what banks an ad for the first tap. So this adds _hidden, set by Hide and cleared by Show, which separates "never shown yet" from "explicitly hidden". Measured on a Pixel 6 emulator, hiding inside the CloudX leg (network off, so the leg lasts through an HttpRetry cycle rather than failing instantly): with the gate load() 21:47:00.626, hide 21:47:00.825 -> 0 AdMob requests without the gate load() 21:49:25.594, hide 21:49:25.796 -> 1 AdMob request at 21:49:27.032, 1.2s after the hide Regression on the same build: pass cadence 30.27s, hide gives zero loads and zero AdMob requests over 70s, re-show returns the banked ad with no new load, and a preload against an invalid CloudX unit still falls back to AdMob. --- .../FirstLook/FirstLookBannerController.cs | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/FirstLook/FirstLookBannerController.cs b/Assets/Scripts/FirstLook/FirstLookBannerController.cs index 7abfd04..710d028 100644 --- a/Assets/Scripts/FirstLook/FirstLookBannerController.cs +++ b/Assets/Scripts/FirstLook/FirstLookBannerController.cs @@ -25,7 +25,9 @@ * Reloading immediately is a request loop, because the new fill renders * into the visible view and spends the next pass at once. * 2. Cancel that pending pass when it calls Hide(), or a hidden slot keeps - * requesting. Show() starts the cycle again. + * requesting. Show() starts the cycle again. Hide also ends the pass that + * is already running: a CloudX load still in flight will not hand over to + * the fallback once the slot is off screen. * * Set Automatic refresh to Disabled on the AdMob ad unit you use as the * fallback. The Google Mobile Ads Unity plugin has no refresh API, so that @@ -78,6 +80,13 @@ public sealed class FirstLookBannerController : IDisposable private bool _isLoadingAdMob; private bool _wantShown; private bool _isShown; + + /* + * Set by Hide, cleared by Show. Distinct from _wantShown, which is also + * false during the preload before the first Show - and the preload must + * still be allowed to reach the fallback, so it cannot be the gate here. + */ + private bool _hidden; private bool _isDisposed; /* @@ -172,6 +181,7 @@ public bool Show() } _wantShown = true; + _hidden = false; /* * An unspent fill wins; otherwise re-show whatever is already in a @@ -196,6 +206,7 @@ public void Hide() _wantShown = false; _isShown = false; + _hidden = true; HideCloudX(); HideAdMob(); @@ -373,8 +384,20 @@ private void CloudXOnLoadFailed(string adUnitId, CloudXError _) return; } - /* The one place the fallback is triggered: CloudX had its first look. */ _isLoadingCloudX = false; + + /* + * The player hid the slot while this CloudX load was still running. + * The pass is over: starting the fallback now would put a request on + * a view nobody can see, which is the one thing Hide has to stop. + * A show starts a fresh pass, and that one begins at CloudX again. + */ + if (_hidden) + { + return; + } + + /* The one place the fallback is triggered: CloudX had its first look. */ LoadAdMobFallback(); } From 2b12bbd8197f13c32a610fff6981333d2ac7e3df Mon Sep 17 00:00:00 2001 From: Anton Urankar <124867229+antonurankar-moloco@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:11:36 +0200 Subject: [PATCH 10/16] Scope the cancelled-pass flag to the pass, not to visibility Copilot's follow-up on #9, confirmed on device. The previous commit cleared the flag in Show(), which tracks whether the slot is currently visible rather than whether the running pass was cancelled. Hide then a quick re-show while a CloudX load is still in flight therefore revived it: the stale terminal callback arrived with the flag already cleared, started the fallback at once, and jumped the 30s cooldown the re-show had just restarted. The flag now belongs to the pass. Hide sets it; only the start of a new pass in Load() clears it. Show leaves it alone, so a cancelled pass stays cancelled however the slot is toggled afterwards. The same reasoning applies to the AdMob leg, which Copilot raised as a second case: its terminal failure is no longer forwarded to the host for a cancelled pass, so the host cannot schedule a retry that jumps the cooldown either. Measured on a Pixel 6 emulator, hiding and re-showing inside the CloudX leg (network off, so the leg lasts through an HttpRetry cycle): pass-scoped flag hide 22:05:41.507, show 22:05:41.588 -> 0 AdMob requests cleared on Show hide+show 22:07:31.846 -> 2 AdMob requests Regression: cadence 30.23s, hide gives zero loads and zero AdMob requests over 65s, re-show returns the banked ad with no new load. Copilot also noted that a cooldown load firing before a stale callback is dropped by the _isLoadingCloudX guard. That is the pre-existing no-per-pass- timeout gap already recorded as a follow-up; it predates this branch and is not addressed here. --- .../FirstLook/FirstLookBannerController.cs | 43 +++++++++++++------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/Assets/Scripts/FirstLook/FirstLookBannerController.cs b/Assets/Scripts/FirstLook/FirstLookBannerController.cs index 710d028..549a1a0 100644 --- a/Assets/Scripts/FirstLook/FirstLookBannerController.cs +++ b/Assets/Scripts/FirstLook/FirstLookBannerController.cs @@ -82,11 +82,18 @@ public sealed class FirstLookBannerController : IDisposable private bool _isShown; /* - * Set by Hide, cleared by Show. Distinct from _wantShown, which is also - * false during the preload before the first Show - and the preload must - * still be allowed to reach the fallback, so it cannot be the gate here. + * Whether the pass currently in flight was cancelled by a Hide. It tracks + * the pass, not the slot: clearing it on Show would revive a pass the + * player just cancelled, so only the start of a new pass clears it. A + * hide-then-quick-show otherwise lets the old request's terminal callback + * land after the show and start the fallback at once, skipping the + * cooldown that show just restarted. + * + * _wantShown cannot do this job either, because it is also false during + * the preload before the first Show, and the preload has to be allowed to + * reach the fallback. */ - private bool _hidden; + private bool _passCancelled; private bool _isDisposed; /* @@ -150,6 +157,9 @@ public void Load() return; } + /* A new pass starts here, so whatever a Hide cancelled is history. */ + _passCancelled = false; + if (!_cloudXAvailable) { LoadAdMobFallback(); @@ -181,7 +191,6 @@ public bool Show() } _wantShown = true; - _hidden = false; /* * An unspent fill wins; otherwise re-show whatever is already in a @@ -206,7 +215,7 @@ public void Hide() _wantShown = false; _isShown = false; - _hidden = true; + _passCancelled = true; HideCloudX(); HideAdMob(); @@ -387,12 +396,13 @@ private void CloudXOnLoadFailed(string adUnitId, CloudXError _) _isLoadingCloudX = false; /* - * The player hid the slot while this CloudX load was still running. - * The pass is over: starting the fallback now would put a request on - * a view nobody can see, which is the one thing Hide has to stop. - * A show starts a fresh pass, and that one begins at CloudX again. + * A Hide cancelled this pass while the load was still running. Do not + * hand over to the fallback: the request would land on a slot the + * player dismissed, and if they have since shown it again, it would + * also jump the cooldown that show restarted. The next pass begins at + * CloudX, as every pass does. */ - if (_hidden) + if (_passCancelled) { return; } @@ -448,10 +458,17 @@ private void AdMobCreateAndLoad() { _isLoadingAdMob = false; - if (!_isDisposed) + /* + * Same reason as the CloudX leg: a cancelled pass must not reach + * the host, or its retry would run against a dismissed slot - or + * jump the cooldown, if the player has shown the banner again. + */ + if (_isDisposed || _passCancelled) { - AdLoadFailed?.Invoke(FirstLookSource.AdMob, error.GetMessage()); + return; } + + AdLoadFailed?.Invoke(FirstLookSource.AdMob, error.GetMessage()); }); _adMobBanner.OnAdClicked += () => MobileAdsEventExecutor.ExecuteInUpdate(() => From 07d7278d27a951c449fffeb09c0e51f08243aae5 Mon Sep 17 00:00:00 2001 From: Anton Urankar <124867229+antonurankar-moloco@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:17:37 +0200 Subject: [PATCH 11/16] Say to copy three files, not two The controller header still told readers to copy this file and FirstLookSource.cs and called it "the whole flow". That was true before the timing was extracted; following it now leaves nothing driving the cycle, so the banner shows one ad and stops. It names all three files, and says what happens if you take only this one. The two host rules stay, since anyone driving the controller from their own component still needs them, but they now say FirstLookBannerCycle already does both. The Hide-ends-the-pass behaviour moves out of that list, because it is this controller's job rather than the host's. FirstLookInterstitialController keeps the same two-file wording: a fullscreen ad is consumed by being shown, so it needs no clock and the claim is accurate there. --- .../FirstLook/FirstLookBannerController.cs | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/Assets/Scripts/FirstLook/FirstLookBannerController.cs b/Assets/Scripts/FirstLook/FirstLookBannerController.cs index 549a1a0..e7a9ab1 100644 --- a/Assets/Scripts/FirstLook/FirstLookBannerController.cs +++ b/Assets/Scripts/FirstLook/FirstLookBannerController.cs @@ -5,10 +5,16 @@ /* * First Look banner: CloudX gets the first chance to fill, AdMob loads lazily - * as the fallback only after CloudX fails. Copy this file and FirstLookSource.cs - * into your project; it is the whole flow, top to bottom, with no base class to - * bring along. Reading order: state, the Load/Show/Hide entry points, the pass - * cycle, then each SDK's callbacks. + * as the fallback only after CloudX fails. + * + * This file decides which SDK fills a pass. It does not decide when the next + * pass starts, because it is a plain class with no clock. Copy three files: + * this one, FirstLookBannerCycle.cs for the timing, and FirstLookSource.cs for + * the enum every event reports. Taking this file alone leaves nothing driving + * the cycle, and the banner stops after its first pass. + * + * Reading order: state, the Load/Show/Hide entry points, the pass cycle, then + * each SDK's callbacks. * * A banner is not the interstitial with different method names. A fullscreen ad * is consumed by being shown, so the SDKs' own readiness answers go false and @@ -19,15 +25,20 @@ * scene is destroyed and one CloudX no-fill hands the slot to the fallback for * the rest of the session. * - * Two things the host has to do, or the cycle stalls: + * Two things the host has to do, or the cycle stalls. FirstLookBannerCycle + * does both; they are written out here for anyone driving this controller from + * their own component instead: * * 1. Start the next pass on PassSpent, after a cooldown of your choosing. * Reloading immediately is a request loop, because the new fill renders * into the visible view and spends the next pass at once. * 2. Cancel that pending pass when it calls Hide(), or a hidden slot keeps - * requesting. Show() starts the cycle again. Hide also ends the pass that - * is already running: a CloudX load still in flight will not hand over to - * the fallback once the slot is off screen. + * requesting. Show() starts the cycle again. + * + * Hide also ends the pass already running, and that part is this controller's + * job rather than the host's: a CloudX load still in flight will not hand over + * to the fallback, and a later Show does not revive it. Only the start of the + * next pass does. * * Set Automatic refresh to Disabled on the AdMob ad unit you use as the * fallback. The Google Mobile Ads Unity plugin has no refresh API, so that From 1f4c54714a0f4daedd6e83e77cb49477cde078ee Mon Sep 17 00:00:00 2001 From: Anton Urankar <124867229+antonurankar-moloco@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:26:56 +0200 Subject: [PATCH 12/16] Do not let a cancelled pass's fill re-time the cycle Copilot's third-round point, and it is a violation of the contract the header gained last round: it says a later Show does not revive a cancelled pass, and the success callbacks did exactly that. Hide, then a quick Show, then the stale CloudX or AdMob fill arrives with its loading flag still set, so it raised PassSpent and reset the cooldown from a pass the player had already dismissed. The cause is that one flag carried two meanings. spendsPass answered both "did this controller ask for the load" - which decides whether the fill is banked, and whether it may take the slot from the other source - and "should this re-time the cycle". Those come apart exactly once: a pass a Hide cancelled is still ours, but its timing is not. So they are two flags now. "ours" keeps the banking and source-replacement behaviour unchanged, and spendsPass is ours && !_passCancelled. A cancelled pass's fill is still banked while the slot is hidden, and still shows if the slot has since been shown - throwing away an ad we paid a request for would be worse - but the cooldown stays with whatever the host scheduled after the hide. Regression on a Pixel 6 emulator: cadence 30.23s, hide gives zero loads and zero AdMob requests over 60s, re-show returns the banked ad with no new load. --- .../FirstLook/FirstLookBannerController.cs | 46 +++++++++++++------ 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/Assets/Scripts/FirstLook/FirstLookBannerController.cs b/Assets/Scripts/FirstLook/FirstLookBannerController.cs index e7a9ab1..323457f 100644 --- a/Assets/Scripts/FirstLook/FirstLookBannerController.cs +++ b/Assets/Scripts/FirstLook/FirstLookBannerController.cs @@ -291,8 +291,14 @@ private void ShowSource(FirstLookSource source, bool spendsPass) } } - /* Returns whether the fill went on screen. */ - private bool ShowIfWanted(FirstLookSource source, bool spendsPass) + /* + * Returns whether the fill went on screen. Two flags, because they answer + * different questions: "ours" is whether this controller asked for the + * load, and "spendsPass" is whether it should re-time the cycle. They part + * company for a pass a Hide cancelled - the fill is still ours to bank and + * show, but the cooldown belongs to whatever the host has scheduled since. + */ + private bool ShowIfWanted(FirstLookSource source, bool ours, bool spendsPass) { if (!_wantShown) { @@ -300,13 +306,13 @@ private bool ShowIfWanted(FirstLookSource source, bool spendsPass) } /* - * A fill from a pass legitimately replaces the ad the previous pass put - * up, so there is no _isShown check. A fill that is not part of a pass - * is different: letting AdMob's own refresh take the slot from CloudX + * A fill we asked for legitimately replaces the ad the previous pass + * put up, so there is no _isShown check. A fill nobody asked for is + * different: letting AdMob's own refresh take the slot from CloudX * would undo the source decision this pass made, so it only re-shows the * source that is already up. */ - if (!spendsPass && _shownSource != null && _shownSource != source) + if (!ours && _shownSource != null && _shownSource != source) { return false; } @@ -324,8 +330,8 @@ private bool ShowIfWanted(FirstLookSource source, bool spendsPass) * Nothing is lost by forgetting it; the native view keeps the creative and * the next pass reloads that side anyway. */ - private static bool KeepsUnspentFill(bool spendsPass, bool wentOnScreen) => - spendsPass && !wentOnScreen; + private static bool KeepsUnspentFill(bool ours, bool wentOnScreen) => + ours && !wentOnScreen; private void HideCloudX() { @@ -387,14 +393,22 @@ private void CloudXOnLoadSuccess(CloudXAd ad) * off, so in practice this is always true; the check keeps the two * sources reading the same way. */ - var spendsPass = _isLoadingCloudX; + var ours = _isLoadingCloudX; + + /* + * A fill for a pass a Hide cancelled is still worth banking and showing + * - it is an ad we paid a request for - but it must not raise PassSpent + * and reset the cooldown, which by now belongs to the show that came + * after the hide. + */ + var spendsPass = ours && !_passCancelled; _isLoadingCloudX = false; _cloudXLoaded = true; AdLoaded?.Invoke(FirstLookSource.CloudX); - var wentOnScreen = ShowIfWanted(FirstLookSource.CloudX, spendsPass); - _cloudXLoaded = KeepsUnspentFill(spendsPass, wentOnScreen); + var wentOnScreen = ShowIfWanted(FirstLookSource.CloudX, ours, spendsPass); + _cloudXLoaded = KeepsUnspentFill(ours, wentOnScreen); } private void CloudXOnLoadFailed(string adUnitId, CloudXError _) @@ -504,7 +518,11 @@ private void OnAdMobLoaded() * but it does not count as a pass, so the pending pass keeps its * original schedule. */ - var spendsPass = _isLoadingAdMob; + var ours = _isLoadingAdMob; + + /* Same as the CloudX leg: a cancelled pass banks and shows, but does + * not re-time the cycle. */ + var spendsPass = ours && !_passCancelled; _isLoadingAdMob = false; @@ -517,8 +535,8 @@ private void OnAdMobLoaded() _adMobLoaded = true; AdLoaded?.Invoke(FirstLookSource.AdMob); - var wentOnScreen = ShowIfWanted(FirstLookSource.AdMob, spendsPass); - _adMobLoaded = KeepsUnspentFill(spendsPass, wentOnScreen); + var wentOnScreen = ShowIfWanted(FirstLookSource.AdMob, ours, spendsPass); + _adMobLoaded = KeepsUnspentFill(ours, wentOnScreen); } private void DestroyAdMobAd() From 2e5de4a94de1314788be25b351368636ab83e8c3 Mon Sep 17 00:00:00 2001 From: Anton Urankar <124867229+antonurankar-moloco@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:28:33 +0200 Subject: [PATCH 13/16] Say Begin waits on CloudX, not on both SDKs Begin's comment said "after both SDKs have answered", which contradicts what FirstLookScreen does and what the docs page now says. The screen starts both initializations and calls Begin on the CloudX result alone, because Google Mobile Ads queues loads issued while it is still initializing and the fallback is lazy regardless. Part of a three-way inconsistency Copilot found across this comment, the Info block on the docs page, and the usage section; the other two are fixed in cloudx-io/docs#407. --- Assets/Scripts/FirstLook/FirstLookBannerCycle.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Assets/Scripts/FirstLook/FirstLookBannerCycle.cs b/Assets/Scripts/FirstLook/FirstLookBannerCycle.cs index e4d8a21..731b578 100644 --- a/Assets/Scripts/FirstLook/FirstLookBannerCycle.cs +++ b/Assets/Scripts/FirstLook/FirstLookBannerCycle.cs @@ -80,7 +80,13 @@ public sealed class FirstLookBannerCycle : MonoBehaviour /* * Creates the controller and preloads one pass, so an ad is ready the first - * time the player asks for one. Call once, after both SDKs have answered. + * time the player asks for one. Call once, after CloudX has answered - + * initialized, failed, or past a timeout of your own - passing whether it + * actually came up. + * + * Google Mobile Ads does not have to be ready. It queues loads issued while + * it is still initializing, and the fallback is lazy in any case, so + * waiting for it as well would only delay the first pass. */ public void Begin(string cloudXAdUnitId, string adMobAdUnitId, bool cloudXAvailable) { From ea4da0cba07e84e70a0ce8e48635206bba7c8304 Mon Sep 17 00:00:00 2001 From: Anton Urankar <124867229+antonurankar-moloco@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:52:43 +0200 Subject: [PATCH 14/16] Drop the unreachable hidden-slot log branch Copilot's non-blocking note, and it is right. Once the controller started dropping the terminal failure of a cancelled pass, nothing reaching the screen's AdLoadFailed handler can be from a hidden slot, so the "not retrying while hidden" arm became dead and IsWanted lost its only caller. The handler now logs the one outcome that can actually happen. This also corrects the record: that log line was real evidence when it was gathered, against the build at the time, but the current build cannot emit it. What replaces it as evidence is the request count, which is the thing that actually matters - zero AdMob requests after a hide, measured against a control build that produces one. The cycle keeps its _wanted guard even though the controller now makes it unreachable, because the rule is the host's to keep: anyone driving the controller from their own component needs that line. The comment says so rather than leaving it looking like live logic. Regression: cadence 30.25s, zero loads and zero AdMob requests over 55s hidden, re-show returns the banked ad with no new load. --- Assets/Scripts/FirstLook/FirstLookBannerCycle.cs | 15 +++++++-------- Assets/Scripts/FirstLook/FirstLookScreen.cs | 10 +++++++--- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/Assets/Scripts/FirstLook/FirstLookBannerCycle.cs b/Assets/Scripts/FirstLook/FirstLookBannerCycle.cs index 731b578..06ea63c 100644 --- a/Assets/Scripts/FirstLook/FirstLookBannerCycle.cs +++ b/Assets/Scripts/FirstLook/FirstLookBannerCycle.cs @@ -71,13 +71,6 @@ public sealed class FirstLookBannerCycle : MonoBehaviour public bool IsShown => _banner != null && _banner.IsShown; - /* - * Whether the slot is meant to hold an ad. Read it from an AdLoadFailed - * handler to tell a failure that will be retried from one that will not, - * because the player hid the banner while the load was still running. - */ - public bool IsWanted => _wanted; - /* * Creates the controller and preloads one pass, so an ad is ready the first * time the player asks for one. Call once, after CloudX has answered - @@ -143,7 +136,13 @@ private void OnAdLoadFailed(FirstLookSource source, string message) { AdLoadFailed?.Invoke(source, message); - /* Rule 3: a load that fails after the hide must not revive the slot. */ + /* + * Rule 3: a load that fails after the hide must not revive the slot. + * FirstLookBannerController already drops the terminal failure of a + * cancelled pass, so nothing reaches this in practice. It stays because + * the rule is the host's to keep - drive that controller from your own + * component and this is the line that keeps it true. + */ if (!_wanted) { return; diff --git a/Assets/Scripts/FirstLook/FirstLookScreen.cs b/Assets/Scripts/FirstLook/FirstLookScreen.cs index 100d26d..329c0b3 100644 --- a/Assets/Scripts/FirstLook/FirstLookScreen.cs +++ b/Assets/Scripts/FirstLook/FirstLookScreen.cs @@ -258,9 +258,13 @@ private void CreateControllers(bool cloudXAvailable) _ui.SetBannerButtonLabel("Show Banner"); } }; - _banner.AdLoadFailed += (source, message) => Log( - $"Banner load failed ({source}): {message}" - + (_banner.IsWanted ? "; retrying" : "; not retrying while hidden")); + /* + * Only a live pass reaches this: the controller drops the terminal + * failure of a pass a Hide cancelled, so there is no hidden-slot case + * to report here. + */ + _banner.AdLoadFailed += (source, message) => + Log($"Banner load failed ({source}): {message}; retrying"); _banner.AdShown += source => _ui.SetBannerButtonLabel($"Hide Banner ({source})"); _banner.AdHidden += () => _ui.SetBannerButtonLabel("Show Banner"); _banner.ShowPending += () => _ui.SetBannerButtonLabel("Banner: loading..."); From 22ce78c8f8ba494e1c848fbaacda7a92f5dcb906 Mon Sep 17 00:00:00 2001 From: Anton Urankar <124867229+antonurankar-moloco@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:40:48 +0200 Subject: [PATCH 15/16] Clear the cancelled-pass flag before Load's in-flight guard Suppressing the terminal callback of a pass a Hide cancelled stops a hidden slot requesting, but it opened a window where a visible banner could stop refreshing. Hide, then Show, leaves _passCancelled set while the cancelled load is still on the network; the Show raises PassSpent and arms the cooldown; that tick then finds the load still in flight, Load() early-returns, and the flag is never cleared. When the stale load completes it is dropped, so no PassSpent and no AdLoadFailed reach the host, nothing is pending, and the cycle stalls with an ad on screen until the next toggle. Clearing the flag above the guard fixes it: a load is only ever asked for on a slot that is wanted, so it supersedes the cancellation even when the guard drops the call, and the in-flight pass carries the cycle forward instead of being silenced. Show still does not clear it, so a Hide with no following Load cancels the pass completely. Load() now leans on the host never calling it while the slot is hidden, so that rule joins the two the header already listed, and the note about what revives a cancelled pass is corrected to match. README: rewrap the hide bullet, which had grown to 141 columns and stated the cancellation twice. --- .../FirstLook/FirstLookBannerController.cs | 46 +++++++++++++------ README.md | 15 +++--- 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/Assets/Scripts/FirstLook/FirstLookBannerController.cs b/Assets/Scripts/FirstLook/FirstLookBannerController.cs index 323457f..5beb93f 100644 --- a/Assets/Scripts/FirstLook/FirstLookBannerController.cs +++ b/Assets/Scripts/FirstLook/FirstLookBannerController.cs @@ -25,20 +25,25 @@ * scene is destroyed and one CloudX no-fill hands the slot to the fallback for * the rest of the session. * - * Two things the host has to do, or the cycle stalls. FirstLookBannerCycle - * does both; they are written out here for anyone driving this controller from - * their own component instead: + * Three things the host has to do, or the cycle stalls or loops. + * FirstLookBannerCycle does all three; they are written out here for anyone + * driving this controller from their own component instead: * * 1. Start the next pass on PassSpent, after a cooldown of your choosing. * Reloading immediately is a request loop, because the new fill renders * into the visible view and spends the next pass at once. * 2. Cancel that pending pass when it calls Hide(), or a hidden slot keeps * requesting. Show() starts the cycle again. + * 3. Do not call Load() while the slot is hidden. Load() means the slot is + * wanted, so it lifts the cancellation below - including on a pass still + * out on the network - and a load asked for on a dismissed slot puts the + * requests back with nothing to stop them. * * Hide also ends the pass already running, and that part is this controller's * job rather than the host's: a CloudX load still in flight will not hand over - * to the fallback, and a later Show does not revive it. Only the start of the - * next pass does. + * to the fallback, and a later Show does not revive it. Only the next Load + * does - including one the in-flight guard drops, so a host tick that produces + * no callback cannot leave the cycle with nothing left to schedule from. * * Set Automatic refresh to Disabled on the AdMob ad unit you use as the * fallback. The Google Mobile Ads Unity plugin has no refresh API, so that @@ -95,10 +100,10 @@ public sealed class FirstLookBannerController : IDisposable /* * Whether the pass currently in flight was cancelled by a Hide. It tracks * the pass, not the slot: clearing it on Show would revive a pass the - * player just cancelled, so only the start of a new pass clears it. A - * hide-then-quick-show otherwise lets the old request's terminal callback - * land after the show and start the fallback at once, skipping the - * cooldown that show just restarted. + * player just cancelled, so only Load clears it. A hide-then-quick-show + * otherwise lets the old request's terminal callback land after the show + * and start the fallback at once, skipping the cooldown that show just + * restarted. * * _wantShown cannot do this job either, because it is also false during * the preload before the first Show, and the preload has to be allowed to @@ -163,14 +168,27 @@ public FirstLookSource? ReadySource */ public void Load() { - if (_isDisposed || _isLoadingCloudX || _isLoadingAdMob || ReadySource != null) + if (_isDisposed) { return; } - /* A new pass starts here, so whatever a Hide cancelled is history. */ + /* + * Whatever a Hide cancelled is history. This has to happen before the + * guard below rather than after it. A load is only ever asked for on a + * slot that is wanted, so it supersedes the cancellation even when the + * cancelled pass is still out on the network and the guard drops this + * call: leaving the flag set there would suppress that pass's terminal + * callback as well, and the host would get neither the PassSpent nor + * the failure it needs to schedule anything after it. + */ _passCancelled = false; + if (_isLoadingCloudX || _isLoadingAdMob || ReadySource != null) + { + return; + } + if (!_cloudXAvailable) { LoadAdMobFallback(); @@ -520,8 +538,10 @@ private void OnAdMobLoaded() */ var ours = _isLoadingAdMob; - /* Same as the CloudX leg: a cancelled pass banks and shows, but does - * not re-time the cycle. */ + /* + * Same as the CloudX leg: a cancelled pass banks and shows, but does + * not re-time the cycle. + */ var spendsPass = ours && !_passCancelled; _isLoadingAdMob = false; diff --git a/README.md b/README.md index 1594f6f..3187ac8 100644 --- a/README.md +++ b/README.md @@ -168,14 +168,13 @@ Three details worth copying as they are: - **Reloading is in place, not a recreate** - `LoadBanner` on the existing view, allowed because refresh was stopped for that ad unit - so a visible ad is replaced only once the new one has filled, and the slot never blanks. -- **The cycle only turns while an ad is on screen.** Hiding cancels the pending pass and stops the - cycle retrying a load that was already in flight, so a hidden slot never keeps requesting in the - background; showing it again puts the same ad back up and restarts the cooldown from that tap. - Cancelling alone is not enough - a request already out on the network completes after the hide, - long after `CancelInvoke` had anything to cancel. A fill is harmless, because it is banked for the - next show; a failure is not, because its retry would start the requests up again. That is why the - cycle also tracks whether the banner is still wanted. It still preloads once before the first tap, so an ad is ready when the user asks for - it. +- **The cycle only turns while an ad is on screen.** Hiding cancels the pending pass, so a hidden + slot never keeps requesting in the background; showing it again puts the same ad back up and + restarts the cooldown from that tap. Cancelling alone is not enough - a request already out on the + network completes after the hide, long after `CancelInvoke` had anything to cancel. A fill is + harmless, because it is banked for the next show; a failure is not, because its retry would start + the requests up again, so the cycle also tracks whether the banner is still wanted. It still + preloads once before the first tap, so an ad is ready when the user asks for it. - **An ad the AdMob console refreshed on its own does not count as a pass.** Only a fill the controller asked for spends one, so an AdMob unit that still has Automatic refresh enabled cannot keep postponing CloudX's next first look - which it otherwise would, on every refresh. The demo's From 7ecfe8518a49ea591a8ea3cd278c6ff893cd9e23 Mon Sep 17 00:00:00 2001 From: Anton Urankar <124867229+antonurankar-moloco@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:20:58 +0200 Subject: [PATCH 16/16] Exempt the preload from the new "do not call Load on a hidden slot" rule The rule as written forbade the preload, which the cycle itself performs from Begin before anything has been shown. What Load must not follow is a Hide; before the first Show nothing has been dismissed, and the preload has to be allowed to reach the fallback - as the _passCancelled comment already says. Reword to name the dismissal rather than the absence of an ad on screen. --- Assets/Scripts/FirstLook/FirstLookBannerController.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Assets/Scripts/FirstLook/FirstLookBannerController.cs b/Assets/Scripts/FirstLook/FirstLookBannerController.cs index 5beb93f..a361fde 100644 --- a/Assets/Scripts/FirstLook/FirstLookBannerController.cs +++ b/Assets/Scripts/FirstLook/FirstLookBannerController.cs @@ -34,10 +34,12 @@ * into the visible view and spends the next pass at once. * 2. Cancel that pending pass when it calls Hide(), or a hidden slot keeps * requesting. Show() starts the cycle again. - * 3. Do not call Load() while the slot is hidden. Load() means the slot is - * wanted, so it lifts the cancellation below - including on a pass still - * out on the network - and a load asked for on a dismissed slot puts the - * requests back with nothing to stop them. + * 3. Do not call Load() on a slot the player dismissed. Load() means the + * slot is wanted, so it lifts the cancellation below - including on a + * pass still out on the network - and a load asked for after a Hide puts + * the requests back with nothing to stop them. The preload before the + * first Show is a different thing and is fine: nothing has been + * dismissed yet. * * Hide also ends the pass already running, and that part is this controller's * job rather than the host's: a CloudX load still in flight will not hand over