cloudx-react-native 3.10.0 → 3.10.1

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 (103) hide show
  1. package/README.md +1 -0
  2. package/android/src/main/java/com/cloudxreactnative/CloudXAdEvents.kt +10 -0
  3. package/android/src/main/java/com/cloudxreactnative/CloudXAdInfo.kt +40 -0
  4. package/android/src/main/java/com/cloudxreactnative/CloudXNativeAdView.kt +718 -0
  5. package/android/src/main/java/com/cloudxreactnative/CloudXNativeAdViewManagerImpl.kt +119 -0
  6. package/android/src/main/java/com/cloudxreactnative/CloudXPackage.kt +4 -1
  7. package/android/src/main/res/layout/cloudx_rn_native_media_banner_template.xml +100 -0
  8. package/android/src/main/res/layout/cloudx_rn_native_medium_template.xml +99 -0
  9. package/android/src/main/res/layout/cloudx_rn_native_small_template.xml +72 -0
  10. package/android/src/main/res/layout/cloudx_rn_native_vertical_media_template.xml +117 -0
  11. package/android/src/newarch/com/cloudxreactnative/CloudXModule.kt +1 -0
  12. package/android/src/newarch/com/cloudxreactnative/CloudXNativeAdViewManager.kt +86 -0
  13. package/android/src/oldarch/com/cloudxreactnative/CloudXModule.kt +1 -0
  14. package/android/src/oldarch/com/cloudxreactnative/CloudXNativeAdViewManager.kt +118 -0
  15. package/ios/CloudX.h +8 -0
  16. package/ios/CloudX.mm +159 -29
  17. package/ios/CloudXNativeAdView.h +75 -0
  18. package/ios/CloudXNativeAdView.mm +979 -0
  19. package/ios/CloudXNativeAdViewManager.h +12 -0
  20. package/ios/CloudXNativeAdViewManager.mm +119 -0
  21. package/lib/commonjs/CloudXAdViewBase.js +8 -97
  22. package/lib/commonjs/CloudXAdViewBase.js.map +1 -1
  23. package/lib/commonjs/CloudXNativeAdView.js +331 -0
  24. package/lib/commonjs/CloudXNativeAdView.js.map +1 -0
  25. package/lib/commonjs/CloudXNativeAdViewComponents.js +252 -0
  26. package/lib/commonjs/CloudXNativeAdViewComponents.js.map +1 -0
  27. package/lib/commonjs/CloudXNativeAdViewProvider.js +117 -0
  28. package/lib/commonjs/CloudXNativeAdViewProvider.js.map +1 -0
  29. package/lib/commonjs/extraParameters.js +52 -0
  30. package/lib/commonjs/extraParameters.js.map +1 -0
  31. package/lib/commonjs/hooks.js +34 -0
  32. package/lib/commonjs/hooks.js.map +1 -1
  33. package/lib/commonjs/index.js +72 -0
  34. package/lib/commonjs/index.js.map +1 -1
  35. package/lib/commonjs/specs/CloudXNativeAdViewNativeComponent.js +41 -0
  36. package/lib/commonjs/specs/CloudXNativeAdViewNativeComponent.js.map +1 -0
  37. package/lib/commonjs/types/NativeAd.js +70 -0
  38. package/lib/commonjs/types/NativeAd.js.map +1 -0
  39. package/lib/commonjs/types/index.js +13 -0
  40. package/lib/commonjs/types/index.js.map +1 -1
  41. package/lib/commonjs/useCloudXSdkInitialization.js +75 -0
  42. package/lib/commonjs/useCloudXSdkInitialization.js.map +1 -0
  43. package/lib/commonjs/version.js +1 -1
  44. package/lib/module/CloudXAdViewBase.js +7 -96
  45. package/lib/module/CloudXAdViewBase.js.map +1 -1
  46. package/lib/module/CloudXNativeAdView.js +324 -0
  47. package/lib/module/CloudXNativeAdView.js.map +1 -0
  48. package/lib/module/CloudXNativeAdViewComponents.js +240 -0
  49. package/lib/module/CloudXNativeAdViewComponents.js.map +1 -0
  50. package/lib/module/CloudXNativeAdViewProvider.js +110 -0
  51. package/lib/module/CloudXNativeAdViewProvider.js.map +1 -0
  52. package/lib/module/extraParameters.js +45 -0
  53. package/lib/module/extraParameters.js.map +1 -0
  54. package/lib/module/hooks.js +34 -0
  55. package/lib/module/hooks.js.map +1 -1
  56. package/lib/module/index.js +13 -0
  57. package/lib/module/index.js.map +1 -1
  58. package/lib/module/specs/CloudXNativeAdViewNativeComponent.js +35 -0
  59. package/lib/module/specs/CloudXNativeAdViewNativeComponent.js.map +1 -0
  60. package/lib/module/types/NativeAd.js +68 -0
  61. package/lib/module/types/NativeAd.js.map +1 -0
  62. package/lib/module/types/index.js +1 -0
  63. package/lib/module/types/index.js.map +1 -1
  64. package/lib/module/useCloudXSdkInitialization.js +68 -0
  65. package/lib/module/useCloudXSdkInitialization.js.map +1 -0
  66. package/lib/module/version.js +1 -1
  67. package/lib/typescript/src/CloudXAdViewBase.d.ts.map +1 -1
  68. package/lib/typescript/src/CloudXNativeAdView.d.ts +24 -0
  69. package/lib/typescript/src/CloudXNativeAdView.d.ts.map +1 -0
  70. package/lib/typescript/src/CloudXNativeAdViewComponents.d.ts +67 -0
  71. package/lib/typescript/src/CloudXNativeAdViewComponents.d.ts.map +1 -0
  72. package/lib/typescript/src/CloudXNativeAdViewProvider.d.ts +72 -0
  73. package/lib/typescript/src/CloudXNativeAdViewProvider.d.ts.map +1 -0
  74. package/lib/typescript/src/extraParameters.d.ts +4 -0
  75. package/lib/typescript/src/extraParameters.d.ts.map +1 -0
  76. package/lib/typescript/src/hooks.d.ts.map +1 -1
  77. package/lib/typescript/src/index.d.ts +4 -1
  78. package/lib/typescript/src/index.d.ts.map +1 -1
  79. package/lib/typescript/src/specs/CloudXNativeAdViewNativeComponent.d.ts +103 -0
  80. package/lib/typescript/src/specs/CloudXNativeAdViewNativeComponent.d.ts.map +1 -0
  81. package/lib/typescript/src/types/AdInfo.d.ts +4 -1
  82. package/lib/typescript/src/types/AdInfo.d.ts.map +1 -1
  83. package/lib/typescript/src/types/NativeAd.d.ts +114 -0
  84. package/lib/typescript/src/types/NativeAd.d.ts.map +1 -0
  85. package/lib/typescript/src/types/index.d.ts +2 -0
  86. package/lib/typescript/src/types/index.d.ts.map +1 -1
  87. package/lib/typescript/src/useCloudXSdkInitialization.d.ts +16 -0
  88. package/lib/typescript/src/useCloudXSdkInitialization.d.ts.map +1 -0
  89. package/lib/typescript/src/version.d.ts +1 -1
  90. package/package.json +1 -1
  91. package/src/CloudXAdViewBase.tsx +8 -125
  92. package/src/CloudXNativeAdView.tsx +419 -0
  93. package/src/CloudXNativeAdViewComponents.tsx +203 -0
  94. package/src/CloudXNativeAdViewProvider.tsx +149 -0
  95. package/src/extraParameters.ts +65 -0
  96. package/src/hooks.ts +38 -0
  97. package/src/index.ts +24 -0
  98. package/src/specs/CloudXNativeAdViewNativeComponent.ts +137 -0
  99. package/src/types/AdInfo.ts +5 -1
  100. package/src/types/NativeAd.ts +125 -0
  101. package/src/types/index.ts +7 -0
  102. package/src/useCloudXSdkInitialization.ts +73 -0
  103. package/src/version.ts +1 -1
