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
- Initialize AdMob.
- Prepare an Android rewarded ad.
- Register the rewarded ad events.
- Await
showRewardVideoAd().
- 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:
- Applications awaiting
showRewardVideoAd() can leave buttons, spinners, or reward flows blocked indefinitely.
- Capacitor stores the Promise resolver and rejector in its native bridge callback map until a native response arrives.
- Because the dismissed call never receives
resolve() or reject(), its stored JavaScript callback remains in the bridge.
- Repeated cancellations or failed shows can accumulate pending callbacks.
- 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.
Describe the bug
On Android,
showRewardVideoAd()never resolves or rejects when a rewarded ad is dismissed beforeOnUserEarnedRewardListenerfires.In
RewardedAdCallbackAndListeners.kt, thePluginCallcreated byshowRewardVideoAd()is resolved only insideOnUserEarnedRewardListener.However, in
FullscreenPluginCallback.kt, bothonAdDismissedFullScreenContent()andonAdFailedToShowFullScreenContent()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
showRewardVideoAd().Example:
When the user closes the ad before earning the reward:
Dismissedevent fires.showRewardVideoAd()does not resolve.showRewardVideoAd()does not reject.console.log('after show')is never reached.A similar pending-call problem exists when
FailedToShowis 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:The existing
Rewarded,Dismissed, andFailedToShowevents should continue to be emitted.Impact
This can cause multiple problems:
showRewardVideoAd()can leave buttons, spinners, or reward flows blocked indefinitely.resolve()orreject(), its stored JavaScript callback remains in the bridge.mRewardedAdreference 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:
The full-screen dismissal callback only emits an event:
Therefore, when dismissal happens without
OnUserEarnedRewardListener, nothing settles the originalPluginCall.Suggested fix
At show time, install a full-screen callback that owns the
PluginCallfromshowRewardVideoAd()and shares anAtomicBooleanwith the reward listener.Suggested behavior:
Reward earned
compareAndSet(false, true).RewardAdPluginEvents.Rewarded.Dismissed before reward
RewardAdPluginEvents.Dismissed.compareAndSet(false, true).Failed to show
RewardAdPluginEvents.FailedToShow.compareAndSet(false, true).Cleanup
mRewardedAdtonullafter dismissal or failure.A simplified outline:
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:
FailedToShow.The patched Android module successfully compiled with:
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.0Related 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.