@bigcrunch/react-native-ads 0.10.1 → 0.13.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 (34) hide show
  1. package/android/bigcrunch-ads/com/bigcrunch/ads/BigCrunchAds.kt +7 -4
  2. package/android/bigcrunch-ads/com/bigcrunch/ads/adapters/GoogleAdsAdapter.kt +14 -3
  3. package/android/bigcrunch-ads/com/bigcrunch/ads/core/AdOrchestrator.kt +37 -12
  4. package/android/bigcrunch-ads/com/bigcrunch/ads/core/AnalyticsClient.kt +199 -40
  5. package/android/bigcrunch-ads/com/bigcrunch/ads/core/BidRequestClient.kt +52 -17
  6. package/android/bigcrunch-ads/com/bigcrunch/ads/core/DeviceContext.kt +1 -1
  7. package/android/bigcrunch-ads/com/bigcrunch/ads/models/AdEvent.kt +81 -2
  8. package/android/bigcrunch-ads/com/bigcrunch/ads/models/PlacementConfig.kt +4 -1
  9. package/android/src/main/java/com/bigcrunch/ads/react/BigCrunchAdsModule.kt +44 -2
  10. package/ios/BigCrunchAds/Sources/Adapters/GoogleAdsAdapter.swift +10 -2
  11. package/ios/BigCrunchAds/Sources/BigCrunchAds.swift +10 -6
  12. package/ios/BigCrunchAds/Sources/BigCrunchRewarded.swift +10 -2
  13. package/ios/BigCrunchAds/Sources/Core/AdOrchestrator.swift +20 -4
  14. package/ios/BigCrunchAds/Sources/Core/AnalyticsClient.swift +179 -77
  15. package/ios/BigCrunchAds/Sources/Core/BidRequestClient.swift +54 -17
  16. package/ios/BigCrunchAds/Sources/Core/DeviceContext.swift +1 -1
  17. package/ios/BigCrunchAds/Sources/Internal/Logger.swift +11 -6
  18. package/ios/BigCrunchAds/Sources/Models/AdEvent.swift +111 -3
  19. package/ios/BigCrunchAds/Sources/Models/PlacementConfig.swift +4 -1
  20. package/ios/BigCrunchAdsModule.m +1 -0
  21. package/ios/BigCrunchAdsModule.swift +34 -2
  22. package/ios/BigCrunchBannerViewManager.swift +1 -0
  23. package/lib/BigCrunchAds.d.ts +19 -2
  24. package/lib/BigCrunchAds.d.ts.map +1 -1
  25. package/lib/BigCrunchAds.js +19 -2
  26. package/lib/NativeBigCrunchAds.d.ts +2 -2
  27. package/lib/NativeBigCrunchAds.d.ts.map +1 -1
  28. package/lib/types/index.d.ts +36 -0
  29. package/lib/types/index.d.ts.map +1 -1
  30. package/package.json +2 -2
  31. package/react-native-bigcrunch-ads.podspec +1 -1
  32. package/src/BigCrunchAds.ts +23 -2
  33. package/src/NativeBigCrunchAds.ts +2 -2
  34. package/src/types/index.ts +38 -0
@@ -12,6 +12,7 @@ import com.bigcrunch.ads.internal.PrivacyStore
12
12
  import com.bigcrunch.ads.internal.SharedPreferencesStore
13
13
  import com.bigcrunch.ads.models.AppConfig
14
14
  import com.bigcrunch.ads.models.DeviceData
15
+ import com.bigcrunch.ads.models.ScreenViewOptions
15
16
  import com.bigcrunch.ads.models.SessionInfo
16
17
  import com.google.android.gms.ads.MobileAds
17
18
  import com.google.android.gms.ads.RequestConfiguration
