package com.bigcrunch.ads.adapters import android.app.Activity import android.content.Context import android.view.Gravity import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import com.bigcrunch.ads.core.AnalyticsClient import com.bigcrunch.ads.internal.BCLogger import com.bigcrunch.ads.models.PlacementConfig import com.google.android.gms.ads.AdError import com.google.android.gms.ads.AdListener import com.google.android.gms.ads.AdSize import com.google.android.gms.ads.FullScreenContentCallback import com.google.android.gms.ads.LoadAdError import com.google.android.gms.ads.OnPaidEventListener 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.admanager.AdManagerInterstitialAdLoadCallback import com.google.android.gms.ads.rewarded.RewardedAd import com.google.android.gms.ads.rewarded.RewardedAdLoadCallback import com.google.android.gms.ads.rewarded.RewardItem import com.google.android.gms.ads.OnUserEarnedRewardListener import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine /** * Callback interface for banner ad events */ interface BannerAdCallback { fun onAdLoaded() fun onAdFailedToLoad(error: String) fun onAdClicked() fun onAdImpression() } /** * Callback interface for interstitial ad events */ interface InterstitialAdCallback { fun onAdLoaded() fun onAdFailedToLoad(error: String) fun onAdShowed() fun onAdDismissed() fun onAdClicked() fun onAdImpression() } /** * Callback interface for rewarded ad events */ interface RewardedAdCallback { fun onAdLoaded() fun onAdFailedToLoad(error: String) fun onAdShowed() fun onAdDismissed() fun onAdClicked() fun onAdImpression() fun onUserEarnedReward(type: String, amount: Int) } /** * Adapter for Google Mobile Ads SDK (Ad Manager) * * Wraps GMA SDK calls and translates callbacks to AnalyticsClient events. * Handles banner, interstitial, and rewarded ad formats. */ internal class GoogleAdsAdapter( private val context: Context, private val analyticsClient: AnalyticsClient ) { companion object { private const val TAG = "GoogleAdsAdapter" // AdValue.precisionType values per Google's published contract. // Hard-coded because the symbolic constants moved/renamed across GMA versions // (PRECISE / PRECISION_TYPE_PRECISE depending on SDK release). // See: https://developers.google.com/admob/android/api/reference/com/google/android/gms/ads/AdValue private const val PRECISION_TYPE_UNKNOWN = 0 private const val PRECISION_TYPE_ESTIMATED = 1 private const val PRECISION_TYPE_PUBLISHER_PROVIDED = 2 private const val PRECISION_TYPE_PRECISE = 3 /** * Safely execute a callback, catching any exceptions thrown by user code. * This ensures SDK stability even if app's callback implementation throws. */ private inline fun safeCallback(block: () -> Unit) { try { block() } catch (e: Exception) { BCLogger.e(TAG, "Callback threw exception - SDK will not propagate this to prevent app crash", e) } } // Google's official test ad unit IDs // These are guaranteed to return test ads and will always fill private const val TEST_BANNER_AD_UNIT = "ca-app-pub-3940256099942544/6300978111" private const val TEST_INTERSTITIAL_AD_UNIT = "ca-app-pub-3940256099942544/1033173712" private const val TEST_REWARDED_AD_UNIT = "ca-app-pub-3940256099942544/5224354917" } /** * Resolve the ad unit ID to use - test ID if useTestAds is true, otherwise production */ private fun resolveAdUnitId( placementConfig: PlacementConfig, gamNetworkCode: String, useTestAds: Boolean ): String { if (useTestAds) { val testAdUnit = when (placementConfig.format) { "banner" -> TEST_BANNER_AD_UNIT "interstitial" -> TEST_INTERSTITIAL_AD_UNIT "rewarded" -> TEST_REWARDED_AD_UNIT else -> TEST_BANNER_AD_UNIT // fallback } BCLogger.d(TAG, "Using TEST ad unit for ${placementConfig.format}: $testAdUnit") return testAdUnit } else { // Production ad unit path (gamAdUnit already contains the full path e.g. /networkCode/adUnit) BCLogger.d(TAG, "Using PRODUCTION ad unit: ${placementConfig.gamAdUnit}") return placementConfig.gamAdUnit } } /** * Resolve a BigCrunch AdSize to a Google AdSize. * For adaptive sizes, calculates the optimal ad size based on screen width. */ /** * Resolve a BigCrunch AdSize to a Google AdSize. * A 0x0 size is always treated as adaptive since it is never valid as a fixed size. */ private fun resolveGoogleAdSize( bcAdSize: com.bigcrunch.ads.models.AdSize ): AdSize { if (bcAdSize.isAdaptive || (bcAdSize.width == 0 && bcAdSize.height == 0)) { val widthDp = if (bcAdSize.width > 0) { bcAdSize.width } else { val displayMetrics = context.resources.displayMetrics (displayMetrics.widthPixels / displayMetrics.density).toInt() } BCLogger.d(TAG, "Resolving adaptive banner with width: ${widthDp}dp (isAdaptive=${bcAdSize.isAdaptive}, original=${bcAdSize.width}x${bcAdSize.height})") return AdSize.getCurrentOrientationAnchoredAdaptiveBannerAdSize(context, widthDp) } return AdSize(bcAdSize.width, bcAdSize.height) } /** * Create and load a banner ad * * @param placementConfig Configuration for the placement * @param gamNetworkCode The GAM network code from app config * @param adRequest Pre-configured ad request (with S2S targeting) * @param container ViewGroup to add the banner to * @param callback Callback for ad events * @param useTestAds Whether to use test ad units * @param adSizeOverride Optional size override (takes precedence over backend config) * @return The loaded AdManagerAdView */ fun loadBannerAd( placementConfig: PlacementConfig, gamNetworkCode: String, adRequest: AdManagerAdRequest, container: ViewGroup, callback: BannerAdCallback, useTestAds: Boolean = false, adSizeOverride: com.bigcrunch.ads.models.AdSize? = null, refreshCount: Int = 0, loadId: String? = null ): AdManagerAdView { BCLogger.d(TAG, "Loading banner ad for: ${placementConfig.placementId} with adSizeOverride: $adSizeOverride") // Resolve ad unit ID (test or production) val adUnitId = resolveAdUnitId(placementConfig, gamNetworkCode, useTestAds) val adView = AdManagerAdView(context) adView.adUnitId = adUnitId // Set ad size: use override first, then placement config, then default if (adSizeOverride != null) { BCLogger.d(TAG, "Using size override: $adSizeOverride (adaptive=${adSizeOverride.isAdaptive})") adView.setAdSize(resolveGoogleAdSize(adSizeOverride)) } else { val size = placementConfig.sizes?.firstOrNull() if (size != null) { adView.setAdSize(resolveGoogleAdSize(size)) } else { // Default to banner size adView.setAdSize(AdSize.BANNER) } } // Set up ad listener for callbacks adView.adListener = object : AdListener() { override fun onAdLoaded() { BCLogger.d(TAG, "Banner ad loaded: ${placementConfig.placementId}") // Manually measure and layout the ad view for React Native compatibility // React Native suppresses normal Android layout passes, so we need to do this explicitly val adSize = adView.adSize if (adSize != null) { val width = adSize.getWidthInPixels(context) val height = adSize.getHeightInPixels(context) val left = adView.left val top = adView.top BCLogger.d(TAG, """ === MANUAL LAYOUT IN onAdLoaded === Ad size: ${adSize.width}x${adSize.height} dp Pixels: ${width}x${height} Position: left=$left, top=$top ==================================== """.trimIndent()) adView.measure( View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY) ) adView.layout(left, top, left + width, top + height) } // Log ad view dimensions after manual layout BCLogger.d(TAG, """ === AD VIEW AFTER LOAD (post-layout) === Ad View dimensions: ${adView.width}x${adView.height} Ad View measured: ${adView.measuredWidth}x${adView.measuredHeight} Ad View visibility: ${adView.visibility} Ad View parent: ${adView.parent} Container dimensions: ${container.width}x${container.height} ========================================= """.trimIndent()) // Force container to re-layout its children (triggers measureAndLayout runnable) container.requestLayout() container.invalidate() safeCallback { callback.onAdLoaded() } } override fun onAdFailedToLoad(error: LoadAdError) { BCLogger.w(TAG, "Banner ad failed to load: ${error.message}") // No impression will fire for this load; drop its stored auction data loadId?.let { analyticsClient.discardAuctionData(it) } safeCallback { callback.onAdFailedToLoad(error.message) } } override fun onAdClicked() { BCLogger.d(TAG, "Banner ad clicked: ${placementConfig.placementId}") analyticsClient.trackAdClick(placementConfig.placementId, placementConfig.id, placementConfig.format) safeCallback { callback.onAdClicked() } } override fun onAdImpression() { BCLogger.d(TAG, "Banner ad impression recorded: ${placementConfig.placementId}") // Extract GAM metadata from ResponseInfo at impression time val responseInfo = adView.responseInfo val adMetadata = extractAdMetadata(responseInfo) // Extract ad size from the loaded banner val loadedAdSize = adView.adSize val adSizeString = if (loadedAdSize != null) "${loadedAdSize.width}x${loadedAdSize.height}" else "" // Track on impression-record (not load) so the deferred-impression window // co-aligns with the GMA paid event for ILRD-driven AdX revenue. analyticsClient.trackAdImpression( placementId = placementConfig.placementId, slotId = placementConfig.id, format = placementConfig.format, gamAdUnit = placementConfig.gamAdUnit, adSize = adSizeString, advertiserId = adMetadata["advertiser_id"] as? String, campaignId = adMetadata["campaign_id"] as? String, lineItemId = adMetadata["line_item_id"] as? String, creativeId = adMetadata["creative_id"] as? String, refreshCount = refreshCount, loadId = loadId ) safeCallback { callback.onAdImpression() } } } // Set up paid event listener for ILRD (Impression-Level Revenue Data) adView.onPaidEventListener = OnPaidEventListener { adValue -> BCLogger.d(TAG, "Banner paid event: ${adValue.valueMicros} ${adValue.currencyCode} precision=${adValue.precisionType}") // valueMicros is per-impression revenue in micros; divide by 1000 to get CPM val revenueCpm = adValue.valueMicros / 1_000.0 // PRECISE/ESTIMATED → Google's auction provided the value (AdX outbid the S2S line item). // PUBLISHER_PROVIDED → our hb_pb targeting was used (S2S line item won). // UNKNOWN → don't override; trust the pre-set auction data. val googleAuctionWon = adValue.precisionType == PRECISION_TYPE_PRECISE || adValue.precisionType == PRECISION_TYPE_ESTIMATED analyticsClient.handleIlrdRevenue(placementConfig.placementId, revenueCpm, googleAuctionWon) } // Add to container with proper layout parameters // Use actual pixel dimensions from the ad size val adSize = adView.adSize val widthPixels = if (adSize != null) { adSize.getWidthInPixels(context) } else { // Default banner width in pixels (320dp) (320 * context.resources.displayMetrics.density).toInt() } val heightPixels = if (adSize != null) { adSize.getHeightInPixels(context) } else { // Default banner height in pixels (50dp) (50 * context.resources.displayMetrics.density).toInt() } val layoutParams = FrameLayout.LayoutParams(widthPixels, heightPixels).apply { gravity = Gravity.CENTER } // Ensure the AdView is visible adView.visibility = View.VISIBLE BCLogger.d(TAG, """ === BEFORE ADDING AD VIEW === Container: $container Container children: ${container.childCount} Ad View: $adView Ad size: ${adSize?.width}x${adSize?.height} Layout params: ${widthPixels}x${heightPixels} ============================ """.trimIndent()) container.addView(adView, layoutParams) // Trigger requestLayout on container to ensure child views get measured/laid out // This is critical for React Native where layout passes are suppressed container.requestLayout() BCLogger.d(TAG, """ === AFTER ADDING AD VIEW === Container children: ${container.childCount} Ad View parent: ${adView.parent} Ad View visibility: ${adView.visibility} Ad View dimensions: ${adView.width}x${adView.height} =========================== """.trimIndent()) adView.loadAd(adRequest) return adView } /** * Load an interstitial ad * * @param placementConfig Configuration for the placement * @param gamNetworkCode The GAM network code from app config * @param adRequest Pre-configured ad request (with S2S targeting) * @return Result containing loaded interstitial or error */ suspend fun loadInterstitialAd( placementConfig: PlacementConfig, gamNetworkCode: String, adRequest: AdManagerAdRequest, useTestAds: Boolean = false ): Result = suspendCoroutine { continuation -> BCLogger.d(TAG, "Loading interstitial ad for: ${placementConfig.placementId}") // Resolve ad unit ID (test or production) val adUnitId = resolveAdUnitId(placementConfig, gamNetworkCode, useTestAds) AdManagerInterstitialAd.load( context, adUnitId, adRequest, object : AdManagerInterstitialAdLoadCallback() { override fun onAdLoaded(interstitialAd: AdManagerInterstitialAd) { BCLogger.d(TAG, "Interstitial ad loaded: ${placementConfig.placementId}") // Set up paid event listener for ILRD interstitialAd.onPaidEventListener = OnPaidEventListener { adValue -> BCLogger.d(TAG, "Interstitial paid event: ${adValue.valueMicros} ${adValue.currencyCode} precision=${adValue.precisionType}") // valueMicros is per-impression revenue in micros; divide by 1000 to get CPM val revenueCpm = adValue.valueMicros / 1_000.0 val googleAuctionWon = adValue.precisionType == PRECISION_TYPE_PRECISE || adValue.precisionType == PRECISION_TYPE_ESTIMATED analyticsClient.handleIlrdRevenue(placementConfig.placementId, revenueCpm, googleAuctionWon) } continuation.resume(Result.success(interstitialAd)) } override fun onAdFailedToLoad(error: LoadAdError) { BCLogger.w(TAG, "Interstitial ad failed to load: ${error.message}") continuation.resume(Result.failure(Exception(error.message))) } } ) } /** * Show an interstitial ad * * @param interstitialAd The loaded interstitial ad * @param activity Activity to show the ad from * @param placementConfig Configuration for the placement * @param callback Callback for ad events */ fun showInterstitialAd( interstitialAd: AdManagerInterstitialAd, activity: Activity, placementConfig: PlacementConfig, callback: InterstitialAdCallback, loadId: String? = null ) { BCLogger.d(TAG, "Showing interstitial ad: ${placementConfig.placementId}") interstitialAd.fullScreenContentCallback = object : FullScreenContentCallback() { override fun onAdShowedFullScreenContent() { BCLogger.d(TAG, "Interstitial ad shown: ${placementConfig.placementId}") // Extract GAM metadata from ResponseInfo val responseInfo = interstitialAd.responseInfo val adMetadata = extractAdMetadata(responseInfo) analyticsClient.trackAdImpression( placementId = placementConfig.placementId, slotId = placementConfig.id, format = placementConfig.format, gamAdUnit = placementConfig.gamAdUnit, advertiserId = adMetadata["advertiser_id"] as? String, campaignId = adMetadata["campaign_id"] as? String, lineItemId = adMetadata["line_item_id"] as? String, creativeId = adMetadata["creative_id"] as? String, loadId = loadId ) safeCallback { callback.onAdShowed() } } override fun onAdDismissedFullScreenContent() { BCLogger.d(TAG, "Interstitial ad dismissed: ${placementConfig.placementId}") safeCallback { callback.onAdDismissed() } } override fun onAdFailedToShowFullScreenContent(error: AdError) { BCLogger.w(TAG, "Interstitial ad failed to show: ${error.message}") // No impression will fire for this load; drop its stored auction data loadId?.let { analyticsClient.discardAuctionData(it) } safeCallback { callback.onAdFailedToLoad(error.message) } } override fun onAdClicked() { BCLogger.d(TAG, "Interstitial ad clicked: ${placementConfig.placementId}") analyticsClient.trackAdClick(placementConfig.placementId, placementConfig.id, placementConfig.format) safeCallback { callback.onAdClicked() } } override fun onAdImpression() { BCLogger.d(TAG, "Interstitial ad impression: ${placementConfig.placementId}") safeCallback { callback.onAdImpression() } } } interstitialAd.show(activity) } /** * Load a rewarded ad * * @param placementConfig Configuration for the placement * @param gamNetworkCode The GAM network code from app config * @param adRequest Pre-configured ad request (with S2S targeting) * @return Result containing the loaded RewardedAd or an error */ suspend fun loadRewardedAd( placementConfig: PlacementConfig, gamNetworkCode: String, adRequest: AdManagerAdRequest, useTestAds: Boolean = false ): Result = suspendCoroutine { continuation -> BCLogger.d(TAG, "Loading rewarded ad for: ${placementConfig.placementId}") // Resolve ad unit ID (test or production) val adUnitId = resolveAdUnitId(placementConfig, gamNetworkCode, useTestAds) RewardedAd.load( context, adUnitId, adRequest, object : RewardedAdLoadCallback() { override fun onAdLoaded(ad: RewardedAd) { BCLogger.d(TAG, "Rewarded ad loaded: ${placementConfig.placementId}") // Set up paid event listener for ILRD ad.onPaidEventListener = OnPaidEventListener { adValue -> BCLogger.d(TAG, "Rewarded ad paid event: ${adValue.valueMicros} ${adValue.currencyCode} precision=${adValue.precisionType}") // valueMicros is per-impression revenue in micros; divide by 1000 to get CPM val revenueCpm = adValue.valueMicros / 1_000.0 val googleAuctionWon = adValue.precisionType == PRECISION_TYPE_PRECISE || adValue.precisionType == PRECISION_TYPE_ESTIMATED analyticsClient.handleIlrdRevenue(placementConfig.placementId, revenueCpm, googleAuctionWon) } continuation.resume(Result.success(ad)) } override fun onAdFailedToLoad(error: LoadAdError) { BCLogger.w(TAG, "Rewarded ad failed to load: ${error.message}") continuation.resume(Result.failure(Exception(error.message))) } } ) } /** * Show a rewarded ad * * @param rewardedAd The loaded rewarded ad to show * @param activity The activity to show from * @param placementConfig Configuration for the placement * @param callback Callback for ad events */ fun showRewardedAd( rewardedAd: RewardedAd, activity: Activity, placementConfig: PlacementConfig, callback: RewardedAdCallback, loadId: String? = null ) { BCLogger.d(TAG, "Showing rewarded ad for: ${placementConfig.placementId}") rewardedAd.fullScreenContentCallback = object : FullScreenContentCallback() { override fun onAdShowedFullScreenContent() { BCLogger.d(TAG, "Rewarded ad shown: ${placementConfig.placementId}") // Extract GAM metadata from ResponseInfo val responseInfo = rewardedAd.responseInfo val adMetadata = extractAdMetadata(responseInfo) analyticsClient.trackAdImpression( placementId = placementConfig.placementId, slotId = placementConfig.id, format = placementConfig.format, gamAdUnit = placementConfig.gamAdUnit, advertiserId = adMetadata["advertiser_id"] as? String, campaignId = adMetadata["campaign_id"] as? String, lineItemId = adMetadata["line_item_id"] as? String, creativeId = adMetadata["creative_id"] as? String, loadId = loadId ) safeCallback { callback.onAdShowed() } } override fun onAdDismissedFullScreenContent() { BCLogger.d(TAG, "Rewarded ad dismissed: ${placementConfig.placementId}") safeCallback { callback.onAdDismissed() } } override fun onAdFailedToShowFullScreenContent(error: AdError) { BCLogger.w(TAG, "Rewarded ad failed to show: ${error.message}") // No impression will fire for this load; drop its stored auction data loadId?.let { analyticsClient.discardAuctionData(it) } safeCallback { callback.onAdFailedToLoad(error.message) } } override fun onAdClicked() { BCLogger.d(TAG, "Rewarded ad clicked: ${placementConfig.placementId}") analyticsClient.trackAdClick(placementConfig.placementId, placementConfig.id, placementConfig.format) safeCallback { callback.onAdClicked() } } override fun onAdImpression() { BCLogger.d(TAG, "Rewarded ad impression: ${placementConfig.placementId}") safeCallback { callback.onAdImpression() } } } // Show the ad with reward handler rewardedAd.show(activity) { rewardItem: RewardItem -> val rewardAmount = rewardItem.amount val rewardType = rewardItem.type BCLogger.d(TAG, "User earned reward: $rewardType x$rewardAmount") safeCallback { callback.onUserEarnedReward(rewardType, rewardAmount) } } } /** * Destroy a banner ad view * * @param adView The AdManagerAdView to destroy */ fun destroyBannerAd(adView: AdManagerAdView) { BCLogger.d(TAG, "Destroying banner ad") adView.destroy() } /** * Extract GAM metadata from ResponseInfo * * Google Ad Manager provides limited metadata in ResponseInfo. The main fields available are: * - adSourceId: Can contain advertiser ID or network ID * - responseId: Unique response identifier (can be used as creative ID proxy) * * Note: GAM doesn't directly expose campaignId or lineItemId in mobile SDK ResponseInfo. * These fields may be available in server-side logs but not in the client SDK. * * @param responseInfo The ResponseInfo from the ad response * @return Map of available metadata fields */ private fun extractAdMetadata(responseInfo: com.google.android.gms.ads.ResponseInfo?): Map { if (responseInfo == null) { return emptyMap() } val metadata = mutableMapOf() // Extract adSourceId (often contains advertiser/network ID) val loadedAdapter = responseInfo.loadedAdapterResponseInfo if (loadedAdapter != null) { metadata["advertiser_id"] = loadedAdapter.adSourceId metadata["creative_id"] = loadedAdapter.adSourceInstanceId } // Response ID can serve as a creative identifier val responseId = responseInfo.responseId if (responseId != null && !responseId.isEmpty()) { // Use responseId as creative_id if we don't have a better one if (!metadata.containsKey("creative_id")) { metadata["creative_id"] = responseId } } // Log extracted metadata for debugging if (metadata.isNotEmpty()) { BCLogger.d(TAG, "Extracted GAM metadata: $metadata") } return metadata } }