This commit is contained in:
Savya Bikram Shah
2026-06-07 18:41:02 +05:45
parent a15b08df0c
commit 729ddf9277
8 changed files with 1791 additions and 69 deletions

View File

@@ -28,7 +28,12 @@ public class ColorbookFlowController : IAsyncStartable, IDisposable
private IDisposable _selectedSub;
private IDisposable _returnToMainMenuSubscription;
private bool _navigatingToGameplay;
private int _selectCount;
// App-session counter, NOT instance state. ColorbookFlowController is scene-scoped: a fresh one
// is built every time the Colorbook scene loads, and selecting a drawing unloads the scene — so
// each visit makes exactly ONE selection. An instance counter would reset to 0 each visit, land
// on 1 (odd) every time, and the "% 2 == 0" show branch would NEVER fire. static persists across
// visits for the whole app run (resets on restart), matching the per-session interstitial cap.
private static int _selectCount;
private CancellationTokenSource _scopeCts;
public ColorbookFlowController(
@@ -65,15 +70,21 @@ public class ColorbookFlowController : IAsyncStartable, IDisposable
if (!_navigatingToGameplay) _loadingScreen.Hide();
PrewarmInterstitialAdAsync(ct).Forget();
PrewarmInterstitialAdAsync().Forget();
}
private async UniTaskVoid PrewarmInterstitialAdAsync(CancellationToken ct)
// Ad ops MUST NOT use the Colorbook scope token. This scene unloads inside HandleSelectionAsync —
// the same moment the interstitial should show — which disposes ColorBookLifetimeScope, calls our
// Dispose(), and cancels _scopeCts. A scope-bound load/show then aborts mid-flight (load: never
// ready; show: await throws OCE, handlers unsubscribed). AdMobAdService is the Boot/Root-scoped,
// app-lifetime singleton and self-manages teardown (_lifetimeCts + OnDestroy), so drive ad ops on
// CancellationToken.None — they intentionally outlive this scene.
private async UniTaskVoid PrewarmInterstitialAdAsync()
{
try
{
if (!_ads.IsInitialized) await _ads.InitializeAsync(ct);
if (!_ads.IsReady(AdFormat.Interstitial)) await _ads.LoadAsync(AdFormat.Interstitial, ct);
if (!_ads.IsInitialized) await _ads.InitializeAsync(CancellationToken.None);
if (!_ads.IsReady(AdFormat.Interstitial)) await _ads.LoadAsync(AdFormat.Interstitial, CancellationToken.None);
}
catch (OperationCanceledException) { }
catch (Exception ex)
@@ -106,19 +117,18 @@ public class ColorbookFlowController : IAsyncStartable, IDisposable
private async UniTaskVoid HandleSelectionAsync(string templateId)
{
var ct = _scopeCts?.Token ?? CancellationToken.None;
_loadingScreen.Show();
_loadingScreen.SetProgress(0f);
// Frequency cap: show an interstitial on every 2nd level open (2nd, 4th, ...). On skip turns
// just keep one prewarmed so the next show has an ad ready. Fire-and-forget either way — the
// ad overlays the transition while the level loads underneath, so a missed/dropped ad callback
// can't stall the flow at 0% anymore.
// can't stall the flow at 0% anymore. Ad ops run on CancellationToken.None, NOT the scene
// scope token: this scene unloads further down, which would otherwise cancel the show.
if (++_selectCount % 2 == 0)
ShowInterstitialAdAsync(ct).Forget();
ShowInterstitialAdAsync().Forget();
else
PrewarmInterstitialAdAsync(ct).Forget();
PrewarmInterstitialAdAsync().Forget();
try
{
@@ -143,7 +153,8 @@ public class ColorbookFlowController : IAsyncStartable, IDisposable
// Fire-and-forget interstitial. Shows only if one is already prewarmed; otherwise it kicks a
// load for next time and returns immediately. Never blocks the level load — by design the
// scene swap below does not depend on the ad's close callback, so the ad can never stall it.
private async UniTaskVoid ShowInterstitialAdAsync(CancellationToken ct)
// CancellationToken.None: the show must survive this scene's unload (see PrewarmInterstitialAdAsync).
private async UniTaskVoid ShowInterstitialAdAsync()
{
try
{
@@ -151,11 +162,11 @@ public class ColorbookFlowController : IAsyncStartable, IDisposable
if (!_ads.IsReady(AdFormat.Interstitial))
{
_ads.LoadAsync(AdFormat.Interstitial, ct).Forget();
_ads.LoadAsync(AdFormat.Interstitial, CancellationToken.None).Forget();
return;
}
await _ads.ShowAsync(AdFormat.Interstitial, ct);
await _ads.ShowAsync(AdFormat.Interstitial, CancellationToken.None);
}
catch (OperationCanceledException) { }
catch (Exception ex)

View File

@@ -19,27 +19,40 @@ namespace Darkmatter.Services.Ads
public class AdMobAdService : MonoBehaviour, IAdService
{
[SerializeField] private AdUnitCatalogSO catalog;
[Tooltip("Auto-reload after dismiss/failure for non-banner formats.")]
[SerializeField] private bool autoReload = true;
[Tooltip("Seconds between auto-reload retries on failure.")]
[SerializeField, Min(1f)] private float reloadDelaySeconds = 5f;
[Tooltip("Max reload attempts before giving up.")]
[SerializeField, Min(1)] private int reloadMaxAttempts = 6;
[Tooltip("Hard fallback (seconds) to recover a full-screen show if AdMob never raises its close callback. Android focus-return recovery usually fires far sooner; this cap covers iOS/edge cases. Must exceed max plausible ad length so a real ad is never cut short.")]
[SerializeField, Min(15f)] private float showWatchdogSeconds = 60f;
[Tooltip("Max interstitials shown per app session (run). Once reached, ShowAsync(Interstitial) no-ops until the app restarts. Counts only ads actually shown, not failed/skipped attempts. 0 disables interstitials.")]
[SerializeField, Min(0)] private int maxInterstitialsPerSession = 8;
[Tooltip("Auto-reload after dismiss/failure for non-banner formats.")] [SerializeField]
private bool autoReload = true;
[Tooltip("Seconds between auto-reload retries on failure.")] [SerializeField, Min(1f)]
private float reloadDelaySeconds = 5f;
[Tooltip("Max reload attempts before giving up.")] [SerializeField, Min(1)]
private int reloadMaxAttempts = 6;
[Tooltip(
"Hard fallback (seconds) to recover a full-screen show if AdMob never raises its close callback. Android focus-return recovery usually fires far sooner; this cap covers iOS/edge cases. Must exceed max plausible ad length so a real ad is never cut short.")]
[SerializeField, Min(15f)]
private float showWatchdogSeconds = 60f;
[Tooltip(
"Max interstitials shown per app session (run). Once reached, ShowAsync(Interstitial) no-ops until the app restarts. Counts only ads actually shown, not failed/skipped attempts. 0 disables interstitials.")]
[SerializeField, Min(0)]
private int maxInterstitialsPerSession = 8;
public bool IsInitialized => _initialized;
public event Action<AdFormat, AdLoadState> LoadStateChanged;
private IAnalyticsService _analytics;
private bool _initialized;
// Per-session interstitial counter. The service is the app-lifetime singleton (it retains
// loaded ads across scene swaps), so this survives Colorbook<->Gameplay transitions and only
// resets on app restart — i.e. a true per-session cap.
private int _interstitialsShownThisSession;
private bool _hasUserConsent = true;
// Coloring book is a child-directed app, so default to true. SetConsent can still
// override if a consent flow later supplies a different value.
private bool _isChildDirected = true;
@@ -165,6 +178,7 @@ namespace Darkmatter.Services.Ads
SetState(format, AdLoadState.Failed);
return false;
}
return false;
#else
await UniTask.CompletedTask;
@@ -198,7 +212,7 @@ namespace Darkmatter.Services.Ads
// Per-session interstitial cap. Check before load so a capped show wastes no fill. Skip
// is silent (Failure, Shown=false); the fire-and-forget caller just keeps playing.
if (format == AdFormat.Interstitial && _interstitialsShownThisSession >= maxInterstitialsPerSession)
return AdShowResult.Failure("Session interstitial cap reached.");
return AdShowResult.Failure("[AdMobAdService] Session interstitial cap reached.");
if (!IsReady(format))
{
@@ -206,6 +220,7 @@ namespace Darkmatter.Services.Ads
if (!loaded) return AdShowResult.Failure($"{format} failed to load.");
}
Debug.Log($"[AdMobAdService] Showing {format} ad.");
switch (format)
{
case AdFormat.Interstitial:
@@ -218,6 +233,7 @@ namespace Darkmatter.Services.Ads
case AdFormat.RewardedInterstitial: return await ShowRewardedInterstitialAsync(cancellationToken);
case AdFormat.AppOpen: return await ShowAppOpenAsync(cancellationToken);
}
return AdShowResult.Failure($"{format} not supported by ShowAsync.");
#else
await UniTask.CompletedTask;
@@ -225,7 +241,8 @@ namespace Darkmatter.Services.Ads
#endif
}
public async UniTask<bool> ShowBannerAsync(BannerSize size, BannerPosition position, CancellationToken cancellationToken)
public async UniTask<bool> ShowBannerAsync(BannerSize size, BannerPosition position,
CancellationToken cancellationToken)
{
if (!_initialized) return false;
@@ -309,7 +326,8 @@ namespace Darkmatter.Services.Ads
MobileAds.SetRequestConfiguration(config);
}
private async UniTask<bool> LoadInterstitialAsync(string unitId, AdRequest request, CancellationToken cancellationToken)
private async UniTask<bool> LoadInterstitialAsync(string unitId, AdRequest request,
CancellationToken cancellationToken)
{
var tcs = new UniTaskCompletionSource<bool>();
InterstitialAd.Load(unitId, request, (ad, error) =>
@@ -320,6 +338,7 @@ namespace Darkmatter.Services.Ads
tcs.TrySetResult(false);
return;
}
_interstitial?.Destroy();
_interstitial = ad;
WireFullScreenEvents(ad, AdFormat.Interstitial);
@@ -334,7 +353,8 @@ namespace Darkmatter.Services.Ads
}
}
private async UniTask<bool> LoadRewardedAsync(string unitId, AdRequest request, CancellationToken cancellationToken)
private async UniTask<bool> LoadRewardedAsync(string unitId, AdRequest request,
CancellationToken cancellationToken)
{
var tcs = new UniTaskCompletionSource<bool>();
RewardedAd.Load(unitId, request, (ad, error) =>
@@ -345,6 +365,7 @@ namespace Darkmatter.Services.Ads
tcs.TrySetResult(false);
return;
}
_rewarded?.Destroy();
_rewarded = ad;
WireFullScreenEvents(ad, AdFormat.Rewarded);
@@ -359,7 +380,8 @@ namespace Darkmatter.Services.Ads
}
}
private async UniTask<bool> LoadRewardedInterstitialAsync(string unitId, AdRequest request, CancellationToken cancellationToken)
private async UniTask<bool> LoadRewardedInterstitialAsync(string unitId, AdRequest request,
CancellationToken cancellationToken)
{
var tcs = new UniTaskCompletionSource<bool>();
RewardedInterstitialAd.Load(unitId, request, (ad, error) =>
@@ -370,6 +392,7 @@ namespace Darkmatter.Services.Ads
tcs.TrySetResult(false);
return;
}
_rewardedInterstitial?.Destroy();
_rewardedInterstitial = ad;
WireFullScreenEvents(ad, AdFormat.RewardedInterstitial);
@@ -384,7 +407,8 @@ namespace Darkmatter.Services.Ads
}
}
private async UniTask<bool> LoadAppOpenAsync(string unitId, AdRequest request, CancellationToken cancellationToken)
private async UniTask<bool> LoadAppOpenAsync(string unitId, AdRequest request,
CancellationToken cancellationToken)
{
var tcs = new UniTaskCompletionSource<bool>();
AppOpenAd.Load(unitId, request, (ad, error) =>
@@ -395,6 +419,7 @@ namespace Darkmatter.Services.Ads
tcs.TrySetResult(false);
return;
}
_appOpen?.Destroy();
_appOpen = ad;
WireFullScreenEvents(ad, AdFormat.AppOpen);
@@ -513,8 +538,16 @@ namespace Darkmatter.Services.Ads
var tcs = new UniTaskCompletionSource<AdShowResult>();
bool resolved = false;
Action onClosed = () => { resolved = true; tcs.TrySetResult(buildResult()); };
Action<AdError> onFailed = err => { resolved = true; tcs.TrySetResult(AdShowResult.Failure(err?.GetMessage())); };
Action onClosed = () =>
{
resolved = true;
tcs.TrySetResult(buildResult());
};
Action<AdError> onFailed = err =>
{
resolved = true;
tcs.TrySetResult(AdShowResult.Failure(err?.GetMessage()));
};
subscribe(onClosed, onFailed);
show();
@@ -570,21 +603,26 @@ namespace Darkmatter.Services.Ads
{
// App returned to foreground after the ad held it => ad was dismissed but
// the close callback was dropped. Brief grace for the real event, then force.
await UniTask.Delay(ForegroundGraceMs, DelayType.Realtime, PlayerLoopTiming.Update, cancellationToken);
await UniTask.Delay(ForegroundGraceMs, DelayType.Realtime, PlayerLoopTiming.Update,
cancellationToken);
if (!isResolved() && tcs.TrySetResult(buildResult()))
Debug.LogWarning("[AdMobAdService] Close callback missed; recovered via foreground watchdog.");
Debug.LogWarning(
"[AdMobAdService] Close callback missed; recovered via foreground watchdog.");
return;
}
if (elapsed >= showWatchdogSeconds)
{
if (!isResolved() && tcs.TrySetResult(buildResult()))
Debug.LogWarning($"[AdMobAdService] Close callback missed; recovered via {showWatchdogSeconds:0}s watchdog cap.");
Debug.LogWarning(
$"[AdMobAdService] Close callback missed; recovered via {showWatchdogSeconds:0}s watchdog cap.");
return;
}
}
}
catch (OperationCanceledException) { }
catch (OperationCanceledException)
{
}
}
private void WireFullScreenEvents(InterstitialAd ad, AdFormat format)
@@ -658,9 +696,12 @@ namespace Darkmatter.Services.Ads
if (IsReady(format)) return;
if (await LoadAsync(format, cancellationToken)) return;
}
Debug.LogWarning($"[AdMobAdService] {format} reload gave up after {reloadMaxAttempts} attempts.");
}
catch (OperationCanceledException) { }
catch (OperationCanceledException)
{
}
}
private static AdSize MapBannerSize(BannerSize size) => size switch
@@ -671,7 +712,8 @@ namespace Darkmatter.Services.Ads
BannerSize.FullBanner => AdSize.IABBanner,
BannerSize.Leaderboard => AdSize.Leaderboard,
BannerSize.SmartBanner => AdSize.SmartBanner,
BannerSize.AnchoredAdaptive => AdSize.GetCurrentOrientationAnchoredAdaptiveBannerAdSizeWithWidth(AdSize.FullWidth),
BannerSize.AnchoredAdaptive => AdSize.GetCurrentOrientationAnchoredAdaptiveBannerAdSizeWithWidth(
AdSize.FullWidth),
_ => AdSize.Banner
};
@@ -694,4 +736,4 @@ namespace Darkmatter.Services.Ads
LoadStateChanged?.Invoke(format, state);
}
}
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
<?xml version="1.0" encoding="utf-8"?><resources><string name="com.crashlytics.android.build_id" translatable="false">0a4c6c8f-8290-4f0a-8af5-899267e3f184</string></resources>
<?xml version="1.0" encoding="utf-8"?><resources><string name="com.crashlytics.android.build_id" translatable="false">ed36bb40-e40f-4cbd-95e3-d76e98ede383</string></resources>

View File

@@ -49,7 +49,7 @@ MonoBehaviour:
m_ExportAsGoogleAndroidProject: 0
m_DebugSymbolLevel: 4
m_DebugSymbolFormat: 5
m_CurrentDeploymentTargetId: KBUKQ4PNCAOBS8YH
m_CurrentDeploymentTargetId: __builtin__target_default
m_BuildType: 2
m_LinkTimeOptimization: 0
m_BuildAppBundle: 1