Skip to content

Android: showRewardVideoAd() never settles when dismissed before reward #438

Description

@wallaceanjos

Describe the bug

On Android, showRewardVideoAd() never resolves or rejects when a rewarded ad is dismissed before OnUserEarnedRewardListener fires.

In RewardedAdCallbackAndListeners.kt, the PluginCall created by showRewardVideoAd() is resolved only inside OnUserEarnedRewardListener.

However, in FullscreenPluginCallback.kt, both onAdDismissedFullScreenContent() and onAdFailedToShowFullScreenContent() only emit plugin events. They never resolve or reject the original show call.

As a result, closing a rewarded ad before earning the reward leaves the Promise returned by showRewardVideoAd() pending indefinitely.

This is related to #236 and #259. Removing the eager call.resolve() was correct because the call must not report success before a reward is earned. However, it left the cancellation and failed-to-show paths without a terminal response.

To reproduce

  1. Initialize AdMob.
  2. Prepare an Android rewarded ad.
  3. Register the rewarded ad events.
  4. Await showRewardVideoAd().
  5. Close the ad before the reward is earned.

Example:

import {
  AdMob,
  RewardAdPluginEvents,
} from '@capacitor-community/admob';

await AdMob.prepareRewardVideoAd({
  adId: 'YOUR_REWARDED_AD_ID',
  isTesting: true,
});

AdMob.addListener(RewardAdPluginEvents.Rewarded, reward => {
  console.log('rewarded', reward);
});

AdMob.addListener(RewardAdPluginEvents.Dismissed, () => {
  console.log('dismissed');
});

AdMob.addListener(RewardAdPluginEvents.FailedToShow, error => {
  console.log('failed to show', error);
});

console.log('before show');

try {
  const reward = await AdMob.showRewardVideoAd();
  console.log('show settled', reward);
} catch (error) {
  console.log('show rejected', error);
}

console.log('after show');

When the user closes the ad before earning the reward:

  • The Dismissed event fires.
  • showRewardVideoAd() does not resolve.
  • showRewardVideoAd() does not reject.
  • console.log('after show') is never reached.

A similar pending-call problem exists when FailedToShow is emitted without rejecting the original show call.

Actual behavior

The Promise returned by showRewardVideoAd() remains pending forever when the ad is dismissed before the reward callback.

Applications that directly await this method can remain in a loading state until the app is restarted.

Ignoring the Promise on the JavaScript side does not cancel the native call or remove its stored callback.

Expected behavior

The call created by showRewardVideoAd() should settle exactly once:

  • When the reward is earned, resolve with the reward item.
  • When the ad is dismissed before the reward, reject with a distinguishable cancellation code.
  • When the ad fails to show, reject with an appropriate error.

The existing Rewarded, Dismissed, and FailedToShow events should continue to be emitted.

Impact

This can cause multiple problems:

  1. Applications awaiting showRewardVideoAd() can leave buttons, spinners, or reward flows blocked indefinitely.
  2. Capacitor stores the Promise resolver and rejector in its native bridge callback map until a native response arrives.
  3. Because the dismissed call never receives resolve() or reject(), its stored JavaScript callback remains in the bridge.
  4. Repeated cancellations or failed shows can accumulate pending callbacks.
  5. The static mRewardedAd reference also remains populated after the full-screen ad is dismissed.

This is more than an early/late resolution problem: cancellation can leave the entire rewarded-ad operation without a terminal result.

Native source involved

The reward listener resolves the call:

fun getOnUserEarnedRewardListener(
    call: PluginCall,
    notifyListenersFunction: BiConsumer<String, JSObject>
): OnUserEarnedRewardListener {
    return OnUserEarnedRewardListener { item: RewardItem ->
        val response = JSObject()
        response.put("type", item.type)
            .put("amount", item.amount)

        notifyListenersFunction.accept(
            RewardAdPluginEvents.Rewarded,
            response
        )

        call.resolve(response)
    }
}

The full-screen dismissal callback only emits an event:

override fun onAdDismissedFullScreenContent() {
    notifyListenersFunction.accept(
        loadPluginObject.Dismissed,
        JSObject()
    )
}

Therefore, when dismissal happens without OnUserEarnedRewardListener, nothing settles the original PluginCall.

Suggested fix

At show time, install a full-screen callback that owns the PluginCall from showRewardVideoAd() and shares an AtomicBoolean with the reward listener.

Suggested behavior:

Reward earned

  • Use compareAndSet(false, true).
  • Emit RewardAdPluginEvents.Rewarded.
  • Resolve the call with the reward item.

Dismissed before reward

  • Emit RewardAdPluginEvents.Dismissed.
  • Use compareAndSet(false, true).
  • Reject the call with a distinguishable cancellation code, for example:
call.reject(
    "Rewarded ad dismissed before earning a reward.",
    "REWARDED_AD_CANCELLED"
);

Failed to show

  • Emit RewardAdPluginEvents.FailedToShow.
  • Use compareAndSet(false, true).
  • Reject the call with the native error.

Cleanup

  • Set mRewardedAd to null after dismissal or failure.
  • Ensure reward, dismissal, and failure cannot resolve or reject the same call more than once.
  • Preserve the existing event API.

A simplified outline:

final RewardedAd rewardedAd = mRewardedAd;
final AtomicBoolean callSettled = new AtomicBoolean(false);

rewardedAd.setFullScreenContentCallback(
    new FullScreenContentCallback() {
        @Override
        public void onAdFailedToShowFullScreenContent(
            AdError adError
        ) {
            mRewardedAd = null;

            notifyListenersFunction.accept(
                RewardAdPluginEvents.INSTANCE.getFailedToShow(),
                new AdMobPluginError(adError)
            );

            if (callSettled.compareAndSet(false, true)) {
                call.reject(adError.getMessage());
            }
        }

        @Override
        public void onAdDismissedFullScreenContent() {
            mRewardedAd = null;

            notifyListenersFunction.accept(
                RewardAdPluginEvents.INSTANCE.getDismissed(),
                new JSObject()
            );

            if (callSettled.compareAndSet(false, true)) {
                call.reject(
                    "Rewarded ad dismissed before earning a reward.",
                    "REWARDED_AD_CANCELLED"
                );
            }
        }
    }
);

rewardedAd.show(activity, item -> {
    if (!callSettled.compareAndSet(false, true)) {
        return;
    }

    JSObject response = new JSObject();
    response
        .put("type", item.getType())
        .put("amount", item.getAmount());

    notifyListenersFunction.accept(
        RewardAdPluginEvents.Rewarded,
        response
    );

    call.resolve(response);
});

The exact implementation can differ, but every terminal path should settle the original show call exactly once.

Local validation

A local patch following this approach was tested with:

  • Native Android Java compilation.
  • Rewarded ad state-machine tests.
  • Reward success.
  • Cancellation before reward.
  • FailedToShow.
  • Native show rejection.
  • Concurrent show protection.
  • Listener cleanup failures.
  • Three sequential rewarded-ad sessions.

The patched Android module successfully compiled with:

:capacitor-community-admob:compileDebugJavaWithJavac

Physical-device validation is still recommended for event ordering across different Android and Google Mobile Ads SDK versions.

Environment

  • @capacitor-community/admob: 8.0.0
  • @capacitor/core: 8.4.0
  • @capacitor/android: 8.4.0
  • Platform: Android
  • Development OS: Windows
  • Java runtime: Android Studio JBR

Related issues and pull requests

The change from #259 correctly removed the premature resolution, but the dismissed-without-reward path still needs to reject or otherwise settle the show call.

Screenshots

Not applicable.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions