From 0634a9f6448a0e415892ad702c0569a898687097 Mon Sep 17 00:00:00 2001 From: Yamir Godil Date: Thu, 9 Jan 2025 16:51:41 -0800 Subject: [PATCH] Moloco Native Ad: Add support for native ads for the Moloco SDK --- .../ads/mediation/moloco/MolocoNativeAd.kt | 165 +++++++++++++++++- .../mediation/moloco/MolocoNativeAdTest.kt | 117 +++++++++++++ 2 files changed, 274 insertions(+), 8 deletions(-) create mode 100644 ThirdPartyAdapters/moloco/moloco/src/test/kotlin/com/google/ads/mediation/moloco/MolocoNativeAdTest.kt diff --git a/ThirdPartyAdapters/moloco/moloco/src/main/kotlin/com/google/ads/mediation/moloco/MolocoNativeAd.kt b/ThirdPartyAdapters/moloco/moloco/src/main/kotlin/com/google/ads/mediation/moloco/MolocoNativeAd.kt index d1d50e771..90d2deaae 100644 --- a/ThirdPartyAdapters/moloco/moloco/src/main/kotlin/com/google/ads/mediation/moloco/MolocoNativeAd.kt +++ b/ThirdPartyAdapters/moloco/moloco/src/main/kotlin/com/google/ads/mediation/moloco/MolocoNativeAd.kt @@ -15,10 +15,21 @@ package com.google.ads.mediation.moloco import android.content.Context +import android.graphics.drawable.Drawable +import android.net.Uri +import android.view.View +import androidx.annotation.VisibleForTesting +import com.google.android.gms.ads.AdError import com.google.android.gms.ads.mediation.MediationAdLoadCallback import com.google.android.gms.ads.mediation.MediationNativeAdCallback import com.google.android.gms.ads.mediation.MediationNativeAdConfiguration import com.google.android.gms.ads.mediation.UnifiedNativeAdMapper +import com.google.android.gms.ads.nativead.NativeAdOptions +import com.moloco.sdk.publisher.AdLoad +import com.moloco.sdk.publisher.Moloco +import com.moloco.sdk.publisher.MolocoAd +import com.moloco.sdk.publisher.MolocoAdError +import com.moloco.sdk.publisher.NativeAd /** * Used to load Moloco native ads and mediate callbacks between Google Mobile Ads SDK and Moloco @@ -26,29 +37,167 @@ import com.google.android.gms.ads.mediation.UnifiedNativeAdMapper */ class MolocoNativeAd private constructor( - private val context: Context, + private val adUnitId: String, + private val nativeAdOptions: NativeAdOptions, // TODO: Not sure where to use this? + private val bidResponse: String, + private val watermark: String, private val mediationNativeAdLoadCallback: - MediationAdLoadCallback, - // TODO: Add other parameters or remove unnecessary ones. -) : UnifiedNativeAdMapper() { + MediationAdLoadCallback, +) : AdLoad.Listener, UnifiedNativeAdMapper() { + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal var nativeAd: NativeAd? = null fun loadAd() { - // TODO: Implement this method. + Moloco.createNativeAd(adUnitId, watermark) { returnedAd, adCreateError -> + if (adCreateError != null) { + val adError = + AdError( + adCreateError.errorCode, + adCreateError.description, + MolocoMediationAdapter.SDK_ERROR_DOMAIN, + ) + mediationNativeAdLoadCallback.onFailure(adError) + return@createNativeAd + } + + nativeAd = returnedAd + + // Now that the ad object is created, load the bid response + nativeAd?.load(bidResponse, this) + } + } + + override fun onAdLoadSuccess(molocoAd: MolocoAd) { + overrideClickHandling = true + // If nativeAd is null here, then that means the ad was destroyed before load was successful or there is a bug + // in the adapter + nativeAd?.apply { + assets?.apply { + // Admob first uses rating, if not present the it uses sponsorText and if that is not present it will use the store. + rating?.let { starRating = it.toDouble() } + sponsorText?.let { advertiser = it } + store = "Google Play" + title?.let { headline = it } + description?.let { body = it } + callToActionText?.let { callToAction = it } + iconUri?.let { + Drawable.createFromPath(it.toString())?.apply { + icon = MolocoNativeMappedImage(this) + } + } + + val mediaView = this.mediaView + + mediaView?.let { + it.tag = MEDIA_VIEW_TAG + setMediaView(it) + } + } + } + + val showCallback = mediationNativeAdLoadCallback.onSuccess(this) + nativeAd?.interactionListener = object : NativeAd.InteractionListener { + /** + * Not needed as Admob handles impressions on its own. + */ + override fun onImpressionHandled() {} + + /** + * When this Moloco function gets triggered, we inform Admob that ad click has occurred + */ + override fun onGeneralClickHandled() = showCallback.reportAdClicked() + } + } + + override fun onAdLoadFailed(molocoAdError: MolocoAdError) { + val adError = + AdError( + molocoAdError.errorType.errorCode, + molocoAdError.errorType.description, + MolocoMediationAdapter.SDK_ERROR_DOMAIN, + ) + mediationNativeAdLoadCallback.onFailure(adError) + } + + /** + * Admob informs us that a click has happened to the view(s) they create. + * This excludes the view set in [setMediaView]. Click from said view must be handled elsewhere + */ + override fun handleClick(view: View) { + nativeAd?.handleGeneralAdClick() + } + + /** + * Admob informs us that an impression happened + */ + override fun recordImpression() { + nativeAd?.handleImpression() + } + + override fun trackViews( + containerView: View, + clickableAssetViews: MutableMap, + nonClickableAssetViews: MutableMap, + ) { + // set the listener to Moloco's NativeAd object. Then we read it from the Moloco's `NativeAd.interactionListener` callback + containerView.setOnClickListener { nativeAd?.handleGeneralAdClick() } + clickableAssetViews.values.forEach { + it.setOnClickListener { nativeAd?.handleGeneralAdClick() } + } + } + + /** + * To be called by the medation to destroy the ad object and any underlying references in the Moloco SDK. This should be called + * when the ad is permanently "hidden" / never going to be visible again. + */ + fun destroy() { + nativeAd?.destroy() + nativeAd = null } companion object { fun newInstance( mediationNativeAdConfiguration: MediationNativeAdConfiguration, mediationNativeAdLoadCallback: - MediationAdLoadCallback, + MediationAdLoadCallback, ): Result { val context = mediationNativeAdConfiguration.context val serverParameters = mediationNativeAdConfiguration.serverParameters val nativeAdOptions = mediationNativeAdConfiguration.nativeAdOptions - // TODO: Implement necessary initialization steps. + val adUnitId = serverParameters.getString(MolocoMediationAdapter.KEY_AD_UNIT_ID) + if (adUnitId.isNullOrEmpty()) { + val adError = + AdError( + MolocoMediationAdapter.ERROR_CODE_MISSING_AD_UNIT, + MolocoMediationAdapter.ERROR_MSG_MISSING_AD_UNIT, + MolocoMediationAdapter.ADAPTER_ERROR_DOMAIN, + ) + mediationNativeAdLoadCallback.onFailure(adError) + return Result.failure(NoSuchElementException(adError.message)) + } + + val bidResponse = mediationNativeAdConfiguration.bidResponse + val watermark = mediationNativeAdConfiguration.watermark - return Result.success(MolocoNativeAd(context, mediationNativeAdLoadCallback)) + return Result.success(MolocoNativeAd(adUnitId, nativeAdOptions, bidResponse, watermark, mediationNativeAdLoadCallback)) } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + const val MEDIA_VIEW_TAG = "native_ad_media_view" + } + + /** + * Admob adapter only needs the [drawable] parameter. The rest are optional + */ + @Suppress("DEPRECATION") + internal class MolocoNativeMappedImage( + private val drawable: Drawable, + private val uri: Uri = Uri.EMPTY, + private val scale: Double = 1.0, + ) : com.google.android.gms.ads.formats.NativeAd.Image() { // Google deprecated the class, but didn't offer an alternative. So for now we *must* use the deprecated class. + override fun getScale() = scale + override fun getDrawable() = drawable + override fun getUri() = uri } } diff --git a/ThirdPartyAdapters/moloco/moloco/src/test/kotlin/com/google/ads/mediation/moloco/MolocoNativeAdTest.kt b/ThirdPartyAdapters/moloco/moloco/src/test/kotlin/com/google/ads/mediation/moloco/MolocoNativeAdTest.kt new file mode 100644 index 000000000..a526aec5f --- /dev/null +++ b/ThirdPartyAdapters/moloco/moloco/src/test/kotlin/com/google/ads/mediation/moloco/MolocoNativeAdTest.kt @@ -0,0 +1,117 @@ +package com.google.ads.mediation.moloco + +import android.content.Context +import androidx.core.os.bundleOf +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.ads.mediation.adaptertestkit.AdErrorMatcher +import com.google.ads.mediation.adaptertestkit.AdapterTestKitConstants.TEST_BID_RESPONSE +import com.google.ads.mediation.adaptertestkit.AdapterTestKitConstants.TEST_WATERMARK +import com.google.android.gms.ads.AdError +import com.google.android.gms.ads.RequestConfiguration +import com.google.android.gms.ads.mediation.MediationAdLoadCallback +import com.google.android.gms.ads.mediation.UnifiedNativeAdMapper +import com.google.android.gms.ads.mediation.MediationNativeAdCallback +import com.google.android.gms.ads.mediation.MediationNativeAdConfiguration +import com.moloco.sdk.publisher.MolocoAdError +import com.moloco.sdk.publisher.NativeAd +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.argThat +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class MolocoNativeAdTest { + // Subject of tests + private lateinit var molocoNativeAd: MolocoNativeAd + private lateinit var mediationAdConfiguration: MediationNativeAdConfiguration + + private val context = ApplicationProvider.getApplicationContext() + private val mockNativeAd = mock() + private val mockMediationAdLoadCallback: + MediationAdLoadCallback = + mock() + private val mockMediationAdCallback = mock() + + @Before + fun setUp() { + // Properly initialize molocoNativeAd + mediationAdConfiguration = createMediationNativeAdConfiguration() + MolocoNativeAd.newInstance(mediationAdConfiguration, mockMediationAdLoadCallback) + .onSuccess { molocoNativeAd = it } + whenever(mockMediationAdLoadCallback.onSuccess(molocoNativeAd)) doReturn + mockMediationAdCallback + } + + @Test + fun onAdLoadFailed_invokesOnFailure() { + val testError = + MolocoAdError("testNetwork", "testAdUnit", MolocoAdError.ErrorType.UNKNOWN, "testDesc") + val expectedAdError = + AdError( + MolocoAdError.ErrorType.UNKNOWN.errorCode, + MolocoAdError.ErrorType.UNKNOWN.description, + MolocoMediationAdapter.SDK_ERROR_DOMAIN, + ) + + molocoNativeAd.onAdLoadFailed(testError) + + verify(mockMediationAdLoadCallback).onFailure(argThat(AdErrorMatcher(expectedAdError))) + } + + @Test + fun onAdLoadSuccess_invokesOnSuccess() { + molocoNativeAd.onAdLoadSuccess(mock()) + + verify(mockMediationAdLoadCallback).onSuccess(molocoNativeAd) + } + + @Test + fun handleClick_invokesReportAdClicked() { + molocoNativeAd.nativeAd = mockNativeAd + + molocoNativeAd.handleClick(mock()) + + verify(mockNativeAd).handleGeneralAdClick() + } + + @Test + fun destroy_invokesOnAdClosed() { + // Arrange + molocoNativeAd.nativeAd = mockNativeAd + + // Act + molocoNativeAd.destroy() + + // Assert + verify(mockNativeAd).destroy() + assert(molocoNativeAd.nativeAd == null) { + "Expected nativeAd to be null after calling destroy" + } + } + + private fun createMediationNativeAdConfiguration(): MediationNativeAdConfiguration { + val serverParameters = bundleOf(MolocoMediationAdapter.KEY_AD_UNIT_ID to TEST_AD_UNIT) + return MediationNativeAdConfiguration( + context, + TEST_BID_RESPONSE, + serverParameters, + /*mediationExtras=*/ bundleOf(), + /*isTesting=*/ true, + /*location=*/ null, + RequestConfiguration.TAG_FOR_CHILD_DIRECTED_TREATMENT_UNSPECIFIED, + RequestConfiguration.TAG_FOR_UNDER_AGE_OF_CONSENT_UNSPECIFIED, + /*maxAdContentRating=*/ "", + TEST_WATERMARK, + /*p10=*/ null + ) + } + + private companion object { + const val TEST_AD_UNIT = "testAdUnit" + } +}