@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,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
+ }