package com.bigcrunch.ads.core import android.app.Activity import android.content.Context import android.view.ViewGroup import com.bigcrunch.ads.adapters.BannerAdCallback import com.bigcrunch.ads.adapters.GoogleAdsAdapter import com.bigcrunch.ads.adapters.InterstitialAdCallback import com.bigcrunch.ads.adapters.RewardedAdCallback import com.bigcrunch.ads.internal.BCLogger import com.bigcrunch.ads.models.AuctionData import com.bigcrunch.ads.models.PlacementConfig import com.google.android.gms.ads.admanager.AdManagerAdRequest import com.google.android.gms.ads.admanager.AdManagerAdView import com.google.android.gms.ads.admanager.AdManagerInterstitialAd import com.google.android.gms.ads.rewarded.RewardedAd import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.util.UUID import java.util.concurrent.ConcurrentHashMap /** * Callback interface for banner ad events */ interface BannerCallback { fun onAdLoaded() fun onAdFailedToLoad(error: String) fun onAdClicked() fun onAdImpression() } /** * Callback interface for interstitial ad events */ interface InterstitialCallback { fun onAdLoaded() fun onAdFailedToLoad(error: String) fun onAdShowed() fun onAdDismissed() fun onAdClicked() } /** * AdOrchestrator - Central orchestration for ad loading lifecycle * * Coordinates the flow between ConfigManager, BidRequestClient, GoogleAdsAdapter, * and AnalyticsClient to load and display ads. * * Flow: * 1. Get PlacementConfig from ConfigManager * 2. Track ad request via AnalyticsClient * 3. Fetch S2S demand via BidRequestClient * 4. Load Google ad via GoogleAdsAdapter (with targeting from S2S) * 5. Wire up all callbacks for analytics */ internal class AdOrchestrator( private val context: Context, private val configManager: ConfigManager, private val analyticsClient: AnalyticsClient, private val bidRequestClient: BidRequestClient, private val googleAdsAdapter: GoogleAdsAdapter ) { private val TAG = "AdOrchestrator" /** * Exception handler for coroutines - catches any uncaught exceptions * to prevent SDK errors from crashing the host app. */ private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> BCLogger.e(TAG, "Uncaught exception in SDK coroutine - SDK will not propagate this to prevent app crash", throwable) // Don't rethrow - SDK errors should not crash the host app } /** * Coroutine scope with SupervisorJob (child failures don't cancel siblings) * and exception handler (uncaught exceptions are logged, not propagated). */ private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob() + exceptionHandler) /** Preloaded ad paired with the loadId of the S2S auction data stored for that specific load */ private data class CachedInterstitial(val ad: AdManagerInterstitialAd, val loadId: String) private data class CachedRewarded(val ad: RewardedAd, val loadId: String) // Cache for preloaded interstitial ads private val interstitialCache = ConcurrentHashMap() private val interstitialMutex = Mutex() // Cache for preloaded rewarded ads private val rewardedCache = ConcurrentHashMap() private val rewardedMutex = Mutex() // Track active banner views for cleanup private val activeBanners = ConcurrentHashMap() /** * Load a banner ad * * @param placementId The placement ID from the BigCrunch dashboard * @param container The ViewGroup to add the banner to * @param callback Callback for ad events * @param adSizeOverride Optional size override (takes precedence over backend config) */ fun loadBannerAd( placementId: String, container: ViewGroup, callback: BannerCallback, adSizeOverride: com.bigcrunch.ads.models.AdSize? = null, refreshCount: Int = 0 ) { BCLogger.d(TAG, "Loading banner ad: $placementId with adSizeOverride: $adSizeOverride") // 1. Get placement config val placement = configManager.getPlacement(placementId) if (placement == null) { BCLogger.e(TAG, "Placement not found: $placementId") analyticsClient.trackUnfilledImpression(placementId, placementId, "banner") callback.onAdFailedToLoad("Placement not found: $placementId") return } if (placement.format != "banner") { BCLogger.e(TAG, "Invalid format for banner: ${placement.format}") analyticsClient.trackUnfilledImpression(placementId, placement.id, placement.format, placement.gamAdUnit) callback.onAdFailedToLoad("Invalid placement format: ${placement.format}") return } if (!placement.enabled) { BCLogger.d(TAG, "Placement disabled: $placementId") analyticsClient.trackUnfilledImpression(placementId, placement.id, placement.format, placement.gamAdUnit) callback.onAdFailedToLoad("Placement is disabled: $placementId") return } // 2. Track ad request analyticsClient.trackAdRequest(placementId, placement.format) // 3. Start the async ad loading flow scope.launch { loadBannerAsync(placement, container, callback, adSizeOverride, refreshCount) } } private suspend fun loadBannerAsync( placement: PlacementConfig, container: ViewGroup, callback: BannerCallback, adSizeOverride: com.bigcrunch.ads.models.AdSize? = null, refreshCount: Int = 0 ) { // Get GAM network code from config val gamNetworkCode = configManager.getGamNetworkCode() if (gamNetworkCode == null) { BCLogger.e(TAG, "GAM network code not available") analyticsClient.trackUnfilledImpression(placement.placementId, placement.id, placement.format, placement.gamAdUnit) callback.onAdFailedToLoad("Config not loaded") return } // Note: ad size resolution happens in GoogleAdsAdapter (for the GMA view) // and in BidRequestClient (for S2S imp sizes) — resolving here is redundant. // Create ad request builder val adRequestBuilder = AdManagerAdRequest.Builder() // 3. Fetch S2S demand (even if it fails, continue with Google) val bidResponse = bidRequestClient.fetchDemand(placement) if (bidResponse != null && bidResponse.targeting.isNotEmpty()) { for ((key, value) in bidResponse.targeting) { adRequestBuilder.addCustomTargeting(key, value) } BCLogger.d(TAG, "S2S demand fetched for: ${placement.placementId} (${bidResponse.targeting.size} keys)") } else { BCLogger.w(TAG, "S2S demand fetch returned no targeting, continuing with Google only") } // Store auction data for analytics (or default to GAM if no S2S), // keyed by a per-load ID so concurrent loads/refreshes don't clobber each other val loadId = UUID.randomUUID().toString() val auctionData = bidResponse?.auctionData ?: AuctionData( bidder = "google_ad_exchange", floorPrice = placement.floorPrice, demandChannel = "Google Ad Exchange" ) analyticsClient.setAuctionData(loadId, auctionData) // 4. Load Google ad with the (possibly enriched) ad request val adRequest = adRequestBuilder.build() val adapterCallback = object : BannerAdCallback { override fun onAdLoaded() { BCLogger.d(TAG, "Banner ad loaded: ${placement.placementId}") callback.onAdLoaded() } override fun onAdFailedToLoad(error: String) { BCLogger.w(TAG, "Banner ad failed to load: ${placement.placementId} - $error") analyticsClient.trackUnfilledImpression(placement.placementId, placement.id, placement.format, placement.gamAdUnit) callback.onAdFailedToLoad(error) } override fun onAdClicked() { BCLogger.d(TAG, "Banner ad clicked: ${placement.placementId}") callback.onAdClicked() } override fun onAdImpression() { BCLogger.d(TAG, "Banner ad impression: ${placement.placementId}") callback.onAdImpression() } } // Get test ads flag from config val useTestAds = configManager.shouldUseTestAds() // Clean up existing banner for refresh activeBanners.remove(placement.placementId)?.let { oldView -> googleAdsAdapter.destroyBannerAd(oldView) } // Load the ad through GoogleAdsAdapter val adView = googleAdsAdapter.loadBannerAd( placement, gamNetworkCode, adRequest, container, adapterCallback, useTestAds, adSizeOverride, refreshCount, loadId ) // Track the active banner for cleanup activeBanners[placement.placementId] = adView } /** * Destroy a banner ad * * @param placementId The placement ID of the banner to destroy */ fun destroyBannerAd(placementId: String) { BCLogger.d(TAG, "Destroying banner ad: $placementId") activeBanners.remove(placementId)?.let { adView -> googleAdsAdapter.destroyBannerAd(adView) } } /** * Preload an interstitial ad * * @param placementId The placement ID from the BigCrunch dashboard * @param callback Callback for ad events */ fun preloadInterstitialAd( placementId: String, callback: InterstitialCallback ) { BCLogger.d(TAG, "Preloading interstitial ad: $placementId") // 1. Get placement config val placement = configManager.getPlacement(placementId) if (placement == null) { BCLogger.e(TAG, "Placement not found: $placementId") analyticsClient.trackUnfilledImpression(placementId, placementId, "interstitial") callback.onAdFailedToLoad("Placement not found: $placementId") return } if (placement.format != "interstitial") { BCLogger.e(TAG, "Invalid format for interstitial: ${placement.format}") analyticsClient.trackUnfilledImpression(placementId, placement.id, placement.format, placement.gamAdUnit) callback.onAdFailedToLoad("Invalid placement format: ${placement.format}") return } if (!placement.enabled) { BCLogger.d(TAG, "Placement disabled: $placementId") analyticsClient.trackUnfilledImpression(placementId, placement.id, placement.format, placement.gamAdUnit) callback.onAdFailedToLoad("Placement is disabled: $placementId") return } // 2. Track ad request analyticsClient.trackAdRequest(placementId, placement.format) // 3. Start the async ad loading flow scope.launch { preloadInterstitialAsync(placement, callback) } } private suspend fun preloadInterstitialAsync( placement: PlacementConfig, callback: InterstitialCallback ) = interstitialMutex.withLock { // Check if already cached if (interstitialCache.containsKey(placement.placementId)) { BCLogger.d(TAG, "Interstitial already cached: ${placement.placementId}") callback.onAdLoaded() return@withLock } // Get GAM network code from config val gamNetworkCode = configManager.getGamNetworkCode() if (gamNetworkCode == null) { BCLogger.e(TAG, "GAM network code not available") analyticsClient.trackUnfilledImpression(placement.placementId, placement.id, placement.format, placement.gamAdUnit) callback.onAdFailedToLoad("Config not loaded") return@withLock } // Create ad request builder val adRequestBuilder = AdManagerAdRequest.Builder() // 3. Fetch S2S demand val bidResponse = bidRequestClient.fetchDemand(placement) if (bidResponse != null && bidResponse.targeting.isNotEmpty()) { for ((key, value) in bidResponse.targeting) { adRequestBuilder.addCustomTargeting(key, value) } BCLogger.d(TAG, "S2S demand fetched for: ${placement.placementId} (${bidResponse.targeting.size} keys)") } else { BCLogger.w(TAG, "S2S demand fetch returned no targeting, continuing with Google only") } // Store auction data for analytics (or default to GAM if no S2S), // keyed by a per-load ID so concurrent loads don't clobber each other val loadId = UUID.randomUUID().toString() val auctionData = bidResponse?.auctionData ?: AuctionData( bidder = "google_ad_exchange", floorPrice = placement.floorPrice, demandChannel = "Google Ad Exchange" ) analyticsClient.setAuctionData(loadId, auctionData) // Get test ads flag from config val useTestAds = configManager.shouldUseTestAds() // 4. Load Google interstitial ad val adRequest = adRequestBuilder.build() val result = googleAdsAdapter.loadInterstitialAd(placement, gamNetworkCode, adRequest, useTestAds) when { result.isSuccess -> { val interstitialAd = result.getOrNull() if (interstitialAd == null) { BCLogger.e(TAG, "Interstitial ad was null despite success result") analyticsClient.discardAuctionData(loadId) analyticsClient.trackUnfilledImpression(placement.placementId, placement.id, placement.format, placement.gamAdUnit) callback.onAdFailedToLoad("Internal error: ad was null") return@withLock } interstitialCache[placement.placementId] = CachedInterstitial(interstitialAd, loadId) BCLogger.d(TAG, "Interstitial ad preloaded: ${placement.placementId}") callback.onAdLoaded() } else -> { val error = result.exceptionOrNull()?.message ?: "Unknown error" BCLogger.w(TAG, "Interstitial ad failed to load: ${placement.placementId} - $error") analyticsClient.discardAuctionData(loadId) analyticsClient.trackUnfilledImpression(placement.placementId, placement.id, placement.format, placement.gamAdUnit) callback.onAdFailedToLoad(error) } } } /** * Show an interstitial ad * * @param activity The activity to show the interstitial from * @param placementId The placement ID from the BigCrunch dashboard * @param callback Callback for ad events * @return true if a preloaded ad was available and presentation was initiated; * presentation failures are delivered asynchronously via callback.onAdFailedToLoad */ fun showInterstitialAd( activity: Activity, placementId: String, callback: InterstitialCallback ): Boolean { BCLogger.d(TAG, "Showing interstitial ad: $placementId") // Get placement config val placement = configManager.getPlacement(placementId) if (placement == null) { BCLogger.e(TAG, "Placement not found: $placementId") analyticsClient.trackUnfilledImpression(placementId, placementId, "interstitial") callback.onAdFailedToLoad("Placement not found: $placementId") return false } // Get cached interstitial val cached = interstitialCache.remove(placementId) if (cached == null) { BCLogger.w(TAG, "No preloaded interstitial for: $placementId") analyticsClient.trackUnfilledImpression(placementId, placement.id, placement.format, placement.gamAdUnit) callback.onAdFailedToLoad("No preloaded ad available") return false } // Create adapter callback val adapterCallback = object : InterstitialAdCallback { override fun onAdLoaded() { // Already loaded, this won't be called during show } override fun onAdFailedToLoad(error: String) { analyticsClient.trackUnfilledImpression(placement.placementId, placement.id, placement.format, placement.gamAdUnit) callback.onAdFailedToLoad(error) } override fun onAdShowed() { callback.onAdShowed() } override fun onAdDismissed() { callback.onAdDismissed() } override fun onAdClicked() { callback.onAdClicked() } override fun onAdImpression() { // Handled by GoogleAdsAdapter analytics tracking } } // Show the ad googleAdsAdapter.showInterstitialAd( cached.ad, activity, placement, adapterCallback, cached.loadId ) return true } /** * Check if an interstitial ad is ready to show * * @param placementId The placement ID to check * @return true if an ad is preloaded and ready */ fun isInterstitialReady(placementId: String): Boolean { return interstitialCache.containsKey(placementId) } /** * Preload a rewarded ad * * @param placementId The placement ID from the BigCrunch dashboard * @param callback Callback for ad events */ fun preloadRewardedAd( placementId: String, callback: RewardedCallback ) { BCLogger.d(TAG, "Preloading rewarded ad: $placementId") // 1. Get placement config val placement = configManager.getPlacement(placementId) if (placement == null) { BCLogger.e(TAG, "Placement not found: $placementId") analyticsClient.trackUnfilledImpression(placementId, placementId, "rewarded") callback.onAdFailedToLoad("Placement not found: $placementId") return } if (placement.format != "rewarded") { BCLogger.e(TAG, "Invalid format for rewarded: ${placement.format}") analyticsClient.trackUnfilledImpression(placementId, placement.id, placement.format, placement.gamAdUnit) callback.onAdFailedToLoad("Invalid placement format: ${placement.format}") return } if (!placement.enabled) { BCLogger.d(TAG, "Placement disabled: $placementId") analyticsClient.trackUnfilledImpression(placementId, placement.id, placement.format, placement.gamAdUnit) callback.onAdFailedToLoad("Placement is disabled: $placementId") return } // 2. Track ad request analyticsClient.trackAdRequest(placementId, placement.format) // 3. Start the async ad loading flow scope.launch { preloadRewardedAsync(placement, callback) } } private suspend fun preloadRewardedAsync( placement: PlacementConfig, callback: RewardedCallback ) = rewardedMutex.withLock { // Check if already cached if (rewardedCache.containsKey(placement.placementId)) { BCLogger.d(TAG, "Rewarded ad already cached: ${placement.placementId}") callback.onAdLoaded() return@withLock } // Get GAM network code from config val gamNetworkCode = configManager.getGamNetworkCode() if (gamNetworkCode == null) { BCLogger.e(TAG, "GAM network code not available") analyticsClient.trackUnfilledImpression(placement.placementId, placement.id, placement.format, placement.gamAdUnit) callback.onAdFailedToLoad("Config not loaded") return@withLock } // Create ad request builder val adRequestBuilder = AdManagerAdRequest.Builder() // 3. Fetch S2S demand val bidResponse = bidRequestClient.fetchDemand(placement) if (bidResponse != null && bidResponse.targeting.isNotEmpty()) { for ((key, value) in bidResponse.targeting) { adRequestBuilder.addCustomTargeting(key, value) } BCLogger.d(TAG, "S2S demand fetched for: ${placement.placementId} (${bidResponse.targeting.size} keys)") } else { BCLogger.w(TAG, "S2S demand fetch returned no targeting, continuing with Google only") } // Store auction data for analytics (or default to GAM if no S2S), // keyed by a per-load ID so concurrent loads don't clobber each other val loadId = UUID.randomUUID().toString() val auctionData = bidResponse?.auctionData ?: AuctionData( bidder = "google_ad_exchange", floorPrice = placement.floorPrice, demandChannel = "Google Ad Exchange" ) analyticsClient.setAuctionData(loadId, auctionData) // Get test ads flag from config val useTestAds = configManager.shouldUseTestAds() // 4. Load Google rewarded ad val adRequest = adRequestBuilder.build() val result = googleAdsAdapter.loadRewardedAd(placement, gamNetworkCode, adRequest, useTestAds) when { result.isSuccess -> { val rewardedAd = result.getOrNull() if (rewardedAd == null) { BCLogger.e(TAG, "Rewarded ad was null despite success result") analyticsClient.discardAuctionData(loadId) analyticsClient.trackUnfilledImpression(placement.placementId, placement.id, placement.format, placement.gamAdUnit) callback.onAdFailedToLoad("Internal error: ad was null") return@withLock } rewardedCache[placement.placementId] = CachedRewarded(rewardedAd, loadId) BCLogger.d(TAG, "Rewarded ad preloaded: ${placement.placementId}") callback.onAdLoaded() } else -> { val error = result.exceptionOrNull()?.message ?: "Unknown error" BCLogger.w(TAG, "Rewarded ad failed to load: ${placement.placementId} - $error") analyticsClient.discardAuctionData(loadId) analyticsClient.trackUnfilledImpression(placement.placementId, placement.id, placement.format, placement.gamAdUnit) callback.onAdFailedToLoad(error) } } } /** * Show a rewarded ad * * @param activity The activity to show the rewarded ad from * @param placementId The placement ID from the BigCrunch dashboard * @param callback Callback for ad events * @return true if a preloaded ad was available and presentation was initiated; * presentation failures are delivered asynchronously via callback.onAdFailedToLoad */ fun showRewardedAd( activity: Activity, placementId: String, callback: RewardedCallback ): Boolean { BCLogger.d(TAG, "Showing rewarded ad: $placementId") // Get placement config val placement = configManager.getPlacement(placementId) if (placement == null) { BCLogger.e(TAG, "Placement not found: $placementId") analyticsClient.trackUnfilledImpression(placementId, placementId, "rewarded") callback.onAdFailedToLoad("Placement not found: $placementId") return false } // Get cached rewarded ad val cached = rewardedCache.remove(placementId) if (cached == null) { BCLogger.w(TAG, "No preloaded rewarded ad for: $placementId") analyticsClient.trackUnfilledImpression(placementId, placement.id, placement.format, placement.gamAdUnit) callback.onAdFailedToLoad("No preloaded ad available") return false } // Create adapter callback val adapterCallback = object : RewardedAdCallback { override fun onAdLoaded() { // Already loaded, this won't be called during show } override fun onAdFailedToLoad(error: String) { analyticsClient.trackUnfilledImpression(placement.placementId, placement.id, placement.format, placement.gamAdUnit) callback.onAdFailedToLoad(error) } override fun onAdShowed() { callback.onAdShowed() } override fun onAdDismissed() { callback.onAdDismissed() } override fun onAdClicked() { callback.onAdClicked() } override fun onUserEarnedReward(type: String, amount: Int) { callback.onUserEarnedReward(type, amount) } override fun onAdImpression() { // Handled by GoogleAdsAdapter analytics tracking } } // Show the ad googleAdsAdapter.showRewardedAd( cached.ad, activity, placement, adapterCallback, cached.loadId ) return true } /** * Check if a rewarded ad is ready to show * * @param placementId The placement ID to check * @return true if an ad is preloaded and ready */ fun isRewardedReady(placementId: String): Boolean { return rewardedCache.containsKey(placementId) } /** * Destroy a specific interstitial ad * * @param placementId The placement ID to destroy */ fun destroyInterstitialAd(placementId: String) { BCLogger.d(TAG, "Destroying interstitial: $placementId") interstitialCache.remove(placementId)?.let { analyticsClient.discardAuctionData(it.loadId) } } /** * Destroy a specific rewarded ad * * @param placementId The placement ID to destroy */ fun destroyRewardedAd(placementId: String) { BCLogger.d(TAG, "Destroying rewarded ad: $placementId") rewardedCache.remove(placementId)?.let { analyticsClient.discardAuctionData(it.loadId) } } /** * Clear all cached ads */ fun clearCache() { BCLogger.d(TAG, "Clearing ad cache") interstitialCache.values.forEach { analyticsClient.discardAuctionData(it.loadId) } interstitialCache.clear() rewardedCache.values.forEach { analyticsClient.discardAuctionData(it.loadId) } rewardedCache.clear() // Destroy all active banners activeBanners.values.forEach { adView -> googleAdsAdapter.destroyBannerAd(adView) } activeBanners.clear() } /** * Cancel all in-flight load coroutines and release banner resources. * * Only call this on orchestrators privately owned by a single banner view — * the singleton orchestrators behind BigCrunchInterstitial/BigCrunchRewarded * must NOT be destroyed, or all subsequent loads would be cancelled. */ fun destroy() { BCLogger.d(TAG, "Destroying orchestrator (cancelling in-flight loads)") scope.cancel() activeBanners.values.forEach { adView -> googleAdsAdapter.destroyBannerAd(adView) } activeBanners.clear() } }