diff --git a/app/src/main/java/com/d4rk/androidtutorials/java/ads/managers/NativeAdLoader.java b/app/src/main/java/com/d4rk/androidtutorials/java/ads/managers/NativeAdLoader.java index d02ba45b..693e194c 100644 --- a/app/src/main/java/com/d4rk/androidtutorials/java/ads/managers/NativeAdLoader.java +++ b/app/src/main/java/com/d4rk/androidtutorials/java/ads/managers/NativeAdLoader.java @@ -1,6 +1,8 @@ package com.d4rk.androidtutorials.java.ads.managers; import android.content.Context; +import android.os.Handler; +import android.os.Looper; import android.util.Log; import android.view.LayoutInflater; import android.view.View; @@ -11,6 +13,7 @@ import androidx.annotation.LayoutRes; import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; import com.d4rk.androidtutorials.java.R; import com.google.android.gms.ads.AdListener; @@ -49,7 +52,7 @@ public static void load(@NonNull Context context, @NonNull AdRequest adRequest, @androidx.annotation.Nullable AdListener listener) { AdLoader.Builder builder = new AdLoader.Builder(context, context.getString(R.string.native_ad_banner_unit_id)) - .forNativeAd(nativeAd -> { + .forNativeAd(nativeAd -> postToMainThread(() -> { LayoutInflater inflater = LayoutInflater.from(context); NativeAdView adView = (NativeAdView) inflater.inflate(layoutRes, container, false); adView.setLayoutParams(new ViewGroup.LayoutParams( @@ -59,22 +62,116 @@ public static void load(@NonNull Context context, container.getPaddingRight(), container.getPaddingBottom()); container.setPadding(0, 0, 0, 0); populateNativeAdView(nativeAd, adView); + container.setVisibility(View.VISIBLE); container.removeAllViews(); container.addView(adView); container.requestLayout(); + })); + + builder.withAdListener(createAdListener(container, listener)); + + AdLoader adLoader = builder.build(); + adLoader.loadAd(adRequest); + } + + private static AdListener createAdListener(@NonNull ViewGroup container, + @androidx.annotation.Nullable AdListener listener) { + return new AdListener() { + @Override + public void onAdLoaded() { + postToMainThread(() -> { + if (listener != null) { + listener.onAdLoaded(); + } }); + } - builder.withAdListener(listener != null ? listener : new AdListener() { @Override public void onAdFailedToLoad(@NonNull LoadAdError loadAdError) { - Log.w(TAG, "Failed to load native ad: " + loadAdError.getMessage()); - container.removeAllViews(); - container.setVisibility(View.GONE); + postToMainThread(() -> { + Log.w(TAG, "Failed to load native ad: " + loadAdError.getMessage()); + container.removeAllViews(); + container.setVisibility(View.GONE); + if (listener != null) { + listener.onAdFailedToLoad(loadAdError); + } + }); } - }); - AdLoader adLoader = builder.build(); - adLoader.loadAd(adRequest); + @Override + public void onAdOpened() { + postToMainThread(() -> { + if (listener != null) { + listener.onAdOpened(); + } + }); + } + + @Override + public void onAdClosed() { + postToMainThread(() -> { + if (listener != null) { + listener.onAdClosed(); + } + }); + } + + @Override + public void onAdClicked() { + postToMainThread(() -> { + if (listener != null) { + listener.onAdClicked(); + } + }); + } + + @Override + public void onAdImpression() { + postToMainThread(() -> { + if (listener != null) { + listener.onAdImpression(); + } + }); + } + }; + } + + interface MainThreadExecutor { + void post(@NonNull Runnable runnable); + } + + private static final class HandlerMainThreadExecutor implements MainThreadExecutor { + private final Handler handler; + + private HandlerMainThreadExecutor() { + Looper looper = Looper.getMainLooper(); + handler = looper != null ? new Handler(looper) : null; + } + + @Override + public void post(@NonNull Runnable runnable) { + if (handler != null) { + handler.post(runnable); + } else { + runnable.run(); + } + } + } + + private static MainThreadExecutor mainThreadExecutor = new HandlerMainThreadExecutor(); + + private static void postToMainThread(@NonNull Runnable runnable) { + mainThreadExecutor.post(runnable); + } + + @VisibleForTesting + static void setMainThreadExecutorForTesting(@NonNull MainThreadExecutor executor) { + mainThreadExecutor = executor; + } + + @VisibleForTesting + static void resetMainThreadExecutorForTesting() { + mainThreadExecutor = new HandlerMainThreadExecutor(); } private static void populateNativeAdView(@NonNull NativeAd nativeAd, @NonNull NativeAdView adView) { diff --git a/app/src/test/java/com/d4rk/androidtutorials/java/ads/managers/AppOpenAdManagerTest.java b/app/src/test/java/com/d4rk/androidtutorials/java/ads/managers/AppOpenAdManagerTest.java index 5924ce28..c1611ddd 100644 --- a/app/src/test/java/com/d4rk/androidtutorials/java/ads/managers/AppOpenAdManagerTest.java +++ b/app/src/test/java/com/d4rk/androidtutorials/java/ads/managers/AppOpenAdManagerTest.java @@ -1,6 +1,8 @@ package com.d4rk.androidtutorials.java.ads.managers; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -16,8 +18,10 @@ import com.d4rk.androidtutorials.java.ads.AdUtils; import com.d4rk.androidtutorials.java.ads.managers.AppOpenAd.OnShowAdCompleteListener; +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.AppOpenAdLoadCallback; import org.junit.Before; @@ -131,6 +135,101 @@ public void showAdIfAvailable_withAd_doesNotShowTwiceWhileShowing() throws Excep verify(ad, times(1)).show(activity); } + @Test + public void showAdIfAvailable_withAd_handlesDismissAndReloads() throws Exception { + Activity activity = mock(Activity.class); + OnShowAdCompleteListener listener = mock(OnShowAdCompleteListener.class); + com.google.android.gms.ads.appopen.AppOpenAd ad = mock(com.google.android.gms.ads.appopen.AppOpenAd.class); + + setField("appOpenAd", ad); + setLongField("loadTime", System.currentTimeMillis()); + + try (MockedStatic adUtils = mockStatic(AdUtils.class); + MockedStatic appOpenAdStatic = + mockStatic(com.google.android.gms.ads.appopen.AppOpenAd.class)) { + appOpenAdStatic + .when(() -> com.google.android.gms.ads.appopen.AppOpenAd.load( + any(Context.class), + anyString(), + any(AdRequest.class), + any(AppOpenAdLoadCallback.class))) + .thenAnswer(invocation -> { + AppOpenAdLoadCallback callback = invocation.getArgument(3); + callback.onAdLoaded(mock(com.google.android.gms.ads.appopen.AppOpenAd.class)); + return null; + }); + + invokeShowAdIfAvailable(activity, listener); + + ArgumentCaptor callbackCaptor = + ArgumentCaptor.forClass(FullScreenContentCallback.class); + verify(ad).setFullScreenContentCallback(callbackCaptor.capture()); + verify(ad).show(activity); + assertTrue(getBooleanField("isShowingAd")); + + callbackCaptor.getValue().onAdDismissedFullScreenContent(); + + assertFalse(getBooleanField("isShowingAd")); + assertFalse(getBooleanField("isLoadingAd")); + assertNotNull(getFieldValue("appOpenAd")); + verify(listener, times(1)).onShowAdComplete(); + adUtils.verify(() -> AdUtils.initialize(any(Context.class))); + appOpenAdStatic.verify(() -> com.google.android.gms.ads.appopen.AppOpenAd.load( + any(Context.class), + anyString(), + any(AdRequest.class), + any(AppOpenAdLoadCallback.class))); + } + } + + @Test + public void showAdIfAvailable_withAd_handlesShowFailureAndReloadFailure() throws Exception { + Activity activity = mock(Activity.class); + OnShowAdCompleteListener listener = mock(OnShowAdCompleteListener.class); + com.google.android.gms.ads.appopen.AppOpenAd ad = mock(com.google.android.gms.ads.appopen.AppOpenAd.class); + LoadAdError loadAdError = mock(LoadAdError.class); + AdError adError = mock(AdError.class); + + setField("appOpenAd", ad); + setLongField("loadTime", System.currentTimeMillis()); + + try (MockedStatic adUtils = mockStatic(AdUtils.class); + MockedStatic appOpenAdStatic = + mockStatic(com.google.android.gms.ads.appopen.AppOpenAd.class)) { + appOpenAdStatic + .when(() -> com.google.android.gms.ads.appopen.AppOpenAd.load( + any(Context.class), + anyString(), + any(AdRequest.class), + any(AppOpenAdLoadCallback.class))) + .thenAnswer(invocation -> { + AppOpenAdLoadCallback callback = invocation.getArgument(3); + callback.onAdFailedToLoad(loadAdError); + return null; + }); + + invokeShowAdIfAvailable(activity, listener); + + ArgumentCaptor callbackCaptor = + ArgumentCaptor.forClass(FullScreenContentCallback.class); + verify(ad).setFullScreenContentCallback(callbackCaptor.capture()); + verify(ad).show(activity); + + callbackCaptor.getValue().onAdFailedToShowFullScreenContent(adError); + + assertFalse(getBooleanField("isShowingAd")); + assertFalse(getBooleanField("isLoadingAd")); + assertNull(getFieldValue("appOpenAd")); + verify(listener, times(1)).onShowAdComplete(); + adUtils.verify(() -> AdUtils.initialize(any(Context.class))); + appOpenAdStatic.verify(() -> com.google.android.gms.ads.appopen.AppOpenAd.load( + any(Context.class), + anyString(), + any(AdRequest.class), + any(AppOpenAdLoadCallback.class))); + } + } + private Class findManagerClass() { for (Class clazz : AppOpenAd.class.getDeclaredClasses()) { if ("AppOpenAdManager".equals(clazz.getSimpleName())) { @@ -156,6 +255,18 @@ private void invokeShowAdIfAvailable(Activity activity, OnShowAdCompleteListener method.invoke(manager, activity, listener); } + private boolean getBooleanField(String fieldName) throws Exception { + Field field = managerClass.getDeclaredField(fieldName); + field.setAccessible(true); + return field.getBoolean(manager); + } + + private Object getFieldValue(String fieldName) throws Exception { + Field field = managerClass.getDeclaredField(fieldName); + field.setAccessible(true); + return field.get(manager); + } + private void setField(String fieldName, Object value) throws Exception { Field field = managerClass.getDeclaredField(fieldName); field.setAccessible(true); diff --git a/app/src/test/java/com/d4rk/androidtutorials/java/ads/managers/NativeAdLoaderTest.java b/app/src/test/java/com/d4rk/androidtutorials/java/ads/managers/NativeAdLoaderTest.java new file mode 100644 index 00000000..8197b72f --- /dev/null +++ b/app/src/test/java/com/d4rk/androidtutorials/java/ads/managers/NativeAdLoaderTest.java @@ -0,0 +1,211 @@ +package com.d4rk.androidtutorials.java.ads.managers; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.TextView; + +import com.d4rk.androidtutorials.java.R; +import com.google.android.gms.ads.AdListener; +import com.google.android.gms.ads.AdLoader; +import com.google.android.gms.ads.AdRequest; +import com.google.android.gms.ads.LoadAdError; +import com.google.android.gms.ads.nativead.MediaView; +import com.google.android.gms.ads.nativead.NativeAd; +import com.google.android.gms.ads.nativead.NativeAdView; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; + +import java.util.ArrayDeque; +import java.util.Queue; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Unit tests for {@link NativeAdLoader}. + */ +public class NativeAdLoaderTest { + + private TestMainThreadExecutor mainThreadExecutor; + + @Before + public void setUp() { + mainThreadExecutor = new TestMainThreadExecutor(); + NativeAdLoader.setMainThreadExecutorForTesting(mainThreadExecutor); + } + + @After + public void tearDown() { + NativeAdLoader.resetMainThreadExecutorForTesting(); + } + + @Test + public void load_whenNativeAdLoaded_postsViewPopulationAndNotifiesListener() { + Context context = mock(Context.class); + when(context.getString(R.string.native_ad_banner_unit_id)).thenReturn("ad-unit"); + when(context.getString(R.string.ad)).thenReturn("Ad"); + + ViewGroup container = mock(ViewGroup.class); + AdListener externalListener = mock(AdListener.class); + AdRequest adRequest = mock(AdRequest.class); + + NativeAd nativeAd = mock(NativeAd.class); + when(nativeAd.getHeadline()).thenReturn("Headline"); + when(nativeAd.getBody()).thenReturn("Body"); + when(nativeAd.getCallToAction()).thenReturn("Install"); + + NativeAd.Image icon = mock(NativeAd.Image.class); + Drawable drawable = mock(Drawable.class); + when(icon.getDrawable()).thenReturn(drawable); + when(nativeAd.getIcon()).thenReturn(icon); + + LayoutInflater layoutInflater = mock(LayoutInflater.class); + NativeAdView adView = mock(NativeAdView.class); + when(adView.getContext()).thenReturn(context); + + MediaView mediaView = mock(MediaView.class); + TextView headlineView = mock(TextView.class); + TextView bodyView = mock(TextView.class); + Button callToActionView = mock(Button.class); + ImageView iconView = mock(ImageView.class); + TextView attributionView = mock(TextView.class); + + when(adView.findViewById(R.id.ad_media)).thenReturn(mediaView); + when(adView.findViewById(R.id.ad_headline)).thenReturn(headlineView); + when(adView.findViewById(R.id.ad_body)).thenReturn(bodyView); + when(adView.findViewById(R.id.ad_call_to_action)).thenReturn(callToActionView); + when(adView.findViewById(R.id.ad_app_icon)).thenReturn(iconView); + when(adView.findViewById(R.id.ad_attribution)).thenReturn(attributionView); + + AdLoader adLoader = mock(AdLoader.class); + AtomicReference nativeAdListenerRef = new AtomicReference<>(); + AtomicReference adListenerRef = new AtomicReference<>(); + + try (MockedStatic layoutInflaterStatic = org.mockito.Mockito.mockStatic(LayoutInflater.class); + MockedConstruction mockedBuilder = org.mockito.Mockito.mockConstruction( + AdLoader.Builder.class, + (mockBuilder, contextArgs) -> { + when(mockBuilder.forNativeAd(any())).thenAnswer(invocation -> { + nativeAdListenerRef.set(invocation.getArgument(0)); + return mockBuilder; + }); + when(mockBuilder.withAdListener(any())).thenAnswer(invocation -> { + adListenerRef.set(invocation.getArgument(0)); + return mockBuilder; + }); + when(mockBuilder.build()).thenReturn(adLoader); + })) { + + layoutInflaterStatic.when(() -> LayoutInflater.from(context)).thenReturn(layoutInflater); + when(layoutInflater.inflate(anyInt(), eq(container), eq(false))).thenReturn(adView); + + NativeAdLoader.load(context, container, R.layout.ad_home_banner_large, adRequest, externalListener); + + NativeAd.OnNativeAdLoadedListener nativeAdListener = nativeAdListenerRef.get(); + AdListener adListener = adListenerRef.get(); + assertNotNull(nativeAdListener); + assertNotNull(adListener); + + nativeAdListener.onNativeAdLoaded(nativeAd); + assertTrue(mainThreadExecutor.hasPendingTasks()); + verify(container, never()).addView(any(View.class)); + + adListener.onAdLoaded(); + assertTrue(mainThreadExecutor.hasPendingTasks()); + verify(externalListener, never()).onAdLoaded(); + + mainThreadExecutor.drain(); + + verify(layoutInflater).inflate(R.layout.ad_home_banner_large, container, false); + verify(container).removeAllViews(); + verify(container).addView(adView); + verify(container).requestLayout(); + verify(container).setVisibility(View.VISIBLE); + verify(adView).setNativeAd(nativeAd); + verify(externalListener, times(1)).onAdLoaded(); + verify(adLoader, times(1)).loadAd(adRequest); + assertFalse(mainThreadExecutor.hasPendingTasks()); + } + } + + @Test + public void load_whenAdFails_postsFailureAndNotifiesListener() { + Context context = mock(Context.class); + when(context.getString(R.string.native_ad_banner_unit_id)).thenReturn("ad-unit"); + + ViewGroup container = mock(ViewGroup.class); + AdListener externalListener = mock(AdListener.class); + AdRequest adRequest = mock(AdRequest.class); + LoadAdError loadAdError = mock(LoadAdError.class); + + AtomicReference adListenerRef = new AtomicReference<>(); + + try (MockedConstruction mockedBuilder = org.mockito.Mockito.mockConstruction( + AdLoader.Builder.class, + (mockBuilder, contextArgs) -> { + when(mockBuilder.forNativeAd(any())).thenReturn(mockBuilder); + when(mockBuilder.withAdListener(any())).thenAnswer(invocation -> { + adListenerRef.set(invocation.getArgument(0)); + return mockBuilder; + }); + when(mockBuilder.build()).thenReturn(mock(AdLoader.class)); + })) { + + NativeAdLoader.load(context, container, R.layout.ad_home_banner_large, adRequest, externalListener); + + AdListener adListener = adListenerRef.get(); + assertNotNull(adListener); + adListener.onAdFailedToLoad(loadAdError); + + assertTrue(mainThreadExecutor.hasPendingTasks()); + verify(container, never()).removeAllViews(); + verify(container, never()).setVisibility(View.GONE); + verify(externalListener, never()).onAdFailedToLoad(loadAdError); + + mainThreadExecutor.drain(); + + verify(container).removeAllViews(); + verify(container).setVisibility(View.GONE); + verify(externalListener, times(1)).onAdFailedToLoad(loadAdError); + assertFalse(mainThreadExecutor.hasPendingTasks()); + } + } + + private static final class TestMainThreadExecutor implements NativeAdLoader.MainThreadExecutor { + private final Queue tasks = new ArrayDeque<>(); + + @Override + public void post(Runnable runnable) { + tasks.add(runnable); + } + + void drain() { + while (!tasks.isEmpty()) { + tasks.poll().run(); + } + } + + boolean hasPendingTasks() { + return !tasks.isEmpty(); + } + } +}