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

@@ -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);
}
}
}
}