@@ -0,0 +1,718 @@
1
+ package com.cloudxreactnative
2
+
3
+ import android.graphics.drawable.BitmapDrawable
4
+ import android.util.Log
5
+ import android.view.View
6
+ import android.view.View.MeasureSpec
7
+ import android.view.ViewGroup
8
+ import android.widget.ImageView
9
+ import com.facebook.react.bridge.Arguments
10
+ import com.facebook.react.bridge.WritableMap
11
+ import com.facebook.react.uimanager.ThemedReactContext
12
+ import com.facebook.react.uimanager.UIManagerHelper
13
+ import com.facebook.react.views.view.ReactViewGroup
14
+ import io.cloudx.sdk.CloudX
15
+ import io.cloudx.sdk.CloudXAd
16
+ import io.cloudx.sdk.CloudXAdRevenueListener
17
+ import io.cloudx.sdk.CloudXError
18
+ import io.cloudx.sdk.CloudXErrorCode
19
+ import io.cloudx.sdk.CloudXNativeAd
20
+ import io.cloudx.sdk.CloudXNativeAdListener
21
+ import io.cloudx.sdk.CloudXNativeAdLoader
22
+ import io.cloudx.sdk.CloudXNativeAdView as SdkNativeAdView
23
+ import io.cloudx.sdk.CloudXNativeAdViewBinder
24
+ import io.cloudx.sdk.toCloudXError
25
+ import org.json.JSONObject
26
+
27
+ /**
28
+ * In-tree native ad view component. Supports two render modes:
29
+ *
30
+ * - Template (default): the bundled SDK templates (small / medium / media-banner
31
+ * / vertical-media) are inflated and populated by the SDK.
32
+ * - Manual: when the JS layer binds asset-view refs via `updateAssetView` and
33
+ * calls `renderNativeAd`, this view populates the publisher-supplied views
34
+ * from `ad.nativeAd.*` getters and calls
35
+ * `ad.nativeAd.prepareForInteraction(clickableViews, this)` so impression
36
+ * and click tracking are wired up against the publisher's own layout.
37
+ *
38
+ * Extends [ReactViewGroup] (not [FrameLayout], the base used by the in-tree
39
+ * banner/MREC [CloudXAdView]) because, unlike a banner, this component can
40
+ * host real React-declared children in manual mode: `ReactViewGroup` makes
41
+ * `onLayout`/`requestLayout` no-ops so React Native's `NativeViewHierarchyManager`
42
+ * keeps authority over their Yoga-computed frames. Extending a plain `ViewGroup`
43
+ * here would let Android's own layout pass resize every RN-managed asset view
44
+ * (title, icon, media, CTA) to `MATCH_PARENT` on the next re-layout, stacking
45
+ * them at identical bounds and burying the CTA.
46
+ *
47
+ * A dedicated [CloudXNativeAdLoader] is created per view instance rather than
48
+ * shared per ad unit. Sharing a loader across concurrent mounts for the same ad
49
+ * unit produces two defects: (1) delegate reassignment — each new view's
50
+ * `loader.nativeAdListener = this` overwrites the previous view's registration,
51
+ * so callbacks are delivered to whichever view attached most recently; (2)
52
+ * duplicate-load rejection — the SDK serializes `loadAd` on a single loader, so
53
+ * concurrent mounts beyond the first get an immediate "load already in
54
+ * progress" failure and stay stuck.
55
+ */
56
+ class CloudXNativeAdView(private val reactContext: ThemedReactContext) : ReactViewGroup(reactContext), CloudXNativeAdListener, CloudXAdRevenueListener {
57
+
58
+ private companion object {
59
+ const val TAG = "CloudXNativeAdView"
60
+
61
+ /**
62
+ * Delay before the second sizing pass on an inserted media view. Some
63
+ * networks attach their media surface asynchronously after the binder
64
+ * returns, so the next-frame pass can still measure an empty view.
65
+ */
66
+ const val MEDIA_VIEW_RESIZE_RETRY_MS = 500L
67
+ }
68
+
69
+ var adUnitId: String? = null
70
+ set(value) {
71
+ if (field == value) return
72
+ field = value
73
+ // The @ReactProp setters for `adUnitId` and `manualRender` fire in
74
+ // declaration order, so eagerly loading from this setter would run
75
+ // before `manualRender` has been applied — locking the view into
76
+ // the template load path even when the publisher mounted asset-view
77
+ // children. Defer to `onAttachedToWindow`, which RN guarantees to
78
+ // fire after all props are set.
79
+ //
80
+ // A change AFTER a loader exists is a live re-target: the old
81
+ // loader belongs to the old ad unit and must not keep serving
82
+ // callbacks for this view.
83
+ if (attachedLoader != null) {
84
+ releaseLoaderState()
85
+ // The same ordering trap described above applies to the
86
+ // re-target: `placement`, `customData`, the video flags and the
87
+ // extra parameters are applied AFTER `adUnitId`, so loading
88
+ // synchronously here configured the new loader from the
89
+ // previous unit's values. Post it instead, so the load runs
90
+ // once the whole prop update has been applied.
91
+ val target = value
92
+ if (isAttachedToWindow) {
93
+ post { if (adUnitId == target && isAttachedToWindow) loadIfPossible() }
94
+ }
95
+ }
96
+ }
97
+
98
+ var templateName: String? = null
99
+
100
+ var placement: String? = null
101
+ set(value) {
102
+ field = value
103
+ currentLoader()?.setPlacement(value)
104
+ }
105
+
106
+ var customData: String? = null
107
+ set(value) {
108
+ field = value
109
+ currentLoader()?.setCustomData(value)
110
+ }
111
+
112
+ var disableVideoFullScreen: Boolean = false
113
+ set(value) {
114
+ field = value
115
+ currentLoader()?.isVideoFullScreenDisabled = value
116
+ }
117
+
118
+ var startVideoUnmuted: Boolean = false
119
+ set(value) {
120
+ field = value
121
+ currentLoader()?.isStartVideoUnmuted = value
122
+ }
123
+
124
+ var hideVideoMediaControls: Boolean = false
125
+ set(value) {
126
+ field = value
127
+ currentLoader()?.isVideoMediaControlsHidden = value
128
+ }
129
+
130
+ /**
131
+ * Controls how the loaded ad is rendered. When true, this view skips the
132
+ * template binder and lets the JS layer drive layout through
133
+ * `updateAssetView` + `renderNativeAd`. Auto-load itself runs in both
134
+ * render modes.
135
+ */
136
+ var manualRender: Boolean = false
137
+
138
+ var extraParameters: Map<String, Any?> = emptyMap()
139
+ set(value) {
140
+ field = value
141
+ applyExtraParametersIfReady()
142
+ }
143
+
144
+ var localExtraParameters: Map<String, Any?> = emptyMap()
145
+ set(value) {
146
+ field = value
147
+ applyExtraParametersIfReady()
148
+ }
149
+
150
+ private var appliedExtraParameterKeys: Set<String> = emptySet()
151
+ private var appliedLocalExtraParameterKeys: Set<String> = emptySet()
152
+
153
+ private var attachedLoader: CloudXNativeAdLoader? = null
154
+ private var hostedAdView: SdkNativeAdView? = null
155
+ private var loadRequested: Boolean = false
156
+
157
+ /**
158
+ * Ad delivered by the current load cycle, if any. Used to distinguish
159
+ * post-load failures (render/adapter errors after a successful load,
160
+ * surfaced to JS as `onAdDisplayFailedEvent`) from genuine load failures
161
+ * (`onAdLoadFailedEvent`).
162
+ */
163
+ private var loadedCycleAd: CloudXAd? = null
164
+
165
+ /**
166
+ * Most recently loaded ad in manual mode. Held so `renderNativeAd` can
167
+ * register click+impression tracking once the JS layer finishes binding
168
+ * asset views.
169
+ */
170
+ private var pendingManualAd: CloudXAd? = null
171
+
172
+ /** Publisher-supplied clickable subviews, collected by `updateAssetView`. */
173
+ private val clickableViews: MutableList<View> = mutableListOf()
174
+
175
+ /**
176
+ * Maps asset role name to the publisher-supplied view. Used so a re-bind
177
+ * of the same role replaces the prior entry without leaving stale
178
+ * references in `clickableViews`.
179
+ */
180
+ private val assetViewsByRole: MutableMap<String, View> = mutableMapOf()
181
+
182
+ /**
183
+ * SDK-owned media/options views inserted into the publisher's asset
184
+ * containers during manual binding. Tracked so a reload or a destroy can
185
+ * pull them back out; without this, successive load cycles stack multiple
186
+ * SDK views inside the same container. Mirrors iOS's `insertedSDKSubviews`.
187
+ */
188
+ private val insertedSdkViews: MutableList<View> = mutableListOf()
189
+
190
+ /**
191
+ * React-declared children only. The SDK ad view is attached as a physical
192
+ * `ViewGroup` child but deliberately kept out of this list so React's
193
+ * `manageChildren` reconciliation never touches it. Mirrors
194
+ * [CloudXNativeAdViewManagerImpl]'s shadowed child accessors.
195
+ */
196
+ private val reactChildren: MutableList<View> = mutableListOf()
197
+
198
+ fun reactChildCount(): Int = reactChildren.size
199
+
200
+ fun reactChildAt(index: Int): View? = reactChildren.getOrNull(index)
201
+
202
+ fun addReactChild(child: View, index: Int) {
203
+ val clampedIndex = index.coerceIn(0, reactChildren.size)
204
+ reactChildren.add(clampedIndex, child)
205
+ super.addView(child, clampedIndex)
206
+ }
207
+
208
+ fun removeReactChildAt(index: Int) {
209
+ if (index !in reactChildren.indices) return
210
+ val child = reactChildren.removeAt(index)
211
+ super.removeView(child)
212
+ }
213
+
214
+ fun removeAllReactChildren() {
215
+ for (child in reactChildren) {
216
+ super.removeView(child)
217
+ }
218
+ reactChildren.clear()
219
+ }
220
+
221
+ /**
222
+ * A dedicated loader per view. Sharing a loader across views for the same
223
+ * ad unit causes delegate reassignment and duplicate-load rejection (see
224
+ * the class doc). Reads the view's current field values so a loader
225
+ * created lazily by any setter (or by `onAttachedToWindow`) always starts
226
+ * fully configured, regardless of which prop landed first.
227
+ */
228
+ private fun currentLoader(): CloudXNativeAdLoader? {
229
+ attachedLoader?.let { return it }
230
+ val id = adUnitId ?: return null
231
+ val loader = CloudX.createNativeAdLoader(reactContext, id)
232
+ attachedLoader = loader
233
+ loader.nativeAdListener = this
234
+ loader.revenueListener = this
235
+ loader.setPlacement(placement)
236
+ loader.setCustomData(customData)
237
+ loader.isVideoFullScreenDisabled = disableVideoFullScreen
238
+ loader.isStartVideoUnmuted = startVideoUnmuted
239
+ loader.isVideoMediaControlsHidden = hideVideoMediaControls
240
+ appliedExtraParameterKeys = emptySet()
241
+ appliedLocalExtraParameterKeys = emptySet()
242
+ applyExtraParametersIfReady()
243
+ return loader
244
+ }
245
+
246
+ private fun applyExtraParametersIfReady() {
247
+ val loader = attachedLoader ?: return
248
+ appliedExtraParameterKeys = applyEntries(loader::setExtraParameter, extraParameters, appliedExtraParameterKeys)
249
+ appliedLocalExtraParameterKeys = applyEntries(loader::setLocalExtraParameter, localExtraParameters, appliedLocalExtraParameterKeys)
250
+ }
251
+
252
+ private fun applyEntries(setter: (String, Any?) -> Unit, entries: Map<String, Any?>, previousKeys: Set<String>): Set<String> {
253
+ for ((key, value) in entries) setter(key, value)
254
+ for (key in previousKeys - entries.keys) setter(key, null)
255
+ return entries.keys
256
+ }
257
+
258
+ private fun buildBinder(): CloudXNativeAdViewBinder {
259
+ // Every bundled layout uses the same resource IDs for each slot so we
260
+ // only need to swap the layout resource based on `templateName`.
261
+ // Unknown values fall back to the medium layout.
262
+ val layoutRes = when (templateName) {
263
+ "small_template" -> R.layout.cloudx_rn_native_small_template
264
+ "media_banner_template" -> R.layout.cloudx_rn_native_media_banner_template
265
+ "vertical_media_template" -> R.layout.cloudx_rn_native_vertical_media_template
266
+ else -> R.layout.cloudx_rn_native_medium_template
267
+ }
268
+ return CloudXNativeAdViewBinder.Builder(layoutRes)
269
+ .setTitleTextViewId(R.id.cloudx_rn_native_title)
270
+ .setBodyTextViewId(R.id.cloudx_rn_native_body)
271
+ .setCallToActionButtonId(R.id.cloudx_rn_native_cta)
272
+ .setIconImageViewId(R.id.cloudx_rn_native_icon)
273
+ .setMediaContentViewGroupId(R.id.cloudx_rn_native_media)
274
+ .setOptionsContentViewGroupId(R.id.cloudx_rn_native_options)
275
+ .setAdvertiserTextViewId(R.id.cloudx_rn_native_advertiser)
276
+ .setStarRatingContentViewGroupId(R.id.cloudx_rn_native_rating)
277
+ .build()
278
+ }
279
+
280
+ private fun loadIfPossible() {
281
+ if (loadRequested) return
282
+ val loader = currentLoader() ?: return
283
+ loadRequested = true
284
+ loadedCycleAd = null
285
+
286
+ if (manualRender) {
287
+ // Flow C (late binding): no pre-built binder view. The publisher
288
+ // children drive layout; `onNativeAdLoaded` will stash the ad and
289
+ // the subsequent `updateAssetView` + `renderNativeAd` commands
290
+ // wire up content and click tracking.
291
+ pendingManualAd = null
292
+ clickableViews.clear()
293
+ assetViewsByRole.clear()
294
+ loader.loadAd()
295
+ } else {
296
+ // Flow B (pre-built binder): the SDK populates the bundled
297
+ // template view. Attach before loading so the SDK's impression
298
+ // observer sees a window-backed view when it binds the creative;
299
+ // attaching only from `onNativeAdLoaded` can miss the first
300
+ // impression and its revenue callback.
301
+ val adView = SdkNativeAdView(context, buildBinder())
302
+ attachAdView(adView)
303
+ loader.loadAd(adView)
304
+ }
305
+ }
306
+
307
+ override fun onAttachedToWindow() {
308
+ super.onAttachedToWindow()
309
+ loadIfPossible()
310
+ }
311
+
312
+ fun teardown() {
313
+ releaseLoaderState()
314
+ }
315
+
316
+ /**
317
+ * Destroys the owned loader and resets all per-loader state. The loader
318
+ * is owned by this view: destroying it releases the SDK-side ad pipeline
319
+ * so nothing calls back into this view afterwards. Used both when React
320
+ * drops the view instance and when `adUnitId` is re-targeted live.
321
+ */
322
+ private fun releaseLoaderState() {
323
+ attachedLoader?.let {
324
+ it.nativeAdListener = null
325
+ it.revenueListener = null
326
+ it.destroy()
327
+ }
328
+ attachedLoader = null
329
+ removeInsertedSdkViews()
330
+ hostedAdView?.let { (it.parent as? ViewGroup)?.removeView(it) }
331
+ hostedAdView = null
332
+ pendingManualAd = null
333
+ loadedCycleAd = null
334
+ loadRequested = false
335
+ clickableViews.clear()
336
+ assetViewsByRole.clear()
337
+ appliedExtraParameterKeys = emptySet()
338
+ appliedLocalExtraParameterKeys = emptySet()
339
+ }
340
+
341
+ /**
342
+ * Pulls SDK-owned views back out of the publisher's containers. The
343
+ * containers themselves are React children and are left mounted.
344
+ */
345
+ private fun removeInsertedSdkViews() {
346
+ for (sdkView in insertedSdkViews) {
347
+ (sdkView.parent as? ViewGroup)?.removeView(sdkView)
348
+ }
349
+ insertedSdkViews.clear()
350
+ }
351
+
352
+ private fun attachAdView(adView: SdkNativeAdView) {
353
+ // If a previous SDK ad view was attached for this instance, detach it
354
+ // first. React-managed children (manual mode's asset views) are never
355
+ // touched here — they belong to the JS tree and must survive reloads.
356
+ hostedAdView?.takeIf { it !== adView }?.let { previous ->
357
+ (previous.parent as? ViewGroup)?.removeView(previous)
358
+ }
359
+ hostedAdView = adView
360
+ if (adView.parent !== this) {
361
+ (adView.parent as? ViewGroup)?.removeView(adView)
362
+ // `super.addView` routes around the React-child shadow list so the
363
+ // SDK ad view is invisible to React's `manageChildren`.
364
+ super.addView(adView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
365
+ }
366
+ layoutAdViewToBounds()
367
+ }
368
+
369
+ /**
370
+ * Explicitly sizes the SDK ad view to fill this view. React Native drives
371
+ * layout through Yoga and does not measure/lay out children it did not
372
+ * declare, so the SDK ad view — added as a physical child but hidden from
373
+ * React's shadow tree — would otherwise stay at 0x0 regardless of its
374
+ * MATCH_PARENT layout params.
375
+ */
376
+ private fun layoutAdViewToBounds() {
377
+ val ad = hostedAdView ?: return
378
+ val w = width
379
+ val h = height
380
+ if (w <= 0 || h <= 0) return
381
+ ad.measure(
382
+ MeasureSpec.makeMeasureSpec(w, MeasureSpec.EXACTLY),
383
+ MeasureSpec.makeMeasureSpec(h, MeasureSpec.EXACTLY),
384
+ )
385
+ ad.layout(0, 0, w, h)
386
+ }
387
+
388
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
389
+ super.onSizeChanged(w, h, oldw, oldh)
390
+ layoutAdViewToBounds()
391
+ }
392
+
393
+ /**
394
+ * Sizes one SDK-inserted asset view to its React-managed container.
395
+ *
396
+ * [layoutAdViewToBounds] covers the hosted ad view in template mode. Manual
397
+ * mode instead inserts the SDK's own options and media views underneath the
398
+ * publisher's React containers, and those containers never measure children
399
+ * they did not declare — their `requestLayout`/`onLayout` are no-ops. An
400
+ * inserted view therefore stays 0x0 and never draws.
401
+ */
402
+ private fun sizeInsertedViewToParent(child: View, parent: ViewGroup) {
403
+ val w = parent.width
404
+ val h = parent.height
405
+ if (w <= 0 || h <= 0) return
406
+ child.measure(
407
+ MeasureSpec.makeMeasureSpec(w, MeasureSpec.EXACTLY),
408
+ MeasureSpec.makeMeasureSpec(h, MeasureSpec.EXACTLY),
409
+ )
410
+ child.layout(0, 0, w, h)
411
+ }
412
+
413
+ /**
414
+ * Sizes [child] on the next frame, and again after [retryDelayMs] when that
415
+ * is non-zero. A single pass is not enough: the container's own bounds can
416
+ * still be settling when the asset mounts, and some networks attach their
417
+ * media surface asynchronously after the binder returns.
418
+ */
419
+ private fun scheduleInsertedViewSizing(child: View, parent: ViewGroup, retryDelayMs: Long) {
420
+ parent.post { sizeInsertedViewToParent(child, parent) }
421
+ if (retryDelayMs > 0L) {
422
+ parent.postDelayed({ sizeInsertedViewToParent(child, parent) }, retryDelayMs)
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Fabric-compatible event dispatch, routed through the same
428
+ * `UIManagerHelper` / `EventDispatcher` pathway (and shared [CloudXAdViewEvent]
429
+ * carrier) as the in-tree banner/MREC component.
430
+ */
431
+ private fun dispatch(eventName: String, payload: WritableMap) {
432
+ val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id) ?: return
433
+ val surfaceId = UIManagerHelper.getSurfaceId(reactContext)
434
+ dispatcher.dispatchEvent(CloudXAdViewEvent(surfaceId, id, eventName, payload))
435
+ }
436
+
437
+ // Commands
438
+
439
+ fun commandLoadAd() {
440
+ loadRequested = false
441
+ loadIfPossible()
442
+ }
443
+
444
+ fun commandUpdateAssetView(assetViewTag: Int, assetViewName: String) {
445
+ val ad = pendingManualAd ?: return
446
+ val nativeAd = ad.nativeAd ?: return
447
+ val view = findViewById<View>(assetViewTag) ?: return
448
+
449
+ // Dropping the prior view for this role ensures only the current one
450
+ // is registered for clicks; otherwise a re-mount (e.g. in a feed)
451
+ // would leave stale references in the list.
452
+ assetViewsByRole[assetViewName]?.let { clickableViews.remove(it) }
453
+ assetViewsByRole[assetViewName] = view
454
+
455
+ when (assetViewName) {
456
+ "TitleView" -> {
457
+ // No text write: JS already renders the title from the
458
+ // dispatched metadata. Assigning TextView.text here replaces
459
+ // React's styled spannable with a plain string and loses the
460
+ // publisher's color and weight.
461
+ view.isClickable = true
462
+ if (!nativeAd.title.isNullOrEmpty()) clickableViews.add(view)
463
+ }
464
+ "AdvertiserView" -> {
465
+ view.isClickable = true
466
+ if (!nativeAd.advertiser.isNullOrEmpty()) clickableViews.add(view)
467
+ }
468
+ "BodyView" -> {
469
+ view.isClickable = true
470
+ if (!nativeAd.body.isNullOrEmpty()) clickableViews.add(view)
471
+ }
472
+ "CallToActionView" -> {
473
+ view.isClickable = true
474
+ if (!nativeAd.callToAction.isNullOrEmpty()) clickableViews.add(view)
475
+ }
476
+ "IconView" -> {
477
+ val icon = nativeAd.icon
478
+ if (icon != null && view is ImageView) {
479
+ // URL-based icons are loaded asynchronously in JS via the
480
+ // dispatched metadata; drawable-based icons must be set
481
+ // here because the raw bytes never cross the bridge.
482
+ if (icon.uri == null && icon.drawable is BitmapDrawable) {
483
+ view.setImageDrawable(icon.drawable)
484
+ }
485
+ }
486
+ view.isClickable = true
487
+ clickableViews.add(view)
488
+ }
489
+ "OptionsView" -> {
490
+ val optionsView = nativeAd.optionsView
491
+ if (optionsView != null && view is ViewGroup) {
492
+ (optionsView.parent as? ViewGroup)?.takeIf { it !== view }?.removeView(optionsView)
493
+ if (optionsView.parent == null) {
494
+ view.addView(optionsView)
495
+ insertedSdkViews.add(optionsView)
496
+ scheduleInsertedViewSizing(optionsView, view, 0L)
497
+ }
498
+ }
499
+ }
500
+ "MediaView" -> {
501
+ val mediaView = nativeAd.mediaView
502
+ if (mediaView != null && view is ViewGroup) {
503
+ (mediaView.parent as? ViewGroup)?.takeIf { it !== view }?.removeView(mediaView)
504
+ if (mediaView.parent == null) {
505
+ view.addView(mediaView)
506
+ insertedSdkViews.add(mediaView)
507
+ scheduleInsertedViewSizing(mediaView, view, MEDIA_VIEW_RESIZE_RETRY_MS)
508
+ }
509
+ }
510
+ view.isClickable = true
511
+ clickableViews.add(view)
512
+ }
513
+ "StarRatingView" -> {
514
+ // Decorative; not clickable. JS handles rendering.
515
+ }
516
+ }
517
+ }
518
+
519
+ fun commandRenderNativeAd() {
520
+ // Each guard is a display failure, not a silent no-op: the publisher's
521
+ // onAdDisplayFailedEvent handler must hear about it so the QA harness
522
+ // and publisher UI don't hang waiting for a callback that never comes.
523
+ val ad = pendingManualAd
524
+ if (ad == null) {
525
+ val error = CloudXErrorCode.ADAPTER_INTERNAL_ERROR.toCloudXError("renderNativeAd called before ad loaded")
526
+ dispatch(CloudXAdEvents.ON_NATIVE_AD_VIEW_DISPLAY_FAILED_EVENT, loadFailedPayload(adUnitId ?: "", error))
527
+ return
528
+ }
529
+ val nativeAd = ad.nativeAd
530
+ if (nativeAd == null) {
531
+ val error = CloudXErrorCode.ADAPTER_INTERNAL_ERROR.toCloudXError("renderNativeAd: native ad not available")
532
+ dispatch(CloudXAdEvents.ON_NATIVE_AD_VIEW_DISPLAY_FAILED_EVENT, loadFailedPayload(ad.adUnitId, error))
533
+ return
534
+ }
535
+ if (nativeAd.isExpired) {
536
+ val error = CloudXErrorCode.ADAPTER_AD_EXPIRED.toCloudXError("renderNativeAd: ad expired")
537
+ dispatch(CloudXAdEvents.ON_NATIVE_AD_VIEW_DISPLAY_FAILED_EVENT, loadFailedPayload(ad.adUnitId, error))
538
+ return
539
+ }
540
+ val registered = nativeAd.prepareForInteraction(clickableViews.toList(), this)
541
+ if (!registered) {
542
+ // A load that cannot register click/impression tracking is a
543
+ // display failure: the ad renders but never produces revenue.
544
+ val error = CloudXErrorCode.ADAPTER_INTERNAL_ERROR.toCloudXError("Native ad failed to register click/impression tracking")
545
+ dispatch(CloudXAdEvents.ON_NATIVE_AD_VIEW_DISPLAY_FAILED_EVENT, loadFailedPayload(ad.adUnitId, error))
546
+ }
547
+ }
548
+
549
+ fun commandDestroyAd() {
550
+ // Destroy whichever ad this cycle holds: manual mode stashes it in
551
+ // pendingManualAd, template mode only in loadedCycleAd. Leaving the
552
+ // template-mode ad alive would leak SDK-side resources.
553
+ val ad = pendingManualAd ?: loadedCycleAd
554
+ if (ad != null) {
555
+ attachedLoader?.destroy(ad)
556
+ }
557
+ // Tear the loader down rather than only clearing per-ad state. When
558
+ // destroyAd arrives before onNativeAdLoaded there is no ad to destroy
559
+ // above, and leaving the loader attached kept its listener wired: the
560
+ // in-flight ad was then delivered into a view the JS layer had already
561
+ // destroyed. Next loadAd rebuilds the loader through currentLoader().
562
+ releaseLoaderState()
563
+ }
564
+
565
+ /**
566
+ * Drops the per-ad mutable state and detaches the SDK-owned ad view, so an
567
+ * invalidated ad can't leak its state into the next load cycle. Used by the
568
+ * SDK-initiated expiry/close callbacks, which leave the loader alive
569
+ * deliberately: the ad is gone but the publisher may still reload this
570
+ * view. [commandDestroyAd] wants the stronger [releaseLoaderState], which
571
+ * also cancels a load still in flight.
572
+ */
573
+ private fun clearAdState() {
574
+ pendingManualAd = null
575
+ loadedCycleAd = null
576
+ clickableViews.clear()
577
+ assetViewsByRole.clear()
578
+ removeInsertedSdkViews()
579
+ // Only detach the SDK-owned ad view. React-managed asset-view children
580
+ // stay mounted — they belong to the JS tree and will be removed by
581
+ // React itself when the publisher unmounts them.
582
+ hostedAdView?.let { (it.parent as? ViewGroup)?.removeView(it) }
583
+ hostedAdView = null
584
+ loadRequested = false
585
+ }
586
+
587
+ // CloudXNativeAdListener
588
+
589
+ override fun onNativeAdLoaded(adView: SdkNativeAdView?, ad: CloudXAd) {
590
+ loadedCycleAd = ad
591
+ if (manualRender) {
592
+ pendingManualAd = ad
593
+ } else {
594
+ adView?.let { attachAdView(it) }
595
+ }
596
+ dispatch(CloudXAdEvents.ON_NATIVE_AD_VIEW_LOADED_EVENT, infoPayload(ad))
597
+ }
598
+
599
+ override fun onNativeAdLoadFailed(adUnitId: String, error: CloudXError) {
600
+ loadRequested = false
601
+ // The SDK funnels post-load adapter/render errors through the same
602
+ // listener callback as load failures. If this cycle already delivered
603
+ // an ad, the failure happened while displaying it — surface it as a
604
+ // display failure so JS sees the correct lifecycle event.
605
+ val displayedAd = loadedCycleAd
606
+ if (displayedAd != null) {
607
+ loadedCycleAd = null
608
+ dispatch(CloudXAdEvents.ON_NATIVE_AD_VIEW_DISPLAY_FAILED_EVENT, loadFailedPayload(displayedAd.adUnitId, error))
609
+ } else {
610
+ // Template mode attaches the ad view before loadAd, so a genuine
611
+ // load failure would otherwise leave a blank attached view.
612
+ hostedAdView?.let { (it.parent as? ViewGroup)?.removeView(it) }
613
+ hostedAdView = null
614
+ dispatch(CloudXAdEvents.ON_NATIVE_AD_VIEW_LOAD_FAILED_EVENT, loadFailedPayload(adUnitId, error))
615
+ }
616
+ }
617
+
618
+ override fun onNativeAdClicked(ad: CloudXAd) {
619
+ dispatch(CloudXAdEvents.ON_NATIVE_AD_VIEW_CLICKED_EVENT, infoPayload(ad))
620
+ }
621
+
622
+ override fun onNativeAdExpired(ad: CloudXAd) {
623
+ dispatch(CloudXAdEvents.ON_NATIVE_AD_VIEW_EXPIRED_EVENT, infoPayload(ad))
624
+ // The ad is invalidated: release SDK-side resources and drop our
625
+ // per-ad refs so the expired state can't leak into the next load
626
+ // cycle. The loader itself stays alive for reuse.
627
+ attachedLoader?.destroy(ad)
628
+ clearAdState()
629
+ }
630
+
631
+ override fun onNativeAdClosed(ad: CloudXAd) {
632
+ dispatch(CloudXAdEvents.ON_NATIVE_AD_VIEW_CLOSED_EVENT, infoPayload(ad))
633
+ // Mirrors onNativeAdExpired: the ad is done, so detach its view and
634
+ // clear per-ad refs. Loader stays alive for the next load cycle.
635
+ attachedLoader?.destroy(ad)
636
+ clearAdState()
637
+ }
638
+
639
+ // CloudXAdRevenueListener
640
+
641
+ override fun onAdRevenuePaid(cloudXAd: CloudXAd) {
642
+ dispatch(CloudXAdEvents.ON_NATIVE_AD_VIEW_REVENUE_PAID_EVENT, infoPayload(cloudXAd))
643
+ }
644
+
645
+ /*
646
+ * Codegen cannot express a nested/heterogeneous native-ad payload as a
647
+ * typed prop, so both `adValues` and the loaded native ad's own metadata
648
+ * are serialized to JSON strings here and re-parsed by the JS wrapper
649
+ * (mirrors CloudXAdView's adValuesJson for the banner/MREC component).
650
+ */
651
+ private fun infoPayload(ad: CloudXAd): WritableMap {
652
+ val map = Arguments.createMap()
653
+ map.putString("adUnitId", ad.adUnitId)
654
+ map.putString("adFormat", ad.adFormat.name)
655
+ map.putString("networkName", ad.networkName)
656
+ map.putString("networkPlacement", ad.networkPlacement ?: "")
657
+ map.putString("placement", ad.placement ?: "")
658
+ map.putString("creativeId", ad.creativeId ?: "")
659
+ map.putDouble("revenue", ad.revenue)
660
+ val adValuesJson = try {
661
+ JSONObject(ad.adValues as Map<*, *>).toString()
662
+ } catch (e: Exception) {
663
+ Log.w(TAG, "Failed to serialize adValues; delivering empty object", e)
664
+ "{}"
665
+ }
666
+ map.putString("adValuesJson", adValuesJson)
667
+ ad.nativeAd?.let { map.putString("nativeAdJson", nativeAdMetadataJson(it)) }
668
+ return map
669
+ }
670
+
671
+ private fun loadFailedPayload(unitId: String, error: CloudXError): WritableMap {
672
+ val map = Arguments.createMap()
673
+ map.putString("adUnitId", unitId)
674
+ // Codegen declares `code` as Double; emit a double to match (and to
675
+ // stay consistent with `revenue` and the iOS side).
676
+ map.putDouble("code", error.code.code.toDouble())
677
+ map.putString("message", error.message)
678
+ return map
679
+ }
680
+
681
+ /**
682
+ * JSON-encoded native ad metadata for the codegen-typed `nativeAdJson`
683
+ * string field (codegen cannot express a nested map here). Mirrors the
684
+ * same field set as CloudXAdInfo.kt's imperative-event metadata map.
685
+ */
686
+ private fun nativeAdMetadataJson(nativeAd: CloudXNativeAd): String {
687
+ val iconImageSource = nativeAd.icon?.let { icon ->
688
+ if (icon.uri == null) {
689
+ (icon.drawable as? BitmapDrawable)?.bitmap?.let(::bitmapToBase64Png)
690
+ } else {
691
+ null
692
+ }
693
+ }
694
+ val json = JSONObject()
695
+ json.put("title", nativeAd.title ?: JSONObject.NULL)
696
+ json.put("advertiser", nativeAd.advertiser ?: JSONObject.NULL)
697
+ json.put("body", nativeAd.body ?: JSONObject.NULL)
698
+ json.put("callToAction", nativeAd.callToAction ?: JSONObject.NULL)
699
+ nativeAd.starRating?.let { json.put("starRating", it) }
700
+ if (nativeAd.mediaContentAspectRatio > 0f) {
701
+ json.put("mediaContentAspectRatio", nativeAd.mediaContentAspectRatio.toDouble())
702
+ }
703
+ val iconAvailable = nativeAd.icon != null
704
+ json.put("isIconImageAvailable", iconAvailable)
705
+ json.put("isOptionsViewAvailable", nativeAd.optionsView != null)
706
+ json.put("isMediaViewAvailable", nativeAd.mediaView != null)
707
+ nativeAd.icon?.let { icon ->
708
+ icon.uri?.let { json.put("url", it.toString()) }
709
+ if (icon.uri == null) {
710
+ iconImageSource?.let { json.put("imageSource", it) }
711
+ }
712
+ }
713
+ json.put("isVideoContent", nativeAd.isVideoContent)
714
+ json.put("videoDuration", nativeAd.videoDuration)
715
+ json.put("isExpired", nativeAd.isExpired)
716
+ return json.toString()
717
+ }
718
+ }