@@ -231,13 +232,14 @@ object BigCrunchAds {
231
232
  * Track screen view for analytics
232
233
  *
233
234
  * @param screenName Name of the screen being viewed
235
+ * @param options Optional overrides for page URL, content metadata, and custom dimensions
234
236
  */
235
- fun trackScreen(screenName: String) = runSafely {
237
+ fun trackScreen(screenName: String, options: ScreenViewOptions? = null) = runSafely {
236
238
  if (!initialized) {
237
239
  BCLogger.w(TAG, "trackScreen called before initialization, ignoring")
238
240
  return@runSafely
239
241
  }
240
- analyticsClient.trackScreenView(screenName)
242
+ analyticsClient.trackScreenView(screenName, options)
241
243
  SessionManager.incrementScreenViewCount()
242
244
  }
243
245
 
@@ -245,9 +247,10 @@ object BigCrunchAds {
245
247
  * Track screen view for analytics (alias for React Native compatibility)
246
248
  *
247
249
  * @param screenName Name of the screen being viewed
250
+ * @param options Optional overrides for page URL, content metadata, and custom dimensions
248
251
  */
249
- fun trackScreenView(screenName: String) = runSafely {
250
- trackScreen(screenName)
252
+ fun trackScreenView(screenName: String, options: ScreenViewOptions? = null) = runSafely {
253
+ trackScreen(screenName, options)
251
254
  }
252
255
 
253
256
  /**
@@ -120,17 +120,21 @@ internal class GoogleAdsAdapter(
120
120
  * Resolve a BigCrunch AdSize to a Google AdSize.
121
121
  * For adaptive sizes, calculates the optimal ad size based on screen width.
122
122
  */
123
+ /**
124
+ * Resolve a BigCrunch AdSize to a Google AdSize.
125
+ * A 0x0 size is always treated as adaptive since it is never valid as a fixed size.
126
+ */
123
127
  private fun resolveGoogleAdSize(
124
128
  bcAdSize: com.bigcrunch.ads.models.AdSize
125
129
  ): AdSize {
126
- if (bcAdSize.isAdaptive) {
130
+ if (bcAdSize.isAdaptive || (bcAdSize.width == 0 && bcAdSize.height == 0)) {
127
131
  val widthDp = if (bcAdSize.width > 0) {
128
132
  bcAdSize.width
129
133
  } else {
130
134
  val displayMetrics = context.resources.displayMetrics
131
135
  (displayMetrics.widthPixels / displayMetrics.density).toInt()
132
136
  }
133
- BCLogger.d(TAG, "Resolving adaptive banner with width: ${widthDp}dp")
137
+ BCLogger.d(TAG, "Resolving adaptive banner with width: ${widthDp}dp (isAdaptive=${bcAdSize.isAdaptive}, original=${bcAdSize.width}x${bcAdSize.height})")
134
138
  return AdSize.getCurrentOrientationAnchoredAdaptiveBannerAdSize(context, widthDp)
135
139
  }
136
140
  return AdSize(bcAdSize.width, bcAdSize.height)
@@ -226,12 +230,17 @@ internal class GoogleAdsAdapter(
226
230
 
227
231
  // Extract GAM metadata from ResponseInfo
228
232
  val responseInfo = adView.responseInfo
229
- val advertiserId = responseInfo?.loadedAdapterResponseInfo?.adSourceId
230
233
  val adMetadata = extractAdMetadata(responseInfo)
231
234
 
235
+ // Extract ad size from the loaded banner
236
+ val loadedAdSize = adView.adSize
237
+ val adSizeString = if (loadedAdSize != null) "${loadedAdSize.width}x${loadedAdSize.height}" else ""
238
+
232
239
  analyticsClient.trackAdImpression(
233
240
  placementId = placementConfig.placementId,
234
241
  format = placementConfig.format,
242
+ gamAdUnit = placementConfig.gamAdUnit,
243
+ adSize = adSizeString,
235
244
  advertiserId = adMetadata["advertiser_id"] as? String,
236
245
  campaignId = adMetadata["campaign_id"] as? String,
237
246
  lineItemId = adMetadata["line_item_id"] as? String,
@@ -390,6 +399,7 @@ internal class GoogleAdsAdapter(
390
399
  analyticsClient.trackAdImpression(
391
400
  placementId = placementConfig.placementId,
392
401
  format = placementConfig.format,
402
+ gamAdUnit = placementConfig.gamAdUnit,
393
403
  advertiserId = adMetadata["advertiser_id"] as? String,
394
404
  campaignId = adMetadata["campaign_id"] as? String,
395
405
  lineItemId = adMetadata["line_item_id"] as? String,
@@ -497,6 +507,7 @@ internal class GoogleAdsAdapter(
497
507
  analyticsClient.trackAdImpression(
498
508
  placementId = placementConfig.placementId,
499
509
  format = placementConfig.format,
510
+ gamAdUnit = placementConfig.gamAdUnit,
500
511
  advertiserId = adMetadata["advertiser_id"] as? String,
501
512
  campaignId = adMetadata["campaign_id"] as? String,
502
513
  lineItemId = adMetadata["line_item_id"] as? String,
@@ -8,6 +8,7 @@ import com.bigcrunch.ads.adapters.GoogleAdsAdapter
8
8
  import com.bigcrunch.ads.adapters.InterstitialAdCallback
9
9
  import com.bigcrunch.ads.adapters.RewardedAdCallback
10
10
  import com.bigcrunch.ads.internal.BCLogger
11
+ import com.bigcrunch.ads.models.AuctionData
11
12
  import com.bigcrunch.ads.models.PlacementConfig
12
13
  import com.google.android.gms.ads.admanager.AdManagerAdRequest
13
14
  import com.google.android.gms.ads.admanager.AdManagerAdView
@@ -164,16 +165,24 @@ internal class AdOrchestrator(
164
165
  val adRequestBuilder = AdManagerAdRequest.Builder()
165
166
 
166
167
  // 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) {
168
+ val bidResponse = bidRequestClient.fetchDemand(placement)
169
+ if (bidResponse != null && bidResponse.targeting.isNotEmpty()) {
170
+ for ((key, value) in bidResponse.targeting) {
170
171
  adRequestBuilder.addCustomTargeting(key, value)
171
172
  }
172
- BCLogger.d(TAG, "S2S demand fetched for: ${placement.placementId} (${targeting.size} keys)")
173
+ BCLogger.d(TAG, "S2S demand fetched for: ${placement.placementId} (${bidResponse.targeting.size} keys)")
173
174
  } else {
174
175
  BCLogger.w(TAG, "S2S demand fetch returned no targeting, continuing with Google only")
175
176
  }
176
177
 
178
+ // Store auction data for analytics (or default to GAM if no S2S)
179
+ val auctionData = bidResponse?.auctionData ?: AuctionData(
180
+ bidder = "google_ad_exchange",
181
+ floorPrice = placement.floorPrice,
182
+ demandChannel = "Google Ad Exchange"
183
+ )
184
+ analyticsClient.setAuctionData(placement.placementId, auctionData)
185
+
177
186
  // 4. Load Google ad with the (possibly enriched) ad request
178
187
  val adRequest = adRequestBuilder.build()
179
188
  val adapterCallback = object : BannerAdCallback {
@@ -292,16 +301,24 @@ internal class AdOrchestrator(
292
301
  val adRequestBuilder = AdManagerAdRequest.Builder()
293
302
 
294
303
  // 3. Fetch S2S demand
295
- val targeting = bidRequestClient.fetchDemand(placement)
296
- if (targeting != null && targeting.isNotEmpty()) {
297
- for ((key, value) in targeting) {
304
+ val bidResponse = bidRequestClient.fetchDemand(placement)
305
+ if (bidResponse != null && bidResponse.targeting.isNotEmpty()) {
306
+ for ((key, value) in bidResponse.targeting) {
298
307
  adRequestBuilder.addCustomTargeting(key, value)
299
308
  }
300
- BCLogger.d(TAG, "S2S demand fetched for: ${placement.placementId} (${targeting.size} keys)")
309
+ BCLogger.d(TAG, "S2S demand fetched for: ${placement.placementId} (${bidResponse.targeting.size} keys)")
301
310
  } else {
302
311
  BCLogger.w(TAG, "S2S demand fetch returned no targeting, continuing with Google only")
303
312
  }
304
313
 
314
+ // Store auction data for analytics (or default to GAM if no S2S)
315
+ val auctionData = bidResponse?.auctionData ?: AuctionData(
316
+ bidder = "google_ad_exchange",
317
+ floorPrice = placement.floorPrice,
318
+ demandChannel = "Google Ad Exchange"
319
+ )
320
+ analyticsClient.setAuctionData(placement.placementId, auctionData)
321
+
305
322
  // Get test ads flag from config
306
323
  val useTestAds = configManager.shouldUseTestAds()
307
324
 
@@ -466,16 +483,24 @@ internal class AdOrchestrator(
466
483
  val adRequestBuilder = AdManagerAdRequest.Builder()
467
484
 
468
485
  // 3. Fetch S2S demand
469
- val targeting = bidRequestClient.fetchDemand(placement)
470
- if (targeting != null && targeting.isNotEmpty()) {
471
- for ((key, value) in targeting) {
486
+ val bidResponse = bidRequestClient.fetchDemand(placement)
487
+ if (bidResponse != null && bidResponse.targeting.isNotEmpty()) {
488
+ for ((key, value) in bidResponse.targeting) {
472
489
  adRequestBuilder.addCustomTargeting(key, value)
473
490
  }
474
- BCLogger.d(TAG, "S2S demand fetched for: ${placement.placementId} (${targeting.size} keys)")
491
+ BCLogger.d(TAG, "S2S demand fetched for: ${placement.placementId} (${bidResponse.targeting.size} keys)")
475
492
  } else {
476
493
  BCLogger.w(TAG, "S2S demand fetch returned no targeting, continuing with Google only")
477
494
  }
478
495
 
496
+ // Store auction data for analytics (or default to GAM if no S2S)
497
+ val auctionData = bidResponse?.auctionData ?: AuctionData(
498
+ bidder = "google_ad_exchange",
499
+ floorPrice = placement.floorPrice,
500
+ demandChannel = "Google Ad Exchange"
501
+ )
502
+ analyticsClient.setAuctionData(placement.placementId, auctionData)
503
+
479
504
  // Get test ads flag from config
480
505
  val useTestAds = configManager.shouldUseTestAds()
481
506
 
@@ -50,16 +50,33 @@ internal class AnalyticsClient(
50
50
  /** Track impression contexts for correlating events */
51
51
  private val impressionContexts = ConcurrentHashMap<String, ImpressionContext>()
52
52
 
53
+ /** Per-placement auction data from S2S responses (set by AdOrchestrator, consumed by trackAdImpression) */
54
+ private val placementAuctionData = ConcurrentHashMap<String, AuctionData>()
55
+
56
+ /** Batching for impression events */
57
+ private val impressionBatch = mutableListOf<ImpressionBatchEvent>()
58
+ private var impressionBatchHandler: android.os.Handler? = null
59
+ private val impressionBatchLock = Any()
60
+
53
61
  /** Batching for viewability events */
54
62
  private val viewabilityBatch = mutableListOf<ViewabilityEvent>()
55
63
  private var viewabilityBatchHandler: android.os.Handler? = null
56
- private val batchLock = Any()
64
+ private val viewabilityBatchLock = Any()
65
+
57
66
  private val BATCH_DELAY_MS = 250L
58
67
 
59
68
  /** Previous screen name for referrer tracking */
60
69
  @Volatile
61
70
  private var previousScreenName: String? = null
62
71
 
72
+ /** Current page URL (set by trackScreenView, used by all events) */
73
+ @Volatile
74
+ private var currentPageUrl: String = ""
75
+
76
+ /** Current custom dimensions (set by trackScreenView, used by all events) */
77
+ @Volatile
78
+ private var currentCustomDimensions: Map<String, String> = emptyMap()
79
+
63
80
  // MARK: - Session Context
64
81
 
65
82
  private val sessionManager: SessionManager
@@ -177,15 +194,37 @@ internal class AnalyticsClient(
177
194
 
178
195
  // MARK: - Page View Tracking
179
196
 
197
+ /**
198
+ * Generate the default page URL from screen name
199
+ */
200
+ private fun generatePageUrl(screenName: String): String {
201
+ val appName = try {
202
+ BigCrunchAds.getAppConfig()?.appName ?: "app"
203
+ } catch (e: Exception) {
204
+ "app"
205
+ }
206
+ val sanitizedAppName = appName.lowercase()
207
+ .replace(" ", "-")
208
+ .let { java.net.URLEncoder.encode(it, "UTF-8") }
209
+ val encodedScreen = java.net.URLEncoder.encode(screenName, "UTF-8")
210
+ return "https://$sanitizedAppName.mobile.app/$encodedScreen"
211
+ }
212
+
180
213
  /**
181
214
  * Track a screen view
182
215
  *
183
216
  * @param screenName Name of the screen being viewed
217
+ * @param options Optional overrides for page URL, metadata, and custom dimensions
184
218
  */
185
- fun trackScreenView(screenName: String) {
219
+ fun trackScreenView(screenName: String, options: ScreenViewOptions? = null) {
186
220
  // Start new page view and get IDs (pass screen name for impression tracking)
187
221
  val pageId = sessionManager.startPageView(screenName)
188
222
 
223
+ // Resolve page URL: explicit override > auto-generated
224
+ val pageUrl = options?.pageUrl ?: generatePageUrl(screenName)
225
+ currentPageUrl = pageUrl
226
+ currentCustomDimensions = options?.customDimensions ?: emptyMap()
227
+
189
228
  // Get common event fields
190
229
  val common = getCommonEventFields()
191
230
 
@@ -201,7 +240,7 @@ internal class AnalyticsClient(
201
240
 
202
241
  val event = PageViewEvent(
203
242
  payloadVersion = "1.0.0",
204
- configVersion = 1, // TODO: Get from ConfigManager when available
243
+ configVersion = 1,
205
244
  browserTimestamp = sessionManager.getCurrentTimestamp(),
206
245
  sessionId = sessionManager.sessionId,
207
246
  userId = sessionManager.userId,
@@ -209,9 +248,9 @@ internal class AnalyticsClient(
209
248
  newUser = sessionManager.isNewUser,
210
249
  pageId = pageId,
211
250
  sessionDepth = sessionManager.sessionDepth,
212
- pageUrl = "", // page_url must be valid URL or empty - mobile doesn't have URLs
213
- pageSearch = "", // Not applicable for mobile
214
- pageReferrer = "", // page_referrer must be valid URL or empty
251
+ pageUrl = pageUrl,
252
+ pageSearch = "",
253
+ pageReferrer = "",
215
254
  browser = common.browser,
216
255
  device = common.device,
217
256
  os = common.os,
@@ -229,8 +268,9 @@ internal class AnalyticsClient(
229
268
  acctType = "anonymous",
230
269
  diiSource = "",
231
270
  gamNetworkCode = gamNetworkCode,
232
- amznPubId = NIL_UUID, // amzn_pub_id must be valid UUID
233
- customDimensions = emptyMap()
271
+ amznPubId = NIL_UUID,
272
+ customDimensions = currentCustomDimensions,
273
+ pageMetaData = options?.pageMeta
234
274
  )
235
275
 
236
276
  sendEvent(event, pageViewAdapter, "pageviews")
@@ -286,6 +326,32 @@ internal class AnalyticsClient(
286
326
  return impressionContexts[impressionId]
287
327
  }
288
328
 
329
+ // MARK: - Auction Data Storage
330
+
331
+ /**
332
+ * Store auction data for a placement (called by AdOrchestrator after S2S demand fetch)
333
+ */
334
+ fun setAuctionData(placementId: String, auctionData: AuctionData) {
335
+ placementAuctionData[placementId] = auctionData
336
+ BCLogger.d(TAG, "Stored auction data for placement: $placementId (channel: ${auctionData.demandChannel ?: "unknown"})")
337
+ }
338
+
339
+ /**
340
+ * Consume auction data for a placement (returns and removes stored data)
341
+ */
342
+ private fun consumeAuctionData(placementId: String): AuctionData? {
343
+ return placementAuctionData.remove(placementId)
344
+ }
345
+
346
+ /**
347
+ * Compute min_bid_to_win: $0.01 above the higher of second-highest bid or floor price
348
+ */
349
+ private fun computeMinBidToWin(auctionData: AuctionData?): Double {
350
+ if (auctionData == null) return 0.0
351
+ val baseline = maxOf(auctionData.secondHighestBid ?: 0.0, auctionData.floorPrice ?: 0.0)
352
+ return if (baseline > 0) baseline + 0.01 else 0.0
353
+ }
354
+
289
355
  // MARK: - Enhanced Impression Tracking
290
356
 
291
357
  /**
@@ -316,16 +382,25 @@ internal class AnalyticsClient(
316
382
  val auctionId = context.auctionData.auctionId?.takeIf { it.isNotEmpty() }
317
383
  ?: UUID.randomUUID().toString()
318
384
 
319
- // Create impression record with all fields (defaults to empty string/0.0)
385
+ // Look up stored auction data (may have been set by AdOrchestrator)
386
+ val storedAuctionData = consumeAuctionData(context.placementId)
387
+ val effectiveAuctionData = storedAuctionData ?: context.auctionData
388
+
389
+ // Create impression record with auction data fields populated
320
390
  val impressionRecord = ImpressionRecord(
321
391
  slotId = context.placementId,
322
392
  gamUnit = context.gamAdUnit,
393
+ gamPriceBucket = effectiveAuctionData.gamPriceBucket ?: "",
323
394
  impressionId = context.impressionId,
324
395
  auctionId = auctionId,
325
- adBidder = context.auctionData.bidder ?: "",
396
+ adBidder = effectiveAuctionData.bidder ?: "",
326
397
  adSize = adSize,
327
- adPrice = context.auctionData.bidPriceCpm ?: 0.0,
328
- creativeId = context.auctionData.creativeId ?: ""
398
+ adPrice = effectiveAuctionData.bidPriceCpm ?: 0.0,
399
+ adFloorPrice = effectiveAuctionData.floorPrice ?: 0.0,
400
+ minBidToWin = computeMinBidToWin(effectiveAuctionData),
401
+ creativeId = effectiveAuctionData.creativeId ?: "",
402
+ adDemandType = context.format,
403
+ demandChannel = effectiveAuctionData.demandChannel ?: "Google Ad Exchange"
329
404
  )
330
405
 
331
406
  // region must be 2 chars or empty per backend validation
@@ -342,7 +417,7 @@ internal class AnalyticsClient(
342
417
  newUser = sessionManager.isNewUser,
343
418
  pageId = sessionManager.getOrCreatePageId(),
344
419
  sessionDepth = sessionManager.sessionDepth,
345
- pageUrl = "", // page_url must be valid URL or empty - mobile doesn't have URLs
420
+ pageUrl = currentPageUrl,
346
421
  pageSearch = "",
347
422
  pageReferrer = "",
348
423
  browser = common.browser,
@@ -362,21 +437,22 @@ internal class AnalyticsClient(
362
437
  acctType = "anonymous",
363
438
  diiSource = "",
364
439
  gamNetworkCode = getGamNetworkCode(),
365
- amznPubId = NIL_UUID, // amzn_pub_id must be valid UUID
366
- customDimensions = emptyMap(),
440
+ amznPubId = NIL_UUID,
441
+ customDimensions = currentCustomDimensions,
367
442
  impressions = listOf(impressionRecord)
368
443
  )
369
444
 
370
- sendEvent(batchEvent, impressionBatchAdapter, "impressions")
445
+ batchImpressionEvent(batchEvent)
371
446
  }
372
447
 
373
448
  /**
374
- * Track an ad impression (simple version without auction data)
449
+ * Track an ad impression (simple version with auction data lookup)
375
450
  */
376
451
  fun trackAdImpression(
377
452
  placementId: String,
378
453
  format: String,
379
454
  gamAdUnit: String = "",
455
+ adSize: String = "",
380
456
  advertiserId: String? = null,
381
457
  campaignId: String? = null,
382
458
  lineItemId: String? = null,
@@ -389,19 +465,31 @@ internal class AnalyticsClient(
389
465
  // Get common event fields
390
466
  val common = getCommonEventFields()
391
467
 
392
- // auction_id is required and must be a valid UUID
393
- val auctionId = UUID.randomUUID().toString()
468
+ // Look up stored auction data from S2S response
469
+ val auctionData = consumeAuctionData(placementId)
470
+
471
+ // auction_id: use S2S auction ID if available, otherwise generate one
472
+ val auctionId = auctionData?.auctionId?.takeIf { it.isNotEmpty() }
473
+ ?: UUID.randomUUID().toString()
394
474
 
395
- // Create impression record with all fields (defaults to empty string/0.0)
475
+ // Create impression record with auction data fields populated
396
476
  val impressionRecord = ImpressionRecord(
397
477
  slotId = placementId,
398
478
  gamUnit = gamAdUnit,
479
+ gamPriceBucket = auctionData?.gamPriceBucket ?: "",
399
480
  impressionId = UUID.randomUUID().toString(),
400
481
  auctionId = auctionId,
482
+ adBidder = auctionData?.bidder ?: "",
483
+ adSize = adSize,
484
+ adPrice = auctionData?.bidPriceCpm ?: 0.0,
485
+ adFloorPrice = auctionData?.floorPrice ?: 0.0,
486
+ minBidToWin = computeMinBidToWin(auctionData),
401
487
  advertiserId = advertiserId ?: "",
402
488
  campaignId = campaignId ?: "",
403
489
  lineItemId = lineItemId ?: "",
404
- creativeId = creativeId ?: "",
490
+ creativeId = auctionData?.creativeId ?: creativeId ?: "",
491
+ adDemandType = format,
492
+ demandChannel = auctionData?.demandChannel ?: "Google Ad Exchange",
405
493
  refreshCount = refreshCount
406
494
  )
407
495
 
@@ -419,7 +507,7 @@ internal class AnalyticsClient(
419
507
  newUser = sessionManager.isNewUser,
420
508
  pageId = sessionManager.getOrCreatePageId(),
421
509
  sessionDepth = sessionManager.sessionDepth,
422
- pageUrl = "", // page_url must be valid URL or empty - mobile doesn't have URLs
510
+ pageUrl = currentPageUrl,
423
511
  pageSearch = "",
424
512
  pageReferrer = "",
425
513
  browser = common.browser,
@@ -439,12 +527,12 @@ internal class AnalyticsClient(
439
527
  acctType = "anonymous",
440
528
  diiSource = "",
441
529
  gamNetworkCode = getGamNetworkCode(),
442
- amznPubId = NIL_UUID, // amzn_pub_id must be valid UUID
443
- customDimensions = emptyMap(),
530
+ amznPubId = NIL_UUID,
531
+ customDimensions = currentCustomDimensions,
444
532
  impressions = listOf(impressionRecord)
445
533
  )
446
534
 
447
- sendEvent(batchEvent, impressionBatchAdapter, "impressions")
535
+ batchImpressionEvent(batchEvent)
448
536
  }
449
537
 
450
538
  // MARK: - Click Tracking
@@ -480,7 +568,7 @@ internal class AnalyticsClient(
480
568
  newUser = sessionManager.isNewUser,
481
569
  pageId = sessionManager.getOrCreatePageId(),
482
570
  sessionDepth = sessionManager.sessionDepth,
483
- pageUrl = "", // page_url must be valid URL or empty - mobile doesn't have URLs
571
+ pageUrl = currentPageUrl,
484
572
  pageSearch = "",
485
573
  pageReferrer = "",
486
574
  browser = common.browser,
@@ -500,8 +588,8 @@ internal class AnalyticsClient(
500
588
  acctType = "anonymous",
501
589
  diiSource = "",
502
590
  gamNetworkCode = getGamNetworkCode(),
503
- amznPubId = NIL_UUID, // amzn_pub_id must be valid UUID
504
- customDimensions = emptyMap(),
591
+ amznPubId = NIL_UUID,
592
+ customDimensions = currentCustomDimensions,
505
593
  click = clickData
506
594
  )
507
595
 
@@ -555,7 +643,7 @@ internal class AnalyticsClient(
555
643
  newUser = sessionManager.isNewUser,
556
644
  pageId = sessionManager.getOrCreatePageId(),
557
645
  sessionDepth = sessionManager.sessionDepth,
558
- pageUrl = "", // page_url must be valid URL or empty - mobile doesn't have URLs
646
+ pageUrl = currentPageUrl,
559
647
  pageSearch = "",
560
648
  pageReferrer = "",
561
649
  browser = common.browser,
@@ -575,8 +663,8 @@ internal class AnalyticsClient(
575
663
  acctType = "anonymous",
576
664
  diiSource = "",
577
665
  gamNetworkCode = getGamNetworkCode(),
578
- amznPubId = NIL_UUID, // amzn_pub_id must be valid UUID
579
- customDimensions = emptyMap(),
666
+ amznPubId = NIL_UUID,
667
+ customDimensions = currentCustomDimensions,
580
668
  viewability = listOf(viewabilityData)
581
669
  )
582
670
 
@@ -616,7 +704,7 @@ internal class AnalyticsClient(
616
704
  newUser = sessionManager.isNewUser,
617
705
  pageId = sessionManager.getOrCreatePageId(),
618
706
  sessionDepth = sessionManager.sessionDepth,
619
- pageUrl = "", // page_url must be valid URL or empty - mobile doesn't have URLs
707
+ pageUrl = currentPageUrl,
620
708
  pageSearch = "",
621
709
  pageReferrer = "",
622
710
  browser = common.browser,
@@ -636,8 +724,8 @@ internal class AnalyticsClient(
636
724
  acctType = "anonymous",
637
725
  diiSource = "",
638
726
  gamNetworkCode = getGamNetworkCode(),
639
- amznPubId = NIL_UUID, // amzn_pub_id must be valid UUID
640
- customDimensions = emptyMap(),
727
+ amznPubId = NIL_UUID,
728
+ customDimensions = emptyMap(), // Engagement uses array-valued custom dimensions per backend schema
641
729
  engagedTime = engagedTime,
642
730
  timeOnPage = timeOnPage,
643
731
  scrollDepth = scrollDepth
@@ -678,11 +766,66 @@ internal class AnalyticsClient(
678
766
 
679
767
  // MARK: - Batching Helpers
680
768
 
769
+ /**
770
+ * Batch an impression event (250ms delay)
771
+ */
772
+ private fun batchImpressionEvent(event: ImpressionBatchEvent) {
773
+ synchronized(impressionBatchLock) {
774
+ impressionBatch.add(event)
775
+
776
+ // Cancel existing timer
777
+ impressionBatchHandler?.removeCallbacksAndMessages(null)
778
+
779
+ // Create new handler if needed
780
+ if (impressionBatchHandler == null) {
781
+ impressionBatchHandler = android.os.Handler(android.os.Looper.getMainLooper())
782
+ }
783
+
784
+ // Schedule flush after 250ms
785
+ impressionBatchHandler?.postDelayed({
786
+ flushImpressionBatch()
787
+ }, BATCH_DELAY_MS)
788
+ }
789
+ }
790
+
791
+ /**
792
+ * Flush impression batch to backend
793
+ *
794
+ * Merges events with the same pageId into a single object with combined impressions array.
795
+ */
796
+ private fun flushImpressionBatch() {
797
+ synchronized(impressionBatchLock) {
798
+ if (impressionBatch.isEmpty()) return
799
+
800
+ val events = impressionBatch.toList()
801
+ impressionBatch.clear()
802
+
803
+ // Group by pageId and merge impressions into single objects
804
+ val merged = mutableMapOf<String, ImpressionBatchEvent>()
805
+ for (event in events) {
806
+ val existing = merged[event.pageId]
807
+ if (existing != null) {
808
+ merged[event.pageId] = existing.copy(
809
+ impressions = existing.impressions + event.impressions
810
+ )
811
+ } else {
812
+ merged[event.pageId] = event
813
+ }
814
+ }
815
+
816
+ val mergedEvents = merged.values.toList()
817
+
818
+ BCLogger.d(TAG, "Flushing ${mergedEvents.size} impression groups (${events.size} total impressions)")
819
+
820
+ sendEventBatch(mergedEvents, "impressions")
821
+ }
822
+ }
823
+
681
824
  /**
682
825
  * Batch a viewability event (250ms delay)
683
826
  */
684
827
  private fun batchViewabilityEvent(event: ViewabilityEvent) {
685
- synchronized(batchLock) {
828
+ synchronized(viewabilityBatchLock) {
686
829
  viewabilityBatch.add(event)
687
830
 
688
831
  // Cancel existing timer
@@ -702,18 +845,34 @@ internal class AnalyticsClient(
702
845
 
703
846
  /**
704
847
  * Flush viewability batch to backend
848
+ *
849
+ * Merges events with the same pageId into a single object with combined viewability array.
705
850
  */
706
851
  private fun flushViewabilityBatch() {
707
- synchronized(batchLock) {
852
+ synchronized(viewabilityBatchLock) {
708
853
  if (viewabilityBatch.isEmpty()) return
709
854
 
710
- val eventsToSend = viewabilityBatch.toList()
855
+ val events = viewabilityBatch.toList()
711
856
  viewabilityBatch.clear()
712
857
 
713
- BCLogger.d(TAG, "Flushing ${eventsToSend.size} viewability events")
858
+ // Group by pageId and merge viewability into single objects
859
+ val merged = mutableMapOf<String, ViewabilityEvent>()
860
+ for (event in events) {
861
+ val existing = merged[event.pageId]
862
+ if (existing != null) {
863
+ merged[event.pageId] = existing.copy(
864
+ viewability = existing.viewability + event.viewability
865
+ )
866
+ } else {
867
+ merged[event.pageId] = event
868
+ }
869
+ }
870
+
871
+ val mergedEvents = merged.values.toList()
872
+
873
+ BCLogger.d(TAG, "Flushing ${mergedEvents.size} viewability groups (${events.size} total)")
714
874
 
715
- // Send events as a batch array
716
- sendEventBatch(eventsToSend, "viewability")
875
+ sendEventBatch(mergedEvents, "viewability")
717
876
  }
718
877
  }
719
878
  }