package com.bigcrunch.ads
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.AttributeSet
import android.view.View
import android.widget.FrameLayout
import com.bigcrunch.ads.adapters.GoogleAdsAdapter
import com.bigcrunch.ads.core.AdOrchestrator
import com.bigcrunch.ads.core.BidRequestClient
import com.bigcrunch.ads.core.BannerCallback
import com.bigcrunch.ads.internal.BCLogger
import com.bigcrunch.ads.listeners.BannerAdListener
import com.bigcrunch.ads.models.AdSize
import com.bigcrunch.ads.models.RefreshConfig
/**
* BigCrunch Banner View - UI component for displaying banner ads
*
* Usage:
* ```kotlin
* val bannerView = BigCrunchBannerView(context)
* bannerView.configure("banner_placement_id")
* bannerView.listener = object : BigCrunchBannerView.Listener {
* override fun onAdLoaded() { /* ad loaded */ }
* override fun onAdFailedToLoad(error: String) { /* handle error */ }
* override fun onAdClicked() { /* ad clicked */ }
* override fun onAdImpression() { /* impression recorded */ }
* }
* bannerView.loadAd()
*
* // When done:
* bannerView.destroy()
* ```
*
* Add to layout XML:
* ```xml
*
* ```
*/
class BigCrunchBannerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
companion object {
private const val TAG = "BigCrunchBannerView"
private const val MIN_REFRESH_INTERVAL_MS = 10000L // 10 seconds minimum
/**
* Safely execute a block, catching exceptions to prevent SDK crashes from affecting the host app.
*/
private inline fun safeExecute(block: () -> Unit) {
try {
block()
} catch (e: Exception) {
BCLogger.e(TAG, "Exception caught in banner view - SDK will not propagate to prevent app crash", e)
}
}
}
/**
* Optional listener for ad events
*/
var listener: BannerAdListener? = null
set(value) {
field = value
bannerAdListener = value
}
// Keep reference to support both old and new listener interfaces
private var bannerAdListener: BannerAdListener? = null
private var placementId: String? = null
private var adSize: AdSize? = null
private var customTargeting: Map? = null
private var isConfigured = false
@Volatile private var isDestroyed = false
@Volatile private var isPaused = false
private var adOrchestrator: AdOrchestrator? = null
// Refresh properties
private val refreshHandler = Handler(Looper.getMainLooper())
private var refreshRunnable: Runnable? = null
private var refreshCount: Int = 0
private var effectiveRefreshConfig: RefreshConfig? = null
/**
* Runnable that manually measures and lays out child views.
* This is needed for React Native compatibility because React Native's UIManagerModule
* intercepts requestLayout() calls, preventing dynamically added child views
* (like AdManagerAdView) from being properly measured and laid out.
* See: https://github.com/facebook/react-native/issues/17968
*/
private val measureAndLayout = Runnable {
BCLogger.d(TAG, "=== measureAndLayout START === childCount=$childCount, parent=${width}x${height}, measured=${measuredWidth}x${measuredHeight}")
for (i in 0 until childCount) {
val child = getChildAt(i)
// For AdManagerAdView, use its own ad size for measurement
val childWidth: Int
val childHeight: Int
if (child is com.google.android.gms.ads.admanager.AdManagerAdView) {
val adSize = child.adSize
BCLogger.d(TAG, "Child $i is AdManagerAdView, adSize=$adSize")
if (adSize != null) {
val adWidthPx = adSize.getWidthInPixels(context)
val adHeightPx = adSize.getHeightInPixels(context)
// Constrain width to parent to prevent overflow (important for adaptive banners)
val parentWidth = if (width > 0) width else measuredWidth
childWidth = if (parentWidth > 0) minOf(adWidthPx, parentWidth) else adWidthPx
childHeight = adHeightPx
BCLogger.d(TAG, "Using adSize pixels: ${adWidthPx}x${adHeightPx}, constrained width: $childWidth (parent: $parentWidth)")
} else {
// Fallback to parent dimensions
childWidth = if (width > 0) width else measuredWidth
childHeight = if (height > 0) height else measuredHeight
BCLogger.d(TAG, "adSize is null, using parent: ${childWidth}x${childHeight}")
}
} else {
// For other views, use parent dimensions
childWidth = if (width > 0) width else measuredWidth
childHeight = if (height > 0) height else measuredHeight
BCLogger.d(TAG, "Child $i is ${child.javaClass.simpleName}, using parent: ${childWidth}x${childHeight}")
}
// Skip if we don't have valid dimensions
if (childWidth <= 0 || childHeight <= 0) {
BCLogger.w(TAG, "Skipping child $i - invalid dimensions: ${childWidth}x${childHeight}")
continue
}
BCLogger.d(TAG, "BEFORE measure/layout - child dimensions: ${child.width}x${child.height}, measured: ${child.measuredWidth}x${child.measuredHeight}, visibility: ${child.visibility}")
child.measure(
View.MeasureSpec.makeMeasureSpec(childWidth, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(childHeight, View.MeasureSpec.EXACTLY)
)
child.layout(0, 0, child.measuredWidth, child.measuredHeight)
// Force invalidation to ensure the view is redrawn after layout
child.invalidate()
BCLogger.d(TAG, "AFTER measure/layout - child dimensions: ${child.width}x${child.height}, measured: ${child.measuredWidth}x${child.measuredHeight}, visibility: ${child.visibility}")
// Log additional view properties for debugging
if (child is com.google.android.gms.ads.admanager.AdManagerAdView) {
BCLogger.d(TAG, """
=== AD VIEW DEBUG ===
isShown: ${child.isShown}
alpha: ${child.alpha}
isAttachedToWindow: ${child.isAttachedToWindow}
hasWindowFocus: ${child.hasWindowFocus()}
parent: ${child.parent}
clipChildren (this): $clipChildren
clipToPadding (this): $clipToPadding
=====================
""".trimIndent())
}
}
// Invalidate this container as well to ensure redraw
invalidate()
BCLogger.d(TAG, "=== measureAndLayout END ===")
}
init {
// Disable clipping to ensure child views can be fully drawn
// This is important for React Native where clipping can cause issues
clipChildren = false
clipToPadding = false
BCLogger.v(TAG, "BigCrunchBannerView created")
}
/**
* Override requestLayout to ensure child views are properly measured and laid out.
* React Native suppresses normal Android layout passes, so we manually trigger them.
*/
override fun requestLayout() {
super.requestLayout()
post(measureAndLayout)
}
/**
* Configure the banner view with a placement ID
*
* @param placementId The placement ID from the BigCrunch dashboard
*/
fun configure(placementId: String) {
if (isDestroyed) {
BCLogger.w(TAG, "Cannot configure destroyed banner view")
return
}
if (placementId.isBlank()) {
BCLogger.e(TAG, "Invalid placementId: cannot be blank")
return
}
this.placementId = placementId
this.isConfigured = true
BCLogger.d(TAG, "Banner configured with placement: $placementId")
}
/**
* Load a banner ad
*
* Must call [configure] before calling this method.
* The listener will be notified of load success or failure.
*/
fun loadAd() = safeExecute {
if (isDestroyed) {
BCLogger.w(TAG, "Cannot load ad on destroyed banner view")
bannerAdListener?.onAdFailedToLoad("DESTROYED", "Banner view has been destroyed")
return@safeExecute
}
if (!isConfigured || placementId == null) {
BCLogger.e(TAG, "Banner not configured. Call configure() first.")
bannerAdListener?.onAdFailedToLoad("NOT_CONFIGURED", "Banner not configured. Call configure() first.")
return@safeExecute
}
val pid = placementId ?: return@safeExecute
BCLogger.d(TAG, "Loading banner ad: $pid with adSize override: $adSize (refreshCount: $refreshCount)")
if (!BigCrunchAds.isInitialized()) {
BCLogger.e(TAG, "SDK not initialized")
bannerAdListener?.onAdFailedToLoad("SDK_NOT_INITIALIZED", "SDK not initialized. Call BigCrunchAds.initialize() first.")
return@safeExecute
}
// Resolve effective refresh config on first load
if (effectiveRefreshConfig == null) {
val configManager = BigCrunchAds.getConfigManager()
effectiveRefreshConfig = configManager.getEffectiveRefreshConfig(pid)
effectiveRefreshConfig?.let { config ->
BCLogger.d(TAG, "Refresh config resolved - enabled: ${config.enabled}, interval: ${config.intervalMs}ms, max: ${config.maxRefreshes}")
}
}
// Create AdOrchestrator lazily
if (adOrchestrator == null) {
adOrchestrator = createAdOrchestrator()
}
// Create callback wrapper
val callback = object : BannerCallback {
override fun onAdLoaded() {
BCLogger.d(TAG, "Banner ad loaded: $pid")
bannerAdListener?.onAdLoaded()
// Schedule next refresh after successful load
scheduleRefresh()
}
override fun onAdFailedToLoad(error: String) {
BCLogger.w(TAG, "Banner ad failed to load: $pid - $error")
bannerAdListener?.onAdFailedToLoad("LOAD_ERROR", error)
// Note: Do NOT schedule refresh on failure to avoid retry loops
}
override fun onAdClicked() {
BCLogger.d(TAG, "Banner ad clicked: $pid")
bannerAdListener?.onAdClicked()
}
override fun onAdImpression() {
BCLogger.d(TAG, "Banner ad impression: $pid")
bannerAdListener?.onAdImpression()
}
}
// Load the ad through the orchestrator, passing the ad size override if set
adOrchestrator?.loadBannerAd(pid, this, callback, adSize, refreshCount)
}
/**
* Destroy the banner view and release resources
*
* Call this when the banner is no longer needed (e.g., in Activity.onDestroy())
*/
fun destroy() = safeExecute {
if (isDestroyed) {
BCLogger.v(TAG, "Banner already destroyed")
return@safeExecute
}
BCLogger.d(TAG, "Destroying banner: $placementId")
// Mark destroyed FIRST so an in-flight load or racing refresh
// sees the flag and bails out
isDestroyed = true
// Stop refresh timer
cancelRefreshTimer()
placementId?.let { pid ->
adOrchestrator?.destroyBannerAd(pid)
}
// Cancel any in-flight load coroutines (suspended at the S2S fetch) so a
// late-arriving ad can't attach to this destroyed container. Safe because
// this orchestrator is owned exclusively by this view.
adOrchestrator?.destroy()
removeAllViews()
listener = null
adOrchestrator = null
effectiveRefreshConfig = null
refreshCount = 0
}
/**
* Check if the banner view has been destroyed
*/
fun isDestroyed(): Boolean = isDestroyed
/**
* Get the configured placement ID
*/
fun getPlacementId(): String? = placementId
/**
* Get the current refresh count
*/
fun getRefreshCount(): Int = refreshCount
/**
* Set the placement ID (alternative to configure method)
*
* @param placementId The placement ID from the BigCrunch dashboard
*/
fun setPlacementId(placementId: String) {
configure(placementId)
}
/**
* Set the ad size for this banner
*
* @param adSize The desired ad size
*/
fun setAdSize(adSize: AdSize) {
this.adSize = adSize
BCLogger.d(TAG, "Ad size set to: $adSize")
}
/**
* Get the current ad size
*
* @return The ad size, or null if not set
*/
fun getAdSize(): AdSize? = adSize
/**
* Set custom targeting parameters for ad requests
*
* @param targeting Map of key-value pairs for custom targeting
*/
fun setCustomTargeting(targeting: Map) {
this.customTargeting = targeting
BCLogger.d(TAG, "Custom targeting set: ${targeting.size} parameters")
}
/**
* Set the refresh interval for auto-refreshing ads
*
* This overrides the config-driven refresh interval.
*
* @param intervalMs Refresh interval in milliseconds (0 to disable)
*/
fun setRefreshInterval(intervalMs: Int) {
if (intervalMs <= 0) {
effectiveRefreshConfig = RefreshConfig(enabled = false, intervalMs = 0, maxRefreshes = 0)
cancelRefreshTimer()
} else {
effectiveRefreshConfig = RefreshConfig(
enabled = true,
intervalMs = intervalMs,
maxRefreshes = effectiveRefreshConfig?.maxRefreshes ?: 99
)
}
BCLogger.d(TAG, "Refresh interval set to: ${intervalMs}ms")
}
/**
* Set the banner ad listener
*
* @param listener The listener for ad events
*/
fun setBannerAdListener(listener: BannerAdListener) {
this.listener = listener
}
/**
* Pause ad refresh (if auto-refresh is enabled)
*/
fun pause() {
isPaused = true
cancelRefreshTimer()
BCLogger.d(TAG, "Banner ad refresh paused")
}
/**
* Resume ad refresh (if auto-refresh is enabled)
*/
fun resume() {
isPaused = false
BCLogger.d(TAG, "Banner ad refresh resumed")
scheduleRefresh()
}
// MARK: - Refresh Logic
private fun scheduleRefresh() {
// Don't schedule if destroyed or paused
if (isDestroyed || isPaused) return
val config = effectiveRefreshConfig ?: return
if (!config.enabled) return
// Check max refreshes limit
if (refreshCount >= config.maxRefreshes) {
BCLogger.d(TAG, "Max refreshes reached (${config.maxRefreshes}) for $placementId")
return
}
// Enforce minimum interval
val intervalMs = maxOf(config.intervalMs.toLong(), MIN_REFRESH_INTERVAL_MS)
// Cancel any existing timer
cancelRefreshTimer()
BCLogger.d(TAG, "Scheduling refresh in ${intervalMs}ms (count: $refreshCount/${config.maxRefreshes}) for $placementId")
val runnable = Runnable {
performRefresh()
}
refreshRunnable = runnable
refreshHandler.postDelayed(runnable, intervalMs)
}
private fun performRefresh() {
if (isDestroyed || isPaused) return
refreshCount++
BCLogger.d(TAG, "Refreshing banner ad (refresh #$refreshCount) for $placementId")
loadAd()
}
private fun cancelRefreshTimer() {
refreshRunnable?.let { refreshHandler.removeCallbacks(it) }
refreshRunnable = null
}
private fun createAdOrchestrator(): AdOrchestrator {
val configManager = BigCrunchAds.getConfigManager()
val analyticsClient = BigCrunchAds.getAnalyticsClient()
val googleAdsAdapter = GoogleAdsAdapter(context, analyticsClient)
// BidRequestClient is shared across all views for batching
val bidRequestClient = BigCrunchAds.bidRequestClient ?: BidRequestClient(
httpClient = com.bigcrunch.ads.internal.HttpClient(),
configManager = configManager,
privacyStore = BigCrunchAds.privacyStore,
s2sConfig = com.bigcrunch.ads.models.S2SConfig(enabled = false, serverUrl = "", timeoutMs = 0)
)
return AdOrchestrator(
context,
configManager,
analyticsClient,
bidRequestClient,
googleAdsAdapter
)
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
// Note: We don't auto-destroy here for React Native compatibility
// React Native may temporarily detach views during ScrollView recycling
// The view manager will call destroy() when appropriate
BCLogger.v(TAG, "Banner detached from window (not auto-destroying)")
}
}