@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.
- package/README.md +5 -5
- package/android/bigcrunch-ads/com/bigcrunch/ads/BigCrunchAds.kt +434 -0
- package/android/bigcrunch-ads/com/bigcrunch/ads/BigCrunchBannerView.kt +484 -0
- package/android/bigcrunch-ads/com/bigcrunch/ads/BigCrunchInterstitial.kt +403 -0
- package/android/bigcrunch-ads/com/bigcrunch/ads/BigCrunchRewarded.kt +409 -0
- package/android/bigcrunch-ads/com/bigcrunch/ads/adapters/GoogleAdsAdapter.kt +592 -0
- package/android/bigcrunch-ads/com/bigcrunch/ads/core/AdOrchestrator.kt +623 -0
- package/android/bigcrunch-ads/com/bigcrunch/ads/core/AnalyticsClient.kt +719 -0
- package/android/bigcrunch-ads/com/bigcrunch/ads/core/BidRequestClient.kt +364 -0
- package/android/bigcrunch-ads/com/bigcrunch/ads/core/ConfigManager.kt +301 -0
- package/android/bigcrunch-ads/com/bigcrunch/ads/core/DeviceContext.kt +385 -0
- package/android/bigcrunch-ads/com/bigcrunch/ads/core/RewardedCallback.kt +42 -0
- package/android/bigcrunch-ads/com/bigcrunch/ads/core/SessionManager.kt +330 -0
- package/android/bigcrunch-ads/com/bigcrunch/ads/internal/DeviceHelper.kt +60 -0
- package/android/bigcrunch-ads/com/bigcrunch/ads/internal/HttpClient.kt +114 -0
- package/android/bigcrunch-ads/com/bigcrunch/ads/internal/Logger.kt +71 -0
- package/android/bigcrunch-ads/com/bigcrunch/ads/internal/PrivacyStore.kt +125 -0
- package/android/bigcrunch-ads/com/bigcrunch/ads/internal/Storage.kt +88 -0
- package/android/bigcrunch-ads/com/bigcrunch/ads/listeners/BannerAdListener.kt +55 -0
- package/android/bigcrunch-ads/com/bigcrunch/ads/listeners/InterstitialAdListener.kt +55 -0
- package/android/bigcrunch-ads/com/bigcrunch/ads/listeners/RewardedAdListener.kt +58 -0
- package/android/bigcrunch-ads/com/bigcrunch/ads/models/AdEvent.kt +880 -0
- package/android/bigcrunch-ads/com/bigcrunch/ads/models/AppConfig.kt +90 -0
- package/android/bigcrunch-ads/com/bigcrunch/ads/models/DeviceData.kt +18 -0
- package/android/bigcrunch-ads/com/bigcrunch/ads/models/PlacementConfig.kt +70 -0
- package/android/bigcrunch-ads/com/bigcrunch/ads/models/SessionInfo.kt +21 -0
- package/android/build.gradle +22 -10
- package/android/settings.gradle +2 -6
- package/ios/BigCrunchAds/Sources/Adapters/GoogleAdsAdapter.swift +512 -0
- package/ios/BigCrunchAds/Sources/BigCrunchAds.swift +387 -0
- package/ios/BigCrunchAds/Sources/BigCrunchBannerView.swift +448 -0
- package/ios/BigCrunchAds/Sources/BigCrunchInterstitial.swift +412 -0
- package/ios/BigCrunchAds/Sources/BigCrunchRewarded.swift +523 -0
- package/ios/BigCrunchAds/Sources/Core/AdOrchestrator.swift +514 -0
- package/ios/BigCrunchAds/Sources/Core/AnalyticsClient.swift +874 -0
- package/ios/BigCrunchAds/Sources/Core/BidRequestClient.swift +344 -0
- package/ios/BigCrunchAds/Sources/Core/ConfigManager.swift +306 -0
- package/ios/BigCrunchAds/Sources/Core/DeviceContext.swift +284 -0
- package/ios/BigCrunchAds/Sources/Core/SessionManager.swift +392 -0
- package/ios/BigCrunchAds/Sources/Internal/HTTPClient.swift +146 -0
- package/ios/BigCrunchAds/Sources/Internal/Logger.swift +62 -0
- package/ios/BigCrunchAds/Sources/Internal/PrivacyStore.swift +129 -0
- package/ios/BigCrunchAds/Sources/Internal/Storage.swift +73 -0
- package/ios/BigCrunchAds/Sources/Models/AdEvent.swift +784 -0
- package/ios/BigCrunchAds/Sources/Models/AppConfig.swift +100 -0
- package/ios/BigCrunchAds/Sources/Models/DeviceData.swift +68 -0
- package/ios/BigCrunchAds/Sources/Models/PlacementConfig.swift +137 -0
- package/ios/BigCrunchAds/Sources/Models/SessionInfo.swift +48 -0
- package/ios/BigCrunchAdsModule.swift +0 -1
- package/ios/BigCrunchBannerViewManager.swift +0 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +3 -2
- package/package.json +8 -2
- package/react-native-bigcrunch-ads.podspec +0 -1
- package/scripts/inject-version.js +55 -0
- 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
|
+
}
|