Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
274 changes: 208 additions & 66 deletions app/src/ads/java/app/opendocument/droid/nonfree/AdManager.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
}
Expand All @@ -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()
Expand All @@ -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 {
Expand All @@ -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
Expand Down
Loading
Loading