@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,344 @@
1
+ import Foundation
2
+
3
+ /**
4
+ * Custom S2S bid request client
5
+ *
6
+ * Replaces Prebid SDK's `fetchDemand()` with a direct HTTP call to the BigCrunch
7
+ * S2S endpoint. Builds OpenRTB-style requests with `ext.bidders` format and
8
+ * implements screen-level batching (100ms debounce) to combine multiple placements
9
+ * into a single multi-impression request.
10
+ *
11
+ * Usage:
12
+ * ```swift
13
+ * let targeting = await bidRequestClient.fetchDemand(placement: placement)
14
+ * // targeting is [String: String] with GAM custom targeting KVPs
15
+ * ```
16
+ */
17
+ internal class BidRequestClient {
18
+
19
+ private let httpClient: HTTPClient
20
+ private let configManager: ConfigManager
21
+ private let privacyStore: PrivacyStore
22
+ private let s2sConfig: S2SConfig
23
+
24
+ private let lock = NSLock()
25
+ private var pendingRequests: [PendingRequest] = []
26
+ private var batchTimer: Timer?
27
+
28
+ private static let batchDelayMs: Int = 100
29
+
30
+ // MARK: - Types
31
+
32
+ private struct PendingRequest {
33
+ let placement: PlacementConfig
34
+ let continuation: CheckedContinuation<[String: String]?, Never>
35
+ }
36
+
37
+ // MARK: - Init
38
+
39
+ init(
40
+ httpClient: HTTPClient,
41
+ configManager: ConfigManager,
42
+ privacyStore: PrivacyStore,
43
+ s2sConfig: S2SConfig
44
+ ) {
45
+ self.httpClient = httpClient
46
+ self.configManager = configManager
47
+ self.privacyStore = privacyStore
48
+ self.s2sConfig = s2sConfig
49
+ }
50
+
51
+ // MARK: - Public API
52
+
53
+ /**
54
+ * Fetch demand for a single placement
55
+ *
56
+ * The placement is queued and batched with other placements requested within
57
+ * a 100ms window. Returns GAM custom targeting KVPs on success, nil on failure.
58
+ *
59
+ * - Parameter placement: The placement to fetch demand for
60
+ * - Returns: Targeting KVPs to apply to GAM request, or nil
61
+ */
62
+ func fetchDemand(placement: PlacementConfig) async -> [String: String]? {
63
+ return await withCheckedContinuation { continuation in
64
+ lock.lock()
65
+ pendingRequests.append(PendingRequest(
66
+ placement: placement,
67
+ continuation: continuation
68
+ ))
69
+
70
+ if batchTimer == nil {
71
+ let delay = TimeInterval(Self.batchDelayMs) / 1000.0
72
+ let timer = Timer(timeInterval: delay, repeats: false) { [weak self] _ in
73
+ self?.flushBatch()
74
+ }
75
+ RunLoop.main.add(timer, forMode: .common)
76
+ batchTimer = timer
77
+ }
78
+ lock.unlock()
79
+ }
80
+ }
81
+
82
+ // MARK: - Batching
83
+
84
+ private func flushBatch() {
85
+ lock.lock()
86
+ let batch = pendingRequests
87
+ pendingRequests = []
88
+ batchTimer?.invalidate()
89
+ batchTimer = nil
90
+ lock.unlock()
91
+
92
+ guard !batch.isEmpty else { return }
93
+
94
+ let placements = batch.map { $0.placement }
95
+ BCLogger.debug("BidRequestClient: Flushing batch of \(placements.count) placements")
96
+
97
+ Task {
98
+ let results = await executeBidRequest(placements: placements)
99
+
100
+ // Distribute results to each pending continuation
101
+ for pending in batch {
102
+ let targeting = results[pending.placement.placementId]
103
+ pending.continuation.resume(returning: targeting)
104
+ }
105
+ }
106
+ }
107
+
108
+ // MARK: - Request Building & Execution
109
+
110
+ private func executeBidRequest(placements: [PlacementConfig]) async -> [String: [String: String]] {
111
+ let requestBody = buildRequestJSON(placements: placements)
112
+
113
+ guard let jsonData = try? JSONSerialization.data(withJSONObject: requestBody),
114
+ let jsonString = String(data: jsonData, encoding: .utf8) else {
115
+ BCLogger.error("BidRequestClient: Failed to serialize bid request")
116
+ return [:]
117
+ }
118
+
119
+ BCLogger.verbose("BidRequestClient: Sending bid request to \(s2sConfig.serverUrl)")
120
+
121
+ let result = await httpClient.post(
122
+ url: s2sConfig.serverUrl,
123
+ body: jsonString,
124
+ headers: [:]
125
+ )
126
+
127
+ switch result {
128
+ case .success(let responseJson):
129
+ return parseResponse(responseJson)
130
+ case .failure(let error):
131
+ BCLogger.error("BidRequestClient: Bid request failed: \(error)")
132
+ return [:]
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Build OpenRTB-style bid request JSON with ext.bidders format
138
+ */
139
+ private func buildRequestJSON(placements: [PlacementConfig]) -> [String: Any] {
140
+ var request: [String: Any] = [:]
141
+
142
+ // Request ID
143
+ request["id"] = UUID().uuidString
144
+
145
+ // Impressions
146
+ request["imp"] = placements.map { buildImp($0) }
147
+
148
+ // App object
149
+ let deviceContext = DeviceContext.shared
150
+ request["app"] = [
151
+ "bundle": deviceContext.appBundleId,
152
+ "ver": deviceContext.appVersion,
153
+ "publisher": ["id": BigCrunchAds.propertyId]
154
+ ]
155
+
156
+ // Device object
157
+ request["device"] = buildDevice(deviceContext)
158
+
159
+ // Privacy: regs & user
160
+ let regs = privacyStore.buildRegsDict()
161
+ if !regs.isEmpty {
162
+ request["regs"] = regs
163
+ }
164
+
165
+ let user = privacyStore.buildUserDict()
166
+ if !user.isEmpty {
167
+ request["user"] = user
168
+ }
169
+
170
+ // ext.bidders — the new format
171
+ let biddersExt = buildBiddersExt(placements: placements)
172
+ if !biddersExt.isEmpty {
173
+ request["ext"] = ["bidders": biddersExt]
174
+ }
175
+
176
+ return request
177
+ }
178
+
179
+ private func buildImp(_ placement: PlacementConfig) -> [String: Any] {
180
+ var imp: [String: Any] = [
181
+ "id": placement.placementId
182
+ ]
183
+
184
+ switch placement.format {
185
+ case "banner":
186
+ var banner: [String: Any] = [:]
187
+ if let sizes = placement.sizes {
188
+ banner["format"] = sizes.map { size -> [String: Any] in
189
+ if size.isAdaptive {
190
+ // Use screen width for adaptive
191
+ let screenWidth = DeviceContext.shared.screenWidth
192
+ return ["w": screenWidth, "h": 0]
193
+ }
194
+ return ["w": size.width, "h": size.height]
195
+ }
196
+ }
197
+ imp["banner"] = banner
198
+
199
+ case "interstitial":
200
+ imp["instl"] = 1
201
+ imp["banner"] = [
202
+ "format": [
203
+ ["w": DeviceContext.shared.screenWidth,
204
+ "h": DeviceContext.shared.screenHeight]
205
+ ]
206
+ ]
207
+
208
+ case "rewarded":
209
+ imp["instl"] = 1
210
+ imp["video"] = [
211
+ "mimes": ["video/mp4"],
212
+ "protocols": [2, 5], // VAST 2.0, VAST 2.0 Wrapper
213
+ "w": DeviceContext.shared.screenWidth,
214
+ "h": DeviceContext.shared.screenHeight
215
+ ]
216
+ imp["ext"] = ["rewarded": 1]
217
+
218
+ default:
219
+ break
220
+ }
221
+
222
+ return imp
223
+ }
224
+
225
+ private func buildDevice(_ ctx: DeviceContext) -> [String: Any] {
226
+ var device: [String: Any] = [
227
+ "os": ctx.osName,
228
+ "osv": ctx.osVersion,
229
+ "make": "Apple",
230
+ "model": ctx.deviceModel,
231
+ "w": ctx.screenWidth,
232
+ "h": ctx.screenHeight,
233
+ "pxratio": ctx.screenScale,
234
+ "language": ctx.languageCode
235
+ ]
236
+
237
+ // User-Agent — use a reasonable default
238
+ device["ua"] = ctx.getBrowserField()
239
+
240
+ return device
241
+ }
242
+
243
+ /**
244
+ * Build the ext.bidders section
245
+ *
246
+ * For each bidder in AppConfig.bidders, check which of the requested placements
247
+ * have entries in that bidder's placements map. Only include relevant bidders/imps.
248
+ */
249
+ private func buildBiddersExt(placements: [PlacementConfig]) -> [String: Any] {
250
+ guard let bidders = configManager.getCachedConfig()?.bidders else {
251
+ return [:]
252
+ }
253
+
254
+ let placementIds = Set(placements.map { $0.placementId })
255
+ var biddersExt: [String: Any] = [:]
256
+
257
+ for (bidderName, bidderEntry) in bidders {
258
+ var bidderObj: [String: Any] = [:]
259
+
260
+ // Shared params
261
+ if let params = bidderEntry.params {
262
+ bidderObj["params"] = params.mapValues { $0.value }
263
+ }
264
+
265
+ // Per-imp params (only for placements in this batch)
266
+ var impEntries: [[String: Any]] = []
267
+ if let bidderPlacements = bidderEntry.placements {
268
+ for placementId in placementIds {
269
+ if let impParams = bidderPlacements[placementId] {
270
+ impEntries.append([
271
+ "impid": placementId,
272
+ "params": impParams.mapValues { $0.value }
273
+ ])
274
+ }
275
+ }
276
+ }
277
+
278
+ // Only include bidder if it has relevant impressions
279
+ if !impEntries.isEmpty {
280
+ bidderObj["imp"] = impEntries
281
+ biddersExt[bidderName] = bidderObj
282
+ }
283
+ }
284
+
285
+ return biddersExt
286
+ }
287
+
288
+ // MARK: - Response Parsing
289
+
290
+ /**
291
+ * Parse S2S response and extract per-placement targeting
292
+ *
293
+ * Expected format:
294
+ * ```json
295
+ * {
296
+ * "seatbid": [{
297
+ * "bid": [{
298
+ * "impid": "leaderboard",
299
+ * "price": 2.50,
300
+ * "ext": { "targeting": { "hb_pb": "2.50", ... } }
301
+ * }]
302
+ * }]
303
+ * }
304
+ * ```
305
+ *
306
+ * Groups bids by impid, picks winner (highest price), copies ext.targeting.
307
+ */
308
+ private func parseResponse(_ jsonString: String) -> [String: [String: String]] {
309
+ guard let data = jsonString.data(using: .utf8),
310
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
311
+ let seatbids = json["seatbid"] as? [[String: Any]] else {
312
+ BCLogger.error("BidRequestClient: Failed to parse bid response")
313
+ return [:]
314
+ }
315
+
316
+ // Collect all bids grouped by impid
317
+ var bidsByImpId: [String: [(price: Double, targeting: [String: String])]] = [:]
318
+
319
+ for seatbid in seatbids {
320
+ guard let bids = seatbid["bid"] as? [[String: Any]] else { continue }
321
+ for bid in bids {
322
+ guard let impid = bid["impid"] as? String,
323
+ let price = bid["price"] as? Double,
324
+ let ext = bid["ext"] as? [String: Any],
325
+ let targeting = ext["targeting"] as? [String: String] else {
326
+ continue
327
+ }
328
+ bidsByImpId[impid, default: []].append((price: price, targeting: targeting))
329
+ }
330
+ }
331
+
332
+ // Pick winner (highest price) for each impid
333
+ var results: [String: [String: String]] = [:]
334
+ for (impid, bids) in bidsByImpId {
335
+ if let winner = bids.max(by: { $0.price < $1.price }) {
336
+ results[impid] = winner.targeting
337
+ BCLogger.debug("BidRequestClient: Winner for \(impid): $\(winner.price)")
338
+ }
339
+ }
340
+
341
+ BCLogger.info("BidRequestClient: Parsed \(results.count) winning bids")
342
+ return results
343
+ }
344
+ }
@@ -0,0 +1,305 @@
1
+ import Foundation
2
+
3
+ /**
4
+ * Manages application configuration fetching, caching, and access
5
+ *
6
+ * ConfigManager is responsible for:
7
+ * - Fetching config from BigCrunch backend
8
+ * - Caching config in memory for fast access
9
+ * - Persisting config to storage for offline/cold start
10
+ * - Thread-safe config access
11
+ * - Placement lookup by ID
12
+ *
13
+ * The config is fetched once at SDK initialization and cached for the app session.
14
+ */
15
+ internal class ConfigManager {
16
+
17
+ private let httpClient: HTTPClient
18
+ private let storage: KeyValueStore
19
+ private let lock = NSLock()
20
+
21
+ private var cachedConfig: AppConfig?
22
+
23
+ private static let configStorageKey = "app_config"
24
+ private static let prodBaseURL = "https://ship.bigcrunch.com"
25
+ private static let stagingBaseURL = "https://dev-ship.bigcrunch.com"
26
+
27
+ init(httpClient: HTTPClient, storage: KeyValueStore) {
28
+ self.httpClient = httpClient
29
+ self.storage = storage
30
+ }
31
+
32
+ /**
33
+ * Generate mock configuration for testing
34
+ *
35
+ * Uses Google's official sample ad units:
36
+ * - Banner: ca-app-pub-3940256099942544/2435281174 (320x50)
37
+ * - MREC: ca-app-pub-3940256099942544/6300978111 (300x250 - same as banner sample)
38
+ * - Interstitial: ca-app-pub-3940256099942544/4411468910
39
+ *
40
+ * For GAM, we need to use a GAM-style ad unit path format.
41
+ * Using empty network code with sample ad unit IDs for testing.
42
+ */
43
+ private static func getMockConfig(propertyId: String) -> AppConfig {
44
+ return AppConfig(
45
+ propertyId: propertyId,
46
+ appName: "Mock Test App",
47
+ gamNetworkCode: "", // Empty for AdMob sample ads
48
+ s2s: S2SConfig(
49
+ serverUrl: "https://s2s.bigcrunch.com/auction",
50
+ timeoutMs: 3000
51
+ ),
52
+ bidders: nil, // No bidders in mock/test mode
53
+ amazonAps: nil,
54
+ useTestAds: true, // Use Google's test ad units in mock mode
55
+ refresh: RefreshConfig(enabled: true, intervalMs: 30000, maxRefreshes: 20),
56
+ placements: [
57
+ PlacementConfig(
58
+ placementId: "test_banner_320x50",
59
+ format: "banner",
60
+ gamAdUnit: "ca-app-pub-3940256099942544/2435281174",
61
+ sizes: [AdSize(width: 320, height: 50)]
62
+ ),
63
+ PlacementConfig(
64
+ placementId: "test_mrec",
65
+ format: "banner",
66
+ gamAdUnit: "ca-app-pub-3940256099942544/2435281174",
67
+ sizes: [AdSize(width: 300, height: 250)],
68
+ refresh: RefreshConfig(enabled: true, intervalMs: 15000, maxRefreshes: 40)
69
+ ),
70
+ PlacementConfig(
71
+ placementId: "test_adaptive_banner",
72
+ format: "banner",
73
+ gamAdUnit: "ca-app-pub-3940256099942544/2435281174",
74
+ sizes: [AdSize.adaptive()]
75
+ ),
76
+ PlacementConfig(
77
+ placementId: "test_interstitial",
78
+ format: "interstitial",
79
+ gamAdUnit: "ca-app-pub-3940256099942544/4411468910",
80
+ sizes: nil
81
+ ),
82
+ PlacementConfig(
83
+ placementId: "test_rewarded",
84
+ format: "rewarded",
85
+ gamAdUnit: "ca-app-pub-3940256099942544/1712485313",
86
+ sizes: nil
87
+ )
88
+ ]
89
+ )
90
+ }
91
+
92
+ /**
93
+ * Load configuration from BigCrunch backend or mock data
94
+ *
95
+ * This method:
96
+ * 1. If useMockConfig is true, returns hardcoded mock config immediately
97
+ * 2. Attempts to load cached config from storage first (for offline/cold start)
98
+ * 3. Fetches fresh config from network
99
+ * 4. If network succeeds, updates cache and storage
100
+ * 5. If network fails but cache exists, returns cached version
101
+ * 6. If network fails and no cache, returns error
102
+ *
103
+ * - Parameters:
104
+ * - propertyId: BigCrunch property ID
105
+ * - isProd: True for production, false for staging
106
+ * - useMockConfig: If true, returns mock config for testing
107
+ * - Returns: Result containing AppConfig or error
108
+ */
109
+ func loadConfig(
110
+ propertyId: String,
111
+ isProd: Bool,
112
+ useMockConfig: Bool = false
113
+ ) async -> Result<AppConfig, Error> {
114
+ lock.lock()
115
+ defer { lock.unlock() }
116
+
117
+ BCLogger.debug("Loading config for property: \(propertyId) (mock: \(useMockConfig))")
118
+
119
+ // If mock mode enabled, return mock config immediately
120
+ if useMockConfig {
121
+ let mockConfig = Self.getMockConfig(propertyId: propertyId)
122
+ cachedConfig = mockConfig
123
+ BCLogger.info("Using mock config (\(mockConfig.placements.count) placements)")
124
+ return .success(mockConfig)
125
+ }
126
+
127
+ // 1. Try to load from storage first (for offline/cold start)
128
+ if let storedConfig = loadFromStorage() {
129
+ cachedConfig = storedConfig
130
+ BCLogger.debug("Loaded config from storage")
131
+ }
132
+
133
+ // 2. Fetch fresh config from network
134
+ let baseURL = isProd ? Self.prodBaseURL : Self.stagingBaseURL
135
+ let url = "\(baseURL)/config-app/\(propertyId).json"
136
+
137
+ let result = await httpClient.get(
138
+ url: url,
139
+ headers: [:]
140
+ )
141
+
142
+ switch result {
143
+ case .success(let json):
144
+ // Network request succeeded
145
+ do {
146
+ let data = json.data(using: .utf8)!
147
+ let config = try JSONDecoder().decode(AppConfig.self, from: data)
148
+ cachedConfig = config
149
+ saveToStorage(json)
150
+ BCLogger.info("Config loaded successfully from network (\(config.placements.count) placements)")
151
+ return .success(config)
152
+ } catch {
153
+ BCLogger.error("Failed to parse config JSON: \(error)")
154
+ return .failure(error)
155
+ }
156
+
157
+ case .failure(let error):
158
+ // Network failed, check for cached version
159
+ if let cached = cachedConfig {
160
+ BCLogger.warning("Using cached config, network fetch failed: \(error)")
161
+ return .success(cached)
162
+ } else {
163
+ BCLogger.error("Config load failed and no cache available")
164
+ return .failure(error)
165
+ }
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Get placement configuration by ID
171
+ *
172
+ * This is a synchronous operation that looks up the placement in the cached config.
173
+ * Must be called after loadConfig() has succeeded at least once.
174
+ *
175
+ * - Parameter placementId: The placement ID to look up
176
+ * - Returns: PlacementConfig if found, nil otherwise
177
+ */
178
+ func getPlacement(_ placementId: String) -> PlacementConfig? {
179
+ let placement = cachedConfig?.placements.first {
180
+ $0.placementId == placementId
181
+ }
182
+
183
+ if placement == nil {
184
+ BCLogger.warning("Placement not found: \(placementId)")
185
+ } else {
186
+ BCLogger.verbose("Found placement: \(placementId) (\(placement!.format))")
187
+ }
188
+
189
+ return placement
190
+ }
191
+
192
+ /**
193
+ * Get all placements from cached config
194
+ *
195
+ * - Returns: List of all placements, or empty list if config not loaded
196
+ */
197
+ func getAllPlacements() -> [PlacementConfig] {
198
+ return cachedConfig?.placements ?? []
199
+ }
200
+
201
+ /**
202
+ * Get the GAM network code from cached config
203
+ *
204
+ * - Returns: The GAM network code, or nil if config not loaded
205
+ */
206
+ func getGamNetworkCode() -> String? {
207
+ return cachedConfig?.gamNetworkCode
208
+ }
209
+
210
+ /**
211
+ * Get the S2S config from cached config
212
+ *
213
+ * - Returns: The S2S config, or nil if config not loaded
214
+ */
215
+ func getS2SConfig() -> S2SConfig? {
216
+ return cachedConfig?.s2s
217
+ }
218
+
219
+ /**
220
+ * Get the cached app configuration
221
+ *
222
+ * - Returns: The cached AppConfig, or nil if not yet loaded
223
+ */
224
+ func getCachedConfig() -> AppConfig? {
225
+ lock.lock()
226
+ defer { lock.unlock() }
227
+ return cachedConfig
228
+ }
229
+
230
+ /**
231
+ * Check if test ads should be used
232
+ *
233
+ * Returns true if the cached config has useTestAds enabled.
234
+ * When true, GoogleAdsAdapter should substitute production ad units with Google's test ad units.
235
+ *
236
+ * - Returns: True if test ads should be used, false otherwise
237
+ */
238
+ func shouldUseTestAds() -> Bool {
239
+ lock.lock()
240
+ defer { lock.unlock() }
241
+ return cachedConfig?.useTestAds ?? false
242
+ }
243
+
244
+ /**
245
+ * Get the effective refresh config for a placement
246
+ *
247
+ * Resolution order:
248
+ * 1. Placement-level refresh config (if present, even if disabled)
249
+ * 2. Global refresh config from AppConfig
250
+ * 3. nil (no refresh)
251
+ *
252
+ * - Parameter placementId: The placement ID to look up
253
+ * - Returns: RefreshConfig if refresh is configured, nil otherwise
254
+ */
255
+ func getEffectiveRefreshConfig(placementId: String) -> RefreshConfig? {
256
+ guard let placement = getPlacement(placementId) else { return nil }
257
+ // Placement-level override takes priority
258
+ if let placementRefresh = placement.refresh {
259
+ return placementRefresh
260
+ }
261
+ // Fall back to global config
262
+ return cachedConfig?.refresh
263
+ }
264
+
265
+ /**
266
+ * Clear cached config (for testing)
267
+ */
268
+ func clearCache() {
269
+ lock.lock()
270
+ defer { lock.unlock() }
271
+
272
+ cachedConfig = nil
273
+ storage.clear()
274
+ BCLogger.debug("Cache cleared")
275
+ }
276
+
277
+ /**
278
+ * Load config from persistent storage
279
+ */
280
+ private func loadFromStorage() -> AppConfig? {
281
+ guard let json = storage.getString(key: Self.configStorageKey, default: nil),
282
+ let data = json.data(using: .utf8) else {
283
+ return nil
284
+ }
285
+
286
+ do {
287
+ return try JSONDecoder().decode(AppConfig.self, from: data)
288
+ } catch {
289
+ BCLogger.error("Failed to load config from storage: \(error)")
290
+ return nil
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Save config to persistent storage
296
+ */
297
+ private func saveToStorage(_ json: String) {
298
+ do {
299
+ storage.putString(key: Self.configStorageKey, value: json)
300
+ BCLogger.verbose("Config saved to storage")
301
+ } catch {
302
+ BCLogger.error("Failed to save config to storage: \(error)")
303
+ }
304
+ }
305
+ }