·10 min read

Capacitor AdMob interstitial resolves too early

My Capacitor game dealt the next level behind the interstitial, because the plugin's show call resolves when the ad appears, not when it closes.

admobcapacitorandroidgamedev

Contents

I finished a stage of ASMR Logo Craft Block on an emulator, the interstitial came up, and by the time I closed it the next level had already been dealt underneath. The rewarded video had the same fault in a worse place. Vision, the hint that colours three groups for three seconds, ran its whole countdown underneath the video, so the player watched a video for a hint and saw none of it.

Both came from one assumption in the thin wrapper my games share around @capacitor-community/admob: that a show call resolves when the ad is over. It does not, and pulling that thread turned up three more bugs, one of them my own.

The game is a React app in a Capacitor WebView. The plugin was 8.1.0. Everything below was checked on two Android emulators, a phone and a tablet, with Google's test ad units and logcat open. The times are elapsed seconds, read off the logcat timestamps.

What the plugin's show calls actually promise

I read the three show paths in the plugin's native source, after the device traces had told me where to look:

Call Resolves Where
showInterstitial() as the ad goes on screen Android calls adToShow.show(...) and call.resolve() on the next line
showRewardVideoAd() when the reward is earned, with the video still up. Never, if the player closes it first Android and iOS resolve only inside the earned-reward callback
showBanner() on a banner that already exists never Android's updateExistingAdView loads a new ad into the old view and never touches the call

The version installed now is 8.2.1, and those three paths read the same.

What the plugin does report reliably is events: interstitialAdShowed, interstitialAdDismissed, onRewardedVideoAdReward, onRewardedVideoAdDismissed, bannerAdSizeChanged. Every fix below moves a decision off a promise and onto one of those.

The next level started behind the ad

The wrapper awaited AdMob.showInterstitial() and returned. The game awaited the wrapper and then dealt the next level. With the plugin resolving as the ad appears, that is a game that carries on the moment the ad is up.

The trace said so plainly. Four seconds after Continue, logcat had interstitialAdShowed and no interstitialAdDismissed, and the game's own state already had the next level open.

The fix is to wait for the dismissal. Subscribe before the show, so there is no window where a close can go unheard, and bound the wait, because a close that never arrives must not freeze the game. Mine is three minutes: a player who taps an ad can spend a while in the store before coming back to close it. A simplified version, not the shared package's code:

import { AdMob, InterstitialAdPluginEvents as I } from '@capacitor-community/admob';

// Resolves on the first of `events`, or after `ms`. `finish` ends it early.
function untilClosed(events, ms = 180_000) {
  let finish;
  const closed = new Promise((resolve) => { finish = resolve; });
  const subs = events.map((e) => AdMob.addListener(e, () => finish()));
  const timer = setTimeout(() => finish(), ms);
  closed.then(() => {
    clearTimeout(timer);
    subs.forEach((s) => s.then((h) => h.remove()));
  });
  return { closed, finish };
}

export async function showInterstitial() {
  const { closed, finish } = untilClosed([I.Dismissed, I.FailedToShow]);
  try {
    await AdMob.showInterstitial(); // the ad is on screen now, not gone
  } catch {
    finish();
    return false;
  }
  await closed;
  return true;
}

On the phone afterwards, with the ad left up on purpose:

+0.00 s   showInterstitial
+0.15 s   interstitialAdShowed
+11.24 s  interstitialAdDismissed
+11.32 s  resumeBanner

For those eleven seconds the game sat on level 21's win screen. Level 22 opened after the close, and the banner came back after the ad rather than under it. The tablet did the same on level 3: still on the board under the ad, level 4 only after I closed it.

The reward arrives while the video is still playing

The rewarded call has the mirror problem. The first rewarded run on the phone paid its reward about 8 seconds in, and the video closed about 4 seconds after that. The wrapper returned on the reward, so a 3-second reveal started there had finished before the player could see the board again.

So the rewarded show also waits for onRewardedVideoAdDismissed and answers with the reward it saw on the way. Rerun on the phone, the reward event came 8.4 seconds after the video appeared. I left the video up another 23 seconds, nothing moved, and the reveal started with its 3-second countdown only once I had closed it. The next rewarded video loaded 1.2 seconds after the close.

A rewarded video closed early never answers

That fix still had a hole, and it was in every game using the wrapper. Look at the table again: showRewardVideoAd() resolves only inside the earned-reward callback. A player who closes the video before the reward point leaves that promise pending forever. My wrapper awaited it before it even began waiting for the close, so on an early close:

  • the free-hint button's spinner spun for good;
  • the wrapper still believed a rewarded ad was on screen, so it never loaded another one for the rest of the session.

The old wrapper had been written on the belief that an early close resolves the call with no reward. That is how it ought to behave. The source says it does not.

