@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
@@ -26,9 +26,21 @@ internal class AnalyticsClient {
26
26
  private var impressionContexts: [String: ImpressionContext] = [:]
27
27
  private let contextLock = NSLock()
28
28
 
29
+ /// Per-placement auction data from S2S responses (set by AdOrchestrator, consumed by trackAdImpression)
30
+ private var placementAuctionData: [String: AuctionData] = [:]
31
+
29
32
  /// Previous screen name for referrer tracking
30
33
  private var previousScreenName: String?
31
34
 
35
+ /// Current page URL (set by trackScreenView, used by all events)
36
+ private var currentPageUrl: String = ""
37
+
38
+ /// Current custom dimensions (set by trackScreenView, used by all events)
39
+ private var currentCustomDimensions: [String: String] = [:]
40
+
41
+ /// Current account type (set by setAccountType, used by all events)
42
+ private var currentAcctType: String = "guest"
43
+
32
44
  /// Batching for impressions (250ms delay)
33
45
  private var impressionBatch: [ImpressionBatchEvent] = []
34
46
  private var impressionBatchTimer: Timer?
@@ -127,15 +139,34 @@ internal class AnalyticsClient {
127
139
 
128
140
  // MARK: - Page View Tracking
129
141
 
142
+ /**
143
+ * Generate the default page URL from screen name
144
+ */
145
+ private func generatePageUrl(_ screenName: String) -> String {
146
+ let appName = BigCrunchAds.getAppConfig()?.appName ?? "app"
147
+ let sanitizedAppName = appName.lowercased()
148
+ .replacingOccurrences(of: " ", with: "-")
149
+ .addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? appName
150
+ let encodedScreen = screenName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? screenName
151
+ return "https://\(sanitizedAppName).mobile.app/\(encodedScreen)"
152
+ }
153
+
130
154
  /**
131
155
  * Track a screen view
132
156
  *
133
- * - Parameter screenName: Name of the screen being viewed
157
+ * - Parameters:
158
+ * - screenName: Name of the screen being viewed
159
+ * - options: Optional overrides for page URL, metadata, and custom dimensions
134
160
  */
135
- func trackScreenView(_ screenName: String) {
161
+ func trackScreenView(_ screenName: String, options: ScreenViewOptions? = nil) {
136
162
  // Start new page view and get IDs
137
163
  let pageId = sessionManager.startPageView()
138
164
 
165
+ // Resolve page URL: explicit override > auto-generated
166
+ let pageUrl = options?.pageUrl ?? generatePageUrl(screenName)
167
+ currentPageUrl = pageUrl
168
+ currentCustomDimensions = options?.customDimensions ?? [:]
169
+
139
170
  // Get common event fields
140
171
  let common = getCommonEventFields()
141
172
 
@@ -155,7 +186,7 @@ internal class AnalyticsClient {
155
186
  newUser: sessionManager.isNewUser,
156
187
  pageId: pageId,
157
188
  sessionDepth: sessionManager.sessionDepth,
158
- pageUrl: "", // page_url must be valid URL or empty - mobile doesn't have URLs
189
+ pageUrl: pageUrl,
159
190
  pageSearch: "",
160
191
  pageReferrer: "", // page_referrer must be valid URL or empty
161
192
  browser: common.browser,
@@ -172,11 +203,12 @@ internal class AnalyticsClient {
172
203
  utmContent: common.utmContent ?? "",
173
204
  gclid: "",
174
205
  fbclid: "",
175
- acctType: "anonymous",
206
+ acctType: currentAcctType,
176
207
  diiSource: "",
177
208
  gamNetworkCode: gamNetworkCode,
178
209
  amznPubId: AnalyticsClient.nilUUID, // amzn_pub_id must be valid UUID
179
- customDimensions: [:]
210
+ customDimensions: currentCustomDimensions,
211
+ pageMetaData: options?.pageMeta
180
212
  )
181
213
 
182
214
  sendEvent(event, endpoint: "pageviews")
@@ -249,6 +281,41 @@ internal class AnalyticsClient {
249
281
  return impressionContexts[impressionId]
250
282
  }
251
283
 
284
+ // MARK: - Auction Data Storage
285
+
286
+ /**
287
+ * Store auction data for a placement (called by AdOrchestrator after S2S demand fetch)
288
+ */
289
+ func setAccountType(_ accountType: String) {
290
+ currentAcctType = accountType
291
+ }
292
+
293
+ func setAuctionData(placementId: String, auctionData: AuctionData) {
294
+ contextLock.lock()
295
+ placementAuctionData[placementId] = auctionData
296
+ contextLock.unlock()
297
+ BCLogger.debug("Stored auction data for placement: \(placementId) (channel: \(auctionData.demandChannel ?? "unknown"))")
298
+ }
299
+
300
+ /**
301
+ * Consume auction data for a placement (returns and removes stored data)
302
+ */
303
+ private func consumeAuctionData(placementId: String) -> AuctionData? {
304
+ contextLock.lock()
305
+ let data = placementAuctionData.removeValue(forKey: placementId)
306
+ contextLock.unlock()
307
+ return data
308
+ }
309
+
310
+ /**
311
+ * Compute min_bid_to_win: $0.01 above the higher of second-highest bid or floor price
312
+ */
313
+ private func computeMinBidToWin(_ auctionData: AuctionData?) -> Double {
314
+ guard let data = auctionData else { return 0.0 }
315
+ let baseline = max(data.secondHighestBid ?? 0.0, data.floorPrice ?? 0.0)
316
+ return baseline > 0 ? baseline + 0.01 : 0.0
317
+ }
318
+
252
319
  // MARK: - Impression Tracking
253
320
 
254
321
  /**
@@ -278,27 +345,31 @@ internal class AnalyticsClient {
278
345
  ? context.auctionData.auctionId!
279
346
  : UUID().uuidString
280
347
 
348
+ // Look up stored auction data (may have been set by AdOrchestrator)
349
+ let storedAuctionData = consumeAuctionData(placementId: context.placementId)
350
+ let effectiveAuctionData = storedAuctionData ?? context.auctionData
351
+
281
352
  // Create the impression record
282
353
  let impressionRecord = ImpressionRecord(
283
354
  slotId: context.placementId,
284
355
  gamUnit: context.gamAdUnit,
285
- gamPriceBucket: "",
356
+ gamPriceBucket: effectiveAuctionData.gamPriceBucket ?? "",
286
357
  impressionId: context.impressionId,
287
358
  auctionId: auctionId, // Must be valid UUID
288
359
  refreshCount: 0,
289
- adBidder: context.auctionData.bidder ?? "",
360
+ adBidder: effectiveAuctionData.bidder ?? "",
290
361
  adSize: adSize,
291
- adPrice: context.auctionData.bidPriceCpm ?? 0.0,
292
- adFloorPrice: 0.0,
293
- minBidToWin: 0.0,
362
+ adPrice: effectiveAuctionData.bidPriceCpm ?? 0.0,
363
+ adFloorPrice: effectiveAuctionData.floorPrice ?? 0.0,
364
+ minBidToWin: computeMinBidToWin(effectiveAuctionData),
294
365
  advertiserId: "",
295
366
  campaignId: "",
296
367
  lineItemId: "",
297
- creativeId: context.auctionData.creativeId ?? "",
368
+ creativeId: effectiveAuctionData.creativeId ?? "",
298
369
  adAmznbid: "",
299
370
  adAmznp: "",
300
- adDemandType: "",
301
- demandChannel: "",
371
+ adDemandType: context.format,
372
+ demandChannel: effectiveAuctionData.demandChannel ?? "Google Ad Exchange",
302
373
  customDimensions: [:]
303
374
  )
304
375
 
@@ -313,7 +384,7 @@ internal class AnalyticsClient {
313
384
  newUser: sessionManager.isNewUser,
314
385
  pageId: sessionManager.getOrCreatePageId(),
315
386
  sessionDepth: sessionManager.sessionDepth,
316
- pageUrl: "", // page_url must be valid URL or empty
387
+ pageUrl: currentPageUrl,
317
388
  pageSearch: "",
318
389
  pageReferrer: "",
319
390
  browser: common.browser,
@@ -330,11 +401,11 @@ internal class AnalyticsClient {
330
401
  utmContent: common.utmContent ?? "",
331
402
  gclid: "",
332
403
  fbclid: "",
333
- acctType: "anonymous",
404
+ acctType: currentAcctType,
334
405
  diiSource: "",
335
406
  gamNetworkCode: gamNetworkCode,
336
407
  amznPubId: AnalyticsClient.nilUUID, // amzn_pub_id must be valid UUID
337
- customDimensions: [:],
408
+ customDimensions: currentCustomDimensions,
338
409
  impressions: [impressionRecord]
339
410
  )
340
411
 
@@ -342,11 +413,13 @@ internal class AnalyticsClient {
342
413
  }
343
414
 
344
415
  /**
345
- * Track an ad impression (simple version without auction data)
416
+ * Track an ad impression (simple version with auction data lookup)
346
417
  */
347
418
  func trackAdImpression(
348
419
  placementId: String,
349
420
  format: String,
421
+ gamAdUnit: String = "",
422
+ adSize: String = "",
350
423
  refreshCount: Int = 0,
351
424
  advertiserId: String? = nil,
352
425
  campaignId: String? = nil,
@@ -362,27 +435,35 @@ internal class AnalyticsClient {
362
435
  // Region must be 2 chars or empty per backend validation
363
436
  let regionCode = common.region.count == 2 ? common.region : ""
364
437
 
365
- // Create the impression record with generated auction_id (required)
438
+ // Look up stored auction data from S2S response
439
+ let auctionData = consumeAuctionData(placementId: placementId)
440
+
441
+ // auction_id: use S2S auction ID if available, otherwise generate one
442
+ let auctionId = auctionData?.auctionId?.isEmpty == false
443
+ ? auctionData!.auctionId!
444
+ : UUID().uuidString
445
+
446
+ // Create the impression record with auction data fields populated
366
447
  let impressionRecord = ImpressionRecord(
367
448
  slotId: placementId,
368
- gamUnit: "",
369
- gamPriceBucket: "",
449
+ gamUnit: gamAdUnit,
450
+ gamPriceBucket: auctionData?.gamPriceBucket ?? "",
370
451
  impressionId: UUID().uuidString,
371
- auctionId: UUID().uuidString, // auction_id is required and must be valid UUID
452
+ auctionId: auctionId,
372
453
  refreshCount: refreshCount,
373
- adBidder: "",
374
- adSize: "",
375
- adPrice: 0.0,
376
- adFloorPrice: 0.0,
377
- minBidToWin: 0.0,
454
+ adBidder: auctionData?.bidder ?? "",
455
+ adSize: adSize,
456
+ adPrice: auctionData?.bidPriceCpm ?? 0.0,
457
+ adFloorPrice: auctionData?.floorPrice ?? 0.0,
458
+ minBidToWin: computeMinBidToWin(auctionData),
378
459
  advertiserId: advertiserId ?? "",
379
460
  campaignId: campaignId ?? "",
380
461
  lineItemId: lineItemId ?? "",
381
- creativeId: creativeId ?? "",
462
+ creativeId: auctionData?.creativeId ?? creativeId ?? "",
382
463
  adAmznbid: "",
383
464
  adAmznp: "",
384
- adDemandType: "",
385
- demandChannel: "",
465
+ adDemandType: format,
466
+ demandChannel: auctionData?.demandChannel ?? "Google Ad Exchange",
386
467
  customDimensions: [:]
387
468
  )
388
469
 
@@ -397,7 +478,7 @@ internal class AnalyticsClient {
397
478
  newUser: sessionManager.isNewUser,
398
479
  pageId: sessionManager.getOrCreatePageId(),
399
480
  sessionDepth: sessionManager.sessionDepth,
400
- pageUrl: "", // page_url must be valid URL or empty
481
+ pageUrl: currentPageUrl,
401
482
  pageSearch: "",
402
483
  pageReferrer: "",
403
484
  browser: common.browser,
@@ -414,11 +495,11 @@ internal class AnalyticsClient {
414
495
  utmContent: common.utmContent ?? "",
415
496
  gclid: "",
416
497
  fbclid: "",
417
- acctType: "anonymous",
498
+ acctType: currentAcctType,
418
499
  diiSource: "",
419
500
  gamNetworkCode: gamNetworkCode,
420
501
  amznPubId: AnalyticsClient.nilUUID, // amzn_pub_id must be valid UUID
421
- customDimensions: [:],
502
+ customDimensions: currentCustomDimensions,
422
503
  impressions: [impressionRecord]
423
504
  )
424
505
 
@@ -474,7 +555,7 @@ internal class AnalyticsClient {
474
555
  newUser: sessionManager.isNewUser,
475
556
  pageId: sessionManager.getOrCreatePageId(),
476
557
  sessionDepth: sessionManager.sessionDepth,
477
- pageUrl: "",
558
+ pageUrl: currentPageUrl,
478
559
  pageSearch: "",
479
560
  pageReferrer: "",
480
561
  browser: common.browser,
@@ -491,11 +572,11 @@ internal class AnalyticsClient {
491
572
  utmContent: common.utmContent ?? "",
492
573
  gclid: "",
493
574
  fbclid: "",
494
- acctType: "anonymous",
575
+ acctType: currentAcctType,
495
576
  diiSource: "",
496
577
  gamNetworkCode: gamNetworkCode,
497
578
  amznPubId: AnalyticsClient.nilUUID,
498
- customDimensions: [:],
579
+ customDimensions: currentCustomDimensions,
499
580
  click: clickData
500
581
  )
501
582
 
@@ -564,7 +645,7 @@ internal class AnalyticsClient {
564
645
  newUser: sessionManager.isNewUser,
565
646
  pageId: sessionManager.getOrCreatePageId(),
566
647
  sessionDepth: sessionManager.sessionDepth,
567
- pageUrl: "",
648
+ pageUrl: currentPageUrl,
568
649
  pageSearch: "",
569
650
  pageReferrer: "",
570
651
  browser: common.browser,
@@ -581,7 +662,7 @@ internal class AnalyticsClient {
581
662
  utmContent: common.utmContent ?? "",
582
663
  gclid: "",
583
664
  fbclid: "",
584
- acctType: "anonymous",
665
+ acctType: currentAcctType,
585
666
  diiSource: "",
586
667
  gamNetworkCode: gamNetworkCode,
587
668
  amznPubId: AnalyticsClient.nilUUID,
@@ -631,7 +712,7 @@ internal class AnalyticsClient {
631
712
  newUser: sessionManager.isNewUser,
632
713
  pageId: sessionManager.getOrCreatePageId(),
633
714
  sessionDepth: sessionManager.sessionDepth,
634
- pageUrl: "",
715
+ pageUrl: currentPageUrl,
635
716
  pageSearch: "",
636
717
  pageReferrer: "",
637
718
  browser: common.browser,
@@ -648,7 +729,7 @@ internal class AnalyticsClient {
648
729
  utmContent: common.utmContent ?? "",
649
730
  gclid: "",
650
731
  fbclid: "",
651
- acctType: "anonymous",
732
+ acctType: currentAcctType,
652
733
  diiSource: "",
653
734
  gamNetworkCode: gamNetworkCode,
654
735
  amznPubId: AnalyticsClient.nilUUID,
@@ -687,7 +768,7 @@ internal class AnalyticsClient {
687
768
  newUser: sessionManager.isNewUser,
688
769
  pageId: sessionManager.getOrCreatePageId(),
689
770
  sessionDepth: sessionManager.sessionDepth,
690
- pageUrl: "",
771
+ pageUrl: currentPageUrl,
691
772
  pageSearch: "",
692
773
  pageReferrer: "",
693
774
  browser: common.browser,
@@ -704,11 +785,11 @@ internal class AnalyticsClient {
704
785
  utmContent: common.utmContent ?? "",
705
786
  gclid: "",
706
787
  fbclid: "",
707
- acctType: "anonymous",
788
+ acctType: currentAcctType,
708
789
  diiSource: "",
709
790
  gamNetworkCode: gamNetworkCode,
710
791
  amznPubId: AnalyticsClient.nilUUID,
711
- customDimensions: [:], // Values would be [String] arrays per backend schema
792
+ customDimensions: [:], // Engagement uses array-valued custom dimensions per backend schema
712
793
  engagedTime: engagedTime,
713
794
  timeOnPage: timeOnPage,
714
795
  scrollDepth: scrollDepth
@@ -761,6 +842,8 @@ internal class AnalyticsClient {
761
842
 
762
843
  /**
763
844
  * Flush all batched impression events
845
+ *
846
+ * Merges events with the same pageId into a single object with combined impressions array.
764
847
  */
765
848
  private func flushImpressionBatch() {
766
849
  impressionBatchLock.lock()
@@ -770,28 +853,40 @@ internal class AnalyticsClient {
770
853
  impressionBatchTimer = nil
771
854
  impressionBatchLock.unlock()
772
855
 
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)")
856
+ if events.isEmpty { return }
857
+
858
+ // Group by pageId and merge impressions into single objects
859
+ var merged: [String: ImpressionBatchEvent] = [:]
860
+ for event in events {
861
+ if var existing = merged[event.pageId] {
862
+ existing.impressions.append(contentsOf: event.impressions)
863
+ merged[event.pageId] = existing
864
+ } else {
865
+ merged[event.pageId] = event
866
+ }
867
+ }
868
+
869
+ let mergedEvents = Array(merged.values)
870
+
871
+ Task {
872
+ do {
873
+ let encoder = JSONEncoder()
874
+ let data = try encoder.encode(mergedEvents)
875
+ let json = String(data: data, encoding: .utf8)!
876
+ let url = "\(baseURL)/impressions"
877
+
878
+ BCLogger.debug("Sending \(mergedEvents.count) batched impression groups (\(events.count) total impressions)")
879
+
880
+ let result = await httpClient.post(url: url, body: json)
881
+
882
+ switch result {
883
+ case .success:
884
+ BCLogger.verbose("Impression batch sent successfully")
885
+ case .failure(let error):
886
+ BCLogger.warning("Failed to send impression batch: \(error)")
794
887
  }
888
+ } catch {
889
+ BCLogger.error("Error sending impression batch: \(error)")
795
890
  }
796
891
  }
797
892
  }
@@ -817,6 +912,8 @@ internal class AnalyticsClient {
817
912
 
818
913
  /**
819
914
  * Flush all batched viewability events
915
+ *
916
+ * Merges events with the same pageId into a single object with combined viewability array.
820
917
  */
821
918
  private func flushViewabilityBatch() {
822
919
  viewabilityBatchLock.lock()
@@ -826,28 +923,40 @@ internal class AnalyticsClient {
826
923
  viewabilityBatchTimer = nil
827
924
  viewabilityBatchLock.unlock()
828
925
 
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)")
926
+ if events.isEmpty { return }
927
+
928
+ // Group by pageId and merge viewability into single objects
929
+ var merged: [String: ViewabilityEvent] = [:]
930
+ for event in events {
931
+ if var existing = merged[event.pageId] {
932
+ existing.viewability.append(contentsOf: event.viewability)
933
+ merged[event.pageId] = existing
934
+ } else {
935
+ merged[event.pageId] = event
936
+ }
937
+ }
938
+
939
+ let mergedEvents = Array(merged.values)
940
+
941
+ Task {
942
+ do {
943
+ let encoder = JSONEncoder()
944
+ let data = try encoder.encode(mergedEvents)
945
+ let json = String(data: data, encoding: .utf8)!
946
+ let url = "\(baseURL)/viewability"
947
+
948
+ BCLogger.debug("Sending \(mergedEvents.count) batched viewability groups (\(events.count) total)")
949
+
950
+ let result = await httpClient.post(url: url, body: json)
951
+
952
+ switch result {
953
+ case .success:
954
+ BCLogger.verbose("Viewability batch sent successfully")
955
+ case .failure(let error):
956
+ BCLogger.warning("Failed to send viewability batch: \(error)")
850
957
  }
958
+ } catch {
959
+ BCLogger.error("Error sending viewability batch: \(error)")
851
960
  }
852
961
  }
853
962
  }
@@ -10,8 +10,8 @@ import Foundation
10
10
  *
11
11
  * Usage:
12
12
  * ```swift
13
- * let targeting = await bidRequestClient.fetchDemand(placement: placement)
14
- * // targeting is [String: String] with GAM custom targeting KVPs
13
+ * let bidResponse = await bidRequestClient.fetchDemand(placement: placement)
14
+ * // bidResponse contains targeting KVPs and auction metadata
15
15
  * ```
16
16
  */
17
17
  internal class BidRequestClient {
@@ -29,9 +29,15 @@ internal class BidRequestClient {
29
29
 
30
30
  // MARK: - Types
31
31
 
32
+ /// Bid response containing both targeting KVPs and auction metadata
33
+ internal struct BidResponse {
34
+ let targeting: [String: String]
35
+ let auctionData: AuctionData
36
+ }
37
+
32
38
  private struct PendingRequest {
33
39
  let placement: PlacementConfig
34
- let continuation: CheckedContinuation<[String: String]?, Never>
40
+ let continuation: CheckedContinuation<BidResponse?, Never>
35
41
  }
36
42
 
37
43
  // MARK: - Init
@@ -59,7 +65,7 @@ internal class BidRequestClient {
59
65
  * - Parameter placement: The placement to fetch demand for
60
66
  * - Returns: Targeting KVPs to apply to GAM request, or nil
61
67
  */
62
- func fetchDemand(placement: PlacementConfig) async -> [String: String]? {
68
+ func fetchDemand(placement: PlacementConfig) async -> BidResponse? {
63
69
  guard s2sConfig.enabled else {
64
70
  BCLogger.debug("BidRequestClient: S2S is disabled, skipping bid request")
65
71
  return nil
@@ -104,15 +110,15 @@ internal class BidRequestClient {
104
110
 
105
111
  // Distribute results to each pending continuation
106
112
  for pending in batch {
107
- let targeting = results[pending.placement.placementId]
108
- pending.continuation.resume(returning: targeting)
113
+ let bidResponse = results[pending.placement.placementId]
114
+ pending.continuation.resume(returning: bidResponse)
109
115
  }
110
116
  }
111
117
  }
112
118
 
113
119
  // MARK: - Request Building & Execution
114
120
 
115
- private func executeBidRequest(placements: [PlacementConfig]) async -> [String: [String: String]] {
121
+ private func executeBidRequest(placements: [PlacementConfig]) async -> [String: BidResponse] {
116
122
  let requestBody = buildRequestJSON(placements: placements)
117
123
 
118
124
  guard let jsonData = try? JSONSerialization.data(withJSONObject: requestBody),
@@ -131,7 +137,7 @@ internal class BidRequestClient {
131
137
 
132
138
  switch result {
133
139
  case .success(let responseJson):
134
- return parseResponse(responseJson)
140
+ return parseResponse(responseJson, placements: placements)
135
141
  case .failure(let error):
136
142
  BCLogger.error("BidRequestClient: Bid request failed: \(error)")
137
143
  return [:]
@@ -310,7 +316,7 @@ internal class BidRequestClient {
310
316
  *
311
317
  * Groups bids by impid, picks winner (highest price), copies ext.targeting.
312
318
  */
313
- private func parseResponse(_ jsonString: String) -> [String: [String: String]] {
319
+ private func parseResponse(_ jsonString: String, placements: [PlacementConfig]) -> [String: BidResponse] {
314
320
  guard let data = jsonString.data(using: .utf8),
315
321
  let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
316
322
  let seatbids = json["seatbid"] as? [[String: Any]] else {
@@ -318,10 +324,23 @@ internal class BidRequestClient {
318
324
  return [:]
319
325
  }
320
326
 
327
+ let auctionId = json["id"] as? String
328
+
329
+ // Build floor price lookup from placement configs
330
+ let floorPrices = Dictionary(uniqueKeysWithValues: placements.map { ($0.placementId, $0.floorPrice) })
331
+
321
332
  // Collect all bids grouped by impid
322
- var bidsByImpId: [String: [(price: Double, targeting: [String: String])]] = [:]
333
+ struct BidInfo {
334
+ let price: Double
335
+ let targeting: [String: String]
336
+ let seat: String
337
+ let creativeId: String?
338
+ }
339
+
340
+ var bidsByImpId: [String: [BidInfo]] = [:]
323
341
 
324
342
  for seatbid in seatbids {
343
+ let seat = seatbid["seat"] as? String ?? ""
325
344
  guard let bids = seatbid["bid"] as? [[String: Any]] else { continue }
326
345
  for bid in bids {
327
346
  guard let impid = bid["impid"] as? String,
@@ -330,17 +349,35 @@ internal class BidRequestClient {
330
349
  let targeting = ext["targeting"] as? [String: String] else {
331
350
  continue
332
351
  }
333
- bidsByImpId[impid, default: []].append((price: price, targeting: targeting))
352
+ let crid = bid["crid"] as? String
353
+ bidsByImpId[impid, default: []].append(BidInfo(
354
+ price: price, targeting: targeting, seat: seat, creativeId: crid
355
+ ))
334
356
  }
335
357
  }
336
358
 
337
- // Pick winner (highest price) for each impid
338
- var results: [String: [String: String]] = [:]
359
+ // Pick winner (highest price) for each impid and compute auction metadata
360
+ var results: [String: BidResponse] = [:]
339
361
  for (impid, bids) in bidsByImpId {
340
- if let winner = bids.max(by: { $0.price < $1.price }) {
341
- results[impid] = winner.targeting
342
- BCLogger.debug("BidRequestClient: Winner for \(impid): $\(winner.price)")
343
- }
362
+ let sortedBids = bids.sorted { $0.price > $1.price }
363
+ guard let winner = sortedBids.first else { continue }
364
+
365
+ let secondHighestBid = sortedBids.count > 1 ? sortedBids[1].price : nil
366
+ let floorPrice = floorPrices[impid] ?? nil
367
+
368
+ let auctionData = AuctionData(
369
+ auctionId: auctionId,
370
+ bidder: winner.seat,
371
+ bidPriceCpm: winner.price,
372
+ creativeId: winner.creativeId,
373
+ gamPriceBucket: winner.targeting["hb_pb"],
374
+ floorPrice: floorPrice,
375
+ secondHighestBid: secondHighestBid,
376
+ demandChannel: "S2S"
377
+ )
378
+
379
+ results[impid] = BidResponse(targeting: winner.targeting, auctionData: auctionData)
380
+ BCLogger.debug("BidRequestClient: Winner for \(impid): $\(winner.price)")
344
381
  }
345
382
 
346
383
  BCLogger.info("BidRequestClient: Parsed \(results.count) winning bids")
@@ -74,7 +74,7 @@ internal final class DeviceContext {
74
74
  // MARK: - SDK Properties
75
75
 
76
76
  /// SDK version
77
- static let SDK_VERSION = "0.11.0"
77
+ static let SDK_VERSION = "0.14.0"
78
78
  let sdkVersion: String = SDK_VERSION
79
79
 
80
80
  /// SDK platform (always "ios")