diff --git a/CHANGELOG.md b/CHANGELOG.md index 63a2640..8bb128a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # 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 + +- **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..55a0789 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/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 new file mode 100644 index 0000000..160e59d --- /dev/null +++ b/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeAdView.kt @@ -0,0 +1,375 @@ +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) { + } + + // 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) + } 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: TextView? + ) + + 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: TextView? = 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 new file mode 100644 index 0000000..2e87579 --- /dev/null +++ b/android/src/main/java/com/appodeal/rnappodeal/RCTAppodealNativeView.kt @@ -0,0 +1,421 @@ +package com.appodeal.rnappodeal + +import android.content.Context +import android.graphics.Color +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 +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.views.view.ReactViewGroup +import java.lang.ref.WeakReference +import java.util.concurrent.CopyOnWriteArrayList + +/** + * RN host for Appodeal **stock** native templates + * (`newsFeed` / `appWall` / `contentStream`). + * + * Custom layouts use [RCTAppodealNativeAdView] + asset children instead. + */ +class RCTAppodealNativeView(context: Context) : ReactViewGroup(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) + } + } + + var adTemplate: String = "contentStream" + set(value) { + val normalized = value.ifBlank { "contentStream" } + if (field == normalized) return + field = normalized + 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 + 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.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 + setBackgroundColor(Color.TRANSPARENT) + contentDescription = "appodeal-native-host" + clipChildren = false + 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 + bindGeneration++ + tearDownAdView() + 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 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() + } + + private fun ensureAdView(): NativeAdView { + adView?.let { existing -> + existing.visibility = VISIBLE + post(measureAndLayout) + return existing + } + + val view = createTemplate(adTemplate).apply { + visibility = VISIBLE + descendantFocusability = FOCUS_BLOCK_DESCENDANTS + setAdChoicesPosition(Position.END_TOP) + contentDescription = "appodeal-native-ad" + layoutParams = FrameLayout.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) { + } + post(measureAndLayout) + + 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 + 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 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() + } catch (_: Exception) { + } + + val registered = try { + view.registerView(ad, placement) + } catch (e: Exception) { + Log.e(TAG, "registerView threw", e) + false + } + + view.visibility = VISIBLE + this.visibility = VISIBLE + runMeasureAndLayoutNow() + + Log.d( + TAG, + "registerView id=$id result=$registered host=${measuredWidth}x${measuredHeight} " + + "childSize=${view.width}x${view.height} template=$adTemplate" + ) + + if (gen != bindGeneration) return + + if (registered) { + boundAdId = id + bindAttempts = 0 + uiHandler.postDelayed({ + if (gen != bindGeneration) return@postDelayed + view.visibility = VISIBLE + runMeasureAndLayoutNow() + }, 100L) + dispatchLoaded(id) + } else if (!retry(gen, id, "registerView returned false")) { + dispatchFailed(id, "registerView returned false") + } + } + + 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 + 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 { + adView?.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 + } + } + + companion object { + private const val TAG = "RNAppodealNative" + 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.adView?.unregisterView() + } catch (_: Exception) { + } + view.boundAdId = null + } + } + } + + private fun prune() { + liveViews.removeAll { it.get() == null } + } + } +} 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 827e2d2..2fea0bb 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 @@ -42,6 +44,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() } @@ -52,6 +55,7 @@ internal class RNAppodealModuleImpl( init { this.currentActivity = WeakReference(reactContext.currentActivity) + RNAppodealActivityHolder.set(reactContext.currentActivity) this.reactContext.addLifecycleEventListener(this) } @@ -63,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() } /** @@ -114,6 +119,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 +394,46 @@ internal class RNAppodealModuleImpl( Appodeal.logEvent(name, parameters.toMap()) } + /** + * 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) + val array = Arguments.createArray().apply { + storedAds.forEach { pushMap(it) } + } + return Arguments.createMap().apply { + putArray("ads", array) + } + } + + fun getAvailableNativeAdsCount(): Double { + return Appodeal.getAvailableNativeAdsCount().toDouble() + } + + fun destroyNativeAd(adId: String) { + RCTAppodealNativeView.unbindAdId(adId) + RCTAppodealNativeAdView.unbindAdId(adId) + 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) { @@ -418,7 +464,13 @@ internal class RNAppodealModuleImpl( } override fun onHostResume() { - // Not implemented + val activity = reactContext.currentActivity + if (activity != null) { + currentActivity = WeakReference(activity) + RNAppodealActivityHolder.set(activity) + } + RCTAppodealNativeView.notifyActivityReady() + RCTAppodealNativeAdView.notifyActivityReady() } companion object Companion { 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/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/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..b9bccd4 100644 --- a/android/src/main/java/com/appodeal/rnappodeal/RNAppodealPackage.kt +++ b/android/src/main/java/com/appodeal/rnappodeal/RNAppodealPackage.kt @@ -20,7 +20,10 @@ class RNAppodealPackage : BaseReactPackage() { reactContext: ReactApplicationContext ): List> = listOf( RNAppodealBannerViewManager(), - RNAppodealMrecViewManager() + RNAppodealMrecViewManager(), + RNAppodealNativeViewManager(), + RNAppodealNativeAdViewManager(), + RNAppodealNativeAssetViewManager() ) 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..c33fba1 --- /dev/null +++ b/android/src/main/java/com/appodeal/rnappodeal/callbacks/RNAppodealNativeCallbacks.kt @@ -0,0 +1,62 @@ +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. + * + * 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, + 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 { + if (nativeAd == null) return@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..38b59cd 100644 --- a/android/src/newarch/com/appodeal/rnappodeal/RNAppodealModule.kt +++ b/android/src/newarch/com/appodeal/rnappodeal/RNAppodealModule.kt @@ -197,6 +197,26 @@ class RNAppodealModule( moduleImplementation.trackEvent(name, parameters) } + override fun getNativeAds(count: Double): WritableMap { + 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/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/newarch/com/appodeal/rnappodeal/RNAppodealNativeViewManager.kt b/android/src/newarch/com/appodeal/rnappodeal/RNAppodealNativeViewManager.kt new file mode 100644 index 0000000..983be89 --- /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 = "adTemplate") + fun setAdTemplate(view: RCTAppodealNativeView, value: String?) { + value?.let { view.adTemplate = it } + } +} diff --git a/android/src/oldarch/com/appodeal/rnappodeal/RNAppodealModule.java b/android/src/oldarch/com/appodeal/rnappodeal/RNAppodealModule.java index d0a9f6b..cca18db 100644 --- a/android/src/oldarch/com/appodeal/rnappodeal/RNAppodealModule.java +++ b/android/src/oldarch/com/appodeal/rnappodeal/RNAppodealModule.java @@ -239,6 +239,31 @@ public void trackEvent(String name, ReadableMap parameters) { moduleImplementation.trackEvent(name, parameters); } + @ReactMethod(isBlockingSynchronousMethod = true) + public WritableMap 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/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/android/src/oldarch/com/appodeal/rnappodeal/RNAppodealNativeViewManager.java b/android/src/oldarch/com/appodeal/rnappodeal/RNAppodealNativeViewManager.java new file mode 100644 index 0000000..a13ac32 --- /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 = "adTemplate") + public void setAdTemplate(RCTAppodealNativeView view, @Nullable String value) { + if (value != null) { + view.setAdTemplate(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..1ef6532 --- /dev/null +++ b/ios/RNAppodealNativeAdStore.m @@ -0,0 +1,174 @@ +#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]; + // Appodeal iOS docs: APDNativeAdSettings.default() + queue.settings = [APDNativeAdSettings default]; + 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; + } + + // APDNativeAdQueue exposes currentAdCount; Appodeal also has availableNativeAdsCount. + 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/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/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..61c11b4 --- /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(adTemplate, 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..79993ae 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,16 @@ { "name": "react-native-appodeal", - "version": "4.2.0", + "version": "4.3.1", "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" }, diff --git a/src/RNAppodeal.ts b/src/RNAppodeal.ts index 9230f3e..85f908f 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.1'; /** * Appodeal SDK implementation @@ -306,6 +333,38 @@ const appodeal: Appodeal = { NativeAppodeal.cache(adTypes); }, + 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 []; + }, + + 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..e0fd3ea --- /dev/null +++ b/src/RNAppodealNative.tsx @@ -0,0 +1,27 @@ +/** + * Appodeal Native Ad Component — stock templates only. + * + * Templates: newsFeed / appWall / contentStream. + * For custom JSX layouts use AppodealNativeAdView + asset views. + */ +import AppodealNativeView, { + type NativeProps, +} from './specs/AppodealNativeViewNativeComponent'; + +const AppodealNative = ({ + adTemplate = 'contentStream', + placement = 'default', + style, + ...rest +}: NativeProps) => { + return ( + + ); +}; + +export default AppodealNative; 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/__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..dcd885f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -9,6 +9,22 @@ 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 { + 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; @@ -29,6 +45,9 @@ export type { AppodealIOSPurchase, AppodealAndroidPurchase, AppodealAdRevenue, + AppodealNativeAdInfo, + AppodealNativeContentType, + AppodealNativeTemplate, AppodealPurchaseValidationResult, Map, } from './types'; @@ -39,4 +58,5 @@ export { AppodealBannerEvents, AppodealInterstitialEvents, AppodealRewardedEvents, + AppodealNativeEvents, } from './RNAppodealEvents'; 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 new file mode 100644 index 0000000..6ea5b92 --- /dev/null +++ b/src/specs/AppodealNativeViewNativeComponent.ts @@ -0,0 +1,28 @@ +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; + /** Layout style: newsFeed | appWall | contentStream. Named adTemplate — `template` is a C++ keyword and breaks Fabric codegen. */ + adTemplate?: 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..77f7bf1 100644 --- a/src/types/AppodealAdTypes.ts +++ b/src/types/AppodealAdTypes.ts @@ -16,15 +16,41 @@ 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 (stock SDK templates). + * Custom layouts use AppodealNativeAdView + asset children instead. + */ +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 {