// ModalGate.ktpackage net.dolice.wallpapers.modalimport androidx.lifecycle.Lifecycleimport androidx.lifecycle.LifecycleOwnerimport java.util.concurrent.atomic.AtomicReferenceenum class ModalKind(val perSessionLimit: Int) { PAYWALL(1), REWARDED_INTERSTITIAL(2), REVIEW_INDUCTION(1),}sealed interface GateResult { data object Granted : GateResult data class Denied(val reason: String) : GateResult}object ModalGate { private const val COOL_DOWN_MS = 1_500L private val current = AtomicReference<ModalKind?>(null) private val shownCount = mutableMapOf<ModalKind, Int>() private var lastDismissedAtMs = 0L /** * 表示の可否を、表示する直前に問い直す。 * show は許可された場合にのみ呼ばれる。 */ @Synchronized fun request(kind: ModalKind, owner: LifecycleOwner, show: () -> Unit): GateResult { // 罠2 への対処: 非同期処理から戻ってきた時点で、画面がまだ生きているかを毎回確かめる if (!owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { return GateResult.Denied("owner is not started") } val holder = current.get() if (holder != null) { // 優先度が高くても割り込ませない(理由は後述) return GateResult.Denied("occupied by $holder") } if (System.currentTimeMillis() - lastDismissedAtMs < COOL_DOWN_MS) { // 罠3 への対処: 閉じた直後に次が出ると、閉じる操作が次のボタンに吸われる return GateResult.Denied("cool down") } val shown = shownCount[kind] ?: 0 if (shown >= kind.perSessionLimit) { return GateResult.Denied("per-session limit reached for $kind") } current.set(kind) shownCount[kind] = shown + 1 show() return GateResult.Granted } /** ダイアログが閉じたら必ず呼ぶ。呼び忘れるとゲートが開かないので、テストで担保する。 */ @Synchronized fun release(kind: ModalKind) { if (current.get() == kind) { current.set(null) lastDismissedAtMs = System.currentTimeMillis() } } /** プロセス再生成をまたいで数え続けないよう、セッション開始時に初期化する。 */ @Synchronized fun resetSession() { current.set(null) shownCount.clear() lastDismissedAtMs = 0L }}
呼び出し側は、表示ではなく申請を書く形になります。
// MainActivity.kt(移した後)rewardedAdLoader.load { ad -> if (ad == null) return@load val result = ModalGate.request(ModalKind.REWARDED_INTERSTITIAL, this) { RewardedIntersDialog(this, ad) { ModalGate.release(ModalKind.REWARDED_INTERSTITIAL) }.show() } if (result is GateResult.Denied) { // 出さなかった理由が残るので、後から配信ログと突き合わせられる Log.i(TAG, "rewarded modal skipped: ${result.reason}") }}
billing.isAdFree() の判定は、ダイアログ側に残しています。ゲートが引き受けるのは「重ならないこと」だけで、「そもそも出すべきか」はダイアログの責務のままです。この線引きを曖昧にすると、ゲートが全ダイアログの業務条件を抱えた巨大な if 文になります。