diff --git a/ChangeLog.txt b/ChangeLog.txt index 209abbd01..918fb38d5 100644 --- a/ChangeLog.txt +++ b/ChangeLog.txt @@ -1,5 +1,11 @@ Google Mobile Ads Unity Plugin Change Log +************** +Version Next +************** + +- Added Picture-in-Picture ad support. + ************** Version 11.4.0 ************** diff --git a/source/plugin/Assets/GoogleMobileAds/Api/Core/PictureInPictureAdPosition.cs b/source/plugin/Assets/GoogleMobileAds/Api/Core/PictureInPictureAdPosition.cs new file mode 100644 index 000000000..9f9b5fb16 --- /dev/null +++ b/source/plugin/Assets/GoogleMobileAds/Api/Core/PictureInPictureAdPosition.cs @@ -0,0 +1,25 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace GoogleMobileAds.Api +{ + public enum PictureInPictureAdPosition + { + Default = 0, + BottomRight = 1, + BottomLeft = 2, + TopLeft = 3, + TopRight = 4 + } +} diff --git a/source/plugin/Assets/GoogleMobileAds/Api/PictureInPictureAd.cs b/source/plugin/Assets/GoogleMobileAds/Api/PictureInPictureAd.cs new file mode 100644 index 000000000..d12ea96fb --- /dev/null +++ b/source/plugin/Assets/GoogleMobileAds/Api/PictureInPictureAd.cs @@ -0,0 +1,241 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using UnityEngine; +using GoogleMobileAds.Common; + +namespace GoogleMobileAds.Api +{ + /// + /// A Picture-in-Picture (PiP) ad that displays in a floating window across screens. + /// + public class PictureInPictureAd + { + /// + /// Raised when the ad is estimated to have earned money. + /// + public event Action OnAdPaid; + + /// + /// Raised when an ad is clicked. + /// + public event Action OnAdClicked; + + /// + /// Raised when an impression is recorded for an ad. + /// + public event Action OnAdImpressionRecorded; + + /// + /// Raised when the PiP ad is displayed on the screen. + /// + public event Action OnAdShown; + + /// + /// Raised when the PiP ad is hidden from the screen. + /// + public event Action OnAdHidden; + + /// + /// Raised when an ad opened full-screen content. + /// + public event Action OnAdFullScreenContentOpened; + + /// + /// Raised when the ad closed full-screen content. + /// + public event Action OnAdFullScreenContentClosed; + + protected internal IPictureInPictureAdClient _client; + + protected internal PictureInPictureAd() {} + + internal PictureInPictureAd(IPictureInPictureAdClient client) + { + _client = client; + RegisterAdEvents(); + } + + /// + /// Loads a Picture-in-Picture ad. + /// + public static void Load(string adUnitId, + AdRequest request, + Action adLoadCallback) + { + if (adLoadCallback == null) + { + Debug.LogError("adLoadCallback is null. No ad was loaded."); + return; + } + + var client = MobileAds.GetClientFactory().BuildPictureInPictureAdClient(); + client.CreatePictureInPictureAd(); + client.OnAdLoaded += (sender, args) => + { + var pipAd = new PictureInPictureAd(client); + MobileAds.RaiseAction(() => + { + adLoadCallback(pipAd, null); + }); + }; + client.OnAdFailedToLoad += (sender, error) => + { + var loadAdError = (error != null && error.LoadAdErrorClient != null) + ? new LoadAdError(error.LoadAdErrorClient) + : null; + MobileAds.RaiseAction(() => + { + adLoadCallback(null, loadAdError); + }); + }; + client.LoadAd(adUnitId, request); + } + + /// + /// Shows the Picture-in-Picture ad on the screen at the specified corner position. + /// + public void Show(PictureInPictureAdPosition position = PictureInPictureAdPosition.Default) + { + if (_client != null) + { + _client.Show(position); + } + } + + /// + /// Hides the Picture-in-Picture ad from the screen. + /// + public void Hide() + { + if (_client != null) + { + _client.Hide(); + } + } + + /// + /// Destroys the Picture-in-Picture ad and cleans up native resources. + /// + public void Destroy() + { + if (_client != null) + { + _client.Destroy(); + } + } + + /// + /// Returns the ResponseInfo for the loaded ad, or null if unavailable. + /// + public ResponseInfo GetResponseInfo() + { + return _client != null ? new ResponseInfo(_client.GetResponseInfoClient()) : null; + } + + /// + /// Returns the current or last known position of the Picture-in-Picture ad. + /// + public PictureInPictureAdPosition GetAdPosition() + { + return _client != null ? _client.GetAdPosition() : PictureInPictureAdPosition.Default; + } + + private void RegisterAdEvents() + { + if (_client == null) + { + return; + } + + _client.OnAdShown += (sender, args) => + { + MobileAds.RaiseAction(() => + { + if (OnAdShown != null) + { + OnAdShown(); + } + }); + }; + + _client.OnAdHidden += (sender, args) => + { + MobileAds.RaiseAction(() => + { + if (OnAdHidden != null) + { + OnAdHidden(); + } + }); + }; + + _client.OnAdClicked += () => + { + MobileAds.RaiseAction(() => + { + if (OnAdClicked != null) + { + OnAdClicked(); + } + }); + }; + + _client.OnAdDidRecordImpression += (sender, args) => + { + MobileAds.RaiseAction(() => + { + if (OnAdImpressionRecorded != null) + { + OnAdImpressionRecorded(); + } + }); + }; + + _client.OnAdDidPresentFullScreenContent += (sender, args) => + { + MobileAds.RaiseAction(() => + { + if (OnAdFullScreenContentOpened != null) + { + OnAdFullScreenContentOpened(); + } + }); + }; + + _client.OnAdDidDismissFullScreenContent += (sender, args) => + { + MobileAds.RaiseAction(() => + { + if (OnAdFullScreenContentClosed != null) + { + OnAdFullScreenContentClosed(); + } + }); + }; + + _client.OnPaidEvent += (adValue) => + { + MobileAds.RaiseAction(() => + { + if (OnAdPaid != null) + { + OnAdPaid(adValue); + } + }); + }; + } + } +} diff --git a/source/plugin/Assets/GoogleMobileAds/Common/IClientFactory.cs b/source/plugin/Assets/GoogleMobileAds/Common/IClientFactory.cs index 37c2e0f7d..df2a08b86 100644 --- a/source/plugin/Assets/GoogleMobileAds/Common/IClientFactory.cs +++ b/source/plugin/Assets/GoogleMobileAds/Common/IClientFactory.cs @@ -38,6 +38,8 @@ public interface IClientFactory INativeOverlayAdClient BuildNativeOverlayAdClient(); + IPictureInPictureAdClient BuildPictureInPictureAdClient(); + IApplicationPreferencesClient ApplicationPreferencesInstance(); IMobileAdsClient MobileAdsInstance(); diff --git a/source/plugin/Assets/GoogleMobileAds/Common/IPictureInPictureAdClient.cs b/source/plugin/Assets/GoogleMobileAds/Common/IPictureInPictureAdClient.cs new file mode 100644 index 000000000..b6a8fdb22 --- /dev/null +++ b/source/plugin/Assets/GoogleMobileAds/Common/IPictureInPictureAdClient.cs @@ -0,0 +1,62 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using GoogleMobileAds.Api; + +namespace GoogleMobileAds.Common +{ + public interface IPictureInPictureAdClient + { + // Ad event fired when the picture in picture ad has loaded. + event EventHandler OnAdLoaded; + // Ad event fired when the picture in picture ad has failed to load. + event EventHandler OnAdFailedToLoad; + // Ad event fired when the picture in picture ad is displayed on screen. + event EventHandler OnAdShown; + // Ad event fired when the picture in picture ad is hidden from screen. + event EventHandler OnAdHidden; + // Ad event fired when an ad impression has been recorded. + event EventHandler OnAdDidRecordImpression; + // Ad event fired when an ad has been clicked. + event Action OnAdClicked; + // Ad event fired when the ad opens an overlay covering the screen. + event EventHandler OnAdDidPresentFullScreenContent; + // Ad event fired when the ad overlay is dismissed. + event EventHandler OnAdDidDismissFullScreenContent; + // Ad event fired when the picture in picture ad is estimated to have earned money. + event Action OnPaidEvent; + + // Creates a picture in picture ad wrapper. + void CreatePictureInPictureAd(); + + // Loads a picture in picture ad. + void LoadAd(string adUnitId, AdRequest request); + + // Shows the picture in picture ad on screen. + void Show(PictureInPictureAdPosition position); + + // Hides the picture in picture ad. + void Hide(); + + // Destroys the picture in picture ad. + void Destroy(); + + // Returns the current or last known position of the picture in picture ad. + PictureInPictureAdPosition GetAdPosition(); + + // Returns the response info for the loaded ad. + IResponseInfoClient GetResponseInfoClient(); + } +} diff --git a/source/plugin/Assets/GoogleMobileAds/Platforms/Android/GoogleMobileAdsClientFactory.cs b/source/plugin/Assets/GoogleMobileAds/Platforms/Android/GoogleMobileAdsClientFactory.cs index b51a39967..f0d243169 100644 --- a/source/plugin/Assets/GoogleMobileAds/Platforms/Android/GoogleMobileAdsClientFactory.cs +++ b/source/plugin/Assets/GoogleMobileAds/Platforms/Android/GoogleMobileAdsClientFactory.cs @@ -146,6 +146,16 @@ public INativeOverlayAdClient BuildNativeOverlayAdClient() " on non-Android runtime"); } + public IPictureInPictureAdClient BuildPictureInPictureAdClient() + { + if (Application.platform == RuntimePlatform.Android) + { + return new GoogleMobileAds.Android.PictureInPictureAdClient(); + } + throw new InvalidOperationException(@"Called " + MethodBase.GetCurrentMethod().Name + + " on non-Android runtime"); + } + public IApplicationPreferencesClient ApplicationPreferencesInstance() { if (Application.platform == RuntimePlatform.Android) { return new GoogleMobileAds.Android.ApplicationPreferencesClient(); @@ -180,7 +190,7 @@ private bool IsNextGenEnabled() new AndroidJavaClass(GoogleMobileAds.Android.NextGenUtils.MobileAdsClassName); _nextGenEnabled = true; } - catch (AndroidJavaException) + catch (Exception) { _nextGenEnabled = false; } diff --git a/source/plugin/Assets/GoogleMobileAds/Platforms/Android/NextGenUtils.cs b/source/plugin/Assets/GoogleMobileAds/Platforms/Android/NextGenUtils.cs index 42305e04f..3f062a486 100644 --- a/source/plugin/Assets/GoogleMobileAds/Platforms/Android/NextGenUtils.cs +++ b/source/plugin/Assets/GoogleMobileAds/Platforms/Android/NextGenUtils.cs @@ -120,6 +120,11 @@ internal class NextGenUtils { public const string UnityBannerAdCallbackClassName = "com.google.unity.ads.nextgen.UnityBannerAdCallback"; + public const string UnityPictureInPictureAdClassName = + "com.google.unity.ads.nextgen.UnityPictureInPictureAd"; + public const string UnityPictureInPictureAdCallbackClassName = + "com.google.unity.ads.nextgen.UnityPictureInPictureAdCallback"; + public const string UnityInterstitialAdClassName = "com.google.unity.ads.nextgen.UnityInterstitialAd"; public const string UnityInterstitialAdCallbackClassName = diff --git a/source/plugin/Assets/GoogleMobileAds/Platforms/Android/PictureInPictureAdClient.cs b/source/plugin/Assets/GoogleMobileAds/Platforms/Android/PictureInPictureAdClient.cs new file mode 100644 index 000000000..03398fcdf --- /dev/null +++ b/source/plugin/Assets/GoogleMobileAds/Platforms/Android/PictureInPictureAdClient.cs @@ -0,0 +1,166 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using GoogleMobileAds.Api; +using GoogleMobileAds.Common; +using UnityEngine; + +namespace GoogleMobileAds.Android +{ + public class PictureInPictureAdClient : AndroidJavaProxy, IPictureInPictureAdClient + { + internal AndroidJavaObject androidPictureInPictureAd; + + public PictureInPictureAdClient() : base(NextGenUtils.UnityPictureInPictureAdCallbackClassName) + { + AndroidJavaClass playerClass = new AndroidJavaClass(Utils.UnityActivityClassName); + AndroidJavaObject activity = playerClass.GetStatic("currentActivity"); + this.androidPictureInPictureAd = new AndroidJavaObject( + NextGenUtils.UnityPictureInPictureAdClassName, activity, this); + } + + public event EventHandler OnAdLoaded; + public event EventHandler OnAdFailedToLoad; + public event EventHandler OnAdShown; + public event EventHandler OnAdHidden; + public event EventHandler OnAdDidRecordImpression; + public event Action OnAdClicked; + public event EventHandler OnAdDidPresentFullScreenContent; + public event EventHandler OnAdDidDismissFullScreenContent; + public event Action OnPaidEvent; + + public void CreatePictureInPictureAd() + { + // No-op for Next-Gen. + } + + public void LoadAd(string adUnitId, AdRequest request) + { + this.androidPictureInPictureAd.Call("load", adUnitId, NextGenUtils.GetAdRequestJavaObject(request, adUnitId)); + } + + public void Show(PictureInPictureAdPosition position) + { + this.androidPictureInPictureAd.Call("show", (int)position); + } + + public void Hide() + { + this.androidPictureInPictureAd.Call("hide"); + } + + public void Destroy() + { + this.androidPictureInPictureAd.Call("destroy"); + } + + public PictureInPictureAdPosition GetAdPosition() + { + int ordinal = this.androidPictureInPictureAd.Call("getAdPosition"); + return (PictureInPictureAdPosition)ordinal; + } + + public IResponseInfoClient GetResponseInfoClient() + { + return new ResponseInfoClient(ResponseInfoClientType.AdLoaded, this.androidPictureInPictureAd); + } + + #region Callback methods called from Java via AndroidJavaProxy + + public void onAdLoaded() + { + if (this.OnAdLoaded != null) + { + this.OnAdLoaded(this, EventArgs.Empty); + } + } + + public void onAdFailedToLoad(AndroidJavaObject error) + { + if (this.OnAdFailedToLoad != null) + { + LoadAdErrorClientEventArgs args = new LoadAdErrorClientEventArgs + { + LoadAdErrorClient = new NextGenLoadAdErrorClient(error) + }; + this.OnAdFailedToLoad(this, args); + } + } + + public void onAdShown() + { + if (this.OnAdShown != null) + { + this.OnAdShown(this, EventArgs.Empty); + } + } + + public void onAdHidden() + { + if (this.OnAdHidden != null) + { + this.OnAdHidden(this, EventArgs.Empty); + } + } + + public void onAdClicked() + { + if (this.OnAdClicked != null) + { + this.OnAdClicked(); + } + } + + public void onAdImpression() + { + if (this.OnAdDidRecordImpression != null) + { + this.OnAdDidRecordImpression(this, EventArgs.Empty); + } + } + + public void onAdShowedFullScreenContent() + { + if (this.OnAdDidPresentFullScreenContent != null) + { + this.OnAdDidPresentFullScreenContent(this, EventArgs.Empty); + } + } + + public void onAdDismissedFullScreenContent() + { + if (this.OnAdDidDismissFullScreenContent != null) + { + this.OnAdDidDismissFullScreenContent(this, EventArgs.Empty); + } + } + + public void onPaidEvent(int precision, long valueMicros, string currencyCode) + { + if (this.OnPaidEvent != null) + { + AdValue adValue = new AdValue + { + Precision = (AdValue.PrecisionType)precision, + Value = valueMicros, + CurrencyCode = currencyCode + }; + this.OnPaidEvent(adValue); + } + } + + #endregion + } +} diff --git a/source/plugin/Assets/GoogleMobileAds/Platforms/Unity/GoogleMobileAdsClientFactory.cs b/source/plugin/Assets/GoogleMobileAds/Platforms/Unity/GoogleMobileAdsClientFactory.cs index aab163f16..ef3f77e89 100644 --- a/source/plugin/Assets/GoogleMobileAds/Platforms/Unity/GoogleMobileAdsClientFactory.cs +++ b/source/plugin/Assets/GoogleMobileAds/Platforms/Unity/GoogleMobileAdsClientFactory.cs @@ -68,6 +68,10 @@ public INativeOverlayAdClient BuildNativeOverlayAdClient() return new GoogleMobileAds.Unity.NativeOverlayAdClient(); } + public IPictureInPictureAdClient BuildPictureInPictureAdClient(){ + return null; + } + public IApplicationPreferencesClient ApplicationPreferencesInstance() { return new GoogleMobileAds.Unity.ApplicationPreferencesClient(); diff --git a/source/plugin/Assets/GoogleMobileAds/Platforms/iOS/GoogleMobileAdsClientFactory.cs b/source/plugin/Assets/GoogleMobileAds/Platforms/iOS/GoogleMobileAdsClientFactory.cs index 449c4b60b..4747a1412 100644 --- a/source/plugin/Assets/GoogleMobileAds/Platforms/iOS/GoogleMobileAdsClientFactory.cs +++ b/source/plugin/Assets/GoogleMobileAds/Platforms/iOS/GoogleMobileAdsClientFactory.cs @@ -111,6 +111,10 @@ public INativeOverlayAdClient BuildNativeOverlayAdClient() { " on non-iOS runtime"); } + public IPictureInPictureAdClient BuildPictureInPictureAdClient(){ + return null; + } + public IApplicationPreferencesClient ApplicationPreferencesInstance() { if (Application.platform == RuntimePlatform.IPhonePlayer) { diff --git a/source/plugin/Assets/Plugins/Android/GoogleMobileAdsPlugin.androidlib/src/main/java/com/google/unity/ads/nextgen/AdWrapper.java b/source/plugin/Assets/Plugins/Android/GoogleMobileAdsPlugin.androidlib/src/main/java/com/google/unity/ads/nextgen/AdWrapper.java index 7808afdc1..b997a8b30 100644 --- a/source/plugin/Assets/Plugins/Android/GoogleMobileAdsPlugin.androidlib/src/main/java/com/google/unity/ads/nextgen/AdWrapper.java +++ b/source/plugin/Assets/Plugins/Android/GoogleMobileAdsPlugin.androidlib/src/main/java/com/google/unity/ads/nextgen/AdWrapper.java @@ -6,6 +6,8 @@ import com.google.android.libraries.ads.mobile.sdk.common.AdLoadCallback; import com.google.android.libraries.ads.mobile.sdk.common.AdRequest; import com.google.android.libraries.ads.mobile.sdk.interstitial.InterstitialAd; +import com.google.android.libraries.ads.mobile.sdk.pip.PictureInPictureAd; +import com.google.android.libraries.ads.mobile.sdk.pip.PictureInPictureAdRequest; import com.google.android.libraries.ads.mobile.sdk.rewarded.RewardedAd; import com.google.android.libraries.ads.mobile.sdk.rewardedinterstitial.RewardedInterstitialAd; @@ -67,4 +69,20 @@ public static AdWrapper forRewarded() { public static AdWrapper forRewardedInterstitial() { return new AdWrapper<>(RewardedInterstitialAd::load); } + + /** Creates a new AdWrapper for loading PictureInPictureAds. */ + public static AdWrapper forPictureInPicture() { + return new AdWrapper<>( + new AdLoader() { + @Override + public void load(AdRequest adRequest, AdLoadCallback callback) { + if (adRequest instanceof PictureInPictureAdRequest) { + PictureInPictureAd.load((PictureInPictureAdRequest) adRequest, callback); + } else { + throw new IllegalArgumentException( + "AdRequest must be of type PictureInPictureAdRequest for PiP Ads"); + } + } + }); + } } diff --git a/source/plugin/Assets/Plugins/Android/GoogleMobileAdsPlugin.androidlib/src/main/java/com/google/unity/ads/nextgen/UnityPictureInPictureAd.java b/source/plugin/Assets/Plugins/Android/GoogleMobileAdsPlugin.androidlib/src/main/java/com/google/unity/ads/nextgen/UnityPictureInPictureAd.java new file mode 100644 index 000000000..e62028c3d --- /dev/null +++ b/source/plugin/Assets/Plugins/Android/GoogleMobileAdsPlugin.androidlib/src/main/java/com/google/unity/ads/nextgen/UnityPictureInPictureAd.java @@ -0,0 +1,244 @@ +/* + * Copyright (C) 2026 Google, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.unity.ads.nextgen; + +import android.app.Activity; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import com.google.android.libraries.ads.mobile.sdk.common.AdLoadCallback; +import com.google.android.libraries.ads.mobile.sdk.common.AdRequest; +import com.google.android.libraries.ads.mobile.sdk.common.AdValue; +import com.google.android.libraries.ads.mobile.sdk.common.LoadAdError; +import com.google.android.libraries.ads.mobile.sdk.common.ResponseInfo; +import com.google.android.libraries.ads.mobile.sdk.pip.PictureInPictureAd; +import com.google.android.libraries.ads.mobile.sdk.pip.PictureInPictureAdEventCallback; +import com.google.android.libraries.ads.mobile.sdk.pip.PictureInPictureAdOptions; +import com.google.android.libraries.ads.mobile.sdk.pip.PictureInPictureAdPosition; +import com.google.android.libraries.ads.mobile.sdk.pip.PictureInPictureAdRequest; + +/** Native Java implementation for Picture-in-Picture Ads in the Google Mobile Ads Unity plugin. */ +public class UnityPictureInPictureAd { + + private static final String LOG_TAG = "GoogleMobileAds"; + + private final Activity activity; + private final UnityPictureInPictureAdCallback callback; + private final AdWrapper adWrapper; + @Nullable private PictureInPictureAd pipAd; + + public UnityPictureInPictureAd(Activity activity, UnityPictureInPictureAdCallback callback) { + this(activity, callback, AdWrapper.forPictureInPicture()); + } + + @VisibleForTesting + UnityPictureInPictureAd( + Activity activity, + UnityPictureInPictureAdCallback callback, + AdWrapper adWrapper) { + this.activity = activity; + this.callback = callback; + this.adWrapper = adWrapper; + } + + /** + * Loads a Picture-in-Picture ad on the UI thread. + * + * @param adUnitId The ad unit ID to load. + * @param adRequest The native AdRequest object. + */ + public void load(String adUnitId, AdRequest adRequest) { + activity.runOnUiThread( + () -> { + try { + PictureInPictureAdRequest pipRequest = + new PictureInPictureAdRequest.Builder(adUnitId).build(); + + adWrapper.load( + pipRequest, + new AdLoadCallback() { + @Override + public void onAdLoaded(@NonNull PictureInPictureAd ad) { + pipAd = ad; + pipAd.setAdEventCallback( + new PictureInPictureAdEventCallback() { + @Override + public void onAdShown() { + if (callback != null) { + callback.onAdShown(); + } + } + + @Override + public void onAdHidden() { + if (callback != null) { + callback.onAdHidden(); + } + } + + @Override + public void onAdClicked() { + if (callback != null) { + callback.onAdClicked(); + } + } + + @Override + public void onAdImpression() { + if (callback != null) { + callback.onAdImpression(); + } + } + + @Override + public void onAdShowedFullScreenContent() { + if (callback != null) { + callback.onAdShowedFullScreenContent(); + } + } + + @Override + public void onAdDismissedFullScreenContent() { + if (callback != null) { + callback.onAdDismissedFullScreenContent(); + } + } + + @Override + public void onAdPaid(@NonNull AdValue adValue) { + if (callback != null) { + callback.onPaidEvent( + Util.getAdValuePrecisionType(adValue.getPrecisionType()), + adValue.getValueMicros(), + adValue.getCurrencyCode()); + } + } + }); + + if (callback != null) { + callback.onAdLoaded(); + } + } + + @Override + public void onAdFailedToLoad(@NonNull LoadAdError loadAdError) { + if (callback != null) { + callback.onAdFailedToLoad(loadAdError); + } + } + }); + } catch (Throwable t) { + Log.e(LOG_TAG, "Failed to load PictureInPictureAd: " + t, t); + if (callback != null) { + callback.onAdFailedToLoad( + new LoadAdError( + LoadAdError.ErrorCode.INTERNAL_ERROR, + t.getMessage() != null ? t.getMessage() : t.toString(), + null)); + } + } + }); + } + + /** + * Shows the Picture-in-Picture ad at the given corner position. + * + * @param positionOrdinal 0=BOTTOM_RIGHT, 1=BOTTOM_LEFT, 2=TOP_LEFT, 3=TOP_RIGHT. + */ + public void show(int positionOrdinal) { + activity.runOnUiThread( + () -> { + if (pipAd == null) { + Log.e(LOG_TAG, "Failed to show PictureInPictureAd: ad is null."); + return; + } + PictureInPictureAdPosition position; + switch (positionOrdinal) { + case 1: + position = PictureInPictureAdPosition.BOTTOM_RIGHT; + break; + case 2: + position = PictureInPictureAdPosition.BOTTOM_LEFT; + break; + case 3: + position = PictureInPictureAdPosition.TOP_LEFT; + break; + case 4: + position = PictureInPictureAdPosition.TOP_RIGHT; + break; + case 0: + default: + position = PictureInPictureAdPosition.DEFAULT; + break; + } + PictureInPictureAdOptions options = + new PictureInPictureAdOptions.Builder().setPosition(position).build(); + pipAd.show(activity, options); + }); + } + + /** Hides the Picture-in-Picture ad. */ + public void hide() { + activity.runOnUiThread( + () -> { + if (pipAd != null) { + pipAd.hide(); + } + }); + } + + /** Destroys the Picture-in-Picture ad and cleans up references. */ + public void destroy() { + activity.runOnUiThread( + () -> { + if (pipAd != null) { + pipAd.destroy(); + pipAd = null; + } + }); + } + + /** Returns the current or last known position ordinal (0=DEFAULT, 1=BOTTOM_RIGHT, etc.). */ + public int getAdPosition() { + if (pipAd == null) { + return 0; + } + PictureInPictureAdPosition position = pipAd.getPosition(); + if (position == null) { + return 0; + } + switch (position) { + case DEFAULT: + return 0; + case BOTTOM_RIGHT: + return 1; + case BOTTOM_LEFT: + return 2; + case TOP_LEFT: + return 3; + case TOP_RIGHT: + return 4; + } + return 0; + } + + /** Returns ResponseInfo for the loaded ad, if available. */ + @Nullable + public ResponseInfo getResponseInfo() { + return pipAd != null ? pipAd.getResponseInfo() : null; + } +} diff --git a/source/plugin/Assets/Plugins/Android/GoogleMobileAdsPlugin.androidlib/src/main/java/com/google/unity/ads/nextgen/UnityPictureInPictureAdCallback.java b/source/plugin/Assets/Plugins/Android/GoogleMobileAdsPlugin.androidlib/src/main/java/com/google/unity/ads/nextgen/UnityPictureInPictureAdCallback.java new file mode 100644 index 000000000..1b7816a56 --- /dev/null +++ b/source/plugin/Assets/Plugins/Android/GoogleMobileAdsPlugin.androidlib/src/main/java/com/google/unity/ads/nextgen/UnityPictureInPictureAdCallback.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2026 Google, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.unity.ads.nextgen; + +import com.google.android.libraries.ads.mobile.sdk.common.LoadAdError; + +/** + * An interface form of {@link UnityPictureInPictureAdCallback} that can be implemented via {@code + * AndroidJavaProxy} in Unity to receive ad events synchronously. + */ +public interface UnityPictureInPictureAdCallback extends UnityPaidEventListener { + + /** Called when the picture in picture ad is loaded. */ + void onAdLoaded(); + + /** Called when the picture in picture ad fails to load. */ + void onAdFailedToLoad(LoadAdError error); + + /** Called when the picture in picture ad is displayed on the screen. */ + void onAdShown(); + + /** Called when the picture in picture ad is hidden from the screen. */ + void onAdHidden(); + + /** Called when the picture in picture ad records an impression. */ + void onAdImpression(); + + /** Called when the picture in picture ad records a click. */ + void onAdClicked(); + + /** Called when the picture in picture ad opens an overlay that covers the screen. */ + void onAdShowedFullScreenContent(); + + /** Called when the picture in picture ad dismisses an overlay that it previously showed. */ + void onAdDismissedFullScreenContent(); +} diff --git a/source/plugin/Assets/Plugins/Android/GoogleMobileAdsPlugin.androidlib/src/test/java/com/google/unity/ads/nextgen/UnityPictureInPictureAdTest.java b/source/plugin/Assets/Plugins/Android/GoogleMobileAdsPlugin.androidlib/src/test/java/com/google/unity/ads/nextgen/UnityPictureInPictureAdTest.java new file mode 100644 index 000000000..a72c5c694 --- /dev/null +++ b/source/plugin/Assets/Plugins/Android/GoogleMobileAdsPlugin.androidlib/src/test/java/com/google/unity/ads/nextgen/UnityPictureInPictureAdTest.java @@ -0,0 +1,287 @@ +/* + * Copyright (C) 2026 Google, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.unity.ads.nextgen; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.Activity; +import android.os.Bundle; +import com.google.android.libraries.ads.mobile.sdk.common.AdLoadCallback; +import com.google.android.libraries.ads.mobile.sdk.common.AdRequest; +import com.google.android.libraries.ads.mobile.sdk.common.AdValue; +import com.google.android.libraries.ads.mobile.sdk.common.LoadAdError; +import com.google.android.libraries.ads.mobile.sdk.common.PrecisionType; +import com.google.android.libraries.ads.mobile.sdk.common.ResponseInfo; +import com.google.android.libraries.ads.mobile.sdk.pip.PictureInPictureAd; +import com.google.android.libraries.ads.mobile.sdk.pip.PictureInPictureAdEventCallback; +import com.google.android.libraries.ads.mobile.sdk.pip.PictureInPictureAdOptions; +import com.google.android.libraries.ads.mobile.sdk.pip.PictureInPictureAdPosition; +import com.google.android.libraries.ads.mobile.sdk.pip.PictureInPictureAdRequest; +import java.util.ArrayList; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; + +/** Unit tests for {@link UnityPictureInPictureAd} and {@link UnityPictureInPictureAdCallback}. */ +@RunWith(RobolectricTestRunner.class) +public final class UnityPictureInPictureAdTest { + + private static final String AD_UNIT_ID = "ca-app-pub-3940256099942544/6300978111"; + + @Rule public final MockitoRule mocks = MockitoJUnit.rule(); + + private Activity activity; + @Mock private UnityPictureInPictureAdCallback mockCallback; + @Mock private PictureInPictureAd mockPipAd; + @Mock private AdWrapper mockAdWrapper; + @Mock private AdRequest mockAdRequest; + + @Captor private ArgumentCaptor adRequestCaptor; + @Captor private ArgumentCaptor> adLoadCallbackCaptor; + @Captor private ArgumentCaptor adEventCallbackCaptor; + @Captor private ArgumentCaptor adOptionsCaptor; + + private UnityPictureInPictureAd unityPictureInPictureAd; + + @Before + public void setUp() { + activity = Robolectric.buildActivity(Activity.class).create().get(); + unityPictureInPictureAd = new UnityPictureInPictureAd(activity, mockCallback, mockAdWrapper); + } + + @Test + public void testPublicConstructor_createsInstance() { + UnityPictureInPictureAd ad = new UnityPictureInPictureAd(activity, mockCallback); + assertThat(ad).isNotNull(); + } + + @Test + public void testLoad_onAdLoaded_invokesCallback() { + unityPictureInPictureAd.load(AD_UNIT_ID, mockAdRequest); + + verify(mockAdWrapper).load(adRequestCaptor.capture(), adLoadCallbackCaptor.capture()); + assertThat(adRequestCaptor.getValue().getAdUnitId()).isEqualTo(AD_UNIT_ID); + + adLoadCallbackCaptor.getValue().onAdLoaded(mockPipAd); + verify(mockCallback).onAdLoaded(); + verify(mockPipAd).setAdEventCallback(any()); + } + + @Test + public void testLoad_onAdFailedToLoad_invokesCallback() { + unityPictureInPictureAd.load(AD_UNIT_ID, mockAdRequest); + + verify(mockAdWrapper).load(adRequestCaptor.capture(), adLoadCallbackCaptor.capture()); + LoadAdError loadAdError = + new LoadAdError(LoadAdError.ErrorCode.INTERNAL_ERROR, "error message", null); + adLoadCallbackCaptor.getValue().onAdFailedToLoad(loadAdError); + + verify(mockCallback).onAdFailedToLoad(loadAdError); + verify(mockCallback, never()).onAdLoaded(); + } + + @Test + public void testAdEventCallbacks_allEventsTriggered() { + // Load the ad first + unityPictureInPictureAd.load(AD_UNIT_ID, mockAdRequest); + verify(mockAdWrapper).load(any(), adLoadCallbackCaptor.capture()); + adLoadCallbackCaptor.getValue().onAdLoaded(mockPipAd); + + verify(mockPipAd).setAdEventCallback(adEventCallbackCaptor.capture()); + PictureInPictureAdEventCallback eventCallback = adEventCallbackCaptor.getValue(); + + // Verify onAdShown + eventCallback.onAdShown(); + verify(mockCallback).onAdShown(); + + // Verify onAdHidden + eventCallback.onAdHidden(); + verify(mockCallback).onAdHidden(); + + // Verify onAdClicked + eventCallback.onAdClicked(); + verify(mockCallback).onAdClicked(); + + // Verify onAdImpression + eventCallback.onAdImpression(); + verify(mockCallback).onAdImpression(); + + // Verify onAdShowedFullScreenContent + eventCallback.onAdShowedFullScreenContent(); + verify(mockCallback).onAdShowedFullScreenContent(); + + // Verify onAdDismissedFullScreenContent + eventCallback.onAdDismissedFullScreenContent(); + verify(mockCallback).onAdDismissedFullScreenContent(); + + // Verify onAdPaid + PrecisionType precisionType = PrecisionType.PRECISE; + long valueMicros = 1500000L; + String currencyCode = "USD"; + eventCallback.onAdPaid(new AdValue(precisionType, valueMicros, currencyCode)); + verify(mockCallback) + .onPaidEvent(Util.getAdValuePrecisionType(precisionType), valueMicros, currencyCode); + } + + @Test + public void testShow_whenAdNotLoaded_doesNotThrow() { + unityPictureInPictureAd.show(0); + verify(mockPipAd, never()).show(any(), any()); + } + + @Test + public void testShow_defaultPosition_passesDefaultOptions() { + // Load the ad + unityPictureInPictureAd.load(AD_UNIT_ID, mockAdRequest); + verify(mockAdWrapper).load(any(), adLoadCallbackCaptor.capture()); + adLoadCallbackCaptor.getValue().onAdLoaded(mockPipAd); + + // Call show(0) for Default + unityPictureInPictureAd.show(0); + + verify(mockPipAd).show(eq(activity), adOptionsCaptor.capture()); + assertThat(adOptionsCaptor.getValue().getPosition()) + .isEqualTo(PictureInPictureAdPosition.DEFAULT); + } + + @Test + public void testShow_explicitPositions_passesCorrectOptions() { + // Load the ad + unityPictureInPictureAd.load(AD_UNIT_ID, mockAdRequest); + verify(mockAdWrapper).load(any(), adLoadCallbackCaptor.capture()); + adLoadCallbackCaptor.getValue().onAdLoaded(mockPipAd); + + // Test position 1 -> BOTTOM_RIGHT + unityPictureInPictureAd.show(1); + verify(mockPipAd).show(eq(activity), adOptionsCaptor.capture()); + assertThat(adOptionsCaptor.getValue().getPosition()) + .isEqualTo(PictureInPictureAdPosition.BOTTOM_RIGHT); + + // Test position 2 -> BOTTOM_LEFT + unityPictureInPictureAd.show(2); + verify(mockPipAd, Mockito.times(2)).show(eq(activity), adOptionsCaptor.capture()); + assertThat(adOptionsCaptor.getValue().getPosition()) + .isEqualTo(PictureInPictureAdPosition.BOTTOM_LEFT); + + // Test position 3 -> TOP_LEFT + unityPictureInPictureAd.show(3); + verify(mockPipAd, Mockito.times(3)).show(eq(activity), adOptionsCaptor.capture()); + assertThat(adOptionsCaptor.getValue().getPosition()) + .isEqualTo(PictureInPictureAdPosition.TOP_LEFT); + + // Test position 4 -> TOP_RIGHT + unityPictureInPictureAd.show(4); + verify(mockPipAd, Mockito.times(4)).show(eq(activity), adOptionsCaptor.capture()); + assertThat(adOptionsCaptor.getValue().getPosition()) + .isEqualTo(PictureInPictureAdPosition.TOP_RIGHT); + } + + @Test + public void testHide_whenAdLoaded_callsPipAdHide() { + unityPictureInPictureAd.load(AD_UNIT_ID, mockAdRequest); + verify(mockAdWrapper).load(any(), adLoadCallbackCaptor.capture()); + adLoadCallbackCaptor.getValue().onAdLoaded(mockPipAd); + + unityPictureInPictureAd.hide(); + verify(mockPipAd).hide(); + } + + @Test + public void testHide_whenAdNotLoaded_doesNotThrow() { + unityPictureInPictureAd.hide(); + verify(mockPipAd, never()).hide(); + } + + @Test + public void testDestroy_whenAdLoaded_destroysAndCleansUp() { + unityPictureInPictureAd.load(AD_UNIT_ID, mockAdRequest); + verify(mockAdWrapper).load(any(), adLoadCallbackCaptor.capture()); + adLoadCallbackCaptor.getValue().onAdLoaded(mockPipAd); + + unityPictureInPictureAd.destroy(); + verify(mockPipAd).destroy(); + + // Verify subsequent show or hide does not invoke pipAd + unityPictureInPictureAd.show(0); + verify(mockPipAd, never()).show(any(), any()); + } + + @Test + public void testGetAdPosition_whenAdNotLoaded_returnsDefaultZero() { + assertThat(unityPictureInPictureAd.getAdPosition()).isEqualTo(0); + } + + @Test + public void testGetAdPosition_whenAdLoaded_mapsPositionsCorrectly() { + unityPictureInPictureAd.load(AD_UNIT_ID, mockAdRequest); + verify(mockAdWrapper).load(any(), adLoadCallbackCaptor.capture()); + adLoadCallbackCaptor.getValue().onAdLoaded(mockPipAd); + + // DEFAULT -> 0 + when(mockPipAd.getPosition()).thenReturn(PictureInPictureAdPosition.DEFAULT); + assertThat(unityPictureInPictureAd.getAdPosition()).isEqualTo(0); + + // BOTTOM_RIGHT -> 1 + when(mockPipAd.getPosition()).thenReturn(PictureInPictureAdPosition.BOTTOM_RIGHT); + assertThat(unityPictureInPictureAd.getAdPosition()).isEqualTo(1); + + // BOTTOM_LEFT -> 2 + when(mockPipAd.getPosition()).thenReturn(PictureInPictureAdPosition.BOTTOM_LEFT); + assertThat(unityPictureInPictureAd.getAdPosition()).isEqualTo(2); + + // TOP_LEFT -> 3 + when(mockPipAd.getPosition()).thenReturn(PictureInPictureAdPosition.TOP_LEFT); + assertThat(unityPictureInPictureAd.getAdPosition()).isEqualTo(3); + + // TOP_RIGHT -> 4 + when(mockPipAd.getPosition()).thenReturn(PictureInPictureAdPosition.TOP_RIGHT); + assertThat(unityPictureInPictureAd.getAdPosition()).isEqualTo(4); + } + + @Test + public void testGetResponseInfo_whenAdNotLoaded_returnsNull() { + assertThat(unityPictureInPictureAd.getResponseInfo()).isNull(); + } + + @Test + public void testGetResponseInfo_whenAdLoaded_returnsResponseInfo() { + ResponseInfo responseInfo = + new ResponseInfo("AdapterName", "responseId", new Bundle(), null, new ArrayList<>()); + when(mockPipAd.getResponseInfo()).thenReturn(responseInfo); + + unityPictureInPictureAd.load(AD_UNIT_ID, mockAdRequest); + verify(mockAdWrapper).load(any(), adLoadCallbackCaptor.capture()); + adLoadCallbackCaptor.getValue().onAdLoaded(mockPipAd); + + ResponseInfo actualResponseInfo = unityPictureInPictureAd.getResponseInfo(); + verify(mockPipAd).getResponseInfo(); + assertThat(actualResponseInfo).isEqualTo(responseInfo); + } +}