@bigcrunch/react-native-ads 0.3.1 → 0.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 (64) hide show
  1. package/README.md +5 -5
  2. package/android/bigcrunch-ads/com/bigcrunch/ads/BigCrunchAds.kt +434 -0
  3. package/android/bigcrunch-ads/com/bigcrunch/ads/BigCrunchBannerView.kt +484 -0
  4. package/android/bigcrunch-ads/com/bigcrunch/ads/BigCrunchInterstitial.kt +403 -0
  5. package/android/bigcrunch-ads/com/bigcrunch/ads/BigCrunchRewarded.kt +409 -0
  6. package/android/bigcrunch-ads/com/bigcrunch/ads/adapters/GoogleAdsAdapter.kt +592 -0
  7. package/android/bigcrunch-ads/com/bigcrunch/ads/core/AdOrchestrator.kt +623 -0
  8. package/android/bigcrunch-ads/com/bigcrunch/ads/core/AnalyticsClient.kt +719 -0
  9. package/android/bigcrunch-ads/com/bigcrunch/ads/core/BidRequestClient.kt +364 -0
  10. package/android/bigcrunch-ads/com/bigcrunch/ads/core/ConfigManager.kt +301 -0
  11. package/android/bigcrunch-ads/com/bigcrunch/ads/core/DeviceContext.kt +385 -0
  12. package/android/bigcrunch-ads/com/bigcrunch/ads/core/RewardedCallback.kt +42 -0
  13. package/android/bigcrunch-ads/com/bigcrunch/ads/core/SessionManager.kt +330 -0
  14. package/android/bigcrunch-ads/com/bigcrunch/ads/internal/DeviceHelper.kt +60 -0
  15. package/android/bigcrunch-ads/com/bigcrunch/ads/internal/HttpClient.kt +114 -0
  16. package/android/bigcrunch-ads/com/bigcrunch/ads/internal/Logger.kt +71 -0
  17. package/android/bigcrunch-ads/com/bigcrunch/ads/internal/PrivacyStore.kt +125 -0
  18. package/android/bigcrunch-ads/com/bigcrunch/ads/internal/Storage.kt +88 -0
  19. package/android/bigcrunch-ads/com/bigcrunch/ads/listeners/BannerAdListener.kt +55 -0
  20. package/android/bigcrunch-ads/com/bigcrunch/ads/listeners/InterstitialAdListener.kt +55 -0
  21. package/android/bigcrunch-ads/com/bigcrunch/ads/listeners/RewardedAdListener.kt +58 -0
  22. package/android/bigcrunch-ads/com/bigcrunch/ads/models/AdEvent.kt +880 -0
  23. package/android/bigcrunch-ads/com/bigcrunch/ads/models/AppConfig.kt +90 -0
  24. package/android/bigcrunch-ads/com/bigcrunch/ads/models/DeviceData.kt +18 -0
  25. package/android/bigcrunch-ads/com/bigcrunch/ads/models/PlacementConfig.kt +70 -0
  26. package/android/bigcrunch-ads/com/bigcrunch/ads/models/SessionInfo.kt +21 -0
  27. package/android/build.gradle +22 -10
  28. package/android/settings.gradle +2 -6
  29. package/android/src/main/java/com/bigcrunch/ads/react/BigCrunchAdsModule.kt +0 -23
  30. package/ios/BigCrunchAds/Sources/Adapters/GoogleAdsAdapter.swift +512 -0
  31. package/ios/BigCrunchAds/Sources/BigCrunchAds.swift +387 -0
  32. package/ios/BigCrunchAds/Sources/BigCrunchBannerView.swift +448 -0
  33. package/ios/BigCrunchAds/Sources/BigCrunchInterstitial.swift +412 -0
  34. package/ios/BigCrunchAds/Sources/BigCrunchRewarded.swift +523 -0
  35. package/ios/BigCrunchAds/Sources/Core/AdOrchestrator.swift +514 -0
  36. package/ios/BigCrunchAds/Sources/Core/AnalyticsClient.swift +874 -0
  37. package/ios/BigCrunchAds/Sources/Core/BidRequestClient.swift +344 -0
  38. package/ios/BigCrunchAds/Sources/Core/ConfigManager.swift +306 -0
  39. package/ios/BigCrunchAds/Sources/Core/DeviceContext.swift +284 -0
  40. package/ios/BigCrunchAds/Sources/Core/SessionManager.swift +392 -0
  41. package/ios/BigCrunchAds/Sources/Internal/HTTPClient.swift +146 -0
  42. package/ios/BigCrunchAds/Sources/Internal/Logger.swift +62 -0
  43. package/ios/BigCrunchAds/Sources/Internal/PrivacyStore.swift +129 -0
  44. package/ios/BigCrunchAds/Sources/Internal/Storage.swift +73 -0
  45. package/ios/BigCrunchAds/Sources/Models/AdEvent.swift +784 -0
  46. package/ios/BigCrunchAds/Sources/Models/AppConfig.swift +100 -0
  47. package/ios/BigCrunchAds/Sources/Models/DeviceData.swift +68 -0
  48. package/ios/BigCrunchAds/Sources/Models/PlacementConfig.swift +137 -0
  49. package/ios/BigCrunchAds/Sources/Models/SessionInfo.swift +48 -0
  50. package/ios/BigCrunchAdsModule.swift +5 -14
  51. package/ios/BigCrunchBannerViewManager.swift +0 -1
  52. package/lib/index.d.ts +1 -1
  53. package/lib/index.d.ts.map +1 -1
  54. package/lib/index.js +3 -2
  55. package/lib/types/config.d.ts +22 -9
  56. package/lib/types/config.d.ts.map +1 -1
  57. package/lib/types/events.d.ts +4 -4
  58. package/lib/types/events.d.ts.map +1 -1
  59. package/package.json +11 -4
  60. package/react-native-bigcrunch-ads.podspec +1 -3
  61. package/scripts/inject-version.js +55 -0
  62. package/src/index.ts +3 -2
  63. package/src/types/config.ts +23 -9
  64. package/src/types/events.ts +4 -4
