@bigcrunch/react-native-ads 0.11.0 → 0.14.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 (33) hide show
  1. package/android/bigcrunch-ads/com/bigcrunch/ads/BigCrunchAds.kt +21 -4
  2. package/android/bigcrunch-ads/com/bigcrunch/ads/adapters/GoogleAdsAdapter.kt +8 -1
  3. package/android/bigcrunch-ads/com/bigcrunch/ads/core/AdOrchestrator.kt +37 -12
  4. package/android/bigcrunch-ads/com/bigcrunch/ads/core/AnalyticsClient.kt +213 -46
  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 +57 -2
  10. package/ios/BigCrunchAds/Sources/Adapters/GoogleAdsAdapter.swift +7 -0
  11. package/ios/BigCrunchAds/Sources/BigCrunchAds.swift +29 -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 +193 -84
  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/Models/AdEvent.swift +111 -3
  18. package/ios/BigCrunchAds/Sources/Models/PlacementConfig.swift +4 -1
  19. package/ios/BigCrunchAdsModule.m +6 -0
  20. package/ios/BigCrunchAdsModule.swift +39 -2
  21. package/ios/BigCrunchBannerViewManager.swift +1 -0
  22. package/lib/BigCrunchAds.d.ts +33 -2
  23. package/lib/BigCrunchAds.d.ts.map +1 -1
  24. package/lib/BigCrunchAds.js +35 -2
  25. package/lib/NativeBigCrunchAds.d.ts +3 -2
  26. package/lib/NativeBigCrunchAds.d.ts.map +1 -1
  27. package/lib/types/index.d.ts +40 -0
  28. package/lib/types/index.d.ts.map +1 -1
  29. package/package.json +1 -1
  30. package/react-native-bigcrunch-ads.podspec +1 -1
  31. package/src/BigCrunchAds.ts +41 -2
  32. package/src/NativeBigCrunchAds.ts +5 -2
  33. package/src/types/index.ts +43 -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
  /**
@@ -338,6 +341,20 @@ object BigCrunchAds {
338
341
  DeviceHelper.getDeviceData(appContext)
339
342
  }
340
343
 
344
+ /**
345
+ * Set the account type for the current user
346
+ *
347
+ * Included in all analytics events. Defaults to "guest" if not set.
348
+ * Valid values: "guest", "logged_in", "paid", "subscriber", "free"
349
+ *
350
+ * @param accountType The user's account type
351
+ */
352
+ fun setAccountType(accountType: String) = runSafely {
353
+ requireInitialized()
354
+ analyticsClient.setAccountType(accountType)
355
+ BCLogger.d(TAG, "Account type set to: $accountType")
356
+ }
357
+
341
358
  /**
342
359
  * Set GDPR consent string for privacy compliance
343
360
  *
@@ -230,12 +230,17 @@ internal class GoogleAdsAdapter(
230
230
 
231
231
  // Extract GAM metadata from ResponseInfo
232
232
  val responseInfo = adView.responseInfo
233
- val advertiserId = responseInfo?.loadedAdapterResponseInfo?.adSourceId
234
233
  val adMetadata = extractAdMetadata(responseInfo)
235
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
+
236
239
  analyticsClient.trackAdImpression(
237
240
  placementId = placementConfig.placementId,
238
241
  format = placementConfig.format,
242
+ gamAdUnit = placementConfig.gamAdUnit,
243
+ adSize = adSizeString,
239
244
  advertiserId = adMetadata["advertiser_id"] as? String,
240
245
  campaignId = adMetadata["campaign_id"] as? String,
241
246
  lineItemId = adMetadata["line_item_id"] as? String,
@@ -394,6 +399,7 @@ internal class GoogleAdsAdapter(
394
399
  analyticsClient.trackAdImpression(
395
400
  placementId = placementConfig.placementId,
396
401
  format = placementConfig.format,
402
+ gamAdUnit = placementConfig.gamAdUnit,
397
403
  advertiserId = adMetadata["advertiser_id"] as? String,
398
404
  campaignId = adMetadata["campaign_id"] as? String,
399
405
  lineItemId = adMetadata["line_item_id"] as? String,
@@ -501,6 +507,7 @@ internal class GoogleAdsAdapter(
501
507
  analyticsClient.trackAdImpression(
502
508
  placementId = placementConfig.placementId,
503
509
  format = placementConfig.format,
510
+ gamAdUnit = placementConfig.gamAdUnit,
504
511
  advertiserId = adMetadata["advertiser_id"] as? String,
505
512
  campaignId = adMetadata["campaign_id"] as? String,
506
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,37 @@ 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
+
80
+ /** Current account type (set by setAccountType, used by all events) */
81
+ @Volatile
82
+ private var currentAcctType: String = "guest"
83
+
63
84
  // MARK: - Session Context
