cloudx-react-native 3.7.0 → 3.8.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.
@@ -158,8 +158,9 @@ repositories {
158
158
  dependencies {
159
159
  implementation "com.facebook.react:react-native:0.76.2"
160
160
 
161
- implementation "io.cloudx:sdk:4.5.0"
161
+ implementation "io.cloudx:sdk:4.6.0"
162
162
 
163
163
  testImplementation "junit:junit:4.13.2"
164
164
  testImplementation "io.mockk:mockk:1.13.10"
165
+ testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2"
165
166
  }
@@ -9,6 +9,7 @@ import android.view.View
9
9
  import android.view.ViewGroup
10
10
  import android.widget.LinearLayout
11
11
  import android.widget.RelativeLayout
12
+ import androidx.annotation.VisibleForTesting
12
13
  import com.facebook.react.bridge.Arguments
13
14
  import com.facebook.react.bridge.LifecycleEventListener
14
15
  import com.facebook.react.bridge.Promise
@@ -87,42 +88,67 @@ class CloudXModuleImpl(private val reactApplicationContext: ReactApplicationCont
87
88
  return currentActivity
88
89
  }
89
90
 
91
+ // =========================================================================
92
+ // Threading
93
+ // =========================================================================
94
+
95
+ /*
96
+ * Every public module method runs its whole body on the UI thread, including reads of the
97
+ * module's own state. React Native invokes Android native-module methods on a shared
98
+ * background thread, so a method that did work inline could execute before a previously
99
+ * posted one (e.g. loadInterstitial() running on an instance that a queued
100
+ * destroyInterstitial() then destroys). Posting everything here keeps JS call order intact,
101
+ * makes the lazy retrieveX() create paths single-threaded, and matches the iOS module, whose
102
+ * methodQueue is the main queue.
103
+ */
104
+ private inline fun onUiThread(crossinline block: () -> Unit) {
105
+ reactApplicationContext.runOnUiQueueThread { block() }
106
+ }
107
+
90
108
  // =========================================================================
91
109
  // Initialization
92
110
  // =========================================================================
93
111
 
94
112
  fun isInitialized(promise: Promise) {
95
- promise.resolve(CloudX.isInitialized())
113
+ onUiThread {
114
+ promise.resolve(CloudX.isInitialized())
115
+ }
96
116
  }
97
117
 
98
118
  fun initialize(pluginVersion: String, appKey: String, promise: Promise) {
99
- val config = CloudXInitializationConfiguration.builder(appKey)
100
- .setPluginVersion(pluginVersion)
101
- .build()
102
-
103
- CloudX.initialize(reactApplicationContext, config, object : CloudXInitializationListener {
104
- override fun onInitialized(configuration: CloudXSdkConfiguration) {
105
- setupOrientationListener()
106
- val result = Arguments.createMap()
107
- result.putBoolean("success", true)
108
- result.putString("message", "SDK initialized successfully")
109
- promise.resolve(result)
110
- }
119
+ onUiThread {
120
+ val config = CloudXInitializationConfiguration.builder(appKey)
121
+ .setPluginVersion(pluginVersion)
122
+ .build()
123
+
124
+ CloudX.initialize(reactApplicationContext, config, object : CloudXInitializationListener {
125
+ override fun onInitialized(configuration: CloudXSdkConfiguration) {
126
+ setupOrientationListener()
127
+ val result = Arguments.createMap()
128
+ result.putBoolean("success", true)
129
+ result.putString("message", "SDK initialized successfully")
130
+ promise.resolve(result)
131
+ }
111
132
 
112
- override fun onInitializationFailed(cloudXError: CloudXError) {
113
- Log.e(TAG, "CloudX SDK initialization failed: ${cloudXError.message}")
114
- promise.reject(cloudXError.code.name, cloudXError.message, null)
115
- }
116
- })
133
+ override fun onInitializationFailed(cloudXError: CloudXError) {
134
+ Log.e(TAG, "CloudX SDK initialization failed: ${cloudXError.message}")
135
+ promise.reject(cloudXError.code.name, cloudXError.message, null)
136
+ }
137
+ })
138
+ }
117
139
  }
118
140
 
119
141
  fun isTablet(promise: Promise) {
120
- val isTablet = reactApplicationContext.resources.configuration.smallestScreenWidthDp >= 600
121
- promise.resolve(isTablet)
142
+ onUiThread {
143
+ val isTablet = reactApplicationContext.resources.configuration.smallestScreenWidthDp >= 600
144
+ promise.resolve(isTablet)
145
+ }
122
146
  }
123
147
 
124
148
  fun getVersion(promise: Promise) {
125
- promise.resolve(CloudX.getVersion())
149
+ onUiThread {
150
+ promise.resolve(CloudX.getVersion())
151
+ }
126
152
  }
127
153
 
128
154
  // =========================================================================
@@ -130,16 +156,18 @@ class CloudXModuleImpl(private val reactApplicationContext: ReactApplicationCont
130
156
  // =========================================================================
131
157
 
132
158
  fun setMinLogLevel(level: String) {
133
- val logLevel = when (level.lowercase()) {
134
- "verbose" -> CloudXLogLevel.VERBOSE
135
- "debug" -> CloudXLogLevel.DEBUG
136
- "info" -> CloudXLogLevel.INFO
137
- "warn" -> CloudXLogLevel.WARN
138
- "error" -> CloudXLogLevel.ERROR
139
- "none" -> CloudXLogLevel.NONE
140
- else -> CloudXLogLevel.INFO
159
+ onUiThread {
160
+ val logLevel = when (level.lowercase()) {
161
+ "verbose" -> CloudXLogLevel.VERBOSE
162
+ "debug" -> CloudXLogLevel.DEBUG
163
+ "info" -> CloudXLogLevel.INFO
164
+ "warn" -> CloudXLogLevel.WARN
165
+ "error" -> CloudXLogLevel.ERROR
166
+ "none" -> CloudXLogLevel.NONE
167
+ else -> CloudXLogLevel.INFO
168
+ }
169
+ CloudX.setMinLogLevel(logLevel)
141
170
  }
142
- CloudX.setMinLogLevel(logLevel)
143
171
  }
144
172
 
145
173
  fun setVisualDebuggingEnabled(enabled: Boolean) {
@@ -151,19 +179,27 @@ class CloudXModuleImpl(private val reactApplicationContext: ReactApplicationCont
151
179
  // =========================================================================
152
180
 
153
181
  fun setHashedUserID(hashedUserID: String) {
154
- CloudX.setHashedUserId(hashedUserID)
182
+ onUiThread {
183
+ CloudX.setHashedUserId(hashedUserID)
184
+ }
155
185
  }
156
186
 
157
187
  fun setUserKeyValue(key: String, value: String) {
158
- CloudX.setUserKeyValue(key, value)
188
+ onUiThread {
189
+ CloudX.setUserKeyValue(key, value)
190
+ }
159
191
  }
160
192
 
161
193
  fun setAppKeyValue(key: String, value: String) {
162
- CloudX.setAppKeyValue(key, value)
194
+ onUiThread {
195
+ CloudX.setAppKeyValue(key, value)
196
+ }
163
197
  }
164
198
 
165
199
  fun clearAllKeyValues() {
166
- CloudX.clearAllKeyValues()
200
+ onUiThread {
201
+ CloudX.clearAllKeyValues()
202
+ }
167
203
  }
168
204
 
169
205
  // =========================================================================
@@ -171,11 +207,15 @@ class CloudXModuleImpl(private val reactApplicationContext: ReactApplicationCont
171
207
  // =========================================================================
172
208
 
173
209
  fun setHasUserConsent(hasUserConsent: Boolean?) {
174
- CloudX.setHasUserConsent(hasUserConsent)
210
+ onUiThread {
211
+ CloudX.setHasUserConsent(hasUserConsent)
212
+ }
175
213
  }
176
214
 
177
215
  fun setDoNotSell(isDoNotSell: Boolean?) {
178
- CloudX.setDoNotSell(isDoNotSell)
216
+ onUiThread {
217
+ CloudX.setDoNotSell(isDoNotSell)
218
+ }
179
219
  }
180
220
 
181
221
  // =========================================================================
@@ -183,27 +223,31 @@ class CloudXModuleImpl(private val reactApplicationContext: ReactApplicationCont
183
223
  // =========================================================================
184
224
 
185
225
  fun arbiter(configuration: ReadableMap, promise: Promise) {
186
- val bids = arbiterBids(configuration)
187
- if (bids.isEmpty()) {
188
- promise.resolve(emptyArbiterResult())
189
- return
190
- }
191
-
192
- val arbiterConfiguration = CloudXArbiterConfiguration.builder(bids).build()
193
- CloudX.arbiter(arbiterConfiguration, object : CloudXArbiterListener {
194
- override fun onCompleted(result: CloudXArbiterResult) {
195
- promise.resolve(result.toWritableMap())
226
+ onUiThread {
227
+ val bids = arbiterBids(configuration)
228
+ if (bids.isEmpty()) {
229
+ promise.resolve(emptyArbiterResult())
230
+ return@onUiThread
196
231
  }
197
- })
232
+
233
+ val arbiterConfiguration = CloudXArbiterConfiguration.builder(bids).build()
234
+ CloudX.arbiter(arbiterConfiguration, object : CloudXArbiterListener {
235
+ override fun onCompleted(result: CloudXArbiterResult) {
236
+ promise.resolve(result.toWritableMap())
237
+ }
238
+ })
239
+ }
198
240
  }
199
241
 
200
242
  fun reportRevenueData(data: ReadableMap, promise: Promise) {
201
- val revenueData = data.toRevenueData()
202
- if (revenueData == null) {
203
- promise.resolve(false)
204
- return
243
+ onUiThread {
244
+ val revenueData = data.toRevenueData()
245
+ if (revenueData == null) {
246
+ promise.resolve(false)
247
+ return@onUiThread
248
+ }
249
+ promise.resolve(CloudX.reportRevenueData(revenueData))
205
250
  }
206
- promise.resolve(CloudX.reportRevenueData(revenueData))
207
251
  }
208
252
 
209
253
  // =========================================================================
@@ -216,8 +260,8 @@ class CloudXModuleImpl(private val reactApplicationContext: ReactApplicationCont
216
260
  fun setBannerPlacement(adUnitId: String, placement: String?) = setAdViewPlacement(adUnitId, CloudXAdFormat.BANNER, placement)
217
261
  fun setBannerCustomData(adUnitId: String, customData: String?) = setAdViewCustomData(adUnitId, CloudXAdFormat.BANNER, customData)
218
262
  fun setBannerExtraParameters(adUnitId: String, extraParameters: ReadableMap) = setAdViewExtraParameters(adUnitId, CloudXAdFormat.BANNER, extraParameters)
219
- fun updateBannerPosition(adUnitId: String, position: String) = updateAdViewPosition(adUnitId, CloudXAdFormat.BANNER, position, adViewOffsetsDp[adUnitId] ?: DEFAULT_AD_VIEW_OFFSET_DP)
220
- fun updateBannerOffsets(adUnitId: String, xOffset: Double, yOffset: Double) = updateAdViewPosition(adUnitId, CloudXAdFormat.BANNER, adViewPositions[adUnitId], Pair(xOffset.toFloat(), yOffset.toFloat()))
263
+ fun updateBannerPosition(adUnitId: String, position: String) = updateAdViewPosition(adUnitId, CloudXAdFormat.BANNER, position, null)
264
+ fun updateBannerOffsets(adUnitId: String, xOffset: Double, yOffset: Double) = updateAdViewPosition(adUnitId, CloudXAdFormat.BANNER, null, Pair(xOffset.toFloat(), yOffset.toFloat()))
221
265
  fun startBannerAutoRefresh(adUnitId: String) = startAdViewAutoRefresh(adUnitId, CloudXAdFormat.BANNER)
222
266
  fun stopBannerAutoRefresh(adUnitId: String) = stopAdViewAutoRefresh(adUnitId, CloudXAdFormat.BANNER)
223
267
  fun showBanner(adUnitId: String) = showAdView(adUnitId, CloudXAdFormat.BANNER)
@@ -234,8 +278,8 @@ class CloudXModuleImpl(private val reactApplicationContext: ReactApplicationCont
234
278
  fun setMRECPlacement(adUnitId: String, placement: String?) = setAdViewPlacement(adUnitId, CloudXAdFormat.MREC, placement)
235
279
  fun setMRECCustomData(adUnitId: String, customData: String?) = setAdViewCustomData(adUnitId, CloudXAdFormat.MREC, customData)
236
280
  fun setMRECExtraParameters(adUnitId: String, extraParameters: ReadableMap) = setAdViewExtraParameters(adUnitId, CloudXAdFormat.MREC, extraParameters)
237
- fun updateMRECPosition(adUnitId: String, position: String) = updateAdViewPosition(adUnitId, CloudXAdFormat.MREC, position, adViewOffsetsDp[adUnitId] ?: DEFAULT_AD_VIEW_OFFSET_DP)
238
- fun updateMRECOffsets(adUnitId: String, xOffset: Double, yOffset: Double) = updateAdViewPosition(adUnitId, CloudXAdFormat.MREC, adViewPositions[adUnitId], Pair(xOffset.toFloat(), yOffset.toFloat()))
281
+ fun updateMRECPosition(adUnitId: String, position: String) = updateAdViewPosition(adUnitId, CloudXAdFormat.MREC, position, null)
282
+ fun updateMRECOffsets(adUnitId: String, xOffset: Double, yOffset: Double) = updateAdViewPosition(adUnitId, CloudXAdFormat.MREC, null, Pair(xOffset.toFloat(), yOffset.toFloat()))
239
283
  fun startMRECAutoRefresh(adUnitId: String) = startAdViewAutoRefresh(adUnitId, CloudXAdFormat.MREC)
240
284
  fun stopMRECAutoRefresh(adUnitId: String) = stopAdViewAutoRefresh(adUnitId, CloudXAdFormat.MREC)
241
285
  fun showMREC(adUnitId: String) = showAdView(adUnitId, CloudXAdFormat.MREC)
@@ -247,21 +291,27 @@ class CloudXModuleImpl(private val reactApplicationContext: ReactApplicationCont
247
291
  // =========================================================================
248
292
 
249
293
  fun loadInterstitial(adUnitId: String) {
250
- retrieveInterstitial(adUnitId).load()
294
+ onUiThread {
295
+ retrieveInterstitial(adUnitId).load()
296
+ }
251
297
  }
252
298
 
253
299
  fun setInterstitialExtraParameters(adUnitId: String, extraParameters: ReadableMap) {
254
- CloudXExtraParameters.forEach(extraParameters) { key, value ->
255
- retrieveInterstitial(adUnitId).setExtraParameter(key, value)
300
+ onUiThread {
301
+ CloudXExtraParameters.forEach(extraParameters) { key, value ->
302
+ retrieveInterstitial(adUnitId).setExtraParameter(key, value)
303
+ }
256
304
  }
257
305
  }
258
306
 
259
307
  fun isInterstitialReady(adUnitId: String, promise: Promise) {
260
- promise.resolve(retrieveInterstitial(adUnitId).isAdReady)
308
+ onUiThread {
309
+ promise.resolve(retrieveInterstitial(adUnitId).isAdReady)
310
+ }
261
311
  }
262
312
 
263
313
  fun showInterstitial(adUnitId: String, placement: String?, customData: String?) {
264
- reactApplicationContext.runOnUiQueueThread {
314
+ onUiThread {
265
315
  val interstitial = retrieveInterstitial(adUnitId)
266
316
  val activity = maybeGetCurrentActivity()
267
317
  if (activity == null) {
@@ -270,14 +320,14 @@ class CloudXModuleImpl(private val reactApplicationContext: ReactApplicationCont
270
320
  CloudXAdEvents.ON_INTERSTITIAL_DISPLAY_FAILED_EVENT,
271
321
  getNoActivityDisplayFailedInfo(adUnitId),
272
322
  )
273
- return@runOnUiQueueThread
323
+ return@onUiThread
274
324
  }
275
325
  interstitial.show(activity, placement, customData)
276
326
  }
277
327
  }
278
328
 
279
329
  fun destroyInterstitial(adUnitId: String) {
280
- reactApplicationContext.runOnUiQueueThread {
330
+ onUiThread {
281
331
  interstitials.remove(adUnitId)?.let {
282
332
  it.listener = null
283
333
  it.revenueListener = null
@@ -291,21 +341,27 @@ class CloudXModuleImpl(private val reactApplicationContext: ReactApplicationCont
291
341
  // =========================================================================
292
342
 
293
343
  fun loadAppOpen(adUnitId: String) {
294
- retrieveAppOpen(adUnitId).load()
344
+ onUiThread {
345
+ retrieveAppOpen(adUnitId).load()
346
+ }
295
347
  }
296
348
 
297
349
  fun setAppOpenExtraParameters(adUnitId: String, extraParameters: ReadableMap) {
298
- CloudXExtraParameters.forEach(extraParameters) { key, value ->
299
- retrieveAppOpen(adUnitId).setExtraParameter(key, value)
350
+ onUiThread {
351
+ CloudXExtraParameters.forEach(extraParameters) { key, value ->
352
+ retrieveAppOpen(adUnitId).setExtraParameter(key, value)
353
+ }
300
354
  }
301
355
  }
302
356
 
303
357
  fun isAppOpenReady(adUnitId: String, promise: Promise) {
304
- promise.resolve(retrieveAppOpen(adUnitId).isAdReady)
358
+ onUiThread {
359
+ promise.resolve(retrieveAppOpen(adUnitId).isAdReady)
360
+ }
305
361
  }
306
362
 
307
363
  fun showAppOpen(adUnitId: String, placement: String?, customData: String?) {
308
- reactApplicationContext.runOnUiQueueThread {
364
+ onUiThread {
309
365
  val appOpen = retrieveAppOpen(adUnitId)
310
366
  val activity = maybeGetCurrentActivity()
311
367
  if (activity == null) {
@@ -314,14 +370,14 @@ class CloudXModuleImpl(private val reactApplicationContext: ReactApplicationCont
314
370
  CloudXAdEvents.ON_APPOPEN_DISPLAY_FAILED_EVENT,
315
371
  getNoActivityDisplayFailedInfo(adUnitId),
316
372
  )
317
- return@runOnUiQueueThread
373
+ return@onUiThread
318
374
  }
319
375
  appOpen.show(activity, placement, customData)
320
376
  }
321
377
  }
322
378
 
323
379
  fun destroyAppOpen(adUnitId: String) {
324
- reactApplicationContext.runOnUiQueueThread {
380
+ onUiThread {
325
381
  appOpenAds.remove(adUnitId)?.let {
326
382
  it.listener = null
327
383
  it.revenueListener = null
@@ -335,21 +391,27 @@ class CloudXModuleImpl(private val reactApplicationContext: ReactApplicationCont
335
391
  // =========================================================================
336
392
 
337
393
  fun loadRewarded(adUnitId: String) {
338
- retrieveRewarded(adUnitId).load()
394
+ onUiThread {
395
+ retrieveRewarded(adUnitId).load()
396
+ }
339
397
  }
340
398
 
341
399
  fun setRewardedExtraParameters(adUnitId: String, extraParameters: ReadableMap) {
342
- CloudXExtraParameters.forEach(extraParameters) { key, value ->
343
- retrieveRewarded(adUnitId).setExtraParameter(key, value)
400
+ onUiThread {
401
+ CloudXExtraParameters.forEach(extraParameters) { key, value ->
402
+ retrieveRewarded(adUnitId).setExtraParameter(key, value)
403
+ }
344
404
  }
345
405
  }
346
406
 
347
407
  fun isRewardedReady(adUnitId: String, promise: Promise) {
348
- promise.resolve(retrieveRewarded(adUnitId).isAdReady)
408
+ onUiThread {
409
+ promise.resolve(retrieveRewarded(adUnitId).isAdReady)
410
+ }
349
411
  }
350
412
 
351
413
  fun showRewarded(adUnitId: String, placement: String?, customData: String?) {
352
- reactApplicationContext.runOnUiQueueThread {
414
+ onUiThread {
353
415
  val rewarded = retrieveRewarded(adUnitId)
354
416
  val activity = maybeGetCurrentActivity()
355
417
  if (activity == null) {
@@ -358,14 +420,14 @@ class CloudXModuleImpl(private val reactApplicationContext: ReactApplicationCont
358
420
  CloudXAdEvents.ON_REWARDED_AD_DISPLAY_FAILED_EVENT,
359
421
  getNoActivityDisplayFailedInfo(adUnitId),
360
422
  )
361
- return@runOnUiQueueThread
423
+ return@onUiThread
362
424
  }
363
425
  rewarded.show(activity, placement, customData)
364
426
  }
365
427
  }
366
428
 
367
429
  fun destroyRewarded(adUnitId: String) {
368
- reactApplicationContext.runOnUiQueueThread {
430
+ onUiThread {
369
431
  rewardedAds.remove(adUnitId)?.let {
370
432
  it.listener = null
371
433
  it.revenueListener = null
@@ -513,7 +575,7 @@ class CloudXModuleImpl(private val reactApplicationContext: ReactApplicationCont
513
575
 
514
576
  private fun createAdView(adUnitId: String, adFormat: CloudXAdFormat, position: String, xOffset: Double, yOffset: Double) {
515
577
  // Run on main thread to ensure there are no concurrency issues with other ad view methods
516
- reactApplicationContext.runOnUiQueueThread {
578
+ onUiThread {
517
579
  // Intentionally load the ad view while detached from any window.
518
580
  // Attaching-then-hiding-then-loading causes the underlying banner
519
581
  // to take a prefetch code path whose hidden->visible transition
@@ -522,7 +584,7 @@ class CloudXModuleImpl(private val reactApplicationContext: ReactApplicationCont
522
584
  // (~30s). Detached loads use the "display when added to a window"
523
585
  // path, so showAdView attaches and displays immediately.
524
586
  val adView = retrieveAdView(adUnitId, adFormat, position, Pair(xOffset.toFloat(), yOffset.toFloat()))
525
- ?: return@runOnUiQueueThread
587
+ ?: return@onUiThread
526
588
 
527
589
  adViewAdFormats[adUnitId] = adFormat
528
590
  applyPendingExtraParameters(adUnitId, adView)
@@ -538,27 +600,27 @@ class CloudXModuleImpl(private val reactApplicationContext: ReactApplicationCont
538
600
  }
539
601
 
540
602
  private fun setAdViewPlacement(adUnitId: String, adFormat: CloudXAdFormat, placement: String?) {
541
- reactApplicationContext.runOnUiQueueThread {
542
- val adView = retrieveAdView(adUnitId, adFormat, "", DEFAULT_AD_VIEW_OFFSET_DP) ?: return@runOnUiQueueThread
603
+ onUiThread {
604
+ val adView = retrieveAdView(adUnitId, adFormat, "", DEFAULT_AD_VIEW_OFFSET_DP) ?: return@onUiThread
543
605
  adView.setPlacement(placement)
544
606
  }
545
607
  }
546
608
 
547
609
  private fun setAdViewCustomData(adUnitId: String, adFormat: CloudXAdFormat, customData: String?) {
548
- reactApplicationContext.runOnUiQueueThread {
549
- val adView = retrieveAdView(adUnitId, adFormat, "", DEFAULT_AD_VIEW_OFFSET_DP) ?: return@runOnUiQueueThread
610
+ onUiThread {
611
+ val adView = retrieveAdView(adUnitId, adFormat, "", DEFAULT_AD_VIEW_OFFSET_DP) ?: return@onUiThread
550
612
  adView.setCustomData(customData)
551
613
  }
552
614
  }
553
615
 
554
616
  private fun setAdViewExtraParameters(adUnitId: String, adFormat: CloudXAdFormat, extraParameters: ReadableMap) {
555
- reactApplicationContext.runOnUiQueueThread {
617
+ onUiThread {
556
618
  val adView = retrieveAdView(adUnitId, adFormat)
557
619
  if (adView != null) {
558
620
  CloudXExtraParameters.forEach(extraParameters) { key, value ->
559
621
  adView.setExtraParameter(key, value)
560
622
  }
561
- return@runOnUiQueueThread
623
+ return@onUiThread
562
624
  }
563
625
 
564
626
  val pending = adViewExtraParameters.getOrPut(adUnitId) { ConcurrentHashMap() }
@@ -840,21 +902,29 @@ class CloudXModuleImpl(private val reactApplicationContext: ReactApplicationCont
840
902
  override val adValues: Map<String, String>,
841
903
  ) : CloudXAd
842
904
 
843
- private fun updateAdViewPosition(adUnitId: String, adFormat: CloudXAdFormat, position: String?, offsetsDp: Pair<Float, Float>) {
844
- reactApplicationContext.runOnUiQueueThread {
845
- val adView = retrieveAdView(adUnitId, adFormat) ?: return@runOnUiQueueThread
846
- adViewPositions[adUnitId] = position ?: return@runOnUiQueueThread
847
- adViewOffsetsDp[adUnitId] = offsetsDp
905
+ /*
906
+ * A null [position] or [offsetsDp] means "keep the current value". The current value is read
907
+ * inside the posted block, so a position update followed by an offsets update from JS cannot
908
+ * overwrite the first call's position with the value that was current before it ran.
909
+ */
910
+ private fun updateAdViewPosition(adUnitId: String, adFormat: CloudXAdFormat, position: String?, offsetsDp: Pair<Float, Float>?) {
911
+ onUiThread {
912
+ val adView = retrieveAdView(adUnitId, adFormat) ?: return@onUiThread
913
+ adViewPositions[adUnitId] = position ?: adViewPositions[adUnitId] ?: return@onUiThread
914
+ adViewOffsetsDp[adUnitId] = offsetsDp ?: adViewOffsetsDp[adUnitId] ?: DEFAULT_AD_VIEW_OFFSET_DP
848
915
  positionAdView(adUnitId, adFormat)
849
916
  }
850
917
  }
851
918
 
919
+ @VisibleForTesting
920
+ internal fun adViewPositionForTesting(adUnitId: String): String? = adViewPositions[adUnitId]
921
+
852
922
  private fun showAdView(adUnitId: String, adFormat: CloudXAdFormat) {
853
- reactApplicationContext.runOnUiQueueThread {
923
+ onUiThread {
854
924
  val adView = retrieveAdView(adUnitId, adFormat)
855
925
  if (adView == null) {
856
926
  adUnitIdsToShowAfterCreate.add(adUnitId)
857
- return@runOnUiQueueThread
927
+ return@onUiThread
858
928
  }
859
929
 
860
930
  // Lazily attach and position on first show. Attaching here — not
@@ -872,18 +942,18 @@ class CloudXModuleImpl(private val reactApplicationContext: ReactApplicationCont
872
942
  }
873
943
 
874
944
  private fun hideAdView(adUnitId: String, adFormat: CloudXAdFormat) {
875
- reactApplicationContext.runOnUiQueueThread {
945
+ onUiThread {
876
946
  adUnitIdsToShowAfterCreate.remove(adUnitId)
877
- val adView = retrieveAdView(adUnitId, adFormat) ?: return@runOnUiQueueThread
947
+ val adView = retrieveAdView(adUnitId, adFormat) ?: return@onUiThread
878
948
  adView.visibility = View.GONE
879
949
  adView.stopAutoRefresh()
880
950
  }
881
951
  }
882
952
 
883
953
  private fun destroyAdView(adUnitId: String, adFormat: CloudXAdFormat) {
884
- reactApplicationContext.runOnUiQueueThread {
954
+ onUiThread {
885
955
  val adView = retrieveAdView(adUnitId, adFormat)
886
- if (adView == null) return@runOnUiQueueThread
956
+ if (adView == null) return@onUiThread
887
957
 
888
958
  val parent = adView.parent
889
959
  if (parent is ViewGroup) {
@@ -904,8 +974,8 @@ class CloudXModuleImpl(private val reactApplicationContext: ReactApplicationCont
904
974
  }
905
975
 
906
976
  private fun setAdViewBackgroundColor(adUnitId: String, adFormat: CloudXAdFormat, hexColorString: String) {
907
- reactApplicationContext.runOnUiQueueThread {
908
- val adView = retrieveAdView(adUnitId, adFormat) ?: return@runOnUiQueueThread
977
+ onUiThread {
978
+ val adView = retrieveAdView(adUnitId, adFormat) ?: return@onUiThread
909
979
  try {
910
980
  adView.setBackgroundColor(Color.parseColor(hexColorString))
911
981
  } catch (e: IllegalArgumentException) {
@@ -915,11 +985,11 @@ class CloudXModuleImpl(private val reactApplicationContext: ReactApplicationCont
915
985
  }
916
986
 
917
987
  private fun startAdViewAutoRefresh(adUnitId: String, adFormat: CloudXAdFormat) {
918
- reactApplicationContext.runOnUiQueueThread { retrieveAdView(adUnitId, adFormat)?.startAutoRefresh() }
988
+ onUiThread { retrieveAdView(adUnitId, adFormat)?.startAutoRefresh() }
919
989
  }
920
990
 
921
991
  private fun stopAdViewAutoRefresh(adUnitId: String, adFormat: CloudXAdFormat) {
922
- reactApplicationContext.runOnUiQueueThread { retrieveAdView(adUnitId, adFormat)?.stopAutoRefresh() }
992
+ onUiThread { retrieveAdView(adUnitId, adFormat)?.stopAutoRefresh() }
923
993
  }
924
994
 
925
995
  // =========================================================================
@@ -1105,7 +1175,7 @@ class CloudXModuleImpl(private val reactApplicationContext: ReactApplicationCont
1105
1175
  destroyAdView(adUnitId, adFormat)
1106
1176
  }
1107
1177
 
1108
- reactApplicationContext.runOnUiQueueThread {
1178
+ onUiThread {
1109
1179
  for (interstitial in interstitials.values) {
1110
1180
  interstitial.destroy()
1111
1181
  }
@@ -0,0 +1,170 @@
1
+ package com.cloudxreactnative
2
+
3
+ import com.facebook.react.bridge.ReactApplicationContext
4
+ import io.cloudx.sdk.CloudX
5
+ import io.cloudx.sdk.CloudXAppOpenAd
6
+ import io.cloudx.sdk.CloudXInterstitialAd
7
+ import io.cloudx.sdk.CloudXRewardedAd
8
+ import io.cloudx.sdk.CloudXAdView
9
+ import io.mockk.every
10
+ import io.mockk.mockk
11
+ import io.mockk.mockkStatic
12
+ import io.mockk.unmockkAll
13
+ import io.mockk.verify
14
+ import kotlinx.coroutines.Dispatchers
15
+ import kotlinx.coroutines.ExperimentalCoroutinesApi
16
+ import kotlinx.coroutines.test.UnconfinedTestDispatcher
17
+ import kotlinx.coroutines.test.resetMain
18
+ import kotlinx.coroutines.test.setMain
19
+ import org.junit.After
20
+ import org.junit.Assert.assertEquals
21
+ import org.junit.Before
22
+ import org.junit.Test
23
+
24
+ /**
25
+ * Covers the module's UI-thread invariant: every public method posts its body via
26
+ * [ReactApplicationContext.runOnUiQueueThread], so JS call order is preserved and a destroy
27
+ * followed by a load always operates on a fresh native ad instance.
28
+ */
29
+ @OptIn(ExperimentalCoroutinesApi::class)
30
+ class CloudXModuleImplThreadingTest {
31
+
32
+ private val adUnitId = "unit-1"
33
+ private val queuedRunnables = mutableListOf<Runnable>()
34
+
35
+ @Before
36
+ fun setUp() {
37
+ // CloudX's static initializer resolves Dispatchers.Main, which needs a main looper on the JVM.
38
+ Dispatchers.setMain(UnconfinedTestDispatcher())
39
+ mockkStatic(CloudX::class)
40
+ }
41
+
42
+ @After
43
+ fun tearDown() {
44
+ unmockkAll()
45
+ Dispatchers.resetMain()
46
+ }
47
+
48
+ // Simulates the UI thread's FIFO queue by running every posted runnable immediately.
49
+ private fun moduleWithInlineUiThread(): CloudXModuleImpl {
50
+ val context = mockk<ReactApplicationContext>(relaxed = true)
51
+ every { context.runOnUiQueueThread(any()) } answers { firstArg<Runnable>().run() }
52
+ return CloudXModuleImpl(context)
53
+ }
54
+
55
+ // Swallows every posted runnable, so anything that runs is running off the UI thread.
56
+ private fun moduleWithStalledUiThread(): CloudXModuleImpl {
57
+ val context = mockk<ReactApplicationContext>(relaxed = true)
58
+ every { context.runOnUiQueueThread(any()) } returns Unit
59
+ return CloudXModuleImpl(context)
60
+ }
61
+
62
+ // Queues posted runnables into [queuedRunnables]; call [drainUiThread] to run them in FIFO order.
63
+ private fun moduleWithQueuedUiThread(): CloudXModuleImpl {
64
+ val context = mockk<ReactApplicationContext>(relaxed = true)
65
+ every { context.runOnUiQueueThread(any()) } answers { queuedRunnables.add(firstArg()) }
66
+ return CloudXModuleImpl(context)
67
+ }
68
+
69
+ private fun drainUiThread() {
70
+ while (queuedRunnables.isNotEmpty()) queuedRunnables.removeAt(0).run()
71
+ }
72
+
73
+ @Test
74
+ fun `destroy then load on same ad unit loads a fresh interstitial`() {
75
+ // Arrange
76
+ val first = mockk<CloudXInterstitialAd>(relaxed = true)
77
+ val second = mockk<CloudXInterstitialAd>(relaxed = true)
78
+ every { CloudX.createInterstitial(any(), any()) } returnsMany listOf(first, second)
79
+ val subject = moduleWithInlineUiThread()
80
+ subject.loadInterstitial(adUnitId)
81
+
82
+ // Act
83
+ subject.destroyInterstitial(adUnitId)
84
+ subject.loadInterstitial(adUnitId)
85
+
86
+ // Assert
87
+ verify(exactly = 1) { first.load() }
88
+ verify(exactly = 1) { first.destroy() }
89
+ verify(exactly = 1) { second.load() }
90
+ }
91
+
92
+ @Test
93
+ fun `destroy then load on same ad unit loads a fresh app open ad`() {
94
+ // Arrange
95
+ val first = mockk<CloudXAppOpenAd>(relaxed = true)
96
+ val second = mockk<CloudXAppOpenAd>(relaxed = true)
97
+ every { CloudX.createAppOpen(any(), any()) } returnsMany listOf(first, second)
98
+ val subject = moduleWithInlineUiThread()
99
+ subject.loadAppOpen(adUnitId)
100
+
101
+ // Act
102
+ subject.destroyAppOpen(adUnitId)
103
+ subject.loadAppOpen(adUnitId)
104
+
105
+ // Assert
106
+ verify(exactly = 1) { first.load() }
107
+ verify(exactly = 1) { first.destroy() }
108
+ verify(exactly = 1) { second.load() }
109
+ }
110
+
111
+ @Test
112
+ fun `destroy then load on same ad unit loads a fresh rewarded ad`() {
113
+ // Arrange
114
+ val first = mockk<CloudXRewardedAd>(relaxed = true)
115
+ val second = mockk<CloudXRewardedAd>(relaxed = true)
116
+ every { CloudX.createRewarded(any(), any()) } returnsMany listOf(first, second)
117
+ val subject = moduleWithInlineUiThread()
118
+ subject.loadRewarded(adUnitId)
119
+
120
+ // Act
121
+ subject.destroyRewarded(adUnitId)
122
+ subject.loadRewarded(adUnitId)
123
+
124
+ // Assert
125
+ verify(exactly = 1) { first.load() }
126
+ verify(exactly = 1) { first.destroy() }
127
+ verify(exactly = 1) { second.load() }
128
+ }
129
+
130
+ @Test
131
+ fun `load does not touch the SDK before the UI runnable runs`() {
132
+ // Arrange
133
+ val subject = moduleWithStalledUiThread()
134
+
135
+ // Act
136
+ subject.loadInterstitial(adUnitId)
137
+
138
+ // Assert
139
+ verify(exactly = 0) { CloudX.createInterstitial(any(), any()) }
140
+ }
141
+
142
+ @Test
143
+ fun `position update followed by offsets update keeps the new position`() {
144
+ // Arrange
145
+ every { CloudX.createBanner(any(), any()) } returns mockk<CloudXAdView>(relaxed = true)
146
+ val subject = moduleWithQueuedUiThread()
147
+ subject.createBanner(adUnitId, "top")
148
+ drainUiThread()
149
+
150
+ // Act
151
+ subject.updateBannerPosition(adUnitId, "bottom")
152
+ subject.updateBannerOffsets(adUnitId, 1.0, 2.0)
153
+ drainUiThread()
154
+
155
+ // Assert
156
+ assertEquals("bottom", subject.adViewPositionForTesting(adUnitId))
157
+ }
158
+
159
+ @Test
160
+ fun `config setter does not touch the SDK before the UI runnable runs`() {
161
+ // Arrange
162
+ val subject = moduleWithStalledUiThread()
163
+
164
+ // Act
165
+ subject.setHashedUserID("hashed")
166
+
167
+ // Assert
168
+ verify(exactly = 0) { CloudX.setHashedUserId(any()) }
169
+ }
170
+ }
@@ -14,7 +14,7 @@ Pod::Spec.new do |s|
14
14
 
15
15
  s.requires_arc = true
16
16
 
17
- s.dependency "CloudXCore", "~> 3.7.0"
17
+ s.dependency "CloudXCore", "~> 3.8.0"
18
18
 
19
19
  s.source_files = "ios/**/*.{h,mm}"
20
20
 
@@ -9,5 +9,5 @@ exports.PLUGIN_VERSION = void 0;
9
9
  // This value is reported to the native SDK in CloudX.initialize() and is used
10
10
  // for wrapper-version telemetry/attribution, so a stale value mis-attributes
11
11
  // installs to the wrong release.
12
- const PLUGIN_VERSION = exports.PLUGIN_VERSION = '3.7.0';
12
+ const PLUGIN_VERSION = exports.PLUGIN_VERSION = '3.8.0';
13
13
  //# sourceMappingURL=version.js.map
@@ -3,5 +3,5 @@
3
3
  // This value is reported to the native SDK in CloudX.initialize() and is used
4
4
  // for wrapper-version telemetry/attribution, so a stale value mis-attributes
5
5
  // installs to the wrong release.
6
- export const PLUGIN_VERSION = '3.7.0';
6
+ export const PLUGIN_VERSION = '3.8.0';
7
7
  //# sourceMappingURL=version.js.map
@@ -1,2 +1,2 @@
1
- export declare const PLUGIN_VERSION = "3.7.0";
1
+ export declare const PLUGIN_VERSION = "3.8.0";
2
2
  //# sourceMappingURL=version.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudx-react-native",
3
- "version": "3.7.0",
3
+ "version": "3.8.0",
4
4
  "description": "React Native wrapper for CloudX SDK (iOS & Android)",
5
5
  "main": "lib/commonjs/index",
6
6
  "module": "lib/module/index",
package/src/version.ts CHANGED
@@ -3,4 +3,4 @@
3
3
  // This value is reported to the native SDK in CloudX.initialize() and is used
4
4
  // for wrapper-version telemetry/attribution, so a stale value mis-attributes
5
5
  // installs to the wrong release.
6
- export const PLUGIN_VERSION = '3.7.0';
6
+ export const PLUGIN_VERSION = '3.8.0';