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 _selectedSub;
private IDisposable _returnToMainMenuSubscription; private IDisposable _returnToMainMenuSubscription;
private bool _navigatingToGameplay; 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; private CancellationTokenSource _scopeCts;
public ColorbookFlowController( public ColorbookFlowController(
@@ -65,15 +70,21 @@ public class ColorbookFlowController : IAsyncStartable, IDisposable
if (!_navigatingToGameplay) _loadingScreen.Hide(); 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 try
{ {
if (!_ads.IsInitialized) await _ads.InitializeAsync(ct); if (!_ads.IsInitialized) await _ads.InitializeAsync(CancellationToken.None);
if (!_ads.IsReady(AdFormat.Interstitial)) await _ads.LoadAsync(AdFormat.Interstitial, ct); if (!_ads.IsReady(AdFormat.Interstitial)) await _ads.LoadAsync(AdFormat.Interstitial, CancellationToken.None);
} }
catch (OperationCanceledException) { } catch (OperationCanceledException) { }
catch (Exception ex) catch (Exception ex)
@@ -106,19 +117,18 @@ public class ColorbookFlowController : IAsyncStartable, IDisposable
private async UniTaskVoid HandleSelectionAsync(string templateId) private async UniTaskVoid HandleSelectionAsync(string templateId)
{ {
var ct = _scopeCts?.Token ?? CancellationToken.None;
_loadingScreen.Show(); _loadingScreen.Show();
_loadingScreen.SetProgress(0f); _loadingScreen.SetProgress(0f);
// Frequency cap: show an interstitial on every 2nd level open (2nd, 4th, ...). On skip turns // 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 // 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 // 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) if (++_selectCount % 2 == 0)
ShowInterstitialAdAsync(ct).Forget(); ShowInterstitialAdAsync().Forget();
else else
PrewarmInterstitialAdAsync(ct).Forget(); PrewarmInterstitialAdAsync().Forget();
try 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 // 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 // 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. // 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 try
{ {
@@ -151,11 +162,11 @@ public class ColorbookFlowController : IAsyncStartable, IDisposable
if (!_ads.IsReady(AdFormat.Interstitial)) if (!_ads.IsReady(AdFormat.Interstitial))
{ {
_ads.LoadAsync(AdFormat.Interstitial, ct).Forget(); _ads.LoadAsync(AdFormat.Interstitial, CancellationToken.None).Forget();
return; return;
} }
await _ads.ShowAsync(AdFormat.Interstitial, ct); await _ads.ShowAsync(AdFormat.Interstitial, CancellationToken.None);
} }
catch (OperationCanceledException) { } catch (OperationCanceledException) { }
catch (Exception ex) catch (Exception ex)

View File

@@ -19,27 +19,40 @@ namespace Darkmatter.Services.Ads
public class AdMobAdService : MonoBehaviour, IAdService public class AdMobAdService : MonoBehaviour, IAdService
{ {
[SerializeField] private AdUnitCatalogSO catalog; [SerializeField] private AdUnitCatalogSO catalog;
[Tooltip("Auto-reload after dismiss/failure for non-banner formats.")]
[SerializeField] private bool autoReload = true; [Tooltip("Auto-reload after dismiss/failure for non-banner formats.")] [SerializeField]
[Tooltip("Seconds between auto-reload retries on failure.")] private bool autoReload = true;
[SerializeField, Min(1f)] private float reloadDelaySeconds = 5f;
[Tooltip("Max reload attempts before giving up.")] [Tooltip("Seconds between auto-reload retries on failure.")] [SerializeField, Min(1f)]
[SerializeField, Min(1)] private int reloadMaxAttempts = 6; private float reloadDelaySeconds = 5f;
[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 reload attempts before giving up.")] [SerializeField, Min(1)]
[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.")] private int reloadMaxAttempts = 6;
[SerializeField, Min(0)] private int maxInterstitialsPerSession = 8;
[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 bool IsInitialized => _initialized;
public event Action<AdFormat, AdLoadState> LoadStateChanged; public event Action<AdFormat, AdLoadState> LoadStateChanged;
private IAnalyticsService _analytics; private IAnalyticsService _analytics;
private bool _initialized; private bool _initialized;
// Per-session interstitial counter. The service is the app-lifetime singleton (it retains // 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 // loaded ads across scene swaps), so this survives Colorbook<->Gameplay transitions and only
// resets on app restart — i.e. a true per-session cap. // resets on app restart — i.e. a true per-session cap.
private int _interstitialsShownThisSession; private int _interstitialsShownThisSession;
private bool _hasUserConsent = true; private bool _hasUserConsent = true;
// Coloring book is a child-directed app, so default to true. SetConsent can still // Coloring book is a child-directed app, so default to true. SetConsent can still
// override if a consent flow later supplies a different value. // override if a consent flow later supplies a different value.
private bool _isChildDirected = true; private bool _isChildDirected = true;
@@ -165,6 +178,7 @@ namespace Darkmatter.Services.Ads
SetState(format, AdLoadState.Failed); SetState(format, AdLoadState.Failed);
return false; return false;
} }
return false; return false;
#else #else
await UniTask.CompletedTask; 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 // 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. // is silent (Failure, Shown=false); the fire-and-forget caller just keeps playing.
if (format == AdFormat.Interstitial && _interstitialsShownThisSession >= maxInterstitialsPerSession) if (format == AdFormat.Interstitial && _interstitialsShownThisSession >= maxInterstitialsPerSession)
return AdShowResult.Failure("Session interstitial cap reached."); return AdShowResult.Failure("[AdMobAdService] Session interstitial cap reached.");
if (!IsReady(format)) if (!IsReady(format))
{ {
@@ -206,6 +220,7 @@ namespace Darkmatter.Services.Ads
if (!loaded) return AdShowResult.Failure($"{format} failed to load."); if (!loaded) return AdShowResult.Failure($"{format} failed to load.");
} }
Debug.Log($"[AdMobAdService] Showing {format} ad.");
switch (format) switch (format)
{ {
case AdFormat.Interstitial: case AdFormat.Interstitial:
@@ -218,6 +233,7 @@ namespace Darkmatter.Services.Ads
case AdFormat.RewardedInterstitial: return await ShowRewardedInterstitialAsync(cancellationToken); case AdFormat.RewardedInterstitial: return await ShowRewardedInterstitialAsync(cancellationToken);
case AdFormat.AppOpen: return await ShowAppOpenAsync(cancellationToken); case AdFormat.AppOpen: return await ShowAppOpenAsync(cancellationToken);
} }
return AdShowResult.Failure($"{format} not supported by ShowAsync."); return AdShowResult.Failure($"{format} not supported by ShowAsync.");
#else #else
await UniTask.CompletedTask; await UniTask.CompletedTask;
@@ -225,7 +241,8 @@ namespace Darkmatter.Services.Ads
#endif #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; if (!_initialized) return false;
@@ -309,7 +326,8 @@ namespace Darkmatter.Services.Ads
MobileAds.SetRequestConfiguration(config); 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>(); var tcs = new UniTaskCompletionSource<bool>();
InterstitialAd.Load(unitId, request, (ad, error) => InterstitialAd.Load(unitId, request, (ad, error) =>
@@ -320,6 +338,7 @@ namespace Darkmatter.Services.Ads
tcs.TrySetResult(false); tcs.TrySetResult(false);
return; return;
} }
_interstitial?.Destroy(); _interstitial?.Destroy();
_interstitial = ad; _interstitial = ad;
WireFullScreenEvents(ad, AdFormat.Interstitial); 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>(); var tcs = new UniTaskCompletionSource<bool>();
RewardedAd.Load(unitId, request, (ad, error) => RewardedAd.Load(unitId, request, (ad, error) =>
@@ -345,6 +365,7 @@ namespace Darkmatter.Services.Ads
tcs.TrySetResult(false); tcs.TrySetResult(false);
return; return;
} }
_rewarded?.Destroy(); _rewarded?.Destroy();
_rewarded = ad; _rewarded = ad;
WireFullScreenEvents(ad, AdFormat.Rewarded); 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>(); var tcs = new UniTaskCompletionSource<bool>();
RewardedInterstitialAd.Load(unitId, request, (ad, error) => RewardedInterstitialAd.Load(unitId, request, (ad, error) =>
@@ -370,6 +392,7 @@ namespace Darkmatter.Services.Ads
tcs.TrySetResult(false); tcs.TrySetResult(false);
return; return;
} }
_rewardedInterstitial?.Destroy(); _rewardedInterstitial?.Destroy();
_rewardedInterstitial = ad; _rewardedInterstitial = ad;
WireFullScreenEvents(ad, AdFormat.RewardedInterstitial); 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>(); var tcs = new UniTaskCompletionSource<bool>();
AppOpenAd.Load(unitId, request, (ad, error) => AppOpenAd.Load(unitId, request, (ad, error) =>
@@ -395,6 +419,7 @@ namespace Darkmatter.Services.Ads
tcs.TrySetResult(false); tcs.TrySetResult(false);
return; return;
} }
_appOpen?.Destroy(); _appOpen?.Destroy();
_appOpen = ad; _appOpen = ad;
WireFullScreenEvents(ad, AdFormat.AppOpen); WireFullScreenEvents(ad, AdFormat.AppOpen);
@@ -513,8 +538,16 @@ namespace Darkmatter.Services.Ads
var tcs = new UniTaskCompletionSource<AdShowResult>(); var tcs = new UniTaskCompletionSource<AdShowResult>();
bool resolved = false; bool resolved = false;
Action onClosed = () => { resolved = true; tcs.TrySetResult(buildResult()); }; Action onClosed = () =>
Action<AdError> onFailed = err => { resolved = true; tcs.TrySetResult(AdShowResult.Failure(err?.GetMessage())); }; {
resolved = true;
tcs.TrySetResult(buildResult());
};
Action<AdError> onFailed = err =>
{
resolved = true;
tcs.TrySetResult(AdShowResult.Failure(err?.GetMessage()));
};
subscribe(onClosed, onFailed); subscribe(onClosed, onFailed);
show(); show();
@@ -570,21 +603,26 @@ namespace Darkmatter.Services.Ads
{ {
// App returned to foreground after the ad held it => ad was dismissed but // 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. // 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())) 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; return;
} }
if (elapsed >= showWatchdogSeconds) if (elapsed >= showWatchdogSeconds)
{ {
if (!isResolved() && tcs.TrySetResult(buildResult())) 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; return;
} }
} }
} }
catch (OperationCanceledException) { } catch (OperationCanceledException)
{
}
} }
private void WireFullScreenEvents(InterstitialAd ad, AdFormat format) private void WireFullScreenEvents(InterstitialAd ad, AdFormat format)
@@ -658,9 +696,12 @@ namespace Darkmatter.Services.Ads
if (IsReady(format)) return; if (IsReady(format)) return;
if (await LoadAsync(format, cancellationToken)) return; if (await LoadAsync(format, cancellationToken)) return;
} }
Debug.LogWarning($"[AdMobAdService] {format} reload gave up after {reloadMaxAttempts} attempts."); Debug.LogWarning($"[AdMobAdService] {format} reload gave up after {reloadMaxAttempts} attempts.");
} }
catch (OperationCanceledException) { } catch (OperationCanceledException)
{
}
} }
private static AdSize MapBannerSize(BannerSize size) => size switch private static AdSize MapBannerSize(BannerSize size) => size switch
@@ -671,7 +712,8 @@ namespace Darkmatter.Services.Ads
BannerSize.FullBanner => AdSize.IABBanner, BannerSize.FullBanner => AdSize.IABBanner,
BannerSize.Leaderboard => AdSize.Leaderboard, BannerSize.Leaderboard => AdSize.Leaderboard,
BannerSize.SmartBanner => AdSize.SmartBanner, BannerSize.SmartBanner => AdSize.SmartBanner,
BannerSize.AnchoredAdaptive => AdSize.GetCurrentOrientationAnchoredAdaptiveBannerAdSizeWithWidth(AdSize.FullWidth), BannerSize.AnchoredAdaptive => AdSize.GetCurrentOrientationAnchoredAdaptiveBannerAdSizeWithWidth(
AdSize.FullWidth),
_ => AdSize.Banner _ => AdSize.Banner
}; };

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_ExportAsGoogleAndroidProject: 0
m_DebugSymbolLevel: 4 m_DebugSymbolLevel: 4
m_DebugSymbolFormat: 5 m_DebugSymbolFormat: 5
m_CurrentDeploymentTargetId: KBUKQ4PNCAOBS8YH m_CurrentDeploymentTargetId: __builtin__target_default
m_BuildType: 2 m_BuildType: 2
m_LinkTimeOptimization: 0 m_LinkTimeOptimization: 0
m_BuildAppBundle: 1 m_BuildAppBundle: 1

View File

@@ -175,7 +175,7 @@ PlayerSettings:
iPhone: 1 iPhone: 1
tvOS: 0 tvOS: 0
overrideDefaultApplicationIdentifier: 1 overrideDefaultApplicationIdentifier: 1
AndroidBundleVersionCode: 6 AndroidBundleVersionCode: 7
AndroidMinSdkVersion: 25 AndroidMinSdkVersion: 25
AndroidTargetSdkVersion: 0 AndroidTargetSdkVersion: 0
AndroidPreferredInstallLocation: 1 AndroidPreferredInstallLocation: 1