@bigcrunch/react-native-ads 0.4.0 → 0.5.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 (57) 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 +301 -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 +90 -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/ios/BigCrunchAds/Sources/Adapters/GoogleAdsAdapter.swift +512 -0
  30. package/ios/BigCrunchAds/Sources/BigCrunchAds.swift +387 -0
  31. package/ios/BigCrunchAds/Sources/BigCrunchBannerView.swift +448 -0
  32. package/ios/BigCrunchAds/Sources/BigCrunchInterstitial.swift +412 -0
  33. package/ios/BigCrunchAds/Sources/BigCrunchRewarded.swift +523 -0
  34. package/ios/BigCrunchAds/Sources/Core/AdOrchestrator.swift +514 -0
  35. package/ios/BigCrunchAds/Sources/Core/AnalyticsClient.swift +874 -0
  36. package/ios/BigCrunchAds/Sources/Core/BidRequestClient.swift +344 -0
  37. package/ios/BigCrunchAds/Sources/Core/ConfigManager.swift +306 -0
  38. package/ios/BigCrunchAds/Sources/Core/DeviceContext.swift +284 -0
  39. package/ios/BigCrunchAds/Sources/Core/SessionManager.swift +392 -0
  40. package/ios/BigCrunchAds/Sources/Internal/HTTPClient.swift +146 -0
  41. package/ios/BigCrunchAds/Sources/Internal/Logger.swift +62 -0
  42. package/ios/BigCrunchAds/Sources/Internal/PrivacyStore.swift +129 -0
  43. package/ios/BigCrunchAds/Sources/Internal/Storage.swift +73 -0
  44. package/ios/BigCrunchAds/Sources/Models/AdEvent.swift +784 -0
  45. package/ios/BigCrunchAds/Sources/Models/AppConfig.swift +100 -0
  46. package/ios/BigCrunchAds/Sources/Models/DeviceData.swift +68 -0
  47. package/ios/BigCrunchAds/Sources/Models/PlacementConfig.swift +137 -0
  48. package/ios/BigCrunchAds/Sources/Models/SessionInfo.swift +48 -0
  49. package/ios/BigCrunchAdsModule.swift +0 -1
  50. package/ios/BigCrunchBannerViewManager.swift +0 -1
  51. package/lib/index.d.ts +1 -1
  52. package/lib/index.d.ts.map +1 -1
  53. package/lib/index.js +3 -2
  54. package/package.json +8 -2
  55. package/react-native-bigcrunch-ads.podspec +0 -1
  56. package/scripts/inject-version.js +55 -0
  57. 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,306 @@
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
+ environment: "test",
48
+ gamNetworkCode: "", // Empty for AdMob sample ads
49
+ s2s: S2SConfig(
50
+ serverUrl: "https://s2s.bigcrunch.com/auction",
51
+ timeoutMs: 3000
52
+ ),
53
+ bidders: nil, // No bidders in mock/test mode
54
+ amazonAps: nil,
55
+ useTestAds: true, // Use Google's test ad units in mock mode
56
+ refresh: RefreshConfig(enabled: true, intervalMs: 30000, maxRefreshes: 20),
57
+ placements: [
58
+ PlacementConfig(
59
+ placementId: "test_banner_320x50",
60
+ format: "banner",
61
+ gamAdUnit: "ca-app-pub-3940256099942544/2435281174",
62
+ sizes: [AdSize(width: 320, height: 50)]
63
+ ),
64
+ PlacementConfig(
65
+ placementId: "test_mrec",
66
+ format: "banner",
67
+ gamAdUnit: "ca-app-pub-3940256099942544/2435281174",
68
+ sizes: [AdSize(width: 300, height: 250)],
69
+ refresh: RefreshConfig(enabled: true, intervalMs: 15000, maxRefreshes: 40)
70
+ ),
71
+ PlacementConfig(
72
+ placementId: "test_adaptive_banner",
73
+ format: "banner",
74
+ gamAdUnit: "ca-app-pub-3940256099942544/2435281174",
75
+ sizes: [AdSize.adaptive()]
76
+ ),
77
+ PlacementConfig(
78
+ placementId: "test_interstitial",
79
+ format: "interstitial",
80
+ gamAdUnit: "ca-app-pub-3940256099942544/4411468910",
81
+ sizes: nil
82
+ ),
83
+ PlacementConfig(
84
+ placementId: "test_rewarded",
85
+ format: "rewarded",
86
+ gamAdUnit: "ca-app-pub-3940256099942544/1712485313",
87
+ sizes: nil
88
+ )
89
+ ]
90
+ )
91
+ }
92
+
93
+ /**
94
+ * Load configuration from BigCrunch backend or mock data
95
+ *
96
+ * This method:
97
+ * 1. If useMockConfig is true, returns hardcoded mock config immediately
98
+ * 2. Attempts to load cached config from storage first (for offline/cold start)
99
+ * 3. Fetches fresh config from network
100
+ * 4. If network succeeds, updates cache and storage
101
+ * 5. If network fails but cache exists, returns cached version
102
+ * 6. If network fails and no cache, returns error
103
+ *
104
+ * - Parameters:
105
+ * - propertyId: BigCrunch property ID
106
+ * - isProd: True for production, false for staging
107
+ * - useMockConfig: If true, returns mock config for testing
108
+ * - Returns: Result containing AppConfig or error
109
+ */
110
+ func loadConfig(
111
+ propertyId: String,
112
+ isProd: Bool,
113
+ useMockConfig: Bool = false
114
+ ) async -> Result<AppConfig, Error> {
115
+ lock.lock()
116
+ defer { lock.unlock() }
117
+
118
+ BCLogger.debug("Loading config for property: \(propertyId) (mock: \(useMockConfig))")
119
+
120
+ // If mock mode enabled, return mock config immediately
121
+ if useMockConfig {
122
+ let mockConfig = Self.getMockConfig(propertyId: propertyId)
123
+ cachedConfig = mockConfig
124
+ BCLogger.info("Using mock config (\(mockConfig.placements.count) placements)")
125
+ return .success(mockConfig)
126
+ }
127
+
128
+ // 1. Try to load from storage first (for offline/cold start)
129
+ if let storedConfig = loadFromStorage() {
130
+ cachedConfig = storedConfig
131
+ BCLogger.debug("Loaded config from storage")
132
+ }
133
+
134
+ // 2. Fetch fresh config from network
135
+ let baseURL = isProd ? Self.prodBaseURL : Self.stagingBaseURL
136
+ let url = "\(baseURL)/config-app/\(propertyId).json"
137
+
138
+ let result = await httpClient.get(
139
+ url: url,
140
+ headers: [:]
141
+ )
142
+
143
+ switch result {
144
+ case .success(let json):
145
+ // Network request succeeded
146
+ do {
147
+ let data = json.data(using: .utf8)!
148
+ let config = try JSONDecoder().decode(AppConfig.self, from: data)
149
+ cachedConfig = config
150
+ saveToStorage(json)
151
+ BCLogger.info("Config loaded successfully from network (\(config.placements.count) placements)")
152
+ return .success(config)
153
+ } catch {
154
+ BCLogger.error("Failed to parse config JSON: \(error)")
155
+ return .failure(error)
156
+ }
157
+
158
+ case .failure(let error):
159
+ // Network failed, check for cached version
160
+ if let cached = cachedConfig {
161
+ BCLogger.warning("Using cached config, network fetch failed: \(error)")
162
+ return .success(cached)
163
+ } else {
164
+ BCLogger.error("Config load failed and no cache available")
165
+ return .failure(error)
166
+ }
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Get placement configuration by ID
172
+ *
173
+ * This is a synchronous operation that looks up the placement in the cached config.
174
+ * Must be called after loadConfig() has succeeded at least once.
175
+ *
176
+ * - Parameter placementId: The placement ID to look up
177
+ * - Returns: PlacementConfig if found, nil otherwise
178
+ */
179
+ func getPlacement(_ placementId: String) -> PlacementConfig? {
180
+ let placement = cachedConfig?.placements.first {
181
+ $0.placementId == placementId
182
+ }
183
+
184
+ if placement == nil {
185
+ BCLogger.warning("Placement not found: \(placementId)")
186
+ } else {
187
+ BCLogger.verbose("Found placement: \(placementId) (\(placement!.format))")
188
+ }
189
+
190
+ return placement
191
+ }
192
+
193
+ /**
194
+ * Get all placements from cached config
195
+ *
196
+ * - Returns: List of all placements, or empty list if config not loaded
197
+ */
198
+ func getAllPlacements() -> [PlacementConfig] {
199
+ return cachedConfig?.placements ?? []
200
+ }
201
+
202
+ /**
203
+ * Get the GAM network code from cached config
204
+ *
205
+ * - Returns: The GAM network code, or nil if config not loaded
206
+ */
207
+ func getGamNetworkCode() -> String? {
208
+ return cachedConfig?.gamNetworkCode
209
+ }
210
+
211
+ /**
212
+ * Get the S2S config from cached config
213
+ *
214
+ * - Returns: The S2S config, or nil if config not loaded
215
+ */
216
+ func getS2SConfig() -> S2SConfig? {
217
+ return cachedConfig?.s2s
218
+ }
219
+
220
+ /**
221
+ * Get the cached app configuration
222
+ *
223
+ * - Returns: The cached AppConfig, or nil if not yet loaded
224
+ */
225
+ func getCachedConfig() -> AppConfig? {
226
+ lock.lock()
227
+ defer { lock.unlock() }
228
+ return cachedConfig
229
+ }
230
+
231
+ /**
232
+ * Check if test ads should be used
233
+ *
234
+ * Returns true if the cached config has useTestAds enabled.
235
+ * When true, GoogleAdsAdapter should substitute production ad units with Google's test ad units.
236
+ *
237
+ * - Returns: True if test ads should be used, false otherwise
238
+ */
239
+ func shouldUseTestAds() -> Bool {
240
+ lock.lock()
241
+ defer { lock.unlock() }
242
+ return cachedConfig?.useTestAds ?? false
243
+ }
244
+
245
+ /**
246
+ * Get the effective refresh config for a placement
247
+ *
248
+ * Resolution order:
249
+ * 1. Placement-level refresh config (if present, even if disabled)
250
+ * 2. Global refresh config from AppConfig
251
+ * 3. nil (no refresh)
252
+ *
253
+ * - Parameter placementId: The placement ID to look up
254
+ * - Returns: RefreshConfig if refresh is configured, nil otherwise
255
+ */
256
+ func getEffectiveRefreshConfig(placementId: String) -> RefreshConfig? {
257
+ guard let placement = getPlacement(placementId) else { return nil }
258
+ // Placement-level override takes priority
259
+ if let placementRefresh = placement.refresh {
260
+ return placementRefresh
261
+ }
262
+ // Fall back to global config
263
+ return cachedConfig?.refresh
264
+ }
265
+
266
+ /**
267
+ * Clear cached config (for testing)
268
+ */
269
+ func clearCache() {
270
+ lock.lock()
271
+ defer { lock.unlock() }
272
+
273
+ cachedConfig = nil
274
+ storage.clear()
275
+ BCLogger.debug("Cache cleared")
276
+ }
277
+
278
+ /**
279
+ * Load config from persistent storage
280
+ */
281
+ private func loadFromStorage() -> AppConfig? {
282
+ guard let json = storage.getString(key: Self.configStorageKey, default: nil),
283
+ let data = json.data(using: .utf8) else {
284
+ return nil
285
+ }
286
+
287
+ do {
288
+ return try JSONDecoder().decode(AppConfig.self, from: data)
289
+ } catch {
290
+ BCLogger.error("Failed to load config from storage: \(error)")
291
+ return nil
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Save config to persistent storage
297
+ */
298
+ private func saveToStorage(_ json: String) {
299
+ do {
300
+ storage.putString(key: Self.configStorageKey, value: json)
301
+ BCLogger.verbose("Config saved to storage")
302
+ } catch {
303
+ BCLogger.error("Failed to save config to storage: \(error)")
304
+ }
305
+ }
306
+ }