diff --git a/README.md b/README.md index dea1fb0e..72dde848 100644 --- a/README.md +++ b/README.md @@ -36,10 +36,10 @@ Made with [contributors-img](https://contrib.rocks). ### Screenshots -| | Banner | Interstitial | Reward | -| :---------- | :----------------------------------: | :----------------------------------------: | :----------------------------------: | -| **iOS** | ![](demo/screenshots/ios_banner.png) | ![](demo/screenshots/ios_interstitial.png) | ![](demo/screenshots/ios_reward.png) | -| **Android** | ![](demo/screenshots/md_banner.png) | ![](demo/screenshots/md_interstitial.png) | ![](demo/screenshots/md_reward.png) | +| | Banner | Interstitial | Reward | App Open | +| :---------- | :----------------------------------: | :----------------------------------------: | :----------------------------------: | :---------------------------------: | +| **iOS** | ![](demo/screenshots/ios_banner.png) | ![](demo/screenshots/ios_interstitial.png) | ![](demo/screenshots/ios_reward.png) | ![](demo/screenshots/ios_open.png) | +| **Android** | ![](demo/screenshots/md_banner.png) | ![](demo/screenshots/md_interstitial.png) | ![](demo/screenshots/md_reward.png) | ![](demo/screenshots/md_open.png) | ## Installation @@ -188,7 +188,44 @@ const consentInfo = await AdMob.requestConsentInfo({ 2. AdMob.requestConsentInfo 3. AdMob.showConsentForm (If consent form required ) 3/ AdMob.showBanner + +### Show App Open Ad +```ts +import { + AdMob, + AppOpenAdPluginEvents, + AppOpenAdOptions, +} from '@capacitor-community/admob'; + +export async function showAppOpenAd(): Promise { + // listen to events + AdMob.addListener(AppOpenAdPluginEvents.Loaded, () => { + console.log('App Open Ad loaded'); + }); + AdMob.addListener(AppOpenAdPluginEvents.FailedToLoad, (error) => { + console.log('Failed to load App Open Ad', error); + }); + AdMob.addListener(AppOpenAdPluginEvents.Opened, () => { + console.log('App Open Ad open'); + }); + AdMob.addListener(AppOpenAdPluginEvents.Closed, () => { + console.log('App Open Ad close'); + }); + AdMob.addListener(AppOpenAdPluginEvents.FailedToShow, (error) => { + console.log('Failed to show App Open Ad', error); + }); + + const options: AppOpenAdOptions = { + adId: 'YOUR_AD_UNIT_ID', + }; + await AdMob.loadAppOpen(options); + const { value } = await AdMob.isAppOpenLoaded(); + if (value) { + await AdMob.showAppOpen(); + } +} +``` ### Show Banner ```ts @@ -334,6 +371,14 @@ AdMob.addListener(RewardAdPluginEvents.Rewarded, async () => { * [`requestTrackingAuthorization()`](#requesttrackingauthorization) * [`setApplicationMuted(...)`](#setapplicationmuted) * [`setApplicationVolume(...)`](#setapplicationvolume) +* [`loadAppOpen(...)`](#loadappopen) +* [`showAppOpen()`](#showappopen) +* [`isAppOpenLoaded()`](#isappopenloaded) +* [`addListener(AppOpenAdPluginEvents.Loaded, ...)`](#addlistenerappopenadplugineventsloaded-) +* [`addListener(AppOpenAdPluginEvents.FailedToLoad, ...)`](#addlistenerappopenadplugineventsfailedtoload-) +* [`addListener(AppOpenAdPluginEvents.Opened, ...)`](#addlistenerappopenadplugineventsopened-) +* [`addListener(AppOpenAdPluginEvents.Closed, ...)`](#addlistenerappopenadplugineventsclosed-) +* [`addListener(AppOpenAdPluginEvents.FailedToShow, ...)`](#addlistenerappopenadplugineventsfailedtoshow-) * [`showBanner(...)`](#showbanner) * [`hideBanner()`](#hidebanner) * [`resumeBanner()`](#resumebanner) @@ -461,6 +506,125 @@ Report application volume to AdMob SDK -------------------- +### loadAppOpen(...) + +```typescript +loadAppOpen(options: AppOpenAdOptions) => Promise +``` + +Load an App Open ad + +| Param | Type | +| ------------- | ------------------------------------------------------------- | +| **`options`** | AppOpenAdOptions | + +-------------------- + + +### showAppOpen() + +```typescript +showAppOpen() => Promise +``` + +Shows the App Open ad if loaded + +-------------------- + + +### isAppOpenLoaded() + +```typescript +isAppOpenLoaded() => Promise<{ value: boolean; }> +``` + +Check if the App Open ad is loaded + +**Returns:** Promise<{ value: boolean; }> + +-------------------- + + +### addListener(AppOpenAdPluginEvents.Loaded, ...) + +```typescript +addListener(eventName: AppOpenAdPluginEvents.Loaded, listenerFunc: () => void) => Promise +``` + +| Param | Type | +| ------------------ | ------------------------------------------------------------------------------ | +| **`eventName`** | AppOpenAdPluginEvents.Loaded | +| **`listenerFunc`** | () => void | + +**Returns:** Promise<PluginListenerHandle> + +-------------------- + + +### addListener(AppOpenAdPluginEvents.FailedToLoad, ...) + +```typescript +addListener(eventName: AppOpenAdPluginEvents.FailedToLoad, listenerFunc: (error: AdMobError) => void) => Promise +``` + +| Param | Type | +| ------------------ | ------------------------------------------------------------------------------------ | +| **`eventName`** | AppOpenAdPluginEvents.FailedToLoad | +| **`listenerFunc`** | (error: AdMobError) => void | + +**Returns:** Promise<PluginListenerHandle> + +-------------------- + + +### addListener(AppOpenAdPluginEvents.Opened, ...) + +```typescript +addListener(eventName: AppOpenAdPluginEvents.Opened, listenerFunc: () => void) => Promise +``` + +| Param | Type | +| ------------------ | ------------------------------------------------------------------------------ | +| **`eventName`** | AppOpenAdPluginEvents.Opened | +| **`listenerFunc`** | () => void | + +**Returns:** Promise<PluginListenerHandle> + +-------------------- + + +### addListener(AppOpenAdPluginEvents.Closed, ...) + +```typescript +addListener(eventName: AppOpenAdPluginEvents.Closed, listenerFunc: () => void) => Promise +``` + +| Param | Type | +| ------------------ | ------------------------------------------------------------------------------ | +| **`eventName`** | AppOpenAdPluginEvents.Closed | +| **`listenerFunc`** | () => void | + +**Returns:** Promise<PluginListenerHandle> + +-------------------- + + +### addListener(AppOpenAdPluginEvents.FailedToShow, ...) + +```typescript +addListener(eventName: AppOpenAdPluginEvents.FailedToShow, listenerFunc: (error: AdMobError) => void) => Promise +``` + +| Param | Type | +| ------------------ | ------------------------------------------------------------------------------------ | +| **`eventName`** | AppOpenAdPluginEvents.FailedToShow | +| **`listenerFunc`** | (error: AdMobError) => void | + +**Returns:** Promise<PluginListenerHandle> + +-------------------- + + ### showBanner(...) ```typescript @@ -1102,6 +1266,31 @@ addListener(eventName: RewardInterstitialAdPluginEvents.Showed, listenerFunc: () | **`volume`** | 0 \| 1 \| 0.1 \| 0.2 \| 0.3 \| 0.4 \| 0.5 \| 0.6 \| 0.7 \| 0.8 \| 0.9 | If your app has its own volume controls (such as custom music or sound effect volumes), disclosing app volume to the Google Mobile Ads SDK allows video ads to respect app volume settings. enable set 0.0 - 1.0, any float allowed. | 4.1.1 | +#### AppOpenAdOptions + +| Prop | Type | +| ---------- | ------------------- | +| **`adId`** | string | + + +#### PluginListenerHandle + +| Prop | Type | +| ------------ | ----------------------------------------- | +| **`remove`** | () => Promise<void> | + + +#### AdMobError + +For more information +https://developers.google.com/android/reference/com/google/android/gms/ads/AdError + +| Prop | Type | Description | +| ------------- | ------------------- | -------------------------------------- | +| **`code`** | number | Gets the error's code. | +| **`message`** | string | Gets the message describing the error. | + + #### BannerAdOptions This interface extends AdOptions @@ -1117,13 +1306,6 @@ This interface extends AdOptions | **`immersiveMode`** | boolean | Sets a flag that controls if this interstitial or reward object will be displayed in immersive mode. Call this method before show. During show, if this flag is on and immersive mode is supported, SYSTEM_UI_FLAG_IMMERSIVE_STICKY &SYSTEM_UI_FLAG_HIDE_NAVIGATION will be turned on for interstitial or reward ad. | | 7.0.3 | -#### PluginListenerHandle - -| Prop | Type | -| ------------ | ----------------------------------------- | -| **`remove`** | () => Promise<void> | - - #### AdMobBannerSize When notice listener of OnAdLoaded, you can get banner size. @@ -1134,17 +1316,6 @@ When notice listener of OnAdLoaded, you can get banner size. | **`height`** | number | -#### AdMobError - -For more information -https://developers.google.com/android/reference/com/google/android/gms/ads/AdError - -| Prop | Type | Description | -| ------------- | ------------------- | -------------------------------------- | -| **`code`** | number | Gets the error's code. | -| **`message`** | string | Gets the message describing the error. | - - #### AdmobConsentInfo | Prop | Type | Description | Since | @@ -1256,6 +1427,17 @@ From T, pick a set of properties whose keys are in the union K | **`MatureAudience`** | 'MatureAudience' | Content suitable only for mature audiences. | +#### AppOpenAdPluginEvents + +| Members | Value | +| ------------------ | ------------------------------------ | +| **`Loaded`** | 'appOpenAdLoaded' | +| **`FailedToLoad`** | 'appOpenAdFailedToLoad' | +| **`Opened`** | 'appOpenAdOpened' | +| **`Closed`** | 'appOpenAdClosed' | +| **`FailedToShow`** | 'appOpenAdFailedToShow' | + + #### BannerAdSize | Members | Value | Description | diff --git a/android/src/main/java/com/getcapacitor/community/admob/AdMob.java b/android/src/main/java/com/getcapacitor/community/admob/AdMob.java index b8b36c79..de1e3a31 100644 --- a/android/src/main/java/com/getcapacitor/community/admob/AdMob.java +++ b/android/src/main/java/com/getcapacitor/community/admob/AdMob.java @@ -1,6 +1,9 @@ package com.getcapacitor.community.admob; import android.Manifest; +import android.app.Activity; +import android.os.Handler; +import android.os.Looper; import com.getcapacitor.JSArray; import com.getcapacitor.JSObject; import com.getcapacitor.Plugin; @@ -8,6 +11,7 @@ import com.getcapacitor.PluginMethod; import com.getcapacitor.annotation.CapacitorPlugin; import com.getcapacitor.annotation.Permission; +import com.getcapacitor.community.admob.appopen.AppOpenAdPlugin; import com.getcapacitor.community.admob.banner.BannerExecutor; import com.getcapacitor.community.admob.consent.AdConsentExecutor; import com.getcapacitor.community.admob.helpers.AuthorizationStatusEnum; @@ -34,18 +38,21 @@ public class AdMob extends Plugin { this::notifyListeners, getLogTag() ); + private final AdRewardExecutor adRewardExecutor = new AdRewardExecutor( this::getContext, this::getActivity, this::notifyListeners, getLogTag() ); + private final AdRewardInterstitialExecutor adRewardInterstitialExecutor = new AdRewardInterstitialExecutor( this::getContext, this::getActivity, this::notifyListeners, getLogTag() ); + private final AdInterstitialExecutor adInterstitialExecutor = new AdInterstitialExecutor( this::getContext, this::getActivity, @@ -61,23 +68,52 @@ public class AdMob extends Plugin { getLogTag() ); - // Initialize AdMob with appId + private final AppOpenAdPlugin appOpenAdPlugin = new AppOpenAdPlugin(); + + @PluginMethod + public void loadAppOpen(final PluginCall call) { + appOpenAdPlugin.loadAppOpen(getContext(), getActivity(), call, this::notifyListeners); + } + + @PluginMethod + public void showAppOpen(final PluginCall call) { + appOpenAdPlugin.showAppOpen(getActivity(), call, this::notifyListeners); + } + + @PluginMethod + public void isAppOpenLoaded(final PluginCall call) { + appOpenAdPlugin.isAppOpenLoaded(getActivity(), call); + } + + // --------------------------------------------------------- + // MAIN METHODS + // --------------------------------------------------------- + @PluginMethod public void initialize(final PluginCall call) { this.setRequestConfiguration(call); - try { - MobileAds.initialize( - getContext(), - new OnInitializationCompleteListener() { - @Override - public void onInitializationComplete(InitializationStatus initializationStatus) {} - } - ); - bannerExecutor.initialize(); - call.resolve(); - } catch (Exception ex) { - call.reject(ex.getLocalizedMessage(), ex); + // Same as banner/interstitial: bridge thread is not the UI thread — MobileAds + view setup must run on main. + Runnable initOnMain = () -> { + try { + MobileAds.initialize( + getContext(), + new OnInitializationCompleteListener() { + @Override + public void onInitializationComplete(InitializationStatus initializationStatus) {} + } + ); + bannerExecutor.initialize(); + call.resolve(); + } catch (Exception ex) { + call.reject(ex.getLocalizedMessage(), ex); + } + }; + Activity activity = getActivity(); + if (activity != null) { + activity.runOnUiThread(initOnMain); + } else { + new Handler(Looper.getMainLooper()).post(initOnMain); } } @@ -93,7 +129,10 @@ public void trackingAuthorizationStatus(final PluginCall call) { call.resolve(response); } - // User Consent + // --------------------------------------------------------- + // USER CONSENT + // --------------------------------------------------------- + @PluginMethod public void requestConsentInfo(final PluginCall call) { adConsentExecutor.requestConsentInfo(call, this::notifyListeners); @@ -114,6 +153,10 @@ public void resetConsentInfo(final PluginCall call) { adConsentExecutor.resetConsentInfo(call, this::notifyListeners); } + // --------------------------------------------------------- + // APP SETTINGS + // --------------------------------------------------------- + @PluginMethod public void setApplicationMuted(final PluginCall call) { Boolean muted = call.getBoolean("muted"); @@ -136,41 +179,48 @@ public void setApplicationVolume(final PluginCall call) { call.resolve(); } - // Show a banner Ad + // --------------------------------------------------------- + // BANNER ADS + // --------------------------------------------------------- + @PluginMethod public void showBanner(final PluginCall call) { bannerExecutor.showBanner(call); } - // Hide the banner, remove it from screen, but can show it later @PluginMethod public void hideBanner(final PluginCall call) { bannerExecutor.hideBanner(call); } - // Resume the banner, show it after hide @PluginMethod public void resumeBanner(final PluginCall call) { bannerExecutor.resumeBanner(call); } - // Destroy the banner, remove it from screen. @PluginMethod public void removeBanner(final PluginCall call) { bannerExecutor.removeBanner(call); } + // --------------------------------------------------------- + // INTERSTITIAL ADS + // --------------------------------------------------------- + @PluginMethod public void prepareInterstitial(final PluginCall call) { adInterstitialExecutor.prepareInterstitial(call, this::notifyListeners); } - // Show interstitial Ad @PluginMethod public void showInterstitial(final PluginCall call) { adInterstitialExecutor.showInterstitial(call, this::notifyListeners); } + // --------------------------------------------------------- + // REWARDED ADS + // --------------------------------------------------------- + @PluginMethod public void prepareRewardVideoAd(final PluginCall call) { adRewardExecutor.prepareRewardVideoAd(call, this::notifyListeners); @@ -191,10 +241,10 @@ public void showRewardInterstitialAd(final PluginCall call) { adRewardInterstitialExecutor.showRewardInterstitialAd(call, this::notifyListeners); } - /** - * @see Test Devices - * @see Target Settings - */ + // --------------------------------------------------------- + // REQUEST CONFIGURATION + // --------------------------------------------------------- + private void setRequestConfiguration(final PluginCall call) { // Testing Devices final boolean initializeForTesting = call.getBoolean("initializeForTesting", false); diff --git a/android/src/main/java/com/getcapacitor/community/admob/appopen/AppOpenAdManager.kt b/android/src/main/java/com/getcapacitor/community/admob/appopen/AppOpenAdManager.kt new file mode 100644 index 00000000..a5f33fee --- /dev/null +++ b/android/src/main/java/com/getcapacitor/community/admob/appopen/AppOpenAdManager.kt @@ -0,0 +1,86 @@ +package com.getcapacitor.community.admob.appopen + +import android.app.Activity +import android.content.Context +import com.google.android.gms.ads.AdError +import com.google.android.gms.ads.AdRequest +import com.google.android.gms.ads.FullScreenContentCallback +import com.google.android.gms.ads.LoadAdError +import com.google.android.gms.ads.appopen.AppOpenAd + +class AppOpenAdManager(val adUnitId: String) { + + private var appOpenAd: AppOpenAd? = null + private var isLoadingAd = false + private var isShowingAd = false + + val isAdLoaded: Boolean + get() = appOpenAd != null + + fun loadAd(context: Context, onLoaded: () -> Unit, onFailed: (LoadAdError?) -> Unit) { + if (appOpenAd != null) { + onLoaded() + return + } + + if (isLoadingAd) { + onFailed(null) + return + } + + isLoadingAd = true + val request = AdRequest.Builder().build() + + // play-services-ads 24.x: orientation overload removed; SDK picks orientation from the activity. + AppOpenAd.load( + context, + adUnitId, + request, + object : AppOpenAd.AppOpenAdLoadCallback() { + override fun onAdLoaded(ad: AppOpenAd) { + appOpenAd = ad + isLoadingAd = false + onLoaded() + } + + override fun onAdFailedToLoad(loadAdError: LoadAdError) { + isLoadingAd = false + onFailed(loadAdError) + } + } + ) + } + + fun showAdIfAvailable( + activity: Activity, + onOpened: () -> Unit, + onClosed: () -> Unit, + onFailedToShow: (AdError?) -> Unit + ) { + if (appOpenAd == null || isShowingAd) { + onFailedToShow(null) + return + } + + isShowingAd = true + appOpenAd?.fullScreenContentCallback = object : FullScreenContentCallback() { + override fun onAdShowedFullScreenContent() { + onOpened() + } + + override fun onAdDismissedFullScreenContent() { + appOpenAd = null + isShowingAd = false + onClosed() + } + + override fun onAdFailedToShowFullScreenContent(adError: AdError) { + appOpenAd = null + isShowingAd = false + onFailedToShow(adError) + } + } + + appOpenAd?.show(activity) + } +} diff --git a/android/src/main/java/com/getcapacitor/community/admob/appopen/AppOpenAdPlugin.kt b/android/src/main/java/com/getcapacitor/community/admob/appopen/AppOpenAdPlugin.kt new file mode 100644 index 00000000..f17a2d5c --- /dev/null +++ b/android/src/main/java/com/getcapacitor/community/admob/appopen/AppOpenAdPlugin.kt @@ -0,0 +1,104 @@ +package com.getcapacitor.community.admob.appopen + +import android.app.Activity +import android.content.Context +import android.os.Handler +import android.os.Looper +import com.getcapacitor.JSObject +import com.getcapacitor.PluginCall +import com.getcapacitor.community.admob.models.AdMobPluginError + +class AppOpenAdPlugin { + + fun interface EventNotifier { + fun notify(eventName: String, data: JSObject) + } + + private var appOpenAdManager: AppOpenAdManager? = null + + private fun runOnMain(activity: Activity?, runnable: Runnable) { + if (activity != null) { + activity.runOnUiThread(runnable) + } else { + Handler(Looper.getMainLooper()).post(runnable) + } + } + + fun loadAppOpen(context: Context?, activity: Activity?, call: PluginCall, notifier: EventNotifier) { + if (context == null) { + call.reject("Context is not available") + return + } + + val adUnitId = call.getString("adId") + if (adUnitId == null) { + call.reject("adId is required") + return + } + + val appContext = context.applicationContext + runOnMain(activity) { + if (appOpenAdManager == null || adUnitId != appOpenAdManager?.adUnitId) { + appOpenAdManager = AppOpenAdManager(adUnitId) + } + + appOpenAdManager?.loadAd( + appContext, + onLoaded = { + val adInfo = JSObject().apply { + put("adUnitId", adUnitId) + } + notifier.notify(AppOpenAdPluginEvents.Loaded, adInfo) + call.resolve(adInfo) + }, + onFailed = { loadAdError -> + val errorMessage = loadAdError?.message ?: "Failed to load App Open Ad" + val errorCode = loadAdError?.code ?: -1 + notifier.notify(AppOpenAdPluginEvents.FailedToLoad, AdMobPluginError(errorCode, errorMessage)) + call.reject(errorMessage) + } + ) + } + } + + fun showAppOpen(activity: Activity?, call: PluginCall, notifier: EventNotifier) { + if (activity == null) { + call.reject("Activity is not available") + return + } + + runOnMain(activity) { + if (appOpenAdManager == null || appOpenAdManager?.isAdLoaded != true) { + call.reject("App Open Ad is not loaded") + return@runOnMain + } + + appOpenAdManager?.showAdIfAvailable( + activity, + onOpened = { + notifier.notify(AppOpenAdPluginEvents.Opened, JSObject()) + }, + onClosed = { + notifier.notify(AppOpenAdPluginEvents.Closed, JSObject()) + call.resolve() + }, + onFailedToShow = { adError -> + val errorMessage = adError?.message ?: "Failed to show App Open Ad" + val errorCode = adError?.code ?: -1 + notifier.notify(AppOpenAdPluginEvents.FailedToShow, AdMobPluginError(errorCode, errorMessage)) + call.reject(errorMessage) + } + ) + } + } + + fun isAppOpenLoaded(activity: Activity?, call: PluginCall) { + runOnMain(activity) { + val loaded = appOpenAdManager?.isAdLoaded ?: false + val result = JSObject().apply { + put("value", loaded) + } + call.resolve(result) + } + } +} diff --git a/android/src/main/java/com/getcapacitor/community/admob/appopen/AppOpenAdPluginEvents.kt b/android/src/main/java/com/getcapacitor/community/admob/appopen/AppOpenAdPluginEvents.kt new file mode 100644 index 00000000..32adc4e5 --- /dev/null +++ b/android/src/main/java/com/getcapacitor/community/admob/appopen/AppOpenAdPluginEvents.kt @@ -0,0 +1,9 @@ +package com.getcapacitor.community.admob.appopen + +object AppOpenAdPluginEvents { + const val Loaded = "appOpenAdLoaded" + const val FailedToLoad = "appOpenAdFailedToLoad" + const val Opened = "appOpenAdOpened" + const val Closed = "appOpenAdClosed" + const val FailedToShow = "appOpenAdFailedToShow" +} diff --git a/android/src/main/java/com/getcapacitor/community/admob/models/AdOptions.java b/android/src/main/java/com/getcapacitor/community/admob/models/AdOptions.java index 9fdaf8b4..cc64f092 100644 --- a/android/src/main/java/com/getcapacitor/community/admob/models/AdOptions.java +++ b/android/src/main/java/com/getcapacitor/community/admob/models/AdOptions.java @@ -28,6 +28,7 @@ public abstract class AdOptions { public static final String INTERSTITIAL_TESTER_ID = "ca-app-pub-3940256099942544/1033173712"; public static final String REWARD_VIDEO_TESTER_ID = "ca-app-pub-3940256099942544/5224354917"; public static final String REWARD_INTERSTITIAL_TESTER_ID = "ca-app-pub-3940256099942544/5354046379"; + public static final String APP_OPEN_TESTER_ID = "ca-app-pub-3940256099942544/9257395921"; /** * The position of the ad, it can be TOP_CENTER, @@ -148,6 +149,15 @@ public String getTestingId() { }; } + public AdOptions createAppOpenOptions(PluginCall call) { + return new AdOptions(call) { + @Override + public String getTestingId() { + return AdOptions.APP_OPEN_TESTER_ID; + } + }; + } + public AdOptions createGenericOptions(PluginCall call, final String testingID) { return new AdOptions(call) { @Override diff --git a/android/src/test/java/com/getcapacitor/community/admob/AdMobTest.java b/android/src/test/java/com/getcapacitor/community/admob/AdMobTest.java index 674e7eaf..27bed79b 100644 --- a/android/src/test/java/com/getcapacitor/community/admob/AdMobTest.java +++ b/android/src/test/java/com/getcapacitor/community/admob/AdMobTest.java @@ -1,6 +1,8 @@ package com.getcapacitor.community.admob; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.reset; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -127,6 +129,12 @@ public void registerTestingDevices() { @DisplayName("Initializes the banner executor") public void bannerExecutorInitialize() { when(pluginCallMock.getBoolean("initializeForTesting", false)).thenReturn(false); + doAnswer((invocation) -> { + ((Runnable) invocation.getArgument(0)).run(); + return null; + }) + .when(mockedActivity) + .runOnUiThread(any(Runnable.class)); sut.initialize(pluginCallMock); diff --git a/android/src/test/java/com/getcapacitor/community/admob/models/AdOptionsTest.java b/android/src/test/java/com/getcapacitor/community/admob/models/AdOptionsTest.java index 197ba426..bb7b0e84 100644 --- a/android/src/test/java/com/getcapacitor/community/admob/models/AdOptionsTest.java +++ b/android/src/test/java/com/getcapacitor/community/admob/models/AdOptionsTest.java @@ -118,5 +118,84 @@ public void ssv() { assertEquals(userId, adOptions.ssvInfo.getUserId()); assertEquals(customData, adOptions.ssvInfo.getCustomData()); } + + @Test + public void appOpen_ad_Id() { + final String expected = "Some Given AppOpen Test Id"; + when(pluginCallMock.getString(eq("adId"), anyString())).thenReturn(expected); + + final AdOptions adOptions = AdOptions.getFactory().createAppOpenOptions(pluginCallMock); + + assertEquals(expected, adOptions.adId); + } + + @Test + public void appOpen_position() { + final String wantedProperty = "position"; + final String expected = "TOP_CENTER"; + final String defaultValue = "BOTTOM_CENTER"; + when(pluginCallMock.getString(eq(wantedProperty), anyString())).thenReturn(expected); + + final AdOptions adOptions = AdOptions.getFactory().createAppOpenOptions(pluginCallMock); + + verify(pluginCallMock).getString(wantedProperty, defaultValue); + assertEquals(expected, adOptions.position); + } + + @Test + public void appOpen_margin() { + final String wantedProperty = "margin"; + final int expected = 10; + final int defaultValue = 0; + when(pluginCallMock.getInt(eq(wantedProperty), anyInt())).thenReturn(expected); + + final AdOptions adOptions = AdOptions.getFactory().createAppOpenOptions(pluginCallMock); + + verify(pluginCallMock).getInt(wantedProperty, defaultValue); + assertEquals(expected, adOptions.margin); + } + + @Test + public void appOpen_isTesting() { + final String wantedProperty = "isTesting"; + final boolean expected = true; + final boolean defaultValue = false; + when(pluginCallMock.getBoolean(eq(wantedProperty), anyBoolean())).thenReturn(expected); + + final AdOptions adOptions = AdOptions.getFactory().createAppOpenOptions(pluginCallMock); + + verify(pluginCallMock).getBoolean(wantedProperty, defaultValue); + assertEquals(expected, adOptions.isTesting); + } + + @Test + public void appOpen_npa() { + final String wantedProperty = "npa"; + final boolean expected = true; + final boolean defaultValue = false; + lenient().when(pluginCallMock.getBoolean(eq(wantedProperty), anyBoolean())).thenReturn(expected); + + final AdOptions adOptions = AdOptions.getFactory().createAppOpenOptions(pluginCallMock); + + verify(pluginCallMock).getBoolean(wantedProperty, defaultValue); + assertEquals(expected, adOptions.npa); + } + + @Test + public void appOpen_ssv() { + final String customData = "customData"; + final String userId = "userId"; + final String wantedProperty = "ssv"; + final JSObject expected = new JSObject(); + expected.put(customData, customData); + expected.put(userId, userId); + lenient().when(pluginCallMock.getObject(eq(wantedProperty))).thenReturn(expected); + + final AdOptions adOptions = AdOptions.getFactory().createAppOpenOptions(pluginCallMock); + + verify(pluginCallMock, atLeastOnce()).getObject(wantedProperty); + assertEquals(userId, adOptions.ssvInfo.getUserId()); + assertEquals(customData, adOptions.ssvInfo.getCustomData()); + } } } diff --git a/demo/angular/src/app/app.component.ts b/demo/angular/src/app/app.component.ts index 2980fb71..460f60a2 100644 --- a/demo/angular/src/app/app.component.ts +++ b/demo/angular/src/app/app.component.ts @@ -9,6 +9,7 @@ import { AdMob } from '@capacitor-community/admob'; templateUrl: 'app.component.html', styleUrls: ['app.component.scss'], imports: [IonApp, IonRouterOutlet], + standalone: true, }) export class AppComponent { constructor(private platform: Platform) { @@ -16,22 +17,26 @@ export class AppComponent { } initializeApp() { - this.platform.ready().then(() => { - /** - * initialize() require after platform.ready(); - */ - AdMob.initialize({ - testingDevices: ['2077ef9a63d2b398840261c8221a0c9b'], - initializeForTesting: true, + this.platform + .ready() + .then(() => { + /** + * initialize() require after platform.ready(); + */ + return AdMob.initialize({ + testingDevices: ['2077ef9a63d2b398840261c8221a0c9b'], + initializeForTesting: true, + }); + }) + .then(() => { + return AdMob.setApplicationMuted({ + muted: false, + }); + }) + .then(() => { + return AdMob.setApplicationVolume({ + volume: 0.5, + }); }); - - AdMob.setApplicationMuted({ - muted: false, - }); - - AdMob.setApplicationVolume({ - volume: 0.5, - }); - }); } } diff --git a/demo/angular/src/app/home/home.page.html b/demo/angular/src/app/home/home.page.html index 2f976293..6f4e2917 100644 --- a/demo/angular/src/app/home/home.page.html +++ b/demo/angular/src/app/home/home.page.html @@ -70,6 +70,17 @@ Prepare Reward Show Reward + + + App Open + @if (lastAppOpenEvent$ | async; as lastAppOpenEvent) { +
(Last Event: {{lastAppOpenEvent.name }} | {{lastAppOpenEvent.value | json}}) + } + +
+ Load App Open + Show App Open +
diff --git a/demo/angular/src/app/home/home.page.ts b/demo/angular/src/app/home/home.page.ts index 1d3b8259..8300c23c 100644 --- a/demo/angular/src/app/home/home.page.ts +++ b/demo/angular/src/app/home/home.page.ts @@ -25,6 +25,7 @@ import { AdmobConsentInfo, AdmobConsentStatus, AdMobRewardItem, + AppOpenAdPluginEvents, BannerAdOptions, BannerAdPluginEvents, BannerAdSize, @@ -32,7 +33,13 @@ import { RewardAdPluginEvents, } from '@capacitor-community/admob'; import { ReplaySubject } from 'rxjs'; -import { bannerBottomOptions, bannerTopOptions, interstitialOptions, rewardOptions } from '../shared/ad.options'; +import { + appOpenOptions, + bannerBottomOptions, + bannerTopOptions, + interstitialOptions, + rewardOptions, +} from '../shared/ad.options'; import { FormsModule } from '@angular/forms'; import { AsyncPipe, JsonPipe } from '@angular/common'; @@ -80,6 +87,12 @@ export class HomePage implements ViewWillEnter, ViewWillLeave { }>(1); public readonly lastInterstitialEvent$ = this.lastInterstitialEvent$$.asObservable(); + private readonly lastAppOpenEvent$$ = new ReplaySubject<{ + name: string; + value: unknown; + }>(1); + public readonly lastAppOpenEvent$ = this.lastAppOpenEvent$$.asObservable(); + private readonly listenerHandlers: PluginListenerHandle[] = []; /** * Height of AdSize @@ -94,6 +107,7 @@ export class HomePage implements ViewWillEnter, ViewWillLeave { public isPrepareBanner = false; public isPrepareReward = false; public isPrepareInterstitial = false; + public isAppOpenLoaded = false; public isLoading = false; @@ -134,6 +148,7 @@ export class HomePage implements ViewWillEnter, ViewWillLeave { this.registerRewardListeners(); this.registerBannerListeners(); this.registerInterstitialListeners(); + this.registerAppOpenListeners(); } ionViewWillLeave() { @@ -380,4 +395,48 @@ export class HomePage implements ViewWillEnter, ViewWillLeave { /** * ==================== /Interstitial ==================== */ + + /** + * ==================== App Open ==================== + */ + async loadAppOpen() { + this.isLoading = true; + + try { + await AdMob.loadAppOpen(appOpenOptions); + console.log('App Open Ad loaded'); + this.isAppOpenLoaded = true; + } catch (e) { + console.error('There was a problem loading the App Open Ad', e); + } finally { + this.isLoading = false; + } + } + + async showAppOpen() { + await AdMob.showAppOpen().catch((e) => console.log(e)); + + this.isAppOpenLoaded = false; + } + + private registerAppOpenListeners(): void { + const eventKeys = Object.keys(AppOpenAdPluginEvents); + + eventKeys.forEach(async (key) => { + const eventName = AppOpenAdPluginEvents[key as keyof typeof AppOpenAdPluginEvents]; + console.log(`registering ${eventName}`); + const handler = await AdMob.addListener(eventName as any, (value: unknown) => { + console.log(`App Open Event "${key}"`, value); + + this.ngZone.run(() => { + this.lastAppOpenEvent$$.next({ name: key, value: value }); + }); + }); + this.listenerHandlers.push(handler); + }); + } + + /** + * ==================== /App Open ==================== + */ } diff --git a/demo/angular/src/app/shared/ad.options.ts b/demo/angular/src/app/shared/ad.options.ts index 01982f46..2bdd3056 100644 --- a/demo/angular/src/app/shared/ad.options.ts +++ b/demo/angular/src/app/shared/ad.options.ts @@ -1,5 +1,6 @@ import { BannerAdOptions, BannerAdPosition, BannerAdSize } from '../../../../../dist/esm/banner'; import { AdOptions } from '../../../../../dist/esm/shared'; +import { AppOpenAdOptions } from '../../../../../dist/esm/app-open'; export const bannerTopOptions: BannerAdOptions = { adId: 'ca-app-pub-3940256099942544/2934735716', @@ -26,3 +27,7 @@ export const rewardInterstitialOptions: AdOptions = { export const interstitialOptions: AdOptions = { adId: 'ca-app-pub-3940256099942544/1033173712', }; + +export const appOpenOptions: AppOpenAdOptions = { + adId: 'ca-app-pub-3940256099942544/5575463023', +}; diff --git a/demo/screenshots/ios_open.png b/demo/screenshots/ios_open.png new file mode 100644 index 00000000..06fb2e9e Binary files /dev/null and b/demo/screenshots/ios_open.png differ diff --git a/demo/screenshots/md_open.png b/demo/screenshots/md_open.png new file mode 100644 index 00000000..3fbc8c89 Binary files /dev/null and b/demo/screenshots/md_open.png differ diff --git a/ios/Sources/AdMobPlugin/AdMobPlugin.swift b/ios/Sources/AdMobPlugin/AdMobPlugin.swift index 6b200750..f16a1f2a 100644 --- a/ios/Sources/AdMobPlugin/AdMobPlugin.swift +++ b/ios/Sources/AdMobPlugin/AdMobPlugin.swift @@ -28,8 +28,34 @@ public class AdMobPlugin: CAPPlugin, CAPBridgedPlugin { CAPPluginMethod(name: "prepareRewardVideoAd", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "showRewardVideoAd", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "prepareRewardInterstitialAd", returnType: CAPPluginReturnPromise), - CAPPluginMethod(name: "showRewardInterstitialAd", returnType: CAPPluginReturnPromise) + CAPPluginMethod(name: "showRewardInterstitialAd", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "loadAppOpen", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "showAppOpen", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "isAppOpenLoaded", returnType: CAPPluginReturnPromise) ] + private let appOpenAdPlugin = AppOpenAdPlugin() + @objc func loadAppOpen(_ call: CAPPluginCall) { + appOpenAdPlugin.loadAppOpen( + call, + notify: { [weak self] eventName, data in + self?.notifyListeners(eventName, data: data) + } + ) + } + + @objc func showAppOpen(_ call: CAPPluginCall) { + appOpenAdPlugin.showAppOpen( + call, + getRootViewController: self.getRootVC, + notify: { [weak self] eventName, data in + self?.notifyListeners(eventName, data: data) + } + ) + } + + @objc func isAppOpenLoaded(_ call: CAPPluginCall) { + appOpenAdPlugin.isAppOpenLoaded(call) + } var testingDevices: [String] = [] diff --git a/ios/Sources/AdMobPlugin/AppOpen/AppOpenAdManager.swift b/ios/Sources/AdMobPlugin/AppOpen/AppOpenAdManager.swift new file mode 100644 index 00000000..bf249071 --- /dev/null +++ b/ios/Sources/AdMobPlugin/AppOpen/AppOpenAdManager.swift @@ -0,0 +1,101 @@ +import Foundation +import GoogleMobileAds +import UIKit + +@objc public class AppOpenAdManager: NSObject { + private var appOpenAd: AppOpenAd? + private var isLoadingAd = false + private var isShowingAd = false + private var adUnitId: String + private var onOpened: (() -> Void)? + + public init(adUnitId: String) { + self.adUnitId = adUnitId + } + + public func loadAd(onLoaded: @escaping () -> Void, onFailed: @escaping (Error?) -> Void) { + if appOpenAd != nil { + onLoaded() + return + } + + if isLoadingAd { + onFailed(nil) + return + } + + isLoadingAd = true + Task { [weak self] in + guard let self = self else { + return + } + do { + let ad = try await AppOpenAd.load(with: self.adUnitId, request: Request()) + await MainActor.run { + self.isLoadingAd = false + self.appOpenAd = ad + onLoaded() + } + } catch { + await MainActor.run { + self.isLoadingAd = false + onFailed(error) + } + } + } + } + + public func showAdIfAvailable( + rootViewController: UIViewController, + onOpened: @escaping () -> Void, + onClosed: @escaping () -> Void, + onFailedToShow: @escaping (Error?) -> Void + ) { + guard let ad = appOpenAd, !isShowingAd else { + onFailedToShow(nil) + return + } + + self.onOpened = onOpened + self.onClosed = onClosed + self.onFailedToShow = onFailedToShow + isShowingAd = true + ad.fullScreenContentDelegate = self + ad.present(from: rootViewController) + } + + public func isAdLoaded() -> Bool { + return appOpenAd != nil + } + + private var onClosed: (() -> Void)? + private var onFailedToShow: ((Error?) -> Void)? +} + +extension AppOpenAdManager: FullScreenContentDelegate { + public func adWillPresentFullScreenContent(_ ad: FullScreenPresentingAd) { + let onOpened = self.onOpened + self.onOpened = nil + onOpened?() + } + + public func adDidDismissFullScreenContent(_ ad: FullScreenPresentingAd) { + appOpenAd = nil + isShowingAd = false + let onClosed = self.onClosed + self.onOpened = nil + self.onClosed = nil + self.onFailedToShow = nil + onClosed?() + } + + public func ad(_ ad: FullScreenPresentingAd, didFailToPresentFullScreenContentWithError error: Error) { + appOpenAd = nil + isShowingAd = false + let onFailedToShow = self.onFailedToShow + self.onOpened = nil + self.onClosed = nil + self.onFailedToShow = nil + onFailedToShow?(error) + } +} diff --git a/ios/Sources/AdMobPlugin/AppOpen/AppOpenAdPlugin.swift b/ios/Sources/AdMobPlugin/AppOpen/AppOpenAdPlugin.swift new file mode 100644 index 00000000..ec241f8d --- /dev/null +++ b/ios/Sources/AdMobPlugin/AppOpen/AppOpenAdPlugin.swift @@ -0,0 +1,71 @@ +import Foundation +import Capacitor +import UIKit + +@objc public class AppOpenAdPlugin: NSObject { + private var appOpenAdManager: AppOpenAdManager? + private var currentAdUnitId: String? + + @objc func loadAppOpen( + _ call: CAPPluginCall, + notify: @escaping (String, [String: Any]) -> Void + ) { + guard let adUnitId = call.getString("adId") else { + call.reject("adId is required") + return + } + if appOpenAdManager == nil || currentAdUnitId != adUnitId { + appOpenAdManager = AppOpenAdManager(adUnitId: adUnitId) + currentAdUnitId = adUnitId + } + DispatchQueue.main.async { + self.appOpenAdManager?.loadAd(onLoaded: { + notify(AppOpenAdPluginEvents.Loaded.rawValue, ["adUnitId": adUnitId]) + call.resolve(["adUnitId": adUnitId]) + }, onFailed: { error in + let message = error?.localizedDescription ?? "Failed to load App Open Ad" + notify(AppOpenAdPluginEvents.FailedToLoad.rawValue, [ + "code": 0, + "message": message + ]) + call.reject(message) + }) + } + } + + @objc func showAppOpen( + _ call: CAPPluginCall, + getRootViewController: @escaping () -> UIViewController?, + notify: @escaping (String, [String: Any]) -> Void + ) { + DispatchQueue.main.async { + guard let manager = self.appOpenAdManager, manager.isAdLoaded() else { + call.reject("App Open Ad is not loaded") + return + } + + if let rootVC = getRootViewController() { + self.appOpenAdManager?.showAdIfAvailable(rootViewController: rootVC, onOpened: { + notify(AppOpenAdPluginEvents.Opened.rawValue, [:]) + }, onClosed: { + notify(AppOpenAdPluginEvents.Closed.rawValue, [:]) + call.resolve() + }, onFailedToShow: { error in + let message = error?.localizedDescription ?? "Failed to show App Open Ad" + notify(AppOpenAdPluginEvents.FailedToShow.rawValue, [ + "code": 0, + "message": message + ]) + call.reject(message) + }) + } else { + call.reject("No rootViewController") + } + } + } + + @objc func isAppOpenLoaded(_ call: CAPPluginCall) { + let loaded = appOpenAdManager?.isAdLoaded() ?? false + call.resolve(["value": loaded]) + } +} diff --git a/ios/Sources/AdMobPlugin/AppOpen/AppOpenAdPluginEvents.swift b/ios/Sources/AdMobPlugin/AppOpen/AppOpenAdPluginEvents.swift new file mode 100644 index 00000000..c1a44a33 --- /dev/null +++ b/ios/Sources/AdMobPlugin/AppOpen/AppOpenAdPluginEvents.swift @@ -0,0 +1,7 @@ +public enum AppOpenAdPluginEvents: String { + case Loaded = "appOpenAdLoaded" + case FailedToLoad = "appOpenAdFailedToLoad" + case Opened = "appOpenAdOpened" + case Closed = "appOpenAdClosed" + case FailedToShow = "appOpenAdFailedToShow" +} diff --git a/src/app-open/app-open-ad-options.interface.ts b/src/app-open/app-open-ad-options.interface.ts new file mode 100644 index 00000000..d43af8b7 --- /dev/null +++ b/src/app-open/app-open-ad-options.interface.ts @@ -0,0 +1,3 @@ +export interface AppOpenAdOptions { + adId: string; +} diff --git a/src/app-open/app-open-ad-plugin-events.enum.ts b/src/app-open/app-open-ad-plugin-events.enum.ts new file mode 100644 index 00000000..327b1e7c --- /dev/null +++ b/src/app-open/app-open-ad-plugin-events.enum.ts @@ -0,0 +1,7 @@ +export enum AppOpenAdPluginEvents { + Loaded = 'appOpenAdLoaded', + FailedToLoad = 'appOpenAdFailedToLoad', + Opened = 'appOpenAdOpened', + Closed = 'appOpenAdClosed', + FailedToShow = 'appOpenAdFailedToShow', +} diff --git a/src/app-open/app-open-definitions.interface.ts b/src/app-open/app-open-definitions.interface.ts new file mode 100644 index 00000000..08ea3471 --- /dev/null +++ b/src/app-open/app-open-definitions.interface.ts @@ -0,0 +1,45 @@ +import type { PluginListenerHandle } from '@capacitor/core'; + +import type { ValidateAllEventsEnumAreImplemented } from '../private/validate-all-events-implemented.type'; +import type { AdMobError } from '../shared'; + +import type { AppOpenAdOptions } from './app-open-ad-options.interface'; +import type { AppOpenAdPluginEvents } from './app-open-ad-plugin-events.enum'; + +export type AppOpenDefinitionsHasAllEvents = ValidateAllEventsEnumAreImplemented< + AppOpenAdPluginEvents, + AppOpenAdPlugin +>; + +export interface AppOpenAdPlugin { + /** + * Load an App Open ad + */ + loadAppOpen(options: AppOpenAdOptions): Promise; + + /** + * Shows the App Open ad if loaded + */ + showAppOpen(): Promise; + + /** + * Check if the App Open ad is loaded + */ + isAppOpenLoaded(): Promise<{ value: boolean }>; + + addListener(eventName: AppOpenAdPluginEvents.Loaded, listenerFunc: () => void): Promise; + + addListener( + eventName: AppOpenAdPluginEvents.FailedToLoad, + listenerFunc: (error: AdMobError) => void, + ): Promise; + + addListener(eventName: AppOpenAdPluginEvents.Opened, listenerFunc: () => void): Promise; + + addListener(eventName: AppOpenAdPluginEvents.Closed, listenerFunc: () => void): Promise; + + addListener( + eventName: AppOpenAdPluginEvents.FailedToShow, + listenerFunc: (error: AdMobError) => void, + ): Promise; +} diff --git a/src/app-open/index.ts b/src/app-open/index.ts new file mode 100644 index 00000000..9b1c9536 --- /dev/null +++ b/src/app-open/index.ts @@ -0,0 +1,3 @@ +export * from './app-open-ad-options.interface'; +export * from './app-open-ad-plugin-events.enum'; +export * from './app-open-definitions.interface'; diff --git a/src/definitions.ts b/src/definitions.ts index 08361cf4..a1f4d2fc 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -1,3 +1,4 @@ +import type { AppOpenAdPlugin } from './app-open'; import type { BannerDefinitions } from './banner'; import type { AdmobConsentDefinitions } from './consent'; import type { InterstitialDefinitions } from './interstitial'; @@ -9,7 +10,8 @@ type AdMobDefinitions = BannerDefinitions & RewardDefinitions & RewardInterstitialDefinitions & InterstitialDefinitions & - AdmobConsentDefinitions; + AdmobConsentDefinitions & + AppOpenAdPlugin; export interface AdMobPlugin extends AdMobDefinitions { /** diff --git a/src/index.ts b/src/index.ts index fef4da63..3ac3e079 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,4 +13,5 @@ export * from './reward-interstitial/index'; export * from './reward/index'; export * from './consent/index'; export * from './shared/index'; +export * from './app-open/index'; export { AdMob }; diff --git a/src/web.ts b/src/web.ts index 938e0fb4..2dd10ed9 100644 --- a/src/web.ts +++ b/src/web.ts @@ -7,6 +7,7 @@ import type { AdmobConsentInfo, AdmobConsentRequestOptions, } from '.'; +import type { AppOpenAdOptions } from './app-open/app-open-ad-options.interface'; import { AdmobConsentStatus } from './consent/consent-status.enum'; import { PrivacyOptionsRequirementStatus } from './consent/privacy-options-requirement-status.enum'; import type { AdMobRewardItem } from './reward'; @@ -67,17 +68,14 @@ export class AdMobWeb extends WebPlugin implements AdMobPlugin { console.log('showBanner', options); } - // Hide the banner, remove it from screen, but can show it later async hideBanner(): Promise { console.log('hideBanner'); } - // Resume the banner, show it after hide async resumeBanner(): Promise { console.log('resumeBanner'); } - // Destroy the banner, remove it from screen. async removeBanner(): Promise { console.log('removeBanner'); } @@ -94,7 +92,7 @@ export class AdMobWeb extends WebPlugin implements AdMobPlugin { } async prepareRewardVideoAd(options: AdOptions): Promise { - console.log(options); + console.log('prepareRewardVideoAd', options); return { adUnitId: options.adId, }; @@ -108,7 +106,7 @@ export class AdMobWeb extends WebPlugin implements AdMobPlugin { } async prepareRewardInterstitialAd(options: AdOptions): Promise { - console.log(options); + console.log('prepareRewardInterstitialAd', options); return { adUnitId: options.adId, }; @@ -120,4 +118,22 @@ export class AdMobWeb extends WebPlugin implements AdMobPlugin { amount: 0, }; } + + async loadAppOpen(options: AppOpenAdOptions): Promise { + console.log('loadAppOpen', options); + } + + async showAppOpen(): Promise { + console.log('showAppOpen'); + } + + async isAppOpenLoaded(): Promise<{ value: boolean }> { + return { value: false }; + } + + addListener(eventName: string, listenerFunc: (...args: any[]) => void): Promise<{ remove: () => Promise }> { + void listenerFunc; + console.log('addListener', eventName); + return Promise.resolve({ remove: () => Promise.resolve() }); + } }