capacitor-community / capacitor-community/admob
Android: showRewardVideoAd() never settles when dismissed before reward
- Dominant language
- Java
- Stars
- 296
- Forks
- 97
- Avg merge
- 2d 6h
- Merged PRs (30d)
- 7
Description
## 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:
```ts
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:
```kotlin
fun getOnUserEarnedRewardListener(
call: PluginCall,
notifyListenersFunction: BiConsumer
): 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:
```kotlin
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:
```java
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:
```java
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:
```text
: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
- #236
- #259
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.
Contributor guide
Research direction
Start by reading RewardedAdCallbackAndListeners.kt and FullscreenPluginCallback.kt to trace how the show PluginCall is resolved and how dismissal or failure events are emitted. Run :capacitor-community-admob:compileDebugJavaWithJavac and review the described rewarded-ad state-machine cases; done means reward, dismissal, and failed-to-show paths settle the call exactly once while preserving existing events and cleanup.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- android, java, kotlin
- Domain
- mobile
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 58/100