@bigcrunch/react-native-ads 0.10.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/android/bigcrunch-ads/com/bigcrunch/ads/BigCrunchAds.kt +7 -4
  2. package/android/bigcrunch-ads/com/bigcrunch/ads/adapters/GoogleAdsAdapter.kt +14 -3
  3. package/android/bigcrunch-ads/com/bigcrunch/ads/core/AdOrchestrator.kt +37 -12
  4. package/android/bigcrunch-ads/com/bigcrunch/ads/core/AnalyticsClient.kt +199 -40
  5. package/android/bigcrunch-ads/com/bigcrunch/ads/core/BidRequestClient.kt +52 -17
  6. package/android/bigcrunch-ads/com/bigcrunch/ads/core/DeviceContext.kt +1 -1
  7. package/android/bigcrunch-ads/com/bigcrunch/ads/models/AdEvent.kt +81 -2
  8. package/android/bigcrunch-ads/com/bigcrunch/ads/models/PlacementConfig.kt +4 -1
  9. package/android/src/main/java/com/bigcrunch/ads/react/BigCrunchAdsModule.kt +44 -2
  10. package/ios/BigCrunchAds/Sources/Adapters/GoogleAdsAdapter.swift +10 -2
  11. package/ios/BigCrunchAds/Sources/BigCrunchAds.swift +10 -6
  12. package/ios/BigCrunchAds/Sources/BigCrunchRewarded.swift +10 -2
  13. package/ios/BigCrunchAds/Sources/Core/AdOrchestrator.swift +20 -4
  14. package/ios/BigCrunchAds/Sources/Core/AnalyticsClient.swift +179 -77
  15. package/ios/BigCrunchAds/Sources/Core/BidRequestClient.swift +54 -17
  16. package/ios/BigCrunchAds/Sources/Core/DeviceContext.swift +1 -1
  17. package/ios/BigCrunchAds/Sources/Internal/Logger.swift +11 -6
  18. package/ios/BigCrunchAds/Sources/Models/AdEvent.swift +111 -3
  19. package/ios/BigCrunchAds/Sources/Models/PlacementConfig.swift +4 -1
  20. package/ios/BigCrunchAdsModule.m +1 -0
  21. package/ios/BigCrunchAdsModule.swift +34 -2
  22. package/ios/BigCrunchBannerViewManager.swift +1 -0
  23. package/lib/BigCrunchAds.d.ts +19 -2
  24. package/lib/BigCrunchAds.d.ts.map +1 -1
  25. package/lib/BigCrunchAds.js +19 -2
  26. package/lib/NativeBigCrunchAds.d.ts +2 -2
  27. package/lib/NativeBigCrunchAds.d.ts.map +1 -1
  28. package/lib/types/index.d.ts +36 -0
  29. package/lib/types/index.d.ts.map +1 -1
  30. package/package.json +2 -2
  31. package/react-native-bigcrunch-ads.podspec +1 -1
  32. package/src/BigCrunchAds.ts +23 -2
  33. package/src/NativeBigCrunchAds.ts +2 -2
  34. package/src/types/index.ts +38 -0
@@ -26,9 +26,18 @@ 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
+
32
41
  /// Batching for impressions (250ms delay)
33
42
  private var impressionBatch: [ImpressionBatchEvent] = []
34
43
  private var impressionBatchTimer: Timer?
