package com.bigcrunch.ads.core import com.bigcrunch.ads.BigCrunchAds import com.bigcrunch.ads.internal.BCLogger import com.bigcrunch.ads.internal.HttpClient import com.bigcrunch.ads.internal.PrivacyStore import com.bigcrunch.ads.models.AuctionData import com.bigcrunch.ads.models.PlacementConfig import com.bigcrunch.ads.models.S2SConfig import kotlinx.coroutines.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.json.JSONArray import org.json.JSONObject import java.util.UUID /** * Custom S2S bid request client * * Replaces Prebid SDK's `fetchDemand()` with a direct HTTP call to the BigCrunch * S2S endpoint. Builds OpenRTB-style requests with `ext.bidders` format and * implements screen-level batching (100ms debounce) to combine multiple placements * into a single multi-impression request. */ internal class BidRequestClient( private val httpClient: HttpClient, private val configManager: ConfigManager, private val privacyStore: PrivacyStore, private val s2sConfig: S2SConfig ) { private val mutex = Mutex() private val pendingRequests = mutableListOf() private var batchJob: Job? = null private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) companion object { private const val TAG = "BidRequestClient" private const val BATCH_DELAY_MS = 100L private const val DEFAULT_TIMEOUT_MS = 3000L } /** Bid response containing both targeting KVPs and auction metadata */ internal data class BidResponse( val targeting: Map, val auctionData: AuctionData ) private class PendingRequest( val placement: PlacementConfig, val deferred: CompletableDeferred ) // MARK: - Public API /** * Fetch demand for a single placement * * The placement is queued and batched with other placements requested within * a 100ms window. Returns GAM custom targeting KVPs on success, null on failure. * * @param placement The placement to fetch demand for * @return Targeting KVPs to apply to GAM request, or null */ suspend fun fetchDemand(placement: PlacementConfig): BidResponse? { if (!s2sConfig.enabled) { BCLogger.d(TAG, "S2S is disabled, skipping bid request") return null } if (!placement.enabled) { BCLogger.d(TAG, "Placement disabled, skipping bid request: ${placement.placementId}") return null } val deferred = CompletableDeferred() mutex.withLock { pendingRequests.add(PendingRequest(placement, deferred)) if (batchJob == null || batchJob?.isCompleted == true) { batchJob = scope.launch { delay(BATCH_DELAY_MS) flushBatch() } } } // Bound the wait by the configured S2S timeout (plus the batching window) so a // hung or slow-dripping S2S endpoint can't stall ad loads indefinitely. A null // return matches the existing "continue with Google only" failure semantics, // and timing out one waiter leaves the shared batch job intact for others. val timeoutMs = if (s2sConfig.timeoutMs > 0) s2sConfig.timeoutMs.toLong() else DEFAULT_TIMEOUT_MS return withTimeoutOrNull(timeoutMs + BATCH_DELAY_MS) { deferred.await() } ?: run { if (!deferred.isCompleted) { BCLogger.w(TAG, "S2S bid request timed out after ${timeoutMs}ms for ${placement.placementId}, continuing with Google only") } null } } // MARK: - Batching private suspend fun flushBatch() { val batch: List mutex.withLock { batch = pendingRequests.toList() pendingRequests.clear() batchJob = null } if (batch.isEmpty()) return val placements = batch.map { it.placement } BCLogger.d(TAG, "Flushing batch of ${placements.size} placements") val results = executeBidRequest(placements) // Distribute results to each pending deferred for (pending in batch) { val bidResponse = results[pending.placement.placementId] pending.deferred.complete(bidResponse) } } // MARK: - Request Building & Execution private suspend fun executeBidRequest( placements: List ): Map { val requestBody = buildRequestJSON(placements) val jsonString = requestBody.toString() BCLogger.v(TAG, "Sending bid request to ${s2sConfig.serverUrl}") val result = httpClient.post( url = s2sConfig.serverUrl, body = jsonString, headers = emptyMap() ) return when { result.isSuccess -> { val responseJson = result.getOrNull()!! parseResponse(responseJson, placements) } else -> { BCLogger.e(TAG, "Bid request failed: ${result.exceptionOrNull()?.message}") emptyMap() } } } /** * Build OpenRTB-style bid request JSON with ext.bidders format */ private fun buildRequestJSON(placements: List): JSONObject { val request = JSONObject() // Request ID request.put("id", UUID.randomUUID().toString()) // Impressions val impArray = JSONArray() for (placement in placements) { impArray.put(buildImp(placement)) } request.put("imp", impArray) // App object val deviceContext = DeviceContext.getInstance() val app = JSONObject().apply { put("bundle", deviceContext.appPackageName) put("ver", deviceContext.appVersion) put("publisher", JSONObject().apply { put("id", BigCrunchAds.propertyId) }) } request.put("app", app) // Device object request.put("device", buildDevice(deviceContext)) // Privacy: regs & user val regs = privacyStore.buildRegsMap() if (regs.isNotEmpty()) { request.put("regs", mapToJson(regs)) } val user = privacyStore.buildUserMap() if (user.isNotEmpty()) { request.put("user", mapToJson(user)) } // ext.bidders — the new format val biddersExt = buildBiddersExt(placements) if (biddersExt.length() > 0) { request.put("ext", JSONObject().apply { put("bidders", biddersExt) }) } return request } private fun buildImp(placement: PlacementConfig): JSONObject { val imp = JSONObject() imp.put("id", placement.id) val deviceContext = DeviceContext.getInstance() when (placement.format) { "banner" -> { val banner = JSONObject() val formatArray = JSONArray() placement.sizes?.forEach { size -> val fmt = JSONObject() if (size.isAdaptive) { fmt.put("w", deviceContext.screenWidth) fmt.put("h", 0) } else { fmt.put("w", size.width) fmt.put("h", size.height) } formatArray.put(fmt) } banner.put("format", formatArray) imp.put("banner", banner) } "interstitial" -> { imp.put("instl", 1) val banner = JSONObject() val formatArray = JSONArray() formatArray.put(JSONObject().apply { put("w", deviceContext.screenWidth) put("h", deviceContext.screenHeight) }) banner.put("format", formatArray) imp.put("banner", banner) } "rewarded" -> { imp.put("instl", 1) val video = JSONObject().apply { put("mimes", JSONArray().apply { put("video/mp4") }) put("protocols", JSONArray().apply { put(2) // VAST 2.0 put(5) // VAST 2.0 Wrapper }) put("w", deviceContext.screenWidth) put("h", deviceContext.screenHeight) } imp.put("video", video) imp.put("ext", JSONObject().apply { put("rewarded", 1) }) } } return imp } private fun buildDevice(ctx: DeviceContext): JSONObject { return JSONObject().apply { put("os", ctx.osName) put("osv", ctx.osVersion) put("make", ctx.deviceManufacturer) put("model", ctx.deviceModel) put("w", ctx.screenWidth) put("h", ctx.screenHeight) put("pxratio", ctx.screenDensity.toDouble()) put("language", ctx.languageCode) put("ua", ctx.getBrowserField()) } } /** * Build the ext.bidders section * * For each bidder in AppConfig.bidders, check which of the requested placements * have entries in that bidder's placements map. Only include relevant bidders/imps. */ private fun buildBiddersExt(placements: List): JSONObject { val bidders = configManager.getCachedConfig()?.bidders ?: return JSONObject() val placementIdToUuid = placements.associate { it.placementId to it.id } val placementIds = placementIdToUuid.keys val biddersExt = JSONObject() for ((bidderName, bidderEntry) in bidders) { val bidderObj = JSONObject() // Shared params bidderEntry.params?.let { params -> bidderObj.put("params", mapToJson(params)) } // Per-imp params (only for placements in this batch) val impArray = JSONArray() bidderEntry.placements?.let { bidderPlacements -> for (placementId in placementIds) { bidderPlacements[placementId]?.let { impParams -> impArray.put(JSONObject().apply { put("impid", placementIdToUuid[placementId]) put("params", mapToJson(impParams)) }) } } } // Only include bidder if it has relevant impressions if (impArray.length() > 0) { bidderObj.put("imp", impArray) biddersExt.put(bidderName, bidderObj) } } return biddersExt } // MARK: - Response Parsing /** * Parse S2S response and extract per-placement targeting * * Groups bids by impid, picks winner (highest price), copies ext.targeting. */ private fun parseResponse(jsonString: String, placements: List): Map { return try { val json = JSONObject(jsonString) val seatbids = json.optJSONArray("seatbid") ?: return emptyMap() val auctionId = json.optString("id", "") // Build floor price lookup and UUID-to-placementId mapping val floorPrices = placements.associate { it.id to it.floorPrice } val uuidToPlacementId = placements.associate { it.id to it.placementId } // Collect all bids grouped by impid data class BidInfo( val price: Double, val targeting: Map, val seat: String, val creativeId: String? ) val bidsByImpId = mutableMapOf>() for (i in 0 until seatbids.length()) { val seatbid = seatbids.getJSONObject(i) val seat = seatbid.optString("seat", "") val bids = seatbid.optJSONArray("bid") ?: continue for (j in 0 until bids.length()) { val bid = bids.getJSONObject(j) val impid = bid.optString("impid", "") val price = bid.optDouble("price", 0.0) val ext = bid.optJSONObject("ext") ?: continue val targetingJson = ext.optJSONObject("targeting") ?: continue val crid = bid.optString("crid", null) val targeting = mutableMapOf() val keys = targetingJson.keys() while (keys.hasNext()) { val key = keys.next() targeting[key] = targetingJson.getString(key) } if (impid.isNotEmpty()) { bidsByImpId.getOrPut(impid) { mutableListOf() } .add(BidInfo(price, targeting, seat, crid)) } } } // Pick winner (highest price) for each impid and compute auction metadata val results = mutableMapOf() for ((impid, bids) in bidsByImpId) { val sortedBids = bids.sortedByDescending { it.price } val winner = sortedBids.first() val secondHighestBid = if (sortedBids.size > 1) sortedBids[1].price else null val floorPrice = floorPrices[impid] val auctionData = AuctionData( auctionId = auctionId.ifEmpty { null }, bidder = winner.seat, bidPriceCpm = winner.price, creativeId = winner.creativeId, gamPriceBucket = winner.targeting["hb_pb"], floorPrice = floorPrice, secondHighestBid = secondHighestBid, demandChannel = "S2S" ) // Re-key from UUID to placementId for internal use val placementId = uuidToPlacementId[impid] ?: impid results[placementId] = BidResponse(winner.targeting, auctionData) BCLogger.d(TAG, "Winner for $placementId: $${winner.price}") } BCLogger.i(TAG, "Parsed ${results.size} winning bids") results } catch (e: Exception) { BCLogger.e(TAG, "Failed to parse bid response", e) emptyMap() } } // MARK: - Helpers private fun mapToJson(map: Map): JSONObject { val obj = JSONObject() for ((key, value) in map) { when (value) { is Map<*, *> -> obj.put(key, mapToJson(@Suppress("UNCHECKED_CAST") (value as Map))) is List<*> -> obj.put(key, JSONArray(value)) else -> obj.put(key, value) } } return obj } }