From 31f91dd3f9f115212dbd70c63b17319cb1787c6c Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Tue, 1 Sep 2026 21:25:28 +0200 Subject: [PATCH] Keep asking for a banner after one does not fill The AdView was loaded while still detached and only added to the container in onAdLoaded; on a failure the house ad took the container instead, so it never entered the hierarchy at all. The ad unit only refreshes a banner that is on screen, and nothing else asks again inside a session - showGoogleAds runs once per launch and refreshAds only on a configuration change - so one request that did not fill ended the session's advertising. The banner is now built once and kept for the life of the activity, beside the house ad, exactly one of them shown at a time. A configuration change lays the house ad out again and asks for a new banner only when the width really changed, as AdSlot does on iOS; nothing is torn out mid-request and the slot never collapses while one is in flight. A failure schedules its own ask, rescheduling before each request so one that never answers cannot end the chain, and onPause/onResume stop and start both it and the sdk's refresh. A refresh that does not fill keeps the ad already on screen, and paying for ad removal destroys the banner rather than leaving it attached and refreshing. --- .../opendocument/droid/nonfree/AdManager.kt | 274 +++++++++++++----- .../droid/ui/activity/MainActivity.kt | 17 ++ .../opendocument/droid/nonfree/AdManager.kt | 4 + 3 files changed, 229 insertions(+), 66 deletions(-) diff --git a/app/src/ads/java/app/opendocument/droid/nonfree/AdManager.kt b/app/src/ads/java/app/opendocument/droid/nonfree/AdManager.kt index b60ee84c9b22..d88afb9d3d16 100644 --- a/app/src/ads/java/app/opendocument/droid/nonfree/AdManager.kt +++ b/app/src/ads/java/app/opendocument/droid/nonfree/AdManager.kt @@ -1,6 +1,8 @@ package app.opendocument.droid.nonfree import android.app.Activity +import android.os.Handler +import android.os.Looper import android.util.TypedValue import android.view.View import android.widget.ImageView @@ -34,6 +36,46 @@ class AdManager { private lateinit var adContainer: LinearLayout private var adView: AdView? = null + /** The house ad sitting in the container beside [adView]; only ever one of the two is shown. */ + private var houseAd: View? = null + + /** Which text the house ad in the container carries. The stored index only moves on a show. */ + private var houseAdVariant = 0 + + /** The slot width the house ad was laid out for, for the breadcrumb it leaves when shown. */ + private var houseAdWidth = 0 + + /** The width the banner was last asked at: a change back to it needs no new request. */ + private var requestedWidth = 0 + + /** Whether a banner has ever filled. A refresh that does not keeps the ad already on screen. */ + private var hasAd = false + + private var paused = false + + private val retries = Handler(Looper.getMainLooper()) + + /** + * How long the next retry waits: doubling from [FIRST_RETRY_DELAY_MS] up to the unit's rate. + */ + private var retryDelay = FIRST_RETRY_DELAY_MS + + /** + * Reschedules itself before it asks, so a request that never comes back cannot end the chain - + * which would leave the slot exactly as silent as the bug this replaces. + */ + private val retry = Runnable { + val adView = this.adView + + if (enabled && !paused && adView != null && !isActivityGone()) { + retryDelay = (retryDelay * 2).coerceAtMost(RETRY_DELAY_MS) + + scheduleRetry() + + adView.loadAd(AdRequest.Builder().build()) + } + } + /** Set by the consent flow, which is also the only thing that makes it readable. */ private var consentInformation: ConsentInformation? = null @@ -99,25 +141,16 @@ class AdManager { onPurchaseRequested = listener } - private fun showAds(adView: AdView) { + /** Swaps the slot between the two views already in it, or hides both while neither has run. */ + private fun show(view: View?) { if (!enabled) { return } - showInAdContainer( - adView, - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.MATCH_PARENT, - ), - ) - } - - private fun showInAdContainer(view: View, params: LinearLayout.LayoutParams) { - adContainer.removeAllViews() - adContainer.addView(view, params) + adView?.visibility = if (view === adView) View.VISIBLE else View.GONE + houseAd?.visibility = if (view === houseAd) View.VISIBLE else View.GONE - adContainer.visibility = View.VISIBLE + adContainer.visibility = if (view == null) View.GONE else View.VISIBLE } private fun hideGoogleAds() { @@ -259,84 +292,138 @@ class AdManager { // banner differently - a change to make on its own rather than in passing @Suppress("DEPRECATION") private fun showAdaptiveBanner() { + if (!enabled || isActivityGone()) { + return + } + // WindowManager.getDefaultDisplay() is deprecated and its replacement needs API // 30; the resources' metrics carry the same width and density. val metrics = activity.resources.displayMetrics val adWidth = (metrics.widthPixels / metrics.density).toInt() + if (adWidth <= 0) { + return + } val adSize = AdSize.getCurrentOrientationAnchoredAdaptiveBannerAdSize(activity, adWidth) - // a webview underneath, and every rotation builds another - adView?.destroy() - adView = null + // both live in the container for as long as the activity does, and take turns being shown. + // the banner staying in the hierarchy is the point: the ad unit refreshes itself, but only + // for a banner that is on screen, so one that is torn out never gets asked for again + layOutHouseAd(houseAd ?: addHouseAd().also { houseAd = it }, adSize, adWidth) if (!hasConsentDecision()) { - showHouseAd(adSize, adWidth) + showHouseAd() return } - val adView = AdView(activity) - this.adView = adView + val adView = this.adView ?: addAdView().also { this.adView = it } + + // an anchored banner keeps the size it was asked at, so a real width change needs a new + // one - but a keyboard opening, or a rotation back, does not + if (adWidth == requestedWidth) { + return + } + requestedWidth = adWidth adView.setAdSize(adSize) + + retries.removeCallbacksAndMessages(null) + retryDelay = FIRST_RETRY_DELAY_MS + + adView.loadAd(AdRequest.Builder().build()) + } + + private fun addAdView(): AdView { + val adView = AdView(activity) + adView.adUnitId = AD_UNIT_ID + adView.visibility = View.GONE adView.adListener = object : AdListener() { - // the sdk retries behind our back and reports every attempt, which would - // otherwise walk the house ad through all three texts at once - private var houseAdShown = false - - /** A rotation replaced this banner, so it is too late to say anything. */ - private fun stale() = adView !== this@AdManager.adView - override fun onAdLoaded() { - if (stale()) { - return - } - - // a retry that eventually fills still gets to replace the house ad - houseAdShown = false + retries.removeCallbacksAndMessages(null) + retryDelay = FIRST_RETRY_DELAY_MS + hasAd = true - showAds(adView) + show(adView) } override fun onAdFailedToLoad(error: LoadAdError) { - if (stale() || houseAdShown) { - return - } - houseAdShown = true - // limited ads fill far less often - the common case for a refusal crashManager.log("ad failed to load: " + error.code + "/" + error.message) - showHouseAd(adSize, adWidth) + // a refresh that did not fill leaves the ad it already has up, and the sdk + // keeps refreshing it; only an empty slot needs the house ad and our own asking + if (hasAd) { + return + } + + showHouseAd() + scheduleRetry() } } - // the container stays as it is until the listener fires, so a request that does not - // fill never shows as a gap - adView.loadAd(AdRequest.Builder().build()) + adContainer.addView( + adView, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.MATCH_PARENT, + ), + ) + + return adView } /** - * One layout for every slot the banner comes in: parts drop out as it narrows, the subline - * first and then the icon. Neither line wraps, so a translation cannot break the height. + * Asks again for a slot that did not fill. The ad unit refreshes itself every 60 seconds, but + * only while its banner is on screen - and a banner the house ad is standing in for is not, so + * without this one refusal ends the session's advertising. + * + * A failure served nothing, so no impression's refresh rate is being protected and the first + * ask can come long before a minute is up - which is what a reader who opens one document and + * leaves needs. Each further one doubles up to the rate the unit is set to, so a session that + * is never going to fill settles on what the sdk itself would have done. */ - private fun showHouseAd(adSize: AdSize, adWidth: Int) { - if (!enabled) { - return - } + private fun scheduleRetry() { + retries.removeCallbacksAndMessages(null) + retries.postDelayed(retry, retryDelay) + } + /** Puts the house ad in the container, hidden, with the text the stored rotation is on. */ + private fun addHouseAd(): View { val houseAd = activity.layoutInflater.inflate(R.layout.house_ad, adContainer, false) - val index = houseAdIndex % HOUSE_ADS.size - val variant = HOUSE_ADS[index] - houseAdIndex = (index + 1) % HOUSE_ADS.size + houseAdVariant = houseAdIndex % HOUSE_ADS.size - crashManager.log("house ad " + index + " at " + adWidth + "dp") + houseAd.setOnClickListener { + analyticsManager.report("house_ad_tapped") - analyticsManager.report("house_ad_shown") + onPurchaseRequested?.invoke() + } + + houseAd.visibility = View.GONE + adContainer.addView(houseAd) + + return houseAd + } + + /** + * Fits the house ad to the slot the banner comes in: parts drop out as it narrows, the subline + * first and then the icon. Neither line wraps, so a translation cannot break the height. Runs + * again on every width change, on the one view - the text it carries does not change under a + * reader who is already looking at it. + */ + private fun layOutHouseAd(houseAd: View, adSize: AdSize, adWidth: Int) { + val variant = HOUSE_ADS[houseAdVariant] + + houseAdWidth = adWidth + + houseAd.layoutParams = + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + adSize.getHeightInPixels(activity), + ) // the 90dp slot, which only tablets get val wide = adWidth >= WIDE_WIDTH @@ -345,6 +432,8 @@ class AdManager { if (adWidth < ICON_WIDTH) { icon.visibility = View.GONE } else { + icon.visibility = View.VISIBLE + val size = dp(if (wide) 58 else 34) icon.layoutParams.width = size icon.layoutParams.height = size @@ -358,6 +447,8 @@ class AdManager { if (adWidth < SUBLINE_WIDTH) { subline.visibility = View.GONE } else { + subline.visibility = View.VISIBLE + subline.setText(if (wide) variant.wideSubline else variant.subline) subline.setTextSize(TypedValue.COMPLEX_UNIT_SP, if (wide) 13f else 11f) } @@ -368,20 +459,30 @@ class AdManager { cta.setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f) cta.setPadding(dp(16), dp(8), dp(16), dp(8)) } + } - houseAd.setOnClickListener { - analyticsManager.report("house_ad_tapped") + /** + * Hands the slot to the house ad. The stored rotation only moves when one is actually shown, so + * a session that never fills does not walk through all three texts. + */ + private fun showHouseAd() { + if (!enabled) { + return + } - onPurchaseRequested?.invoke() + val houseAd = this.houseAd ?: return + + if (houseAd.visibility == View.VISIBLE) { + return } - showInAdContainer( - houseAd, - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - adSize.getHeightInPixels(activity), - ), - ) + crashManager.log("house ad " + houseAdVariant + " at " + houseAdWidth + "dp") + + houseAdIndex = (houseAdVariant + 1) % HOUSE_ADS.size + + analyticsManager.report("house_ad_shown") + + show(houseAd) } private fun dp(value: Int) = (value * activity.resources.displayMetrics.density).toInt() @@ -394,21 +495,57 @@ class AdManager { val cta: Int, ) + /** Stops the banner with the activity: the sdk's own refresh does not know it went away. */ + fun pauseAds() { + paused = true + + retries.removeCallbacksAndMessages(null) + + adView?.pause() + } + + fun resumeAds() { + paused = false + + adView?.resume() + + // a slot that came back still empty gets asked for again; a filled one is the sdk's + if (enabled && !hasAd && adView != null && !isActivityGone()) { + scheduleRetry() + } + } + + /** Not gated on [enabled]: billing calls it once the user has paid. */ fun removeAds() { enabled = false + destroyAds() + hideGoogleAds() } fun destroyAds() { + retries.removeCallbacksAndMessages(null) + + destroyAdView() + + adView = null + houseAd = null + hasAd = false + requestedWidth = 0 + + if (::adContainer.isInitialized) { + adContainer.removeAllViews() + } + } + + private fun destroyAdView() { try { // has thrown out of the ad sdk's own focus handling for some users adView?.destroy() } catch (e: Exception) { crashManager.log(e) } - - adView = null } private companion object { @@ -418,6 +555,11 @@ class AdManager { const val PREF_HOUSE_AD_INDEX = "house_ad_index" + // 10s, 20s, 40s, then the 60s the unit refreshes at. Not shorter: the sdk throttles a + // burst of failed requests itself. + const val FIRST_RETRY_DELAY_MS = 10_000L + const val RETRY_DELAY_MS = 60_000L + // the slot widths, in dp, at which the house ad loses a part const val WIDE_WIDTH = 700 const val SUBLINE_WIDTH = 360 diff --git a/app/src/main/java/app/opendocument/droid/ui/activity/MainActivity.kt b/app/src/main/java/app/opendocument/droid/ui/activity/MainActivity.kt index a4d262702872..2ce1312e2b9a 100644 --- a/app/src/main/java/app/opendocument/droid/ui/activity/MainActivity.kt +++ b/app/src/main/java/app/opendocument/droid/ui/activity/MainActivity.kt @@ -382,6 +382,11 @@ class MainActivity : AppCompatActivity() { } private fun initializeManagers() { + // the play services dialog can bring us back here; the first manager still owns a slot + if (::adManager.isInitialized) { + adManager.destroyAds() + } + // the ad and consent sdks are the only thing left that needs play services on the device, // and a flavor without them never asks - the dialog would offer a fix for nothing val adsAvailable = @@ -909,9 +914,21 @@ class MainActivity : AppCompatActivity() { override fun onPause() { ttsActionMode?.stop() + if (::adManager.isInitialized) { + adManager.pauseAds() + } + super.onPause() } + override fun onResume() { + super.onResume() + + if (::adManager.isInitialized) { + adManager.resumeAds() + } + } + override fun onDestroy() { // appcompat does not finish it for us, and TtsActionModeCallback only shuts its // engine down when the mode is destroyed diff --git a/app/src/noAds/java/app/opendocument/droid/nonfree/AdManager.kt b/app/src/noAds/java/app/opendocument/droid/nonfree/AdManager.kt index 03fe29ce841b..dbd6f49924f0 100644 --- a/app/src/noAds/java/app/opendocument/droid/nonfree/AdManager.kt +++ b/app/src/noAds/java/app/opendocument/droid/nonfree/AdManager.kt @@ -33,6 +33,10 @@ class AdManager { fun showPrivacyOptions() {} + fun pauseAds() {} + + fun resumeAds() {} + fun removeAds() {} fun destroyAds() {}