@bigcrunch/react-native-ads 0.4.0 → 0.6.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 (58) 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 +300 -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 +87 -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 +8 -2
  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 +305 -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 +97 -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 +37 -9
  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/package.json +7 -1
  56. package/react-native-bigcrunch-ads.podspec +0 -1
  57. package/scripts/inject-version.js +55 -0
  58. package/src/index.ts +3 -2
@@ -0,0 +1,719 @@
1
+ package com.bigcrunch.ads.core
2
+
3
+ import android.content.Context
4
+ import android.os.Build
5
+ import com.bigcrunch.ads.BigCrunchAds
6
+ import com.bigcrunch.ads.internal.BCLogger
7
+ import com.bigcrunch.ads.internal.HttpClient
8
+ import com.bigcrunch.ads.models.*
9
+ import com.squareup.moshi.Moshi
10
+ import kotlinx.coroutines.CoroutineDispatcher
11
+ import kotlinx.coroutines.CoroutineScope
12
+ import kotlinx.coroutines.Dispatchers
13
+ import kotlinx.coroutines.launch
14
+ import java.util.UUID
15
+ import java.util.concurrent.ConcurrentHashMap
16
+
17
+ /**
18
+ * Analytics client for tracking ad events
19
+ *
20
+ * AnalyticsClient sends all ad-related events to the BigCrunch pipeline:
21
+ * - Page/screen views → `/pageviews`
22
+ * - Ad impressions (includes request + auction + revenue) → `/impressions`
23
+ * - Ad clicks → `/clicks`
24
+ * - Ad viewability → `/viewability`
25
+ * - User engagement → `/engagement`
26
+ *
27
+ * All events are fire-and-forget (non-blocking) and sent asynchronously.
28
+ * Failures are logged but do not propagate to the caller.
29
+ */
30
+ internal class AnalyticsClient(
31
+ private val context: Context,
32
+ private val httpClient: HttpClient,
33
+ private val moshi: Moshi,
34
+ private val baseUrl: String,
35
+ dispatcher: CoroutineDispatcher = Dispatchers.IO
36
+ ) {
37
+ companion object {
38
+ private const val TAG = "AnalyticsClient"
39
+ // Nil UUID for fields that require UUID format but have no value
40
+ private const val NIL_UUID = "00000000-0000-0000-0000-000000000000"
41
+ }
42
+
43
+ private val scope = CoroutineScope(dispatcher)
44
+ private val pageViewAdapter = moshi.adapter(PageViewEvent::class.java)
45
+ private val impressionBatchAdapter = moshi.adapter(ImpressionBatchEvent::class.java)
46
+ private val clickAdapter = moshi.adapter(ClickEvent::class.java)
47
+ private val viewabilityAdapter = moshi.adapter(ViewabilityEvent::class.java)
48
+ private val engagementAdapter = moshi.adapter(EngagementEvent::class.java)
49
+
50
+ /** Track impression contexts for correlating events */
51
+ private val impressionContexts = ConcurrentHashMap<String, ImpressionContext>()
52
+
53
+ /** Batching for viewability events */
54
+ private val viewabilityBatch = mutableListOf<ViewabilityEvent>()
55
+ private var viewabilityBatchHandler: android.os.Handler? = null
56
+ private val batchLock = Any()
57
+ private val BATCH_DELAY_MS = 250L
58
+
59
+ /** Previous screen name for referrer tracking */
60
+ @Volatile
61
+ private var previousScreenName: String? = null
62
+
63
+ // MARK: - Session Context
64
+
65
+ private val sessionManager: SessionManager
66
+ get() = SessionManager.getInstance()
67
+
68
+ private val propertyId: String
69
+ get() = BigCrunchAds.propertyId
70
+
71
+ private val configVersion: String?
72
+ get() = null // TODO: Get from ConfigManager when available
73
+
74
+ // MARK: - Event Sending
75
+
76
+ /**
77
+ * Send an event to a specific endpoint (fire-and-forget)
78
+ * Wraps single event in an array as required by server
79
+ */
80
+ private fun <T> sendEvent(event: T, adapter: com.squareup.moshi.JsonAdapter<T>, endpoint: String) {
81
+ scope.launch {
82
+ try {
83
+ // Wrap single event in array as server expects arrays
84
+ val eventArray = listOf(event)
85
+ val listAdapter = moshi.adapter<List<T>>(
86
+ com.squareup.moshi.Types.newParameterizedType(List::class.java, event!!::class.java)
87
+ )
88
+ val json = listAdapter.toJson(eventArray)
89
+ val url = "$baseUrl/$endpoint"
90
+
91
+ BCLogger.d(TAG, "Sending event to: $endpoint")
92
+
93
+ val result = httpClient.post(url, json)
94
+
95
+ if (result.isSuccess) {
96
+ BCLogger.v(TAG, "Event sent successfully: $endpoint")
97
+ } else {
98
+ BCLogger.w(TAG, "Failed to send event: $endpoint - ${result.exceptionOrNull()?.message}")
99
+ }
100
+ } catch (e: Exception) {
101
+ BCLogger.e(TAG, "Error sending event", e)
102
+ }
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Send a batch of events to a specific endpoint (fire-and-forget)
108
+ */
109
+ private fun <T> sendEventBatch(events: List<T>, endpoint: String) {
110
+ scope.launch {
111
+ try {
112
+ if (events.isEmpty()) return@launch
113
+
114
+ // Get the type from the first event
115
+ val listAdapter = moshi.adapter<List<T>>(
116
+ com.squareup.moshi.Types.newParameterizedType(List::class.java, events.first()!!::class.java)
117
+ )
118
+ val json = listAdapter.toJson(events)
119
+ val url = "$baseUrl/$endpoint"
120
+
121
+ BCLogger.d(TAG, "Sending ${events.size} events to: $endpoint")
122
+
123
+ val result = httpClient.post(url, json)
124
+
125
+ if (result.isSuccess) {
126
+ BCLogger.v(TAG, "Event batch sent successfully: $endpoint")
127
+ } else {
128
+ BCLogger.w(TAG, "Failed to send event batch: $endpoint - ${result.exceptionOrNull()?.message}")
129
+ }
130
+ } catch (e: Exception) {
131
+ BCLogger.e(TAG, "Error sending event batch", e)
132
+ }
133
+ }
134
+ }
135
+
136
+ // MARK: - Common Event Fields
137
+
138
+ /**
139
+ * Get common web schema fields (device context, attribution, etc.)
140
+ *
141
+ * Returns a data class with all the common fields needed for events
142
+ */
143
+ private data class CommonEventFields(
144
+ val browser: String,
145
+ val device: String,
146
+ val os: String,
147
+ val country: String,
148
+ val region: String,
149
+ val sessionSource: String,
150
+ val sessionMedium: String,
151
+ val utmSource: String?,
152
+ val utmMedium: String?,
153
+ val utmCampaign: String?,
154
+ val utmTerm: String?,
155
+ val utmContent: String?
156
+ )
157
+
158
+ private fun getCommonEventFields(): CommonEventFields {
159
+ val deviceContext = DeviceContext.getInstance()
160
+ val webFields = deviceContext.getWebSchemaFields()
161
+
162
+ return CommonEventFields(
163
+ browser = webFields["browser"] as String,
164
+ device = webFields["device"] as String,
165
+ os = webFields["os"] as String,
166
+ country = webFields["country"] as String,
167
+ region = webFields["region"] as String,
168
+ sessionSource = sessionManager.sessionSource,
169
+ sessionMedium = sessionManager.sessionMedium,
170
+ utmSource = sessionManager.utmSource,
171
+ utmMedium = sessionManager.utmMedium,
172
+ utmCampaign = sessionManager.utmCampaign,
173
+ utmTerm = sessionManager.utmTerm,
174
+ utmContent = sessionManager.utmContent
175
+ )
176
+ }
177
+
178
+ // MARK: - Page View Tracking
179
+
180
+ /**
181
+ * Track a screen view
182
+ *
183
+ * @param screenName Name of the screen being viewed
184
+ */
185
+ fun trackScreenView(screenName: String) {
186
+ // Start new page view and get IDs (pass screen name for impression tracking)
187
+ val pageId = sessionManager.startPageView(screenName)
188
+
189
+ // Get common event fields
190
+ val common = getCommonEventFields()
191
+
192
+ // Get GAM network code from BigCrunchAds if available
193
+ val gamNetworkCode = try {
194
+ BigCrunchAds.getConfigManager().getGamNetworkCode() ?: ""
195
+ } catch (e: Exception) {
196
+ ""
197
+ }
198
+
199
+ // region must be 2 chars or empty per backend validation
200
+ val regionCode = common.region.takeIf { it.length == 2 } ?: ""
201
+
202
+ val event = PageViewEvent(
203
+ payloadVersion = "1.0.0",
204
+ configVersion = 1, // TODO: Get from ConfigManager when available
205
+ browserTimestamp = sessionManager.getCurrentTimestamp(),
206
+ sessionId = sessionManager.sessionId,
207
+ userId = sessionManager.userId,
208
+ propertyId = propertyId,
209
+ newUser = sessionManager.isNewUser,
210
+ pageId = pageId,
211
+ 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
215
+ browser = common.browser,
216
+ device = common.device,
217
+ os = common.os,
218
+ country = common.country,
219
+ region = regionCode,
220
+ sessionSource = common.sessionSource,
221
+ sessionMedium = common.sessionMedium,
222
+ utmSource = common.utmSource ?: "",
223
+ utmMedium = common.utmMedium ?: "",
224
+ utmCampaign = common.utmCampaign ?: "",
225
+ utmTerm = common.utmTerm ?: "",
226
+ utmContent = common.utmContent ?: "",
227
+ gclid = "",
228
+ fbclid = "",
229
+ acctType = "anonymous",
230
+ diiSource = "",
231
+ gamNetworkCode = gamNetworkCode,
232
+ amznPubId = NIL_UUID, // amzn_pub_id must be valid UUID
233
+ customDimensions = emptyMap()
234
+ )
235
+
236
+ sendEvent(event, pageViewAdapter, "pageviews")
237
+
238
+ // Update previous screen for next referrer
239
+ previousScreenName = screenName
240
+ }
241
+
242
+
243
+ // MARK: - Impression Context Management
244
+
245
+ /**
246
+ * Create and store an impression context for tracking
247
+ *
248
+ * Call this when starting to load an ad to generate an impression ID.
249
+ */
250
+ fun createImpressionContext(
251
+ placementId: String,
252
+ gamAdUnit: String,
253
+ format: String,
254
+ width: Int? = null,
255
+ height: Int? = null
256
+ ): ImpressionContext {
257
+ val context = ImpressionContext(
258
+ placementId = placementId,
259
+ gamAdUnit = gamAdUnit,
260
+ format = format,
261
+ width = width,
262
+ height = height
263
+ )
264
+
265
+ impressionContexts[context.impressionId] = context
266
+
267
+ BCLogger.d(TAG, "Created impression context: ${context.impressionId} for $placementId")
268
+
269
+ return context
270
+ }
271
+
272
+ /**
273
+ * Update auction data for an impression context
274
+ */
275
+ fun updateAuctionData(impressionId: String, auctionData: AuctionData) {
276
+ impressionContexts[impressionId]?.let { context ->
277
+ impressionContexts[impressionId] = context.copy(auctionData = auctionData)
278
+ }
279
+ BCLogger.d(TAG, "Updated auction data for impression: $impressionId")
280
+ }
281
+
282
+ /**
283
+ * Get impression context by ID
284
+ */
285
+ fun getImpressionContext(impressionId: String): ImpressionContext? {
286
+ return impressionContexts[impressionId]
287
+ }
288
+
289
+ // MARK: - Enhanced Impression Tracking
290
+
291
+ /**
292
+ * Get GAM network code from config (helper method)
293
+ */
294
+ private fun getGamNetworkCode(): String {
295
+ return try {
296
+ BigCrunchAds.getConfigManager().getGamNetworkCode() ?: ""
297
+ } catch (e: Exception) {
298
+ ""
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Track an ad impression with auction data
304
+ *
305
+ * @param context The impression context with all tracking data
306
+ */
307
+ fun trackAdImpression(context: ImpressionContext) {
308
+ val adSize = if (context.width != null && context.height != null) {
309
+ "${context.width}x${context.height}"
310
+ } else ""
311
+
312
+ // Get common event fields
313
+ val common = getCommonEventFields()
314
+
315
+ // auction_id is required and must be a valid UUID - generate one if not provided
316
+ val auctionId = context.auctionData.auctionId?.takeIf { it.isNotEmpty() }
317
+ ?: UUID.randomUUID().toString()
318
+
319
+ // Create impression record with all fields (defaults to empty string/0.0)
320
+ val impressionRecord = ImpressionRecord(
321
+ slotId = context.placementId,
322
+ gamUnit = context.gamAdUnit,
323
+ impressionId = context.impressionId,
324
+ auctionId = auctionId,
325
+ adBidder = context.auctionData.bidder ?: "",
326
+ adSize = adSize,
327
+ adPrice = context.auctionData.bidPriceCpm ?: 0.0,
328
+ creativeId = context.auctionData.creativeId ?: ""
329
+ )
330
+
331
+ // region must be 2 chars or empty per backend validation
332
+ val regionCode = common.region.takeIf { it.length == 2 } ?: ""
333
+
334
+ // Create batch event with session/page context
335
+ val batchEvent = ImpressionBatchEvent(
336
+ payloadVersion = "1.0.0",
337
+ configVersion = 1,
338
+ browserTimestamp = sessionManager.getCurrentTimestamp(),
339
+ sessionId = sessionManager.sessionId,
340
+ userId = sessionManager.userId,
341
+ propertyId = propertyId,
342
+ newUser = sessionManager.isNewUser,
343
+ pageId = sessionManager.getOrCreatePageId(),
344
+ sessionDepth = sessionManager.sessionDepth,
345
+ pageUrl = "", // page_url must be valid URL or empty - mobile doesn't have URLs
346
+ pageSearch = "",
347
+ pageReferrer = "",
348
+ browser = common.browser,
349
+ device = common.device,
350
+ os = common.os,
351
+ country = common.country,
352
+ region = regionCode,
353
+ sessionSource = common.sessionSource,
354
+ sessionMedium = common.sessionMedium,
355
+ utmSource = common.utmSource ?: "",
356
+ utmMedium = common.utmMedium ?: "",
357
+ utmCampaign = common.utmCampaign ?: "",
358
+ utmTerm = common.utmTerm ?: "",
359
+ utmContent = common.utmContent ?: "",
360
+ gclid = "",
361
+ fbclid = "",
362
+ acctType = "anonymous",
363
+ diiSource = "",
364
+ gamNetworkCode = getGamNetworkCode(),
365
+ amznPubId = NIL_UUID, // amzn_pub_id must be valid UUID
366
+ customDimensions = emptyMap(),
367
+ impressions = listOf(impressionRecord)
368
+ )
369
+
370
+ sendEvent(batchEvent, impressionBatchAdapter, "impressions")
371
+ }
372
+
373
+ /**
374
+ * Track an ad impression (simple version without auction data)
375
+ */
376
+ fun trackAdImpression(
377
+ placementId: String,
378
+ format: String,
379
+ gamAdUnit: String = "",
380
+ advertiserId: String? = null,
381
+ campaignId: String? = null,
382
+ lineItemId: String? = null,
383
+ creativeId: String? = null,
384
+ refreshCount: Int = 0
385
+ ) {
386
+ // Increment session impression counter
387
+ SessionManager.incrementAdImpressionCount()
388
+
389
+ // Get common event fields
390
+ val common = getCommonEventFields()
391
+
392
+ // auction_id is required and must be a valid UUID
393
+ val auctionId = UUID.randomUUID().toString()
394
+
395
+ // Create impression record with all fields (defaults to empty string/0.0)
396
+ val impressionRecord = ImpressionRecord(
397
+ slotId = placementId,
398
+ gamUnit = gamAdUnit,
399
+ impressionId = UUID.randomUUID().toString(),
400
+ auctionId = auctionId,
401
+ advertiserId = advertiserId ?: "",
402
+ campaignId = campaignId ?: "",
403
+ lineItemId = lineItemId ?: "",
404
+ creativeId = creativeId ?: "",
405
+ refreshCount = refreshCount
406
+ )
407
+
408
+ // region must be 2 chars or empty per backend validation
409
+ val regionCode = common.region.takeIf { it.length == 2 } ?: ""
410
+
411
+ // Create batch event with session/page context
412
+ val batchEvent = ImpressionBatchEvent(
413
+ payloadVersion = "1.0.0",
414
+ configVersion = 1,
415
+ browserTimestamp = sessionManager.getCurrentTimestamp(),
416
+ sessionId = sessionManager.sessionId,
417
+ userId = sessionManager.userId,
418
+ propertyId = propertyId,
419
+ newUser = sessionManager.isNewUser,
420
+ pageId = sessionManager.getOrCreatePageId(),
421
+ sessionDepth = sessionManager.sessionDepth,
422
+ pageUrl = "", // page_url must be valid URL or empty - mobile doesn't have URLs
423
+ pageSearch = "",
424
+ pageReferrer = "",
425
+ browser = common.browser,
426
+ device = common.device,
427
+ os = common.os,
428
+ country = common.country,
429
+ region = regionCode,
430
+ sessionSource = common.sessionSource,
431
+ sessionMedium = common.sessionMedium,
432
+ utmSource = common.utmSource ?: "",
433
+ utmMedium = common.utmMedium ?: "",
434
+ utmCampaign = common.utmCampaign ?: "",
435
+ utmTerm = common.utmTerm ?: "",
436
+ utmContent = common.utmContent ?: "",
437
+ gclid = "",
438
+ fbclid = "",
439
+ acctType = "anonymous",
440
+ diiSource = "",
441
+ gamNetworkCode = getGamNetworkCode(),
442
+ amznPubId = NIL_UUID, // amzn_pub_id must be valid UUID
443
+ customDimensions = emptyMap(),
444
+ impressions = listOf(impressionRecord)
445
+ )
446
+
447
+ sendEvent(batchEvent, impressionBatchAdapter, "impressions")
448
+ }
449
+
450
+ // MARK: - Click Tracking
451
+
452
+ /**
453
+ * Track an ad click with impression context
454
+ *
455
+ * @param impressionId The impression that was clicked
456
+ * @param placementId Placement ID
457
+ * @param format Ad format
458
+ */
459
+ fun trackAdClick(impressionId: String, placementId: String, format: String) {
460
+ // Get common event fields
461
+ val common = getCommonEventFields()
462
+
463
+ // region must be 2 chars or empty per backend validation
464
+ val regionCode = common.region.takeIf { it.length == 2 } ?: ""
465
+
466
+ // Create nested click data - impressionId must be valid UUID
467
+ val clickData = ClickData(
468
+ clickId = UUID.randomUUID().toString(),
469
+ slotId = placementId,
470
+ impressionId = impressionId.takeIf { it.isNotEmpty() } ?: UUID.randomUUID().toString()
471
+ )
472
+
473
+ val event = ClickEvent(
474
+ payloadVersion = "1.0.0", // Must be semver format
475
+ configVersion = 1,
476
+ browserTimestamp = sessionManager.getCurrentTimestamp(),
477
+ sessionId = sessionManager.sessionId,
478
+ userId = sessionManager.userId,
479
+ propertyId = propertyId,
480
+ newUser = sessionManager.isNewUser,
481
+ pageId = sessionManager.getOrCreatePageId(),
482
+ sessionDepth = sessionManager.sessionDepth,
483
+ pageUrl = "", // page_url must be valid URL or empty - mobile doesn't have URLs
484
+ pageSearch = "",
485
+ pageReferrer = "",
486
+ browser = common.browser,
487
+ device = common.device,
488
+ os = common.os,
489
+ country = common.country,
490
+ region = regionCode,
491
+ sessionSource = common.sessionSource,
492
+ sessionMedium = common.sessionMedium,
493
+ utmSource = common.utmSource ?: "",
494
+ utmMedium = common.utmMedium ?: "",
495
+ utmCampaign = common.utmCampaign ?: "",
496
+ utmTerm = common.utmTerm ?: "",
497
+ utmContent = common.utmContent ?: "",
498
+ gclid = "",
499
+ fbclid = "",
500
+ acctType = "anonymous",
501
+ diiSource = "",
502
+ gamNetworkCode = getGamNetworkCode(),
503
+ amznPubId = NIL_UUID, // amzn_pub_id must be valid UUID
504
+ customDimensions = emptyMap(),
505
+ click = clickData
506
+ )
507
+
508
+ sendEvent(event, clickAdapter, "clicks")
509
+ }
510
+
511
+ /**
512
+ * Track an ad click (simple version without impression ID)
513
+ */
514
+ fun trackAdClick(placementId: String, format: String) {
515
+ trackAdClick("", placementId, format)
516
+ }
517
+
518
+ // MARK: - Viewability Tracking
519
+
520
+ /**
521
+ * Track ad viewability with impression context
522
+ *
523
+ * @param impressionId The impression that became viewable
524
+ * @param placementId Placement ID
525
+ * @param format Ad format
526
+ * @param viewableTimeMs Time the ad was viewable in milliseconds (unused, kept for API compatibility)
527
+ * @param percentVisible Percentage of ad that was visible (unused, kept for API compatibility)
528
+ */
529
+ fun trackAdViewable(
530
+ impressionId: String,
531
+ placementId: String,
532
+ format: String,
533
+ viewableTimeMs: Long,
534
+ percentVisible: Int
535
+ ) {
536
+ // Get common event fields
537
+ val common = getCommonEventFields()
538
+
539
+ // region must be 2 chars or empty per backend validation
540
+ val regionCode = common.region.takeIf { it.length == 2 } ?: ""
541
+
542
+ // Create nested viewability data - impressionId must be valid UUID
543
+ val viewabilityData = ViewabilityData(
544
+ slotId = placementId,
545
+ impressionId = impressionId.takeIf { it.isNotEmpty() } ?: UUID.randomUUID().toString()
546
+ )
547
+
548
+ val event = ViewabilityEvent(
549
+ payloadVersion = "1.0.0", // Must be semver format
550
+ configVersion = 1,
551
+ browserTimestamp = sessionManager.getCurrentTimestamp(),
552
+ sessionId = sessionManager.sessionId,
553
+ userId = sessionManager.userId,
554
+ propertyId = propertyId,
555
+ newUser = sessionManager.isNewUser,
556
+ pageId = sessionManager.getOrCreatePageId(),
557
+ sessionDepth = sessionManager.sessionDepth,
558
+ pageUrl = "", // page_url must be valid URL or empty - mobile doesn't have URLs
559
+ pageSearch = "",
560
+ pageReferrer = "",
561
+ browser = common.browser,
562
+ device = common.device,
563
+ os = common.os,
564
+ country = common.country,
565
+ region = regionCode,
566
+ sessionSource = common.sessionSource,
567
+ sessionMedium = common.sessionMedium,
568
+ utmSource = common.utmSource ?: "",
569
+ utmMedium = common.utmMedium ?: "",
570
+ utmCampaign = common.utmCampaign ?: "",
571
+ utmTerm = common.utmTerm ?: "",
572
+ utmContent = common.utmContent ?: "",
573
+ gclid = "",
574
+ fbclid = "",
575
+ acctType = "anonymous",
576
+ diiSource = "",
577
+ gamNetworkCode = getGamNetworkCode(),
578
+ amznPubId = NIL_UUID, // amzn_pub_id must be valid UUID
579
+ customDimensions = emptyMap(),
580
+ viewability = listOf(viewabilityData)
581
+ )
582
+
583
+ batchViewabilityEvent(event)
584
+ }
585
+
586
+ /**
587
+ * Track ad viewability (simple version without metrics)
588
+ */
589
+ fun trackAdViewable(placementId: String, format: String) {
590
+ trackAdViewable("", placementId, format, 0, 0)
591
+ }
592
+
593
+ // MARK: - Engagement Tracking
594
+
595
+ /**
596
+ * Track user engagement with content
597
+ *
598
+ * @param engagedTime Time actively engaged in seconds
599
+ * @param timeOnPage Total time on page/screen in seconds
600
+ * @param scrollDepth Maximum scroll depth percentage (0-100)
601
+ */
602
+ fun trackEngagement(engagedTime: Int, timeOnPage: Int, scrollDepth: Int = 0) {
603
+ // Get common event fields
604
+ val common = getCommonEventFields()
605
+
606
+ // region must be 2 chars or empty per backend validation
607
+ val regionCode = common.region.takeIf { it.length == 2 } ?: ""
608
+
609
+ val event = EngagementEvent(
610
+ payloadVersion = "1.0.0", // Must be semver format
611
+ configVersion = 1,
612
+ browserTimestamp = sessionManager.getCurrentTimestamp(),
613
+ sessionId = sessionManager.sessionId,
614
+ userId = sessionManager.userId,
615
+ propertyId = propertyId,
616
+ newUser = sessionManager.isNewUser,
617
+ pageId = sessionManager.getOrCreatePageId(),
618
+ sessionDepth = sessionManager.sessionDepth,
619
+ pageUrl = "", // page_url must be valid URL or empty - mobile doesn't have URLs
620
+ pageSearch = "",
621
+ pageReferrer = "",
622
+ browser = common.browser,
623
+ device = common.device,
624
+ os = common.os,
625
+ country = common.country,
626
+ region = regionCode,
627
+ sessionSource = common.sessionSource,
628
+ sessionMedium = common.sessionMedium,
629
+ utmSource = common.utmSource ?: "",
630
+ utmMedium = common.utmMedium ?: "",
631
+ utmCampaign = common.utmCampaign ?: "",
632
+ utmTerm = common.utmTerm ?: "",
633
+ utmContent = common.utmContent ?: "",
634
+ gclid = "",
635
+ fbclid = "",
636
+ acctType = "anonymous",
637
+ diiSource = "",
638
+ gamNetworkCode = getGamNetworkCode(),
639
+ amznPubId = NIL_UUID, // amzn_pub_id must be valid UUID
640
+ customDimensions = emptyMap(),
641
+ engagedTime = engagedTime,
642
+ timeOnPage = timeOnPage,
643
+ scrollDepth = scrollDepth
644
+ )
645
+
646
+ sendEvent(event, engagementAdapter, "engagement")
647
+ }
648
+
649
+ /**
650
+ * Track an ad request
651
+ *
652
+ * @param placementId The placement ID being requested
653
+ * @param format The ad format (banner, interstitial, etc.)
654
+ */
655
+ fun trackAdRequest(placementId: String, format: String) {
656
+ SessionManager.incrementAdRequestCount()
657
+ BCLogger.d("AnalyticsClient", "Tracked ad request: $placementId ($format)")
658
+ // TODO: Send actual event to backend when endpoint is available
659
+ }
660
+
661
+ // NOTE: trackAdRevenue() removed - revenue now tracked via ImpressionEvent.bidPriceCpm
662
+
663
+ // MARK: - Cleanup
664
+
665
+ /**
666
+ * Remove an impression context (call when ad is destroyed)
667
+ */
668
+ fun removeImpressionContext(impressionId: String) {
669
+ impressionContexts.remove(impressionId)
670
+ }
671
+
672
+ /**
673
+ * Clear all impression contexts
674
+ */
675
+ fun clearImpressionContexts() {
676
+ impressionContexts.clear()
677
+ }
678
+
679
+ // MARK: - Batching Helpers
680
+
681
+ /**
682
+ * Batch a viewability event (250ms delay)
683
+ */
684
+ private fun batchViewabilityEvent(event: ViewabilityEvent) {
685
+ synchronized(batchLock) {
686
+ viewabilityBatch.add(event)
687
+
688
+ // Cancel existing timer
689
+ viewabilityBatchHandler?.removeCallbacksAndMessages(null)
690
+
691
+ // Create new handler if needed
692
+ if (viewabilityBatchHandler == null) {
693
+ viewabilityBatchHandler = android.os.Handler(android.os.Looper.getMainLooper())
694
+ }
695
+
696
+ // Schedule flush after 250ms
697
+ viewabilityBatchHandler?.postDelayed({
698
+ flushViewabilityBatch()
699
+ }, BATCH_DELAY_MS)
700
+ }
701
+ }
702
+
703
+ /**
704
+ * Flush viewability batch to backend
705
+ */
706
+ private fun flushViewabilityBatch() {
707
+ synchronized(batchLock) {
708
+ if (viewabilityBatch.isEmpty()) return
709
+
710
+ val eventsToSend = viewabilityBatch.toList()
711
+ viewabilityBatch.clear()
712
+
713
+ BCLogger.d(TAG, "Flushing ${eventsToSend.size} viewability events")
714
+
715
+ // Send events as a batch array
716
+ sendEventBatch(eventsToSend, "viewability")
717
+ }
718
+ }
719
+ }