The fix stops awaiting the show call altogether. The close event ends the show; the reward counts only if an earned-reward callback arrived with an amount above zero; one reported up to 500 ms after the close still counts, and one reported both ways counts once.

// Simplified, inside the wrapper's showRewarded(); R = RewardAdPluginEvents
let amount = 0;
const earned = AdMob.addListener(R.Rewarded, (r) => { amount = Math.max(amount, r?.amount ?? 0); });
const { closed, finish } = untilClosed([R.Dismissed, R.FailedToShow]);
AdMob.showRewardVideoAd().catch(() => finish()); // not awaited: an early close never settles it
await closed;
await new Promise((r) => setTimeout(r, 500));    // a reward reported just after the close
(await earned).remove();
return amount > 0;

This part is only half proven, and I would rather say which half. The cause is read straight from the plugin source on both platforms. The fix is covered by unit tests whose fake plugin behaves like that source, and taking the close path back out of the wrapper brings the hang back in those tests. What I never did is close a real rewarded video early on a device. Google's Android test video has no close button and blocks Back until it pays out at about 8 seconds, and I did not manage an early close on the iOS simulator either. That check needs a phone registered as an AdMob test device, and it is still open.

Room for the banner

The banner had its own problems, starting with position. It is a native view drawn over the bottom of the WebView, so it sat on the clue buttons. The fix is to shrink the app by the height bannerAdSizeChanged reports: 64 dp on the phone, 90 dp on the tablet, and 0 when it hides. Neither is the 50 dp people assume, which I measured properly in the post about my AdMob banner covering the game.

The banner that never came back after a hide

The game hides the banner on the win screen, in Settings and on the review prompt. After the first win screen it never came back, and the player got an empty band where it had been.

A dump of the view tree showed the banner's container still GONE, with the AdView inside it. The wrapper brought the banner back by calling showBanner() again, and the plugin, seeing a view that already exists, loads a fresh ad into it and leaves it hidden. That call also never resolves, which is the third row of the table.

So the wrapper tracks the banner itself:

let banner = 'none'; // 'none' | 'shown' | 'hidden'

async function showBanner(options) {
  if (banner === 'shown') return;            // a second showBanner() never answers
  if (banner === 'hidden') await AdMob.resumeBanner();
  else await AdMob.showBanner(options);
  banner = 'shown';
}

async function hideBanner() {
  if (banner !== 'shown') return;
  await AdMob.hideBanner();
  banner = 'hidden';
}

Moving to another position is the one case that removes the view and shows a new one, since the plugin fixes the position when it creates the view. On the tablet, with Settings opened and closed twice, the size events went 800×90, 0×0, 800×90, 0×0, and the banner was back on screen at the end.

My fix raced itself

That sketch has a race in it, and I shipped it before I saw it. Continue on a win screen that asks for a rating goes back to the board and straight on to the review prompt. The board asks for the banner and the prompt asks for it gone, back to back. The hide arrived while the resume was still in flight, read banner as still hidden, and returned. Then the resume landed, and the banner sat under the review prompt.

The tablet trace had a hideBanner, a resumeBanner 5.1 seconds later, and no hide after it.

Now every banner call runs one at a time, in the order the game made them, each deciding from the state the previous one left. Each is also bounded at ten seconds. Without that, one showBanner() that never answers, the very bug above, would have jammed every banner call behind it. Rerun, the trace had the resume and then the hide 117 ms after it. No banner under the prompt, and it came back when the prompt closed.

Ads that arrived late

The last report was vaguer: the interstitial "sometimes launches when I finish and sometimes when I click Continue". What the logs did show was that the ad's moment depended on the player. It fired from the win-screen buttons, so on the tablet the ad came 6.9 seconds after the win screen opened: whenever Continue happened to be tapped.

I moved the interstitial to the end of the stage: the board finishes celebrating, the ad shows, and the win screen opens after the close. One moment, whatever the player taps. Three stages in a row on the phone, each ad left up at least 8 seconds before I closed it, all went that way.

I also changed something I cannot fully justify. The wrapper waited up to 15 seconds for an ad that was still loading and then showed it, which is a way to get an ad out of nowhere after the moment has passed. A show now waits at most 1.5 seconds, and otherwise gives up and keeps the load for the next stage end. I never caught a late load in a log, so I cannot say this caused anything I saw. It closes a path that could have.

Takeaway

Read a show promise from this plugin as "the call was accepted", not "the ad happened". Take state from the events, subscribe before you show, and bound every wait, including the ones that should never take long.

Hold the ad up when you test. A level dealt behind an interstitial, or a reveal run under a video, is invisible if the ad is closed within a second. My device runs left each ad up for at least 8 seconds before closing it.

And make the regression test fail first. Each round of package fixes came with new tests, and each time I ran those tests against the previous release to watch them fail before I trusted them to pass. The game's own 1,856 tests stayed green through all of it.

More posts

New writing when there is something worth saying. By email or by RSS, whichever you prefer.