@ezoic/react-native-sdk 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/EzoicReactNativeSdk.podspec +1 -1
  2. package/README.md +63 -3
  3. package/android/build.gradle +1 -1
  4. package/android/src/main/java/com/ezoic/reactnative/EzoicAdsModule.kt +129 -0
  5. package/android/src/main/java/com/ezoic/reactnative/EzoicOutstreamAdViewManager.kt +191 -0
  6. package/android/src/main/java/com/ezoic/reactnative/EzoicReactNativeSdkPackage.kt +2 -1
  7. package/ios/EzoicAdsImpl.swift +160 -0
  8. package/ios/EzoicOutstreamAdHostView.swift +86 -0
  9. package/ios/EzoicOutstreamAdViewComponentView.mm +105 -0
  10. package/ios/EzoicReactNativeSdk.mm +41 -0
  11. package/lib/module/EzoicInstreamAd.js +91 -0
  12. package/lib/module/EzoicInstreamAd.js.map +1 -0
  13. package/lib/module/EzoicOutstreamAdViewNativeComponent.ts +23 -0
  14. package/lib/module/NativeEzoicAds.js.map +1 -1
  15. package/lib/module/index.js +30 -0
  16. package/lib/module/index.js.map +1 -1
  17. package/lib/typescript/src/EzoicInstreamAd.d.ts +86 -0
  18. package/lib/typescript/src/EzoicInstreamAd.d.ts.map +1 -0
  19. package/lib/typescript/src/EzoicOutstreamAdViewNativeComponent.d.ts +18 -0
  20. package/lib/typescript/src/EzoicOutstreamAdViewNativeComponent.d.ts.map +1 -0
  21. package/lib/typescript/src/NativeEzoicAds.d.ts +4 -0
  22. package/lib/typescript/src/NativeEzoicAds.d.ts.map +1 -1
  23. package/lib/typescript/src/index.d.ts +22 -0
  24. package/lib/typescript/src/index.d.ts.map +1 -1
  25. package/package.json +7 -2
  26. package/src/EzoicInstreamAd.ts +110 -0
  27. package/src/EzoicOutstreamAdViewNativeComponent.ts +23 -0
  28. package/src/NativeEzoicAds.ts +18 -0
  29. package/src/index.tsx +53 -0
@@ -20,7 +20,7 @@ Pod::Spec.new do |s|
20
20
 
21
21
  # Native Ezoic Ads SDK (vends the `EzoicAdsSDKBinary` module). Brings in
22
22
  # PrebidMobile + Google-Mobile-Ads-SDK transitively.
23
- s.dependency "EzoicAdsSDK", "~> 1.3"
23
+ s.dependency "EzoicAdsSDK", "~> 1.5"
24
24
  # The native-ad host imports GoogleMobileAds directly (NativeAdView,
25
25
  # MediaView, NativeAd). Pin GMA 12 so the module is on the compile path.
26
26
  s.dependency "Google-Mobile-Ads-SDK", "~> 12.0"
package/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # @ezoic/react-native-sdk
2
2
 
3
- Ezoic Ads SDK for React Native (Prebid + Google Ad Manager banner, native, interstitial and rewarded ads).
3
+ Ezoic Ads SDK for React Native (Prebid + Google Ad Manager banner, native, interstitial, rewarded, outstream and instream video ads).
4
4
 
5
5
  A thin React Native (New Architecture) wrapper over the native Ezoic Ads SDKs
6
6
  for iOS (`EzoicAdsSDK`, via CocoaPods) and Android
7
7
  (`com.ezoic.sdk:ezoic-ads-sdk`, via Maven Central). It exposes an imperative
8
- `EzoicAds` TurboModule plus `EzoicBannerView` and `EzoicNativeAdView` Fabric
9
- components.
8
+ `EzoicAds` TurboModule plus `EzoicBannerView`, `EzoicNativeAdView` and
9
+ `EzoicOutstreamAdView` Fabric components, and the `EzoicInstreamAd` controller.
10
10
 
11
11
  ## Requirements
12
12
 
@@ -91,6 +91,60 @@ import { EzoicAds, EzoicNativeAdView } from '@ezoic/react-native-sdk';
91
91
  />;