@@ -0,0 +1,623 @@
1
+ package com.bigcrunch.ads.core
2
+
3
+ import android.app.Activity
4
+ import android.content.Context
5
+ import android.view.ViewGroup
6
+ import com.bigcrunch.ads.adapters.BannerAdCallback
7
+ import com.bigcrunch.ads.adapters.GoogleAdsAdapter
8
+ import com.bigcrunch.ads.adapters.InterstitialAdCallback
9
+ import com.bigcrunch.ads.adapters.RewardedAdCallback
10
+ import com.bigcrunch.ads.internal.BCLogger
11
+ import com.bigcrunch.ads.models.PlacementConfig
12
+ import com.google.android.gms.ads.admanager.AdManagerAdRequest
13
+ import com.google.android.gms.ads.admanager.AdManagerAdView
14
+ import com.google.android.gms.ads.admanager.AdManagerInterstitialAd
15
+ import com.google.android.gms.ads.rewarded.RewardedAd
16
+ import kotlinx.coroutines.CoroutineExceptionHandler
17
+ import kotlinx.coroutines.CoroutineScope
18
+ import kotlinx.coroutines.Dispatchers
19
+ import kotlinx.coroutines.SupervisorJob
20
+ import kotlinx.coroutines.launch
21
+ import kotlinx.coroutines.sync.Mutex
22
+ import kotlinx.coroutines.sync.withLock
23
+ import java.util.concurrent.ConcurrentHashMap
24
+
25
+ /**
26
+ * Callback interface for banner ad events
27
+ */
28
+ interface BannerCallback {
29
+ fun onAdLoaded()
30
+ fun onAdFailedToLoad(error: String)
31
+ fun onAdClicked()
32
+ fun onAdImpression()
33
+ }
34
+
35
+ /**
36
+ * Callback interface for interstitial ad events
37
+ */
38
+ interface InterstitialCallback {
39
+ fun onAdLoaded()
40
+ fun onAdFailedToLoad(error: String)
41
+ fun onAdShowed()
42
+ fun onAdDismissed()
43
+ fun onAdClicked()
44
+ }
45
+
46
+ /**
47
+ * AdOrchestrator - Central orchestration for ad loading lifecycle
48
+ *
49
+ * Coordinates the flow between ConfigManager, BidRequestClient, GoogleAdsAdapter,
50
+ * and AnalyticsClient to load and display ads.
51
+ *
52
+ * Flow:
53
+ * 1. Get PlacementConfig from ConfigManager
54
+ * 2. Track ad request via AnalyticsClient
55
+ * 3. Fetch S2S demand via BidRequestClient
56
+ * 4. Load Google ad via GoogleAdsAdapter (with targeting from S2S)
57
+ * 5. Wire up all callbacks for analytics
58
+ */
59
+ internal class AdOrchestrator(
60
+ private val context: Context,
61
+ private val configManager: ConfigManager,
62
+ private val analyticsClient: AnalyticsClient,
63
+ private val bidRequestClient: BidRequestClient,
64
+ private val googleAdsAdapter: GoogleAdsAdapter
65
+ ) {
66
+ private val TAG = "AdOrchestrator"
67
+
68
+ /**
69
+ * Exception handler for coroutines - catches any uncaught exceptions
70
+ * to prevent SDK errors from crashing the host app.
71
+ */
72
+ private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
73
+ BCLogger.e(TAG, "Uncaught exception in SDK coroutine - SDK will not propagate this to prevent app crash", throwable)
74
+ // Don't rethrow - SDK errors should not crash the host app
75
+ }
76
+
77
+ /**
78
+ * Coroutine scope with SupervisorJob (child failures don't cancel siblings)
79
+ * and exception handler (uncaught exceptions are logged, not propagated).
80
+ */
81
+ private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob() + exceptionHandler)
82
+
83
+ // Cache for preloaded interstitial ads
84
+ private val interstitialCache = ConcurrentHashMap<String, AdManagerInterstitialAd>()
85
+ private val interstitialMutex = Mutex()
86
+
87
+ // Cache for preloaded rewarded ads
88
+ private val rewardedCache = ConcurrentHashMap<String, RewardedAd>()
89
+ private val rewardedMutex = Mutex()
90
+
91
+ // Track active banner views for cleanup
92
+ private val activeBanners = ConcurrentHashMap<String, AdManagerAdView>()
93
+
94
+ /**
95
+ * Load a banner ad
96
+ *
97
+ * @param placementId The placement ID from the BigCrunch dashboard
98
+ * @param container The ViewGroup to add the banner to
99
+ * @param callback Callback for ad events
100
+ * @param adSizeOverride Optional size override (takes precedence over backend config)
101
+ */
102
+ fun loadBannerAd(
103
+ placementId: String,
104
+ container: ViewGroup,
105
+ callback: BannerCallback,
106
+ adSizeOverride: com.bigcrunch.ads.models.AdSize? = null,
107
+ refreshCount: Int = 0
108
+ ) {
109
+ BCLogger.d(TAG, "Loading banner ad: $placementId with adSizeOverride: $adSizeOverride")
110
+
111
+ // 1. Get placement config
112
+ val placement = configManager.getPlacement(placementId)
113
+ if (placement == null) {
114
+ BCLogger.e(TAG, "Placement not found: $placementId")
115
+ callback.onAdFailedToLoad("Placement not found: $placementId")
116
+ return
117
+ }
118
+
119
+ if (placement.format != "banner") {
120
+ BCLogger.e(TAG, "Invalid format for banner: ${placement.format}")
121
+ callback.onAdFailedToLoad("Invalid placement format: ${placement.format}")
122
+ return
123
+ }
124
+
125
+ // 2. Track ad request
126
+ analyticsClient.trackAdRequest(placementId, placement.format)
127
+
128
+ // 3. Start the async ad loading flow
129
+ scope.launch {
130
+ loadBannerAsync(placement, container, callback, adSizeOverride, refreshCount)
131
+ }
132
+ }
133
+
134
+ private suspend fun loadBannerAsync(
135
+ placement: PlacementConfig,
136
+ container: ViewGroup,
137
+ callback: BannerCallback,
138
+ adSizeOverride: com.bigcrunch.ads.models.AdSize? = null,
139
+ refreshCount: Int = 0
140
+ ) {
141
+ // Get GAM network code from config
142
+ val gamNetworkCode = configManager.getGamNetworkCode()
143
+ if (gamNetworkCode == null) {
144
+ BCLogger.e(TAG, "GAM network code not available")
145
+ callback.onAdFailedToLoad("Config not loaded")
146
+ return
147
+ }
148
+
149
+ // Resolve effective size for S2S bid request (adaptive → concrete dimensions)
150
+ val effectiveSize = adSizeOverride ?: placement.sizes?.firstOrNull()
151
+ val resolvedSize: com.bigcrunch.ads.models.AdSize? = if (effectiveSize?.isAdaptive == true) {
152
+ val displayMetrics = context.resources.displayMetrics
153
+ val widthDp = if (effectiveSize.width > 0) effectiveSize.width
154
+ else (displayMetrics.widthPixels / displayMetrics.density).toInt()
155
+ val googleAdSize = com.google.android.gms.ads.AdSize
156
+ .getCurrentOrientationAnchoredAdaptiveBannerAdSize(context, widthDp)
157
+ BCLogger.d(TAG, "Resolved adaptive size to ${googleAdSize.width}x${googleAdSize.height}")
158
+ com.bigcrunch.ads.models.AdSize(googleAdSize.width, googleAdSize.height)
159
+ } else {
160
+ effectiveSize
161
+ }
162
+
163
+ // Create ad request builder
164
+ val adRequestBuilder = AdManagerAdRequest.Builder()
165
+
166
+ // 3. Fetch S2S demand (even if it fails, continue with Google)
167
+ val targeting = bidRequestClient.fetchDemand(placement)
168
+ if (targeting != null && targeting.isNotEmpty()) {
169
+ for ((key, value) in targeting) {
170
+ adRequestBuilder.addCustomTargeting(key, value)
171
+ }
172
+ BCLogger.d(TAG, "S2S demand fetched for: ${placement.placementId} (${targeting.size} keys)")
173
+ } else {
174
+ BCLogger.w(TAG, "S2S demand fetch returned no targeting, continuing with Google only")
175
+ }
176
+
177
+ // 4. Load Google ad with the (possibly enriched) ad request
178
+ val adRequest = adRequestBuilder.build()
179
+ val adapterCallback = object : BannerAdCallback {
180
+ override fun onAdLoaded() {
181
+ BCLogger.d(TAG, "Banner ad loaded: ${placement.placementId}")
182
+ callback.onAdLoaded()
183
+ }
184
+
185
+ override fun onAdFailedToLoad(error: String) {
186
+ BCLogger.w(TAG, "Banner ad failed to load: ${placement.placementId} - $error")
187
+ callback.onAdFailedToLoad(error)
188
+ }
189
+
190
+ override fun onAdClicked() {
191
+ BCLogger.d(TAG, "Banner ad clicked: ${placement.placementId}")
192
+ callback.onAdClicked()
193
+ }
194
+
195
+ override fun onAdImpression() {
196
+ BCLogger.d(TAG, "Banner ad impression: ${placement.placementId}")
197
+ callback.onAdImpression()
198
+ }
199
+ }
200
+
201
+ // Get test ads flag from config
202
+ val useTestAds = configManager.shouldUseTestAds()
203
+
204
+ // Clean up existing banner for refresh
205
+ activeBanners.remove(placement.placementId)?.let { oldView ->
206
+ googleAdsAdapter.destroyBannerAd(oldView)
207
+ }
208
+
209
+ // Load the ad through GoogleAdsAdapter
210
+ val adView = googleAdsAdapter.loadBannerAd(
211
+ placement,
212
+ gamNetworkCode,
213
+ adRequest,
214
+ container,
215
+ adapterCallback,
216
+ useTestAds,
217
+ adSizeOverride,
218
+ refreshCount
219
+ )
220
+
221
+ // Track the active banner for cleanup
222
+ activeBanners[placement.placementId] = adView
223
+ }
224
+
225
+ /**
226
+ * Destroy a banner ad
227
+ *
228
+ * @param placementId The placement ID of the banner to destroy
229
+ */
230
+ fun destroyBannerAd(placementId: String) {
231
+ BCLogger.d(TAG, "Destroying banner ad: $placementId")
232
+ activeBanners.remove(placementId)?.let { adView ->
233
+ googleAdsAdapter.destroyBannerAd(adView)
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Preload an interstitial ad
239
+ *
240
+ * @param placementId The placement ID from the BigCrunch dashboard
241
+ * @param callback Callback for ad events
242
+ */
243
+ fun preloadInterstitialAd(
244
+ placementId: String,
245
+ callback: InterstitialCallback
246
+ ) {
247
+ BCLogger.d(TAG, "Preloading interstitial ad: $placementId")
248
+
249
+ // 1. Get placement config
250
+ val placement = configManager.getPlacement(placementId)
251
+ if (placement == null) {
252
+ BCLogger.e(TAG, "Placement not found: $placementId")
253
+ callback.onAdFailedToLoad("Placement not found: $placementId")
254
+ return
255
+ }
256
+
257
+ if (placement.format != "interstitial") {
258
+ BCLogger.e(TAG, "Invalid format for interstitial: ${placement.format}")
259
+ callback.onAdFailedToLoad("Invalid placement format: ${placement.format}")
260
+ return
261
+ }
262
+
263
+ // 2. Track ad request
264
+ analyticsClient.trackAdRequest(placementId, placement.format)
265
+
266
+ // 3. Start the async ad loading flow
267
+ scope.launch {
268
+ preloadInterstitialAsync(placement, callback)
269
+ }
270
+ }
271
+
272
+ private suspend fun preloadInterstitialAsync(
273
+ placement: PlacementConfig,
274
+ callback: InterstitialCallback
275
+ ) = interstitialMutex.withLock {
276
+ // Check if already cached
277
+ if (interstitialCache.containsKey(placement.placementId)) {
278
+ BCLogger.d(TAG, "Interstitial already cached: ${placement.placementId}")
279
+ callback.onAdLoaded()
280
+ return@withLock
281
+ }
282
+
283
+ // Get GAM network code from config
284
+ val gamNetworkCode = configManager.getGamNetworkCode()
285
+ if (gamNetworkCode == null) {
286
+ BCLogger.e(TAG, "GAM network code not available")
287
+ callback.onAdFailedToLoad("Config not loaded")
288
+ return@withLock
289
+ }
290
+
291
+ // Create ad request builder
292
+ val adRequestBuilder = AdManagerAdRequest.Builder()
293
+
294
+ // 3. Fetch S2S demand
295
+ val targeting = bidRequestClient.fetchDemand(placement)
296
+ if (targeting != null && targeting.isNotEmpty()) {
297
+ for ((key, value) in targeting) {
298
+ adRequestBuilder.addCustomTargeting(key, value)
299
+ }
300
+ BCLogger.d(TAG, "S2S demand fetched for: ${placement.placementId} (${targeting.size} keys)")
301
+ } else {
302
+ BCLogger.w(TAG, "S2S demand fetch returned no targeting, continuing with Google only")
303
+ }
304
+
305
+ // Get test ads flag from config
306
+ val useTestAds = configManager.shouldUseTestAds()
307
+
308
+ // 4. Load Google interstitial ad
309
+ val adRequest = adRequestBuilder.build()
310
+ val result = googleAdsAdapter.loadInterstitialAd(placement, gamNetworkCode, adRequest, useTestAds)
311
+
312
+ when {
313
+ result.isSuccess -> {
314
+ val interstitialAd = result.getOrNull()
315
+ if (interstitialAd == null) {
316
+ BCLogger.e(TAG, "Interstitial ad was null despite success result")
317
+ callback.onAdFailedToLoad("Internal error: ad was null")
318
+ return@withLock
319
+ }
320
+ interstitialCache[placement.placementId] = interstitialAd
321
+ BCLogger.d(TAG, "Interstitial ad preloaded: ${placement.placementId}")
322
+ callback.onAdLoaded()
323
+ }
324
+ else -> {
325
+ val error = result.exceptionOrNull()?.message ?: "Unknown error"
326
+ BCLogger.w(TAG, "Interstitial ad failed to load: ${placement.placementId} - $error")
327
+ callback.onAdFailedToLoad(error)
328
+ }
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Show an interstitial ad
334
+ *
335
+ * @param activity The activity to show the interstitial from
336
+ * @param placementId The placement ID from the BigCrunch dashboard
337
+ * @param callback Callback for ad events
338
+ * @return true if an ad was available and shown, false otherwise
339
+ */
340
+ fun showInterstitialAd(
341
+ activity: Activity,
342
+ placementId: String,
343
+ callback: InterstitialCallback
344
+ ): Boolean {
345
+ BCLogger.d(TAG, "Showing interstitial ad: $placementId")
346
+
347
+ // Get placement config
348
+ val placement = configManager.getPlacement(placementId)
349
+ if (placement == null) {
350
+ BCLogger.e(TAG, "Placement not found: $placementId")
351
+ callback.onAdFailedToLoad("Placement not found: $placementId")
352
+ return false
353
+ }
354
+
355
+ // Get cached interstitial
356
+ val interstitialAd = interstitialCache.remove(placementId)
357
+ if (interstitialAd == null) {
358
+ BCLogger.w(TAG, "No preloaded interstitial for: $placementId")
359
+ callback.onAdFailedToLoad("No preloaded ad available")
360
+ return false
361
+ }
362
+
363
+ // Create adapter callback
364
+ val adapterCallback = object : InterstitialAdCallback {
365
+ override fun onAdLoaded() {
366
+ // Already loaded, this won't be called during show
367
+ }
368
+
369
+ override fun onAdFailedToLoad(error: String) {
370
+ callback.onAdFailedToLoad(error)
371
+ }
372
+
373
+ override fun onAdShowed() {
374
+ callback.onAdShowed()
375
+ }
376
+
377
+ override fun onAdDismissed() {
378
+ callback.onAdDismissed()
379
+ }
380
+
381
+ override fun onAdClicked() {
382
+ callback.onAdClicked()
383
+ }
384
+
385
+ override fun onAdImpression() {
386
+ // Handled by GoogleAdsAdapter analytics tracking
387
+ }
388
+ }
389
+
390
+ // Show the ad
391
+ googleAdsAdapter.showInterstitialAd(
392
+ interstitialAd,
393
+ activity,
394
+ placement,
395
+ adapterCallback
396
+ )
397
+
398
+ return true
399
+ }
400
+
401
+ /**
402
+ * Check if an interstitial ad is ready to show
403
+ *
404
+ * @param placementId The placement ID to check
405
+ * @return true if an ad is preloaded and ready
406
+ */
407
+ fun isInterstitialReady(placementId: String): Boolean {
408
+ return interstitialCache.containsKey(placementId)
409
+ }
410
+
411
+ /**
412
+ * Preload a rewarded ad
413
+ *
414
+ * @param placementId The placement ID from the BigCrunch dashboard
415
+ * @param callback Callback for ad events
416
+ */
417
+ fun preloadRewardedAd(
418
+ placementId: String,
419
+ callback: RewardedCallback
420
+ ) {
421
+ BCLogger.d(TAG, "Preloading rewarded ad: $placementId")
422
+
423
+ // 1. Get placement config
424
+ val placement = configManager.getPlacement(placementId)
425
+ if (placement == null) {
426
+ BCLogger.e(TAG, "Placement not found: $placementId")
427
+ callback.onAdFailedToLoad("Placement not found: $placementId")
428
+ return
429
+ }
430
+
431
+ if (placement.format != "rewarded") {
432
+ BCLogger.e(TAG, "Invalid format for rewarded: ${placement.format}")
433
+ callback.onAdFailedToLoad("Invalid placement format: ${placement.format}")
434
+ return
435
+ }
436
+
437
+ // 2. Track ad request
438
+ analyticsClient.trackAdRequest(placementId, placement.format)
439
+
440
+ // 3. Start the async ad loading flow
441
+ scope.launch {
442
+ preloadRewardedAsync(placement, callback)
443
+ }
444
+ }
445
+
446
+ private suspend fun preloadRewardedAsync(
447
+ placement: PlacementConfig,
448
+ callback: RewardedCallback
449
+ ) = rewardedMutex.withLock {
450
+ // Check if already cached
451
+ if (rewardedCache.containsKey(placement.placementId)) {
452
+ BCLogger.d(TAG, "Rewarded ad already cached: ${placement.placementId}")
453
+ callback.onAdLoaded()
454
+ return@withLock
455
+ }
456
+
457
+ // Get GAM network code from config
458
+ val gamNetworkCode = configManager.getGamNetworkCode()
459
+ if (gamNetworkCode == null) {
460
+ BCLogger.e(TAG, "GAM network code not available")
461
+ callback.onAdFailedToLoad("Config not loaded")
462
+ return@withLock
463
+ }
464
+
465
+ // Create ad request builder
466
+ val adRequestBuilder = AdManagerAdRequest.Builder()
467
+
468
+ // 3. Fetch S2S demand
469
+ val targeting = bidRequestClient.fetchDemand(placement)
470
+ if (targeting != null && targeting.isNotEmpty()) {
471
+ for ((key, value) in targeting) {
472
+ adRequestBuilder.addCustomTargeting(key, value)
473
+ }
474
+ BCLogger.d(TAG, "S2S demand fetched for: ${placement.placementId} (${targeting.size} keys)")
475
+ } else {
476
+ BCLogger.w(TAG, "S2S demand fetch returned no targeting, continuing with Google only")
477
+ }
478
+
479
+ // Get test ads flag from config
480
+ val useTestAds = configManager.shouldUseTestAds()
481
+
482
+ // 4. Load Google rewarded ad
483
+ val adRequest = adRequestBuilder.build()
484
+ val result = googleAdsAdapter.loadRewardedAd(placement, gamNetworkCode, adRequest, useTestAds)
485
+
486
+ when {
487
+ result.isSuccess -> {
488
+ val rewardedAd = result.getOrNull()
489
+ if (rewardedAd == null) {
490
+ BCLogger.e(TAG, "Rewarded ad was null despite success result")
491
+ callback.onAdFailedToLoad("Internal error: ad was null")
492
+ return@withLock
493
+ }
494
+ rewardedCache[placement.placementId] = rewardedAd
495
+ BCLogger.d(TAG, "Rewarded ad preloaded: ${placement.placementId}")
496
+ callback.onAdLoaded()
497
+ }
498
+ else -> {
499
+ val error = result.exceptionOrNull()?.message ?: "Unknown error"
500
+ BCLogger.w(TAG, "Rewarded ad failed to load: ${placement.placementId} - $error")
501
+ callback.onAdFailedToLoad(error)
502
+ }
503
+ }
504
+ }
505
+
506
+ /**
507
+ * Show a rewarded ad
508
+ *
509
+ * @param activity The activity to show the rewarded ad from
510
+ * @param placementId The placement ID from the BigCrunch dashboard
511
+ * @param callback Callback for ad events
512
+ * @return true if an ad was available and shown, false otherwise
513
+ */
514
+ fun showRewardedAd(
515
+ activity: Activity,
516
+ placementId: String,
517
+ callback: RewardedCallback
518
+ ): Boolean {
519
+ BCLogger.d(TAG, "Showing rewarded ad: $placementId")
520
+
521
+ // Get placement config
522
+ val placement = configManager.getPlacement(placementId)
523
+ if (placement == null) {
524
+ BCLogger.e(TAG, "Placement not found: $placementId")
525
+ callback.onAdFailedToLoad("Placement not found: $placementId")
526
+ return false
527
+ }
528
+
529
+ // Get cached rewarded ad
530
+ val rewardedAd = rewardedCache.remove(placementId)
531
+ if (rewardedAd == null) {
532
+ BCLogger.w(TAG, "No preloaded rewarded ad for: $placementId")
533
+ callback.onAdFailedToLoad("No preloaded ad available")
534
+ return false
535
+ }
536
+
537
+ // Create adapter callback
538
+ val adapterCallback = object : RewardedAdCallback {
539
+ override fun onAdLoaded() {
540
+ // Already loaded, this won't be called during show
541
+ }
542
+
543
+ override fun onAdFailedToLoad(error: String) {
544
+ callback.onAdFailedToLoad(error)
545
+ }
546
+
547
+ override fun onAdShowed() {
548
+ callback.onAdShowed()
549
+ }
550
+
551
+ override fun onAdDismissed() {
552
+ callback.onAdDismissed()
553
+ }
554
+
555
+ override fun onAdClicked() {
556
+ callback.onAdClicked()
557
+ }
558
+
559
+ override fun onUserEarnedReward(type: String, amount: Int) {
560
+ callback.onUserEarnedReward(type, amount)
561
+ }
562
+
563
+ override fun onAdImpression() {
564
+ // Handled by GoogleAdsAdapter analytics tracking
565
+ }
566
+ }
567
+
568
+ // Show the ad
569
+ googleAdsAdapter.showRewardedAd(
570
+ rewardedAd,
571
+ activity,
572
+ placement,
573
+ adapterCallback
574
+ )
575
+
576
+ return true
577
+ }
578
+
579
+ /**
580
+ * Check if a rewarded ad is ready to show
581
+ *
582
+ * @param placementId The placement ID to check
583
+ * @return true if an ad is preloaded and ready
584
+ */
585
+ fun isRewardedReady(placementId: String): Boolean {
586
+ return rewardedCache.containsKey(placementId)
587
+ }
588
+
589
+ /**
590
+ * Destroy a specific interstitial ad
591
+ *
592
+ * @param placementId The placement ID to destroy
593
+ */
594
+ fun destroyInterstitialAd(placementId: String) {
595
+ BCLogger.d(TAG, "Destroying interstitial: $placementId")
596
+ interstitialCache.remove(placementId)
597
+ }
598
+
599
+ /**
600
+ * Destroy a specific rewarded ad
601
+ *
602
+ * @param placementId The placement ID to destroy
603
+ */
604
+ fun destroyRewardedAd(placementId: String) {
605
+ BCLogger.d(TAG, "Destroying rewarded ad: $placementId")
606
+ rewardedCache.remove(placementId)
607
+ }
608
+
609
+ /**
610
+ * Clear all cached ads
611
+ */
612
+ fun clearCache() {
613
+ BCLogger.d(TAG, "Clearing ad cache")
614
+ interstitialCache.clear()
615
+ rewardedCache.clear()
616
+
617
+ // Destroy all active banners
618
+ activeBanners.values.forEach { adView ->
619
+ googleAdsAdapter.destroyBannerAd(adView)
620
+ }
621
+ activeBanners.clear()
622
+ }
623
+ }