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() {}