This commit is contained in:
Savya Bikram Shah
2026-06-01 16:52:00 +05:45
parent 89011184d5
commit 705d6b4825
4 changed files with 764 additions and 126 deletions

View File

@@ -23,6 +23,8 @@ namespace Darkmatter.Services.Ads
[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;
public bool IsInitialized => _initialized;
public event Action<AdFormat, AdLoadState> LoadStateChanged;
@@ -32,6 +34,13 @@ namespace Darkmatter.Services.Ads
private bool _isChildDirected;
private CancellationTokenSource _lifetimeCts;
// App interruption state, fed by the Unity lifecycle messages below. A full-screen ad
// pushes the app into this state (Android raises focus-loss, iOS raises pause); the
// watchdog uses the return-to-foreground transition to recover a missed close callback.
private bool _appPaused;
private bool _appUnfocused;
private bool AppInterrupted => _appPaused || _appUnfocused;
private readonly Dictionary<AdFormat, AdLoadState> _states = new();
#if GOOGLE_MOBILE_ADS
@@ -64,6 +73,11 @@ namespace Darkmatter.Services.Ads
#endif
}
// Android raises focus-loss for full-screen ads; iOS raises pause. Track both so the show
// watchdog can detect the ad's return-to-foreground regardless of platform.
private void OnApplicationPause(bool pauseStatus) => _appPaused = pauseStatus;
private void OnApplicationFocus(bool hasFocus) => _appUnfocused = !hasFocus;
public async UniTask InitializeAsync(CancellationToken cancellationToken)
{
if (_initialized) return;
@@ -364,81 +378,116 @@ namespace Darkmatter.Services.Ads
}
}
private async UniTask<AdShowResult> ShowInterstitialAsync(CancellationToken cancellationToken)
{
var tcs = new UniTaskCompletionSource<AdShowResult>();
Action onClosed = () => tcs.TrySetResult(AdShowResult.Success());
Action<AdError> onFailed = err => tcs.TrySetResult(AdShowResult.Failure(err?.GetMessage()));
_interstitial.OnAdFullScreenContentClosed += onClosed;
_interstitial.OnAdFullScreenContentFailed += onFailed;
_interstitial.Show();
try
{
using (cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken)))
return await tcs.Task;
}
finally
{
if (_interstitial != null)
private UniTask<AdShowResult> ShowInterstitialAsync(CancellationToken cancellationToken) =>
ShowFullScreenAsync(
AdFormat.Interstitial,
(c, f) =>
{
_interstitial.OnAdFullScreenContentClosed -= onClosed;
_interstitial.OnAdFullScreenContentFailed -= onFailed;
}
ScheduleReload(AdFormat.Interstitial);
}
}
_interstitial.OnAdFullScreenContentClosed += c;
_interstitial.OnAdFullScreenContentFailed += f;
},
(c, f) =>
{
if (_interstitial == null) return;
_interstitial.OnAdFullScreenContentClosed -= c;
_interstitial.OnAdFullScreenContentFailed -= f;
},
() => AdShowResult.Success(),
() => _interstitial.Show(),
cancellationToken);
private async UniTask<AdShowResult> ShowRewardedAsync(CancellationToken cancellationToken)
private UniTask<AdShowResult> ShowRewardedAsync(CancellationToken cancellationToken)
{
var tcs = new UniTaskCompletionSource<AdShowResult>();
bool earned = false;
AdReward reward = default;
Action onClosed = () => tcs.TrySetResult(earned ? AdShowResult.WithReward(reward) : AdShowResult.Success());
Action<AdError> onFailed = err => tcs.TrySetResult(AdShowResult.Failure(err?.GetMessage()));
_rewarded.OnAdFullScreenContentClosed += onClosed;
_rewarded.OnAdFullScreenContentFailed += onFailed;
_rewarded.Show(r =>
{
earned = true;
reward = new AdReward(r.Type, r.Amount);
});
try
{
using (cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken)))
return await tcs.Task;
}
finally
{
if (_rewarded != null)
return ShowFullScreenAsync(
AdFormat.Rewarded,
(c, f) =>
{
_rewarded.OnAdFullScreenContentClosed -= onClosed;
_rewarded.OnAdFullScreenContentFailed -= onFailed;
}
ScheduleReload(AdFormat.Rewarded);
}
_rewarded.OnAdFullScreenContentClosed += c;
_rewarded.OnAdFullScreenContentFailed += f;
},
(c, f) =>
{
if (_rewarded == null) return;
_rewarded.OnAdFullScreenContentClosed -= c;
_rewarded.OnAdFullScreenContentFailed -= f;
},
() => earned ? AdShowResult.WithReward(reward) : AdShowResult.Success(),
() => _rewarded.Show(r =>
{
earned = true;
reward = new AdReward(r.Type, r.Amount);
}),
cancellationToken);
}
private async UniTask<AdShowResult> ShowRewardedInterstitialAsync(CancellationToken cancellationToken)
private UniTask<AdShowResult> ShowRewardedInterstitialAsync(CancellationToken cancellationToken)
{
var tcs = new UniTaskCompletionSource<AdShowResult>();
bool earned = false;
AdReward reward = default;
return ShowFullScreenAsync(
AdFormat.RewardedInterstitial,
(c, f) =>
{
_rewardedInterstitial.OnAdFullScreenContentClosed += c;
_rewardedInterstitial.OnAdFullScreenContentFailed += f;
},
(c, f) =>
{
if (_rewardedInterstitial == null) return;
_rewardedInterstitial.OnAdFullScreenContentClosed -= c;
_rewardedInterstitial.OnAdFullScreenContentFailed -= f;
},
() => earned ? AdShowResult.WithReward(reward) : AdShowResult.Success(),
() => _rewardedInterstitial.Show(r =>
{
earned = true;
reward = new AdReward(r.Type, r.Amount);
}),
cancellationToken);
}
Action onClosed = () => tcs.TrySetResult(earned ? AdShowResult.WithReward(reward) : AdShowResult.Success());
Action<AdError> onFailed = err => tcs.TrySetResult(AdShowResult.Failure(err?.GetMessage()));
private UniTask<AdShowResult> ShowAppOpenAsync(CancellationToken cancellationToken) =>
ShowFullScreenAsync(
AdFormat.AppOpen,
(c, f) =>
{
_appOpen.OnAdFullScreenContentClosed += c;
_appOpen.OnAdFullScreenContentFailed += f;
},
(c, f) =>
{
if (_appOpen == null) return;
_appOpen.OnAdFullScreenContentClosed -= c;
_appOpen.OnAdFullScreenContentFailed -= f;
},
() => AdShowResult.Success(),
() => _appOpen.Show(),
cancellationToken);
_rewardedInterstitial.OnAdFullScreenContentClosed += onClosed;
_rewardedInterstitial.OnAdFullScreenContentFailed += onFailed;
_rewardedInterstitial.Show(r =>
{
earned = true;
reward = new AdReward(r.Type, r.Amount);
});
// Shared full-screen show flow. AdMob can drop OnAdFullScreenContentClosed for a shown ad
// (focus loss/regain mid-ad, reloaded-ad reuse on a 2nd show, SDK edge cases); without a
// fallback the awaiting caller hangs forever and the post-ad scene load never runs (loading
// bar frozen at 0%). WatchShowAsync force-resolves via idempotent TrySetResult if the real
// close event never arrives. buildResult is reused so a granted reward survives recovery.
private async UniTask<AdShowResult> ShowFullScreenAsync(
AdFormat format,
Action<Action, Action<AdError>> subscribe,
Action<Action, Action<AdError>> unsubscribe,
Func<AdShowResult> buildResult,
Action show,
CancellationToken cancellationToken)
{
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())); };
subscribe(onClosed, onFailed);
show();
WatchShowAsync(tcs, () => resolved, buildResult, cancellationToken).Forget();
try
{
@@ -447,39 +496,59 @@ namespace Darkmatter.Services.Ads
}
finally
{
if (_rewardedInterstitial != null)
{
_rewardedInterstitial.OnAdFullScreenContentClosed -= onClosed;
_rewardedInterstitial.OnAdFullScreenContentFailed -= onFailed;
}
ScheduleReload(AdFormat.RewardedInterstitial);
resolved = true; // stop the watchdog within one poll
unsubscribe(onClosed, onFailed);
ScheduleReload(format);
}
}
private async UniTask<AdShowResult> ShowAppOpenAsync(CancellationToken cancellationToken)
// Recovers a full-screen show when AdMob never raises its close callback. Primary signal:
// the ad interrupts the app (focus-loss on Android, pause on iOS) and then the app returns
// to the foreground — independent of OnAdFullScreenContentOpened, which can also be dropped.
// Fallback: a hard time cap for platforms/cases where neither lifecycle event fires.
// Realtime delays so a paused game (timeScale = 0) can't freeze the watchdog.
private async UniTaskVoid WatchShowAsync(
UniTaskCompletionSource<AdShowResult> tcs,
Func<bool> isResolved,
Func<AdShowResult> buildResult,
CancellationToken cancellationToken)
{
var tcs = new UniTaskCompletionSource<AdShowResult>();
Action onClosed = () => tcs.TrySetResult(AdShowResult.Success());
Action<AdError> onFailed = err => tcs.TrySetResult(AdShowResult.Failure(err?.GetMessage()));
_appOpen.OnAdFullScreenContentClosed += onClosed;
_appOpen.OnAdFullScreenContentFailed += onFailed;
_appOpen.Show();
const int PollMs = 250;
const int ForegroundGraceMs = 750;
bool sawInterrupted = false;
float elapsed = 0f;
try
{
using (cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken)))
return await tcs.Task;
}
finally
{
if (_appOpen != null)
while (!cancellationToken.IsCancellationRequested && !isResolved())
{
_appOpen.OnAdFullScreenContentClosed -= onClosed;
_appOpen.OnAdFullScreenContentFailed -= onFailed;
await UniTask.Delay(PollMs, DelayType.Realtime, PlayerLoopTiming.Update, cancellationToken);
if (isResolved()) return;
elapsed += PollMs / 1000f;
if (AppInterrupted)
{
sawInterrupted = true; // ad took the foreground
}
else if (sawInterrupted)
{
// 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);
if (!isResolved() && tcs.TrySetResult(buildResult()))
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.");
return;
}
}
ScheduleReload(AdFormat.AppOpen);
}
catch (OperationCanceledException) { }
}
private void WireFullScreenEvents(InterstitialAd ad, AdFormat format) =>