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)") } }