capacitor-community / capacitor-community/admob

Android: showRewardVideoAd() never settles when dismissed before reward

Open
#438 1 comment 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.