64
85
 
65
86
  private val sessionManager: SessionManager
@@ -177,15 +198,37 @@ internal class AnalyticsClient(
177
198
 
178
199
  // MARK: - Page View Tracking
179
200
 
201
+ /**
202
+ * Generate the default page URL from screen name
203
+ */
204
+ private fun generatePageUrl(screenName: String): String {
205
+ val appName = try {
206
+ BigCrunchAds.getAppConfig()?.appName ?: "app"
207
+ } catch (e: Exception) {
208
+ "app"
209
+ }
210
+ val sanitizedAppName = appName.lowercase()
211
+ .replace(" ", "-")
212
+ .let { java.net.URLEncoder.encode(it, "UTF-8") }
213
+ val encodedScreen = java.net.URLEncoder.encode(screenName, "UTF-8")
214
+ return "https://$sanitizedAppName.mobile.app/$encodedScreen"
215
+ }
216
+
180
217
  /**
181
218
  * Track a screen view
182
219
  *
183
220
  * @param screenName Name of the screen being viewed
221
+ * @param options Optional overrides for page URL, metadata, and custom dimensions
184
222
  */
185
- fun trackScreenView(screenName: String) {
223
+ fun trackScreenView(screenName: String, options: ScreenViewOptions? = null) {
186
224
  // Start new page view and get IDs (pass screen name for impression tracking)
187
225
  val pageId = sessionManager.startPageView(screenName)
188
226
 
227
+ // Resolve page URL: explicit override > auto-generated
228
+ val pageUrl = options?.pageUrl ?: generatePageUrl(screenName)
229
+ currentPageUrl = pageUrl
230
+ currentCustomDimensions = options?.customDimensions ?: emptyMap()
231
+
189
232
  // Get common event fields
190
233
  val common = getCommonEventFields()
191
234
 
@@ -201,7 +244,7 @@ internal class AnalyticsClient(
201
244
 
202
245
  val event = PageViewEvent(
203
246
  payloadVersion = "1.0.0",
204
- configVersion = 1, // TODO: Get from ConfigManager when available
247
+ configVersion = 1,
205
248
  browserTimestamp = sessionManager.getCurrentTimestamp(),
206
249
  sessionId = sessionManager.sessionId,
207
250
  userId = sessionManager.userId,
@@ -209,9 +252,9 @@ internal class AnalyticsClient(
209
252
  newUser = sessionManager.isNewUser,
210
253
  pageId = pageId,
211
254
  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
255
+ pageUrl = pageUrl,
256
+ pageSearch = "",
257
+ pageReferrer = "",
215
258
  browser = common.browser,
216
259
  device = common.device,
217
260
  os = common.os,
@@ -226,11 +269,12 @@ internal class AnalyticsClient(
226
269
  utmContent = common.utmContent ?: "",
227
270
  gclid = "",
228
271
  fbclid = "",
229
- acctType = "anonymous",
272
+ acctType = currentAcctType,
230
273
  diiSource = "",
231
274
  gamNetworkCode = gamNetworkCode,
232
- amznPubId = NIL_UUID, // amzn_pub_id must be valid UUID
233
- customDimensions = emptyMap()
275
+ amznPubId = NIL_UUID,
276
+ customDimensions = currentCustomDimensions,
277
+ pageMetaData = options?.pageMeta
234
278
  )
235
279
 
236
280
  sendEvent(event, pageViewAdapter, "pageviews")
@@ -286,6 +330,36 @@ internal class AnalyticsClient(
286
330
  return impressionContexts[impressionId]
287
331
  }
288
332
 
333
+ // MARK: - Auction Data Storage
334
+
335
+ /**
336
+ * Store auction data for a placement (called by AdOrchestrator after S2S demand fetch)
337
+ */
338
+ fun setAccountType(accountType: String) {
339
+ currentAcctType = accountType
340
+ }
341
+
342
+ fun setAuctionData(placementId: String, auctionData: AuctionData) {
343
+ placementAuctionData[placementId] = auctionData
344
+ BCLogger.d(TAG, "Stored auction data for placement: $placementId (channel: ${auctionData.demandChannel ?: "unknown"})")
345
+ }
346
+
347
+ /**
348
+ * Consume auction data for a placement (returns and removes stored data)
349
+ */
350
+ private fun consumeAuctionData(placementId: String): AuctionData? {
351
+ return placementAuctionData.remove(placementId)
352
+ }
353
+
354
+ /**
355
+ * Compute min_bid_to_win: $0.01 above the higher of second-highest bid or floor price
356
+ */
357
+ private fun computeMinBidToWin(auctionData: AuctionData?): Double {
358
+ if (auctionData == null) return 0.0
359
+ val baseline = maxOf(auctionData.secondHighestBid ?: 0.0, auctionData.floorPrice ?: 0.0)
360
+ return if (baseline > 0) baseline + 0.01 else 0.0
361
+ }
362
+
289
363
  // MARK: - Enhanced Impression Tracking
290
364
 
291
365
  /**
@@ -316,16 +390,25 @@ internal class AnalyticsClient(
316
390
  val auctionId = context.auctionData.auctionId?.takeIf { it.isNotEmpty() }
317
391
  ?: UUID.randomUUID().toString()
318
392
 
319
- // Create impression record with all fields (defaults to empty string/0.0)
393
+ // Look up stored auction data (may have been set by AdOrchestrator)
394
+ val storedAuctionData = consumeAuctionData(context.placementId)
395
+ val effectiveAuctionData = storedAuctionData ?: context.auctionData
396
+
397
+ // Create impression record with auction data fields populated
320
398
  val impressionRecord = ImpressionRecord(
321
399
  slotId = context.placementId,
322
400
  gamUnit = context.gamAdUnit,
401
+ gamPriceBucket = effectiveAuctionData.gamPriceBucket ?: "",
323
402
  impressionId = context.impressionId,
324
403
  auctionId = auctionId,
325
- adBidder = context.auctionData.bidder ?: "",
404
+ adBidder = effectiveAuctionData.bidder ?: "",
326
405
  adSize = adSize,
327
- adPrice = context.auctionData.bidPriceCpm ?: 0.0,
328
- creativeId = context.auctionData.creativeId ?: ""
406
+ adPrice = effectiveAuctionData.bidPriceCpm ?: 0.0,
407
+ adFloorPrice = effectiveAuctionData.floorPrice ?: 0.0,
408
+ minBidToWin = computeMinBidToWin(effectiveAuctionData),
409
+ creativeId = effectiveAuctionData.creativeId ?: "",
410
+ adDemandType = context.format,
411
+ demandChannel = effectiveAuctionData.demandChannel ?: "Google Ad Exchange"
329
412
  )
330
413
 
331
414
  // region must be 2 chars or empty per backend validation
@@ -342,7 +425,7 @@ internal class AnalyticsClient(
342
425
  newUser = sessionManager.isNewUser,
343
426
  pageId = sessionManager.getOrCreatePageId(),
344
427
  sessionDepth = sessionManager.sessionDepth,
345
- pageUrl = "", // page_url must be valid URL or empty - mobile doesn't have URLs
428
+ pageUrl = currentPageUrl,
346
429
  pageSearch = "",
347
430
  pageReferrer = "",
348
431
  browser = common.browser,
@@ -359,24 +442,25 @@ internal class AnalyticsClient(
359
442
  utmContent = common.utmContent ?: "",
360
443
  gclid = "",
361
444
  fbclid = "",
362
- acctType = "anonymous",
445
+ acctType = currentAcctType,
363
446
  diiSource = "",
364
447
  gamNetworkCode = getGamNetworkCode(),
365
- amznPubId = NIL_UUID, // amzn_pub_id must be valid UUID
366
- customDimensions = emptyMap(),
448
+ amznPubId = NIL_UUID,
449
+ customDimensions = currentCustomDimensions,
367
450
  impressions = listOf(impressionRecord)
368
451
  )
369
452
 
370
- sendEvent(batchEvent, impressionBatchAdapter, "impressions")
453
+ batchImpressionEvent(batchEvent)
371
454
  }
372
455
 
373
456
  /**
374
- * Track an ad impression (simple version without auction data)
457
+ * Track an ad impression (simple version with auction data lookup)
375
458
  */
376
459
  fun trackAdImpression(
377
460
  placementId: String,
378
461
  format: String,
379
462
  gamAdUnit: String = "",
463
+ adSize: String = "",
380
464
  advertiserId: String? = null,
381
465
  campaignId: String? = null,
382
466
  lineItemId: String? = null,
@@ -389,19 +473,31 @@ internal class AnalyticsClient(
389
473
  // Get common event fields
390
474
  val common = getCommonEventFields()
391
475
 
392
- // auction_id is required and must be a valid UUID
393
- val auctionId = UUID.randomUUID().toString()
476
+ // Look up stored auction data from S2S response
477
+ val auctionData = consumeAuctionData(placementId)
394
478
 
395
- // Create impression record with all fields (defaults to empty string/0.0)
479
+ // auction_id: use S2S auction ID if available, otherwise generate one
480
+ val auctionId = auctionData?.auctionId?.takeIf { it.isNotEmpty() }
481
+ ?: UUID.randomUUID().toString()
482
+
483
+ // Create impression record with auction data fields populated
396
484
  val impressionRecord = ImpressionRecord(
397
485
  slotId = placementId,
398
486
  gamUnit = gamAdUnit,
487
+ gamPriceBucket = auctionData?.gamPriceBucket ?: "",
399
488
  impressionId = UUID.randomUUID().toString(),
400
489
  auctionId = auctionId,
490
+ adBidder = auctionData?.bidder ?: "",
491
+ adSize = adSize,
492
+ adPrice = auctionData?.bidPriceCpm ?: 0.0,
493
+ adFloorPrice = auctionData?.floorPrice ?: 0.0,
494
+ minBidToWin = computeMinBidToWin(auctionData),
401
495
  advertiserId = advertiserId ?: "",
402
496
  campaignId = campaignId ?: "",
403
497
  lineItemId = lineItemId ?: "",
404
- creativeId = creativeId ?: "",
498
+ creativeId = auctionData?.creativeId ?: creativeId ?: "",
499
+ adDemandType = format,
500
+ demandChannel = auctionData?.demandChannel ?: "Google Ad Exchange",
405
501
  refreshCount = refreshCount
406
502
  )
407
503
 
@@ -419,7 +515,7 @@ internal class AnalyticsClient(
419
515
  newUser = sessionManager.isNewUser,
420
516
  pageId = sessionManager.getOrCreatePageId(),
421
517
  sessionDepth = sessionManager.sessionDepth,
422
- pageUrl = "", // page_url must be valid URL or empty - mobile doesn't have URLs
518
+ pageUrl = currentPageUrl,
423
519
  pageSearch = "",
424
520
  pageReferrer = "",
425
521
  browser = common.browser,
@@ -436,15 +532,15 @@ internal class AnalyticsClient(
436
532
  utmContent = common.utmContent ?: "",
437
533
  gclid = "",
438
534
  fbclid = "",
439
- acctType = "anonymous",
535
+ acctType = currentAcctType,
440
536
  diiSource = "",
441
537
  gamNetworkCode = getGamNetworkCode(),
442
- amznPubId = NIL_UUID, // amzn_pub_id must be valid UUID
443
- customDimensions = emptyMap(),
538
+ amznPubId = NIL_UUID,
539
+ customDimensions = currentCustomDimensions,
444
540
  impressions = listOf(impressionRecord)
445
541
  )
446
542
 
447
- sendEvent(batchEvent, impressionBatchAdapter, "impressions")
543
+ batchImpressionEvent(batchEvent)
448
544
  }
449
545
 
450
546
  // MARK: - Click Tracking
@@ -480,7 +576,7 @@ internal class AnalyticsClient(
480
576
  newUser = sessionManager.isNewUser,
481
577
  pageId = sessionManager.getOrCreatePageId(),
482
578
  sessionDepth = sessionManager.sessionDepth,
483
- pageUrl = "", // page_url must be valid URL or empty - mobile doesn't have URLs
579
+ pageUrl = currentPageUrl,
484
580
  pageSearch = "",
485
581
  pageReferrer = "",
486
582
  browser = common.browser,
@@ -497,11 +593,11 @@ internal class AnalyticsClient(
497
593
  utmContent = common.utmContent ?: "",
498
594
  gclid = "",
499
595
  fbclid = "",
500
- acctType = "anonymous",
596
+ acctType = currentAcctType,
501
597
  diiSource = "",
502
598
  gamNetworkCode = getGamNetworkCode(),
503
- amznPubId = NIL_UUID, // amzn_pub_id must be valid UUID
504
- customDimensions = emptyMap(),
599
+ amznPubId = NIL_UUID,
600
+ customDimensions = currentCustomDimensions,
505
601
  click = clickData
506
602
  )
507
603
 
@@ -555,7 +651,7 @@ internal class AnalyticsClient(
555
651
  newUser = sessionManager.isNewUser,
556
652
  pageId = sessionManager.getOrCreatePageId(),
557
653
  sessionDepth = sessionManager.sessionDepth,
558
- pageUrl = "", // page_url must be valid URL or empty - mobile doesn't have URLs
654
+ pageUrl = currentPageUrl,
559
655
  pageSearch = "",
560
656
  pageReferrer = "",
561
657
  browser = common.browser,
@@ -572,11 +668,11 @@ internal class AnalyticsClient(
572
668
  utmContent = common.utmContent ?: "",
573
669
  gclid = "",
574
670
  fbclid = "",
575
- acctType = "anonymous",
671
+ acctType = currentAcctType,
576
672
  diiSource = "",
577
673
  gamNetworkCode = getGamNetworkCode(),
578
- amznPubId = NIL_UUID, // amzn_pub_id must be valid UUID
579
- customDimensions = emptyMap(),
674
+ amznPubId = NIL_UUID,
675
+ customDimensions = currentCustomDimensions,
580
676
  viewability = listOf(viewabilityData)
581
677
  )
582
678
 
@@ -616,7 +712,7 @@ internal class AnalyticsClient(
616
712
  newUser = sessionManager.isNewUser,
617
713
  pageId = sessionManager.getOrCreatePageId(),
618
714
  sessionDepth = sessionManager.sessionDepth,
619
- pageUrl = "", // page_url must be valid URL or empty - mobile doesn't have URLs
715
+ pageUrl = currentPageUrl,
620
716
  pageSearch = "",
621
717
  pageReferrer = "",
622
718
  browser = common.browser,
@@ -633,11 +729,11 @@ internal class AnalyticsClient(
633
729
  utmContent = common.utmContent ?: "",
634
730
  gclid = "",
635
731
  fbclid = "",
636
- acctType = "anonymous",
732
+ acctType = currentAcctType,
637
733
  diiSource = "",
638
734
  gamNetworkCode = getGamNetworkCode(),
639
- amznPubId = NIL_UUID, // amzn_pub_id must be valid UUID
640
- customDimensions = emptyMap(),
735
+ amznPubId = NIL_UUID,
736
+ customDimensions = emptyMap(), // Engagement uses array-valued custom dimensions per backend schema
641
737
  engagedTime = engagedTime,
642
738
  timeOnPage = timeOnPage,
643
739
  scrollDepth = scrollDepth
@@ -678,11 +774,66 @@ internal class AnalyticsClient(
678
774
 
679
775
  // MARK: - Batching Helpers
680
776
 
777
+ /**
778
+ * Batch an impression event (250ms delay)
779
+ */
780
+ private fun batchImpressionEvent(event: ImpressionBatchEvent) {
781
+ synchronized(impressionBatchLock) {
782
+ impressionBatch.add(event)
783
+
784
+ // Cancel existing timer
785
+ impressionBatchHandler?.removeCallbacksAndMessages(null)
786
+
787
+ // Create new handler if needed
788
+ if (impressionBatchHandler == null) {
789
+ impressionBatchHandler = android.os.Handler(android.os.Looper.getMainLooper())
790
+ }
791
+
792
+ // Schedule flush after 250ms
793
+ impressionBatchHandler?.postDelayed({
794
+ flushImpressionBatch()
795
+ }, BATCH_DELAY_MS)
796
+ }
797
+ }
798
+
799
+ /**
800
+ * Flush impression batch to backend
801
+ *
802
+ * Merges events with the same pageId into a single object with combined impressions array.
803
+ */
804
+ private fun flushImpressionBatch() {
805
+ synchronized(impressionBatchLock) {
806
+ if (impressionBatch.isEmpty()) return
807
+
808
+ val events = impressionBatch.toList()
809
+ impressionBatch.clear()
810
+
811
+ // Group by pageId and merge impressions into single objects
812
+ val merged = mutableMapOf<String, ImpressionBatchEvent>()
813
+ for (event in events) {
814
+ val existing = merged[event.pageId]
815
+ if (existing != null) {
816
+ merged[event.pageId] = existing.copy(
817
+ impressions = existing.impressions + event.impressions
818
+ )
819
+ } else {
820
+ merged[event.pageId] = event
821
+ }
822
+ }
823
+
824
+ val mergedEvents = merged.values.toList()
825
+
826
+ BCLogger.d(TAG, "Flushing ${mergedEvents.size} impression groups (${events.size} total impressions)")
827
+
828
+ sendEventBatch(mergedEvents, "impressions")
829
+ }
830
+ }
831
+
681
832
  /**
682
833
  * Batch a viewability event (250ms delay)
683
834
  */
684
835
  private fun batchViewabilityEvent(event: ViewabilityEvent) {
685
- synchronized(batchLock) {
836
+ synchronized(viewabilityBatchLock) {
686
837
  viewabilityBatch.add(event)
687
838
 
688
839
  // Cancel existing timer
@@ -702,18 +853,34 @@ internal class AnalyticsClient(
702
853
 
703
854
  /**
704
855
  * Flush viewability batch to backend
856
+ *
857
+ * Merges events with the same pageId into a single object with combined viewability array.
705
858
  */
706
859
  private fun flushViewabilityBatch() {
707
- synchronized(batchLock) {
860
+ synchronized(viewabilityBatchLock) {
708
861
  if (viewabilityBatch.isEmpty()) return
709
862
 
710
- val eventsToSend = viewabilityBatch.toList()
863
+ val events = viewabilityBatch.toList()
711
864
  viewabilityBatch.clear()
712
865
 
713
- BCLogger.d(TAG, "Flushing ${eventsToSend.size} viewability events")
866
+ // Group by pageId and merge viewability into single objects
867
+ val merged = mutableMapOf<String, ViewabilityEvent>()
868
+ for (event in events) {
869
+ val existing = merged[event.pageId]
870
+ if (existing != null) {
871
+ merged[event.pageId] = existing.copy(
872
+ viewability = existing.viewability + event.viewability
873
+ )
874
+ } else {
875
+ merged[event.pageId] = event
876
+ }
877
+ }
878
+
879
+ val mergedEvents = merged.values.toList()
880
+
881
+ BCLogger.d(TAG, "Flushing ${mergedEvents.size} viewability groups (${events.size} total)")
714
882
 
715
- // Send events as a batch array
716
- sendEventBatch(eventsToSend, "viewability")
883
+ sendEventBatch(mergedEvents, "viewability")
717
884
  }
718
885
  }
719
886
  }