広告除去を購入してくださった方から「まだ広告が出ます」という連絡をいただく朝ほど、気の重いものはありません。
クラッシュなら「直しました」で済みます。けれど購入いただいた対価がその場で返っていないという不具合は、返金対応で数字が戻っても、その方の中に残った印象までは戻せません。個人開発で壁紙アプリを長く運用してきて、私がいちばん神経を使っているのはこの一点です。
そして原因を追いかけると、たいてい「フラグが足りなかった」のではありませんでした。フラグは足りていて、それを見に行かない場所が一つ残っていた 、というのが実際のところです。
広告を消す入口は、性質の違うものが複数ある
私のアプリでは、広告が消える経路が最初から一本ではありませんでした。
経路 状態の持ち主 有効期間 失われ方
広告除去の購入 BillingManager (Google Play Billing)恒久 返金・アカウント変更
リワード広告の視聴 AdFreeManager (ローカル保存)一定時間 時間経過
購入の復元 BillingManager(起動時の照会) 恒久 照会が返るまで未確定
この三つは、寿命も、真偽が確定するタイミングも違います。購入は起動直後にはまだ分かりません。リワードは時計の話です。復元は非同期で後から真になります。
にもかかわらず、画面側のコードはどれも同じ顔をした条件式になります。
// 初期の実装。画面ごとに少しずつ違う条件式が生えていた
if ( ! billingManager.isAdFree) {
interstitialAd. show ( this )
}
リワードを足したときに、この行は次のように育ちます。
if ( ! billingManager.isAdFree && ! adFreeManager.isRewardAdFree) {
interstitialAd. show ( this )
}
条件が一つ増えるだけなので、その場では正しい修正に見えます。問題は、この形の行がアプリ内に何箇所あるかを誰も把握していないことです。壁紙一覧、詳細、カテゴリ、設定からの戻り、そして戻るボタンの終了フロー。追加したときに一つ書き漏らせば、そこだけが古い判定のまま生き残ります。
探すべきものは、フラグ名ではなく広告 SDK の呼び出し口
棚卸しを始めたとき、私は最初 isAdFree を grep しました。これは半分しか当たりません。書き漏らした箇所には、そもそも isAdFree が書かれていない からです。探すべきは条件式ではなく、広告を出す側の呼び出しでした。
Claude Code にはこの棚卸しをそのまま任せられます。私が使っているのは、探索対象を広告 SDK 側に固定した指示です。
claude -p '広告の表示につながる呼び出しを全て列挙してください。
探索の起点は条件式ではなく、以下の呼び出し側とします。
- InterstitialAd.show / RewardedAd.show / RewardedInterstitialAd.show
- AdView.loadAd / AdLoader.loadAd
- AdView をレイアウトXMLに直接置いている箇所
各ヒットについて次の3列で表にしてください。
1) ファイルと行番号
2) その呼び出しの手前にある広告非表示の判定(無ければ「なし」と明記)
3) 判定が参照している状態(billing / reward / 両方 / なし)
推測は書かず、コード上に存在するものだけを挙げてください。'
ここで効くのは3列目です。「判定あり」で終わらせると、片方の状態しか見ていない箇所 が合格してしまいます。私の場合、抜けていたのは条件式が無い箇所ではなく、購入だけを見てリワードを見ていない箇所と、レイアウト XML に直接置かれたバナーでした。XML のバナーはコードを grep しても永遠に出てきません。
判定が「なし」と「片方だけ」の二種類に分かれて出てくると、直す順番も決まります。私は次の順で片づけました。
判定が「なし」の行を塞ぐ。購入いただいた方に広告が出ている箇所なので、ここが最優先です
判定が「片方だけ」の行を直す。リワードを視聴した方に広告が出ている箇所で、優先度は次点になります
レイアウト XML のバナーを目視で拾う。コードの grep には載らないため、最後に人の目で確認します
なお私のアプリは AdMob を使っており、バナーとインタースティシャル、リワードが同じ画面に混在します。AdMob のように読み込みと表示が分かれている SDK では、loadAd の側も対象に含めないと、広告は出ないのにリクエストだけ飛び続ける状態が残ります。本番運用ではこれが通信量として静かに積み上がるので、表示側と読み込み側の両方を同じ判定に載せることを推奨します。
フラグを一つにするのではなく、問いを一つにする
ここが、私が当初考えていたことと逆になった部分です。
最初に思いついた解決策は「状態を一つにまとめる」ことでした。isAdFree という単一の真偽値をどこかに持ち、購入もリワードもそこへ書き込む形です。実装は簡単ですが、これは破綻します。リワードには期限があり、購入には無い ので、単一の真偽値は「いつ再評価するか」という問題を必ず抱え込みます。画面遷移のたびに再計算するのか、タイマーで監視するのか。書き込み側が増えるほど、誰が最後に false を書いたのかが追えなくなります。
実際に落ち着いたのは、状態を一つにするのではなく、問い方を一つにする 形でした。状態は元の持ち主に置いたまま、聞く窓口だけを一箇所にします。
/**
* 広告を出してよいかどうかを決める唯一の窓口。
* 状態は BillingManager / AdFreeManager が持ったままにし、
* ここでは「今この瞬間の答え」だけを合成する。
*/
class AdGate (
private val billing: BillingManager ,
private val rewardAdFree: AdFreeManager ,
) {
/** 購入・復元・リワードのいずれかで広告非表示なら true */
fun isAdFree (): Boolean =
billing. isPurchasedAdFree () || rewardAdFree. isActive ()
/**
* 広告を出してよいか。呼び出し側はこれ以外の判定を書かない。
* reason を返すのは、出さなかった理由をログで追えるようにするため。
*/
fun evaluate (): AdDecision = when {
billing. isPurchasedAdFree () -> AdDecision. Suppress (Reason.PURCHASED)
rewardAdFree. isActive () -> AdDecision. Suppress (Reason.REWARD)
! billing. isRestoreSettled () -> AdDecision. Suppress (Reason.RESTORE_PENDING)
else -> AdDecision.Allow
}
}
sealed interface AdDecision {
data object Allow : AdDecision
data class Suppress ( val reason: Reason ) : AdDecision
}
enum class Reason { PURCHASED, REWARD, RESTORE_PENDING }
呼び出し側はこうなります。
when (adGate. evaluate ()) {
is AdDecision.Allow -> interstitialAd. show ( this )
is AdDecision.Suppress -> Unit // 何もしない
}
RESTORE_PENDING を明示的に「出さない」側へ入れているのが、実運用で効いている部分です。Google Play Billing の照会は起動直後には返っていません。ここを「未確定だからとりあえず出す」にすると、購入者がアプリを起動した最初の数秒だけ広告が出る という、再現しづらく、しかも本人には確実に見えている不具合になります。未確定のときは出さない。広告を1回出し損ねるのと、購入者に広告を見せるのとでは、失うものの大きさが違います。
この「迷ったら出さない」という置き方は、以前に整理したAndroid の戻るボタン広告ゲートを入れ子から並列独立構造へ でも同じ結論に着地しました。広告まわりの分岐は、優先度を暗黙にした瞬間に読めなくなります。
一時的な広告非表示の期限は、端末の時計に預けない
本番運用で最初に踏んだ落とし穴は、この一時的な広告非表示の期限を端末の時計だけで持っていたことでした。素直に書くとこうなります。
// 素直だが、端末の時計を戻されると無効化されない
fun grant (durationMillis: Long ) {
prefs. edit (). putLong (KEY_EXPIRES_AT, System. currentTimeMillis () + durationMillis). apply ()
}
fun isActive (): Boolean =
prefs. getLong (KEY_EXPIRES_AT, 0L ) > System. currentTimeMillis ()
System.currentTimeMillis() は設定から自由に変更できます。時計を過去に戻せば、保存した期限は相対的にいくらでも先へ伸びます。逆に SystemClock.elapsedRealtime() は端末が起動してからの経過時間なので改変できませんが、再起動でゼロに戻る ため、単独では期限を跨げません。
どちらも単独では成立しません。時計の巻き戻しを回避しつつ再起動も跨ぐために、両方を保存して短い方を採る形にしました。
class AdFreeManager ( private val prefs: SharedPreferences ) {
fun grant (durationMillis: Long ) {
prefs. edit ()
. putLong (KEY_WALL_EXPIRES_AT, System. currentTimeMillis () + durationMillis)
. putLong (KEY_BOOT_EXPIRES_AT, SystemClock. elapsedRealtime () + durationMillis)
. putLong (KEY_GRANTED_BOOT_ID, bootId ())
. apply ()
}
fun isActive (): Boolean {
val wallLeft = prefs. getLong (KEY_WALL_EXPIRES_AT, 0L ) - System. currentTimeMillis ()
// 付与時と同じ起動セッションのときだけ elapsedRealtime を信用する
val sameBoot = prefs. getLong (KEY_GRANTED_BOOT_ID, - 1L ) == bootId ()
val bootLeft = if (sameBoot) {
prefs. getLong (KEY_BOOT_EXPIRES_AT, 0L ) - SystemClock. elapsedRealtime ()
} else {
Long.MAX_VALUE // 再起動後は壁時計側だけで判断する
}
return minOf (wallLeft, bootLeft) > 0L
}
/** 起動セッションの識別子(現在時刻 - 起動からの経過時間 ≒ 起動時刻) */
private fun bootId (): Long =
(System. currentTimeMillis () - SystemClock. elapsedRealtime ()) / 1000L
private companion object {
const val KEY_WALL_EXPIRES_AT = "ad_free_wall_expires_at"
const val KEY_BOOT_EXPIRES_AT = "ad_free_boot_expires_at"
const val KEY_GRANTED_BOOT_ID = "ad_free_granted_boot_id"
}
}
同一起動セッション内では二つの時計が互いを検算し、再起動後は壁時計だけで判断します。時計を戻す操作は起動時刻の推定値も同時にずらすので、bootId() が変わって elapsedRealtime 側の判定が外れます。完全な防御ではありませんが、「設定アプリで時計を1日戻す」程度の操作で恒久 ad-free になってしまう状態 は、これで塞げます。
バックエンドを持っているなら、期限はサーバー時刻で持つことを推奨します。私の壁紙アプリはバックエンドを持たない構成なので、端末内で成立する範囲の落としどころとしてこの形にしています。ここは自分の構成に合わせて判断してください。
純粋関数にしておくと、テストが「書ける」に変わる
AdGate を作ったことの副産物として大きかったのは、判定がテストできる形になったことです。Activity の中に条件式として散っている限り、この分岐にテストを書くのは現実的ではありませんでした。
class AdGateTest {
private class FakeBilling (
private val purchased: Boolean ,
private val settled: Boolean = true ,
) : BillingManager {
override fun isPurchasedAdFree () = purchased
override fun isRestoreSettled () = settled
}
private class FakeReward ( private val active: Boolean ) : AdFreeManager {
override fun isActive () = active
}
@Test
fun `復元が未確定のあいだは広告を出さない` () {
val gate = AdGate ( FakeBilling (purchased = false , settled = false ), FakeReward ( false ))
assertEquals (AdDecision. Suppress (Reason.RESTORE_PENDING), gate. evaluate ())
}
@Test
fun `購入済みならリワードの状態によらず出さない` () {
val gate = AdGate ( FakeBilling (purchased = true ), FakeReward ( false ))
assertEquals (AdDecision. Suppress (Reason.PURCHASED), gate. evaluate ())
}
@Test
fun `どの経路にも該当しなければ出す` () {
val gate = AdGate ( FakeBilling (purchased = false ), FakeReward ( false ))
assertEquals (AdDecision.Allow, gate. evaluate ())
}
}
テストが書けること自体より、「この分岐に何通りの入力があるか」を数え上げられるようになったこと の効果が大きいと感じています。購入・リワード・復元の3軸なら組み合わせは8通りで、そのうち広告を出してよいのは1通りだけです。条件式が散っていた頃は、この「1通りだけ」という感覚が持てませんでした。
消し忘れを構造で防ぐ
一度きれいにしても、次の画面を追加するときに同じことが起こります。ここは人間の注意力ではなく、CI 側で止めるようにしました。
#!/usr/bin/env bash
# tools/check-ad-gate.sh
# AdGate を経由しない広告表示の呼び出しを検出する
set -euo pipefail
VIOLATIONS = $( grep -rnE '\.(show|loadAd)\(' app/src/main/java \
--include= '*.kt' \
| grep -vE 'AdGate\.kt' \
| while IFS = read -r hit ; do
FILE = "${ hit %%:* }"
# 同じファイル内で AdGate を経由しているか
grep -q 'adGate' " $FILE " || echo " $hit "
done )
if [ -n " $VIOLATIONS " ]; then
echo "AdGate を経由していない広告呼び出しがあります:"
echo " $VIOLATIONS "
exit 1
fi
echo "ad gate check: OK"
ファイル単位の粗いチェックですが、新しい画面で広告を出そうとした人が必ず一度 AdGate の存在に気づく という目的には十分に働いています。厳密さより、気づく機会を作ることを優先しました。
レイアウト XML に直接置かれたバナーは、この grep では拾えません。こちらは XML 側に専用のカスタムビューを1つ用意し、AdView を直接置かない規約にすることで、同じチェックの土俵に載せています。
ダイアログや広告のように「同時に出てはいけないもの」を扱う設計は、Android でペイウォールとレビュー誘導が重なって表示される競合を ModalGate で解決した実装メモ でも同じ形に行き着きました。表示可否を各所で判断させず、一箇所に集約して問い合わせる。結論はいつもここに戻ってきます。
Claude Code に任せるところと、任せないところ
この作業で Claude Code に任せたのは、棚卸しと、機械的な置き換えの下書きまでです。
任せて良かったのは、呼び出し箇所の網羅的な列挙でした。人が grep すると「あるはずのもの」を探してしまい、書かれていないものを見落とします。呼び出し側を起点に列挙させると、判定が付いていない箇所が自然に浮かび上がります。
一方で、RESTORE_PENDING を「出さない」側に置くかどうかの判断は、渡した情報だけからは決まりませんでした。ここは、購入者に広告を見せることの損失をどう見積もるかという、事業側の判断です。エージェントは選択肢を並べるところまでは速く、どれを選ぶかは自分で決める 。この線引きが、私の中では作業前より少しはっきりしました。
次の一歩
もし同じ構造のアプリを運用しているなら、まず1つだけ試してみてください。広告 SDK の show と loadAd の呼び出しを全て列挙し、その手前にある判定が 購入とリワードの両方を見ているか を1列で書き出すことです。
判定が片方しか見ていない行が1つでも出てきたら、そこが今この瞬間、誰かに広告を見せている場所です。私の場合はそれが着手の理由になりました。
私自身まだ手探りの部分が多い領域ですが、同じところで足を止めている方の役に立てば嬉しく思います。お読みいただきありがとうございました。