@@ -127,15 +136,34 @@ internal class AnalyticsClient {
127
136
 
128
137
  // MARK: - Page View Tracking
129
138
 
139
+ /**
140
+ * Generate the default page URL from screen name
141
+ */
142
+ private func generatePageUrl(_ screenName: String) -> String {
143
+ let appName = BigCrunchAds.getAppConfig()?.appName ?? "app"
144
+ let sanitizedAppName = appName.lowercased()
145
+ .replacingOccurrences(of: " ", with: "-")
146
+ .addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? appName
147
+ let encodedScreen = screenName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? screenName
148
+ return "https://\(sanitizedAppName).mobile.app/\(encodedScreen)"
149
+ }
150
+
130
151
  /**
131
152
  * Track a screen view
132
153
  *
133
- * - Parameter screenName: Name of the screen being viewed
154
+ * - Parameters:
155
+ * - screenName: Name of the screen being viewed
156
+ * - options: Optional overrides for page URL, metadata, and custom dimensions
134
157
  */
135
- func trackScreenView(_ screenName: String) {
158
+ func trackScreenView(_ screenName: String, options: ScreenViewOptions? = nil) {
136
159
  // Start new page view and get IDs
137
160
  let pageId = sessionManager.startPageView()
138
161
 
162
+ // Resolve page URL: explicit override > auto-generated
163
+ let pageUrl = options?.pageUrl ?? generatePageUrl(screenName)
164
+ currentPageUrl = pageUrl
165
+ currentCustomDimensions = options?.customDimensions ?? [:]
166
+
139
167
  // Get common event fields
140
168
  let common = getCommonEventFields()
141
169
 
@@ -155,7 +183,7 @@ internal class AnalyticsClient {
155
183
  newUser: sessionManager.isNewUser,
156
184
  pageId: pageId,
157
185
  sessionDepth: sessionManager.sessionDepth,
158
- pageUrl: "", // page_url must be valid URL or empty - mobile doesn't have URLs
186
+ pageUrl: pageUrl,
159
187
  pageSearch: "",
160
188
  pageReferrer: "", // page_referrer must be valid URL or empty
161
189
  browser: common.browser,
@@ -176,7 +204,8 @@ internal class AnalyticsClient {
176
204
  diiSource: "",
177
205
  gamNetworkCode: gamNetworkCode,
178
206
  amznPubId: AnalyticsClient.nilUUID, // amzn_pub_id must be valid UUID
179
- customDimensions: [:]
207
+ customDimensions: currentCustomDimensions,
208
+ pageMetaData: options?.pageMeta
180
209
  )
181
210
 
182
211
  sendEvent(event, endpoint: "pageviews")
@@ -249,6 +278,37 @@ internal class AnalyticsClient {
249
278
  return impressionContexts[impressionId]
250
279
  }
251
280
 
281
+ // MARK: - Auction Data Storage
282
+
283
+ /**
284
+ * Store auction data for a placement (called by AdOrchestrator after S2S demand fetch)
285
+ */
286
+ func setAuctionData(placementId: String, auctionData: AuctionData) {
287
+ contextLock.lock()
288
+ placementAuctionData[placementId] = auctionData
289
+ contextLock.unlock()
290
+ BCLogger.debug("Stored auction data for placement: \(placementId) (channel: \(auctionData.demandChannel ?? "unknown"))")
291
+ }
292
+
293
+ /**
294
+ * Consume auction data for a placement (returns and removes stored data)
295
+ */
296
+ private func consumeAuctionData(placementId: String) -> AuctionData? {
297
+ contextLock.lock()
298
+ let data = placementAuctionData.removeValue(forKey: placementId)
299
+ contextLock.unlock()
300
+ return data
301
+ }
302
+
303
+ /**
304
+ * Compute min_bid_to_win: $0.01 above the higher of second-highest bid or floor price
305
+ */
306
+ private func computeMinBidToWin(_ auctionData: AuctionData?) -> Double {
307
+ guard let data = auctionData else { return 0.0 }
308
+ let baseline = max(data.secondHighestBid ?? 0.0, data.floorPrice ?? 0.0)
309
+ return baseline > 0 ? baseline + 0.01 : 0.0
310
+ }
311
+
252
312
  // MARK: - Impression Tracking
253
313
 
254
314
  /**
@@ -278,27 +338,31 @@ internal class AnalyticsClient {
278
338
  ? context.auctionData.auctionId!
279
339
  : UUID().uuidString
280
340
 
341
+ // Look up stored auction data (may have been set by AdOrchestrator)
342
+ let storedAuctionData = consumeAuctionData(placementId: context.placementId)
343
+ let effectiveAuctionData = storedAuctionData ?? context.auctionData
344
+
281
345
  // Create the impression record
282
346
  let impressionRecord = ImpressionRecord(
283
347
  slotId: context.placementId,
284
348
  gamUnit: context.gamAdUnit,
285
- gamPriceBucket: "",
349
+ gamPriceBucket: effectiveAuctionData.gamPriceBucket ?? "",
286
350
  impressionId: context.impressionId,
287
351
  auctionId: auctionId, // Must be valid UUID
288
352
  refreshCount: 0,
289
- adBidder: context.auctionData.bidder ?? "",
353
+ adBidder: effectiveAuctionData.bidder ?? "",
290
354
  adSize: adSize,
291
- adPrice: context.auctionData.bidPriceCpm ?? 0.0,
292
- adFloorPrice: 0.0,
293
- minBidToWin: 0.0,
355
+ adPrice: effectiveAuctionData.bidPriceCpm ?? 0.0,
356
+ adFloorPrice: effectiveAuctionData.floorPrice ?? 0.0,
357
+ minBidToWin: computeMinBidToWin(effectiveAuctionData),
294
358
  advertiserId: "",
295
359
  campaignId: "",
296
360
  lineItemId: "",
297
- creativeId: context.auctionData.creativeId ?? "",
361
+ creativeId: effectiveAuctionData.creativeId ?? "",
298
362
  adAmznbid: "",
299
363
  adAmznp: "",
300
- adDemandType: "",
301
- demandChannel: "",
364
+ adDemandType: context.format,
365
+ demandChannel: effectiveAuctionData.demandChannel ?? "Google Ad Exchange",
302
366
  customDimensions: [:]
303
367
  )
304
368
 
@@ -313,7 +377,7 @@ internal class AnalyticsClient {
313
377
  newUser: sessionManager.isNewUser,
314
378
  pageId: sessionManager.getOrCreatePageId(),
315
379
  sessionDepth: sessionManager.sessionDepth,
316
- pageUrl: "", // page_url must be valid URL or empty
380
+ pageUrl: currentPageUrl,
317
381
  pageSearch: "",
318
382
  pageReferrer: "",
319
383
  browser: common.browser,
@@ -334,7 +398,7 @@ internal class AnalyticsClient {
334
398
  diiSource: "",
335
399
  gamNetworkCode: gamNetworkCode,
336
400
  amznPubId: AnalyticsClient.nilUUID, // amzn_pub_id must be valid UUID
337
- customDimensions: [:],
401
+ customDimensions: currentCustomDimensions,
338
402
  impressions: [impressionRecord]
339
403
  )
340
404
 
@@ -342,11 +406,13 @@ internal class AnalyticsClient {
342
406
  }
343
407
 
344
408
  /**
345
- * Track an ad impression (simple version without auction data)
409
+ * Track an ad impression (simple version with auction data lookup)
346
410
  */
347
411
  func trackAdImpression(
348
412
  placementId: String,
349
413
  format: String,
414
+ gamAdUnit: String = "",
415
+ adSize: String = "",
350
416
  refreshCount: Int = 0,
351
417
  advertiserId: String? = nil,
352
418
  campaignId: String? = nil,
@@ -362,27 +428,35 @@ internal class AnalyticsClient {
362
428
  // Region must be 2 chars or empty per backend validation
363
429
  let regionCode = common.region.count == 2 ? common.region : ""
364
430
 
365
- // Create the impression record with generated auction_id (required)
431
+ // Look up stored auction data from S2S response
432
+ let auctionData = consumeAuctionData(placementId: placementId)
433
+
434
+ // auction_id: use S2S auction ID if available, otherwise generate one
435
+ let auctionId = auctionData?.auctionId?.isEmpty == false
436
+ ? auctionData!.auctionId!
437
+ : UUID().uuidString
438
+
439
+ // Create the impression record with auction data fields populated
366
440
  let impressionRecord = ImpressionRecord(
367
441
  slotId: placementId,
368
- gamUnit: "",
369
- gamPriceBucket: "",
442
+ gamUnit: gamAdUnit,
443
+ gamPriceBucket: auctionData?.gamPriceBucket ?? "",
370
444
  impressionId: UUID().uuidString,
371
- auctionId: UUID().uuidString, // auction_id is required and must be valid UUID
445
+ auctionId: auctionId,
372
446
  refreshCount: refreshCount,
373
- adBidder: "",
374
- adSize: "",
375
- adPrice: 0.0,
376
- adFloorPrice: 0.0,
377
- minBidToWin: 0.0,
447
+ adBidder: auctionData?.bidder ?? "",
448
+ adSize: adSize,
449
+ adPrice: auctionData?.bidPriceCpm ?? 0.0,
450
+ adFloorPrice: auctionData?.floorPrice ?? 0.0,
451
+ minBidToWin: computeMinBidToWin(auctionData),
378
452
  advertiserId: advertiserId ?? "",
379
453
  campaignId: campaignId ?? "",
380
454
  lineItemId: lineItemId ?? "",
381
- creativeId: creativeId ?? "",
455
+ creativeId: auctionData?.creativeId ?? creativeId ?? "",
382
456
  adAmznbid: "",
383
457
  adAmznp: "",
384
- adDemandType: "",
385
- demandChannel: "",
458
+ adDemandType: format,
459
+ demandChannel: auctionData?.demandChannel ?? "Google Ad Exchange",
386
460
  customDimensions: [:]
387
461
  )
388
462
 
@@ -397,7 +471,7 @@ internal class AnalyticsClient {
397
471
  newUser: sessionManager.isNewUser,
398
472
  pageId: sessionManager.getOrCreatePageId(),
399
473
  sessionDepth: sessionManager.sessionDepth,
400
- pageUrl: "", // page_url must be valid URL or empty
474
+ pageUrl: currentPageUrl,
401
475
  pageSearch: "",
402
476
  pageReferrer: "",
403
477
  browser: common.browser,
@@ -418,7 +492,7 @@ internal class AnalyticsClient {
418
492
  diiSource: "",
419
493
  gamNetworkCode: gamNetworkCode,
420
494
  amznPubId: AnalyticsClient.nilUUID, // amzn_pub_id must be valid UUID
421
- customDimensions: [:],
495
+ customDimensions: currentCustomDimensions,
422
496
  impressions: [impressionRecord]
423
497
  )
424
498
 
@@ -474,7 +548,7 @@ internal class AnalyticsClient {
474
548
  newUser: sessionManager.isNewUser,
475
549
  pageId: sessionManager.getOrCreatePageId(),
476
550
  sessionDepth: sessionManager.sessionDepth,
477
- pageUrl: "",
551
+ pageUrl: currentPageUrl,
478
552
  pageSearch: "",
479
553
  pageReferrer: "",
480
554
  browser: common.browser,
@@ -495,7 +569,7 @@ internal class AnalyticsClient {
495
569
  diiSource: "",
496
570
  gamNetworkCode: gamNetworkCode,
497
571
  amznPubId: AnalyticsClient.nilUUID,
498
- customDimensions: [:],
572
+ customDimensions: currentCustomDimensions,
499
573
  click: clickData
500
574
  )
501
575
 
@@ -564,7 +638,7 @@ internal class AnalyticsClient {
564
638
  newUser: sessionManager.isNewUser,
565
639
  pageId: sessionManager.getOrCreatePageId(),
566
640
  sessionDepth: sessionManager.sessionDepth,
567
- pageUrl: "",
641
+ pageUrl: currentPageUrl,
568
642
  pageSearch: "",
569
643
  pageReferrer: "",
570
644
  browser: common.browser,
@@ -631,7 +705,7 @@ internal class AnalyticsClient {
631
705
  newUser: sessionManager.isNewUser,
632
706
  pageId: sessionManager.getOrCreatePageId(),
633
707
  sessionDepth: sessionManager.sessionDepth,
634
- pageUrl: "",
708
+ pageUrl: currentPageUrl,
635
709
  pageSearch: "",
636
710
  pageReferrer: "",
637
711
  browser: common.browser,
@@ -687,7 +761,7 @@ internal class AnalyticsClient {
687
761
  newUser: sessionManager.isNewUser,
688
762
  pageId: sessionManager.getOrCreatePageId(),
689
763
  sessionDepth: sessionManager.sessionDepth,
690
- pageUrl: "",
764
+ pageUrl: currentPageUrl,
691
765
  pageSearch: "",
692
766
  pageReferrer: "",
693
767
  browser: common.browser,
@@ -708,7 +782,7 @@ internal class AnalyticsClient {
708
782
  diiSource: "",
709
783
  gamNetworkCode: gamNetworkCode,
710
784
  amznPubId: AnalyticsClient.nilUUID,
711
- customDimensions: [:], // Values would be [String] arrays per backend schema
785
+ customDimensions: [:], // Engagement uses array-valued custom dimensions per backend schema
712
786
  engagedTime: engagedTime,
713
787
  timeOnPage: timeOnPage,
714
788
  scrollDepth: scrollDepth
@@ -761,6 +835,8 @@ internal class AnalyticsClient {
761
835
 
762
836
  /**
763
837
  * Flush all batched impression events
838
+ *
839
+ * Merges events with the same pageId into a single object with combined impressions array.
764
840
  */
765
841
  private func flushImpressionBatch() {
766
842
  impressionBatchLock.lock()
@@ -770,28 +846,40 @@ internal class AnalyticsClient {
770
846
  impressionBatchTimer = nil
771
847
  impressionBatchLock.unlock()
772
848
 
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)")
849
+ if events.isEmpty { return }
850
+
851
+ // Group by pageId and merge impressions into single objects
852
+ var merged: [String: ImpressionBatchEvent] = [:]
853
+ for event in events {
854
+ if var existing = merged[event.pageId] {
855
+ existing.impressions.append(contentsOf: event.impressions)
856
+ merged[event.pageId] = existing
857
+ } else {
858
+ merged[event.pageId] = event
859
+ }
860
+ }
861
+
862
+ let mergedEvents = Array(merged.values)
863
+
864
+ Task {
865
+ do {
866
+ let encoder = JSONEncoder()
867
+ let data = try encoder.encode(mergedEvents)
868
+ let json = String(data: data, encoding: .utf8)!
869
+ let url = "\(baseURL)/impressions"
870
+
871
+ BCLogger.debug("Sending \(mergedEvents.count) batched impression groups (\(events.count) total impressions)")
872
+
873
+ let result = await httpClient.post(url: url, body: json)
874
+
875
+ switch result {
876
+ case .success:
877
+ BCLogger.verbose("Impression batch sent successfully")
878
+ case .failure(let error):
879
+ BCLogger.warning("Failed to send impression batch: \(error)")
794
880
  }
881
+ } catch {
882
+ BCLogger.error("Error sending impression batch: \(error)")
795
883
  }
796
884
  }
797
885
  }
@@ -817,6 +905,8 @@ internal class AnalyticsClient {
817
905
 
818
906
  /**
819
907
  * Flush all batched viewability events
908
+ *
909
+ * Merges events with the same pageId into a single object with combined viewability array.
820
910
  */
821
911
  private func flushViewabilityBatch() {
822
912
  viewabilityBatchLock.lock()
@@ -826,28 +916,40 @@ internal class AnalyticsClient {
826
916
  viewabilityBatchTimer = nil
827
917
  viewabilityBatchLock.unlock()
828
918
 
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)")
919
+ if events.isEmpty { return }
920
+
921
+ // Group by pageId and merge viewability into single objects
922
+ var merged: [String: ViewabilityEvent] = [:]
923
+ for event in events {
924
+ if var existing = merged[event.pageId] {
925
+ existing.viewability.append(contentsOf: event.viewability)
926
+ merged[event.pageId] = existing
927
+ } else {
928
+ merged[event.pageId] = event
929
+ }
930
+ }
931
+
932
+ let mergedEvents = Array(merged.values)
933
+
934
+ Task {
935
+ do {
936
+ let encoder = JSONEncoder()
937
+ let data = try encoder.encode(mergedEvents)
938
+ let json = String(data: data, encoding: .utf8)!
939
+ let url = "\(baseURL)/viewability"
940
+
941
+ BCLogger.debug("Sending \(mergedEvents.count) batched viewability groups (\(events.count) total)")
942
+
943
+ let result = await httpClient.post(url: url, body: json)
944
+
945
+ switch result {
946
+ case .success:
947
+ BCLogger.verbose("Viewability batch sent successfully")
948
+ case .failure(let error):
949
+ BCLogger.warning("Failed to send viewability batch: \(error)")
850
950
  }
951
+ } catch {
952
+ BCLogger.error("Error sending viewability batch: \(error)")
851
953
  }
852
954
  }
853
955
  }
@@ -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.10.1"
77
+ static let SDK_VERSION = "0.13.0"
78
78
  let sdkVersion: String = SDK_VERSION
79
79
 
80
80
  /// SDK platform (always "ios")
@@ -1,14 +1,19 @@
1
1
  import Foundation
2
+ import os.log
2
3
 
3
4
  /**
4
5
  * Internal logger for BigCrunch Ads SDK
5
6
  *
6
- * All logs are prefixed with "[BCrunch:LEVEL]" for easy filtering.
7
+ * Uses os_log for unified logging, visible in Console.app and terminal
8
+ * without requiring the Xcode debugger to be attached.
9
+ * All logs use the "BCrunch" subsystem for easy filtering.
7
10
  * Logging can be disabled in production by setting isEnabled = false.
8
11
  * Error logs are always shown regardless of isEnabled flag.
9
12
  */
10
13
  internal class BCLogger {
11
14
 
15
+ private static let log = OSLog(subsystem: "com.bigcrunch.ads", category: "BCrunch")
16
+
12
17
  /**
13
18
  * Enable/disable debug logging
14
19
  * Defaults to false for production builds
@@ -20,7 +25,7 @@ internal class BCLogger {
20
25
  */
21
26
  static func verbose(_ message: String) {
22
27
  if isEnabled {
23
- print("[BCrunch:VERBOSE] \(message)")
28
+ os_log("[BCrunch:VERBOSE] %{public}@", log: log, type: .debug, message)
24
29
  }
25
30
  }
26
31
 
@@ -29,7 +34,7 @@ internal class BCLogger {
29
34
  */
30
35
  static func debug(_ message: String) {
31
36
  if isEnabled {
32
- print("[BCrunch:DEBUG] \(message)")
37
+ os_log("[BCrunch:DEBUG] %{public}@", log: log, type: .debug, message)
33
38
  }
34
39
  }
35
40
 
@@ -38,7 +43,7 @@ internal class BCLogger {
38
43
  */
39
44
  static func info(_ message: String) {
40
45
  if isEnabled {
41
- print("[BCrunch:INFO] \(message)")
46
+ os_log("[BCrunch:INFO] %{public}@", log: log, type: .info, message)
42
47
  }
43
48
  }
44
49
 
@@ -47,7 +52,7 @@ internal class BCLogger {
47
52
  */
48
53
  static func warning(_ message: String) {
49
54
  if isEnabled {
50
- print("[BCrunch:WARNING] \(message)")
55
+ os_log("[BCrunch:WARNING] %{public}@", log: log, type: .default, message)
51
56
  }
52
57
  }
53
58
 
@@ -57,6 +62,6 @@ internal class BCLogger {
57
62
  */
58
63
  static func error(_ message: String) {
59
64
  // Always log errors
60
- print("[BCrunch:ERROR] \(message)")
65
+ os_log("[BCrunch:ERROR] %{public}@", log: log, type: .error, message)
61
66
  }
62
67
  }