@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,874 @@
1
+ import Foundation
2
+ import UIKit
3
+
4
+ /**
5
+ * Analytics client for tracking ad events
6
+ *
7
+ * AnalyticsClient sends all ad-related events to the BigCrunch pipeline:
8
+ * - Page/screen views → `/pageviews`
9
+ * - Ad impressions (includes request + auction + revenue) → `/impressions`
10
+ * - Ad clicks → `/clicks`
11
+ * - Ad viewability → `/viewability`
12
+ * - User engagement → `/engagement`
13
+ *
14
+ * All events are fire-and-forget (non-blocking) and sent asynchronously.
15
+ * Failures are logged but do not propagate to the caller.
16
+ */
17
+ internal class AnalyticsClient {
18
+
19
+ private let httpClient: HTTPClient
20
+ private let baseURL: String
21
+
22
+ /// Nil UUID for fields that require UUID format but have no value
23
+ private static let nilUUID = "00000000-0000-0000-0000-000000000000"
24
+
25
+ /// Track impression contexts for correlating events
26
+ private var impressionContexts: [String: ImpressionContext] = [:]
27
+ private let contextLock = NSLock()
28
+
29
+ /// Previous screen name for referrer tracking
30
+ private var previousScreenName: String?
31
+
32
+ /// Batching for impressions (250ms delay)
33
+ private var impressionBatch: [ImpressionBatchEvent] = []
34
+ private var impressionBatchTimer: Timer?
35
+ private let impressionBatchLock = NSLock()
36
+
37
+ /// Batching for viewability (250ms delay)
38
+ private var viewabilityBatch: [ViewabilityEvent] = []
39
+ private var viewabilityBatchTimer: Timer?
40
+ private let viewabilityBatchLock = NSLock()
41
+
42
+ init(httpClient: HTTPClient, baseURL: String) {
43
+ self.httpClient = httpClient
44
+ self.baseURL = baseURL
45
+ }
46
+
47
+ // MARK: - Session Context
48
+
49
+ private var sessionManager: SessionManager {
50
+ return SessionManager.shared
51
+ }
52
+
53
+ private var propertyId: String {
54
+ return BigCrunchAds.propertyId
55
+ }
56
+
57
+ // MARK: - Event Sending
58
+
59
+ /**
60
+ * Send an event to a specific endpoint (fire-and-forget)
61
+ * Wraps single event in an array as required by server
62
+ */
63
+ private func sendEvent<T: Encodable>(_ event: T, endpoint: String) {
64
+ Task {
65
+ do {
66
+ let encoder = JSONEncoder()
67
+ // Wrap single event in array as server expects arrays
68
+ let eventArray = [event]
69
+ let data = try encoder.encode(eventArray)
70
+ let json = String(data: data, encoding: .utf8)!
71
+ let url = "\(baseURL)/\(endpoint)"
72
+
73
+ BCLogger.debug("Sending event to: \(endpoint)")
74
+
75
+ let result = await httpClient.post(url: url, body: json)
76
+
77
+ switch result {
78
+ case .success:
79
+ BCLogger.verbose("Event sent successfully: \(endpoint)")
80
+ case .failure(let error):
81
+ BCLogger.warning("Failed to send event: \(endpoint) - \(error)")
82
+ }
83
+ } catch {
84
+ BCLogger.error("Error sending event: \(error)")
85
+ }
86
+ }
87
+ }
88
+
89
+ // MARK: - Common Event Fields
90
+
91
+ /**
92
+ * Get common web schema fields (device context, attribution, etc.)
93
+ * Returns a tuple with all the common fields needed for events
94
+ */
95
+ private func getCommonEventFields() -> (
96
+ browser: String,
97
+ device: String,
98
+ os: String,
99
+ country: String,
100
+ region: String,
101
+ sessionSource: String,
102
+ sessionMedium: String,
103
+ utmSource: String?,
104
+ utmMedium: String?,
105
+ utmCampaign: String?,
106
+ utmTerm: String?,
107
+ utmContent: String?
108
+ ) {
109
+ let deviceContext = DeviceContext.shared
110
+ let webFields = deviceContext.getWebSchemaFields()
111
+
112
+ return (
113
+ browser: webFields["browser"] as! String,
114
+ device: webFields["device"] as! String,
115
+ os: webFields["os"] as! String,
116
+ country: webFields["country"] as! String,
117
+ region: webFields["region"] as! String,
118
+ sessionSource: sessionManager.sessionSource,
119
+ sessionMedium: sessionManager.sessionMedium,
120
+ utmSource: sessionManager.utmSource,
121
+ utmMedium: sessionManager.utmMedium,
122
+ utmCampaign: sessionManager.utmCampaign,
123
+ utmTerm: sessionManager.utmTerm,
124
+ utmContent: sessionManager.utmContent
125
+ )
126
+ }
127
+
128
+ // MARK: - Page View Tracking
129
+
130
+ /**
131
+ * Track a screen view
132
+ *
133
+ * - Parameter screenName: Name of the screen being viewed
134
+ */
135
+ func trackScreenView(_ screenName: String) {
136
+ // Start new page view and get IDs
137
+ let pageId = sessionManager.startPageView()
138
+
139
+ // Get common event fields
140
+ let common = getCommonEventFields()
141
+
142
+ // Get GAM network code from config if available
143
+ let gamNetworkCode = BigCrunchAds.getConfigManager().getGamNetworkCode() ?? ""
144
+
145
+ // Region must be 2 chars or empty per backend validation
146
+ let regionCode = common.region.count == 2 ? common.region : ""
147
+
148
+ let event = PageViewEvent(
149
+ payloadVersion: "1.0.0", // Must be semver format
150
+ configVersion: 1,
151
+ browserTimestamp: sessionManager.getCurrentTimestamp(),
152
+ sessionId: sessionManager.sessionId,
153
+ userId: sessionManager.userId,
154
+ propertyId: propertyId,
155
+ newUser: sessionManager.isNewUser,
156
+ pageId: pageId,
157
+ sessionDepth: sessionManager.sessionDepth,
158
+ pageUrl: "", // page_url must be valid URL or empty - mobile doesn't have URLs
159
+ pageSearch: "",
160
+ pageReferrer: "", // page_referrer must be valid URL or empty
161
+ browser: common.browser,
162
+ device: common.device,
163
+ os: common.os,
164
+ country: common.country,
165
+ region: regionCode,
166
+ sessionSource: common.sessionSource,
167
+ sessionMedium: common.sessionMedium,
168
+ utmSource: common.utmSource ?? "",
169
+ utmMedium: common.utmMedium ?? "",
170
+ utmCampaign: common.utmCampaign ?? "",
171
+ utmTerm: common.utmTerm ?? "",
172
+ utmContent: common.utmContent ?? "",
173
+ gclid: "",
174
+ fbclid: "",
175
+ acctType: "anonymous",
176
+ diiSource: "",
177
+ gamNetworkCode: gamNetworkCode,
178
+ amznPubId: AnalyticsClient.nilUUID, // amzn_pub_id must be valid UUID
179
+ customDimensions: [:]
180
+ )
181
+
182
+ sendEvent(event, endpoint: "pageviews")
183
+
184
+ // Update previous screen for next referrer
185
+ previousScreenName = screenName
186
+ }
187
+
188
+ /**
189
+ * Track an ad request
190
+ *
191
+ * - Parameters:
192
+ * - placementId: Placement identifier
193
+ * - format: Ad format (banner, interstitial, rewarded)
194
+ */
195
+ func trackAdRequest(placementId: String, format: String) {
196
+ // For now, this is a placeholder as ad requests are tracked via createImpressionContext
197
+ // We may expand this in the future to track requests that don't result in impressions
198
+ print("[BigCrunchAds] Ad request tracked: \(placementId) format: \(format)")
199
+ }
200
+
201
+ // MARK: - Impression Context Management
202
+
203
+ /**
204
+ * Create and store an impression context for tracking
205
+ *
206
+ * Call this when starting to load an ad to generate an impression ID.
207
+ */
208
+ func createImpressionContext(
209
+ placementId: String,
210
+ gamAdUnit: String,
211
+ format: String,
212
+ width: Int? = nil,
213
+ height: Int? = nil
214
+ ) -> ImpressionContext {
215
+ let context = ImpressionContext(
216
+ placementId: placementId,
217
+ gamAdUnit: gamAdUnit,
218
+ format: format,
219
+ width: width,
220
+ height: height
221
+ )
222
+
223
+ contextLock.lock()
224
+ impressionContexts[context.impressionId] = context
225
+ contextLock.unlock()
226
+
227
+ BCLogger.debug("Created impression context: \(context.impressionId) for \(placementId)")
228
+
229
+ return context
230
+ }
231
+
232
+ /**
233
+ * Update auction data for an impression context
234
+ */
235
+ func updateAuctionData(impressionId: String, auctionData: AuctionData) {
236
+ contextLock.lock()
237
+ impressionContexts[impressionId]?.auctionData = auctionData
238
+ contextLock.unlock()
239
+
240
+ BCLogger.debug("Updated auction data for impression: \(impressionId)")
241
+ }
242
+
243
+ /**
244
+ * Get impression context by ID
245
+ */
246
+ func getImpressionContext(_ impressionId: String) -> ImpressionContext? {
247
+ contextLock.lock()
248
+ defer { contextLock.unlock() }
249
+ return impressionContexts[impressionId]
250
+ }
251
+
252
+ // MARK: - Impression Tracking
253
+
254
+ /**
255
+ * Track an ad impression with auction data
256
+ *
257
+ * - Parameter context: The impression context with all tracking data
258
+ */
259
+ func trackAdImpression(context: ImpressionContext) {
260
+ let adSize: String
261
+ if let width = context.width, let height = context.height {
262
+ adSize = "\(width)x\(height)"
263
+ } else {
264
+ adSize = ""
265
+ }
266
+
267
+ // Get common event fields
268
+ let common = getCommonEventFields()
269
+
270
+ // Get GAM network code from config if available
271
+ let gamNetworkCode = BigCrunchAds.getConfigManager().getGamNetworkCode() ?? ""
272
+
273
+ // Region must be 2 chars or empty per backend validation
274
+ let regionCode = common.region.count == 2 ? common.region : ""
275
+
276
+ // auction_id is required and must be a valid UUID - generate one if not provided
277
+ let auctionId = context.auctionData.auctionId?.isEmpty == false
278
+ ? context.auctionData.auctionId!
279
+ : UUID().uuidString
280
+
281
+ // Create the impression record
282
+ let impressionRecord = ImpressionRecord(
283
+ slotId: context.placementId,
284
+ gamUnit: context.gamAdUnit,
285
+ gamPriceBucket: "",
286
+ impressionId: context.impressionId,
287
+ auctionId: auctionId, // Must be valid UUID
288
+ refreshCount: 0,
289
+ adBidder: context.auctionData.bidder ?? "",
290
+ adSize: adSize,
291
+ adPrice: context.auctionData.bidPriceCpm ?? 0.0,
292
+ adFloorPrice: 0.0,
293
+ minBidToWin: 0.0,
294
+ advertiserId: "",
295
+ campaignId: "",
296
+ lineItemId: "",
297
+ creativeId: context.auctionData.creativeId ?? "",
298
+ adAmznbid: "",
299
+ adAmznp: "",
300
+ adDemandType: "",
301
+ demandChannel: "",
302
+ customDimensions: [:]
303
+ )
304
+
305
+ // Wrap in batch event with session context
306
+ let batchEvent = ImpressionBatchEvent(
307
+ payloadVersion: "1.0.0", // Must be semver format
308
+ configVersion: 1,
309
+ browserTimestamp: sessionManager.getCurrentTimestamp(),
310
+ sessionId: sessionManager.sessionId,
311
+ userId: sessionManager.userId,
312
+ propertyId: propertyId,
313
+ newUser: sessionManager.isNewUser,
314
+ pageId: sessionManager.getOrCreatePageId(),
315
+ sessionDepth: sessionManager.sessionDepth,
316
+ pageUrl: "", // page_url must be valid URL or empty
317
+ pageSearch: "",
318
+ pageReferrer: "",
319
+ browser: common.browser,
320
+ device: common.device,
321
+ os: common.os,
322
+ country: common.country,
323
+ region: regionCode,
324
+ sessionSource: common.sessionSource,
325
+ sessionMedium: common.sessionMedium,
326
+ utmSource: common.utmSource ?? "",
327
+ utmMedium: common.utmMedium ?? "",
328
+ utmCampaign: common.utmCampaign ?? "",
329
+ utmTerm: common.utmTerm ?? "",
330
+ utmContent: common.utmContent ?? "",
331
+ gclid: "",
332
+ fbclid: "",
333
+ acctType: "anonymous",
334
+ diiSource: "",
335
+ gamNetworkCode: gamNetworkCode,
336
+ amznPubId: AnalyticsClient.nilUUID, // amzn_pub_id must be valid UUID
337
+ customDimensions: [:],
338
+ impressions: [impressionRecord]
339
+ )
340
+
341
+ batchImpressionEvent(batchEvent)
342
+ }
343
+
344
+ /**
345
+ * Track an ad impression (simple version without auction data)
346
+ */
347
+ func trackAdImpression(
348
+ placementId: String,
349
+ format: String,
350
+ refreshCount: Int = 0,
351
+ advertiserId: String? = nil,
352
+ campaignId: String? = nil,
353
+ lineItemId: String? = nil,
354
+ creativeId: String? = nil
355
+ ) {
356
+ // Get common event fields
357
+ let common = getCommonEventFields()
358
+
359
+ // Get GAM network code from config if available
360
+ let gamNetworkCode = BigCrunchAds.getConfigManager().getGamNetworkCode() ?? ""
361
+
362
+ // Region must be 2 chars or empty per backend validation
363
+ let regionCode = common.region.count == 2 ? common.region : ""
364
+
365
+ // Create the impression record with generated auction_id (required)
366
+ let impressionRecord = ImpressionRecord(
367
+ slotId: placementId,
368
+ gamUnit: "",
369
+ gamPriceBucket: "",
370
+ impressionId: UUID().uuidString,
371
+ auctionId: UUID().uuidString, // auction_id is required and must be valid UUID
372
+ refreshCount: refreshCount,
373
+ adBidder: "",
374
+ adSize: "",
375
+ adPrice: 0.0,
376
+ adFloorPrice: 0.0,
377
+ minBidToWin: 0.0,
378
+ advertiserId: advertiserId ?? "",
379
+ campaignId: campaignId ?? "",
380
+ lineItemId: lineItemId ?? "",
381
+ creativeId: creativeId ?? "",
382
+ adAmznbid: "",
383
+ adAmznp: "",
384
+ adDemandType: "",
385
+ demandChannel: "",
386
+ customDimensions: [:]
387
+ )
388
+
389
+ // Wrap in batch event with session context
390
+ let batchEvent = ImpressionBatchEvent(
391
+ payloadVersion: "1.0.0", // Must be semver format
392
+ configVersion: 1,
393
+ browserTimestamp: sessionManager.getCurrentTimestamp(),
394
+ sessionId: sessionManager.sessionId,
395
+ userId: sessionManager.userId,
396
+ propertyId: propertyId,
397
+ newUser: sessionManager.isNewUser,
398
+ pageId: sessionManager.getOrCreatePageId(),
399
+ sessionDepth: sessionManager.sessionDepth,
400
+ pageUrl: "", // page_url must be valid URL or empty
401
+ pageSearch: "",
402
+ pageReferrer: "",
403
+ browser: common.browser,
404
+ device: common.device,
405
+ os: common.os,
406
+ country: common.country,
407
+ region: regionCode,
408
+ sessionSource: common.sessionSource,
409
+ sessionMedium: common.sessionMedium,
410
+ utmSource: common.utmSource ?? "",
411
+ utmMedium: common.utmMedium ?? "",
412
+ utmCampaign: common.utmCampaign ?? "",
413
+ utmTerm: common.utmTerm ?? "",
414
+ utmContent: common.utmContent ?? "",
415
+ gclid: "",
416
+ fbclid: "",
417
+ acctType: "anonymous",
418
+ diiSource: "",
419
+ gamNetworkCode: gamNetworkCode,
420
+ amznPubId: AnalyticsClient.nilUUID, // amzn_pub_id must be valid UUID
421
+ customDimensions: [:],
422
+ impressions: [impressionRecord]
423
+ )
424
+
425
+ batchImpressionEvent(batchEvent)
426
+ }
427
+
428
+ // MARK: - Click Tracking
429
+
430
+ /**
431
+ * Track an ad click with impression context
432
+ *
433
+ * - Parameters:
434
+ * - impressionId: The impression that was clicked
435
+ * - placementId: Placement ID
436
+ * - format: Ad format
437
+ */
438
+ func trackAdClick(impressionId: String, placementId: String, format: String) {
439
+ // Get common event fields
440
+ let common = getCommonEventFields()
441
+
442
+ // Get GAM network code from config if available
443
+ let gamNetworkCode = BigCrunchAds.getConfigManager().getGamNetworkCode() ?? ""
444
+
445
+ // Region must be 2 chars or empty per backend validation
446
+ let regionCode = common.region.count == 2 ? common.region : ""
447
+
448
+ // Create nested click data per backend schema
449
+ // Note: customDimensions values must be string arrays per backend validation
450
+ let clickData = ClickData(
451
+ clickId: UUID().uuidString,
452
+ slotId: placementId,
453
+ impressionId: impressionId.isEmpty ? UUID().uuidString : impressionId, // Must be valid UUID
454
+ refreshCount: 0,
455
+ adAmznp: "",
456
+ adBidder: "",
457
+ adSize: "",
458
+ advertiserId: "",
459
+ campaignId: "",
460
+ lineItemId: "",
461
+ creativeId: "",
462
+ adDemandType: "",
463
+ demandChannel: "",
464
+ customDimensions: [:] // Empty dict - values would be [String] arrays
465
+ )
466
+
467
+ let event = ClickEvent(
468
+ payloadVersion: "1.0.0", // Must be semver format
469
+ configVersion: 1,
470
+ browserTimestamp: sessionManager.getCurrentTimestamp(),
471
+ sessionId: sessionManager.sessionId,
472
+ userId: sessionManager.userId,
473
+ propertyId: propertyId,
474
+ newUser: sessionManager.isNewUser,
475
+ pageId: sessionManager.getOrCreatePageId(),
476
+ sessionDepth: sessionManager.sessionDepth,
477
+ pageUrl: "",
478
+ pageSearch: "",
479
+ pageReferrer: "",
480
+ browser: common.browser,
481
+ device: common.device,
482
+ os: common.os,
483
+ country: common.country,
484
+ region: regionCode,
485
+ sessionSource: common.sessionSource,
486
+ sessionMedium: common.sessionMedium,
487
+ utmSource: common.utmSource ?? "",
488
+ utmMedium: common.utmMedium ?? "",
489
+ utmCampaign: common.utmCampaign ?? "",
490
+ utmTerm: common.utmTerm ?? "",
491
+ utmContent: common.utmContent ?? "",
492
+ gclid: "",
493
+ fbclid: "",
494
+ acctType: "anonymous",
495
+ diiSource: "",
496
+ gamNetworkCode: gamNetworkCode,
497
+ amznPubId: AnalyticsClient.nilUUID,
498
+ customDimensions: [:],
499
+ click: clickData
500
+ )
501
+
502
+ sendEvent(event, endpoint: "clicks")
503
+ }
504
+
505
+ /**
506
+ * Track an ad click (simple version without impression ID)
507
+ */
508
+ func trackAdClick(placementId: String, format: String) {
509
+ trackAdClick(impressionId: "", placementId: placementId, format: format)
510
+ }
511
+
512
+ // MARK: - Viewability Tracking
513
+
514
+ /**
515
+ * Track ad viewability with impression context
516
+ *
517
+ * - Parameters:
518
+ * - impressionId: The impression that became viewable (must be valid UUID)
519
+ * - placementId: Placement ID (used as slot_id)
520
+ * - format: Ad format (not sent directly, used for logging)
521
+ * - viewableTimeMs: Time the ad was viewable in milliseconds (not in current schema)
522
+ * - percentVisible: Percentage of ad that was visible (not in current schema)
523
+ */
524
+ func trackAdViewable(
525
+ impressionId: String,
526
+ placementId: String,
527
+ format: String,
528
+ viewableTimeMs: Int64,
529
+ percentVisible: Int
530
+ ) {
531
+ // Get common event fields
532
+ let common = getCommonEventFields()
533
+
534
+ // Get GAM network code from config if available
535
+ let gamNetworkCode = BigCrunchAds.getConfigManager().getGamNetworkCode() ?? ""
536
+
537
+ // Region must be 2 chars or empty per backend validation
538
+ let regionCode = common.region.count == 2 ? common.region : ""
539
+
540
+ // Create viewability data with nested structure per backend schema
541
+ let viewabilityData = ViewabilityData(
542
+ slotId: placementId,
543
+ impressionId: impressionId.isEmpty ? UUID().uuidString : impressionId, // Must be valid UUID
544
+ refreshCount: 0,
545
+ adAmznp: "",
546
+ adBidder: "",
547
+ adSize: "",
548
+ advertiserId: "",
549
+ campaignId: "",
550
+ lineItemId: "",
551
+ creativeId: "",
552
+ adDemandType: "",
553
+ demandChannel: "",
554
+ customDimensions: [:]
555
+ )
556
+
557
+ let event = ViewabilityEvent(
558
+ payloadVersion: "1.0.0", // Must be semver format
559
+ configVersion: 1,
560
+ browserTimestamp: sessionManager.getCurrentTimestamp(),
561
+ sessionId: sessionManager.sessionId,
562
+ userId: sessionManager.userId,
563
+ propertyId: propertyId,
564
+ newUser: sessionManager.isNewUser,
565
+ pageId: sessionManager.getOrCreatePageId(),
566
+ sessionDepth: sessionManager.sessionDepth,
567
+ pageUrl: "",
568
+ pageSearch: "",
569
+ pageReferrer: "",
570
+ browser: common.browser,
571
+ device: common.device,
572
+ os: common.os,
573
+ country: common.country,
574
+ region: regionCode,
575
+ sessionSource: common.sessionSource,
576
+ sessionMedium: common.sessionMedium,
577
+ utmSource: common.utmSource ?? "",
578
+ utmMedium: common.utmMedium ?? "",
579
+ utmCampaign: common.utmCampaign ?? "",
580
+ utmTerm: common.utmTerm ?? "",
581
+ utmContent: common.utmContent ?? "",
582
+ gclid: "",
583
+ fbclid: "",
584
+ acctType: "anonymous",
585
+ diiSource: "",
586
+ gamNetworkCode: gamNetworkCode,
587
+ amznPubId: AnalyticsClient.nilUUID,
588
+ viewability: [viewabilityData]
589
+ )
590
+
591
+ batchViewabilityEvent(event)
592
+ }
593
+
594
+ /**
595
+ * Track ad viewability (simple version without metrics)
596
+ */
597
+ func trackAdViewable(placementId: String, format: String) {
598
+ // Get common event fields
599
+ let common = getCommonEventFields()
600
+
601
+ // Get GAM network code from config if available
602
+ let gamNetworkCode = BigCrunchAds.getConfigManager().getGamNetworkCode() ?? ""
603
+
604
+ // Region must be 2 chars or empty per backend validation
605
+ let regionCode = common.region.count == 2 ? common.region : ""
606
+
607
+ // Create viewability data with nested structure per backend schema
608
+ let viewabilityData = ViewabilityData(
609
+ slotId: placementId,
610
+ impressionId: UUID().uuidString, // Generate UUID since none provided
611
+ refreshCount: 0,
612
+ adAmznp: "",
613
+ adBidder: "",
614
+ adSize: "",
615
+ advertiserId: "",
616
+ campaignId: "",
617
+ lineItemId: "",
618
+ creativeId: "",
619
+ adDemandType: "",
620
+ demandChannel: "",
621
+ customDimensions: [:]
622
+ )
623
+
624
+ let event = ViewabilityEvent(
625
+ payloadVersion: "1.0.0", // Must be semver format
626
+ configVersion: 1,
627
+ browserTimestamp: sessionManager.getCurrentTimestamp(),
628
+ sessionId: sessionManager.sessionId,
629
+ userId: sessionManager.userId,
630
+ propertyId: propertyId,
631
+ newUser: sessionManager.isNewUser,
632
+ pageId: sessionManager.getOrCreatePageId(),
633
+ sessionDepth: sessionManager.sessionDepth,
634
+ pageUrl: "",
635
+ pageSearch: "",
636
+ pageReferrer: "",
637
+ browser: common.browser,
638
+ device: common.device,
639
+ os: common.os,
640
+ country: common.country,
641
+ region: regionCode,
642
+ sessionSource: common.sessionSource,
643
+ sessionMedium: common.sessionMedium,
644
+ utmSource: common.utmSource ?? "",
645
+ utmMedium: common.utmMedium ?? "",
646
+ utmCampaign: common.utmCampaign ?? "",
647
+ utmTerm: common.utmTerm ?? "",
648
+ utmContent: common.utmContent ?? "",
649
+ gclid: "",
650
+ fbclid: "",
651
+ acctType: "anonymous",
652
+ diiSource: "",
653
+ gamNetworkCode: gamNetworkCode,
654
+ amznPubId: AnalyticsClient.nilUUID,
655
+ viewability: [viewabilityData]
656
+ )
657
+ batchViewabilityEvent(event)
658
+ }
659
+
660
+ // MARK: - Engagement Tracking
661
+
662
+ /**
663
+ * Track user engagement with content
664
+ *
665
+ * - Parameters:
666
+ * - engagedTime: Time actively engaged in seconds
667
+ * - timeOnPage: Total time on page/screen in seconds
668
+ * - scrollDepth: Maximum scroll depth percentage (0-100)
669
+ */
670
+ func trackEngagement(engagedTime: Int, timeOnPage: Int, scrollDepth: Int = 0) {
671
+ // Get common event fields
672
+ let common = getCommonEventFields()
673
+
674
+ // Get GAM network code from config if available
675
+ let gamNetworkCode = BigCrunchAds.getConfigManager().getGamNetworkCode() ?? ""
676
+
677
+ // Region must be 2 chars or empty per backend validation
678
+ let regionCode = common.region.count == 2 ? common.region : ""
679
+
680
+ let event = EngagementEvent(
681
+ payloadVersion: "1.0.0", // Must be semver format
682
+ configVersion: 1,
683
+ browserTimestamp: sessionManager.getCurrentTimestamp(),
684
+ sessionId: sessionManager.sessionId,
685
+ userId: sessionManager.userId,
686
+ propertyId: propertyId,
687
+ newUser: sessionManager.isNewUser,
688
+ pageId: sessionManager.getOrCreatePageId(),
689
+ sessionDepth: sessionManager.sessionDepth,
690
+ pageUrl: "",
691
+ pageSearch: "",
692
+ pageReferrer: "",
693
+ browser: common.browser,
694
+ device: common.device,
695
+ os: common.os,
696
+ country: common.country,
697
+ region: regionCode,
698
+ sessionSource: common.sessionSource,
699
+ sessionMedium: common.sessionMedium,
700
+ utmSource: common.utmSource ?? "",
701
+ utmMedium: common.utmMedium ?? "",
702
+ utmCampaign: common.utmCampaign ?? "",
703
+ utmTerm: common.utmTerm ?? "",
704
+ utmContent: common.utmContent ?? "",
705
+ gclid: "",
706
+ fbclid: "",
707
+ acctType: "anonymous",
708
+ diiSource: "",
709
+ gamNetworkCode: gamNetworkCode,
710
+ amznPubId: AnalyticsClient.nilUUID,
711
+ customDimensions: [:], // Values would be [String] arrays per backend schema
712
+ engagedTime: engagedTime,
713
+ timeOnPage: timeOnPage,
714
+ scrollDepth: scrollDepth
715
+ )
716
+
717
+ sendEvent(event, endpoint: "engagement")
718
+ }
719
+
720
+ // MARK: - Revenue Tracking
721
+
722
+ /**
723
+ * Track ad revenue from ILRD (Impression-Level Revenue Data)
724
+ *
725
+ * Note: Revenue is now primarily tracked via the impression event's bidPriceCpm field.
726
+ * This method logs revenue data for debugging but does not send a separate event.
727
+ *
728
+ * - Parameters:
729
+ * - placementId: The placement ID
730
+ * - format: Ad format (banner, interstitial, rewarded)
731
+ * - valueMicros: Revenue value in micros (1 micro = 0.000001 of currency)
732
+ * - currency: ISO 4217 currency code (e.g., "USD")
733
+ */
734
+ func trackAdRevenue(placementId: String, format: String, valueMicros: Int64, currency: String) {
735
+ // Revenue is now tracked via ImpressionEvent.bidPriceCpm
736
+ // This method is kept for backwards compatibility and logging
737
+ let valueInCurrency = Double(valueMicros) / 1_000_000.0
738
+ BCLogger.debug("Ad revenue: \(placementId) (\(format)) - \(valueInCurrency) \(currency)")
739
+ }
740
+
741
+ // MARK: - Batching Helpers
742
+
743
+ /**
744
+ * Add impression event to batch queue
745
+ *
746
+ * Batches impressions for 250ms before sending to reduce network requests.
747
+ */
748
+ private func batchImpressionEvent(_ event: ImpressionBatchEvent) {
749
+ impressionBatchLock.lock()
750
+ impressionBatch.append(event)
751
+
752
+ // Start timer if not already running
753
+ if impressionBatchTimer == nil {
754
+ impressionBatchTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: false) { [weak self] _ in
755
+ self?.flushImpressionBatch()
756
+ }
757
+ }
758
+
759
+ impressionBatchLock.unlock()
760
+ }
761
+
762
+ /**
763
+ * Flush all batched impression events
764
+ */
765
+ private func flushImpressionBatch() {
766
+ impressionBatchLock.lock()
767
+ let events = impressionBatch
768
+ impressionBatch.removeAll()
769
+ impressionBatchTimer?.invalidate()
770
+ impressionBatchTimer = nil
771
+ impressionBatchLock.unlock()
772
+
773
+ // Send batched events as array
774
+ if !events.isEmpty {
775
+ Task {
776
+ do {
777
+ let encoder = JSONEncoder()
778
+ let data = try encoder.encode(events)
779
+ let json = String(data: data, encoding: .utf8)!
780
+ let url = "\(baseURL)/impressions"
781
+
782
+ BCLogger.debug("Sending \(events.count) batched impressions")
783
+
784
+ let result = await httpClient.post(url: url, body: json)
785
+
786
+ switch result {
787
+ case .success:
788
+ BCLogger.verbose("Impression batch sent successfully")
789
+ case .failure(let error):
790
+ BCLogger.warning("Failed to send impression batch: \(error)")
791
+ }
792
+ } catch {
793
+ BCLogger.error("Error sending impression batch: \(error)")
794
+ }
795
+ }
796
+ }
797
+ }
798
+
799
+ /**
800
+ * Add viewability event to batch queue
801
+ *
802
+ * Batches viewability events for 250ms before sending to reduce network requests.
803
+ */
804
+ private func batchViewabilityEvent(_ event: ViewabilityEvent) {
805
+ viewabilityBatchLock.lock()
806
+ viewabilityBatch.append(event)
807
+
808
+ // Start timer if not already running
809
+ if viewabilityBatchTimer == nil {
810
+ viewabilityBatchTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: false) { [weak self] _ in
811
+ self?.flushViewabilityBatch()
812
+ }
813
+ }
814
+
815
+ viewabilityBatchLock.unlock()
816
+ }
817
+
818
+ /**
819
+ * Flush all batched viewability events
820
+ */
821
+ private func flushViewabilityBatch() {
822
+ viewabilityBatchLock.lock()
823
+ let events = viewabilityBatch
824
+ viewabilityBatch.removeAll()
825
+ viewabilityBatchTimer?.invalidate()
826
+ viewabilityBatchTimer = nil
827
+ viewabilityBatchLock.unlock()
828
+
829
+ // Send batched events as array
830
+ if !events.isEmpty {
831
+ Task {
832
+ do {
833
+ let encoder = JSONEncoder()
834
+ let data = try encoder.encode(events)
835
+ let json = String(data: data, encoding: .utf8)!
836
+ let url = "\(baseURL)/viewability"
837
+
838
+ BCLogger.debug("Sending \(events.count) batched viewability events")
839
+
840
+ let result = await httpClient.post(url: url, body: json)
841
+
842
+ switch result {
843
+ case .success:
844
+ BCLogger.verbose("Viewability batch sent successfully")
845
+ case .failure(let error):
846
+ BCLogger.warning("Failed to send viewability batch: \(error)")
847
+ }
848
+ } catch {
849
+ BCLogger.error("Error sending viewability batch: \(error)")
850
+ }
851
+ }
852
+ }
853
+ }
854
+
855
+ // MARK: - Cleanup
856
+
857
+ /**
858
+ * Remove an impression context (call when ad is destroyed)
859
+ */
860
+ func removeImpressionContext(_ impressionId: String) {
861
+ contextLock.lock()
862
+ impressionContexts.removeValue(forKey: impressionId)
863
+ contextLock.unlock()
864
+ }
865
+
866
+ /**
867
+ * Clear all impression contexts
868
+ */
869
+ func clearImpressionContexts() {
870
+ contextLock.lock()
871
+ impressionContexts.removeAll()
872
+ contextLock.unlock()
873
+ }
874
+ }