From e87be234af9fe0e89be026fdba90e9da9f34e4a1 Mon Sep 17 00:00:00 2001 From: Roo Date: Thu, 30 Jul 2026 16:45:17 -0400 Subject: [PATCH 01/16] feat: add Native ads bridge for Android and iOS Expose Appodeal Native (the only format missing from the RN plugin): AppodealAdType.NATIVE, getNativeAds/cacheNativeAds APIs, native events, and AppodealNative template view on both platforms. Co-authored-by: Cursor --- CHANGELOG.md | 15 ++ README.md | 43 +++- .../rnappodeal/RCTAppodealNativeView.kt | 124 ++++++++++++ .../rnappodeal/RNAppodealModuleImpl.kt | 36 ++++ .../rnappodeal/RNAppodealNativeAdStore.kt | 38 ++++ .../RNAppodealNativeViewManagerImpl.kt | 35 ++++ .../appodeal/rnappodeal/RNAppodealPackage.kt | 3 +- .../callbacks/RNAppodealNativeCallbacks.kt | 67 +++++++ .../rnappodeal/constants/NativeEvents.kt | 21 ++ .../rnappodeal/ext/AdTypeExtensions.kt | 3 + .../appodeal/rnappodeal/RNAppodealModule.kt | 21 ++ .../rnappodeal/RNAppodealNativeViewManager.kt | 43 ++++ .../appodeal/rnappodeal/RNAppodealModule.java | 26 +++ .../RNAppodealNativeViewManager.java | 62 ++++++ ios/RNADefines.h | 10 + ios/RNADefines.m | 16 ++ ios/RNAppodeal.mm | 55 ++++++ ios/RNAppodealNativeAdStore.h | 22 +++ ios/RNAppodealNativeAdStore.m | 185 ++++++++++++++++++ ios/RNAppodealNativeView.h | 19 ++ ios/RNAppodealNativeView.m | 157 +++++++++++++++ ios/RNAppodealNativeViewManager.h | 5 + ios/RNAppodealNativeViewManager.mm | 22 +++ package.json | 2 +- src/RNAppodeal.ts | 55 +++++- src/RNAppodealEvents.ts | 18 ++ src/RNAppodealNative.tsx | 27 +++ src/__tests__/types.test.ts | 5 + src/index.tsx | 5 + .../AppodealNativeViewNativeComponent.ts | 27 +++ src/specs/NativeAppodealModule.ts | 7 + src/types/AppodealAdTypes.ts | 25 +++ src/types/index.ts | 7 +- 33 files changed, 1200 insertions(+), 6 deletions(-) create mode 100644 android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt create mode 100644 android/src/main/java/com/appodeal/rnappodeal/RNAppodealNativeAdStore.kt create mode 100644 android/src/main/java/com/appodeal/rnappodeal/RNAppodealNativeViewManagerImpl.kt create mode 100644 android/src/main/java/com/appodeal/rnappodeal/callbacks/RNAppodealNativeCallbacks.kt create mode 100644 android/src/main/java/com/appodeal/rnappodeal/constants/NativeEvents.kt create mode 100644 android/src/newarch/com/appodeal/rnappodeal/RNAppodealNativeViewManager.kt create mode 100644 android/src/oldarch/com/appodeal/rnappodeal/RNAppodealNativeViewManager.java create mode 100644 ios/RNAppodealNativeAdStore.h create mode 100644 ios/RNAppodealNativeAdStore.m create mode 100644 ios/RNAppodealNativeView.h create mode 100644 ios/RNAppodealNativeView.m create mode 100644 ios/RNAppodealNativeViewManager.h create mode 100644 ios/RNAppodealNativeViewManager.mm create mode 100644 src/RNAppodealNative.tsx create mode 100644 src/specs/AppodealNativeViewNativeComponent.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 63a2640..8af1ede 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 4.3.0 + +### Features + +- **Native ads**: full React Native bridge for Appodeal Native (the previously + missing format). Adds `AppodealAdType.NATIVE`, module APIs + (`getNativeAds`, `getAvailableNativeAdsCount`, `destroyNativeAd`, + `cacheNativeAds`, `setPreferredNativeContentType`), + `AppodealNativeEvents`, and the `` view component + (templates: `newsFeed` / `appWall` / `contentStream`). +- Android: `Appodeal.NATIVE` type mapping, `NativeCallbacks`, ad store, + template `NativeAdView` registration. +- iOS: `APDNativeAdQueue` store, `getViewForPlacement` view binding, + native event constants. + ## 4.2.0 ### Features diff --git a/README.md b/README.md index ec9988d..a8973ff 100644 --- a/README.md +++ b/README.md @@ -546,7 +546,8 @@ Appodeal.initialize('YOUR_APPODEAL_APP_KEY', AppodealAdType.INTERSTITIAL | AppodealAdType.REWARDED_VIDEO | AppodealAdType.BANNER | - AppodealAdType.MREC + AppodealAdType.MREC | + AppodealAdType.NATIVE ); ``` @@ -558,6 +559,7 @@ Use the type codes below to set the preferred ad format: - `AppodealAdType.REWARDED_VIDEO` for rewarded videos. - `AppodealAdType.BANNER` for banners. - `AppodealAdType.MREC` for 300*250 banners. +- `AppodealAdType.NATIVE` for native ads. 2. Configure SDK @@ -818,6 +820,45 @@ import { AppodealMrec } from 'react-native-appodeal'; /> ``` +### Native ads + +Pull native ads from the SDK cache, then bind each ad id to ``: + +```javascript +import Appodeal, { + AppodealAdType, + AppodealNative, + AppodealNativeEvents, +} from 'react-native-appodeal'; + +// Include NATIVE in initialize(), then: +Appodeal.cacheNativeAds(3); + +Appodeal.addEventListener(AppodealNativeEvents.LOADED, () => { + const ads = Appodeal.getNativeAds(1); + // ads[0] => { id, title, description, callToAction, rating, containsVideo, predictedEcpm } +}); + +// Render a platform template for a pulled ad: + console.log('Native shown')} + onAdFailedToLoad={() => console.log('Native failed')} + onAdClicked={() => console.log('Native clicked')} + style={{ width: '100%', height: 300 }} +/> +``` + +Optional: + +```javascript +Appodeal.setPreferredNativeContentType('auto'); // 'auto' | 'noVideo' | 'video' +Appodeal.getAvailableNativeAdsCount(); +Appodeal.destroyNativeAd(adId); +``` + ## Privacy Policy and Consent > Note: Keep in mind that it's best to contact qualified legal professionals, if you haven't done so already, to get diff --git a/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt b/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt new file mode 100644 index 0000000..1be818c --- /dev/null +++ b/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt @@ -0,0 +1,124 @@ +package com.appodeal.rnappodeal + +import android.content.Context +import android.view.ViewGroup +import android.widget.FrameLayout +import com.appodeal.ads.nativead.NativeAdView +import com.appodeal.ads.nativead.NativeAdViewAppWall +import com.appodeal.ads.nativead.NativeAdViewContentStream +import com.appodeal.ads.nativead.NativeAdViewNewsFeed +import com.appodeal.rnappodeal.callbacks.RNAppodealEventHandler +import com.appodeal.rnappodeal.constants.NativeEvents +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReactContext +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.events.Event +import com.facebook.react.uimanager.events.EventDispatcher +import com.facebook.react.views.view.ReactViewGroup + +class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppodealEventHandler { + + private val surfaceId: Int by lazy { UIManagerHelper.getSurfaceId(reactContext) } + + var adId: String? = null + set(value) { + field = value + bindAd() + } + + var placement: String = "default" + set(value) { + field = value + bindAd() + } + + var template: String = "contentStream" + set(value) { + if (field != value) { + field = value + recreateNativeAdView() + } + } + + private var nativeAdView: NativeAdView? = null + + init { + recreateNativeAdView() + } + + fun cleanup() { + nativeAdView?.let { view -> + view.unregisterView() + view.destroy() + } + removeAllViews() + nativeAdView = null + } + + private fun recreateNativeAdView() { + cleanup() + val view = createNativeAdView() + nativeAdView = view + view.layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + addView(view) + bindAd() + } + + private fun createNativeAdView(): NativeAdView { + return when (template) { + "newsFeed" -> NativeAdViewNewsFeed(context) + "appWall" -> NativeAdViewAppWall(context) + else -> NativeAdViewContentStream(context) + } + } + + private fun bindAd() { + val id = adId ?: return + val ad = RNAppodealNativeAdStore.get(id) ?: return + val view = nativeAdView ?: return + view.registerView(ad, placement) + } + + override fun handleEvent(event: String, params: WritableMap?) { + when (event) { + NativeEvents.ON_NATIVE_LOADED -> dispatchFabricEvent(id, NativeEvents.ON_AD_LOADED, params) + NativeEvents.ON_NATIVE_FAILED_TO_LOAD -> dispatchFabricEvent(id, NativeEvents.ON_AD_FAILED_TO_LOAD, params) + NativeEvents.ON_NATIVE_SHOWN -> dispatchFabricEvent(id, NativeEvents.ON_AD_SHOWN, params) + NativeEvents.ON_NATIVE_CLICKED -> dispatchFabricEvent(id, NativeEvents.ON_AD_CLICKED, params) + NativeEvents.ON_NATIVE_EXPIRED -> dispatchFabricEvent(id, NativeEvents.ON_AD_EXPIRED, params) + else -> Unit + } + } + + private fun dispatchFabricEvent( + viewId: Int, + eventName: String, + params: WritableMap? + ) { + val dispatcher: EventDispatcher? = + UIManagerHelper.getEventDispatcherForReactTag(reactContext, viewId) + dispatcher?.dispatchEvent(OnViewEvent(surfaceId, viewId, eventName, params)) + } + + private class OnViewEvent( + surfaceId: Int, + viewId: Int, + private val eventNameParam: String, + private val payload: WritableMap? + ) : Event(surfaceId, viewId) { + override fun getEventName(): String = eventNameParam + override fun getEventData(): WritableMap? { + if (payload == null) return null + val copy = Arguments.createMap() + copy.merge(payload) + return copy + } + } + + private val reactContext: ReactContext + get() = context as ReactContext +} diff --git a/android/src/main/java/com/appodeal/rnappodeal/RNAppodealModuleImpl.kt b/android/src/main/java/com/appodeal/rnappodeal/RNAppodealModuleImpl.kt index 827e2d2..4ad39c3 100644 --- a/android/src/main/java/com/appodeal/rnappodeal/RNAppodealModuleImpl.kt +++ b/android/src/main/java/com/appodeal/rnappodeal/RNAppodealModuleImpl.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.content.Context import android.util.Log import com.appodeal.ads.Appodeal +import com.appodeal.ads.NativeMediaViewContentType import com.appodeal.ads.inapp.InAppPurchase import com.appodeal.ads.inapp.InAppPurchaseValidateCallback import com.appodeal.ads.service.ServiceError @@ -11,6 +12,7 @@ import com.appodeal.rnappodeal.callbacks.RNAppodealAdRevenueCallbacks import com.appodeal.rnappodeal.callbacks.RNAppodealBannerCallbacks import com.appodeal.rnappodeal.callbacks.RNAppodealInterstitialCallbacks import com.appodeal.rnappodeal.callbacks.RNAppodealMrecCallbacks +import com.appodeal.rnappodeal.callbacks.RNAppodealNativeCallbacks import com.appodeal.rnappodeal.callbacks.RNAppodealRewardedVideoCallbacks import com.appodeal.rnappodeal.ext.AdTypeExtensions.toAppodealTypes import com.appodeal.rnappodeal.ext.LogLevelExtensions.toLogLevel @@ -27,6 +29,7 @@ import com.facebook.react.bridge.LifecycleEventListener import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.WritableArray import com.facebook.react.bridge.WritableMap import java.lang.ref.WeakReference @@ -42,6 +45,7 @@ internal class RNAppodealModuleImpl( private val mrecCallbacks by lazy { RNAppodealMrecCallbacks(eventDispatcher) } private val rewardedVideoCallbacks by lazy { RNAppodealRewardedVideoCallbacks(eventDispatcher) } private val adRevenueCallbacks by lazy { RNAppodealAdRevenueCallbacks(eventDispatcher) } + private val nativeCallbacks by lazy { RNAppodealNativeCallbacks(eventDispatcher) } // Consent handler private val consentHandler by lazy { RNAppodealConsent() } @@ -114,6 +118,7 @@ internal class RNAppodealModuleImpl( Appodeal.setBannerCallbacks(bannerCallbacks) Appodeal.setMrecCallbacks(mrecCallbacks) Appodeal.setRewardedVideoCallbacks(rewardedVideoCallbacks) + Appodeal.setNativeCallbacks(nativeCallbacks) Appodeal.setAdRevenueCallbacks(adRevenueCallbacks) // Set up Appodeal options @@ -388,6 +393,37 @@ internal class RNAppodealModuleImpl( Appodeal.logEvent(name, parameters.toMap()) } + fun getNativeAds(count: Double): WritableArray { + val ads = Appodeal.getNativeAds(count.toInt()) + val storedAds = RNAppodealNativeAdStore.putAds(ads) + return Arguments.createArray().apply { + storedAds.forEach { pushMap(it) } + } + } + + fun getAvailableNativeAdsCount(): Double { + return Appodeal.getAvailableNativeAdsCount().toDouble() + } + + fun destroyNativeAd(adId: String) { + RNAppodealNativeAdStore.remove(adId) + } + + fun cacheNativeAds(count: Double) { + withActivity("cacheNativeAds") { activity -> + Appodeal.cache(activity, Appodeal.NATIVE, count.toInt().coerceIn(1, 5)) + } + } + + fun setPreferredNativeContentType(type: String) { + val contentType = when (type) { + "noVideo" -> NativeMediaViewContentType.NoVideo + "video" -> NativeMediaViewContentType.Video + else -> NativeMediaViewContentType.Auto + } + Appodeal.setPreferredNativeContentType(contentType) + } + // MARK: - Event Management fun eventsNotifyReady(ready: Boolean) { diff --git a/android/src/main/java/com/appodeal/rnappodeal/RNAppodealNativeAdStore.kt b/android/src/main/java/com/appodeal/rnappodeal/RNAppodealNativeAdStore.kt new file mode 100644 index 0000000..2cfcd4a --- /dev/null +++ b/android/src/main/java/com/appodeal/rnappodeal/RNAppodealNativeAdStore.kt @@ -0,0 +1,38 @@ +package com.appodeal.rnappodeal + +import com.appodeal.ads.NativeAd +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +internal object RNAppodealNativeAdStore { + + private val ads = ConcurrentHashMap() + + fun putAds(ads: List): List { + return ads.map { ad -> + val id = UUID.randomUUID().toString() + this.ads[id] = ad + ad.toWritableMap(id) + } + } + + fun get(id: String): NativeAd? = ads[id] + + fun remove(id: String) { + ads.remove(id) + } + + private fun NativeAd.toWritableMap(id: String): WritableMap { + return Arguments.createMap().apply { + putString("id", id) + putString("title", title.orEmpty()) + putString("description", description.orEmpty()) + putString("callToAction", callToAction.orEmpty()) + putDouble("rating", rating.toDouble()) + putBoolean("containsVideo", containsVideo()) + putDouble("predictedEcpm", predictedEcpm) + } + } +} diff --git a/android/src/main/java/com/appodeal/rnappodeal/RNAppodealNativeViewManagerImpl.kt b/android/src/main/java/com/appodeal/rnappodeal/RNAppodealNativeViewManagerImpl.kt new file mode 100644 index 0000000..7b6eeb5 --- /dev/null +++ b/android/src/main/java/com/appodeal/rnappodeal/RNAppodealNativeViewManagerImpl.kt @@ -0,0 +1,35 @@ +package com.appodeal.rnappodeal + +import com.appodeal.rnappodeal.callbacks.RNAppodealEventHandlerManager +import com.appodeal.rnappodeal.constants.NativeEvents +import com.facebook.react.uimanager.ThemedReactContext + +internal class RNAppodealNativeViewManagerImpl( + private val handlerManager: RNAppodealEventHandlerManager = RNAppodealEventHandlerManager +) { + + fun createViewInstance(reactContext: ThemedReactContext): RCTAppodealNativeView { + val nativeView = RCTAppodealNativeView(reactContext) + handlerManager.registerHandler(nativeView) + return nativeView + } + + fun onDropViewInstance(view: RCTAppodealNativeView) { + view.cleanup() + handlerManager.unregisterHandler(view) + } + + fun getExportedCustomDirectEventTypeConstants(): Map? { + return mapOf( + NativeEvents.ON_AD_LOADED to mapOf("registrationName" to NativeEvents.ON_AD_LOADED), + NativeEvents.ON_AD_FAILED_TO_LOAD to mapOf("registrationName" to NativeEvents.ON_AD_FAILED_TO_LOAD), + NativeEvents.ON_AD_SHOWN to mapOf("registrationName" to NativeEvents.ON_AD_SHOWN), + NativeEvents.ON_AD_CLICKED to mapOf("registrationName" to NativeEvents.ON_AD_CLICKED), + NativeEvents.ON_AD_EXPIRED to mapOf("registrationName" to NativeEvents.ON_AD_EXPIRED) + ) + } + + companion object Companion { + const val NAME = "RNAppodealNativeView" + } +} diff --git a/android/src/main/java/com/appodeal/rnappodeal/RNAppodealPackage.kt b/android/src/main/java/com/appodeal/rnappodeal/RNAppodealPackage.kt index 2611291..40406b1 100644 --- a/android/src/main/java/com/appodeal/rnappodeal/RNAppodealPackage.kt +++ b/android/src/main/java/com/appodeal/rnappodeal/RNAppodealPackage.kt @@ -20,7 +20,8 @@ class RNAppodealPackage : BaseReactPackage() { reactContext: ReactApplicationContext ): List> = listOf( RNAppodealBannerViewManager(), - RNAppodealMrecViewManager() + RNAppodealMrecViewManager(), + RNAppodealNativeViewManager() ) override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { diff --git a/android/src/main/java/com/appodeal/rnappodeal/callbacks/RNAppodealNativeCallbacks.kt b/android/src/main/java/com/appodeal/rnappodeal/callbacks/RNAppodealNativeCallbacks.kt new file mode 100644 index 0000000..3dc5a92 --- /dev/null +++ b/android/src/main/java/com/appodeal/rnappodeal/callbacks/RNAppodealNativeCallbacks.kt @@ -0,0 +1,67 @@ +package com.appodeal.rnappodeal.callbacks + +import com.appodeal.ads.NativeAd +import com.appodeal.ads.NativeCallbacks +import com.appodeal.rnappodeal.RNAEventDispatcher +import com.appodeal.rnappodeal.constants.NativeEvents +import com.facebook.react.bridge.Arguments + +/** + * Callback handler for Appodeal native ad events. + * + * This class implements the NativeCallbacks interface and handles native ad lifecycle events + * including loading, showing, clicking, and expiration. It forwards events to both: + * + * 1. **Event Dispatcher**: For advanced event management with priority handling + * 2. **Event Handler Manager**: For Fabric/New Architecture event dispatching + * + * @param eventDispatcher The RNAEventDispatcher instance used for advanced event management + * @param handlers The event handler manager that forwards events to registered native views + */ +internal class RNAppodealNativeCallbacks( + private val eventDispatcher: RNAEventDispatcher, + private val handlers: RNAppodealEventHandler = RNAppodealEventHandlerManager +) : NativeCallbacks { + + override fun onNativeLoaded() { + eventDispatcher.dispatchEvent(NativeEvents.ON_NATIVE_LOADED, null) + handlers.handleEvent(NativeEvents.ON_NATIVE_LOADED, null) + } + + override fun onNativeFailedToLoad() { + eventDispatcher.dispatchEvent(NativeEvents.ON_NATIVE_FAILED_TO_LOAD, null) + handlers.handleEvent(NativeEvents.ON_NATIVE_FAILED_TO_LOAD, null) + } + + override fun onNativeShown(nativeAd: NativeAd) { + val params = createNativeAdParams(nativeAd) + eventDispatcher.dispatchEvent(NativeEvents.ON_NATIVE_SHOWN, params) + handlers.handleEvent(NativeEvents.ON_NATIVE_SHOWN, params) + } + + override fun onNativeShowFailed(nativeAd: NativeAd) { + val params = createNativeAdParams(nativeAd) + eventDispatcher.dispatchEvent(NativeEvents.ON_NATIVE_SHOW_FAILED, params) + handlers.handleEvent(NativeEvents.ON_NATIVE_SHOW_FAILED, params) + } + + override fun onNativeClicked(nativeAd: NativeAd) { + val params = createNativeAdParams(nativeAd) + eventDispatcher.dispatchEvent(NativeEvents.ON_NATIVE_CLICKED, params) + handlers.handleEvent(NativeEvents.ON_NATIVE_CLICKED, params) + } + + override fun onNativeExpired() { + eventDispatcher.dispatchEvent(NativeEvents.ON_NATIVE_EXPIRED, null) + handlers.handleEvent(NativeEvents.ON_NATIVE_EXPIRED, null) + } + + private fun createNativeAdParams(nativeAd: NativeAd) = Arguments.createMap().apply { + putString("title", nativeAd.title ?: "") + putString("description", nativeAd.description ?: "") + putString("callToAction", nativeAd.callToAction ?: "") + putDouble("rating", nativeAd.rating.toDouble()) + putBoolean("containsVideo", nativeAd.containsVideo()) + putDouble("predictedEcpm", nativeAd.predictedEcpm) + } +} diff --git a/android/src/main/java/com/appodeal/rnappodeal/constants/NativeEvents.kt b/android/src/main/java/com/appodeal/rnappodeal/constants/NativeEvents.kt new file mode 100644 index 0000000..4f74238 --- /dev/null +++ b/android/src/main/java/com/appodeal/rnappodeal/constants/NativeEvents.kt @@ -0,0 +1,21 @@ +package com.appodeal.rnappodeal.constants + +/** + * Constants for native ad events. + * Centralized event names that can be reused across the codebase. + */ +internal object NativeEvents { + const val ON_NATIVE_LOADED = "onNativeLoaded" + const val ON_NATIVE_FAILED_TO_LOAD = "onNativeFailedToLoad" + const val ON_NATIVE_SHOWN = "onNativeShown" + const val ON_NATIVE_SHOW_FAILED = "onNativeShowFailed" + const val ON_NATIVE_CLICKED = "onNativeClicked" + const val ON_NATIVE_EXPIRED = "onNativeExpired" + + // React Native event registration names + const val ON_AD_LOADED = "onAdLoaded" + const val ON_AD_FAILED_TO_LOAD = "onAdFailedToLoad" + const val ON_AD_CLICKED = "onAdClicked" + const val ON_AD_EXPIRED = "onAdExpired" + const val ON_AD_SHOWN = "onAdShown" +} diff --git a/android/src/main/java/com/appodeal/rnappodeal/ext/AdTypeExtensions.kt b/android/src/main/java/com/appodeal/rnappodeal/ext/AdTypeExtensions.kt index 11d81db..8a0e3d2 100644 --- a/android/src/main/java/com/appodeal/rnappodeal/ext/AdTypeExtensions.kt +++ b/android/src/main/java/com/appodeal/rnappodeal/ext/AdTypeExtensions.kt @@ -24,6 +24,7 @@ internal object AdTypeExtensions { if (types.hasFlag(BANNER_TOP_FLAG)) result = result or Appodeal.BANNER_TOP if (types.hasFlag(REWARDED_VIDEO_FLAG)) result = result or Appodeal.REWARDED_VIDEO if (types.hasFlag(MREC_FLAG)) result = result or Appodeal.MREC + if (types.hasFlag(NATIVE_FLAG)) result = result or Appodeal.NATIVE return result } @@ -42,6 +43,7 @@ internal object AdTypeExtensions { if (this.hasFlag(Appodeal.BANNER_TOP)) result = result or BANNER_TOP_FLAG if (this.hasFlag(Appodeal.REWARDED_VIDEO)) result = result or REWARDED_VIDEO_FLAG if (this.hasFlag(Appodeal.MREC)) result = result or MREC_FLAG + if (this.hasFlag(Appodeal.NATIVE)) result = result or NATIVE_FLAG return result } @@ -61,4 +63,5 @@ internal object AdTypeExtensions { private const val BANNER_TOP_FLAG = 1 shl 4 private const val REWARDED_VIDEO_FLAG = 1 shl 5 private const val MREC_FLAG = 1 shl 8 + private const val NATIVE_FLAG = 1 shl 7 } \ No newline at end of file diff --git a/android/src/newarch/com/appodeal/rnappodeal/RNAppodealModule.kt b/android/src/newarch/com/appodeal/rnappodeal/RNAppodealModule.kt index 0995c4b..cf9aa8a 100644 --- a/android/src/newarch/com/appodeal/rnappodeal/RNAppodealModule.kt +++ b/android/src/newarch/com/appodeal/rnappodeal/RNAppodealModule.kt @@ -3,6 +3,7 @@ package com.appodeal.rnappodeal import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.WritableArray import com.facebook.react.bridge.WritableMap import com.facebook.react.module.annotations.ReactModule @@ -197,6 +198,26 @@ class RNAppodealModule( moduleImplementation.trackEvent(name, parameters) } + override fun getNativeAds(count: Double): WritableArray { + return moduleImplementation.getNativeAds(count) + } + + override fun getAvailableNativeAdsCount(): Double { + return moduleImplementation.getAvailableNativeAdsCount() + } + + override fun destroyNativeAd(adId: String) { + moduleImplementation.destroyNativeAd(adId) + } + + override fun cacheNativeAds(count: Double) { + moduleImplementation.cacheNativeAds(count) + } + + override fun setPreferredNativeContentType(type: String) { + moduleImplementation.setPreferredNativeContentType(type) + } + override fun eventsNotifyReady(ready: Boolean) { moduleImplementation.eventsNotifyReady(ready) } diff --git a/android/src/newarch/com/appodeal/rnappodeal/RNAppodealNativeViewManager.kt b/android/src/newarch/com/appodeal/rnappodeal/RNAppodealNativeViewManager.kt new file mode 100644 index 0000000..fd6b77c --- /dev/null +++ b/android/src/newarch/com/appodeal/rnappodeal/RNAppodealNativeViewManager.kt @@ -0,0 +1,43 @@ +package com.appodeal.rnappodeal + +import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.uimanager.SimpleViewManager +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.annotations.ReactProp + +@ReactModule(name = RNAppodealNativeViewManagerImpl.NAME) +class RNAppodealNativeViewManager : + SimpleViewManager() { + + private val nativeViewManagerImpl = RNAppodealNativeViewManagerImpl() + + override fun getName(): String = RNAppodealNativeViewManagerImpl.NAME + + override fun createViewInstance(reactContext: ThemedReactContext): RCTAppodealNativeView { + return nativeViewManagerImpl.createViewInstance(reactContext) + } + + override fun onDropViewInstance(view: RCTAppodealNativeView) { + super.onDropViewInstance(view) + nativeViewManagerImpl.onDropViewInstance(view) + } + + override fun getExportedCustomDirectEventTypeConstants(): Map? { + return nativeViewManagerImpl.getExportedCustomDirectEventTypeConstants() + } + + @ReactProp(name = "adId") + fun setAdId(view: RCTAppodealNativeView, value: String?) { + view.adId = value + } + + @ReactProp(name = "placement") + fun setPlacement(view: RCTAppodealNativeView, value: String?) { + value?.let { view.placement = it } + } + + @ReactProp(name = "template") + fun setTemplate(view: RCTAppodealNativeView, value: String?) { + value?.let { view.template = it } + } +} diff --git a/android/src/oldarch/com/appodeal/rnappodeal/RNAppodealModule.java b/android/src/oldarch/com/appodeal/rnappodeal/RNAppodealModule.java index d0a9f6b..d603540 100644 --- a/android/src/oldarch/com/appodeal/rnappodeal/RNAppodealModule.java +++ b/android/src/oldarch/com/appodeal/rnappodeal/RNAppodealModule.java @@ -7,6 +7,7 @@ import com.facebook.react.bridge.ReactContextBaseJavaModule; import com.facebook.react.bridge.ReactMethod; import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.WritableArray; import com.facebook.react.bridge.WritableMap; public class RNAppodealModule extends ReactContextBaseJavaModule { @@ -239,6 +240,31 @@ public void trackEvent(String name, ReadableMap parameters) { moduleImplementation.trackEvent(name, parameters); } + @ReactMethod(isBlockingSynchronousMethod = true) + public WritableArray getNativeAds(double count) { + return moduleImplementation.getNativeAds(count); + } + + @ReactMethod(isBlockingSynchronousMethod = true) + public double getAvailableNativeAdsCount() { + return moduleImplementation.getAvailableNativeAdsCount(); + } + + @ReactMethod + public void destroyNativeAd(String adId) { + moduleImplementation.destroyNativeAd(adId); + } + + @ReactMethod + public void cacheNativeAds(double count) { + moduleImplementation.cacheNativeAds(count); + } + + @ReactMethod + public void setPreferredNativeContentType(String type) { + moduleImplementation.setPreferredNativeContentType(type); + } + @ReactMethod public void setBidonEndpoint(String endpoint) { moduleImplementation.setBidonEndpoint(endpoint); diff --git a/android/src/oldarch/com/appodeal/rnappodeal/RNAppodealNativeViewManager.java b/android/src/oldarch/com/appodeal/rnappodeal/RNAppodealNativeViewManager.java new file mode 100644 index 0000000..b601114 --- /dev/null +++ b/android/src/oldarch/com/appodeal/rnappodeal/RNAppodealNativeViewManager.java @@ -0,0 +1,62 @@ +package com.appodeal.rnappodeal; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.facebook.react.uimanager.SimpleViewManager; +import com.facebook.react.uimanager.ThemedReactContext; +import com.facebook.react.uimanager.annotations.ReactProp; + +import java.util.Map; + +public class RNAppodealNativeViewManager extends SimpleViewManager { + + private final RNAppodealNativeViewManagerImpl nativeViewManagerImpl; + + public RNAppodealNativeViewManager() { + this.nativeViewManagerImpl = new RNAppodealNativeViewManagerImpl(); + } + + @NonNull + @Override + public String getName() { + return RNAppodealNativeViewManagerImpl.NAME; + } + + @NonNull + @Override + protected RCTAppodealNativeView createViewInstance(@NonNull ThemedReactContext reactContext) { + return nativeViewManagerImpl.createViewInstance(reactContext); + } + + @Override + public void onDropViewInstance(@NonNull RCTAppodealNativeView view) { + super.onDropViewInstance(view); + nativeViewManagerImpl.onDropViewInstance(view); + } + + @Nullable + @Override + public Map getExportedCustomDirectEventTypeConstants() { + return nativeViewManagerImpl.getExportedCustomDirectEventTypeConstants(); + } + + @ReactProp(name = "adId") + public void setAdId(RCTAppodealNativeView view, @Nullable String value) { + view.setAdId(value); + } + + @ReactProp(name = "placement") + public void setPlacement(RCTAppodealNativeView view, @Nullable String value) { + if (value != null) { + view.setPlacement(value); + } + } + + @ReactProp(name = "template") + public void setTemplate(RCTAppodealNativeView view, @Nullable String value) { + if (value != null) { + view.setTemplate(value); + } + } +} diff --git a/ios/RNADefines.h b/ios/RNADefines.h index d8da48d..b992c37 100644 --- a/ios/RNADefines.h +++ b/ios/RNADefines.h @@ -37,6 +37,16 @@ FOUNDATION_EXPORT NSString *const kEventInterstitialShown; FOUNDATION_EXPORT NSString *const kEventInterstitialClosed; FOUNDATION_EXPORT NSString *const kEventInterstitialClicked; +/** + * Native ad events + */ +FOUNDATION_EXPORT NSString *const kEventNativeLoaded; +FOUNDATION_EXPORT NSString *const kEventNativeFailedToLoad; +FOUNDATION_EXPORT NSString *const kEventNativeShown; +FOUNDATION_EXPORT NSString *const kEventNativeShowFailed; +FOUNDATION_EXPORT NSString *const kEventNativeClicked; +FOUNDATION_EXPORT NSString *const kEventNativeExpired; + /** * Rewarded video ad events */ diff --git a/ios/RNADefines.m b/ios/RNADefines.m index 7e0e180..6640494 100644 --- a/ios/RNADefines.m +++ b/ios/RNADefines.m @@ -27,6 +27,14 @@ NSString *const kEventInterstitialClosed = @"onInterstitialClosed"; NSString *const kEventInterstitialClicked = @"onInterstitialClicked"; +// Native ad events +NSString *const kEventNativeLoaded = @"onNativeLoaded"; +NSString *const kEventNativeFailedToLoad = @"onNativeFailedToLoad"; +NSString *const kEventNativeShown = @"onNativeShown"; +NSString *const kEventNativeShowFailed = @"onNativeShowFailed"; +NSString *const kEventNativeClicked = @"onNativeClicked"; +NSString *const kEventNativeExpired = @"onNativeExpired"; + // Rewarded video ad events NSString *const kEventRewardedVideoLoaded = @"onRewardedVideoLoaded"; NSString *const kEventRewardedVideoFailedToLoad = @"onRewardedVideoFailedToLoad"; @@ -101,6 +109,14 @@ + (RNAAdType)RNAAdType:(id)json RCT_DYNAMIC { kEventInterstitialExpired, kEventInterstitialClosed, kEventInterstitialClicked, + + // Native events + kEventNativeLoaded, + kEventNativeFailedToLoad, + kEventNativeShown, + kEventNativeShowFailed, + kEventNativeClicked, + kEventNativeExpired, // Rewarded video events kEventRewardedVideoLoaded, diff --git a/ios/RNAppodeal.mm b/ios/RNAppodeal.mm index 9281296..9b95338 100644 --- a/ios/RNAppodeal.mm +++ b/ios/RNAppodeal.mm @@ -2,6 +2,7 @@ #import "RNADefines.h" #import "RNAEventDispatcher.h" #import "RNAppodealBannerView.h" +#import "RNAppodealNativeAdStore.h" #import #import @@ -58,6 +59,10 @@ - (void)initializeWithAppKey:(NSString *)appKey [Appodeal initializeWithApiKey:appKey types:AppodealAdTypeFromRNAAdType(adTypes)]; + + if (((NSInteger)adTypes & RNAAdTypeNative) > 0) { + [[RNAppodealNativeAdStore shared] ensureQueue]; + } } #ifndef RCT_NEW_ARCH_ENABLED @@ -86,6 +91,9 @@ - (void)initializeWithAppKey:(NSString *)appKey if ((NSInteger)showType == RNAAdTypeMREC) { return @([RNAppodealMrecView isActiveMrecReady]); } + if ((NSInteger)showType == RNAAdTypeNative) { + return @([[RNAppodealNativeAdStore shared] availableCount] > 0); + } BOOL isLoaded = [Appodeal isReadyForShowWithStyle:AppodealShowStyleFromRNAAdType(showType)]; return @(isLoaded); } @@ -119,6 +127,28 @@ - (void)initializeWithAppKey:(NSString *)appKey return @(canShow); } +#pragma mark - Native Ads + +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(getNativeAds:(double)count) { + return [[RNAppodealNativeAdStore shared] getNativeAds:(NSInteger)count]; +} + +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(getAvailableNativeAdsCount) { + return @([[RNAppodealNativeAdStore shared] availableCount]); +} + +RCT_EXPORT_METHOD(destroyNativeAd:(NSString *)adId) { + [[RNAppodealNativeAdStore shared] destroyAdId:adId]; +} + +RCT_EXPORT_METHOD(cacheNativeAds:(double)count) { + [[RNAppodealNativeAdStore shared] cacheNativeAds:(NSInteger)count]; +} + +RCT_EXPORT_METHOD(setPreferredNativeContentType:(NSString *)type) { + [[RNAppodealNativeAdStore shared] setPreferredType:type]; +} + #pragma mark - Banner Settings RCT_EXPORT_METHOD(setTabletBanners:(BOOL)value) { @@ -440,6 +470,9 @@ - (NSNumber *)isLoaded:(double)showType { if ((NSInteger)showType == RNAAdTypeMREC) { return @([RNAppodealMrecView isActiveMrecReady]); } + if ((NSInteger)showType == RNAAdTypeNative) { + return @([[RNAppodealNativeAdStore shared] availableCount] > 0); + } BOOL isLoaded = [Appodeal isReadyForShowWithStyle:AppodealShowStyleFromRNAAdType(showType)]; return @(isLoaded); } @@ -472,6 +505,28 @@ - (NSNumber *)isPrecache:(double)adTypes { return @([Appodeal isPrecacheAd:AppodealAdTypeFromRNAAdType(adTypes)]); } +#pragma mark - Native Ads + +- (NSArray *)getNativeAds:(double)count { + return [[RNAppodealNativeAdStore shared] getNativeAds:(NSInteger)count]; +} + +- (NSNumber *)getAvailableNativeAdsCount { + return @([[RNAppodealNativeAdStore shared] availableCount]); +} + +- (void)destroyNativeAd:(NSString *)adId { + [[RNAppodealNativeAdStore shared] destroyAdId:adId]; +} + +- (void)cacheNativeAds:(double)count { + [[RNAppodealNativeAdStore shared] cacheNativeAds:(NSInteger)count]; +} + +- (void)setPreferredNativeContentType:(NSString *)type { + [[RNAppodealNativeAdStore shared] setPreferredType:type]; +} + #pragma mark - Banner Settings - (void)setTabletBanners:(BOOL)value { diff --git a/ios/RNAppodealNativeAdStore.h b/ios/RNAppodealNativeAdStore.h new file mode 100644 index 0000000..7d9eab5 --- /dev/null +++ b/ios/RNAppodealNativeAdStore.h @@ -0,0 +1,22 @@ +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface RNAppodealNativeAdStore : NSObject + ++ (instancetype)shared; + +- (void)ensureQueue; +- (NSArray *)getNativeAds:(NSInteger)count; +- (NSInteger)availableCount; +- (nullable APDNativeAd *)nativeAdForId:(NSString *)adId; +- (void)destroyAdId:(NSString *)adId; +- (void)setPreferredType:(NSString *)type; +- (void)setAdViewTemplateClass:(Class)templateClass; +- (void)cacheNativeAds:(NSInteger)count; +- (void)emitNativeEvent:(NSString *)eventName payload:(nullable NSDictionary *)payload; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/RNAppodealNativeAdStore.m b/ios/RNAppodealNativeAdStore.m new file mode 100644 index 0000000..7a7c458 --- /dev/null +++ b/ios/RNAppodealNativeAdStore.m @@ -0,0 +1,185 @@ +#import "RNAppodealNativeAdStore.h" +#import "RNADefines.h" +#import "RNAEventDispatcher.h" + +@interface RNAppodealNativeAdStore () + +@property (nonatomic, strong, nullable) APDNativeAdQueue *queue; +@property (nonatomic, strong) NSMutableDictionary *adsById; +@property (nonatomic, copy) NSString *preferredContentType; + +@end + +@implementation RNAppodealNativeAdStore + ++ (instancetype)shared { + static RNAppodealNativeAdStore *sharedInstance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + sharedInstance = [[self alloc] init]; + }); + return sharedInstance; +} + +- (instancetype)init { + self = [super init]; + if (self) { + _adsById = [NSMutableDictionary dictionary]; + _preferredContentType = @"auto"; + } + return self; +} + +#pragma mark - Queue lifecycle + +- (void)ensureQueue { + if (self.queue != nil) { + return; + } + + APDNativeAdQueue *queue = [[APDNativeAdQueue alloc] init]; + if ([APDNativeAdSettings respondsToSelector:@selector(defaultSettings)]) { + queue.settings = [APDNativeAdSettings defaultSettings]; + } else if ([APDNativeAdSettings respondsToSelector:@selector(default)]) { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + queue.settings = [APDNativeAdSettings performSelector:@selector(default)]; +#pragma clang diagnostic pop + } else { + queue.settings = [[APDNativeAdSettings alloc] init]; + } + queue.settings.adViewClass = APDDefaultNativeAdView.class; + queue.settings.type = [self nativeAdTypeFromPreferredContentType:self.preferredContentType]; + queue.delegate = self; + + self.queue = queue; + [self.queue loadAd]; +} + +- (APDNativeAdType)nativeAdTypeFromPreferredContentType:(NSString *)type { + if ([type isEqualToString:@"noVideo"]) { + return APDNativeAdTypeNoVideo; + } + if ([type isEqualToString:@"video"]) { + return APDNativeAdTypeVideo; + } + return APDNativeAdTypeAuto; +} + +- (void)setPreferredType:(NSString *)type { + self.preferredContentType = type ?: @"auto"; + + if (self.queue != nil) { + self.queue.settings.type = [self nativeAdTypeFromPreferredContentType:self.preferredContentType]; + } +} + +- (void)setAdViewTemplateClass:(Class)templateClass { + [self ensureQueue]; + + if (templateClass != Nil) { + self.queue.settings.adViewClass = templateClass; + } else { + self.queue.settings.adViewClass = APDDefaultNativeAdView.class; + } +} + +- (void)cacheNativeAds:(NSInteger)count { + [self ensureQueue]; + + if ([self.queue respondsToSelector:@selector(setMaxAdSize:)]) { + [self.queue setMaxAdSize:count]; + } + + [self.queue loadAd]; +} + +#pragma mark - Ad access + +- (NSInteger)availableCount { + if (self.queue == nil) { + return 0; + } + + if ([self.queue respondsToSelector:@selector(availableAdsCount)]) { + return self.queue.availableAdsCount; + } + + if ([self.queue respondsToSelector:@selector(currentAdCount)]) { + return self.queue.currentAdCount; + } + + return (NSInteger)[Appodeal availableNativeAdsCount]; +} + +- (NSArray *)getNativeAds:(NSInteger)count { + [self ensureQueue]; + + if (count <= 0) { + return @[]; + } + + NSArray *nativeAds = [self.queue getNativeAdsOfCount:count]; + NSMutableArray *result = [NSMutableArray arrayWithCapacity:nativeAds.count]; + + for (APDNativeAd *nativeAd in nativeAds) { + NSString *adId = [[NSUUID UUID] UUIDString]; + self.adsById[adId] = nativeAd; + + NSMutableDictionary *payload = [NSMutableDictionary dictionary]; + payload[@"id"] = adId; + payload[@"title"] = nativeAd.title ?: @""; + payload[@"description"] = nativeAd.descriptionText ?: @""; + payload[@"callToAction"] = nativeAd.callToActionText ?: @""; + payload[@"rating"] = nativeAd.starRating ?: @0; + payload[@"containsVideo"] = @(nativeAd.containsVideo); + payload[@"predictedEcpm"] = @([Appodeal predictedEcpmForAdType:AppodealAdTypeNativeAd]); + + [result addObject:payload]; + } + + return result; +} + +- (APDNativeAd *)nativeAdForId:(NSString *)adId { + if (adId.length == 0) { + return nil; + } + return self.adsById[adId]; +} + +- (void)destroyAdId:(NSString *)adId { + if (adId.length == 0) { + return; + } + + APDNativeAd *nativeAd = self.adsById[adId]; + if (nativeAd != nil) { + [nativeAd detachFromView]; + nativeAd.delegate = nil; + } + + [self.adsById removeObjectForKey:adId]; +} + +#pragma mark - Events + +- (void)emitNativeEvent:(NSString *)eventName payload:(nullable NSDictionary *)payload { + [[RNAEventDispatcher sharedDispatcher] dispatchEvent:eventName payload:payload]; +} + +#pragma mark - APDNativeAdQueueDelegate + +- (void)adQueueAdIsAvailable:(APDNativeAdQueue *)adQueue ofCount:(NSInteger)count { + [self emitNativeEvent:kEventNativeLoaded payload:@{@"count": @(count)}]; +} + +- (void)adQueue:(APDNativeAdQueue *)adQueue failedWithError:(NSError *)error { + NSMutableDictionary *payload = [NSMutableDictionary dictionary]; + if (error.localizedDescription.length > 0) { + payload[@"message"] = error.localizedDescription; + } + [self emitNativeEvent:kEventNativeFailedToLoad payload:payload.count > 0 ? payload : nil]; +} + +@end diff --git a/ios/RNAppodealNativeView.h b/ios/RNAppodealNativeView.h new file mode 100644 index 0000000..67ca964 --- /dev/null +++ b/ios/RNAppodealNativeView.h @@ -0,0 +1,19 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface RNAppodealNativeView : RCTView + +@property (nonatomic, copy, nullable) NSString *adId; +@property (nonatomic, copy) NSString *placement; +@property (nonatomic, copy, nullable) NSString *templateName; + +@property (nonatomic, copy) RCTBubblingEventBlock onAdLoaded; +@property (nonatomic, copy) RCTBubblingEventBlock onAdFailedToLoad; +@property (nonatomic, copy) RCTBubblingEventBlock onAdClicked; +@property (nonatomic, copy) RCTBubblingEventBlock onAdExpired; +@property (nonatomic, copy) RCTBubblingEventBlock onAdShown; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/RNAppodealNativeView.m b/ios/RNAppodealNativeView.m new file mode 100644 index 0000000..ef128b2 --- /dev/null +++ b/ios/RNAppodealNativeView.m @@ -0,0 +1,157 @@ +#import "RNAppodealNativeView.h" +#import "RNAppodealNativeAdStore.h" +#import "RNADefines.h" + +#import +#import +#import + +@interface RNAppodealNativeView () + +@property (nonatomic, strong, nullable) APDNativeAd *nativeAd; +@property (nonatomic, strong, nullable) UIView *adContentView; + +@end + +@implementation RNAppodealNativeView + +- (instancetype)initWithFrame:(CGRect)frame { + if (self = [super initWithFrame:frame]) { + self.backgroundColor = UIColor.clearColor; + _placement = @"default"; + } + return self; +} + +- (void)setAdId:(NSString *)adId { + if ([_adId isEqualToString:adId]) { + return; + } + + _adId = [adId copy]; + [self reloadNativeAdView]; +} + +- (void)setPlacement:(NSString *)placement { + _placement = placement.length > 0 ? [placement copy] : @"default"; + + if (self.adContentView != nil) { + [self reloadNativeAdView]; + } +} + +- (void)setTemplateName:(NSString *)templateName { + _templateName = [templateName copy]; + + if (self.adContentView != nil) { + [self reloadNativeAdView]; + } +} + +- (void)reloadNativeAdView { + [self clearNativeAdView]; + + if (self.adId.length == 0) { + return; + } + + NSAssert([Appodeal isInitializedForAdType:AppodealAdTypeNativeAd], + @"Appodeal should be initialised with AppodealAdTypeNativeAd before trying to add RNAppodealNativeView in hierarchy"); + + APDNativeAd *nativeAd = [[RNAppodealNativeAdStore shared] nativeAdForId:self.adId]; + if (nativeAd == nil) { + if (self.onAdFailedToLoad) { + self.onAdFailedToLoad(@{}); + } + [[RNAppodealNativeAdStore shared] emitNativeEvent:kEventNativeShowFailed payload:@{@"adId": self.adId}]; + return; + } + + [self applyTemplateIfNeeded]; + + self.nativeAd = nativeAd; + self.nativeAd.delegate = self; + + UIViewController *rootViewController = RCTPresentedViewController(); + NSError *error = nil; + UIView *adView = [nativeAd getViewForPlacement:self.placement + withRootViewController:rootViewController + error:&error]; + + if (adView == nil || error != nil) { + if (self.onAdFailedToLoad) { + self.onAdFailedToLoad(@{}); + } + + NSMutableDictionary *payload = [NSMutableDictionary dictionaryWithObject:self.adId forKey:@"adId"]; + if (error.localizedDescription.length > 0) { + payload[@"message"] = error.localizedDescription; + } + [[RNAppodealNativeAdStore shared] emitNativeEvent:kEventNativeShowFailed payload:payload]; + return; + } + + self.adContentView = adView; + self.adContentView.frame = self.bounds; + self.adContentView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + [self addSubview:self.adContentView]; + + if (self.onAdLoaded) { + self.onAdLoaded(@{@"adId": self.adId}); + } +} + +- (void)applyTemplateIfNeeded { + [[RNAppodealNativeAdStore shared] ensureQueue]; + + if (self.templateName.length == 0 || [self.templateName isEqualToString:@"default"]) { + [[RNAppodealNativeAdStore shared] setAdViewTemplateClass:APDDefaultNativeAdView.class]; + return; + } + + Class templateClass = NSClassFromString(self.templateName); + [[RNAppodealNativeAdStore shared] setAdViewTemplateClass:templateClass != Nil ? templateClass : APDDefaultNativeAdView.class]; +} + +- (void)clearNativeAdView { + if (self.nativeAd != nil) { + [self.nativeAd detachFromView]; + self.nativeAd.delegate = nil; + self.nativeAd = nil; + } + + [self.adContentView removeFromSuperview]; + self.adContentView = nil; +} + +- (void)layoutSubviews { + [super layoutSubviews]; + self.adContentView.frame = self.bounds; +} + +- (void)removeFromSuperview { + [self clearNativeAdView]; + [super removeFromSuperview]; +} + +- (void)insertReactSubview:(UIView *)subview atIndex:(NSInteger)atIndex { + RCTLogError(@"RNAppodealNativeView cannot have subviews"); +} + +#pragma mark - APDNativeAdPresentationDelegate + +- (void)nativeAdWillLogImpression:(APDNativeAd *)nativeAd { + if (self.onAdShown) { + self.onAdShown(@{@"adId": self.adId ?: @""}); + } + [[RNAppodealNativeAdStore shared] emitNativeEvent:kEventNativeShown payload:@{@"adId": self.adId ?: @""}]; +} + +- (void)nativeAdWillLogUserInteraction:(APDNativeAd *)nativeAd { + if (self.onAdClicked) { + self.onAdClicked(@{@"adId": self.adId ?: @""}); + } + [[RNAppodealNativeAdStore shared] emitNativeEvent:kEventNativeClicked payload:@{@"adId": self.adId ?: @""}]; +} + +@end diff --git a/ios/RNAppodealNativeViewManager.h b/ios/RNAppodealNativeViewManager.h new file mode 100644 index 0000000..3e490ff --- /dev/null +++ b/ios/RNAppodealNativeViewManager.h @@ -0,0 +1,5 @@ +#import + +@interface RNAppodealNativeViewManager : RCTViewManager + +@end diff --git a/ios/RNAppodealNativeViewManager.mm b/ios/RNAppodealNativeViewManager.mm new file mode 100644 index 0000000..68978aa --- /dev/null +++ b/ios/RNAppodealNativeViewManager.mm @@ -0,0 +1,22 @@ +#import "RNAppodealNativeViewManager.h" +#import "RNAppodealNativeView.h" + +@implementation RNAppodealNativeViewManager + +RCT_EXPORT_MODULE(RNAppodealNativeView); + +- (UIView *)view { + return [[RNAppodealNativeView alloc] initWithFrame:CGRectZero]; +} + +RCT_EXPORT_VIEW_PROPERTY(adId, NSString) +RCT_EXPORT_VIEW_PROPERTY(placement, NSString) +RCT_REMAP_VIEW_PROPERTY(template, templateName, NSString) + +RCT_EXPORT_VIEW_PROPERTY(onAdLoaded, RCTBubblingEventBlock) +RCT_EXPORT_VIEW_PROPERTY(onAdFailedToLoad, RCTBubblingEventBlock) +RCT_EXPORT_VIEW_PROPERTY(onAdClicked, RCTBubblingEventBlock) +RCT_EXPORT_VIEW_PROPERTY(onAdExpired, RCTBubblingEventBlock) +RCT_EXPORT_VIEW_PROPERTY(onAdShown, RCTBubblingEventBlock) + +@end diff --git a/package.json b/package.json index fd13116..6ebb35f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-appodeal", - "version": "4.2.0", + "version": "4.3.0", "description": "React Native Module created to support Appodeal SDK for iOS and Android platforms", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/src/RNAppodeal.ts b/src/RNAppodeal.ts index 9230f3e..df90d6e 100644 --- a/src/RNAppodeal.ts +++ b/src/RNAppodeal.ts @@ -3,7 +3,7 @@ * * This module provides a complete interface to the Appodeal SDK for React Native applications. * It includes functionality for: - * - Ad management (banner, interstitial, rewarded video, MREC) + * - Ad management (banner, interstitial, rewarded video, MREC, native) * - Analytics and revenue tracking * - In-app purchase validation * - Consent management (GDPR compliance) @@ -19,6 +19,8 @@ import type { AppodealConsentStatus, AppodealPrivacyOptionsStatus, AppodealIOSPurchase, + AppodealNativeAdInfo, + AppodealNativeContentType, AppodealReward, Map, } from './types'; @@ -93,6 +95,31 @@ export interface Appodeal { * @param adTypes Ad types mask */ cache(adTypes: AppodealAdType): void; + /** + * Pull cached native ads from the SDK (removes them from the SDK cache). + * Returns metadata + opaque ids for use with AppodealNative. + * @param count Number of ads to pull (Android max 5) + */ + getNativeAds(count?: number): AppodealNativeAdInfo[]; + /** + * Number of native ads currently available in the SDK cache / queue + */ + getAvailableNativeAdsCount(): number; + /** + * Destroy a native ad previously returned by getNativeAds + * @param adId Opaque ad id + */ + destroyNativeAd(adId: string): void; + /** + * Manually cache native ads + * @param count Number of ads to request (clamped 1–5 on Android) + */ + cacheNativeAds(count?: number): void; + /** + * Preferred native media content type + * @param type auto | noVideo | video + */ + setPreferredNativeContentType(type: AppodealNativeContentType): void; /** * Enables or disables autocache for specific ad type * @param adTypes Ad types mask @@ -253,7 +280,7 @@ export interface Appodeal { /** * Plugin version constant */ -const PLUGIN_VERSION = '4.2.0'; +const PLUGIN_VERSION = '4.3.0'; /** * Appodeal SDK implementation @@ -306,6 +333,30 @@ const appodeal: Appodeal = { NativeAppodeal.cache(adTypes); }, + getNativeAds: (count: number = 1): AppodealNativeAdInfo[] => { + const result = NativeAppodeal.getNativeAds(count) as unknown; + if (Array.isArray(result)) { + return result as AppodealNativeAdInfo[]; + } + return []; + }, + + getAvailableNativeAdsCount: (): number => { + return NativeAppodeal.getAvailableNativeAdsCount(); + }, + + destroyNativeAd: (adId: string): void => { + NativeAppodeal.destroyNativeAd(adId); + }, + + cacheNativeAds: (count: number = 2): void => { + NativeAppodeal.cacheNativeAds(count); + }, + + setPreferredNativeContentType: (type: AppodealNativeContentType): void => { + NativeAppodeal.setPreferredNativeContentType(type); + }, + setAutoCache: (adTypes: AppodealAdType, value: boolean): void => { NativeAppodeal.setAutoCache(adTypes, value); }, diff --git a/src/RNAppodealEvents.ts b/src/RNAppodealEvents.ts index 10b6087..17636f6 100644 --- a/src/RNAppodealEvents.ts +++ b/src/RNAppodealEvents.ts @@ -51,6 +51,24 @@ export namespace AppodealInterstitialEvents { export const CLOSED = 'onInterstitialClosed'; } +/** + * Native ad events + */ +export namespace AppodealNativeEvents { + /** Fired when native ads are loaded into the queue */ + export const LOADED = 'onNativeLoaded'; + /** Fired when native ads fail to load */ + export const FAILED_TO_LOAD = 'onNativeFailedToLoad'; + /** Fired when a native ad is shown */ + export const SHOWN = 'onNativeShown'; + /** Fired when a native ad fails to show */ + export const SHOW_FAILED = 'onNativeShowFailed'; + /** Fired when a native ad is clicked */ + export const CLICKED = 'onNativeClicked'; + /** Fired when a native ad expires */ + export const EXPIRED = 'onNativeExpired'; +} + /** * Rewarded video ad events */ diff --git a/src/RNAppodealNative.tsx b/src/RNAppodealNative.tsx new file mode 100644 index 0000000..2897a13 --- /dev/null +++ b/src/RNAppodealNative.tsx @@ -0,0 +1,27 @@ +/** + * Appodeal Native Ad Component + * + * Renders a platform native-ad template (newsFeed / appWall / contentStream) + * bound to an ad id returned from Appodeal.getNativeAds(). + */ +import AppodealNativeView, { + type NativeProps, +} from './specs/AppodealNativeViewNativeComponent'; + +const AppodealNative = ({ + template = 'contentStream', + placement = 'default', + style, + ...rest +}: NativeProps) => { + return ( + + ); +}; + +export default AppodealNative; diff --git a/src/__tests__/types.test.ts b/src/__tests__/types.test.ts index f9e7c97..2e36e43 100644 --- a/src/__tests__/types.test.ts +++ b/src/__tests__/types.test.ts @@ -21,7 +21,12 @@ describe('Appodeal Type Definitions', () => { expect(typeof AppodealAdType.INTERSTITIAL).toBe('number'); expect(typeof AppodealAdType.BANNER).toBe('number'); expect(typeof AppodealAdType.REWARDED_VIDEO).toBe('number'); + expect(typeof AppodealAdType.NATIVE).toBe('number'); + expect(AppodealAdType.NATIVE).toBe(1 << 7); expect(typeof AppodealAdType.MREC).toBe('number'); + expect(AppodealAdType.ALL & AppodealAdType.NATIVE).toBe( + AppodealAdType.NATIVE + ); }); it('should have correct AppodealLogLevel values', () => { diff --git a/src/index.tsx b/src/index.tsx index 1d23484..4bffdae 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -9,6 +9,7 @@ import Appodeal from './RNAppodeal'; // React Native components export { default as AppodealBanner } from './RNAppodealBanner'; export { default as AppodealMrec } from './RNAppodealMrec'; +export { default as AppodealNative } from './RNAppodealNative'; // Export the main Appodeal interface export default Appodeal; @@ -29,6 +30,9 @@ export type { AppodealIOSPurchase, AppodealAndroidPurchase, AppodealAdRevenue, + AppodealNativeAdInfo, + AppodealNativeContentType, + AppodealNativeTemplate, AppodealPurchaseValidationResult, Map, } from './types'; @@ -39,4 +43,5 @@ export { AppodealBannerEvents, AppodealInterstitialEvents, AppodealRewardedEvents, + AppodealNativeEvents, } from './RNAppodealEvents'; diff --git a/src/specs/AppodealNativeViewNativeComponent.ts b/src/specs/AppodealNativeViewNativeComponent.ts new file mode 100644 index 0000000..3f53495 --- /dev/null +++ b/src/specs/AppodealNativeViewNativeComponent.ts @@ -0,0 +1,27 @@ +import type { HostComponent, ViewProps } from 'react-native'; +import type { DirectEventHandler } from 'react-native/Libraries/Types/CodegenTypes'; +import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'; + +export type NativeAdInfoEvent = Readonly<{ + adId?: string; +}>; + +export type NativeAdLoadFailedEvent = Readonly<{ + adId?: string; + message?: string; +}>; + +export interface NativeProps extends ViewProps { + adId?: string; + placement?: string; + template?: string; + onAdLoaded?: DirectEventHandler; + onAdFailedToLoad?: DirectEventHandler; + onAdShown?: DirectEventHandler; + onAdClicked?: DirectEventHandler; + onAdExpired?: DirectEventHandler; +} + +export default codegenNativeComponent( + 'RNAppodealNativeView' +) as HostComponent; diff --git a/src/specs/NativeAppodealModule.ts b/src/specs/NativeAppodealModule.ts index 2aa6b67..39bfd16 100644 --- a/src/specs/NativeAppodealModule.ts +++ b/src/specs/NativeAppodealModule.ts @@ -74,6 +74,13 @@ export interface Spec extends TurboModule { ): Promise; trackEvent(name: string, parameters: UnsafeObject): void; + // Native ads + getNativeAds(count: number): UnsafeObject; + getAvailableNativeAdsCount(): number; + destroyNativeAd(adId: string): void; + cacheNativeAds(count: number): void; + setPreferredNativeContentType(type: string): void; + // Bidon setBidonEndpoint(endpoint: string): void; getBidonEndpoint(): string | null; diff --git a/src/types/AppodealAdTypes.ts b/src/types/AppodealAdTypes.ts index dab445c..3d85967 100644 --- a/src/types/AppodealAdTypes.ts +++ b/src/types/AppodealAdTypes.ts @@ -16,15 +16,40 @@ export enum AppodealAdType { BANNER_BOTTOM = 1 << 3, // 8 BANNER_TOP = 1 << 4, // 16 REWARDED_VIDEO = 1 << 5, // 32 + NATIVE = 1 << 7, // 128 MREC = 1 << 8, // 256 ALL = INTERSTITIAL | BANNER | BANNER_BOTTOM | BANNER_TOP | REWARDED_VIDEO | + NATIVE | MREC, } +/** + * Preferred media content type for native ads + */ +export type AppodealNativeContentType = 'auto' | 'noVideo' | 'video'; + +/** + * Native ad template used by AppodealNative view + */ +export type AppodealNativeTemplate = 'newsFeed' | 'appWall' | 'contentStream'; + +/** + * Metadata for a cached native ad pulled from the SDK + */ +export interface AppodealNativeAdInfo { + id: string; + title: string; + description: string; + callToAction: string; + rating: number; + containsVideo: boolean; + predictedEcpm: number; +} + /** * Ad revenue information for analytics */ diff --git a/src/types/index.ts b/src/types/index.ts index 2f52762..e7ca576 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -8,7 +8,12 @@ // Ad-related types export { AppodealAdType } from './AppodealAdTypes'; -export type { AppodealAdRevenue } from './AppodealAdTypes'; +export type { + AppodealAdRevenue, + AppodealNativeAdInfo, + AppodealNativeContentType, + AppodealNativeTemplate, +} from './AppodealAdTypes'; // Consent-related types export { From 9c0d18f1262c7fef54647e8b8dcb316a46301627 Mon Sep 17 00:00:00 2001 From: Roo Date: Thu, 30 Jul 2026 16:59:11 -0400 Subject: [PATCH 02/16] fix(ios): use documented APDNativeAdSettings.default for native queue Co-authored-by: Cursor --- ios/RNAppodealNativeAdStore.m | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/ios/RNAppodealNativeAdStore.m b/ios/RNAppodealNativeAdStore.m index 7a7c458..1ef6532 100644 --- a/ios/RNAppodealNativeAdStore.m +++ b/ios/RNAppodealNativeAdStore.m @@ -38,16 +38,8 @@ - (void)ensureQueue { } APDNativeAdQueue *queue = [[APDNativeAdQueue alloc] init]; - if ([APDNativeAdSettings respondsToSelector:@selector(defaultSettings)]) { - queue.settings = [APDNativeAdSettings defaultSettings]; - } else if ([APDNativeAdSettings respondsToSelector:@selector(default)]) { -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Warc-performSelector-leaks" - queue.settings = [APDNativeAdSettings performSelector:@selector(default)]; -#pragma clang diagnostic pop - } else { - queue.settings = [[APDNativeAdSettings alloc] init]; - } + // Appodeal iOS docs: APDNativeAdSettings.default() + queue.settings = [APDNativeAdSettings default]; queue.settings.adViewClass = APDDefaultNativeAdView.class; queue.settings.type = [self nativeAdTypeFromPreferredContentType:self.preferredContentType]; queue.delegate = self; @@ -101,10 +93,7 @@ - (NSInteger)availableCount { return 0; } - if ([self.queue respondsToSelector:@selector(availableAdsCount)]) { - return self.queue.availableAdsCount; - } - + // APDNativeAdQueue exposes currentAdCount; Appodeal also has availableNativeAdsCount. if ([self.queue respondsToSelector:@selector(currentAdCount)]) { return self.queue.currentAdCount; } From 083520b5075f7a599eeaca6469cdd4aa9f793b8e Mon Sep 17 00:00:00 2001 From: Roo Date: Thu, 30 Jul 2026 17:05:55 -0400 Subject: [PATCH 03/16] fix: point package entry at src for Metro git installs bob lib/ is not produced on yarn git install; Metro can consume src directly. Co-authored-by: Cursor --- package.json | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 6ebb35f..13c06ad 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,15 @@ "name": "react-native-appodeal", "version": "4.3.0", "description": "React Native Module created to support Appodeal SDK for iOS and Android platforms", - "main": "./lib/module/index.js", - "types": "./lib/typescript/src/index.d.ts", + "main": "./src/index.tsx", + "react-native": "./src/index.tsx", + "types": "./src/index.tsx", "exports": { ".": { + "react-native": "./src/index.tsx", + "types": "./src/index.tsx", "source": "./src/index.tsx", - "types": "./lib/typescript/src/index.d.ts", - "default": "./lib/module/index.js" + "default": "./src/index.tsx" }, "./package.json": "./package.json" }, From d2bf0f583e47d77dfcda4e3e0da0e3bc59ffc982 Mon Sep 17 00:00:00 2001 From: Roo Date: Thu, 30 Jul 2026 17:37:43 -0400 Subject: [PATCH 04/16] fix(android): match Appodeal 4.2 NativeCallbacks and TurboModule map return Nullable NativeAd? overrides; getNativeAds returns { ads: [...] } WritableMap. Co-authored-by: Cursor --- .../rnappodeal/RNAppodealModuleImpl.kt | 12 +++++++++--- .../callbacks/RNAppodealNativeCallbacks.kt | 19 +++++++------------ .../appodeal/rnappodeal/RNAppodealModule.kt | 3 +-- .../appodeal/rnappodeal/RNAppodealModule.java | 3 +-- src/RNAppodeal.ts | 8 ++++++++ 5 files changed, 26 insertions(+), 19 deletions(-) diff --git a/android/src/main/java/com/appodeal/rnappodeal/RNAppodealModuleImpl.kt b/android/src/main/java/com/appodeal/rnappodeal/RNAppodealModuleImpl.kt index 4ad39c3..a2c9900 100644 --- a/android/src/main/java/com/appodeal/rnappodeal/RNAppodealModuleImpl.kt +++ b/android/src/main/java/com/appodeal/rnappodeal/RNAppodealModuleImpl.kt @@ -29,7 +29,6 @@ import com.facebook.react.bridge.LifecycleEventListener import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReadableMap -import com.facebook.react.bridge.WritableArray import com.facebook.react.bridge.WritableMap import java.lang.ref.WeakReference @@ -393,12 +392,19 @@ internal class RNAppodealModuleImpl( Appodeal.logEvent(name, parameters.toMap()) } - fun getNativeAds(count: Double): WritableArray { + /** + * TurboModule codegen maps Spec `UnsafeObject` → WritableMap. + * Shape: `{ ads: Array }`. + */ + fun getNativeAds(count: Double): WritableMap { val ads = Appodeal.getNativeAds(count.toInt()) val storedAds = RNAppodealNativeAdStore.putAds(ads) - return Arguments.createArray().apply { + val array = Arguments.createArray().apply { storedAds.forEach { pushMap(it) } } + return Arguments.createMap().apply { + putArray("ads", array) + } } fun getAvailableNativeAdsCount(): Double { diff --git a/android/src/main/java/com/appodeal/rnappodeal/callbacks/RNAppodealNativeCallbacks.kt b/android/src/main/java/com/appodeal/rnappodeal/callbacks/RNAppodealNativeCallbacks.kt index 3dc5a92..c33fba1 100644 --- a/android/src/main/java/com/appodeal/rnappodeal/callbacks/RNAppodealNativeCallbacks.kt +++ b/android/src/main/java/com/appodeal/rnappodeal/callbacks/RNAppodealNativeCallbacks.kt @@ -9,14 +9,8 @@ import com.facebook.react.bridge.Arguments /** * Callback handler for Appodeal native ad events. * - * This class implements the NativeCallbacks interface and handles native ad lifecycle events - * including loading, showing, clicking, and expiration. It forwards events to both: - * - * 1. **Event Dispatcher**: For advanced event management with priority handling - * 2. **Event Handler Manager**: For Fabric/New Architecture event dispatching - * - * @param eventDispatcher The RNAEventDispatcher instance used for advanced event management - * @param handlers The event handler manager that forwards events to registered native views + * Appodeal 4.2.0 exposes these Java callbacks as platform types; Kotlin sees + * the NativeAd parameters as nullable (`NativeAd?`). */ internal class RNAppodealNativeCallbacks( private val eventDispatcher: RNAEventDispatcher, @@ -33,19 +27,19 @@ internal class RNAppodealNativeCallbacks( handlers.handleEvent(NativeEvents.ON_NATIVE_FAILED_TO_LOAD, null) } - override fun onNativeShown(nativeAd: NativeAd) { + override fun onNativeShown(nativeAd: NativeAd?) { val params = createNativeAdParams(nativeAd) eventDispatcher.dispatchEvent(NativeEvents.ON_NATIVE_SHOWN, params) handlers.handleEvent(NativeEvents.ON_NATIVE_SHOWN, params) } - override fun onNativeShowFailed(nativeAd: NativeAd) { + override fun onNativeShowFailed(nativeAd: NativeAd?) { val params = createNativeAdParams(nativeAd) eventDispatcher.dispatchEvent(NativeEvents.ON_NATIVE_SHOW_FAILED, params) handlers.handleEvent(NativeEvents.ON_NATIVE_SHOW_FAILED, params) } - override fun onNativeClicked(nativeAd: NativeAd) { + override fun onNativeClicked(nativeAd: NativeAd?) { val params = createNativeAdParams(nativeAd) eventDispatcher.dispatchEvent(NativeEvents.ON_NATIVE_CLICKED, params) handlers.handleEvent(NativeEvents.ON_NATIVE_CLICKED, params) @@ -56,7 +50,8 @@ internal class RNAppodealNativeCallbacks( handlers.handleEvent(NativeEvents.ON_NATIVE_EXPIRED, null) } - private fun createNativeAdParams(nativeAd: NativeAd) = Arguments.createMap().apply { + private fun createNativeAdParams(nativeAd: NativeAd?) = Arguments.createMap().apply { + if (nativeAd == null) return@apply putString("title", nativeAd.title ?: "") putString("description", nativeAd.description ?: "") putString("callToAction", nativeAd.callToAction ?: "") diff --git a/android/src/newarch/com/appodeal/rnappodeal/RNAppodealModule.kt b/android/src/newarch/com/appodeal/rnappodeal/RNAppodealModule.kt index cf9aa8a..38b59cd 100644 --- a/android/src/newarch/com/appodeal/rnappodeal/RNAppodealModule.kt +++ b/android/src/newarch/com/appodeal/rnappodeal/RNAppodealModule.kt @@ -3,7 +3,6 @@ package com.appodeal.rnappodeal import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReadableMap -import com.facebook.react.bridge.WritableArray import com.facebook.react.bridge.WritableMap import com.facebook.react.module.annotations.ReactModule @@ -198,7 +197,7 @@ class RNAppodealModule( moduleImplementation.trackEvent(name, parameters) } - override fun getNativeAds(count: Double): WritableArray { + override fun getNativeAds(count: Double): WritableMap { return moduleImplementation.getNativeAds(count) } diff --git a/android/src/oldarch/com/appodeal/rnappodeal/RNAppodealModule.java b/android/src/oldarch/com/appodeal/rnappodeal/RNAppodealModule.java index d603540..cca18db 100644 --- a/android/src/oldarch/com/appodeal/rnappodeal/RNAppodealModule.java +++ b/android/src/oldarch/com/appodeal/rnappodeal/RNAppodealModule.java @@ -7,7 +7,6 @@ import com.facebook.react.bridge.ReactContextBaseJavaModule; import com.facebook.react.bridge.ReactMethod; import com.facebook.react.bridge.ReadableMap; -import com.facebook.react.bridge.WritableArray; import com.facebook.react.bridge.WritableMap; public class RNAppodealModule extends ReactContextBaseJavaModule { @@ -241,7 +240,7 @@ public void trackEvent(String name, ReadableMap parameters) { } @ReactMethod(isBlockingSynchronousMethod = true) - public WritableArray getNativeAds(double count) { + public WritableMap getNativeAds(double count) { return moduleImplementation.getNativeAds(count); } diff --git a/src/RNAppodeal.ts b/src/RNAppodeal.ts index df90d6e..e6f9c01 100644 --- a/src/RNAppodeal.ts +++ b/src/RNAppodeal.ts @@ -334,10 +334,18 @@ const appodeal: Appodeal = { }, getNativeAds: (count: number = 1): AppodealNativeAdInfo[] => { + // Android TurboModule Spec uses UnsafeObject → { ads: [...] } const result = NativeAppodeal.getNativeAds(count) as unknown; if (Array.isArray(result)) { return result as AppodealNativeAdInfo[]; } + if ( + result && + typeof result === 'object' && + Array.isArray((result as { ads?: unknown }).ads) + ) { + return (result as { ads: AppodealNativeAdInfo[] }).ads; + } return []; }, From 41c7704401ac8d6a0edd25717d00aa10c80957e1 Mon Sep 17 00:00:00 2001 From: Roo Date: Thu, 30 Jul 2026 21:36:05 -0400 Subject: [PATCH 05/16] =?UTF-8?q?fix(android):=20rename=20native=20view=20?= =?UTF-8?q?prop=20template=20=E2=86=92=20adTemplate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabric codegen emits the prop as a C++ field; `template` is a reserved keyword and breaks clang (`std::string template{}`). Co-authored-by: Cursor --- .../java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt | 4 ++-- .../com/appodeal/rnappodeal/RNAppodealNativeViewManager.kt | 6 +++--- .../appodeal/rnappodeal/RNAppodealNativeViewManager.java | 6 +++--- ios/RNAppodealNativeViewManager.mm | 2 +- package.json | 2 +- src/RNAppodealNative.tsx | 4 ++-- src/specs/AppodealNativeViewNativeComponent.ts | 3 ++- 7 files changed, 14 insertions(+), 13 deletions(-) diff --git a/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt b/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt index 1be818c..31ea88c 100644 --- a/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt +++ b/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt @@ -33,7 +33,7 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod bindAd() } - var template: String = "contentStream" + var adTemplate: String = "contentStream" set(value) { if (field != value) { field = value @@ -69,7 +69,7 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod } private fun createNativeAdView(): NativeAdView { - return when (template) { + return when (adTemplate) { "newsFeed" -> NativeAdViewNewsFeed(context) "appWall" -> NativeAdViewAppWall(context) else -> NativeAdViewContentStream(context) diff --git a/android/src/newarch/com/appodeal/rnappodeal/RNAppodealNativeViewManager.kt b/android/src/newarch/com/appodeal/rnappodeal/RNAppodealNativeViewManager.kt index fd6b77c..983be89 100644 --- a/android/src/newarch/com/appodeal/rnappodeal/RNAppodealNativeViewManager.kt +++ b/android/src/newarch/com/appodeal/rnappodeal/RNAppodealNativeViewManager.kt @@ -36,8 +36,8 @@ class RNAppodealNativeViewManager : value?.let { view.placement = it } } - @ReactProp(name = "template") - fun setTemplate(view: RCTAppodealNativeView, value: String?) { - value?.let { view.template = it } + @ReactProp(name = "adTemplate") + fun setAdTemplate(view: RCTAppodealNativeView, value: String?) { + value?.let { view.adTemplate = it } } } diff --git a/android/src/oldarch/com/appodeal/rnappodeal/RNAppodealNativeViewManager.java b/android/src/oldarch/com/appodeal/rnappodeal/RNAppodealNativeViewManager.java index b601114..a13ac32 100644 --- a/android/src/oldarch/com/appodeal/rnappodeal/RNAppodealNativeViewManager.java +++ b/android/src/oldarch/com/appodeal/rnappodeal/RNAppodealNativeViewManager.java @@ -53,10 +53,10 @@ public void setPlacement(RCTAppodealNativeView view, @Nullable String value) { } } - @ReactProp(name = "template") - public void setTemplate(RCTAppodealNativeView view, @Nullable String value) { + @ReactProp(name = "adTemplate") + public void setAdTemplate(RCTAppodealNativeView view, @Nullable String value) { if (value != null) { - view.setTemplate(value); + view.setAdTemplate(value); } } } diff --git a/ios/RNAppodealNativeViewManager.mm b/ios/RNAppodealNativeViewManager.mm index 68978aa..61c11b4 100644 --- a/ios/RNAppodealNativeViewManager.mm +++ b/ios/RNAppodealNativeViewManager.mm @@ -11,7 +11,7 @@ - (UIView *)view { RCT_EXPORT_VIEW_PROPERTY(adId, NSString) RCT_EXPORT_VIEW_PROPERTY(placement, NSString) -RCT_REMAP_VIEW_PROPERTY(template, templateName, NSString) +RCT_REMAP_VIEW_PROPERTY(adTemplate, templateName, NSString) RCT_EXPORT_VIEW_PROPERTY(onAdLoaded, RCTBubblingEventBlock) RCT_EXPORT_VIEW_PROPERTY(onAdFailedToLoad, RCTBubblingEventBlock) diff --git a/package.json b/package.json index 13c06ad..79993ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-appodeal", - "version": "4.3.0", + "version": "4.3.1", "description": "React Native Module created to support Appodeal SDK for iOS and Android platforms", "main": "./src/index.tsx", "react-native": "./src/index.tsx", diff --git a/src/RNAppodealNative.tsx b/src/RNAppodealNative.tsx index 2897a13..2c4aff4 100644 --- a/src/RNAppodealNative.tsx +++ b/src/RNAppodealNative.tsx @@ -9,14 +9,14 @@ import AppodealNativeView, { } from './specs/AppodealNativeViewNativeComponent'; const AppodealNative = ({ - template = 'contentStream', + adTemplate = 'contentStream', placement = 'default', style, ...rest }: NativeProps) => { return ( ; onAdFailedToLoad?: DirectEventHandler; onAdShown?: DirectEventHandler; From 660a527a86af2860545b7b019057d45e6ba2a2cb Mon Sep 17 00:00:00 2001 From: Roo Date: Thu, 30 Jul 2026 21:36:16 -0400 Subject: [PATCH 06/16] docs: update native ad prop name to adTemplate Co-authored-by: Cursor --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a8973ff..55a0789 100644 --- a/README.md +++ b/README.md @@ -843,7 +843,7 @@ Appodeal.addEventListener(AppodealNativeEvents.LOADED, () => { console.log('Native shown')} onAdFailedToLoad={() => console.log('Native failed')} onAdClicked={() => console.log('Native clicked')} From ff22ec3af3e8ea69b142d67eb3576b553abd8712 Mon Sep 17 00:00:00 2001 From: Roo Date: Thu, 30 Jul 2026 22:45:33 -0400 Subject: [PATCH 07/16] fix(android): lay out native ad views and bind with Activity context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReactViewGroup was not measuring children (unlike banner/MREC), and registerView often failed canShow under ReactContext — templates stayed GONE. Co-authored-by: Cursor --- .../rnappodeal/RCTAppodealNativeView.kt | 180 ++++++++++++++++-- 1 file changed, 160 insertions(+), 20 deletions(-) diff --git a/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt b/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt index 31ea88c..7fc10cd 100644 --- a/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt +++ b/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt @@ -1,8 +1,12 @@ package com.appodeal.rnappodeal import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.Log import android.view.ViewGroup import android.widget.FrameLayout +import com.appodeal.ads.NativeAd import com.appodeal.ads.nativead.NativeAdView import com.appodeal.ads.nativead.NativeAdViewAppWall import com.appodeal.ads.nativead.NativeAdViewContentStream @@ -17,76 +21,208 @@ import com.facebook.react.uimanager.events.Event import com.facebook.react.uimanager.events.EventDispatcher import com.facebook.react.views.view.ReactViewGroup +/** + * Hosts an Appodeal native-ad template inside React Native. + * + * Important: ReactViewGroup does not lay out Android children unless we + * explicitly measure/layout them (same pattern as banner/MREC). Templates + * also need an Activity context so [NativeAd.canShow] / [NativeAdView.registerView] + * succeed — ReactContext alone often makes registerView return false and the + * template stays GONE. + */ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppodealEventHandler { private val surfaceId: Int by lazy { UIManagerHelper.getSurfaceId(reactContext) } + private val uiHandler: Handler by lazy { Handler(Looper.getMainLooper()) } var adId: String? = null set(value) { + if (field == value) return field = value - bindAd() + boundAdId = null + scheduleBind() } var placement: String = "default" set(value) { + if (field == value) return field = value - bindAd() + boundAdId = null + scheduleBind() } var adTemplate: String = "contentStream" set(value) { - if (field != value) { - field = value - recreateNativeAdView() - } + if (field == value) return + field = value + boundAdId = null + recreateNativeAdView() + scheduleBind() } private var nativeAdView: NativeAdView? = null + private var boundAdId: String? = null + private var bindRunnable: Runnable? = null + /** True when the template was constructed with an Activity (needed for canShow). */ + private var createdWithActivity: Boolean = false + + /** + * Mirror banner/MREC: RN won't size native children without this. + */ + private val measureAndLayout = Runnable { + val w = measuredWidth + val h = measuredHeight + if (w <= 0 || h <= 0) return@Runnable + for (i in 0 until childCount) { + val child = getChildAt(i) + child.measure( + MeasureSpec.makeMeasureSpec(w, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(h, MeasureSpec.EXACTLY) + ) + child.layout(0, 0, child.measuredWidth, child.measuredHeight) + } + } init { recreateNativeAdView() } + override fun requestLayout() { + super.requestLayout() + post(measureAndLayout) + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + post(measureAndLayout) + if (right - left > 0 && bottom - top > 0) { + scheduleBind() + } + } + fun cleanup() { + bindRunnable?.let { uiHandler.removeCallbacks(it) } + bindRunnable = null nativeAdView?.let { view -> - view.unregisterView() - view.destroy() + try { + view.unregisterView() + } catch (_: Exception) { + } + try { + view.destroy() + } catch (_: Exception) { + } } removeAllViews() nativeAdView = null + boundAdId = null + } + + private fun hostContext(): Context { + return reactContext.currentActivity ?: context } private fun recreateNativeAdView() { - cleanup() - val view = createNativeAdView() + nativeAdView?.let { view -> + try { + view.unregisterView() + } catch (_: Exception) { + } + try { + view.destroy() + } catch (_: Exception) { + } + } + removeAllViews() + boundAdId = null + + val host = hostContext() + createdWithActivity = reactContext.currentActivity != null + val view = createNativeAdView(host) nativeAdView = view view.layoutParams = FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT + ViewGroup.LayoutParams.MATCH_PARENT ) + // Templates start GONE until registerView succeeds. addView(view) - bindAd() + post(measureAndLayout) } - private fun createNativeAdView(): NativeAdView { + private fun createNativeAdView(ctx: Context): NativeAdView { return when (adTemplate) { - "newsFeed" -> NativeAdViewNewsFeed(context) - "appWall" -> NativeAdViewAppWall(context) - else -> NativeAdViewContentStream(context) + "newsFeed" -> NativeAdViewNewsFeed(ctx) + "appWall" -> NativeAdViewAppWall(ctx) + else -> NativeAdViewContentStream(ctx) } } + private fun scheduleBind() { + bindRunnable?.let { uiHandler.removeCallbacks(it) } + val runnable = Runnable { bindAd() }.also { bindRunnable = it } + // Slight delay so RN has committed size (same idea as banner show). + uiHandler.postDelayed(runnable, 50L) + } + private fun bindAd() { - val id = adId ?: return - val ad = RNAppodealNativeAdStore.get(id) ?: return + val id = adId + if (id.isNullOrEmpty()) return + if (boundAdId == id) return + + val ad: NativeAd = RNAppodealNativeAdStore.get(id) ?: run { + Log.w(TAG, "bindAd: no NativeAd in store for id=$id") + return + } + + // Prefer Activity context — ReactContext alone often makes canShow/registerView fail. + if (nativeAdView == null || (reactContext.currentActivity != null && !createdWithActivity)) { + recreateNativeAdView() + } val view = nativeAdView ?: return - view.registerView(ad, placement) + + if (measuredWidth <= 0 || measuredHeight <= 0) { + Log.d(TAG, "bindAd: waiting for size id=$id") + return + } + + post(measureAndLayout) + + val canShow = try { + ad.canShow(hostContext(), placement) + } catch (e: Exception) { + Log.e(TAG, "canShow threw", e) + false + } + if (!canShow) { + Log.w(TAG, "bindAd: canShow=false id=$id placement=$placement") + return + } + + val registered = try { + view.registerView(ad, placement) + } catch (e: Exception) { + Log.e(TAG, "registerView threw", e) + false + } + + Log.d( + TAG, + "registerView id=$id placement=$placement result=$registered size=${measuredWidth}x${measuredHeight}" + ) + + if (registered) { + boundAdId = id + visibility = VISIBLE + view.visibility = VISIBLE + post(measureAndLayout) + } } override fun handleEvent(event: String, params: WritableMap?) { when (event) { NativeEvents.ON_NATIVE_LOADED -> dispatchFabricEvent(id, NativeEvents.ON_AD_LOADED, params) - NativeEvents.ON_NATIVE_FAILED_TO_LOAD -> dispatchFabricEvent(id, NativeEvents.ON_AD_FAILED_TO_LOAD, params) + NativeEvents.ON_NATIVE_FAILED_TO_LOAD -> + dispatchFabricEvent(id, NativeEvents.ON_AD_FAILED_TO_LOAD, params) NativeEvents.ON_NATIVE_SHOWN -> dispatchFabricEvent(id, NativeEvents.ON_AD_SHOWN, params) NativeEvents.ON_NATIVE_CLICKED -> dispatchFabricEvent(id, NativeEvents.ON_AD_CLICKED, params) NativeEvents.ON_NATIVE_EXPIRED -> dispatchFabricEvent(id, NativeEvents.ON_AD_EXPIRED, params) @@ -121,4 +257,8 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod private val reactContext: ReactContext get() = context as ReactContext + + companion object { + private const val TAG = "RNAppodealNative" + } } From 04b0cad60cfcb7ee966435e0d611b8fdd22c7c13 Mon Sep 17 00:00:00 2001 From: Roo Date: Thu, 30 Jul 2026 22:51:42 -0400 Subject: [PATCH 08/16] =?UTF-8?q?fix(android):=20harden=20native=20ad=20bi?= =?UTF-8?q?nd=20=E2=80=94=20retries,=20Activity=20holder,=20events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unregister before rebind, retry canShow/size/Activity failures, notify views on resume, unbind on destroyNativeAd, and emit per-view load/fail events. Co-authored-by: Cursor --- .../rnappodeal/RCTAppodealNativeView.kt | 208 ++++++++++++++---- .../rnappodeal/RNAppodealActivityHolder.kt | 19 ++ .../rnappodeal/RNAppodealModuleImpl.kt | 12 +- 3 files changed, 191 insertions(+), 48 deletions(-) create mode 100644 android/src/main/java/com/appodeal/rnappodeal/RNAppodealActivityHolder.kt diff --git a/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt b/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt index 7fc10cd..435a7e7 100644 --- a/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt +++ b/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt @@ -20,15 +20,15 @@ import com.facebook.react.uimanager.UIManagerHelper import com.facebook.react.uimanager.events.Event import com.facebook.react.uimanager.events.EventDispatcher import com.facebook.react.views.view.ReactViewGroup +import java.lang.ref.WeakReference +import java.util.concurrent.CopyOnWriteArrayList /** * Hosts an Appodeal native-ad template inside React Native. * - * Important: ReactViewGroup does not lay out Android children unless we - * explicitly measure/layout them (same pattern as banner/MREC). Templates - * also need an Activity context so [NativeAd.canShow] / [NativeAdView.registerView] - * succeed — ReactContext alone often makes registerView return false and the - * template stays GONE. + * ReactViewGroup does not lay out Android children unless we measure them + * (same pattern as banner/MREC). Templates also need an Activity context so + * [NativeAd.canShow] / [NativeAdView.registerView] succeed. */ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppodealEventHandler { @@ -38,37 +38,33 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod var adId: String? = null set(value) { if (field == value) return + unregisterCurrent() field = value - boundAdId = null - scheduleBind() + scheduleBind(0) } var placement: String = "default" set(value) { if (field == value) return + unregisterCurrent() field = value - boundAdId = null - scheduleBind() + scheduleBind(0) } var adTemplate: String = "contentStream" set(value) { if (field == value) return field = value - boundAdId = null recreateNativeAdView() - scheduleBind() + scheduleBind(0) } private var nativeAdView: NativeAdView? = null private var boundAdId: String? = null private var bindRunnable: Runnable? = null - /** True when the template was constructed with an Activity (needed for canShow). */ private var createdWithActivity: Boolean = false + private var bindAttempts: Int = 0 - /** - * Mirror banner/MREC: RN won't size native children without this. - */ private val measureAndLayout = Runnable { val w = measuredWidth val h = measuredHeight @@ -84,6 +80,7 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod } init { + liveViews.add(WeakReference(this)) recreateNativeAdView() } @@ -95,19 +92,16 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { super.onLayout(changed, left, top, right, bottom) post(measureAndLayout) - if (right - left > 0 && bottom - top > 0) { - scheduleBind() + if (right - left > 0 && bottom - top > 0 && boundAdId == null && !adId.isNullOrEmpty()) { + scheduleBind(0) } } fun cleanup() { bindRunnable?.let { uiHandler.removeCallbacks(it) } bindRunnable = null + unregisterCurrent() nativeAdView?.let { view -> - try { - view.unregisterView() - } catch (_: Exception) { - } try { view.destroy() } catch (_: Exception) { @@ -115,36 +109,53 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod } removeAllViews() nativeAdView = null - boundAdId = null + val self = this + liveViews.removeAll { it.get() == null || it.get() === self } + } + + /** Called when Activity becomes available (module onHostResume). */ + fun onActivityReady() { + if (boundAdId == null && !adId.isNullOrEmpty()) { + scheduleBind(0) + } } private fun hostContext(): Context { - return reactContext.currentActivity ?: context + return RNAppodealActivityHolder.get() + ?: reactContext.currentActivity + ?: context } - private fun recreateNativeAdView() { - nativeAdView?.let { view -> + private fun unregisterCurrent() { + if (boundAdId != null || nativeAdView != null) { try { - view.unregisterView() + nativeAdView?.unregisterView() } catch (_: Exception) { } + } + boundAdId = null + bindAttempts = 0 + } + + private fun recreateNativeAdView() { + unregisterCurrent() + nativeAdView?.let { view -> try { view.destroy() } catch (_: Exception) { } } removeAllViews() - boundAdId = null - val host = hostContext() - createdWithActivity = reactContext.currentActivity != null - val view = createNativeAdView(host) + val activity = RNAppodealActivityHolder.get() ?: reactContext.currentActivity + createdWithActivity = activity != null + val view = createNativeAdView(activity ?: context) nativeAdView = view view.layoutParams = FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT ) - // Templates start GONE until registerView succeeds. + view.descendantFocusability = FOCUS_BLOCK_DESCENDANTS addView(view) post(measureAndLayout) } @@ -157,11 +168,10 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod } } - private fun scheduleBind() { + private fun scheduleBind(delayMs: Long) { bindRunnable?.let { uiHandler.removeCallbacks(it) } val runnable = Runnable { bindAd() }.also { bindRunnable = it } - // Slight delay so RN has committed size (same idea as banner show). - uiHandler.postDelayed(runnable, 50L) + uiHandler.postDelayed(runnable, delayMs.coerceAtLeast(0L)) } private fun bindAd() { @@ -171,33 +181,55 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod val ad: NativeAd = RNAppodealNativeAdStore.get(id) ?: run { Log.w(TAG, "bindAd: no NativeAd in store for id=$id") + // Ad may arrive slightly later; retry a few times before failing. + if (retryOrFail(id, "native ad not in store")) return return } - // Prefer Activity context — ReactContext alone often makes canShow/registerView fail. - if (nativeAdView == null || (reactContext.currentActivity != null && !createdWithActivity)) { + val activity = RNAppodealActivityHolder.get() ?: reactContext.currentActivity + if (nativeAdView == null || (activity != null && !createdWithActivity)) { recreateNativeAdView() } - val view = nativeAdView ?: return + val view = nativeAdView ?: run { + dispatchFailed(id, "native ad view missing") + return + } if (measuredWidth <= 0 || measuredHeight <= 0) { - Log.d(TAG, "bindAd: waiting for size id=$id") + Log.d(TAG, "bindAd: waiting for size id=$id attempt=$bindAttempts") + retryOrFail(id, "view has zero size") + return + } + + if (activity == null) { + Log.d(TAG, "bindAd: waiting for Activity id=$id attempt=$bindAttempts") + retryOrFail(id, "activity unavailable") return } post(measureAndLayout) val canShow = try { - ad.canShow(hostContext(), placement) + ad.canShow(activity, placement) } catch (e: Exception) { Log.e(TAG, "canShow threw", e) false } if (!canShow) { - Log.w(TAG, "bindAd: canShow=false id=$id placement=$placement") + Log.w(TAG, "bindAd: canShow=false id=$id placement=$placement attempt=$bindAttempts") + retryOrFail(id, "canShow=false for placement=$placement") return } + // Ensure clean registration if something was partially bound. + if (boundAdId != null && boundAdId != id) { + try { + view.unregisterView() + } catch (_: Exception) { + } + boundAdId = null + } + val registered = try { view.registerView(ad, placement) } catch (e: Exception) { @@ -212,24 +244,79 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod if (registered) { boundAdId = id + bindAttempts = 0 visibility = VISIBLE view.visibility = VISIBLE + try { + (parent as? ViewGroup)?.bringChildToFront(this) + } catch (_: Exception) { + } + view.bringToFront() post(measureAndLayout) + dispatchLoaded(id) + } else { + retryOrFail(id, "registerView returned false") } } + /** @return true if a retry was scheduled */ + private fun retryOrFail(id: String, reason: String): Boolean { + bindAttempts += 1 + if (bindAttempts <= MAX_BIND_ATTEMPTS) { + val delay = BIND_BASE_DELAY_MS * bindAttempts + scheduleBind(delay) + return true + } + dispatchFailed(id, reason) + return false + } + + private fun dispatchLoaded(id: String) { + val params = Arguments.createMap().apply { putString("adId", id) } + dispatchFabricEvent(getId(), NativeEvents.ON_AD_LOADED, params) + } + + private fun dispatchFailed(id: String, message: String) { + Log.w(TAG, "bind failed id=$id message=$message") + val params = Arguments.createMap().apply { + putString("adId", id) + putString("message", message) + } + dispatchFabricEvent(getId(), NativeEvents.ON_AD_FAILED_TO_LOAD, params) + } + override fun handleEvent(event: String, params: WritableMap?) { + // Global SDK callbacks fan out to every view — only forward if this + // view is bound, and attach adId when missing. when (event) { - NativeEvents.ON_NATIVE_LOADED -> dispatchFabricEvent(id, NativeEvents.ON_AD_LOADED, params) - NativeEvents.ON_NATIVE_FAILED_TO_LOAD -> - dispatchFabricEvent(id, NativeEvents.ON_AD_FAILED_TO_LOAD, params) - NativeEvents.ON_NATIVE_SHOWN -> dispatchFabricEvent(id, NativeEvents.ON_AD_SHOWN, params) - NativeEvents.ON_NATIVE_CLICKED -> dispatchFabricEvent(id, NativeEvents.ON_AD_CLICKED, params) - NativeEvents.ON_NATIVE_EXPIRED -> dispatchFabricEvent(id, NativeEvents.ON_AD_EXPIRED, params) + NativeEvents.ON_NATIVE_SHOWN -> { + if (boundAdId == null) return + dispatchFabricEvent(getId(), NativeEvents.ON_AD_SHOWN, withAdId(params)) + } + NativeEvents.ON_NATIVE_CLICKED -> { + if (boundAdId == null) return + dispatchFabricEvent(getId(), NativeEvents.ON_AD_CLICKED, withAdId(params)) + } + NativeEvents.ON_NATIVE_EXPIRED -> { + if (boundAdId == null) return + unregisterCurrent() + visibility = GONE + dispatchFabricEvent(getId(), NativeEvents.ON_AD_EXPIRED, withAdId(params)) + } + // Cache-level load events are not per-view bind results — ignore here. else -> Unit } } + private fun withAdId(params: WritableMap?): WritableMap { + val copy = Arguments.createMap() + if (params != null) copy.merge(params) + if (!copy.hasKey("adId")) { + copy.putString("adId", boundAdId ?: adId) + } + return copy + } + private fun dispatchFabricEvent( viewId: Int, eventName: String, @@ -260,5 +347,34 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod companion object { private const val TAG = "RNAppodealNative" + private const val MAX_BIND_ATTEMPTS = 8 + private const val BIND_BASE_DELAY_MS = 250L + + private val liveViews = CopyOnWriteArrayList>() + + fun notifyActivityReady() { + pruneLiveViews() + for (ref in liveViews) { + ref.get()?.onActivityReady() + } + } + + fun unbindAdId(adId: String) { + pruneLiveViews() + for (ref in liveViews) { + val view = ref.get() ?: continue + if (view.adId == adId || view.boundAdId == adId) { + view.unregisterCurrent() + view.visibility = GONE + } + } + } + + private fun pruneLiveViews() { + val it = liveViews.iterator() + while (it.hasNext()) { + if (it.next().get() == null) it.remove() + } + } } } diff --git a/android/src/main/java/com/appodeal/rnappodeal/RNAppodealActivityHolder.kt b/android/src/main/java/com/appodeal/rnappodeal/RNAppodealActivityHolder.kt new file mode 100644 index 0000000..84d9147 --- /dev/null +++ b/android/src/main/java/com/appodeal/rnappodeal/RNAppodealActivityHolder.kt @@ -0,0 +1,19 @@ +package com.appodeal.rnappodeal + +import android.app.Activity +import java.lang.ref.WeakReference + +/** + * Shared Activity ref for native ad views. + * Mirrors [RNAppodealModuleImpl]'s workaround for RN's null getCurrentActivity bug. + */ +internal object RNAppodealActivityHolder { + @Volatile + private var activityRef: WeakReference? = null + + fun set(activity: Activity?) { + activityRef = if (activity != null) WeakReference(activity) else null + } + + fun get(): Activity? = activityRef?.get() +} diff --git a/android/src/main/java/com/appodeal/rnappodeal/RNAppodealModuleImpl.kt b/android/src/main/java/com/appodeal/rnappodeal/RNAppodealModuleImpl.kt index a2c9900..9e2e0c8 100644 --- a/android/src/main/java/com/appodeal/rnappodeal/RNAppodealModuleImpl.kt +++ b/android/src/main/java/com/appodeal/rnappodeal/RNAppodealModuleImpl.kt @@ -55,6 +55,7 @@ internal class RNAppodealModuleImpl( init { this.currentActivity = WeakReference(reactContext.currentActivity) + RNAppodealActivityHolder.set(reactContext.currentActivity) this.reactContext.addLifecycleEventListener(this) } @@ -66,8 +67,9 @@ internal class RNAppodealModuleImpl( private fun getActivity(): Activity? { if (reactContext.hasCurrentActivity()) { currentActivity = WeakReference(reactContext.currentActivity) + RNAppodealActivityHolder.set(reactContext.currentActivity) } - return currentActivity?.get() + return currentActivity?.get() ?: RNAppodealActivityHolder.get() } /** @@ -412,6 +414,7 @@ internal class RNAppodealModuleImpl( } fun destroyNativeAd(adId: String) { + RCTAppodealNativeView.unbindAdId(adId) RNAppodealNativeAdStore.remove(adId) } @@ -460,7 +463,12 @@ internal class RNAppodealModuleImpl( } override fun onHostResume() { - // Not implemented + val activity = reactContext.currentActivity + if (activity != null) { + currentActivity = WeakReference(activity) + RNAppodealActivityHolder.set(activity) + } + RCTAppodealNativeView.notifyActivityReady() } companion object Companion { From 4afc15f81f9f1740dc35363f72336ec110b4ba26 Mon Sep 17 00:00:00 2001 From: Roo Date: Thu, 30 Jul 2026 23:08:52 -0400 Subject: [PATCH 09/16] fix(android): render native ads via custom NativeAdView layout Stock templates registered then got destroyed/blank in RN. Bind title/CTA/ media on a custom NativeAdView, stop recreate-after-bind races, single-flight binds. Co-authored-by: Cursor --- .../rnappodeal/RCTAppodealNativeView.kt | 375 ++++++++++++------ 1 file changed, 244 insertions(+), 131 deletions(-) diff --git a/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt b/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt index 435a7e7..adf4156 100644 --- a/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt +++ b/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt @@ -1,16 +1,22 @@ package com.appodeal.rnappodeal import android.content.Context +import android.graphics.Color +import android.graphics.Typeface import android.os.Handler import android.os.Looper import android.util.Log +import android.util.TypedValue +import android.view.Gravity import android.view.ViewGroup +import android.widget.Button import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView import com.appodeal.ads.NativeAd import com.appodeal.ads.nativead.NativeAdView -import com.appodeal.ads.nativead.NativeAdViewAppWall -import com.appodeal.ads.nativead.NativeAdViewContentStream -import com.appodeal.ads.nativead.NativeAdViewNewsFeed +import com.appodeal.ads.nativead.NativeIconView +import com.appodeal.ads.nativead.NativeMediaView import com.appodeal.rnappodeal.callbacks.RNAppodealEventHandler import com.appodeal.rnappodeal.constants.NativeEvents import com.facebook.react.bridge.Arguments @@ -22,13 +28,16 @@ import com.facebook.react.uimanager.events.EventDispatcher import com.facebook.react.views.view.ReactViewGroup import java.lang.ref.WeakReference import java.util.concurrent.CopyOnWriteArrayList +import kotlin.math.roundToInt /** - * Hosts an Appodeal native-ad template inside React Native. + * Hosts an Appodeal native ad using a **custom** [NativeAdView] layout. * - * ReactViewGroup does not lay out Android children unless we measure them - * (same pattern as banner/MREC). Templates also need an Activity context so - * [NativeAd.canShow] / [NativeAdView.registerView] succeed. + * Stock templates (ContentStream/etc.) register successfully inside RN but stay + * blank / "not visible globally" because ReactViewGroup + template GONE/VISIBLE + * + recreate races destroy the view right after bind. Custom asset binding + * (title/CTA/media) matches Appodeal's documented custom-layout path and paints + * reliably in RN. */ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppodealEventHandler { @@ -38,31 +47,34 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod var adId: String? = null set(value) { if (field == value) return - unregisterCurrent() field = value - scheduleBind(0) + boundAdId = null + bindGeneration++ + scheduleBind(BIND_DELAY_MS) } var placement: String = "default" set(value) { if (field == value) return - unregisterCurrent() field = value - scheduleBind(0) + if (boundAdId != null) { + boundAdId = null + bindGeneration++ + scheduleBind(BIND_DELAY_MS) + } } + /** Kept for API compat; custom layout ignores template style. */ + @Suppress("UNUSED_PARAMETER") var adTemplate: String = "contentStream" - set(value) { - if (field == value) return - field = value - recreateNativeAdView() - scheduleBind(0) - } private var nativeAdView: NativeAdView? = null + private var titleView: TextView? = null + private var descriptionView: TextView? = null + private var ctaView: Button? = null private var boundAdId: String? = null private var bindRunnable: Runnable? = null - private var createdWithActivity: Boolean = false + private var bindGeneration: Int = 0 private var bindAttempts: Int = 0 private val measureAndLayout = Runnable { @@ -81,7 +93,7 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod init { liveViews.add(WeakReference(this)) - recreateNativeAdView() + ensureNativeAdView() } override fun requestLayout() { @@ -100,113 +112,205 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod fun cleanup() { bindRunnable?.let { uiHandler.removeCallbacks(it) } bindRunnable = null - unregisterCurrent() - nativeAdView?.let { view -> - try { - view.destroy() - } catch (_: Exception) { - } - } + bindGeneration++ + unbindQuietly() removeAllViews() nativeAdView = null + titleView = null + descriptionView = null + ctaView = null val self = this liveViews.removeAll { it.get() == null || it.get() === self } } - /** Called when Activity becomes available (module onHostResume). */ fun onActivityReady() { if (boundAdId == null && !adId.isNullOrEmpty()) { scheduleBind(0) } } - private fun hostContext(): Context { + private fun hostActivityContext(): Context { return RNAppodealActivityHolder.get() ?: reactContext.currentActivity ?: context } - private fun unregisterCurrent() { - if (boundAdId != null || nativeAdView != null) { - try { - nativeAdView?.unregisterView() - } catch (_: Exception) { - } - } - boundAdId = null - bindAttempts = 0 + private fun ensureNativeAdView() { + if (nativeAdView != null) return + val view = buildCustomNativeAdView(hostActivityContext()) + nativeAdView = view + addView( + view, + LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + ) + post(measureAndLayout) } - private fun recreateNativeAdView() { - unregisterCurrent() - nativeAdView?.let { view -> - try { - view.destroy() - } catch (_: Exception) { - } + private fun buildCustomNativeAdView(ctx: Context): NativeAdView { + val adView = NativeAdView(ctx) + adView.setBackgroundColor(Color.WHITE) + + val root = LinearLayout(ctx).apply { + orientation = LinearLayout.VERTICAL + setBackgroundColor(Color.WHITE) + setPadding(dpPx(ctx, 8), dpPx(ctx, 8), dpPx(ctx, 8), dpPx(ctx, 8)) } - removeAllViews() - val activity = RNAppodealActivityHolder.get() ?: reactContext.currentActivity - createdWithActivity = activity != null - val view = createNativeAdView(activity ?: context) - nativeAdView = view - view.layoutParams = FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT + val attribution = TextView(ctx).apply { + text = "Ad" + setTextColor(Color.WHITE) + setBackgroundColor(Color.parseColor("#FF9800")) + setPadding(dpPx(ctx, 6), dpPx(ctx, 2), dpPx(ctx, 6), dpPx(ctx, 2)) + textSize = 10f + typeface = Typeface.DEFAULT_BOLD + } + + val header = LinearLayout(ctx).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + } + + val icon = NativeIconView(ctx) + val iconLp = LinearLayout.LayoutParams(dpPx(ctx, 40), dpPx(ctx, 40)).apply { + rightMargin = dpPx(ctx, 8) + } + + val title = TextView(ctx).apply { + setTextColor(Color.BLACK) + textSize = 14f + typeface = Typeface.DEFAULT_BOLD + maxLines = 2 + } + titleView = title + + header.addView(icon, iconLp) + header.addView( + title, + LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f) ) - view.descendantFocusability = FOCUS_BLOCK_DESCENDANTS - addView(view) - post(measureAndLayout) - } - private fun createNativeAdView(ctx: Context): NativeAdView { - return when (adTemplate) { - "newsFeed" -> NativeAdViewNewsFeed(ctx) - "appWall" -> NativeAdViewAppWall(ctx) - else -> NativeAdViewContentStream(ctx) + val description = TextView(ctx).apply { + setTextColor(Color.DKGRAY) + textSize = 12f + maxLines = 3 + } + descriptionView = description + + val media = NativeMediaView(ctx).apply { + setBackgroundColor(Color.parseColor("#EEEEEE")) + } + + val cta = Button(ctx).apply { + isAllCaps = false + setTextColor(Color.WHITE) + setBackgroundColor(Color.parseColor("#1A73E8")) } + ctaView = cta + + root.addView( + attribution, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + ) + root.addView( + header, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { topMargin = dpPx(ctx, 6) } + ) + root.addView( + description, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { topMargin = dpPx(ctx, 4) } + ) + root.addView( + media, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + 0, + 1f + ).apply { topMargin = dpPx(ctx, 6) } + ) + root.addView( + cta, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { topMargin = dpPx(ctx, 6) } + ) + + adView.addView( + root, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + ) + + adView.setTitleView(title) + adView.setDescriptionView(description) + adView.setCallToActionView(cta) + adView.setIconView(icon) + adView.setMediaView(media) + adView.setAdAttributionView(attribution) + + return adView } private fun scheduleBind(delayMs: Long) { bindRunnable?.let { uiHandler.removeCallbacks(it) } - val runnable = Runnable { bindAd() }.also { bindRunnable = it } + val gen = bindGeneration + val runnable = Runnable { + if (gen != bindGeneration) return@Runnable + bindAd(gen) + }.also { bindRunnable = it } uiHandler.postDelayed(runnable, delayMs.coerceAtLeast(0L)) } - private fun bindAd() { + private fun bindAd(gen: Int) { + if (gen != bindGeneration) return val id = adId if (id.isNullOrEmpty()) return if (boundAdId == id) return val ad: NativeAd = RNAppodealNativeAdStore.get(id) ?: run { - Log.w(TAG, "bindAd: no NativeAd in store for id=$id") - // Ad may arrive slightly later; retry a few times before failing. - if (retryOrFail(id, "native ad not in store")) return + if (!retry(gen, id, "native ad not in store")) { + dispatchFailed(id, "native ad not in store") + } return } - val activity = RNAppodealActivityHolder.get() ?: reactContext.currentActivity - if (nativeAdView == null || (activity != null && !createdWithActivity)) { - recreateNativeAdView() - } - val view = nativeAdView ?: run { - dispatchFailed(id, "native ad view missing") + if (measuredWidth <= 0 || measuredHeight <= 0) { + if (!retry(gen, id, "view has zero size")) { + dispatchFailed(id, "view has zero size") + } return } - if (measuredWidth <= 0 || measuredHeight <= 0) { - Log.d(TAG, "bindAd: waiting for size id=$id attempt=$bindAttempts") - retryOrFail(id, "view has zero size") + val activity = RNAppodealActivityHolder.get() ?: reactContext.currentActivity + if (activity == null) { + if (!retry(gen, id, "activity unavailable")) { + dispatchFailed(id, "activity unavailable") + } return } - if (activity == null) { - Log.d(TAG, "bindAd: waiting for Activity id=$id attempt=$bindAttempts") - retryOrFail(id, "activity unavailable") + ensureNativeAdView() + val view = nativeAdView ?: run { + dispatchFailed(id, "native ad view missing") return } + // Populate assets BEFORE registerView (Appodeal custom-layout contract). + titleView?.text = ad.title.orEmpty().ifEmpty { "Ad" } + descriptionView?.text = ad.description.orEmpty() + ctaView?.text = ad.callToAction.orEmpty().ifEmpty { "Learn more" } + post(measureAndLayout) val canShow = try { @@ -216,18 +320,16 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod false } if (!canShow) { - Log.w(TAG, "bindAd: canShow=false id=$id placement=$placement attempt=$bindAttempts") - retryOrFail(id, "canShow=false for placement=$placement") + if (!retry(gen, id, "canShow=false")) { + dispatchFailed(id, "canShow=false for placement=$placement") + } return } - // Ensure clean registration if something was partially bound. - if (boundAdId != null && boundAdId != id) { - try { - view.unregisterView() - } catch (_: Exception) { - } - boundAdId = null + // Do NOT destroy/recreate here — that was wiping a successful register. + try { + view.unregisterView() + } catch (_: Exception) { } val registered = try { @@ -239,36 +341,47 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod Log.d( TAG, - "registerView id=$id placement=$placement result=$registered size=${measuredWidth}x${measuredHeight}" + "registerView id=$id result=$registered size=${measuredWidth}x${measuredHeight} title=${ad.title}" ) + if (gen != bindGeneration) return + if (registered) { boundAdId = id bindAttempts = 0 visibility = VISIBLE view.visibility = VISIBLE - try { - (parent as? ViewGroup)?.bringChildToFront(this) - } catch (_: Exception) { - } - view.bringToFront() post(measureAndLayout) + // Second layout pass after SDK mutates media children. + uiHandler.postDelayed({ + if (gen == bindGeneration) post(measureAndLayout) + }, 100L) dispatchLoaded(id) - } else { - retryOrFail(id, "registerView returned false") + } else if (!retry(gen, id, "registerView returned false")) { + dispatchFailed(id, "registerView returned false") } } - /** @return true if a retry was scheduled */ - private fun retryOrFail(id: String, reason: String): Boolean { + private fun retry(gen: Int, id: String, reason: String): Boolean { + if (gen != bindGeneration) return true bindAttempts += 1 - if (bindAttempts <= MAX_BIND_ATTEMPTS) { - val delay = BIND_BASE_DELAY_MS * bindAttempts - scheduleBind(delay) - return true + if (bindAttempts > MAX_BIND_ATTEMPTS) return false + Log.d(TAG, "retry #$bindAttempts id=$id reason=$reason") + scheduleBind(BIND_DELAY_MS * bindAttempts) + return true + } + + private fun unbindQuietly() { + try { + nativeAdView?.unregisterView() + } catch (_: Exception) { } - dispatchFailed(id, reason) - return false + try { + nativeAdView?.destroy() + } catch (_: Exception) { + } + boundAdId = null + bindAttempts = 0 } private fun dispatchLoaded(id: String) { @@ -286,8 +399,6 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod } override fun handleEvent(event: String, params: WritableMap?) { - // Global SDK callbacks fan out to every view — only forward if this - // view is bound, and attach adId when missing. when (event) { NativeEvents.ON_NATIVE_SHOWN -> { if (boundAdId == null) return @@ -299,11 +410,13 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod } NativeEvents.ON_NATIVE_EXPIRED -> { if (boundAdId == null) return - unregisterCurrent() - visibility = GONE + try { + nativeAdView?.unregisterView() + } catch (_: Exception) { + } + boundAdId = null dispatchFabricEvent(getId(), NativeEvents.ON_AD_EXPIRED, withAdId(params)) } - // Cache-level load events are not per-view bind results — ignore here. else -> Unit } } @@ -311,18 +424,12 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod private fun withAdId(params: WritableMap?): WritableMap { val copy = Arguments.createMap() if (params != null) copy.merge(params) - if (!copy.hasKey("adId")) { - copy.putString("adId", boundAdId ?: adId) - } + if (!copy.hasKey("adId")) copy.putString("adId", boundAdId ?: adId) return copy } - private fun dispatchFabricEvent( - viewId: Int, - eventName: String, - params: WritableMap? - ) { - val dispatcher: EventDispatcher? = + private fun dispatchFabricEvent(viewId: Int, eventName: String, params: WritableMap?) { + val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, viewId) dispatcher?.dispatchEvent(OnViewEvent(surfaceId, viewId, eventName, params)) } @@ -347,34 +454,40 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod companion object { private const val TAG = "RNAppodealNative" - private const val MAX_BIND_ATTEMPTS = 8 - private const val BIND_BASE_DELAY_MS = 250L + private const val MAX_BIND_ATTEMPTS = 6 + private const val BIND_DELAY_MS = 200L private val liveViews = CopyOnWriteArrayList>() fun notifyActivityReady() { - pruneLiveViews() - for (ref in liveViews) { - ref.get()?.onActivityReady() - } + prune() + for (ref in liveViews) ref.get()?.onActivityReady() } fun unbindAdId(adId: String) { - pruneLiveViews() + prune() for (ref in liveViews) { val view = ref.get() ?: continue if (view.adId == adId || view.boundAdId == adId) { - view.unregisterCurrent() - view.visibility = GONE + view.bindGeneration++ + try { + view.nativeAdView?.unregisterView() + } catch (_: Exception) { + } + view.boundAdId = null } } } - private fun pruneLiveViews() { - val it = liveViews.iterator() - while (it.hasNext()) { - if (it.next().get() == null) it.remove() - } + private fun prune() { + liveViews.removeAll { it.get() == null } } } } + +private fun dpPx(ctx: Context, value: Int): Int = + TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + value.toFloat(), + ctx.resources.displayMetrics + ).roundToInt() From 21a78e715510b80f4874c977027af7e0b1fdebbd Mon Sep 17 00:00:00 2001 From: Roo Date: Thu, 30 Jul 2026 23:30:41 -0400 Subject: [PATCH 10/16] fix(android): make RN native ad view extend NativeAdViewContentStream Nesting NativeAdView inside ReactViewGroup left a GONE/empty host in the UI hierarchy (banner WebView children rendered; native did not). Match the official Appodeal Android demo: the host IS NativeAdViewContentStream and registerView runs on self. Co-authored-by: Cursor --- .../rnappodeal/RCTAppodealNativeView.kt | 328 +++++------------- package.json | 2 +- 2 files changed, 84 insertions(+), 246 deletions(-) diff --git a/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt b/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt index adf4156..4320eba 100644 --- a/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt +++ b/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt @@ -2,21 +2,13 @@ package com.appodeal.rnappodeal import android.content.Context import android.graphics.Color -import android.graphics.Typeface import android.os.Handler import android.os.Looper import android.util.Log -import android.util.TypedValue -import android.view.Gravity import android.view.ViewGroup -import android.widget.Button -import android.widget.FrameLayout -import android.widget.LinearLayout -import android.widget.TextView import com.appodeal.ads.NativeAd -import com.appodeal.ads.nativead.NativeAdView -import com.appodeal.ads.nativead.NativeIconView -import com.appodeal.ads.nativead.NativeMediaView +import com.appodeal.ads.nativead.NativeAdViewContentStream +import com.appodeal.ads.nativead.Position import com.appodeal.rnappodeal.callbacks.RNAppodealEventHandler import com.appodeal.rnappodeal.constants.NativeEvents import com.facebook.react.bridge.Arguments @@ -24,22 +16,24 @@ import com.facebook.react.bridge.ReactContext import com.facebook.react.bridge.WritableMap import com.facebook.react.uimanager.UIManagerHelper import com.facebook.react.uimanager.events.Event -import com.facebook.react.uimanager.events.EventDispatcher -import com.facebook.react.views.view.ReactViewGroup import java.lang.ref.WeakReference import java.util.concurrent.CopyOnWriteArrayList -import kotlin.math.roundToInt /** - * Hosts an Appodeal native ad using a **custom** [NativeAdView] layout. + * React Native host for Appodeal native ads. * - * Stock templates (ContentStream/etc.) register successfully inside RN but stay - * blank / "not visible globally" because ReactViewGroup + template GONE/VISIBLE - * + recreate races destroy the view right after bind. Custom asset binding - * (title/CTA/media) matches Appodeal's documented custom-layout path and paints - * reliably in RN. + * Matches the official Appodeal Android demo + * (`appodeal/appodeal-android-sdk` → `NativeListAdapter` / `NativeActivity`): + * the list item **is** a [NativeAdViewContentStream], then `registerView(nativeAd)`. + * + * Previous approach nested a GONE-by-default [com.appodeal.ads.nativead.NativeAdView] + * inside [com.facebook.react.views.view.ReactViewGroup]. `registerView` returned true + * but the child stayed absent from the UI hierarchy (uiautomator: empty host), while + * banners (VISIBLE [com.appodeal.ads.BannerView] child) rendered fine. */ -class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppodealEventHandler { +class RCTAppodealNativeView( + private val reactContext: ReactContext +) : NativeAdViewContentStream(reactContext), RNAppodealEventHandler { private val surfaceId: Int by lazy { UIManagerHelper.getSurfaceId(reactContext) } private val uiHandler: Handler by lazy { Handler(Looper.getMainLooper()) } @@ -64,61 +58,40 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod } } - /** Kept for API compat; custom layout ignores template style. */ + /** Kept for API compat; ContentStream template is fixed (matches Android demo default). */ @Suppress("UNUSED_PARAMETER") var adTemplate: String = "contentStream" - private var nativeAdView: NativeAdView? = null - private var titleView: TextView? = null - private var descriptionView: TextView? = null - private var ctaView: Button? = null private var boundAdId: String? = null private var bindRunnable: Runnable? = null private var bindGeneration: Int = 0 private var bindAttempts: Int = 0 - private val measureAndLayout = Runnable { - val w = measuredWidth - val h = measuredHeight - if (w <= 0 || h <= 0) return@Runnable - for (i in 0 until childCount) { - val child = getChildAt(i) - child.measure( - MeasureSpec.makeMeasureSpec(w, MeasureSpec.EXACTLY), - MeasureSpec.makeMeasureSpec(h, MeasureSpec.EXACTLY) - ) - child.layout(0, 0, child.measuredWidth, child.measuredHeight) - } - } - init { liveViews.add(WeakReference(this)) - ensureNativeAdView() - } - - override fun requestLayout() { - super.requestLayout() - post(measureAndLayout) - } - - override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { - super.onLayout(changed, left, top, right, bottom) - post(measureAndLayout) - if (right - left > 0 && bottom - top > 0 && boundAdId == null && !adId.isNullOrEmpty()) { - scheduleBind(0) - } + // Demo XML starts templates as gone; RN must keep a real laid-out host so + // Appodeal's viewability check can pass. Force visible like BannerView hosts. + visibility = VISIBLE + descendantFocusability = FOCUS_BLOCK_DESCENDANTS + setBackgroundColor(Color.WHITE) + setAdChoicesPosition(Position.END_TOP) + contentDescription = "appodeal-native-ad" } fun cleanup() { bindRunnable?.let { uiHandler.removeCallbacks(it) } bindRunnable = null bindGeneration++ - unbindQuietly() - removeAllViews() - nativeAdView = null - titleView = null - descriptionView = null - ctaView = null + try { + unregisterView() + } catch (_: Exception) { + } + try { + destroy() + } catch (_: Exception) { + } + boundAdId = null + bindAttempts = 0 val self = this liveViews.removeAll { it.get() == null || it.get() === self } } @@ -129,137 +102,18 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod } } - private fun hostActivityContext(): Context { - return RNAppodealActivityHolder.get() - ?: reactContext.currentActivity - ?: context - } - - private fun ensureNativeAdView() { - if (nativeAdView != null) return - val view = buildCustomNativeAdView(hostActivityContext()) - nativeAdView = view - addView( - view, - LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) - ) - post(measureAndLayout) - } - - private fun buildCustomNativeAdView(ctx: Context): NativeAdView { - val adView = NativeAdView(ctx) - adView.setBackgroundColor(Color.WHITE) - - val root = LinearLayout(ctx).apply { - orientation = LinearLayout.VERTICAL - setBackgroundColor(Color.WHITE) - setPadding(dpPx(ctx, 8), dpPx(ctx, 8), dpPx(ctx, 8), dpPx(ctx, 8)) - } - - val attribution = TextView(ctx).apply { - text = "Ad" - setTextColor(Color.WHITE) - setBackgroundColor(Color.parseColor("#FF9800")) - setPadding(dpPx(ctx, 6), dpPx(ctx, 2), dpPx(ctx, 6), dpPx(ctx, 2)) - textSize = 10f - typeface = Typeface.DEFAULT_BOLD - } - - val header = LinearLayout(ctx).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - } - - val icon = NativeIconView(ctx) - val iconLp = LinearLayout.LayoutParams(dpPx(ctx, 40), dpPx(ctx, 40)).apply { - rightMargin = dpPx(ctx, 8) - } - - val title = TextView(ctx).apply { - setTextColor(Color.BLACK) - textSize = 14f - typeface = Typeface.DEFAULT_BOLD - maxLines = 2 - } - titleView = title - - header.addView(icon, iconLp) - header.addView( - title, - LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f) - ) - - val description = TextView(ctx).apply { - setTextColor(Color.DKGRAY) - textSize = 12f - maxLines = 3 - } - descriptionView = description - - val media = NativeMediaView(ctx).apply { - setBackgroundColor(Color.parseColor("#EEEEEE")) + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + if (right - left > 0 && bottom - top > 0 && boundAdId == null && !adId.isNullOrEmpty()) { + scheduleBind(0) } + } - val cta = Button(ctx).apply { - isAllCaps = false - setTextColor(Color.WHITE) - setBackgroundColor(Color.parseColor("#1A73E8")) + override fun onAttachedToWindow() { + super.onAttachedToWindow() + if (boundAdId == null && !adId.isNullOrEmpty()) { + scheduleBind(0) } - ctaView = cta - - root.addView( - attribution, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ) - ) - root.addView( - header, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ).apply { topMargin = dpPx(ctx, 6) } - ) - root.addView( - description, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ).apply { topMargin = dpPx(ctx, 4) } - ) - root.addView( - media, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - 0, - 1f - ).apply { topMargin = dpPx(ctx, 6) } - ) - root.addView( - cta, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ).apply { topMargin = dpPx(ctx, 6) } - ) - - adView.addView( - root, - FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - ) - - adView.setTitleView(title) - adView.setDescriptionView(description) - adView.setCallToActionView(cta) - adView.setIconView(icon) - adView.setMediaView(media) - adView.setAdAttributionView(attribution) - - return adView } private fun scheduleBind(delayMs: Long) { @@ -285,13 +139,20 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod return } - if (measuredWidth <= 0 || measuredHeight <= 0) { + if (width <= 0 || height <= 0) { if (!retry(gen, id, "view has zero size")) { dispatchFailed(id, "view has zero size") } return } + if (!isAttachedToWindow) { + if (!retry(gen, id, "not attached to window")) { + dispatchFailed(id, "not attached to window") + } + return + } + val activity = RNAppodealActivityHolder.get() ?: reactContext.currentActivity if (activity == null) { if (!retry(gen, id, "activity unavailable")) { @@ -300,19 +161,6 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod return } - ensureNativeAdView() - val view = nativeAdView ?: run { - dispatchFailed(id, "native ad view missing") - return - } - - // Populate assets BEFORE registerView (Appodeal custom-layout contract). - titleView?.text = ad.title.orEmpty().ifEmpty { "Ad" } - descriptionView?.text = ad.description.orEmpty() - ctaView?.text = ad.callToAction.orEmpty().ifEmpty { "Learn more" } - - post(measureAndLayout) - val canShow = try { ad.canShow(activity, placement) } catch (e: Exception) { @@ -326,22 +174,33 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod return } - // Do NOT destroy/recreate here — that was wiping a successful register. + // Same sequence as NativeActivity.showNative / NativeListAdapter.bind: + // configure → registerView(nativeAd). Do not destroy/recreate between binds. try { - view.unregisterView() + unregisterView() } catch (_: Exception) { } + visibility = VISIBLE + val registered = try { - view.registerView(ad, placement) + registerView(ad, placement) } catch (e: Exception) { Log.e(TAG, "registerView threw", e) false } + // SDK flips GONE→VISIBLE on success; keep forcing VISIBLE for RN hosts. + visibility = VISIBLE + bringToFront() + (parent as? ViewGroup)?.bringChildToFront(this) + requestLayout() + invalidate() + Log.d( TAG, - "registerView id=$id result=$registered size=${measuredWidth}x${measuredHeight} title=${ad.title}" + "registerView id=$id result=$registered size=${width}x${height} " + + "vis=$visibility attached=$isAttachedToWindow title=${ad.title}" ) if (gen != bindGeneration) return @@ -349,12 +208,14 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod if (registered) { boundAdId = id bindAttempts = 0 - visibility = VISIBLE - view.visibility = VISIBLE - post(measureAndLayout) - // Second layout pass after SDK mutates media children. uiHandler.postDelayed({ - if (gen == bindGeneration) post(measureAndLayout) + if (gen != bindGeneration) return@postDelayed + visibility = VISIBLE + requestLayout() + Log.d( + TAG, + "post-bind childCount=$childCount size=${width}x${height} vis=$visibility" + ) }, 100L) dispatchLoaded(id) } else if (!retry(gen, id, "registerView returned false")) { @@ -371,51 +232,38 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod return true } - private fun unbindQuietly() { - try { - nativeAdView?.unregisterView() - } catch (_: Exception) { - } - try { - nativeAdView?.destroy() - } catch (_: Exception) { - } - boundAdId = null - bindAttempts = 0 - } - - private fun dispatchLoaded(id: String) { - val params = Arguments.createMap().apply { putString("adId", id) } - dispatchFabricEvent(getId(), NativeEvents.ON_AD_LOADED, params) + private fun dispatchLoaded(adIdValue: String) { + val params = Arguments.createMap().apply { putString("adId", adIdValue) } + dispatchFabricEvent(id, NativeEvents.ON_AD_LOADED, params) } - private fun dispatchFailed(id: String, message: String) { - Log.w(TAG, "bind failed id=$id message=$message") + private fun dispatchFailed(adIdValue: String, message: String) { + Log.w(TAG, "bind failed id=$adIdValue message=$message") val params = Arguments.createMap().apply { - putString("adId", id) + putString("adId", adIdValue) putString("message", message) } - dispatchFabricEvent(getId(), NativeEvents.ON_AD_FAILED_TO_LOAD, params) + dispatchFabricEvent(id, NativeEvents.ON_AD_FAILED_TO_LOAD, params) } override fun handleEvent(event: String, params: WritableMap?) { when (event) { NativeEvents.ON_NATIVE_SHOWN -> { if (boundAdId == null) return - dispatchFabricEvent(getId(), NativeEvents.ON_AD_SHOWN, withAdId(params)) + dispatchFabricEvent(id, NativeEvents.ON_AD_SHOWN, withAdId(params)) } NativeEvents.ON_NATIVE_CLICKED -> { if (boundAdId == null) return - dispatchFabricEvent(getId(), NativeEvents.ON_AD_CLICKED, withAdId(params)) + dispatchFabricEvent(id, NativeEvents.ON_AD_CLICKED, withAdId(params)) } NativeEvents.ON_NATIVE_EXPIRED -> { if (boundAdId == null) return try { - nativeAdView?.unregisterView() + unregisterView() } catch (_: Exception) { } boundAdId = null - dispatchFabricEvent(getId(), NativeEvents.ON_AD_EXPIRED, withAdId(params)) + dispatchFabricEvent(id, NativeEvents.ON_AD_EXPIRED, withAdId(params)) } else -> Unit } @@ -449,9 +297,6 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod } } - private val reactContext: ReactContext - get() = context as ReactContext - companion object { private const val TAG = "RNAppodealNative" private const val MAX_BIND_ATTEMPTS = 6 @@ -471,7 +316,7 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod if (view.adId == adId || view.boundAdId == adId) { view.bindGeneration++ try { - view.nativeAdView?.unregisterView() + view.unregisterView() } catch (_: Exception) { } view.boundAdId = null @@ -484,10 +329,3 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod } } } - -private fun dpPx(ctx: Context, value: Int): Int = - TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, - value.toFloat(), - ctx.resources.displayMetrics - ).roundToInt() diff --git a/package.json b/package.json index 79993ae..d594b4c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-appodeal", - "version": "4.3.1", + "version": "4.3.2", "description": "React Native Module created to support Appodeal SDK for iOS and Android platforms", "main": "./src/index.tsx", "react-native": "./src/index.tsx", From b493595770509c829a9adad6fd66e047bf136af9 Mon Sep 17 00:00:00 2001 From: Roo Date: Thu, 30 Jul 2026 23:34:14 -0400 Subject: [PATCH 11/16] fix(android): compose NativeAdView template inside FrameLayout host NativeAdViewContentStream is final and cannot be subclassed. Host is a VISIBLE FrameLayout; template is added as a MATCH_PARENT child with visibility forced VISIBLE before registerView (same attach pattern as the working banner bridge). Co-authored-by: Cursor --- .../rnappodeal/RCTAppodealNativeView.kt | 147 +++++++++++++----- package.json | 2 +- 2 files changed, 106 insertions(+), 43 deletions(-) diff --git a/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt b/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt index 4320eba..06ddf76 100644 --- a/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt +++ b/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt @@ -6,8 +6,12 @@ import android.os.Handler import android.os.Looper import android.util.Log import android.view.ViewGroup +import android.widget.FrameLayout import com.appodeal.ads.NativeAd +import com.appodeal.ads.nativead.NativeAdView +import com.appodeal.ads.nativead.NativeAdViewAppWall import com.appodeal.ads.nativead.NativeAdViewContentStream +import com.appodeal.ads.nativead.NativeAdViewNewsFeed import com.appodeal.ads.nativead.Position import com.appodeal.rnappodeal.callbacks.RNAppodealEventHandler import com.appodeal.rnappodeal.constants.NativeEvents @@ -20,21 +24,19 @@ import java.lang.ref.WeakReference import java.util.concurrent.CopyOnWriteArrayList /** - * React Native host for Appodeal native ads. + * RN host for Appodeal native ads. * - * Matches the official Appodeal Android demo - * (`appodeal/appodeal-android-sdk` → `NativeListAdapter` / `NativeActivity`): - * the list item **is** a [NativeAdViewContentStream], then `registerView(nativeAd)`. + * [NativeAdViewContentStream] / templates are **final** — cannot subclass. + * Official Android demo uses the template as the RecyclerView item root. + * Here we mirror the working banner bridge: always-VISIBLE [FrameLayout] host, + * template as MATCH_PARENT child, then [NativeAdView.registerView]. * - * Previous approach nested a GONE-by-default [com.appodeal.ads.nativead.NativeAdView] - * inside [com.facebook.react.views.view.ReactViewGroup]. `registerView` returned true - * but the child stayed absent from the UI hierarchy (uiautomator: empty host), while - * banners (VISIBLE [com.appodeal.ads.BannerView] child) rendered fine. + * Critical: templates default to GONE. Banner works because [com.appodeal.ads.BannerView] + * is VISIBLE before addView. We force the same. */ -class RCTAppodealNativeView( - private val reactContext: ReactContext -) : NativeAdViewContentStream(reactContext), RNAppodealEventHandler { +class RCTAppodealNativeView(context: Context) : FrameLayout(context), RNAppodealEventHandler { + private val reactContext: ReactContext = context as ReactContext private val surfaceId: Int by lazy { UIManagerHelper.getSurfaceId(reactContext) } private val uiHandler: Handler by lazy { Handler(Looper.getMainLooper()) } @@ -58,10 +60,19 @@ class RCTAppodealNativeView( } } - /** Kept for API compat; ContentStream template is fixed (matches Android demo default). */ - @Suppress("UNUSED_PARAMETER") var adTemplate: String = "contentStream" + set(value) { + val normalized = value.ifBlank { "contentStream" } + if (field == normalized) return + field = normalized + // Template class change requires a new child instance. + tearDownAdView() + boundAdId = null + bindGeneration++ + scheduleBind(BIND_DELAY_MS) + } + private var adView: NativeAdView? = null private var boundAdId: String? = null private var bindRunnable: Runnable? = null private var bindGeneration: Int = 0 @@ -69,27 +80,18 @@ class RCTAppodealNativeView( init { liveViews.add(WeakReference(this)) - // Demo XML starts templates as gone; RN must keep a real laid-out host so - // Appodeal's viewability check can pass. Force visible like BannerView hosts. visibility = VISIBLE - descendantFocusability = FOCUS_BLOCK_DESCENDANTS setBackgroundColor(Color.WHITE) - setAdChoicesPosition(Position.END_TOP) - contentDescription = "appodeal-native-ad" + contentDescription = "appodeal-native-host" + clipChildren = false + clipToPadding = false } fun cleanup() { bindRunnable?.let { uiHandler.removeCallbacks(it) } bindRunnable = null bindGeneration++ - try { - unregisterView() - } catch (_: Exception) { - } - try { - destroy() - } catch (_: Exception) { - } + tearDownAdView() boundAdId = null bindAttempts = 0 val self = this @@ -116,6 +118,66 @@ class RCTAppodealNativeView( } } + private fun tearDownAdView() { + val view = adView + adView = null + if (view != null) { + try { + view.unregisterView() + } catch (_: Exception) { + } + try { + view.destroy() + } catch (_: Exception) { + } + (view.parent as? ViewGroup)?.removeView(view) + } + removeAllViews() + } + + /** + * Same attach sequence as [RCTAppodealBannerView.showBannerView]: + * VISIBLE → layout params → removeAllViews → addView → bringToFront. + */ + private fun ensureAdView(): NativeAdView { + adView?.let { existing -> + existing.visibility = VISIBLE + return existing + } + + val view = createTemplate(adTemplate).apply { + // Templates default to GONE — that was the blank-card root cause. + visibility = VISIBLE + descendantFocusability = FOCUS_BLOCK_DESCENDANTS + setAdChoicesPosition(Position.END_TOP) + contentDescription = "appodeal-native-ad" + layoutParams = LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + } + + removeAllViews() + this.visibility = VISIBLE + addView(view) + view.bringToFront() + try { + (parent as? ViewGroup)?.bringChildToFront(this) + } catch (_: Exception) { + } + + adView = view + return view + } + + private fun createTemplate(template: String): NativeAdView { + return when (template) { + "newsFeed" -> NativeAdViewNewsFeed(context) + "appWall" -> NativeAdViewAppWall(context) + else -> NativeAdViewContentStream(context) + } + } + private fun scheduleBind(delayMs: Long) { bindRunnable?.let { uiHandler.removeCallbacks(it) } val gen = bindGeneration @@ -174,33 +236,32 @@ class RCTAppodealNativeView( return } - // Same sequence as NativeActivity.showNative / NativeListAdapter.bind: - // configure → registerView(nativeAd). Do not destroy/recreate between binds. + val view = ensureAdView() + view.visibility = VISIBLE + try { - unregisterView() + view.unregisterView() } catch (_: Exception) { } - visibility = VISIBLE - val registered = try { - registerView(ad, placement) + view.registerView(ad, placement) } catch (e: Exception) { Log.e(TAG, "registerView threw", e) false } - // SDK flips GONE→VISIBLE on success; keep forcing VISIBLE for RN hosts. - visibility = VISIBLE - bringToFront() - (parent as? ViewGroup)?.bringChildToFront(this) + view.visibility = VISIBLE + this.visibility = VISIBLE + view.bringToFront() requestLayout() invalidate() Log.d( TAG, - "registerView id=$id result=$registered size=${width}x${height} " + - "vis=$visibility attached=$isAttachedToWindow title=${ad.title}" + "registerView id=$id result=$registered host=${width}x${height} " + + "childCount=$childCount childVis=${view.visibility} " + + "attached=$isAttachedToWindow title=${ad.title}" ) if (gen != bindGeneration) return @@ -210,11 +271,13 @@ class RCTAppodealNativeView( bindAttempts = 0 uiHandler.postDelayed({ if (gen != bindGeneration) return@postDelayed - visibility = VISIBLE + view.visibility = VISIBLE + this.visibility = VISIBLE requestLayout() Log.d( TAG, - "post-bind childCount=$childCount size=${width}x${height} vis=$visibility" + "post-bind childCount=$childCount childVis=${view.visibility} " + + "childSize=${view.width}x${view.height}" ) }, 100L) dispatchLoaded(id) @@ -259,7 +322,7 @@ class RCTAppodealNativeView( NativeEvents.ON_NATIVE_EXPIRED -> { if (boundAdId == null) return try { - unregisterView() + adView?.unregisterView() } catch (_: Exception) { } boundAdId = null @@ -316,7 +379,7 @@ class RCTAppodealNativeView( if (view.adId == adId || view.boundAdId == adId) { view.bindGeneration++ try { - view.unregisterView() + view.adView?.unregisterView() } catch (_: Exception) { } view.boundAdId = null diff --git a/package.json b/package.json index d594b4c..80bc3c7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-appodeal", - "version": "4.3.2", + "version": "4.3.3", "description": "React Native Module created to support Appodeal SDK for iOS and Android platforms", "main": "./src/index.tsx", "react-native": "./src/index.tsx", From 6042aaaf5a342d8f5cea9ab66615b1e380abb201 Mon Sep 17 00:00:00 2001 From: Roo Date: Thu, 30 Jul 2026 23:47:32 -0400 Subject: [PATCH 12/16] fix(android): measureAndLayout native ad child before registerView Logs showed registerView=true but childSize=0x0, so Appodeal reported "ad not visible globally". RN does not lay out non-RN children; apply the same measureAndLayout path used by the working banner/MREC views. Co-authored-by: Cursor --- .../rnappodeal/RCTAppodealNativeView.kt | 111 ++++++++++++------ package.json | 2 +- 2 files changed, 76 insertions(+), 37 deletions(-) diff --git a/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt b/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt index 06ddf76..3493cb9 100644 --- a/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt +++ b/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt @@ -20,21 +20,20 @@ import com.facebook.react.bridge.ReactContext import com.facebook.react.bridge.WritableMap import com.facebook.react.uimanager.UIManagerHelper import com.facebook.react.uimanager.events.Event +import com.facebook.react.views.view.ReactViewGroup import java.lang.ref.WeakReference import java.util.concurrent.CopyOnWriteArrayList /** * RN host for Appodeal native ads. * - * [NativeAdViewContentStream] / templates are **final** — cannot subclass. - * Official Android demo uses the template as the RecyclerView item root. - * Here we mirror the working banner bridge: always-VISIBLE [FrameLayout] host, - * template as MATCH_PARENT child, then [NativeAdView.registerView]. + * Templates are final (cannot subclass). Host mirrors the working banner bridge: + * [ReactViewGroup] + VISIBLE template child + manual [measureAndLayout]. * - * Critical: templates default to GONE. Banner works because [com.appodeal.ads.BannerView] - * is VISIBLE before addView. We force the same. + * Without measureAndLayout, registerView succeeds but the child stays 0×0 + * (`childSize=0x0`) and Appodeal logs "ad not visible globally". */ -class RCTAppodealNativeView(context: Context) : FrameLayout(context), RNAppodealEventHandler { +class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppodealEventHandler { private val reactContext: ReactContext = context as ReactContext private val surfaceId: Int by lazy { UIManagerHelper.getSurfaceId(reactContext) } @@ -65,7 +64,6 @@ class RCTAppodealNativeView(context: Context) : FrameLayout(context), RNAppodeal val normalized = value.ifBlank { "contentStream" } if (field == normalized) return field = normalized - // Template class change requires a new child instance. tearDownAdView() boundAdId = null bindGeneration++ @@ -78,6 +76,22 @@ class RCTAppodealNativeView(context: Context) : FrameLayout(context), RNAppodeal private var bindGeneration: Int = 0 private var bindAttempts: Int = 0 + /** Same as banner/MREC — RN will not size native children otherwise. */ + private val measureAndLayout = Runnable { + val w = measuredWidth + val h = measuredHeight + if (w <= 0 || h <= 0) return@Runnable + for (i in 0 until childCount) { + val child = getChildAt(i) + child.visibility = VISIBLE + child.measure( + MeasureSpec.makeMeasureSpec(w, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(h, MeasureSpec.EXACTLY) + ) + child.layout(0, 0, child.measuredWidth, child.measuredHeight) + } + } + init { liveViews.add(WeakReference(this)) visibility = VISIBLE @@ -87,6 +101,26 @@ class RCTAppodealNativeView(context: Context) : FrameLayout(context), RNAppodeal clipToPadding = false } + override fun requestLayout() { + super.requestLayout() + post(measureAndLayout) + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + post(measureAndLayout) + if (right - left > 0 && bottom - top > 0 && boundAdId == null && !adId.isNullOrEmpty()) { + scheduleBind(0) + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + if (boundAdId == null && !adId.isNullOrEmpty()) { + scheduleBind(0) + } + } + fun cleanup() { bindRunnable?.let { uiHandler.removeCallbacks(it) } bindRunnable = null @@ -104,20 +138,6 @@ class RCTAppodealNativeView(context: Context) : FrameLayout(context), RNAppodeal } } - override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { - super.onLayout(changed, left, top, right, bottom) - if (right - left > 0 && bottom - top > 0 && boundAdId == null && !adId.isNullOrEmpty()) { - scheduleBind(0) - } - } - - override fun onAttachedToWindow() { - super.onAttachedToWindow() - if (boundAdId == null && !adId.isNullOrEmpty()) { - scheduleBind(0) - } - } - private fun tearDownAdView() { val view = adView adView = null @@ -135,23 +155,20 @@ class RCTAppodealNativeView(context: Context) : FrameLayout(context), RNAppodeal removeAllViews() } - /** - * Same attach sequence as [RCTAppodealBannerView.showBannerView]: - * VISIBLE → layout params → removeAllViews → addView → bringToFront. - */ private fun ensureAdView(): NativeAdView { adView?.let { existing -> existing.visibility = VISIBLE + post(measureAndLayout) return existing } val view = createTemplate(adTemplate).apply { - // Templates default to GONE — that was the blank-card root cause. + // Templates default to GONE — force VISIBLE like BannerView. visibility = VISIBLE descendantFocusability = FOCUS_BLOCK_DESCENDANTS setAdChoicesPosition(Position.END_TOP) contentDescription = "appodeal-native-ad" - layoutParams = LayoutParams( + layoutParams = FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT ) @@ -165,6 +182,7 @@ class RCTAppodealNativeView(context: Context) : FrameLayout(context), RNAppodeal (parent as? ViewGroup)?.bringChildToFront(this) } catch (_: Exception) { } + post(measureAndLayout) adView = view return view @@ -201,7 +219,7 @@ class RCTAppodealNativeView(context: Context) : FrameLayout(context), RNAppodeal return } - if (width <= 0 || height <= 0) { + if (measuredWidth <= 0 || measuredHeight <= 0) { if (!retry(gen, id, "view has zero size")) { dispatchFailed(id, "view has zero size") } @@ -236,8 +254,17 @@ class RCTAppodealNativeView(context: Context) : FrameLayout(context), RNAppodeal return } + // Size the child BEFORE registerView so viewability can pass. val view = ensureAdView() view.visibility = VISIBLE + runMeasureAndLayoutNow() + + if (view.width <= 0 || view.height <= 0) { + if (!retry(gen, id, "child still 0x0 after layout")) { + dispatchFailed(id, "child still 0x0 after layout") + } + return + } try { view.unregisterView() @@ -253,15 +280,13 @@ class RCTAppodealNativeView(context: Context) : FrameLayout(context), RNAppodeal view.visibility = VISIBLE this.visibility = VISIBLE - view.bringToFront() - requestLayout() - invalidate() + runMeasureAndLayoutNow() Log.d( TAG, - "registerView id=$id result=$registered host=${width}x${height} " + + "registerView id=$id result=$registered host=${measuredWidth}x${measuredHeight} " + "childCount=$childCount childVis=${view.visibility} " + - "attached=$isAttachedToWindow title=${ad.title}" + "childSize=${view.width}x${view.height} title=${ad.title}" ) if (gen != bindGeneration) return @@ -272,8 +297,7 @@ class RCTAppodealNativeView(context: Context) : FrameLayout(context), RNAppodeal uiHandler.postDelayed({ if (gen != bindGeneration) return@postDelayed view.visibility = VISIBLE - this.visibility = VISIBLE - requestLayout() + runMeasureAndLayoutNow() Log.d( TAG, "post-bind childCount=$childCount childVis=${view.visibility} " + @@ -286,6 +310,21 @@ class RCTAppodealNativeView(context: Context) : FrameLayout(context), RNAppodeal } } + private fun runMeasureAndLayoutNow() { + val w = measuredWidth + val h = measuredHeight + if (w <= 0 || h <= 0) return + for (i in 0 until childCount) { + val child = getChildAt(i) + child.visibility = VISIBLE + child.measure( + MeasureSpec.makeMeasureSpec(w, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(h, MeasureSpec.EXACTLY) + ) + child.layout(0, 0, child.measuredWidth, child.measuredHeight) + } + } + private fun retry(gen: Int, id: String, reason: String): Boolean { if (gen != bindGeneration) return true bindAttempts += 1 diff --git a/package.json b/package.json index 80bc3c7..1823b81 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-appodeal", - "version": "4.3.3", + "version": "4.3.4", "description": "React Native Module created to support Appodeal SDK for iOS and Android platforms", "main": "./src/index.tsx", "react-native": "./src/index.tsx", From c3c4bad005a297f8c38886d96b1546f81bbadefe Mon Sep 17 00:00:00 2001 From: Roo Date: Fri, 31 Jul 2026 00:39:17 -0400 Subject: [PATCH 13/16] feat(android): add gridCard native template matching AdMob feed layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stock contentStream is a horizontal news layout and looks broken in portrait grid cells. gridCard is a custom NativeAdView: media ~56% top, icon/title/body, then CTA — same structure as the previous AdMob card. Co-authored-by: Cursor --- .../rnappodeal/RCTAppodealNativeView.kt | 186 +++++++++++++++++- package.json | 2 +- src/RNAppodealNative.tsx | 2 +- .../AppodealNativeViewNativeComponent.ts | 2 +- src/types/AppodealAdTypes.ts | 7 +- 5 files changed, 194 insertions(+), 5 deletions(-) diff --git a/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt b/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt index 3493cb9..d304bf0 100644 --- a/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt +++ b/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt @@ -2,16 +2,23 @@ package com.appodeal.rnappodeal import android.content.Context import android.graphics.Color +import android.graphics.Typeface import android.os.Handler import android.os.Looper import android.util.Log +import android.util.TypedValue +import android.view.Gravity import android.view.ViewGroup import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView import com.appodeal.ads.NativeAd import com.appodeal.ads.nativead.NativeAdView import com.appodeal.ads.nativead.NativeAdViewAppWall import com.appodeal.ads.nativead.NativeAdViewContentStream import com.appodeal.ads.nativead.NativeAdViewNewsFeed +import com.appodeal.ads.nativead.NativeIconView +import com.appodeal.ads.nativead.NativeMediaView import com.appodeal.ads.nativead.Position import com.appodeal.rnappodeal.callbacks.RNAppodealEventHandler import com.appodeal.rnappodeal.constants.NativeEvents @@ -71,6 +78,9 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod } private var adView: NativeAdView? = null + private var titleView: TextView? = null + private var descriptionView: TextView? = null + private var ctaView: TextView? = null private var boundAdId: String? = null private var bindRunnable: Runnable? = null private var bindGeneration: Int = 0 @@ -95,7 +105,7 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod init { liveViews.add(WeakReference(this)) visibility = VISIBLE - setBackgroundColor(Color.WHITE) + setBackgroundColor(Color.TRANSPARENT) contentDescription = "appodeal-native-host" clipChildren = false clipToPadding = false @@ -153,6 +163,9 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod (view.parent as? ViewGroup)?.removeView(view) } removeAllViews() + titleView = null + descriptionView = null + ctaView = null } private fun ensureAdView(): NativeAdView { @@ -192,10 +205,163 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod return when (template) { "newsFeed" -> NativeAdViewNewsFeed(context) "appWall" -> NativeAdViewAppWall(context) + "gridCard" -> createGridCard(context) else -> NativeAdViewContentStream(context) } } + /** + * Portrait feed card matching our AdMob native layout: + * media ~56% top, then icon + title/body, then CTA. + * Stock contentStream is horizontal and looks broken in a tall grid cell. + */ + private fun createGridCard(ctx: Context): NativeAdView { + val adView = NativeAdView(ctx) + adView.setBackgroundColor(COLOR_SURFACE) + + val root = LinearLayout(ctx).apply { + orientation = LinearLayout.VERTICAL + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + } + + val mediaWrap = FrameLayout(ctx).apply { + setBackgroundColor(COLOR_MEDIA_BG) + } + val media = NativeMediaView(ctx) + mediaWrap.addView( + media, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + ) + val badge = TextView(ctx).apply { + text = "Ad" + setTextColor(Color.WHITE) + setBackgroundColor(0x8C000000.toInt()) + setPadding(dp(ctx, 8), dp(ctx, 4), dp(ctx, 8), dp(ctx, 4)) + textSize = 9f + typeface = Typeface.DEFAULT_BOLD + isAllCaps = true + } + mediaWrap.addView( + badge, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { + gravity = Gravity.START or Gravity.TOP + setMargins(dp(ctx, 8), dp(ctx, 8), 0, 0) + } + ) + root.addView( + mediaWrap, + LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 0.56f) + ) + + val panel = LinearLayout(ctx).apply { + orientation = LinearLayout.VERTICAL + setBackgroundColor(COLOR_SURFACE) + setPadding(dp(ctx, 10), dp(ctx, 10), dp(ctx, 10), dp(ctx, 10)) + } + + val header = LinearLayout(ctx).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.TOP + } + val icon = NativeIconView(ctx) + header.addView( + icon, + LinearLayout.LayoutParams(dp(ctx, 34), dp(ctx, 34)).apply { + rightMargin = dp(ctx, 8) + } + ) + + val textCol = LinearLayout(ctx).apply { + orientation = LinearLayout.VERTICAL + } + val title = TextView(ctx).apply { + setTextColor(COLOR_TEXT) + textSize = 13f + typeface = Typeface.DEFAULT_BOLD + maxLines = 2 + setLineSpacing(0f, 1.15f) + } + titleView = title + val description = TextView(ctx).apply { + setTextColor(COLOR_TEXT_SECONDARY) + textSize = 11f + maxLines = 1 + } + descriptionView = description + textCol.addView( + title, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + ) + textCol.addView( + description, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { topMargin = dp(ctx, 2) } + ) + header.addView( + textCol, + LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f) + ) + panel.addView( + header, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + ) + + val cta = TextView(ctx).apply { + setTextColor(Color.WHITE) + setBackgroundColor(COLOR_CTA) + gravity = Gravity.CENTER + textSize = 12f + typeface = Typeface.DEFAULT_BOLD + setPadding(dp(ctx, 12), dp(ctx, 9), dp(ctx, 12), dp(ctx, 9)) + maxLines = 1 + } + ctaView = cta + panel.addView( + cta, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { topMargin = dp(ctx, 8) } + ) + + root.addView( + panel, + LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 0.44f) + ) + + adView.addView( + root, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + ) + adView.setMediaView(media) + adView.setIconView(icon) + adView.setTitleView(title) + adView.setDescriptionView(description) + adView.setCallToActionView(cta) + adView.setAdAttributionView(badge) + return adView + } + private fun scheduleBind(delayMs: Long) { bindRunnable?.let { uiHandler.removeCallbacks(it) } val gen = bindGeneration @@ -256,6 +422,10 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod // Size the child BEFORE registerView so viewability can pass. val view = ensureAdView() + // Custom gridCard needs text filled before registerView (stock templates self-fill). + titleView?.text = ad.title.orEmpty().ifEmpty { "Ad" } + descriptionView?.text = ad.description.orEmpty() + ctaView?.text = ad.callToAction.orEmpty().ifEmpty { "Learn more" } view.visibility = VISIBLE runMeasureAndLayoutNow() @@ -429,5 +599,19 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod private fun prune() { liveViews.removeAll { it.get() == null } } + + // Match light AdMob card tokens (RN outerClip still applies theme surface/radius). + private val COLOR_SURFACE: Int = Color.WHITE + private val COLOR_MEDIA_BG: Int = Color.parseColor("#1A1515") + private val COLOR_TEXT: Int = Color.parseColor("#2D2424") + private val COLOR_TEXT_SECONDARY: Int = Color.parseColor("#7F6D5F") + private val COLOR_CTA: Int = Color.parseColor("#2D2424") } } + +private fun dp(ctx: Context, value: Int): Int = + TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + value.toFloat(), + ctx.resources.displayMetrics + ).toInt() diff --git a/package.json b/package.json index 1823b81..628ce1c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-appodeal", - "version": "4.3.4", + "version": "4.3.5", "description": "React Native Module created to support Appodeal SDK for iOS and Android platforms", "main": "./src/index.tsx", "react-native": "./src/index.tsx", diff --git a/src/RNAppodealNative.tsx b/src/RNAppodealNative.tsx index 2c4aff4..4d42cfd 100644 --- a/src/RNAppodealNative.tsx +++ b/src/RNAppodealNative.tsx @@ -1,7 +1,7 @@ /** * Appodeal Native Ad Component * - * Renders a platform native-ad template (newsFeed / appWall / contentStream) + * Renders a platform native-ad template (newsFeed / appWall / contentStream / gridCard) * bound to an ad id returned from Appodeal.getNativeAds(). */ import AppodealNativeView, { diff --git a/src/specs/AppodealNativeViewNativeComponent.ts b/src/specs/AppodealNativeViewNativeComponent.ts index 6ea5b92..292afe9 100644 --- a/src/specs/AppodealNativeViewNativeComponent.ts +++ b/src/specs/AppodealNativeViewNativeComponent.ts @@ -14,7 +14,7 @@ export type NativeAdLoadFailedEvent = Readonly<{ export interface NativeProps extends ViewProps { adId?: string; placement?: string; - /** Layout style: newsFeed | appWall | contentStream. Named adTemplate — `template` is a C++ keyword and breaks Fabric codegen. */ + /** Layout style: newsFeed | appWall | contentStream | gridCard. Named adTemplate — `template` is a C++ keyword and breaks Fabric codegen. */ adTemplate?: string; onAdLoaded?: DirectEventHandler; onAdFailedToLoad?: DirectEventHandler; diff --git a/src/types/AppodealAdTypes.ts b/src/types/AppodealAdTypes.ts index 3d85967..3d0716f 100644 --- a/src/types/AppodealAdTypes.ts +++ b/src/types/AppodealAdTypes.ts @@ -35,7 +35,12 @@ export type AppodealNativeContentType = 'auto' | 'noVideo' | 'video'; /** * Native ad template used by AppodealNative view */ -export type AppodealNativeTemplate = 'newsFeed' | 'appWall' | 'contentStream'; +export type AppodealNativeTemplate = + | 'newsFeed' + | 'appWall' + | 'contentStream' + /** Portrait feed card: media top ~56%, icon/title/CTA below (matches AdMob grid). */ + | 'gridCard'; /** * Metadata for a cached native ad pulled from the SDK From 0156e82f92268c5781ef3759728a5f9c0c6ad243 Mon Sep 17 00:00:00 2001 From: Roo Date: Fri, 31 Jul 2026 01:06:44 -0400 Subject: [PATCH 14/16] feat(native): add composable NativeAdView assets; drop gridCard Stock templates stay newsFeed/appWall/contentStream. Custom layouts use AppodealNativeAdView + asset children on Android and iOS (AdMob-style JSX). Co-authored-by: Cursor --- CHANGELOG.md | 15 + .../rnappodeal/RCTAppodealNativeAdView.kt | 371 ++++++++++++++++++ .../rnappodeal/RCTAppodealNativeAssetView.kt | 113 ++++++ .../rnappodeal/RCTAppodealNativeView.kt | 204 +--------- .../rnappodeal/RNAppodealModuleImpl.kt | 2 + .../RNAppodealNativeAdViewManagerImpl.kt | 34 ++ .../RNAppodealNativeAssetViewManagerImpl.kt | 17 + .../appodeal/rnappodeal/RNAppodealPackage.kt | 4 +- .../RNAppodealNativeAdViewManager.kt | 38 ++ .../RNAppodealNativeAssetViewManager.kt | 29 ++ .../RNAppodealNativeAdViewManager.java | 51 +++ .../RNAppodealNativeAssetViewManager.java | 38 ++ ios/RNAppodealNativeAdView.h | 24 ++ ios/RNAppodealNativeAdView.m | 237 +++++++++++ ios/RNAppodealNativeAdViewManager.mm | 23 ++ ios/RNAppodealNativeAssetView.h | 19 + ios/RNAppodealNativeAssetView.m | 83 ++++ ios/RNAppodealNativeAssetViewManager.mm | 17 + package.json | 2 +- src/RNAppodeal.ts | 2 +- src/RNAppodealNative.tsx | 6 +- src/RNAppodealNativeAdView.tsx | 66 ++++ src/index.tsx | 15 + .../AppodealNativeAdViewNativeComponent.ts | 26 ++ .../AppodealNativeAssetViewNativeComponent.ts | 14 + .../AppodealNativeViewNativeComponent.ts | 2 +- src/types/AppodealAdTypes.ts | 10 +- 27 files changed, 1248 insertions(+), 214 deletions(-) create mode 100644 android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeAdView.kt create mode 100644 android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeAssetView.kt create mode 100644 android/src/main/java/com/appodeal/rnappodeal/RNAppodealNativeAdViewManagerImpl.kt create mode 100644 android/src/main/java/com/appodeal/rnappodeal/RNAppodealNativeAssetViewManagerImpl.kt create mode 100644 android/src/newarch/com/appodeal/rnappodeal/RNAppodealNativeAdViewManager.kt create mode 100644 android/src/newarch/com/appodeal/rnappodeal/RNAppodealNativeAssetViewManager.kt create mode 100644 android/src/oldarch/com/appodeal/rnappodeal/RNAppodealNativeAdViewManager.java create mode 100644 android/src/oldarch/com/appodeal/rnappodeal/RNAppodealNativeAssetViewManager.java create mode 100644 ios/RNAppodealNativeAdView.h create mode 100644 ios/RNAppodealNativeAdView.m create mode 100644 ios/RNAppodealNativeAdViewManager.mm create mode 100644 ios/RNAppodealNativeAssetView.h create mode 100644 ios/RNAppodealNativeAssetView.m create mode 100644 ios/RNAppodealNativeAssetViewManager.mm create mode 100644 src/RNAppodealNativeAdView.tsx create mode 100644 src/specs/AppodealNativeAdViewNativeComponent.ts create mode 100644 src/specs/AppodealNativeAssetViewNativeComponent.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8af1ede..8bb128a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 4.3.1 + +### Features + +- **Composable native ads**: AdMob-style JSX layout via + `AppodealNativeAdView` + asset views (`media` / `icon` / `title` / + `description` / `callToAction` / `attribution`) on Android and iOS. + Matches Appodeal Android custom `NativeAdView` and iOS `APDNativeAdView` + asset binding — app owns styling in React Native. + +### Changes + +- Stock `` remains for + `newsFeed` / `appWall` / `contentStream` only (no app-specific templates). + ## 4.3.0 ### Features diff --git a/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeAdView.kt b/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeAdView.kt new file mode 100644 index 0000000..0b80acd --- /dev/null +++ b/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeAdView.kt @@ -0,0 +1,371 @@ +package com.appodeal.rnappodeal + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import com.appodeal.ads.NativeAd +import com.appodeal.ads.nativead.NativeAdView +import com.appodeal.ads.nativead.NativeIconView +import com.appodeal.ads.nativead.NativeMediaView +import com.appodeal.ads.nativead.Position +import com.appodeal.rnappodeal.callbacks.RNAppodealEventHandler +import com.appodeal.rnappodeal.constants.NativeEvents +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReactContext +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.events.Event +import java.lang.ref.WeakReference +import java.util.concurrent.CopyOnWriteArrayList + +/** + * Composable native ad root — matches Appodeal Android demo custom [NativeAdView] + * (`native_ad_view_custom.xml`): asset children + [registerView]. + */ +class RCTAppodealNativeAdView(context: Context) : + NativeAdView(context), + RNAppodealEventHandler { + + private val reactContext: ReactContext = context as ReactContext + private val surfaceId: Int by lazy { UIManagerHelper.getSurfaceId(reactContext) } + private val uiHandler: Handler by lazy { Handler(Looper.getMainLooper()) } + + var adId: String? = null + set(value) { + if (field == value) return + field = value + boundAdId = null + bindGeneration++ + scheduleBind(BIND_DELAY_MS) + } + + var placement: String = "default" + set(value) { + if (field == value) return + field = value + if (boundAdId != null) { + boundAdId = null + bindGeneration++ + scheduleBind(BIND_DELAY_MS) + } + } + + private var boundAdId: String? = null + private var bindRunnable: Runnable? = null + private var bindGeneration: Int = 0 + private var bindAttempts: Int = 0 + + init { + liveViews.add(WeakReference(this)) + visibility = VISIBLE + setAdChoicesPosition(Position.END_TOP) + clipChildren = false + clipToPadding = false + contentDescription = "appodeal-native-ad-view" + } + + fun onAssetChanged() { + if (boundAdId != null) { + boundAdId = null + bindGeneration++ + } + if (!adId.isNullOrEmpty()) { + scheduleBind(BIND_DELAY_MS) + } + } + + override fun onViewAdded(child: View?) { + super.onViewAdded(child) + if (!adId.isNullOrEmpty() && boundAdId == null) { + scheduleBind(BIND_DELAY_MS) + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + if (boundAdId == null && !adId.isNullOrEmpty()) { + scheduleBind(0) + } + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + if (right - left > 0 && bottom - top > 0 && boundAdId == null && !adId.isNullOrEmpty()) { + scheduleBind(0) + } + } + + fun cleanup() { + bindRunnable?.let { uiHandler.removeCallbacks(it) } + bindRunnable = null + bindGeneration++ + try { + unregisterView() + } catch (_: Exception) { + } + boundAdId = null + bindAttempts = 0 + val self = this + liveViews.removeAll { it.get() == null || it.get() === self } + } + + fun onActivityReady() { + if (boundAdId == null && !adId.isNullOrEmpty()) { + scheduleBind(0) + } + } + + private fun scheduleBind(delayMs: Long) { + bindRunnable?.let { uiHandler.removeCallbacks(it) } + val gen = bindGeneration + val runnable = Runnable { + if (gen != bindGeneration) return@Runnable + bindAd(gen) + }.also { bindRunnable = it } + uiHandler.postDelayed(runnable, delayMs.coerceAtLeast(0L)) + } + + private fun bindAd(gen: Int) { + if (gen != bindGeneration) return + val id = adId + if (id.isNullOrEmpty()) return + if (boundAdId == id) return + + val ad: NativeAd = RNAppodealNativeAdStore.get(id) ?: run { + if (!retry(gen, id, "native ad not in store")) { + dispatchFailed(id, "native ad not in store") + } + return + } + + if (measuredWidth <= 0 || measuredHeight <= 0) { + if (!retry(gen, id, "view has zero size")) { + dispatchFailed(id, "view has zero size") + } + return + } + + if (!isAttachedToWindow) { + if (!retry(gen, id, "not attached to window")) { + dispatchFailed(id, "not attached to window") + } + return + } + + val activity = RNAppodealActivityHolder.get() ?: reactContext.currentActivity + if (activity == null) { + if (!retry(gen, id, "activity unavailable")) { + dispatchFailed(id, "activity unavailable") + } + return + } + + val canShow = try { + ad.canShow(activity, placement) + } catch (e: Exception) { + Log.e(TAG, "canShow threw", e) + false + } + if (!canShow) { + if (!retry(gen, id, "canShow=false")) { + dispatchFailed(id, "canShow=false for placement=$placement") + } + return + } + + val assets = collectAssets(this) + if (assets.media == null && assets.icon == null) { + if (!retry(gen, id, "missing media/icon asset")) { + dispatchFailed(id, "composable native ad requires media or icon asset") + } + return + } + + try { + unregisterView() + } catch (_: Exception) { + } + + // Same wiring as native_ad_view_custom.xml attrs → registerView. + assets.media?.let { setMediaView(it) } + assets.icon?.let { setIconView(it) } + assets.title?.let { setTitleView(it) } + assets.description?.let { setDescriptionView(it) } + assets.callToAction?.let { setCallToActionView(it) } + assets.attribution?.let { setAdAttributionView(it) } + + val registered = try { + registerView(ad, placement) + } catch (e: Exception) { + Log.e(TAG, "registerView threw", e) + false + } + + Log.d( + TAG, + "composable registerView id=$id result=$registered " + + "size=${measuredWidth}x${measuredHeight} " + + "media=${assets.media != null} icon=${assets.icon != null} " + + "title=${assets.title != null} cta=${assets.callToAction != null}" + ) + + if (gen != bindGeneration) return + + if (registered) { + boundAdId = id + bindAttempts = 0 + dispatchLoaded(id) + } else if (!retry(gen, id, "registerView returned false")) { + dispatchFailed(id, "registerView returned false") + } + } + + private fun retry(gen: Int, id: String, reason: String): Boolean { + if (gen != bindGeneration) return true + bindAttempts += 1 + if (bindAttempts > MAX_BIND_ATTEMPTS) return false + Log.d(TAG, "retry #$bindAttempts id=$id reason=$reason") + scheduleBind(BIND_DELAY_MS * bindAttempts) + return true + } + + private fun dispatchLoaded(adIdValue: String) { + val params = Arguments.createMap().apply { putString("adId", adIdValue) } + dispatchFabricEvent(id, NativeEvents.ON_AD_LOADED, params) + } + + private fun dispatchFailed(adIdValue: String, message: String) { + Log.w(TAG, "bind failed id=$adIdValue message=$message") + val params = Arguments.createMap().apply { + putString("adId", adIdValue) + putString("message", message) + } + dispatchFabricEvent(id, NativeEvents.ON_AD_FAILED_TO_LOAD, params) + } + + override fun handleEvent(event: String, params: WritableMap?) { + when (event) { + NativeEvents.ON_NATIVE_SHOWN -> { + if (boundAdId == null) return + dispatchFabricEvent(id, NativeEvents.ON_AD_SHOWN, withAdId(params)) + } + NativeEvents.ON_NATIVE_CLICKED -> { + if (boundAdId == null) return + dispatchFabricEvent(id, NativeEvents.ON_AD_CLICKED, withAdId(params)) + } + NativeEvents.ON_NATIVE_EXPIRED -> { + if (boundAdId == null) return + try { + unregisterView() + } catch (_: Exception) { + } + boundAdId = null + dispatchFabricEvent(id, NativeEvents.ON_AD_EXPIRED, withAdId(params)) + } + else -> Unit + } + } + + private fun withAdId(params: WritableMap?): WritableMap { + val copy = Arguments.createMap() + if (params != null) copy.merge(params) + if (!copy.hasKey("adId")) copy.putString("adId", boundAdId ?: adId) + return copy + } + + private fun dispatchFabricEvent(viewId: Int, eventName: String, params: WritableMap?) { + val dispatcher = + UIManagerHelper.getEventDispatcherForReactTag(reactContext, viewId) + dispatcher?.dispatchEvent(OnViewEvent(surfaceId, viewId, eventName, params)) + } + + private class OnViewEvent( + surfaceId: Int, + viewId: Int, + private val eventNameParam: String, + private val payload: WritableMap? + ) : Event(surfaceId, viewId) { + override fun getEventName(): String = eventNameParam + override fun getEventData(): WritableMap? { + if (payload == null) return null + val copy = Arguments.createMap() + copy.merge(payload) + return copy + } + } + + private data class Assets( + val media: NativeMediaView?, + val icon: NativeIconView?, + val title: TextView?, + val description: TextView?, + val callToAction: View?, + val attribution: View? + ) + + companion object { + private const val TAG = "RNAppodealNativeAd" + private const val MAX_BIND_ATTEMPTS = 6 + private const val BIND_DELAY_MS = 200L + + private val liveViews = CopyOnWriteArrayList>() + + fun notifyActivityReady() { + prune() + for (ref in liveViews) ref.get()?.onActivityReady() + } + + fun unbindAdId(adId: String) { + prune() + for (ref in liveViews) { + val view = ref.get() ?: continue + if (view.adId == adId || view.boundAdId == adId) { + view.bindGeneration++ + try { + view.unregisterView() + } catch (_: Exception) { + } + view.boundAdId = null + } + } + } + + private fun prune() { + liveViews.removeAll { it.get() == null } + } + + private fun collectAssets(root: ViewGroup): Assets { + var media: NativeMediaView? = null + var icon: NativeIconView? = null + var title: TextView? = null + var description: TextView? = null + var callToAction: View? = null + var attribution: View? = null + + fun walk(view: View) { + if (view is RCTAppodealNativeAssetView) { + when (view.assetType) { + "media" -> media = view.mediaView() ?: media + "icon" -> icon = view.iconView() ?: icon + "title" -> title = view.textView() ?: title + "description" -> description = view.textView() ?: description + "callToAction" -> callToAction = view.textView() ?: callToAction + "attribution" -> attribution = view.textView() ?: attribution + } + } + if (view is ViewGroup) { + for (i in 0 until view.childCount) { + walk(view.getChildAt(i)) + } + } + } + + walk(root) + return Assets(media, icon, title, description, callToAction, attribution) + } + } +} diff --git a/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeAssetView.kt b/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeAssetView.kt new file mode 100644 index 0000000..657dcde --- /dev/null +++ b/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeAssetView.kt @@ -0,0 +1,113 @@ +package com.appodeal.rnappodeal + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.TextView +import com.appodeal.ads.nativead.NativeIconView +import com.appodeal.ads.nativead.NativeMediaView +import com.facebook.react.views.text.ReactTextView + +/** + * Marker view for a single native-ad asset inside [RCTAppodealNativeAdView]. + * + * Mirrors Appodeal Android demo custom layout children: + * [NativeMediaView], [NativeIconView], and TextViews for title / body / CTA. + */ +class RCTAppodealNativeAssetView(context: Context) : FrameLayout(context) { + + var assetType: String = "title" + set(value) { + val normalized = value.ifBlank { "title" } + if (field == normalized) return + field = normalized + rebuild() + } + + private var content: View? = null + + private val measureAndLayout = Runnable { + val w = measuredWidth + val h = measuredHeight + if (w <= 0 || h <= 0) return@Runnable + val child = content ?: return@Runnable + child.visibility = VISIBLE + child.measure( + MeasureSpec.makeMeasureSpec(w, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(h, MeasureSpec.EXACTLY) + ) + child.layout(0, 0, child.measuredWidth, child.measuredHeight) + } + + init { + clipChildren = false + clipToPadding = false + rebuild() + } + + override fun requestLayout() { + super.requestLayout() + post(measureAndLayout) + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + post(measureAndLayout) + } + + fun mediaView(): NativeMediaView? = content as? NativeMediaView + + fun iconView(): NativeIconView? = content as? NativeIconView + + fun textView(): TextView? = content as? TextView + + private fun rebuild() { + removeAllViews() + val view: View = when (assetType) { + "media" -> NativeMediaView(context) + "icon" -> NativeIconView(context) + else -> ReactTextView(context).apply { + when (assetType) { + "callToAction" -> { + setTextColor(0xFFFFFFFF.toInt()) + textSize = 12f + gravity = android.view.Gravity.CENTER + maxLines = 1 + } + "attribution" -> { + setTextColor(0xFFFFFFFF.toInt()) + textSize = 9f + gravity = android.view.Gravity.CENTER + text = "Ad" + maxLines = 1 + } + "description" -> { + setTextColor(0xFF7F6D5F.toInt()) + textSize = 11f + maxLines = 1 + } + else -> { + setTextColor(0xFF2D2424.toInt()) + textSize = 13f + maxLines = 2 + } + } + } + } + content = view + addView( + view, + LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + ) + post(measureAndLayout) + (parent as? RCTAppodealNativeAdView)?.onAssetChanged() + } + + companion object { + const val NAME = "RNAppodealNativeAssetView" + } +} diff --git a/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt b/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt index d304bf0..2e87579 100644 --- a/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt +++ b/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt @@ -2,23 +2,16 @@ package com.appodeal.rnappodeal import android.content.Context import android.graphics.Color -import android.graphics.Typeface import android.os.Handler import android.os.Looper import android.util.Log -import android.util.TypedValue -import android.view.Gravity import android.view.ViewGroup import android.widget.FrameLayout -import android.widget.LinearLayout -import android.widget.TextView import com.appodeal.ads.NativeAd import com.appodeal.ads.nativead.NativeAdView import com.appodeal.ads.nativead.NativeAdViewAppWall import com.appodeal.ads.nativead.NativeAdViewContentStream import com.appodeal.ads.nativead.NativeAdViewNewsFeed -import com.appodeal.ads.nativead.NativeIconView -import com.appodeal.ads.nativead.NativeMediaView import com.appodeal.ads.nativead.Position import com.appodeal.rnappodeal.callbacks.RNAppodealEventHandler import com.appodeal.rnappodeal.constants.NativeEvents @@ -32,13 +25,10 @@ import java.lang.ref.WeakReference import java.util.concurrent.CopyOnWriteArrayList /** - * RN host for Appodeal native ads. + * RN host for Appodeal **stock** native templates + * (`newsFeed` / `appWall` / `contentStream`). * - * Templates are final (cannot subclass). Host mirrors the working banner bridge: - * [ReactViewGroup] + VISIBLE template child + manual [measureAndLayout]. - * - * Without measureAndLayout, registerView succeeds but the child stays 0×0 - * (`childSize=0x0`) and Appodeal logs "ad not visible globally". + * Custom layouts use [RCTAppodealNativeAdView] + asset children instead. */ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppodealEventHandler { @@ -78,15 +68,11 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod } private var adView: NativeAdView? = null - private var titleView: TextView? = null - private var descriptionView: TextView? = null - private var ctaView: TextView? = null private var boundAdId: String? = null private var bindRunnable: Runnable? = null private var bindGeneration: Int = 0 private var bindAttempts: Int = 0 - /** Same as banner/MREC — RN will not size native children otherwise. */ private val measureAndLayout = Runnable { val w = measuredWidth val h = measuredHeight @@ -163,9 +149,6 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod (view.parent as? ViewGroup)?.removeView(view) } removeAllViews() - titleView = null - descriptionView = null - ctaView = null } private fun ensureAdView(): NativeAdView { @@ -176,7 +159,6 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod } val view = createTemplate(adTemplate).apply { - // Templates default to GONE — force VISIBLE like BannerView. visibility = VISIBLE descendantFocusability = FOCUS_BLOCK_DESCENDANTS setAdChoicesPosition(Position.END_TOP) @@ -205,163 +187,10 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod return when (template) { "newsFeed" -> NativeAdViewNewsFeed(context) "appWall" -> NativeAdViewAppWall(context) - "gridCard" -> createGridCard(context) else -> NativeAdViewContentStream(context) } } - /** - * Portrait feed card matching our AdMob native layout: - * media ~56% top, then icon + title/body, then CTA. - * Stock contentStream is horizontal and looks broken in a tall grid cell. - */ - private fun createGridCard(ctx: Context): NativeAdView { - val adView = NativeAdView(ctx) - adView.setBackgroundColor(COLOR_SURFACE) - - val root = LinearLayout(ctx).apply { - orientation = LinearLayout.VERTICAL - layoutParams = FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - } - - val mediaWrap = FrameLayout(ctx).apply { - setBackgroundColor(COLOR_MEDIA_BG) - } - val media = NativeMediaView(ctx) - mediaWrap.addView( - media, - FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - ) - val badge = TextView(ctx).apply { - text = "Ad" - setTextColor(Color.WHITE) - setBackgroundColor(0x8C000000.toInt()) - setPadding(dp(ctx, 8), dp(ctx, 4), dp(ctx, 8), dp(ctx, 4)) - textSize = 9f - typeface = Typeface.DEFAULT_BOLD - isAllCaps = true - } - mediaWrap.addView( - badge, - FrameLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ).apply { - gravity = Gravity.START or Gravity.TOP - setMargins(dp(ctx, 8), dp(ctx, 8), 0, 0) - } - ) - root.addView( - mediaWrap, - LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 0.56f) - ) - - val panel = LinearLayout(ctx).apply { - orientation = LinearLayout.VERTICAL - setBackgroundColor(COLOR_SURFACE) - setPadding(dp(ctx, 10), dp(ctx, 10), dp(ctx, 10), dp(ctx, 10)) - } - - val header = LinearLayout(ctx).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.TOP - } - val icon = NativeIconView(ctx) - header.addView( - icon, - LinearLayout.LayoutParams(dp(ctx, 34), dp(ctx, 34)).apply { - rightMargin = dp(ctx, 8) - } - ) - - val textCol = LinearLayout(ctx).apply { - orientation = LinearLayout.VERTICAL - } - val title = TextView(ctx).apply { - setTextColor(COLOR_TEXT) - textSize = 13f - typeface = Typeface.DEFAULT_BOLD - maxLines = 2 - setLineSpacing(0f, 1.15f) - } - titleView = title - val description = TextView(ctx).apply { - setTextColor(COLOR_TEXT_SECONDARY) - textSize = 11f - maxLines = 1 - } - descriptionView = description - textCol.addView( - title, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ) - ) - textCol.addView( - description, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ).apply { topMargin = dp(ctx, 2) } - ) - header.addView( - textCol, - LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f) - ) - panel.addView( - header, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ) - ) - - val cta = TextView(ctx).apply { - setTextColor(Color.WHITE) - setBackgroundColor(COLOR_CTA) - gravity = Gravity.CENTER - textSize = 12f - typeface = Typeface.DEFAULT_BOLD - setPadding(dp(ctx, 12), dp(ctx, 9), dp(ctx, 12), dp(ctx, 9)) - maxLines = 1 - } - ctaView = cta - panel.addView( - cta, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ).apply { topMargin = dp(ctx, 8) } - ) - - root.addView( - panel, - LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 0.44f) - ) - - adView.addView( - root, - FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - ) - adView.setMediaView(media) - adView.setIconView(icon) - adView.setTitleView(title) - adView.setDescriptionView(description) - adView.setCallToActionView(cta) - adView.setAdAttributionView(badge) - return adView - } - private fun scheduleBind(delayMs: Long) { bindRunnable?.let { uiHandler.removeCallbacks(it) } val gen = bindGeneration @@ -420,12 +249,7 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod return } - // Size the child BEFORE registerView so viewability can pass. val view = ensureAdView() - // Custom gridCard needs text filled before registerView (stock templates self-fill). - titleView?.text = ad.title.orEmpty().ifEmpty { "Ad" } - descriptionView?.text = ad.description.orEmpty() - ctaView?.text = ad.callToAction.orEmpty().ifEmpty { "Learn more" } view.visibility = VISIBLE runMeasureAndLayoutNow() @@ -455,8 +279,7 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod Log.d( TAG, "registerView id=$id result=$registered host=${measuredWidth}x${measuredHeight} " + - "childCount=$childCount childVis=${view.visibility} " + - "childSize=${view.width}x${view.height} title=${ad.title}" + "childSize=${view.width}x${view.height} template=$adTemplate" ) if (gen != bindGeneration) return @@ -468,11 +291,6 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod if (gen != bindGeneration) return@postDelayed view.visibility = VISIBLE runMeasureAndLayoutNow() - Log.d( - TAG, - "post-bind childCount=$childCount childVis=${view.visibility} " + - "childSize=${view.width}x${view.height}" - ) }, 100L) dispatchLoaded(id) } else if (!retry(gen, id, "registerView returned false")) { @@ -599,19 +417,5 @@ class RCTAppodealNativeView(context: Context) : ReactViewGroup(context), RNAppod private fun prune() { liveViews.removeAll { it.get() == null } } - - // Match light AdMob card tokens (RN outerClip still applies theme surface/radius). - private val COLOR_SURFACE: Int = Color.WHITE - private val COLOR_MEDIA_BG: Int = Color.parseColor("#1A1515") - private val COLOR_TEXT: Int = Color.parseColor("#2D2424") - private val COLOR_TEXT_SECONDARY: Int = Color.parseColor("#7F6D5F") - private val COLOR_CTA: Int = Color.parseColor("#2D2424") } } - -private fun dp(ctx: Context, value: Int): Int = - TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, - value.toFloat(), - ctx.resources.displayMetrics - ).toInt() diff --git a/android/src/main/java/com/appodeal/rnappodeal/RNAppodealModuleImpl.kt b/android/src/main/java/com/appodeal/rnappodeal/RNAppodealModuleImpl.kt index 9e2e0c8..2fea0bb 100644 --- a/android/src/main/java/com/appodeal/rnappodeal/RNAppodealModuleImpl.kt +++ b/android/src/main/java/com/appodeal/rnappodeal/RNAppodealModuleImpl.kt @@ -415,6 +415,7 @@ internal class RNAppodealModuleImpl( fun destroyNativeAd(adId: String) { RCTAppodealNativeView.unbindAdId(adId) + RCTAppodealNativeAdView.unbindAdId(adId) RNAppodealNativeAdStore.remove(adId) } @@ -469,6 +470,7 @@ internal class RNAppodealModuleImpl( RNAppodealActivityHolder.set(activity) } RCTAppodealNativeView.notifyActivityReady() + RCTAppodealNativeAdView.notifyActivityReady() } companion object Companion { diff --git a/android/src/main/java/com/appodeal/rnappodeal/RNAppodealNativeAdViewManagerImpl.kt b/android/src/main/java/com/appodeal/rnappodeal/RNAppodealNativeAdViewManagerImpl.kt new file mode 100644 index 0000000..510b43c --- /dev/null +++ b/android/src/main/java/com/appodeal/rnappodeal/RNAppodealNativeAdViewManagerImpl.kt @@ -0,0 +1,34 @@ +package com.appodeal.rnappodeal + +import com.appodeal.rnappodeal.callbacks.RNAppodealEventHandlerManager +import com.appodeal.rnappodeal.constants.NativeEvents +import com.facebook.react.uimanager.ThemedReactContext + +internal class RNAppodealNativeAdViewManagerImpl( + private val handlerManager: RNAppodealEventHandlerManager = RNAppodealEventHandlerManager +) { + fun createViewInstance(reactContext: ThemedReactContext): RCTAppodealNativeAdView { + val view = RCTAppodealNativeAdView(reactContext) + handlerManager.registerHandler(view) + return view + } + + fun onDropViewInstance(view: RCTAppodealNativeAdView) { + view.cleanup() + handlerManager.unregisterHandler(view) + } + + fun getExportedCustomDirectEventTypeConstants(): Map? { + return mapOf( + NativeEvents.ON_AD_LOADED to mapOf("registrationName" to NativeEvents.ON_AD_LOADED), + NativeEvents.ON_AD_FAILED_TO_LOAD to mapOf("registrationName" to NativeEvents.ON_AD_FAILED_TO_LOAD), + NativeEvents.ON_AD_SHOWN to mapOf("registrationName" to NativeEvents.ON_AD_SHOWN), + NativeEvents.ON_AD_CLICKED to mapOf("registrationName" to NativeEvents.ON_AD_CLICKED), + NativeEvents.ON_AD_EXPIRED to mapOf("registrationName" to NativeEvents.ON_AD_EXPIRED) + ) + } + + companion object { + const val NAME = "RNAppodealNativeAdView" + } +} diff --git a/android/src/main/java/com/appodeal/rnappodeal/RNAppodealNativeAssetViewManagerImpl.kt b/android/src/main/java/com/appodeal/rnappodeal/RNAppodealNativeAssetViewManagerImpl.kt new file mode 100644 index 0000000..8f7fa0b --- /dev/null +++ b/android/src/main/java/com/appodeal/rnappodeal/RNAppodealNativeAssetViewManagerImpl.kt @@ -0,0 +1,17 @@ +package com.appodeal.rnappodeal + +import com.facebook.react.uimanager.ThemedReactContext + +internal class RNAppodealNativeAssetViewManagerImpl { + fun createViewInstance(reactContext: ThemedReactContext): RCTAppodealNativeAssetView { + return RCTAppodealNativeAssetView(reactContext) + } + + fun onDropViewInstance(view: RCTAppodealNativeAssetView) { + // no-op + } + + companion object { + const val NAME = RCTAppodealNativeAssetView.NAME + } +} diff --git a/android/src/main/java/com/appodeal/rnappodeal/RNAppodealPackage.kt b/android/src/main/java/com/appodeal/rnappodeal/RNAppodealPackage.kt index 40406b1..b9bccd4 100644 --- a/android/src/main/java/com/appodeal/rnappodeal/RNAppodealPackage.kt +++ b/android/src/main/java/com/appodeal/rnappodeal/RNAppodealPackage.kt @@ -21,7 +21,9 @@ class RNAppodealPackage : BaseReactPackage() { ): List> = listOf( RNAppodealBannerViewManager(), RNAppodealMrecViewManager(), - RNAppodealNativeViewManager() + RNAppodealNativeViewManager(), + RNAppodealNativeAdViewManager(), + RNAppodealNativeAssetViewManager() ) override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { diff --git a/android/src/newarch/com/appodeal/rnappodeal/RNAppodealNativeAdViewManager.kt b/android/src/newarch/com/appodeal/rnappodeal/RNAppodealNativeAdViewManager.kt new file mode 100644 index 0000000..dd2d352 --- /dev/null +++ b/android/src/newarch/com/appodeal/rnappodeal/RNAppodealNativeAdViewManager.kt @@ -0,0 +1,38 @@ +package com.appodeal.rnappodeal + +import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.ViewGroupManager +import com.facebook.react.uimanager.annotations.ReactProp + +@ReactModule(name = RNAppodealNativeAdViewManagerImpl.NAME) +class RNAppodealNativeAdViewManager : + ViewGroupManager() { + + private val impl = RNAppodealNativeAdViewManagerImpl() + + override fun getName(): String = RNAppodealNativeAdViewManagerImpl.NAME + + override fun createViewInstance(reactContext: ThemedReactContext): RCTAppodealNativeAdView { + return impl.createViewInstance(reactContext) + } + + override fun onDropViewInstance(view: RCTAppodealNativeAdView) { + super.onDropViewInstance(view) + impl.onDropViewInstance(view) + } + + override fun getExportedCustomDirectEventTypeConstants(): Map? { + return impl.getExportedCustomDirectEventTypeConstants() + } + + @ReactProp(name = "adId") + fun setAdId(view: RCTAppodealNativeAdView, value: String?) { + view.adId = value + } + + @ReactProp(name = "placement") + fun setPlacement(view: RCTAppodealNativeAdView, value: String?) { + value?.let { view.placement = it } + } +} diff --git a/android/src/newarch/com/appodeal/rnappodeal/RNAppodealNativeAssetViewManager.kt b/android/src/newarch/com/appodeal/rnappodeal/RNAppodealNativeAssetViewManager.kt new file mode 100644 index 0000000..1d302c7 --- /dev/null +++ b/android/src/newarch/com/appodeal/rnappodeal/RNAppodealNativeAssetViewManager.kt @@ -0,0 +1,29 @@ +package com.appodeal.rnappodeal + +import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.uimanager.SimpleViewManager +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.annotations.ReactProp + +@ReactModule(name = RNAppodealNativeAssetViewManagerImpl.NAME) +class RNAppodealNativeAssetViewManager : + SimpleViewManager() { + + private val impl = RNAppodealNativeAssetViewManagerImpl() + + override fun getName(): String = RNAppodealNativeAssetViewManagerImpl.NAME + + override fun createViewInstance(reactContext: ThemedReactContext): RCTAppodealNativeAssetView { + return impl.createViewInstance(reactContext) + } + + override fun onDropViewInstance(view: RCTAppodealNativeAssetView) { + super.onDropViewInstance(view) + impl.onDropViewInstance(view) + } + + @ReactProp(name = "asset") + fun setAsset(view: RCTAppodealNativeAssetView, value: String?) { + value?.let { view.assetType = it } + } +} diff --git a/android/src/oldarch/com/appodeal/rnappodeal/RNAppodealNativeAdViewManager.java b/android/src/oldarch/com/appodeal/rnappodeal/RNAppodealNativeAdViewManager.java new file mode 100644 index 0000000..416cfc4 --- /dev/null +++ b/android/src/oldarch/com/appodeal/rnappodeal/RNAppodealNativeAdViewManager.java @@ -0,0 +1,51 @@ +package com.appodeal.rnappodeal; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.facebook.react.uimanager.ThemedReactContext; +import com.facebook.react.uimanager.ViewGroupManager; +import com.facebook.react.uimanager.annotations.ReactProp; + +import java.util.Map; + +public class RNAppodealNativeAdViewManager extends ViewGroupManager { + + private final RNAppodealNativeAdViewManagerImpl impl = new RNAppodealNativeAdViewManagerImpl(); + + @NonNull + @Override + public String getName() { + return RNAppodealNativeAdViewManagerImpl.NAME; + } + + @NonNull + @Override + protected RCTAppodealNativeAdView createViewInstance(@NonNull ThemedReactContext reactContext) { + return impl.createViewInstance(reactContext); + } + + @Override + public void onDropViewInstance(@NonNull RCTAppodealNativeAdView view) { + super.onDropViewInstance(view); + impl.onDropViewInstance(view); + } + + @Nullable + @Override + public Map getExportedCustomDirectEventTypeConstants() { + return impl.getExportedCustomDirectEventTypeConstants(); + } + + @ReactProp(name = "adId") + public void setAdId(RCTAppodealNativeAdView view, @Nullable String value) { + view.setAdId(value); + } + + @ReactProp(name = "placement") + public void setPlacement(RCTAppodealNativeAdView view, @Nullable String value) { + if (value != null) { + view.setPlacement(value); + } + } +} diff --git a/android/src/oldarch/com/appodeal/rnappodeal/RNAppodealNativeAssetViewManager.java b/android/src/oldarch/com/appodeal/rnappodeal/RNAppodealNativeAssetViewManager.java new file mode 100644 index 0000000..ab9a594 --- /dev/null +++ b/android/src/oldarch/com/appodeal/rnappodeal/RNAppodealNativeAssetViewManager.java @@ -0,0 +1,38 @@ +package com.appodeal.rnappodeal; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.facebook.react.uimanager.SimpleViewManager; +import com.facebook.react.uimanager.ThemedReactContext; +import com.facebook.react.uimanager.annotations.ReactProp; + +public class RNAppodealNativeAssetViewManager extends SimpleViewManager { + + private final RNAppodealNativeAssetViewManagerImpl impl = new RNAppodealNativeAssetViewManagerImpl(); + + @NonNull + @Override + public String getName() { + return RNAppodealNativeAssetViewManagerImpl.NAME; + } + + @NonNull + @Override + protected RCTAppodealNativeAssetView createViewInstance(@NonNull ThemedReactContext reactContext) { + return impl.createViewInstance(reactContext); + } + + @Override + public void onDropViewInstance(@NonNull RCTAppodealNativeAssetView view) { + super.onDropViewInstance(view); + impl.onDropViewInstance(view); + } + + @ReactProp(name = "asset") + public void setAsset(RCTAppodealNativeAssetView view, @Nullable String value) { + if (value != null) { + view.setAssetType(value); + } + } +} diff --git a/ios/RNAppodealNativeAdView.h b/ios/RNAppodealNativeAdView.h new file mode 100644 index 0000000..087a9fc --- /dev/null +++ b/ios/RNAppodealNativeAdView.h @@ -0,0 +1,24 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + * Composable native ad root (AdMob-style). + * Asset children are RNAppodealNativeAssetView instances. + */ +@interface RNAppodealNativeAdView : RCTView + +@property (nonatomic, copy, nullable) NSString *adId; +@property (nonatomic, copy) NSString *placement; + +@property (nonatomic, copy) RCTBubblingEventBlock onAdLoaded; +@property (nonatomic, copy) RCTBubblingEventBlock onAdFailedToLoad; +@property (nonatomic, copy) RCTBubblingEventBlock onAdClicked; +@property (nonatomic, copy) RCTBubblingEventBlock onAdExpired; +@property (nonatomic, copy) RCTBubblingEventBlock onAdShown; + +- (void)onAssetChanged; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/RNAppodealNativeAdView.m b/ios/RNAppodealNativeAdView.m new file mode 100644 index 0000000..334268c --- /dev/null +++ b/ios/RNAppodealNativeAdView.m @@ -0,0 +1,237 @@ +#import "RNAppodealNativeAdView.h" +#import "RNAppodealNativeAssetView.h" +#import "RNAppodealNativeAdStore.h" +#import "RNADefines.h" + +#import +#import +#import + +@interface RNAppodealNativeAdView () +@property (nonatomic, strong, nullable) APDNativeAd *nativeAd; +@property (nonatomic, strong, nullable) UIView *mediaView; +@property (nonatomic, copy, nullable) NSString *boundAdId; +@end + +@implementation RNAppodealNativeAdView + +- (instancetype)initWithFrame:(CGRect)frame { + if (self = [super initWithFrame:frame]) { + self.backgroundColor = UIColor.clearColor; + _placement = @"default"; + self.clipsToBounds = NO; + } + return self; +} + +- (void)setAdId:(NSString *)adId { + if ([_adId isEqualToString:adId]) { + return; + } + _adId = [adId copy]; + self.boundAdId = nil; + [self bindNativeAd]; +} + +- (void)setPlacement:(NSString *)placement { + _placement = placement.length > 0 ? [placement copy] : @"default"; + if (self.boundAdId != nil) { + self.boundAdId = nil; + [self bindNativeAd]; + } +} + +- (void)onAssetChanged { + self.boundAdId = nil; + [self bindNativeAd]; +} + +- (void)didMoveToWindow { + [super didMoveToWindow]; + if (self.window != nil && self.boundAdId == nil && self.adId.length > 0) { + [self bindNativeAd]; + } +} + +- (void)layoutSubviews { + [super layoutSubviews]; + self.mediaView.frame = self.mediaAsset.mediaContainer.bounds; + if (self.boundAdId == nil && self.adId.length > 0 && CGRectGetWidth(self.bounds) > 0) { + [self bindNativeAd]; + } +} + +- (void)clearBinding { + if (self.nativeAd != nil) { + [self.nativeAd detachFromView]; + self.nativeAd.delegate = nil; + self.nativeAd = nil; + } + [self.mediaView removeFromSuperview]; + self.mediaView = nil; + self.boundAdId = nil; +} + +- (void)bindNativeAd { + if (self.adId.length == 0) { + return; + } + if ([self.boundAdId isEqualToString:self.adId]) { + return; + } + if (self.window == nil || CGRectGetWidth(self.bounds) <= 0 || CGRectGetHeight(self.bounds) <= 0) { + return; + } + + [self clearBinding]; + + APDNativeAd *nativeAd = [[RNAppodealNativeAdStore shared] nativeAdForId:self.adId]; + if (nativeAd == nil) { + if (self.onAdFailedToLoad) { + self.onAdFailedToLoad(@{@"adId": self.adId ?: @"", @"message": @"native ad not in store"}); + } + return; + } + + RNAppodealNativeAssetView *titleAsset = [self assetWithType:@"title"]; + RNAppodealNativeAssetView *descriptionAsset = [self assetWithType:@"description"]; + RNAppodealNativeAssetView *ctaAsset = [self assetWithType:@"callToAction"]; + RNAppodealNativeAssetView *attributionAsset = [self assetWithType:@"attribution"]; + RNAppodealNativeAssetView *iconAsset = [self assetWithType:@"icon"]; + RNAppodealNativeAssetView *mediaAsset = [self assetWithType:@"media"]; + + if (mediaAsset == nil && iconAsset == nil) { + if (self.onAdFailedToLoad) { + self.onAdFailedToLoad(@{ + @"adId": self.adId ?: @"", + @"message": @"composable native ad requires media or icon asset" + }); + } + return; + } + + // Fill text/image assets from the ad object (iOS custom path fills via + // APDNativeAdView outlets; here RN owns the views so we copy fields). + titleAsset.textLabel.text = nativeAd.title ?: @""; + descriptionAsset.textLabel.text = nativeAd.descriptionText ?: @""; + ctaAsset.textLabel.text = nativeAd.callToActionText ?: @""; + if (attributionAsset.textLabel.text.length == 0) { + attributionAsset.textLabel.text = @"Ad"; + } + + if (iconAsset.iconImageView != nil && nativeAd.iconImage != nil) { + if ([nativeAd.iconImage respondsToSelector:@selector(image)]) { + iconAsset.iconImageView.image = [nativeAd.iconImage performSelector:@selector(image)]; + } + } + + UIViewController *rootViewController = RCTPresentedViewController(); + if (rootViewController == nil) { + if (self.onAdFailedToLoad) { + self.onAdFailedToLoad(@{@"adId": self.adId ?: @"", @"message": @"root view controller unavailable"}); + } + return; + } + + // Media: prefer APDMediaView when available (Appodeal iOS media container). + if (mediaAsset.mediaContainer != nil) { + Class mediaClass = NSClassFromString(@"APDMediaView"); + if (mediaClass != Nil) { + UIView *media = [[mediaClass alloc] initWithFrame:mediaAsset.mediaContainer.bounds]; + media.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + [mediaAsset.mediaContainer addSubview:media]; + self.mediaView = media; + if ([media respondsToSelector:@selector(setNativeAd:rootViewController:)]) { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + [media performSelector:@selector(setNativeAd:rootViewController:) + withObject:nativeAd + withObject:rootViewController]; +#pragma clang diagnostic pop + } else if ([media respondsToSelector:@selector(setNativeAd:)]) { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + [media performSelector:@selector(setNativeAd:) withObject:nativeAd]; +#pragma clang diagnostic pop + } + } else if (nativeAd.mainImage != nil && [nativeAd.mainImage respondsToSelector:@selector(image)]) { + UIImageView *imageView = [[UIImageView alloc] initWithFrame:mediaAsset.mediaContainer.bounds]; + imageView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + imageView.contentMode = UIViewContentModeScaleAspectFit; + imageView.image = [nativeAd.mainImage performSelector:@selector(image)]; + [mediaAsset.mediaContainer addSubview:imageView]; + self.mediaView = imageView; + } + } + + self.nativeAd = nativeAd; + self.nativeAd.delegate = self; + + // Register the RN-composed hierarchy for impressions / clicks. + // Pair of detachFromView used in RNAppodealNativeAdStore. + if ([nativeAd respondsToSelector:@selector(attachToView:withRootViewController:)]) { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + [nativeAd performSelector:@selector(attachToView:withRootViewController:) + withObject:self + withObject:rootViewController]; +#pragma clang diagnostic pop + } else if ([nativeAd respondsToSelector:@selector(attachToView:)]) { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + [nativeAd performSelector:@selector(attachToView:) withObject:self]; +#pragma clang diagnostic pop + } + + self.boundAdId = self.adId; + if (self.onAdLoaded) { + self.onAdLoaded(@{@"adId": self.adId ?: @""}); + } +} + +- (RNAppodealNativeAssetView *)mediaAsset { + return [self assetWithType:@"media"]; +} + +- (RNAppodealNativeAssetView *)assetWithType:(NSString *)type { + return [self findAsset:type inView:self]; +} + +- (RNAppodealNativeAssetView *)findAsset:(NSString *)type inView:(UIView *)view { + if ([view isKindOfClass:[RNAppodealNativeAssetView class]]) { + RNAppodealNativeAssetView *assetView = (RNAppodealNativeAssetView *)view; + if ([assetView.asset isEqualToString:type]) { + return assetView; + } + } + for (UIView *child in view.subviews) { + RNAppodealNativeAssetView *found = [self findAsset:type inView:child]; + if (found != nil) { + return found; + } + } + return nil; +} + +- (void)removeFromSuperview { + [self clearBinding]; + [super removeFromSuperview]; +} + +#pragma mark - APDNativeAdPresentationDelegate + +- (void)nativeAdWillLogImpression:(APDNativeAd *)nativeAd { + if (self.onAdShown) { + self.onAdShown(@{@"adId": self.adId ?: @""}); + } + [[RNAppodealNativeAdStore shared] emitNativeEvent:kEventNativeShown payload:@{@"adId": self.adId ?: @""}]; +} + +- (void)nativeAdWillLogUserInteraction:(APDNativeAd *)nativeAd { + if (self.onAdClicked) { + self.onAdClicked(@{@"adId": self.adId ?: @""}); + } + [[RNAppodealNativeAdStore shared] emitNativeEvent:kEventNativeClicked payload:@{@"adId": self.adId ?: @""}]; +} + +@end diff --git a/ios/RNAppodealNativeAdViewManager.mm b/ios/RNAppodealNativeAdViewManager.mm new file mode 100644 index 0000000..db64ff0 --- /dev/null +++ b/ios/RNAppodealNativeAdViewManager.mm @@ -0,0 +1,23 @@ +#import +#import "RNAppodealNativeAdView.h" + +@interface RNAppodealNativeAdViewManager : RCTViewManager +@end + +@implementation RNAppodealNativeAdViewManager + +RCT_EXPORT_MODULE(RNAppodealNativeAdView) + +- (UIView *)view { + return [[RNAppodealNativeAdView alloc] initWithFrame:CGRectZero]; +} + +RCT_EXPORT_VIEW_PROPERTY(adId, NSString) +RCT_EXPORT_VIEW_PROPERTY(placement, NSString) +RCT_EXPORT_VIEW_PROPERTY(onAdLoaded, RCTBubblingEventBlock) +RCT_EXPORT_VIEW_PROPERTY(onAdFailedToLoad, RCTBubblingEventBlock) +RCT_EXPORT_VIEW_PROPERTY(onAdClicked, RCTBubblingEventBlock) +RCT_EXPORT_VIEW_PROPERTY(onAdExpired, RCTBubblingEventBlock) +RCT_EXPORT_VIEW_PROPERTY(onAdShown, RCTBubblingEventBlock) + +@end diff --git a/ios/RNAppodealNativeAssetView.h b/ios/RNAppodealNativeAssetView.h new file mode 100644 index 0000000..4dd3dbf --- /dev/null +++ b/ios/RNAppodealNativeAssetView.h @@ -0,0 +1,19 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + * Single native-ad asset marker. + * asset: media | icon | title | description | callToAction | attribution + */ +@interface RNAppodealNativeAssetView : RCTView + +@property (nonatomic, copy) NSString *asset; + +@property (nonatomic, strong, readonly) UILabel *textLabel; +@property (nonatomic, strong, readonly) UIImageView *iconImageView; +@property (nonatomic, strong, readonly) UIView *mediaContainer; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/RNAppodealNativeAssetView.m b/ios/RNAppodealNativeAssetView.m new file mode 100644 index 0000000..66d3a04 --- /dev/null +++ b/ios/RNAppodealNativeAssetView.m @@ -0,0 +1,83 @@ +#import "RNAppodealNativeAssetView.h" + +@interface RNAppodealNativeAssetView () +@property (nonatomic, strong, readwrite) UILabel *textLabel; +@property (nonatomic, strong, readwrite) UIImageView *iconImageView; +@property (nonatomic, strong, readwrite) UIView *mediaContainer; +@end + +@implementation RNAppodealNativeAssetView + +- (instancetype)initWithFrame:(CGRect)frame { + if (self = [super initWithFrame:frame]) { + _asset = @"title"; + self.clipsToBounds = NO; + [self rebuildContent]; + } + return self; +} + +- (void)setAsset:(NSString *)asset { + NSString *normalized = asset.length > 0 ? asset : @"title"; + if ([_asset isEqualToString:normalized]) { + return; + } + _asset = [normalized copy]; + [self rebuildContent]; + + UIView *parent = self.superview; + while (parent != nil) { + if ([parent respondsToSelector:@selector(onAssetChanged)]) { + [parent performSelector:@selector(onAssetChanged)]; + break; + } + parent = parent.superview; + } +} + +- (void)rebuildContent { + [self.textLabel removeFromSuperview]; + [self.iconImageView removeFromSuperview]; + [self.mediaContainer removeFromSuperview]; + self.textLabel = nil; + self.iconImageView = nil; + self.mediaContainer = nil; + + if ([self.asset isEqualToString:@"media"]) { + UIView *container = [[UIView alloc] initWithFrame:self.bounds]; + container.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + container.clipsToBounds = YES; + [self addSubview:container]; + self.mediaContainer = container; + return; + } + + if ([self.asset isEqualToString:@"icon"]) { + UIImageView *imageView = [[UIImageView alloc] initWithFrame:self.bounds]; + imageView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + imageView.contentMode = UIViewContentModeScaleAspectFit; + imageView.clipsToBounds = YES; + [self addSubview:imageView]; + self.iconImageView = imageView; + return; + } + + UILabel *label = [[UILabel alloc] initWithFrame:self.bounds]; + label.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + label.numberOfLines = [self.asset isEqualToString:@"title"] ? 2 : 1; + label.lineBreakMode = NSLineBreakByTruncatingTail; + if ([self.asset isEqualToString:@"callToAction"] || [self.asset isEqualToString:@"attribution"]) { + label.textAlignment = NSTextAlignmentCenter; + } + [self addSubview:label]; + self.textLabel = label; +} + +- (void)layoutSubviews { + [super layoutSubviews]; + self.textLabel.frame = self.bounds; + self.iconImageView.frame = self.bounds; + self.mediaContainer.frame = self.bounds; +} + +@end diff --git a/ios/RNAppodealNativeAssetViewManager.mm b/ios/RNAppodealNativeAssetViewManager.mm new file mode 100644 index 0000000..3882ed8 --- /dev/null +++ b/ios/RNAppodealNativeAssetViewManager.mm @@ -0,0 +1,17 @@ +#import +#import "RNAppodealNativeAssetView.h" + +@interface RNAppodealNativeAssetViewManager : RCTViewManager +@end + +@implementation RNAppodealNativeAssetViewManager + +RCT_EXPORT_MODULE(RNAppodealNativeAssetView) + +- (UIView *)view { + return [[RNAppodealNativeAssetView alloc] initWithFrame:CGRectZero]; +} + +RCT_EXPORT_VIEW_PROPERTY(asset, NSString) + +@end diff --git a/package.json b/package.json index 628ce1c..79993ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-appodeal", - "version": "4.3.5", + "version": "4.3.1", "description": "React Native Module created to support Appodeal SDK for iOS and Android platforms", "main": "./src/index.tsx", "react-native": "./src/index.tsx", diff --git a/src/RNAppodeal.ts b/src/RNAppodeal.ts index e6f9c01..85f908f 100644 --- a/src/RNAppodeal.ts +++ b/src/RNAppodeal.ts @@ -280,7 +280,7 @@ export interface Appodeal { /** * Plugin version constant */ -const PLUGIN_VERSION = '4.3.0'; +const PLUGIN_VERSION = '4.3.1'; /** * Appodeal SDK implementation diff --git a/src/RNAppodealNative.tsx b/src/RNAppodealNative.tsx index 4d42cfd..e0fd3ea 100644 --- a/src/RNAppodealNative.tsx +++ b/src/RNAppodealNative.tsx @@ -1,8 +1,8 @@ /** - * Appodeal Native Ad Component + * Appodeal Native Ad Component — stock templates only. * - * Renders a platform native-ad template (newsFeed / appWall / contentStream / gridCard) - * bound to an ad id returned from Appodeal.getNativeAds(). + * Templates: newsFeed / appWall / contentStream. + * For custom JSX layouts use AppodealNativeAdView + asset views. */ import AppodealNativeView, { type NativeProps, diff --git a/src/RNAppodealNativeAdView.tsx b/src/RNAppodealNativeAdView.tsx new file mode 100644 index 0000000..e6f763e --- /dev/null +++ b/src/RNAppodealNativeAdView.tsx @@ -0,0 +1,66 @@ +/** + * Composable Appodeal native ad — AdMob-style layout in JSX. + * + * Stock templates: use . + * Custom layout: compose assets under AppodealNativeAdView (both platforms). + */ +import React from 'react'; +import type { StyleProp, TextStyle, ViewStyle } from 'react-native'; +import AppodealNativeAdViewNative, { + type NativeProps as AdViewProps, +} from './specs/AppodealNativeAdViewNativeComponent'; +import AppodealNativeAssetViewNative from './specs/AppodealNativeAssetViewNativeComponent'; + +export type AppodealNativeAssetType = + | 'media' + | 'icon' + | 'title' + | 'description' + | 'callToAction' + | 'attribution'; + +export type AppodealNativeAdViewProps = AdViewProps; + +export type AppodealNativeAssetProps = { + asset: AppodealNativeAssetType; + style?: StyleProp; + children?: React.ReactNode; +}; + +export const AppodealNativeAdView = (props: AppodealNativeAdViewProps) => { + return ; +}; + +export const AppodealNativeAsset = ({ + asset, + style, + ...rest +}: AppodealNativeAssetProps) => { + return ( + + ); +}; + +export const AppodealNativeMediaView = ( + props: Omit +) => ; + +export const AppodealNativeIconView = ( + props: Omit +) => ; + +export const AppodealNativeTitleView = ( + props: Omit +) => ; + +export const AppodealNativeDescriptionView = ( + props: Omit +) => ; + +export const AppodealNativeCallToActionView = ( + props: Omit +) => ; + +export const AppodealNativeAttributionView = ( + props: Omit +) => ; diff --git a/src/index.tsx b/src/index.tsx index 4bffdae..dcd885f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -10,6 +10,21 @@ import Appodeal from './RNAppodeal'; export { default as AppodealBanner } from './RNAppodealBanner'; export { default as AppodealMrec } from './RNAppodealMrec'; export { default as AppodealNative } from './RNAppodealNative'; +export { + AppodealNativeAdView, + AppodealNativeAsset, + AppodealNativeMediaView, + AppodealNativeIconView, + AppodealNativeTitleView, + AppodealNativeDescriptionView, + AppodealNativeCallToActionView, + AppodealNativeAttributionView, +} from './RNAppodealNativeAdView'; +export type { + AppodealNativeAdViewProps, + AppodealNativeAssetProps, + AppodealNativeAssetType, +} from './RNAppodealNativeAdView'; // Export the main Appodeal interface export default Appodeal; diff --git a/src/specs/AppodealNativeAdViewNativeComponent.ts b/src/specs/AppodealNativeAdViewNativeComponent.ts new file mode 100644 index 0000000..1c88f76 --- /dev/null +++ b/src/specs/AppodealNativeAdViewNativeComponent.ts @@ -0,0 +1,26 @@ +import type { HostComponent, ViewProps } from 'react-native'; +import type { DirectEventHandler } from 'react-native/Libraries/Types/CodegenTypes'; +import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'; + +export type NativeAdInfoEvent = Readonly<{ + adId?: string; +}>; + +export type NativeAdLoadFailedEvent = Readonly<{ + adId?: string; + message?: string; +}>; + +export interface NativeProps extends ViewProps { + adId?: string; + placement?: string; + onAdLoaded?: DirectEventHandler; + onAdFailedToLoad?: DirectEventHandler; + onAdShown?: DirectEventHandler; + onAdClicked?: DirectEventHandler; + onAdExpired?: DirectEventHandler; +} + +export default codegenNativeComponent( + 'RNAppodealNativeAdView' +) as HostComponent; diff --git a/src/specs/AppodealNativeAssetViewNativeComponent.ts b/src/specs/AppodealNativeAssetViewNativeComponent.ts new file mode 100644 index 0000000..b234461 --- /dev/null +++ b/src/specs/AppodealNativeAssetViewNativeComponent.ts @@ -0,0 +1,14 @@ +import type { HostComponent, ViewProps } from 'react-native'; +import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'; + +export interface NativeProps extends ViewProps { + /** + * Asset role inside AppodealNativeAdView: + * media | icon | title | description | callToAction | attribution + */ + asset?: string; +} + +export default codegenNativeComponent( + 'RNAppodealNativeAssetView' +) as HostComponent; diff --git a/src/specs/AppodealNativeViewNativeComponent.ts b/src/specs/AppodealNativeViewNativeComponent.ts index 292afe9..6ea5b92 100644 --- a/src/specs/AppodealNativeViewNativeComponent.ts +++ b/src/specs/AppodealNativeViewNativeComponent.ts @@ -14,7 +14,7 @@ export type NativeAdLoadFailedEvent = Readonly<{ export interface NativeProps extends ViewProps { adId?: string; placement?: string; - /** Layout style: newsFeed | appWall | contentStream | gridCard. Named adTemplate — `template` is a C++ keyword and breaks Fabric codegen. */ + /** Layout style: newsFeed | appWall | contentStream. Named adTemplate — `template` is a C++ keyword and breaks Fabric codegen. */ adTemplate?: string; onAdLoaded?: DirectEventHandler; onAdFailedToLoad?: DirectEventHandler; diff --git a/src/types/AppodealAdTypes.ts b/src/types/AppodealAdTypes.ts index 3d0716f..77f7bf1 100644 --- a/src/types/AppodealAdTypes.ts +++ b/src/types/AppodealAdTypes.ts @@ -33,14 +33,10 @@ export enum AppodealAdType { export type AppodealNativeContentType = 'auto' | 'noVideo' | 'video'; /** - * Native ad template used by AppodealNative view + * Native ad template used by AppodealNative view (stock SDK templates). + * Custom layouts use AppodealNativeAdView + asset children instead. */ -export type AppodealNativeTemplate = - | 'newsFeed' - | 'appWall' - | 'contentStream' - /** Portrait feed card: media top ~56%, icon/title/CTA below (matches AdMob grid). */ - | 'gridCard'; +export type AppodealNativeTemplate = 'newsFeed' | 'appWall' | 'contentStream'; /** * Metadata for a cached native ad pulled from the SDK From b480a91a6f04fdaf697048e627bdce65e40769e0 Mon Sep 17 00:00:00 2001 From: Roo Date: Fri, 31 Jul 2026 01:32:39 -0400 Subject: [PATCH 15/16] fix(android): assign NativeAdView asset properties from Kotlin subclass JavaBean setters like setMediaView do not resolve on a Kotlin subclass; use mediaView/iconView/titleView property assignment instead. Co-authored-by: Cursor --- .../rnappodeal/RCTAppodealNativeAdView.kt | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeAdView.kt b/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeAdView.kt index 0b80acd..b28688a 100644 --- a/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeAdView.kt +++ b/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeAdView.kt @@ -191,12 +191,14 @@ class RCTAppodealNativeAdView(context: Context) : } // Same wiring as native_ad_view_custom.xml attrs → registerView. - assets.media?.let { setMediaView(it) } - assets.icon?.let { setIconView(it) } - assets.title?.let { setTitleView(it) } - assets.description?.let { setDescriptionView(it) } - assets.callToAction?.let { setCallToActionView(it) } - assets.attribution?.let { setAdAttributionView(it) } + // NativeAdView exposes JavaBean setters; from a Kotlin subclass those + // must be assigned as properties (setMediaView(...) does not resolve). + assets.media?.let { mediaView = it } + assets.icon?.let { iconView = it } + assets.title?.let { titleView = it } + assets.description?.let { descriptionView = it } + assets.callToAction?.let { callToActionView = it } + assets.attribution?.let { adAttributionView = it } val registered = try { registerView(ad, placement) @@ -304,7 +306,7 @@ class RCTAppodealNativeAdView(context: Context) : val title: TextView?, val description: TextView?, val callToAction: View?, - val attribution: View? + val attribution: TextView? ) companion object { @@ -344,7 +346,7 @@ class RCTAppodealNativeAdView(context: Context) : var title: TextView? = null var description: TextView? = null var callToAction: View? = null - var attribution: View? = null + var attribution: TextView? = null fun walk(view: View) { if (view is RCTAppodealNativeAssetView) { From a67d83dfa6154e73b7fcd717a0ce76d08fc5cf91 Mon Sep 17 00:00:00 2001 From: Roo Date: Fri, 31 Jul 2026 01:42:49 -0400 Subject: [PATCH 16/16] fix(android): bind NativeAdView assets via Java helper Appodeal's NativeAdView Kotlin metadata is obfuscated, so setMediaView / mediaView= are unresolved from a Kotlin subclass. Call the public JVM setters from Java instead. Co-authored-by: Cursor --- .../rnappodeal/NativeAdViewAssetBinder.java | 47 +++++++++++++++++++ .../rnappodeal/RCTAppodealNativeAdView.kt | 20 ++++---- 2 files changed, 58 insertions(+), 9 deletions(-) create mode 100644 android/src/main/java/com/appodeal/rnappodeal/NativeAdViewAssetBinder.java diff --git a/android/src/main/java/com/appodeal/rnappodeal/NativeAdViewAssetBinder.java b/android/src/main/java/com/appodeal/rnappodeal/NativeAdViewAssetBinder.java new file mode 100644 index 0000000..014786f --- /dev/null +++ b/android/src/main/java/com/appodeal/rnappodeal/NativeAdViewAssetBinder.java @@ -0,0 +1,47 @@ +package com.appodeal.rnappodeal; + +import android.view.View; +import android.widget.TextView; + +import com.appodeal.ads.nativead.NativeAdView; +import com.appodeal.ads.nativead.NativeIconView; +import com.appodeal.ads.nativead.NativeMediaView; + +/** + * Binds custom native-ad assets onto {@link NativeAdView}. + * + *

