From 4051678e2e52c90a77a5e92cc4ee8bc0972e1fc5 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 6 Aug 2026 21:35:32 +0200 Subject: [PATCH 1/2] Serve a limited ad to whoever refuses consent, instead of nothing Refusing everything got no ad at all. That was a choice rather than an obligation: "do not consent" still emits a tc string carrying the special purposes, and google picks limited ads from it server-side - no cookies, no identifiers, no local storage. There is no client-side flag for that mode, so not sending the request is the only way to lose it. So canRequestAds() stops being a gate, which is where this parts ways with google's own sample. The two consent errors stop being gates for the same reason - neither is a refusal. The sdk caches the user's decision, so a form that fails to show, or an update that times out because the device was asleep, still leaves an earlier consent standing, and outside the regions where a form is required at all there is no decision to fail. One timeout used to blank the banner for the rest of the session, for users who had already said yes. The rest follows from asking for ads we now expect not to fill: - Limited ads fill at a fraction of the rate, and there was no AdListener at all - the container was made visible before the load resolved. Nothing enters it until the listener fires now, and a request that does not fill puts a house ad for Pro there rather than an empty strip. Three angles at the same offer, one per request, sized to the banner they replace; the subline drops below 360dp and the icon below 300dp, and neither line wraps, so a long translation shortens instead of breaking the height. The sdk retries behind our back and reports every attempt, hence the guard against walking through all three texts in half a second. - The consent form needs a re-entry point, both because withdrawal has to be as easy as granting and because the tcf asks for one. It goes in the overflow menu, gated on the sdk's own privacyOptionsRequirementStatus - which is only answerable once the update has come back, so the menu is invalidated then. Not gated on the ad manager being enabled: buying ad removal clears that, and someone who consented before buying must still be able to take it back. - requestConsentInfoUpdate stays at once per launch, which is what refreshes region and message version. onConfigurationChanged used to re-run the whole thing; it now only rebuilds the banner, whose size is what the orientation actually changes. Verified on device: consent declined -> an ad still serves; offline -> the consent error is logged and the house ad appears rather than silence; the privacy item shows on first launch and reopens the form; all three house ad texts cycle, and both drop-out widths render. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0141xLyuaHVh7rizEVDb8q75 --- .../opendocument/droid/nonfree/AdManager.kt | 256 ++++++++++++++++-- .../droid/ui/activity/MainActivity.kt | 15 +- .../main/res/drawable/house_ad_background.xml | 20 ++ app/src/main/res/drawable/house_ad_pill.xml | 7 + app/src/main/res/layout/house_ad.xml | 73 +++++ app/src/main/res/menu/menu_main.xml | 8 + app/src/main/res/values/colors.xml | 10 + app/src/main/res/values/strings.xml | 17 ++ 8 files changed, 380 insertions(+), 26 deletions(-) create mode 100644 app/src/main/res/drawable/house_ad_background.xml create mode 100644 app/src/main/res/drawable/house_ad_pill.xml create mode 100644 app/src/main/res/layout/house_ad.xml create mode 100644 app/src/main/res/values/colors.xml diff --git a/app/src/main/java/app/opendocument/droid/nonfree/AdManager.kt b/app/src/main/java/app/opendocument/droid/nonfree/AdManager.kt index 2bee420b1e03..16536ccd6317 100644 --- a/app/src/main/java/app/opendocument/droid/nonfree/AdManager.kt +++ b/app/src/main/java/app/opendocument/droid/nonfree/AdManager.kt @@ -1,13 +1,20 @@ package app.opendocument.droid.nonfree import android.app.Activity +import android.util.TypedValue import android.view.View +import android.widget.ImageView import android.widget.LinearLayout +import android.widget.TextView +import app.opendocument.droid.R +import com.google.android.gms.ads.AdListener import com.google.android.gms.ads.AdRequest import com.google.android.gms.ads.AdSize import com.google.android.gms.ads.AdView +import com.google.android.gms.ads.LoadAdError import com.google.android.gms.ads.MobileAds import com.google.android.gms.ads.RequestConfiguration +import com.google.android.ump.ConsentInformation import com.google.android.ump.ConsentRequestParameters import com.google.android.ump.FormError import com.google.android.ump.UserMessagingPlatform @@ -23,6 +30,14 @@ class AdManager { private lateinit var adContainer: LinearLayout private var adView: AdView? = null + /** Set once the consent flow has settled, which is also what makes its state readable. */ + private var consentInformation: ConsentInformation? = null + + private var onConsentSettled: (() -> Unit)? = null + private var onPurchaseRequested: (() -> Unit)? = null + + private var houseAdIndex = 0 + fun initialize( activity: Activity, analyticsManager: AnalyticsManager, @@ -58,6 +73,16 @@ class AdManager { this.adContainer = adContainer } + /** Called once the consent flow has settled, so the caller can re-read what it decided. */ + fun setConsentListener(listener: () -> Unit) { + onConsentSettled = listener + } + + /** Where the house ad sends whoever taps it. */ + fun setPurchaseListener(listener: () -> Unit) { + onPurchaseRequested = listener + } + private fun showAds(adView: AdView) { if (!enabled) { return @@ -65,14 +90,18 @@ class AdManager { this.adView = adView - adContainer.removeAllViews() - - val params = + showInAdContainer( + adView, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT, - ) - adContainer.addView(adView, params) + ), + ) + } + + private fun showInAdContainer(view: View, params: LinearLayout.LayoutParams) { + adContainer.removeAllViews() + adContainer.addView(view, params) adContainer.visibility = View.VISIBLE } @@ -81,6 +110,20 @@ class AdManager { activity.runOnUiThread { adContainer.visibility = View.GONE } } + /** + * Runs the consent flow, once per launch, and loads a banner whatever it decides. + * + * [ConsentInformation.canRequestAds] is deliberately not a gate, which is where this parts ways + * with google's own sample. Refusing everything still emits a tc string carrying the special + * purposes, from which google picks limited ads server-side: no cookies, no identifiers, no + * local storage. There is no client-side flag for that mode - not sending the request is the + * only way to lose it, and showing nothing is a choice rather than an obligation. + * + * The two errors are breadcrumbs for the same reason: neither is a refusal. The sdk caches the + * user's decision, so a form that fails to show, or an update that times out because the device + * is offline, still leaves an earlier consent standing, and outside the regions where a form is + * required at all there is no decision to fail in the first place. + */ fun showGoogleAds() { if (!enabled) { return @@ -95,23 +138,12 @@ class AdManager { { UserMessagingPlatform.loadAndShowConsentFormIfRequired(activity) { loadAndShowError -> - if (loadAndShowError != null || !consentInformation.canRequestAds()) { - // without this the banner just silently stays hidden, - // and the ump sdk only logs an unspecific "Error - // making request." - crashManager.log( - "consent form failed: " + - describe(loadAndShowError) + - ", canRequestAds=" + - consentInformation.canRequestAds() - ) - - hideGoogleAds() - - return@loadAndShowConsentFormIfRequired + if (loadAndShowError != null) { + // the ump sdk only logs an unspecific "Error making request." + crashManager.log("consent form failed: " + describe(loadAndShowError)) } - activity.runOnUiThread { showAdaptiveBanner() } + consentSettled(consentInformation) } }, { requestConsentError -> @@ -119,11 +151,54 @@ class AdManager { // times out against fundingchoicesmessages.google.com crashManager.log("consent info update failed: " + describe(requestConsentError)) - hideGoogleAds() + consentSettled(consentInformation) }, ) } + private fun consentSettled(consentInformation: ConsentInformation) { + this.consentInformation = consentInformation + + // the served mode is not readable from here, but this is what decides it + crashManager.log("consent settled, canRequestAds=" + consentInformation.canRequestAds()) + + activity.runOnUiThread { + onConsentSettled?.invoke() + + showAdaptiveBanner() + } + } + + /** + * Rebuilds the banner, whose size is orientation-dependent. The consent flow stays at once per + * launch - it is a network call, and nothing about a rotation can change its answer. + */ + fun refreshAds() { + if (!enabled || consentInformation == null) { + return + } + + activity.runOnUiThread { showAdaptiveBanner() } + } + + /** + * Whether the sdk wants a re-entry point into the consent form, which is how a user withdraws. + * Not gated on [enabled]: buying ad removal clears that, and someone who consented before + * buying must still be able to take it back. + */ + fun isPrivacyOptionsRequired(): Boolean = + consentInformation?.privacyOptionsRequirementStatus == + ConsentInformation.PrivacyOptionsRequirementStatus.REQUIRED + + /** Only ever from user input - the sdk preloads the form for exactly this. */ + fun showPrivacyOptions() { + UserMessagingPlatform.showPrivacyOptionsForm(activity) { formError -> + if (formError != null) { + crashManager.log("privacy options form failed: " + describe(formError)) + } + } + } + // https://developers.google.com/admob/android/banner/adaptive // the anchored adaptive size is deprecated in favour of the inline one, which sizes the // banner differently - a change to make on its own rather than in passing @@ -136,15 +211,115 @@ class AdManager { val metrics = activity.resources.displayMetrics val adWidth = (metrics.widthPixels / metrics.density).toInt() - adView.setAdSize( - AdSize.getCurrentOrientationAnchoredAdaptiveBannerAdSize(activity, adWidth) - ) + val adSize = AdSize.getCurrentOrientationAnchoredAdaptiveBannerAdSize(activity, adWidth) + adView.setAdSize(adSize) adView.adUnitId = AD_UNIT_ID + adView.adListener = + object : AdListener() { + // the sdk retries behind our back and reports every attempt, so one request + // arrives here as several failures - which would otherwise walk the house ad + // through all three of its texts in half a second + private var houseAdShown = false + + override fun onAdLoaded() { + // a retry that eventually fills still gets to replace the house ad + houseAdShown = false + + showAds(adView) + } + + override fun onAdFailedToLoad(error: LoadAdError) { + if (houseAdShown) { + return + } + houseAdShown = true + + // limited ads fill far less often than personalised ones, so an empty + // strip is the common case for whoever refused consent + crashManager.log("ad failed to load: " + error.code + "/" + error.message) + + showHouseAd(adSize, adWidth) + } + } + + // nothing goes into the container until the listener fires, so a request that does + // not fill never shows as a gap adView.loadAd(AdRequest.Builder().build()) + } + + /** + * One layout for every slot the banner comes in. Parts drop out as it narrows - the subline + * first, then the icon - and the headline has a short form for when it is all that is left. + * Neither line ever wraps, so a long translation shortens rather than breaking the height. + */ + private fun showHouseAd(adSize: AdSize, adWidth: Int) { + if (!enabled) { + return + } + + val houseAd = activity.layoutInflater.inflate(R.layout.house_ad, adContainer, false) + + val variant = HOUSE_ADS[houseAdIndex % HOUSE_ADS.size] + houseAdIndex++ + + crashManager.log("house ad " + houseAdIndex + " at " + adWidth + "dp") + + // the 90dp slot, which only tablets get + val wide = adWidth >= WIDE_WIDTH + + val icon = houseAd.findViewById(R.id.house_ad_icon) + if (adWidth < ICON_WIDTH) { + icon.visibility = View.GONE + } else { + val size = dp(if (wide) 58 else 34) + icon.layoutParams.width = size + icon.layoutParams.height = size + } + + val headline = houseAd.findViewById(R.id.house_ad_headline) + headline.setText(if (adWidth < SUBLINE_WIDTH) variant.shortHeadline else variant.headline) + headline.setTextSize(TypedValue.COMPLEX_UNIT_SP, if (wide) 17f else 13f) + + val subline = houseAd.findViewById(R.id.house_ad_subline) + if (adWidth < SUBLINE_WIDTH) { + subline.visibility = View.GONE + } else { + subline.setText(if (wide) variant.wideSubline else variant.subline) + subline.setTextSize(TypedValue.COMPLEX_UNIT_SP, if (wide) 13f else 11f) + } + + val cta = houseAd.findViewById(R.id.house_ad_cta) + cta.setText(variant.cta) + if (wide) { + cta.setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f) + cta.setPadding(dp(16), dp(8), dp(16), dp(8)) + } + + houseAd.setOnClickListener { + analyticsManager.report("house_ad") + + onPurchaseRequested?.invoke() + } - showAds(adView) + showInAdContainer( + houseAd, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + adSize.getHeightInPixels(activity), + ), + ) } + private fun dp(value: Int) = (value * activity.resources.displayMetrics.density).toInt() + + private class HouseAd( + val headline: Int, + val shortHeadline: Int, + val subline: Int, + val wideSubline: Int, + val cta: Int, + ) + fun removeAds() { enabled = false @@ -169,6 +344,37 @@ class AdManager { const val TEST_DEVICE_ID = "46C05048B04145D0724C1ADA7FC17619" + // the slot widths, in dp, at which the house ad loses a part + const val WIDE_WIDTH = 700 + const val SUBLINE_WIDTH = 360 + const val ICON_WIDTH = 300 + + // three angles at the same offer, one per banner that did not fill + val HOUSE_ADS = + listOf( + HouseAd( + R.string.house_ad_support_headline, + R.string.house_ad_support_headline_short, + R.string.house_ad_support_subline, + R.string.house_ad_support_subline_wide, + R.string.house_ad_cta_go_pro, + ), + HouseAd( + R.string.house_ad_ad_free_headline, + R.string.house_ad_ad_free_headline, + R.string.house_ad_ad_free_subline, + R.string.house_ad_ad_free_subline_wide, + R.string.house_ad_cta_get_pro, + ), + HouseAd( + R.string.house_ad_open_source_headline, + R.string.house_ad_open_source_headline_short, + R.string.house_ad_open_source_subline, + R.string.house_ad_open_source_subline_wide, + R.string.house_ad_cta_go_pro, + ), + ) + fun describe(error: FormError?): String { if (error == null) { return "no error" 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 06fdd97884cd..7f3274194552 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 @@ -327,7 +327,8 @@ class MainActivity : AppCompatActivity(), MenuProvider { override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) - adManager.showGoogleAds() + // the banner's size follows the orientation; the consent flow behind it does not + adManager.refreshAds() } fun requestSave() { @@ -377,6 +378,10 @@ class MainActivity : AppCompatActivity(), MenuProvider { adManager = AdManager() adManager.setEnabled(!IS_TESTING && useProprietaryLibraries) adManager.setAdContainer(adContainer) + // the menu is built long before the consent update comes back, so whether the + // privacy item belongs in it is only known after the fact + adManager.setConsentListener { invalidateMenu() } + adManager.setPurchaseListener { buyAdRemoval() } adManager.initialize(this, analyticsManager, crashManager) billingManager = BillingManager() @@ -406,6 +411,8 @@ class MainActivity : AppCompatActivity(), MenuProvider { if (billingManager.hasPurchased()) { menu.findItem(R.id.menu_remove_ads).isVisible = false } + + menu.findItem(R.id.menu_privacy_options).isVisible = adManager.isPrivacyOptionsRequired() } // The play services availability dialog calls startActivityForResult() itself with a @@ -522,6 +529,12 @@ class MainActivity : AppCompatActivity(), MenuProvider { buyAdRemoval() } + R.id.menu_privacy_options -> { + analyticsManager.report("menu_privacy_options") + + adManager.showPrivacyOptions() + } + R.id.menu_fullscreen -> { if (fullscreen) { analyticsManager.report("menu_fullscreen_leave") diff --git a/app/src/main/res/drawable/house_ad_background.xml b/app/src/main/res/drawable/house_ad_background.xml new file mode 100644 index 000000000000..1bcdfe092f95 --- /dev/null +++ b/app/src/main/res/drawable/house_ad_background.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/house_ad_pill.xml b/app/src/main/res/drawable/house_ad_pill.xml new file mode 100644 index 000000000000..5932ab940bac --- /dev/null +++ b/app/src/main/res/drawable/house_ad_pill.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/layout/house_ad.xml b/app/src/main/res/layout/house_ad.xml new file mode 100644 index 000000000000..218a72168347 --- /dev/null +++ b/app/src/main/res/layout/house_ad.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/menu_main.xml b/app/src/main/res/menu/menu_main.xml index 9c0df3c4e1b0..ba1a488fda0c 100644 --- a/app/src/main/res/menu/menu_main.xml +++ b/app/src/main/res/menu/menu_main.xml @@ -67,4 +67,12 @@ android:id="@+id/menu_remove_ads" android:title="@string/menu_remove_ads" app:showAsAction="never" /> + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 000000000000..92185f46a894 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + + + #6b7280 + #2f80c8 + #e3e6ea + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a87a9614efd7..659898996260 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -28,6 +28,23 @@ Share document Edit document Remove advertisements + Ad privacy settings + + + Support OpenDocument Reader + Support us + Get Pro — no ads, ever + It is open source and ad-supported. Pro drops the advertisement. + Read without ads + ODR Pro — a one-time purchase + ODR Pro is the same reader, minus the advertisement. + Open source, kept free + Open source + Pro pays for it — and drops the ads + Buying Pro funds the work and removes the advertisement. + Go Pro + Get Pro Fullscreen mode Print document Text-To-Speech From 34cdf8396040aa6fdfa0986c407ffa1bae2d1431 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 6 Aug 2026 22:12:59 +0200 Subject: [PATCH 2/2] Ask for ads only when there is an answer to send Three points from the review of the parent commit. An update that fails on a device that has never had a consent decision leaves the sdk with nothing: no tc string, so the request google would get carries nothing to pick limited ads from. That is not the case this branch argues for, so `hasConsentDecision` gates the request on the sdk holding an answer - a refusal counts, `UNKNOWN` does not - and the house ad, which is where an unfilled request ends anyway, goes up directly. Ad removal used to take the withdrawal item with it: `showGoogleAds` is what populated `consentInformation`, and a purchase means it never runs. `updateConsentInfo` is the consent update without a form or a banner behind it, which is all `isPrivacyOptionsRequired` needs, and the purchased path asks for it. Wherever ads never ran at all - the pro flavor, the instrumented suite - it is a no-op. Every rotation built an `AdView` and dropped the previous one out of the container without destroying it, webview and all. The outgoing one is destroyed now, and a callback from a banner a rotation has replaced is ignored rather than allowed to overwrite the current one. Comments throughout are shorter. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NdFTJsRNpeYhXfN6GpcL1p --- .../opendocument/droid/nonfree/AdManager.kt | 132 ++++++++++++------ .../droid/nonfree/BillingManager.kt | 4 + .../droid/ui/activity/MainActivity.kt | 3 +- .../main/res/drawable/house_ad_background.xml | 4 +- app/src/main/res/layout/house_ad.xml | 9 +- app/src/main/res/menu/menu_main.xml | 3 +- app/src/main/res/values/colors.xml | 4 +- app/src/main/res/values/strings.xml | 4 +- 8 files changed, 104 insertions(+), 59 deletions(-) diff --git a/app/src/main/java/app/opendocument/droid/nonfree/AdManager.kt b/app/src/main/java/app/opendocument/droid/nonfree/AdManager.kt index 16536ccd6317..b4ef79223a9a 100644 --- a/app/src/main/java/app/opendocument/droid/nonfree/AdManager.kt +++ b/app/src/main/java/app/opendocument/droid/nonfree/AdManager.kt @@ -24,13 +24,16 @@ class AdManager { private var enabled = false + /** Whether [initialize] ran far enough for the fields below to exist. */ + private var initialized = false + private lateinit var activity: Activity private lateinit var crashManager: CrashManager private lateinit var analyticsManager: AnalyticsManager private lateinit var adContainer: LinearLayout private var adView: AdView? = null - /** Set once the consent flow has settled, which is also what makes its state readable. */ + /** Set by the consent flow, which is also the only thing that makes it readable. */ private var consentInformation: ConsentInformation? = null private var onConsentSettled: (() -> Unit)? = null @@ -63,6 +66,8 @@ class AdManager { val configuration = RequestConfiguration.Builder().setTestDeviceIds(listOf(TEST_DEVICE_ID)).build() MobileAds.setRequestConfiguration(configuration) + + initialized = true } fun setEnabled(enabled: Boolean) { @@ -73,7 +78,7 @@ class AdManager { this.adContainer = adContainer } - /** Called once the consent flow has settled, so the caller can re-read what it decided. */ + /** Fires once the consent flow has settled, so the caller can re-read it. */ fun setConsentListener(listener: () -> Unit) { onConsentSettled = listener } @@ -88,8 +93,6 @@ class AdManager { return } - this.adView = adView - showInAdContainer( adView, LinearLayout.LayoutParams( @@ -113,22 +116,32 @@ class AdManager { /** * Runs the consent flow, once per launch, and loads a banner whatever it decides. * - * [ConsentInformation.canRequestAds] is deliberately not a gate, which is where this parts ways - * with google's own sample. Refusing everything still emits a tc string carrying the special - * purposes, from which google picks limited ads server-side: no cookies, no identifiers, no - * local storage. There is no client-side flag for that mode - not sending the request is the - * only way to lose it, and showing nothing is a choice rather than an obligation. - * - * The two errors are breadcrumbs for the same reason: neither is a refusal. The sdk caches the - * user's decision, so a form that fails to show, or an update that times out because the device - * is offline, still leaves an earlier consent standing, and outside the regions where a form is - * required at all there is no decision to fail in the first place. + * [ConsentInformation.canRequestAds] is deliberately not a gate, unlike in google's sample: a + * refusal still emits a tc string google picks limited ads from, and not asking is the only way + * to lose those. Nor are the two errors, neither of which is a refusal. [hasConsentDecision] is + * the gate. */ fun showGoogleAds() { if (!enabled) { return } + requestConsentInfo(gatherConsent = true) + } + + /** + * The update on its own, no form and no banner, which is all [isPrivacyOptionsRequired] needs. + * Ad removal leads here, since [showGoogleAds] never runs once ads are gone. + */ + fun updateConsentInfo() { + requestConsentInfo(gatherConsent = false) + } + + private fun requestConsentInfo(gatherConsent: Boolean) { + if (!initialized) { + return + } + val params = ConsentRequestParameters.Builder().setTagForUnderAgeOfConsent(false).build() val consentInformation = UserMessagingPlatform.getConsentInformation(activity) @@ -136,13 +149,17 @@ class AdManager { activity, params, { - UserMessagingPlatform.loadAndShowConsentFormIfRequired(activity) { loadAndShowError - -> - if (loadAndShowError != null) { - // the ump sdk only logs an unspecific "Error making request." - crashManager.log("consent form failed: " + describe(loadAndShowError)) + if (gatherConsent) { + UserMessagingPlatform.loadAndShowConsentFormIfRequired(activity) { + loadAndShowError -> + if (loadAndShowError != null) { + // the ump sdk only logs an unspecific "Error making request." + crashManager.log("consent form failed: " + describe(loadAndShowError)) + } + + consentSettled(consentInformation) } - + } else { consentSettled(consentInformation) } }, @@ -159,20 +176,35 @@ class AdManager { private fun consentSettled(consentInformation: ConsentInformation) { this.consentInformation = consentInformation - // the served mode is not readable from here, but this is what decides it - crashManager.log("consent settled, canRequestAds=" + consentInformation.canRequestAds()) + // the served mode is not readable from here; this is what decides it + crashManager.log( + "consent settled, status=" + + consentInformation.consentStatus + + ", canRequestAds=" + + consentInformation.canRequestAds() + ) activity.runOnUiThread { onConsentSettled?.invoke() - showAdaptiveBanner() + if (enabled) { + showAdaptiveBanner() + } } } /** - * Rebuilds the banner, whose size is orientation-dependent. The consent flow stays at once per - * launch - it is a network call, and nothing about a rotation can change its answer. + * Whether the sdk holds an answer to send. A refusal counts; an update that never came back on + * a device that has never had one does not, and would send no tc string to pick from. */ + private fun hasConsentDecision(): Boolean = + when (consentInformation?.consentStatus) { + ConsentInformation.ConsentStatus.OBTAINED, + ConsentInformation.ConsentStatus.NOT_REQUIRED -> true + else -> false + } + + /** Rebuilds the banner for a new size. A rotation cannot change what consent decided. */ fun refreshAds() { if (!enabled || consentInformation == null) { return @@ -181,16 +213,12 @@ class AdManager { activity.runOnUiThread { showAdaptiveBanner() } } - /** - * Whether the sdk wants a re-entry point into the consent form, which is how a user withdraws. - * Not gated on [enabled]: buying ad removal clears that, and someone who consented before - * buying must still be able to take it back. - */ + /** Not gated on [enabled]: buying ad removal clears that, withdrawal outlives it. */ fun isPrivacyOptionsRequired(): Boolean = consentInformation?.privacyOptionsRequirementStatus == ConsentInformation.PrivacyOptionsRequirementStatus.REQUIRED - /** Only ever from user input - the sdk preloads the form for exactly this. */ + /** Only from user input - the sdk preloads the form for exactly this. */ fun showPrivacyOptions() { UserMessagingPlatform.showPrivacyOptionsForm(activity) { formError -> if (formError != null) { @@ -204,24 +232,42 @@ class AdManager { // banner differently - a change to make on its own rather than in passing @Suppress("DEPRECATION") private fun showAdaptiveBanner() { - val adView = AdView(activity) - // 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() val adSize = AdSize.getCurrentOrientationAnchoredAdaptiveBannerAdSize(activity, adWidth) + + // a webview underneath, and every rotation builds another + adView?.destroy() + adView = null + + if (!hasConsentDecision()) { + showHouseAd(adSize, adWidth) + + return + } + + val adView = AdView(activity) + this.adView = adView + adView.setAdSize(adSize) adView.adUnitId = AD_UNIT_ID adView.adListener = object : AdListener() { - // the sdk retries behind our back and reports every attempt, so one request - // arrives here as several failures - which would otherwise walk the house ad - // through all three of its texts in half a second + // 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 @@ -229,28 +275,26 @@ class AdManager { } override fun onAdFailedToLoad(error: LoadAdError) { - if (houseAdShown) { + if (stale() || houseAdShown) { return } houseAdShown = true - // limited ads fill far less often than personalised ones, so an empty - // strip is the common case for whoever refused consent + // 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) } } - // nothing goes into the container until the listener fires, so a request that does - // not fill never shows as a gap + // 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()) } /** - * One layout for every slot the banner comes in. Parts drop out as it narrows - the subline - * first, then the icon - and the headline has a short form for when it is all that is left. - * Neither line ever wraps, so a long translation shortens rather than breaking the height. + * 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. */ private fun showHouseAd(adSize: AdSize, adWidth: Int) { if (!enabled) { diff --git a/app/src/main/java/app/opendocument/droid/nonfree/BillingManager.kt b/app/src/main/java/app/opendocument/droid/nonfree/BillingManager.kt index 0abe01eddc6c..28d637335df7 100644 --- a/app/src/main/java/app/opendocument/droid/nonfree/BillingManager.kt +++ b/app/src/main/java/app/opendocument/droid/nonfree/BillingManager.kt @@ -28,6 +28,10 @@ class BillingManager { if (hasPurchased()) { adManager.removeAds() + + // no banner to gate, but withdrawal has to survive the purchase, and only an + // update tells us whether the sdk wants a way back out + adManager.updateConsentInfo() } else { adManager.showGoogleAds() } 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 7f3274194552..87120023ff3e 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 @@ -378,8 +378,7 @@ class MainActivity : AppCompatActivity(), MenuProvider { adManager = AdManager() adManager.setEnabled(!IS_TESTING && useProprietaryLibraries) adManager.setAdContainer(adContainer) - // the menu is built long before the consent update comes back, so whether the - // privacy item belongs in it is only known after the fact + // the menu is built long before the consent update comes back adManager.setConsentListener { invalidateMenu() } adManager.setPurchaseListener { buyAdRemoval() } adManager.initialize(this, analyticsManager, crashManager) diff --git a/app/src/main/res/drawable/house_ad_background.xml b/app/src/main/res/drawable/house_ad_background.xml index 1bcdfe092f95..88e4dc5ac013 100644 --- a/app/src/main/res/drawable/house_ad_background.xml +++ b/app/src/main/res/drawable/house_ad_background.xml @@ -1,7 +1,7 @@ - + diff --git a/app/src/main/res/layout/house_ad.xml b/app/src/main/res/layout/house_ad.xml index 218a72168347..eea15e4c6857 100644 --- a/app/src/main/res/layout/house_ad.xml +++ b/app/src/main/res/layout/house_ad.xml @@ -1,8 +1,8 @@ - + - + - + - + #6b7280 #2f80c8 #e3e6ea diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 659898996260..3498939b2633 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -30,8 +30,8 @@ Remove advertisements Ad privacy settings - + Support OpenDocument Reader Support us Get Pro — no ads, ever