@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,364 @@
|
|
|
1
|
+
package com.bigcrunch.ads.core
|
|
2
|
+
|
|
3
|
+
import com.bigcrunch.ads.BigCrunchAds
|
|
4
|
+
import com.bigcrunch.ads.internal.BCLogger
|
|
5
|
+
import com.bigcrunch.ads.internal.HttpClient
|
|
6
|
+
import com.bigcrunch.ads.internal.PrivacyStore
|
|
7
|
+
import com.bigcrunch.ads.models.PlacementConfig
|
|
8
|
+
import com.bigcrunch.ads.models.S2SConfig
|
|
9
|
+
import kotlinx.coroutines.*
|
|
10
|
+
import kotlinx.coroutines.sync.Mutex
|
|
11
|
+
import kotlinx.coroutines.sync.withLock
|
|
12
|
+
import org.json.JSONArray
|
|
13
|
+
import org.json.JSONObject
|
|
14
|
+
import java.util.UUID
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Custom S2S bid request client
|
|
18
|
+
*
|
|
19
|
+
* Replaces Prebid SDK's `fetchDemand()` with a direct HTTP call to the BigCrunch
|
|
20
|
+
* S2S endpoint. Builds OpenRTB-style requests with `ext.bidders` format and
|
|
21
|
+
* implements screen-level batching (100ms debounce) to combine multiple placements
|
|
22
|
+
* into a single multi-impression request.
|
|
23
|
+
*/
|
|
24
|
+
internal class BidRequestClient(
|
|
25
|
+
private val httpClient: HttpClient,
|
|
26
|
+
private val configManager: ConfigManager,
|
|
27
|
+
private val privacyStore: PrivacyStore,
|
|
28
|
+
private val s2sConfig: S2SConfig
|
|
29
|
+
) {
|
|
30
|
+
private val mutex = Mutex()
|
|
31
|
+
private val pendingRequests = mutableListOf<PendingRequest>()
|
|
32
|
+
private var batchJob: Job? = null
|
|
33
|
+
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
|
34
|
+
|
|
35
|
+
companion object {
|
|
36
|
+
private const val TAG = "BidRequestClient"
|
|
37
|
+
private const val BATCH_DELAY_MS = 100L
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private class PendingRequest(
|
|
41
|
+
val placement: PlacementConfig,
|
|
42
|
+
val deferred: CompletableDeferred<Map<String, String>?>
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
// MARK: - Public API
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Fetch demand for a single placement
|
|
49
|
+
*
|
|
50
|
+
* The placement is queued and batched with other placements requested within
|
|
51
|
+
* a 100ms window. Returns GAM custom targeting KVPs on success, null on failure.
|
|
52
|
+
*
|
|
53
|
+
* @param placement The placement to fetch demand for
|
|
54
|
+
* @return Targeting KVPs to apply to GAM request, or null
|
|
55
|
+
*/
|
|
56
|
+
suspend fun fetchDemand(placement: PlacementConfig): Map<String, String>? {
|
|
57
|
+
val deferred = CompletableDeferred<Map<String, String>?>()
|
|
58
|
+
|
|
59
|
+
mutex.withLock {
|
|
60
|
+
pendingRequests.add(PendingRequest(placement, deferred))
|
|
61
|
+
|
|
62
|
+
if (batchJob == null || batchJob?.isCompleted == true) {
|
|
63
|
+
batchJob = scope.launch {
|
|
64
|
+
delay(BATCH_DELAY_MS)
|
|
65
|
+
flushBatch()
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return deferred.await()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// MARK: - Batching
|
|
74
|
+
|
|
75
|
+
private suspend fun flushBatch() {
|
|
76
|
+
val batch: List<PendingRequest>
|
|
77
|
+
mutex.withLock {
|
|
78
|
+
batch = pendingRequests.toList()
|
|
79
|
+
pendingRequests.clear()
|
|
80
|
+
batchJob = null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (batch.isEmpty()) return
|
|
84
|
+
|
|
85
|
+
val placements = batch.map { it.placement }
|
|
86
|
+
BCLogger.d(TAG, "Flushing batch of ${placements.size} placements")
|
|
87
|
+
|
|
88
|
+
val results = executeBidRequest(placements)
|
|
89
|
+
|
|
90
|
+
// Distribute results to each pending deferred
|
|
91
|
+
for (pending in batch) {
|
|
92
|
+
val targeting = results[pending.placement.placementId]
|
|
93
|
+
pending.deferred.complete(targeting)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// MARK: - Request Building & Execution
|
|
98
|
+
|
|
99
|
+
private suspend fun executeBidRequest(
|
|
100
|
+
placements: List<PlacementConfig>
|
|
101
|
+
): Map<String, Map<String, String>> {
|
|
102
|
+
val requestBody = buildRequestJSON(placements)
|
|
103
|
+
val jsonString = requestBody.toString()
|
|
104
|
+
|
|
105
|
+
BCLogger.v(TAG, "Sending bid request to ${s2sConfig.serverUrl}")
|
|
106
|
+
|
|
107
|
+
val result = httpClient.post(
|
|
108
|
+
url = s2sConfig.serverUrl,
|
|
109
|
+
body = jsonString,
|
|
110
|
+
headers = emptyMap()
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
return when {
|
|
114
|
+
result.isSuccess -> {
|
|
115
|
+
val responseJson = result.getOrNull()!!
|
|
116
|
+
parseResponse(responseJson)
|
|
117
|
+
}
|
|
118
|
+
else -> {
|
|
119
|
+
BCLogger.e(TAG, "Bid request failed: ${result.exceptionOrNull()?.message}")
|
|
120
|
+
emptyMap()
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Build OpenRTB-style bid request JSON with ext.bidders format
|
|
127
|
+
*/
|
|
128
|
+
private fun buildRequestJSON(placements: List<PlacementConfig>): JSONObject {
|
|
129
|
+
val request = JSONObject()
|
|
130
|
+
|
|
131
|
+
// Request ID
|
|
132
|
+
request.put("id", UUID.randomUUID().toString())
|
|
133
|
+
|
|
134
|
+
// Impressions
|
|
135
|
+
val impArray = JSONArray()
|
|
136
|
+
for (placement in placements) {
|
|
137
|
+
impArray.put(buildImp(placement))
|
|
138
|
+
}
|
|
139
|
+
request.put("imp", impArray)
|
|
140
|
+
|
|
141
|
+
// App object
|
|
142
|
+
val deviceContext = DeviceContext.getInstance()
|
|
143
|
+
val app = JSONObject().apply {
|
|
144
|
+
put("bundle", deviceContext.appPackageName)
|
|
145
|
+
put("ver", deviceContext.appVersion)
|
|
146
|
+
put("publisher", JSONObject().apply {
|
|
147
|
+
put("id", BigCrunchAds.propertyId)
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
request.put("app", app)
|
|
151
|
+
|
|
152
|
+
// Device object
|
|
153
|
+
request.put("device", buildDevice(deviceContext))
|
|
154
|
+
|
|
155
|
+
// Privacy: regs & user
|
|
156
|
+
val regs = privacyStore.buildRegsMap()
|
|
157
|
+
if (regs.isNotEmpty()) {
|
|
158
|
+
request.put("regs", mapToJson(regs))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
val user = privacyStore.buildUserMap()
|
|
162
|
+
if (user.isNotEmpty()) {
|
|
163
|
+
request.put("user", mapToJson(user))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ext.bidders — the new format
|
|
167
|
+
val biddersExt = buildBiddersExt(placements)
|
|
168
|
+
if (biddersExt.length() > 0) {
|
|
169
|
+
request.put("ext", JSONObject().apply {
|
|
170
|
+
put("bidders", biddersExt)
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return request
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private fun buildImp(placement: PlacementConfig): JSONObject {
|
|
178
|
+
val imp = JSONObject()
|
|
179
|
+
imp.put("id", placement.placementId)
|
|
180
|
+
|
|
181
|
+
val deviceContext = DeviceContext.getInstance()
|
|
182
|
+
|
|
183
|
+
when (placement.format) {
|
|
184
|
+
"banner" -> {
|
|
185
|
+
val banner = JSONObject()
|
|
186
|
+
val formatArray = JSONArray()
|
|
187
|
+
placement.sizes?.forEach { size ->
|
|
188
|
+
val fmt = JSONObject()
|
|
189
|
+
if (size.isAdaptive) {
|
|
190
|
+
fmt.put("w", deviceContext.screenWidth)
|
|
191
|
+
fmt.put("h", 0)
|
|
192
|
+
} else {
|
|
193
|
+
fmt.put("w", size.width)
|
|
194
|
+
fmt.put("h", size.height)
|
|
195
|
+
}
|
|
196
|
+
formatArray.put(fmt)
|
|
197
|
+
}
|
|
198
|
+
banner.put("format", formatArray)
|
|
199
|
+
imp.put("banner", banner)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
"interstitial" -> {
|
|
203
|
+
imp.put("instl", 1)
|
|
204
|
+
val banner = JSONObject()
|
|
205
|
+
val formatArray = JSONArray()
|
|
206
|
+
formatArray.put(JSONObject().apply {
|
|
207
|
+
put("w", deviceContext.screenWidth)
|
|
208
|
+
put("h", deviceContext.screenHeight)
|
|
209
|
+
})
|
|
210
|
+
banner.put("format", formatArray)
|
|
211
|
+
imp.put("banner", banner)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
"rewarded" -> {
|
|
215
|
+
imp.put("instl", 1)
|
|
216
|
+
val video = JSONObject().apply {
|
|
217
|
+
put("mimes", JSONArray().apply {
|
|
218
|
+
put("video/mp4")
|
|
219
|
+
})
|
|
220
|
+
put("protocols", JSONArray().apply {
|
|
221
|
+
put(2) // VAST 2.0
|
|
222
|
+
put(5) // VAST 2.0 Wrapper
|
|
223
|
+
})
|
|
224
|
+
put("w", deviceContext.screenWidth)
|
|
225
|
+
put("h", deviceContext.screenHeight)
|
|
226
|
+
}
|
|
227
|
+
imp.put("video", video)
|
|
228
|
+
imp.put("ext", JSONObject().apply {
|
|
229
|
+
put("rewarded", 1)
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return imp
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private fun buildDevice(ctx: DeviceContext): JSONObject {
|
|
238
|
+
return JSONObject().apply {
|
|
239
|
+
put("os", ctx.osName)
|
|
240
|
+
put("osv", ctx.osVersion)
|
|
241
|
+
put("make", ctx.deviceManufacturer)
|
|
242
|
+
put("model", ctx.deviceModel)
|
|
243
|
+
put("w", ctx.screenWidth)
|
|
244
|
+
put("h", ctx.screenHeight)
|
|
245
|
+
put("pxratio", ctx.screenDensity.toDouble())
|
|
246
|
+
put("language", ctx.languageCode)
|
|
247
|
+
put("ua", ctx.getBrowserField())
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Build the ext.bidders section
|
|
253
|
+
*
|
|
254
|
+
* For each bidder in AppConfig.bidders, check which of the requested placements
|
|
255
|
+
* have entries in that bidder's placements map. Only include relevant bidders/imps.
|
|
256
|
+
*/
|
|
257
|
+
private fun buildBiddersExt(placements: List<PlacementConfig>): JSONObject {
|
|
258
|
+
val bidders = configManager.getCachedConfig()?.bidders ?: return JSONObject()
|
|
259
|
+
val placementIds = placements.map { it.placementId }.toSet()
|
|
260
|
+
val biddersExt = JSONObject()
|
|
261
|
+
|
|
262
|
+
for ((bidderName, bidderEntry) in bidders) {
|
|
263
|
+
val bidderObj = JSONObject()
|
|
264
|
+
|
|
265
|
+
// Shared params
|
|
266
|
+
bidderEntry.params?.let { params ->
|
|
267
|
+
bidderObj.put("params", mapToJson(params))
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Per-imp params (only for placements in this batch)
|
|
271
|
+
val impArray = JSONArray()
|
|
272
|
+
bidderEntry.placements?.let { bidderPlacements ->
|
|
273
|
+
for (placementId in placementIds) {
|
|
274
|
+
bidderPlacements[placementId]?.let { impParams ->
|
|
275
|
+
impArray.put(JSONObject().apply {
|
|
276
|
+
put("impid", placementId)
|
|
277
|
+
put("params", mapToJson(impParams))
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Only include bidder if it has relevant impressions
|
|
284
|
+
if (impArray.length() > 0) {
|
|
285
|
+
bidderObj.put("imp", impArray)
|
|
286
|
+
biddersExt.put(bidderName, bidderObj)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return biddersExt
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// MARK: - Response Parsing
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Parse S2S response and extract per-placement targeting
|
|
297
|
+
*
|
|
298
|
+
* Groups bids by impid, picks winner (highest price), copies ext.targeting.
|
|
299
|
+
*/
|
|
300
|
+
private fun parseResponse(jsonString: String): Map<String, Map<String, String>> {
|
|
301
|
+
return try {
|
|
302
|
+
val json = JSONObject(jsonString)
|
|
303
|
+
val seatbids = json.optJSONArray("seatbid") ?: return emptyMap()
|
|
304
|
+
|
|
305
|
+
// Collect all bids grouped by impid
|
|
306
|
+
val bidsByImpId = mutableMapOf<String, MutableList<Pair<Double, Map<String, String>>>>()
|
|
307
|
+
|
|
308
|
+
for (i in 0 until seatbids.length()) {
|
|
309
|
+
val seatbid = seatbids.getJSONObject(i)
|
|
310
|
+
val bids = seatbid.optJSONArray("bid") ?: continue
|
|
311
|
+
|
|
312
|
+
for (j in 0 until bids.length()) {
|
|
313
|
+
val bid = bids.getJSONObject(j)
|
|
314
|
+
val impid = bid.optString("impid", "")
|
|
315
|
+
val price = bid.optDouble("price", 0.0)
|
|
316
|
+
val ext = bid.optJSONObject("ext") ?: continue
|
|
317
|
+
val targetingJson = ext.optJSONObject("targeting") ?: continue
|
|
318
|
+
|
|
319
|
+
val targeting = mutableMapOf<String, String>()
|
|
320
|
+
val keys = targetingJson.keys()
|
|
321
|
+
while (keys.hasNext()) {
|
|
322
|
+
val key = keys.next()
|
|
323
|
+
targeting[key] = targetingJson.getString(key)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (impid.isNotEmpty()) {
|
|
327
|
+
bidsByImpId.getOrPut(impid) { mutableListOf() }
|
|
328
|
+
.add(Pair(price, targeting))
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Pick winner (highest price) for each impid
|
|
334
|
+
val results = mutableMapOf<String, Map<String, String>>()
|
|
335
|
+
for ((impid, bids) in bidsByImpId) {
|
|
336
|
+
val winner = bids.maxByOrNull { it.first }
|
|
337
|
+
if (winner != null) {
|
|
338
|
+
results[impid] = winner.second
|
|
339
|
+
BCLogger.d(TAG, "Winner for $impid: $${winner.first}")
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
BCLogger.i(TAG, "Parsed ${results.size} winning bids")
|
|
344
|
+
results
|
|
345
|
+
} catch (e: Exception) {
|
|
346
|
+
BCLogger.e(TAG, "Failed to parse bid response", e)
|
|
347
|
+
emptyMap()
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// MARK: - Helpers
|
|
352
|
+
|
|
353
|
+
private fun mapToJson(map: Map<String, Any>): JSONObject {
|
|
354
|
+
val obj = JSONObject()
|
|
355
|
+
for ((key, value) in map) {
|
|
356
|
+
when (value) {
|
|
357
|
+
is Map<*, *> -> obj.put(key, mapToJson(@Suppress("UNCHECKED_CAST") (value as Map<String, Any>)))
|
|
358
|
+
is List<*> -> obj.put(key, JSONArray(value))
|
|
359
|
+
else -> obj.put(key, value)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return obj
|
|
363
|
+
}
|
|
364
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
package com.bigcrunch.ads.core
|
|
2
|
+
|
|
3
|
+
import com.bigcrunch.ads.internal.BCLogger
|
|
4
|
+
import com.bigcrunch.ads.internal.HttpClient
|
|
5
|
+
import com.bigcrunch.ads.internal.KeyValueStore
|
|
6
|
+
import com.bigcrunch.ads.models.AppConfig
|
|
7
|
+
import com.bigcrunch.ads.models.PlacementConfig
|
|
8
|
+
import com.squareup.moshi.Moshi
|
|
9
|
+
import kotlinx.coroutines.sync.Mutex
|
|
10
|
+
import kotlinx.coroutines.sync.withLock
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Manages application configuration fetching, caching, and access
|
|
14
|
+
*
|
|
15
|
+
* ConfigManager is responsible for:
|
|
16
|
+
* - Fetching config from BigCrunch backend
|
|
17
|
+
* - Caching config in memory for fast access
|
|
18
|
+
* - Persisting config to storage for offline/cold start
|
|
19
|
+
* - Thread-safe config access
|
|
20
|
+
* - Placement lookup by ID
|
|
21
|
+
*
|
|
22
|
+
* The config is fetched once at SDK initialization and cached for the app session.
|
|
23
|
+
*/
|
|
24
|
+
internal class ConfigManager(
|
|
25
|
+
private val httpClient: HttpClient,
|
|
26
|
+
private val storage: KeyValueStore,
|
|
27
|
+
private val moshi: Moshi
|
|
28
|
+
) {
|
|
29
|
+
private val mutex = Mutex()
|
|
30
|
+
|
|
31
|
+
@Volatile
|
|
32
|
+
private var cachedConfig: AppConfig? = null
|
|
33
|
+
|
|
34
|
+
private val adapter = moshi.adapter(AppConfig::class.java)
|
|
35
|
+
|
|
36
|
+
companion object {
|
|
37
|
+
private const val CONFIG_STORAGE_KEY = "app_config"
|
|
38
|
+
private const val PROD_BASE_URL = "https://ship.bigcrunch.com"
|
|
39
|
+
private const val STAGING_BASE_URL = "https://dev-ship.bigcrunch.com"
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Generate mock configuration for testing
|
|
43
|
+
*
|
|
44
|
+
* Uses Google's official sample ad units:
|
|
45
|
+
* - Banner: ca-app-pub-3940256099942544/6300978111 (adaptive banner)
|
|
46
|
+
* - MREC: ca-app-pub-3940256099942544/6300978111 (adaptive banner - same)
|
|
47
|
+
* - Interstitial: ca-app-pub-3940256099942544/1033173712
|
|
48
|
+
*/
|
|
49
|
+
private fun getMockConfig(propertyId: String): AppConfig {
|
|
50
|
+
return AppConfig(
|
|
51
|
+
propertyId = propertyId,
|
|
52
|
+
appName = "Mock Test App",
|
|
53
|
+
environment = "test",
|
|
54
|
+
gamNetworkCode = "", // Empty for AdMob sample ads
|
|
55
|
+
s2s = com.bigcrunch.ads.models.S2SConfig(
|
|
56
|
+
serverUrl = "https://s2s.bigcrunch.com/auction",
|
|
57
|
+
timeoutMs = 3000
|
|
58
|
+
),
|
|
59
|
+
bidders = null, // No bidders in mock/test mode
|
|
60
|
+
amazonAps = null,
|
|
61
|
+
useTestAds = true,
|
|
62
|
+
refresh = com.bigcrunch.ads.models.RefreshConfig(enabled = true, intervalMs = 30000, maxRefreshes = 20),
|
|
63
|
+
placements = listOf(
|
|
64
|
+
PlacementConfig(
|
|
65
|
+
placementId = "test_banner_320x50",
|
|
66
|
+
format = "banner",
|
|
67
|
+
gamAdUnit = "ca-app-pub-3940256099942544/6300978111",
|
|
68
|
+
sizes = listOf(com.bigcrunch.ads.models.AdSize(width = 320, height = 50))
|
|
69
|
+
),
|
|
70
|
+
PlacementConfig(
|
|
71
|
+
placementId = "test_mrec",
|
|
72
|
+
format = "banner",
|
|
73
|
+
gamAdUnit = "ca-app-pub-3940256099942544/6300978111",
|
|
74
|
+
sizes = listOf(com.bigcrunch.ads.models.AdSize(width = 300, height = 250)),
|
|
75
|
+
refresh = com.bigcrunch.ads.models.RefreshConfig(enabled = true, intervalMs = 15000, maxRefreshes = 40)
|
|
76
|
+
),
|
|
77
|
+
PlacementConfig(
|
|
78
|
+
placementId = "test_adaptive_banner",
|
|
79
|
+
format = "banner",
|
|
80
|
+
gamAdUnit = "ca-app-pub-3940256099942544/6300978111",
|
|
81
|
+
sizes = listOf(com.bigcrunch.ads.models.AdSize.adaptive())
|
|
82
|
+
),
|
|
83
|
+
PlacementConfig(
|
|
84
|
+
placementId = "test_interstitial",
|
|
85
|
+
format = "interstitial",
|
|
86
|
+
gamAdUnit = "ca-app-pub-3940256099942544/1033173712",
|
|
87
|
+
sizes = null
|
|
88
|
+
),
|
|
89
|
+
PlacementConfig(
|
|
90
|
+
placementId = "test_rewarded",
|
|
91
|
+
format = "rewarded",
|
|
92
|
+
gamAdUnit = "ca-app-pub-3940256099942544/5224354917",
|
|
93
|
+
sizes = null
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Load configuration from BigCrunch backend or mock data
|
|
102
|
+
*
|
|
103
|
+
* This method:
|
|
104
|
+
* 1. If useMockConfig is true, returns hardcoded mock config immediately
|
|
105
|
+
* 2. Attempts to load cached config from storage first (for offline/cold start)
|
|
106
|
+
* 3. Fetches fresh config from network
|
|
107
|
+
* 4. If network succeeds, updates cache and storage
|
|
108
|
+
* 5. If network fails but cache exists, returns cached version
|
|
109
|
+
* 6. If network fails and no cache, returns error
|
|
110
|
+
*
|
|
111
|
+
* @param propertyId BigCrunch property ID
|
|
112
|
+
* @param isProd True for production, false for staging
|
|
113
|
+
* @param useMockConfig If true, returns mock config for testing
|
|
114
|
+
* @return Result containing AppConfig or error
|
|
115
|
+
*/
|
|
116
|
+
suspend fun loadConfig(
|
|
117
|
+
propertyId: String,
|
|
118
|
+
isProd: Boolean,
|
|
119
|
+
useMockConfig: Boolean = false
|
|
120
|
+
): Result<AppConfig> = mutex.withLock {
|
|
121
|
+
BCLogger.d("ConfigManager", "Loading config for property: $propertyId (mock: $useMockConfig)")
|
|
122
|
+
|
|
123
|
+
// If mock mode enabled, return mock config immediately
|
|
124
|
+
if (useMockConfig) {
|
|
125
|
+
val mockConfig = getMockConfig(propertyId)
|
|
126
|
+
cachedConfig = mockConfig
|
|
127
|
+
BCLogger.i("ConfigManager", "Using mock config (${mockConfig.placements.size} placements)")
|
|
128
|
+
return Result.success(mockConfig)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 1. Try to load from storage first (for offline/cold start)
|
|
132
|
+
val storedConfig = loadFromStorage()
|
|
133
|
+
if (storedConfig != null) {
|
|
134
|
+
cachedConfig = storedConfig
|
|
135
|
+
BCLogger.d("ConfigManager", "Loaded config from storage")
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 2. Fetch fresh config from network
|
|
139
|
+
val baseUrl = if (isProd) PROD_BASE_URL else STAGING_BASE_URL
|
|
140
|
+
val url = "$baseUrl/config-app/$propertyId.json"
|
|
141
|
+
|
|
142
|
+
val result = httpClient.get(
|
|
143
|
+
url = url,
|
|
144
|
+
headers = emptyMap() // No authentication required for public config files
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
return when {
|
|
148
|
+
result.isSuccess -> {
|
|
149
|
+
// Network request succeeded
|
|
150
|
+
val json = result.getOrNull()!!
|
|
151
|
+
val config = try {
|
|
152
|
+
adapter.fromJson(json)
|
|
153
|
+
} catch (e: Exception) {
|
|
154
|
+
BCLogger.e("ConfigManager", "Failed to parse config JSON", e)
|
|
155
|
+
null
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (config != null) {
|
|
159
|
+
cachedConfig = config
|
|
160
|
+
saveToStorage(json)
|
|
161
|
+
BCLogger.i("ConfigManager", "Config loaded successfully from network (${config.placements.size} placements)")
|
|
162
|
+
Result.success(config)
|
|
163
|
+
} else {
|
|
164
|
+
BCLogger.e("ConfigManager", "Invalid config JSON")
|
|
165
|
+
Result.failure(Exception("Invalid config JSON"))
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
storedConfig != null -> {
|
|
169
|
+
// Network failed but we have cached version
|
|
170
|
+
BCLogger.w("ConfigManager", "Using cached config, network fetch failed: ${result.exceptionOrNull()?.message}")
|
|
171
|
+
Result.success(storedConfig)
|
|
172
|
+
}
|
|
173
|
+
else -> {
|
|
174
|
+
// Network failed and no cache available
|
|
175
|
+
BCLogger.e("ConfigManager", "Config load failed and no cache available")
|
|
176
|
+
Result.failure(result.exceptionOrNull() ?: Exception("Config load failed"))
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Get the cached app configuration
|
|
183
|
+
*
|
|
184
|
+
* @return The cached AppConfig, or null if not loaded
|
|
185
|
+
*/
|
|
186
|
+
fun getCachedConfig(): AppConfig? = cachedConfig
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Get placement configuration by ID
|
|
190
|
+
*
|
|
191
|
+
* This is a synchronous operation that looks up the placement in the cached config.
|
|
192
|
+
* Must be called after loadConfig() has succeeded at least once.
|
|
193
|
+
*
|
|
194
|
+
* @param placementId The placement ID to look up
|
|
195
|
+
* @return PlacementConfig if found, null otherwise
|
|
196
|
+
*/
|
|
197
|
+
fun getPlacement(placementId: String): PlacementConfig? {
|
|
198
|
+
val placement = cachedConfig?.placements?.firstOrNull {
|
|
199
|
+
it.placementId == placementId
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (placement == null) {
|
|
203
|
+
BCLogger.w("ConfigManager", "Placement not found: $placementId")
|
|
204
|
+
} else {
|
|
205
|
+
BCLogger.v("ConfigManager", "Found placement: $placementId (${placement.format})")
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return placement
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Get all placements from cached config
|
|
213
|
+
*
|
|
214
|
+
* @return List of all placements, or empty list if config not loaded
|
|
215
|
+
*/
|
|
216
|
+
fun getAllPlacements(): List<PlacementConfig> {
|
|
217
|
+
return cachedConfig?.placements ?: emptyList()
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Get the GAM network code from cached config
|
|
222
|
+
*
|
|
223
|
+
* @return The GAM network code, or null if config not loaded
|
|
224
|
+
*/
|
|
225
|
+
fun getGamNetworkCode(): String? {
|
|
226
|
+
return cachedConfig?.gamNetworkCode
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Get the S2S config from cached config
|
|
231
|
+
*
|
|
232
|
+
* @return The S2S config, or null if config not loaded
|
|
233
|
+
*/
|
|
234
|
+
fun getS2SConfig(): com.bigcrunch.ads.models.S2SConfig? {
|
|
235
|
+
return cachedConfig?.s2s
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Get the effective refresh config for a placement
|
|
240
|
+
*
|
|
241
|
+
* Resolution order:
|
|
242
|
+
* 1. Placement-level refresh config (if present, even if disabled)
|
|
243
|
+
* 2. Global refresh config from AppConfig
|
|
244
|
+
* 3. null (no refresh)
|
|
245
|
+
*
|
|
246
|
+
* @param placementId The placement ID to look up
|
|
247
|
+
* @return RefreshConfig if refresh is configured, null otherwise
|
|
248
|
+
*/
|
|
249
|
+
fun getEffectiveRefreshConfig(placementId: String): com.bigcrunch.ads.models.RefreshConfig? {
|
|
250
|
+
val placement = getPlacement(placementId) ?: return null
|
|
251
|
+
// Placement-level override takes priority
|
|
252
|
+
if (placement.refresh != null) {
|
|
253
|
+
return placement.refresh
|
|
254
|
+
}
|
|
255
|
+
// Fall back to global config
|
|
256
|
+
return cachedConfig?.refresh
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Check if test ads mode is enabled in config
|
|
261
|
+
*
|
|
262
|
+
* @return true if test ads should be used, false otherwise
|
|
263
|
+
*/
|
|
264
|
+
fun shouldUseTestAds(): Boolean {
|
|
265
|
+
return cachedConfig?.useTestAds ?: false
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Clear cached config (for testing)
|
|
270
|
+
*/
|
|
271
|
+
internal fun clearCache() {
|
|
272
|
+
cachedConfig = null
|
|
273
|
+
storage.clear()
|
|
274
|
+
BCLogger.d("ConfigManager", "Cache cleared")
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Load config from persistent storage
|
|
279
|
+
*/
|
|
280
|
+
private fun loadFromStorage(): AppConfig? {
|
|
281
|
+
return try {
|
|
282
|
+
val json = storage.getString(CONFIG_STORAGE_KEY) ?: return null
|
|
283
|
+
adapter.fromJson(json)
|
|
284
|
+
} catch (e: Exception) {
|
|
285
|
+
BCLogger.e("ConfigManager", "Failed to load config from storage", e)
|
|
286
|
+
null
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Save config to persistent storage
|
|
292
|
+
*/
|
|
293
|
+
private fun saveToStorage(json: String) {
|
|
294
|
+
try {
|
|
295
|
+
storage.putString(CONFIG_STORAGE_KEY, json)
|
|
296
|
+
BCLogger.v("ConfigManager", "Config saved to storage")
|
|
297
|
+
} catch (e: Exception) {
|
|
298
|
+
BCLogger.e("ConfigManager", "Failed to save config to storage", e)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|