92
92
  ```
93
93
 
94
+ ### Outstream video
95
+
96
+ `EzoicOutstreamAdView` loads and renders a self-contained outstream video ad.
97
+ Like the native ad it has no `size` prop — size it with `style` and the native
98
+ view lays the player out inside those bounds. It is view-managed: mounting the
99
+ component loads the ad, unmounting destroys it.
100
+
101
+ ```tsx
102
+ import { EzoicAds, EzoicOutstreamAdView } from '@ezoic/react-native-sdk';
103
+
104
+ <EzoicOutstreamAdView
105
+ adUnitIdentifier="123456"
106
+ style={{ width: '100%', height: 250 }}
107
+ onLoad={() => console.log('loaded')}
108
+ onError={(e) => console.log('error', e.message, e.code)}
109
+ onImpression={() => console.log('impression')}
110
+ onClick={() => console.log('click')}
111
+ onOpen={() => console.log('open')}
112
+ onClose={() => console.log('close')}
113
+ />;
114
+ ```
115
+
116
+ ### Instream video
117
+
118
+ `EzoicInstreamAd` is a view-less controller for instream (pre/mid/post-roll)
119
+ video. **The host owns the video player and the Google IMA SDK** — the SDK
120
+ renders nothing; its sole deliverable is a GAM VAST ad-tag URL string you feed
121
+ to your own IMA `AdsRequest`. A controller is multi-use and prefetchable: it is
122
+ not auto-destroyed, so you `load()` it repeatedly and `destroy()` it yourself.
123
+
124
+ ```tsx
125
+ import { EzoicInstreamAd } from '@ezoic/react-native-sdk';
126
+
127
+ const instream = new EzoicInstreamAd('123456');
128
+
129
+ // Resolve the VAST ad-tag URL and hand it to your IMA player.
130
+ const adTagUrl = await instream.load({ contentUrl: playingVideoUrl });
131
+ adsLoader.requestAds({ adTagUrl });
132
+
133
+ // On an IMA ad error, walk down the floor waterfall to the next tag.
134
+ const next = await instream.getNextAdTagUrl(); // null once exhausted
135
+ if (next) adsLoader.requestAds({ adTagUrl: next });
136
+
137
+ // On the IMA STARTED event, fire the Ezoic impression pixel.
138
+ await instream.reportImpression({ revenueUsd: 0.012 });
139
+
140
+ // Release the native controller when done.
141
+ await instream.destroy();
142
+ ```
143
+
144
+ `load()` rejects on no fill, an uninitialized SDK, or an overlapping load
145
+ already in flight for this id; it is safe to call again after a previous load
146
+ resolves. `contentUrl` and `revenueUsd` are optional.
147
+
94
148
  ## API
95
149
 
96
150
  - `EzoicAds.initialize(config)` → `Promise<void>`
@@ -100,6 +154,12 @@ import { EzoicAds, EzoicNativeAdView } from '@ezoic/react-native-sdk';
100
154
  - `EzoicAds.trackPageview()` → `Promise<boolean>`
101
155
  - `<EzoicBannerView adUnitIdentifier size onLoad onError onImpression onClick onOpen onClose />`
102
156
  - `<EzoicNativeAdView adUnitIdentifier onLoad onError onImpression onClick onOpen onClose />`
157
+ - `<EzoicOutstreamAdView adUnitIdentifier onLoad onError onImpression onClick onOpen onClose />`
158
+ - `new EzoicInstreamAd(adUnitIdentifier)`
159
+ - `.load({ contentUrl? })` → `Promise<string>` (GAM VAST ad-tag URL)
160
+ - `.getNextAdTagUrl()` → `Promise<string | null>`
161
+ - `.reportImpression({ revenueUsd? })` → `Promise<void>`
162
+ - `.destroy()` → `Promise<void>`
103
163
 
104
164
  ## License
105
165
 
@@ -68,7 +68,7 @@ dependencies {
68
68
  // Native Ezoic Ads SDK (resolved from Maven Central). Brings in Google Mobile
69
69
  // Ads + Prebid transitively. Requires mavenCentral() + google() in the
70
70
  // consuming app's repositories (the RN template provides both).
71
- implementation "com.ezoic.sdk:ezoic-ads-sdk:1.3.0"
71
+ implementation "com.ezoic.sdk:ezoic-ads-sdk:1.5.0"
72
72
 
73
73
  // The native-ad wrapper references GMA's NativeAdView/MediaView/NativeAd
74
74
  // types at compile time. The Ezoic SDK POM scopes Google Mobile Ads at
@@ -7,6 +7,8 @@ import com.facebook.react.bridge.ReactApplicationContext
7
7
  import com.facebook.react.bridge.ReadableMap
8
8
  import com.facebook.react.bridge.WritableMap
9
9
  import com.facebook.react.modules.core.DeviceEventManagerModule
10
+ import com.ezoic.ads.sdk.adunits.EzoicInstreamAd
11
+ import com.ezoic.ads.sdk.adunits.EzoicInstreamAdListener
10
12
  import com.ezoic.ads.sdk.adunits.EzoicInterstitialAd
11
13
  import com.ezoic.ads.sdk.adunits.EzoicInterstitialAdListener
12
14
  import com.ezoic.ads.sdk.adunits.EzoicInterstitialAdListenerAdapter
@@ -18,6 +20,7 @@ import com.ezoic.ads.sdk.core.EzoicAds
18
20
  import com.ezoic.ads.sdk.core.EzoicConfiguration
19
21
  import com.ezoic.ads.sdk.core.EzoicError
20
22
  import java.util.concurrent.ConcurrentHashMap
23
+ import java.util.concurrent.atomic.AtomicBoolean
21
24
 
22
25
  class EzoicAdsModule(reactContext: ReactApplicationContext) :
23
26
  NativeEzoicAdsSpec(reactContext) {
@@ -51,6 +54,28 @@ class EzoicAdsModule(reactContext: ReactApplicationContext) :
51
54
  /** Ad unit ids with an in-flight interstitial `load`. */
52
55
  private val loadingInterstitial = ConcurrentHashMap.newKeySet<Int>()
53
56
 
57
+ /**
58
+ * Instream controllers, keyed by ad unit id. Instream is multi-use and
59
+ * prefetchable, so a controller is created-or-reused per id and NOT
60
+ * auto-destroyed — it lives until [destroyInstreamAd] or module teardown.
61
+ */
62
+ private val instreamAds = ConcurrentHashMap<Int, EzoicInstreamAd>()
63
+
64
+ /**
65
+ * In-flight instream `load` calls, keyed by ad unit id. Doubles as the
66
+ * duplicate-load guard: the native `load` is a SILENT no-op while already
67
+ * loading, so an overlapping load must be rejected here or its promise hangs
68
+ * forever.
69
+ */
70
+ private val loadingInstream = ConcurrentHashMap<Int, InstreamLoad>()
71
+
72
+ private class InstreamLoad(val promise: Promise) {
73
+ // destroy/invalidate run on the JS thread while listener callbacks run on
74
+ // main, so settling must be an atomic claim (compareAndSet) rather than a
75
+ // check-then-set, or both sides can settle the same promise.
76
+ val settled = AtomicBoolean(false)
77
+ }
78
+
54
79
  override fun initialize(config: ReadableMap, promise: Promise) {
55
80
  val domain = if (config.hasKey("domain")) config.getString("domain") else null
56
81
  if (domain.isNullOrEmpty()) {
@@ -235,6 +260,110 @@ class EzoicAdsModule(reactContext: ReactApplicationContext) :
235
260
  }
236
261
  }
237
262
 
263
+ override fun loadInstreamAd(adUnitIdentifier: Double, contentUrl: String?, promise: Promise) {
264
+ val id = adUnitIdentifier.toInt()
265
+ if (id <= 0) {
266
+ promise.reject("EzoicAds", "Invalid adUnitIdentifier: $adUnitIdentifier")
267
+ return
268
+ }
269
+ // Reject overlapping loads for this id: the native load silently no-ops
270
+ // while loading (a second load on the reused controller, or a load from a
271
+ // fresh JS instance sharing the id), which would hang the promise.
272
+ if (loadingInstream.containsKey(id)) {
273
+ promise.reject("EzoicAds", "An instream ad is already loading for ad unit $id")
274
+ return
275
+ }
276
+
277
+ // Create-or-reuse: instream is multi-use, so a repeat load on the same id
278
+ // reuses the existing native controller (preserving its tag/waterfall state).
279
+ val ad = instreamAds.getOrPut(id) { EzoicInstreamAd(id) }
280
+
281
+ val holder = InstreamLoad(promise)
282
+ loadingInstream[id] = holder
283
+ // The native load uses the context only for parity (it is not retained);
284
+ // pass the same reactApplicationContext the rewarded/interstitial loads use.
285
+ // The native SDK already posts these callbacks to main, so settle directly
286
+ // here — no need to re-post via a Handler.
287
+ ad.load(reactApplicationContext, contentUrl, object : EzoicInstreamAdListener {
288
+ override fun onAdTagReady(adTagUrl: String) {
289
+ // CAS guard: after a destroy->reload a stale callback must not evict
290
+ // the newer load's holder (only the winning settle removes it).
291
+ if (holder.settled.compareAndSet(false, true)) {
292
+ loadingInstream.remove(id)
293
+ promise.resolve(adTagUrl)
294
+ }
295
+ }
296
+
297
+ override fun onAdFailedToLoad(error: EzoicError) {
298
+ if (holder.settled.compareAndSet(false, true)) {
299
+ loadingInstream.remove(id)
300
+ val userInfo = Arguments.createMap()
301
+ userInfo.putInt("code", error.code)
302
+ promise.reject("EzoicAds", error.message ?: "Instream ad failed to load", userInfo)
303
+ }
304
+ }
305
+ })
306
+ }
307
+
308
+ override fun getInstreamNextAdTagUrl(adUnitIdentifier: Double, promise: Promise) {
309
+ val id = adUnitIdentifier.toInt()
310
+ if (id <= 0) {
311
+ promise.reject("EzoicAds", "Invalid adUnitIdentifier: $adUnitIdentifier")
312
+ return
313
+ }
314
+ // Synchronous native call; resolves the next waterfall tag or null when
315
+ // exhausted / before a successful load / after destroy.
316
+ promise.resolve(instreamAds[id]?.getNextAdTagUrl())
317
+ }
318
+
319
+ override fun reportInstreamImpression(
320
+ adUnitIdentifier: Double,
321
+ revenueUsd: Double?,
322
+ promise: Promise
323
+ ) {
324
+ val id = adUnitIdentifier.toInt()
325
+ if (id <= 0) {
326
+ promise.reject("EzoicAds", "Invalid adUnitIdentifier: $adUnitIdentifier")
327
+ return
328
+ }
329
+ // No-op natively when no tag has been delivered or after destroy.
330
+ instreamAds[id]?.reportImpression(revenueUsd)
331
+ promise.resolve(null)
332
+ }
333
+
334
+ override fun destroyInstreamAd(adUnitIdentifier: Double, promise: Promise) {
335
+ val id = adUnitIdentifier.toInt()
336
+ if (id <= 0) {
337
+ promise.reject("EzoicAds", "Invalid adUnitIdentifier: $adUnitIdentifier")
338
+ return
339
+ }
340
+ // The native SDK suppresses load callbacks once destroyed (load tokens), so
341
+ // settle any pending load's promise HERE first or it hangs forever. The CAS
342
+ // claim also disarms a listener callback racing in on main concurrently.
343
+ val pending = loadingInstream.remove(id)
344
+ if (pending != null && pending.settled.compareAndSet(false, true)) {
345
+ pending.promise.reject("EzoicAds", "Instream ad was destroyed while loading")
346
+ }
347
+ instreamAds.remove(id)?.destroy()
348
+ promise.resolve(null)
349
+ }
350
+
351
+ override fun invalidate() {
352
+ // Module teardown: settle every pending instream load with an error and
353
+ // destroy every controller so no promise hangs and no native resource leaks.
354
+ for ((_, holder) in loadingInstream) {
355
+ if (holder.settled.compareAndSet(false, true)) {
356
+ holder.promise.reject("EzoicAds", "Module was destroyed while loading")
357
+ }
358
+ }
359
+ loadingInstream.clear()
360
+ for ((_, ad) in instreamAds) {
361
+ ad.destroy()
362
+ }
363
+ instreamAds.clear()
364
+ super.invalidate()
365
+ }
366
+
238
367
  override fun addListener(eventName: String) {
239
368
  // No-op: required by the React Native NativeEventEmitter contract.
240
369
  }
@@ -0,0 +1,191 @@
1
+ package com.ezoic.reactnative
2
+
3
+ import android.content.Context
4
+ import android.view.View
5
+ import android.view.ViewGroup
6
+ import android.widget.FrameLayout
7
+ import com.facebook.react.bridge.Arguments
8
+ import com.facebook.react.bridge.ReactApplicationContext
9
+ import com.facebook.react.bridge.WritableMap
10
+ import com.facebook.react.module.annotations.ReactModule
11
+ import com.facebook.react.uimanager.SimpleViewManager
12
+ import com.facebook.react.uimanager.ThemedReactContext
13
+ import com.facebook.react.uimanager.annotations.ReactProp
14
+ import com.facebook.react.uimanager.events.RCTEventEmitter
15
+ import com.ezoic.ads.sdk.adunits.EzoicOutstreamAdView
16
+ import com.ezoic.ads.sdk.adunits.EzoicOutstreamAdViewListener
17
+ import com.ezoic.ads.sdk.core.EzoicError
18
+
19
+ /**
20
+ * Fabric view manager for the outstream video component. Mirrors
21
+ * [EzoicNativeAdViewManager] exactly (deferred load, load guards, teardown,
22
+ * manual measure/layout fix, bubbling-event registry) and differs only in the
23
+ * body of [maybeLoad]: the native [EzoicOutstreamAdView] is itself the rendered
24
+ * view (a `FrameLayout` that attaches its own GAM `AdManagerAdView`), so this
25
+ * manager adds that native view as the container's child and lets it render —
26
+ * there is no template to build as the native-ad manager does.
27
+ */
28
+ @ReactModule(name = EzoicOutstreamAdViewManager.NAME)
29
+ class EzoicOutstreamAdViewManager(private val ctx: ReactApplicationContext) :
30
+ SimpleViewManager<EzoicOutstreamAdViewManager.OutstreamAdContainer>() {
31
+
32
+ override fun getName() = NAME
33
+
34
+ override fun createViewInstance(reactContext: ThemedReactContext): OutstreamAdContainer {
35
+ val container = OutstreamAdContainer(reactContext)
36
+ container.layoutParams = ViewGroup.LayoutParams(
37
+ ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT
38
+ )
39
+ return container
40
+ }
41
+
42
+ @ReactProp(name = "adUnitIdentifier")
43
+ fun setAdUnitIdentifier(view: OutstreamAdContainer, value: String?) {
44
+ val newAdUnitId = value?.toIntOrNull() ?: 0
45
+ // Ad unit changed after a load already started: tear down the loaded/loading
46
+ // ad and clear the started flag so onAfterUpdateTransaction's maybeLoad
47
+ // starts a fresh load for the new id.
48
+ if (newAdUnitId != view.adUnitId && view.loadStarted) {
49
+ view.ezoicOutstreamAd?.destroy()
50
+ view.ezoicOutstreamAd = null
51
+ view.removeAllViews()
52
+ view.loadStarted = false
53
+ view.loadGeneration++
54
+ }
55
+ view.adUnitId = newAdUnitId
56
+ view.rawAdUnitId = value ?: ""
57
+ }
58
+
59
+ // Fabric sets every prop for the mount transaction before this runs, so the
60
+ // ad unit id is final here. `view.post` escapes the mount transaction so the
61
+ // synchronous native-SDK failure path (uninitialized) can't reenter the
62
+ // mounting layer; the started flag survives repeated transactions.
63
+ override fun onAfterUpdateTransaction(view: OutstreamAdContainer) {
64
+ super.onAfterUpdateTransaction(view)
65
+ view.post { maybeLoad(view) }
66
+ }
67
+
68
+ private fun maybeLoad(view: OutstreamAdContainer) {
69
+ if (view.loadStarted || view.disposed) return
70
+ if (view.adUnitId <= 0) {
71
+ // Non-numeric or missing id coerces to 0 in setAdUnitIdentifier. Only
72
+ // emit an error when a prop was actually supplied (non-empty raw
73
+ // string); an unset prop should stay silent.
74
+ if (view.rawAdUnitId.isNotEmpty()) {
75
+ view.loadStarted = true
76
+ emit(view, "topError", errorMap("Invalid ad unit identifier", 0))
77
+ }
78
+ return
79
+ }
80
+ view.loadStarted = true
81
+ val generation = view.loadGeneration
82
+ // The native outstream view renders itself. Attach the listener BEFORE
83
+ // loadAd() so no early lifecycle callback is missed, add it to the
84
+ // container, then load.
85
+ val outstreamAd = EzoicOutstreamAdView(view.context, view.adUnitId)
86
+ outstreamAd.listener = object : EzoicOutstreamAdViewListener {
87
+ override fun onOutstreamLoaded(adView: EzoicOutstreamAdView) {
88
+ // dispose() and this callback both arrive on the main thread, so the
89
+ // check is race-free. A generation mismatch means the ad unit changed
90
+ // mid-load (mirrors the SDK's isCurrentLoad token pattern): emit nothing.
91
+ if (view.disposed || view.loadGeneration != generation) return
92
+ emit(view, "topLoad", Arguments.createMap())
93
+ }
94
+
95
+ override fun onOutstreamLoadFailed(adView: EzoicOutstreamAdView, error: EzoicError) {
96
+ if (view.disposed || view.loadGeneration != generation) return
97
+ emit(view, "topError", errorMap(error.message, error.code))
98
+ }
99
+
100
+ override fun onOutstreamImpression(adView: EzoicOutstreamAdView) {
101
+ if (view.disposed || view.loadGeneration != generation) return
102
+ emit(view, "topImpression", Arguments.createMap())
103
+ }
104
+
105
+ override fun onOutstreamClicked(adView: EzoicOutstreamAdView) {
106
+ if (view.disposed || view.loadGeneration != generation) return
107
+ emit(view, "topAdClick", Arguments.createMap())
108
+ }
109
+
110
+ override fun onOutstreamOpened(adView: EzoicOutstreamAdView) {
111
+ if (view.disposed || view.loadGeneration != generation) return
112
+ emit(view, "topOpen", Arguments.createMap())
113
+ }
114
+
115
+ override fun onOutstreamClosed(adView: EzoicOutstreamAdView) {
116
+ if (view.disposed || view.loadGeneration != generation) return
117
+ emit(view, "topClose", Arguments.createMap())
118
+ }
119
+ }
120
+ view.ezoicOutstreamAd = outstreamAd
121
+ view.removeAllViews()
122
+ view.addView(outstreamAd)
123
+ outstreamAd.loadAd()
124
+ }
125
+
126
+ override fun onDropViewInstance(view: OutstreamAdContainer) {
127
+ super.onDropViewInstance(view)
128
+ view.disposed = true
129
+ view.loadGeneration++
130
+ view.ezoicOutstreamAd?.destroy()
131
+ view.ezoicOutstreamAd = null
132
+ view.removeAllViews()
133
+ }
134
+
135
+ private fun emit(view: OutstreamAdContainer, event: String, payload: WritableMap) {
136
+ if (view.disposed) return
137
+ ctx.getJSModule(RCTEventEmitter::class.java).receiveEvent(view.id, event, payload)
138
+ }
139
+
140
+ private fun errorMap(message: String?, code: Int): WritableMap {
141
+ val map = Arguments.createMap()
142
+ map.putString("message", message ?: "")
143
+ map.putInt("code", code)
144
+ return map
145
+ }
146
+
147
+ override fun getExportedCustomBubblingEventTypeConstants(): Map<String, Any> {
148
+ fun reg(on: String) = mapOf("phasedRegistrationNames" to mapOf("bubbled" to on))
149
+ return mapOf(
150
+ "topLoad" to reg("onLoad"),
151
+ "topError" to reg("onError"),
152
+ "topImpression" to reg("onImpression"),
153
+ "topAdClick" to reg("onAdClick"),
154
+ "topOpen" to reg("onOpen"),
155
+ "topClose" to reg("onClose")
156
+ )
157
+ }
158
+
159
+ /**
160
+ * Container for the native outstream view. RN lays out only Yoga-managed
161
+ * views; the native [EzoicOutstreamAdView] is added from native code and stays
162
+ * unmeasured (blank) unless we force a measure/layout pass. Overriding
163
+ * [requestLayout] to post a manual measure(EXACTLY)+layout of the current
164
+ * bounds is the proven fix (identical to [EzoicNativeAdViewManager]).
165
+ */
166
+ class OutstreamAdContainer(context: Context) : FrameLayout(context) {
167
+ var adUnitId: Int = 0
168
+ var rawAdUnitId: String = ""
169
+ var loadStarted: Boolean = false
170
+ var disposed: Boolean = false
171
+ var loadGeneration: Int = 0
172
+ var ezoicOutstreamAd: EzoicOutstreamAdView? = null
173
+
174
+ private val measureAndLayout = Runnable {
175
+ measure(
176
+ View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
177
+ View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY)
178
+ )
179
+ layout(left, top, right, bottom)
180
+ }
181
+
182
+ override fun requestLayout() {
183
+ super.requestLayout()
184
+ post(measureAndLayout)
185
+ }
186
+ }
187
+
188
+ companion object {
189
+ const val NAME = "EzoicOutstreamAdView"
190
+ }
191
+ }
@@ -21,7 +21,8 @@ class EzoicReactNativeSdkPackage : BaseReactPackage() {
21
21
  ): List<ViewManager<*, *>> {
22
22
  return listOf(
23
23
  EzoicBannerViewManager(reactContext),
24
- EzoicNativeAdViewManager(reactContext)
24
+ EzoicNativeAdViewManager(reactContext),
25
+ EzoicOutstreamAdViewManager(reactContext)
25
26
  )
26
27
  }
27
28
 
@@ -43,6 +43,28 @@ import EzoicAdsSDKBinary
43
43
  /// Ad unit ids with an in-flight interstitial `load`.
44
44
  private var loadingInterstitial: Set<Int> = []
45
45
 
46
+ /// Active instream controllers, keyed by ad unit id. Instream is multi-use and
47
+ /// NOT auto-destroying, so this impl retains each controller across load
48
+ /// cycles (and is its `weak` delegate) until `destroyInstreamAd`.
49
+ private var instreamAds: [Int: EzoicInstreamAd] = [:]
50
+
51
+ /// In-flight instream `load` calls, keyed by ad unit id. Doubles as the
52
+ /// duplicate-load guard: the native `load` is a silent no-op while already
53
+ /// loading, so an unguarded second promise would hang forever.
54
+ private var pendingInstreamLoads: [Int: PendingInstreamLoad] = [:]
55
+
56
+ private final class PendingInstreamLoad {
57
+ let resolve: (Any?) -> Void
58
+ let reject: (String, String, NSError?) -> Void
59
+ /// Set once the promise is fulfilled so a late delegate callback (or a
60
+ /// destroy that already settled it) cannot double-settle it.
61
+ var settled: Bool = false
62
+ init(resolve: @escaping (Any?) -> Void, reject: @escaping (String, String, NSError?) -> Void) {
63
+ self.resolve = resolve
64
+ self.reject = reject
65
+ }
66
+ }
67
+
46
68
  /// Runs `work` on the main thread. The ad/pending/loading dictionaries and
47
69
  /// every native load/show call touch UIKit and this shared state, so they must
48
70
  /// only run on main. Delegate callbacks already arrive on main.
@@ -228,6 +250,113 @@ import EzoicAdsSDKBinary
228
250
  for (key, value) in extra { body[key] = value }
229
251
  eventEmitter?("EzoicInterstitialAdEvent", body)
230
252
  }
253
+
254
+ // MARK: - Instream video
255
+
256
+ /// Converts a bridge `Double` ad unit id to a native `Int`, rejecting NaN,
257
+ /// infinite, and out-of-`Int` ids. `Number("abc")` on the JS side arrives as
258
+ /// NaN, and `Int(Double.nan)` traps; likewise `Int(1e20)` traps even though
259
+ /// 1e20 is finite, so the upper bound must be checked before the `Int(...)`
260
+ /// conversion. Bounded to `Int32.max` and requires >= 1 to match Android's
261
+ /// rejection of ids <= 0.
262
+ private func instreamId(_ adUnitIdentifier: Double,
263
+ _ reject: (String, String, NSError?) -> Void) -> Int? {
264
+ guard adUnitIdentifier.isFinite,
265
+ adUnitIdentifier >= 1,
266
+ adUnitIdentifier <= Double(Int32.max) else {
267
+ reject("EzoicAds", "Invalid adUnitIdentifier: \(adUnitIdentifier)", nil)
268
+ return nil
269
+ }
270
+ return Int(adUnitIdentifier)
271
+ }
272
+
273
+ @objc public func loadInstreamAd(_ adUnitIdentifier: Double,
274
+ contentUrl: String?,
275
+ resolve: @escaping (Any?) -> Void,
276
+ reject: @escaping (String, String, NSError?) -> Void) {
277
+ onMain { [weak self] in
278
+ guard let self = self else { return }
279
+ guard let id = self.instreamId(adUnitIdentifier, reject) else { return }
280
+ // Reject overlapping loads: the native load silently no-ops while an
281
+ // earlier one is in flight, which would hang this promise forever.
282
+ if self.pendingInstreamLoads[id] != nil {
283
+ reject("EzoicAds", "An instream ad is already loading for ad unit \(id)", nil)
284
+ return
285
+ }
286
+ // Create-or-reuse: instream is multi-use, so a repeat load on the same id
287
+ // reuses the existing native controller (preserving its tag state).
288
+ let ad: EzoicInstreamAd
289
+ if let existing = self.instreamAds[id] {
290
+ ad = existing
291
+ } else {
292
+ ad = EzoicInstreamAd(adUnitId: id)
293
+ self.instreamAds[id] = ad
294
+ }
295
+ // Register the pending holder BEFORE calling load: early validation
296
+ // failures deliver the delegate callback synchronously.
297
+ self.pendingInstreamLoads[id] = PendingInstreamLoad(resolve: resolve, reject: reject)
298
+ ad.load(contentUrl: contentUrl, delegate: self)
299
+ }
300
+ }
301
+
302
+ @objc public func getInstreamNextAdTagUrl(_ adUnitIdentifier: Double,
303
+ resolve: @escaping (Any?) -> Void,
304
+ reject: @escaping (String, String, NSError?) -> Void) {
305
+ onMain { [weak self] in
306
+ guard let self = self else { return }
307
+ guard let id = self.instreamId(adUnitIdentifier, reject) else { return }
308
+ // Native getNextAdTagUrl returns nil once the waterfall is exhausted,
309
+ // before a successful load, or after destroy — surface that as JS null.
310
+ resolve(self.instreamAds[id]?.getNextAdTagUrl())
311
+ }
312
+ }
313
+
314
+ @objc public func reportInstreamImpression(_ adUnitIdentifier: Double,
315
+ revenueUsd: NSNumber?,
316
+ resolve: @escaping (Any?) -> Void,
317
+ reject: @escaping (String, String, NSError?) -> Void) {
318
+ onMain { [weak self] in
319
+ guard let self = self else { return }
320
+ guard let id = self.instreamId(adUnitIdentifier, reject) else { return }
321
+ self.instreamAds[id]?.reportImpression(revenueUsd: revenueUsd?.doubleValue)
322
+ resolve(nil)
323
+ }
324
+ }
325
+
326
+ @objc public func destroyInstreamAd(_ adUnitIdentifier: Double,
327
+ resolve: @escaping (Any?) -> Void,
328
+ reject: @escaping (String, String, NSError?) -> Void) {
329
+ onMain { [weak self] in
330
+ guard let self = self else { return }
331
+ guard let id = self.instreamId(adUnitIdentifier, reject) else { return }
332
+ // Native suppresses load callbacks once destroyed, so settle any pending
333
+ // load's promise here first or it hangs forever.
334
+ if let pending = self.pendingInstreamLoads.removeValue(forKey: id), !pending.settled {
335
+ pending.settled = true
336
+ pending.reject("EzoicAds", "Instream ad was destroyed while loading", nil)
337
+ }
338
+ self.instreamAds.removeValue(forKey: id)?.destroy()
339
+ resolve(nil)
340
+ }
341
+ }
342
+
343
+ /// Module teardown parity with Android's `EzoicAdsModule.invalidate`: settle
344
+ /// every pending instream load with an error and destroy every controller so
345
+ /// no promise hangs and no native resource leaks.
346
+ @objc public func invalidate() {
347
+ onMain { [weak self] in
348
+ guard let self = self else { return }
349
+ for (_, pending) in self.pendingInstreamLoads where !pending.settled {
350
+ pending.settled = true
351
+ pending.reject("EzoicAds", "Module was destroyed while loading", nil)
352
+ }
353
+ self.pendingInstreamLoads.removeAll()
354
+ for (_, ad) in self.instreamAds {
355
+ ad.destroy()
356
+ }
357
+ self.instreamAds.removeAll()
358
+ }
359
+ }
231
360
  }
232
361
 
233
362
  // MARK: - EzoicRewardedAdDelegate
@@ -310,3 +439,34 @@ extension EzoicAdsImpl: EzoicInterstitialAdDelegate {
310
439
  }
311
440
  }
312
441
  }
442
+
443
+ // MARK: - EzoicInstreamAdDelegate
444
+
445
+ extension EzoicAdsImpl: EzoicInstreamAdDelegate {
446
+
447
+ // Delegate callbacks arrive on main (see `onMain`), matching the rewarded /
448
+ // interstitial extensions which touch shared state directly. Removal happens
449
+ // only inside the not-yet-settled branch (settled-conditional removal) so a
450
+ // stale callback can't evict a newer load's holder after destroy→reload.
451
+ public func instreamAd(_ instreamAd: EzoicInstreamAd, didReceiveAdTag adTagUrl: String) {
452
+ let id = instreamAd.adUnitId
453
+ // Identity check: after destroy->reload for this id, a late callback from
454
+ // the destroyed controller must not settle the newer load's promise.
455
+ guard self.instreamAds[id] === instreamAd else { return }
456
+ guard let pending = pendingInstreamLoads[id], !pending.settled else { return }
457
+ pending.settled = true
458
+ pendingInstreamLoads.removeValue(forKey: id)
459
+ pending.resolve(adTagUrl)
460
+ }
461
+
462
+ public func instreamAd(_ instreamAd: EzoicInstreamAd, didFailToLoadWithError error: EzoicError) {
463
+ let id = instreamAd.adUnitId
464
+ // Identity check: after destroy->reload for this id, a late callback from
465
+ // the destroyed controller must not settle the newer load's promise.
466
+ guard self.instreamAds[id] === instreamAd else { return }
467
+ guard let pending = pendingInstreamLoads[id], !pending.settled else { return }
468
+ pending.settled = true
469
+ pendingInstreamLoads.removeValue(forKey: id)
470
+ pending.reject("EzoicAds", error.localizedDescription, error as NSError)
471
+ }
472
+ }