Appodeal's {@code NativeAdView} keeps JavaBean setters for Java callers, but the Kotlin + * metadata is obfuscated — so a Kotlin subclass cannot resolve {@code setMediaView} / + * {@code mediaView = ...}. Call the setters from Java instead. + */ +public final class NativeAdViewAssetBinder { + private NativeAdViewAssetBinder() {} + + public static void bind( + NativeAdView view, + NativeMediaView media, + NativeIconView icon, + View title, + View description, + View callToAction, + TextView attribution) { + if (media != null) { + view.setMediaView(media); + } + if (icon != null) { + view.setIconView(icon); + } + if (title != null) { + view.setTitleView(title); + } + if (description != null) { + view.setDescriptionView(description); + } + if (callToAction != null) { + view.setCallToActionView(callToAction); + } + if (attribution != null) { + view.setAdAttributionView(attribution); + } + } +} diff --git a/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeAdView.kt b/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeAdView.kt index b28688a..160e59d 100644 --- a/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeAdView.kt +++ b/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeAdView.kt @@ -190,15 +190,17 @@ class RCTAppodealNativeAdView(context: Context) : } catch (_: Exception) { } - // Same wiring as native_ad_view_custom.xml attrs → registerView. - // NativeAdView exposes JavaBean setters; from a Kotlin subclass those - // must be assigned as properties (setMediaView(...) does not resolve). - assets.media?.let { mediaView = it } - assets.icon?.let { iconView = it } - assets.title?.let { titleView = it } - assets.description?.let { descriptionView = it } - assets.callToAction?.let { callToActionView = it } - assets.attribution?.let { adAttributionView = it } + // Bind via Java helper — Appodeal's Kotlin metadata is obfuscated, so + // setMediaView / mediaView= are unresolved from a Kotlin subclass. + NativeAdViewAssetBinder.bind( + this, + assets.media, + assets.icon, + assets.title, + assets.description, + assets.callToAction, + assets.attribution + ) val registered = try { registerView(ad, placement)