22時を回った頃、Play Console から一通のメールが届きました。件名は「ポリシーの問題」。
本文には、どのファイルの何行目が悪いとは書かれていません。書いてあるのは条項名と、ポリシーページへのリンクだけです。
個人開発でアプリを出していると、この一通で夜の予定が消えます。私自身、最初の数回は Play Console の画面を行ったり来たりするだけで時間を使ってしまいました。
やがて分かったのは、Rork で生成した Expo アプリが Google Play で止まる場所は、思っているより限られているということです。そしてその多くが、自分では書いた覚えのない場所にあります。
通知メールは、原因の場所を教えてくれない
まず押さえておきたいのは、通知メールと Play Console の表示は「結果」であって「原因」ではないという点です。
確認すべき場所は決まっています。
Google Play Console → [アプリ名] → ポリシーとプログラム → アプリのコンテンツ
Google Play Console → [アプリ名] → ポリシーとプログラム → ポリシーのステータス
ここで大切なのは、違反条項のリンクを必ず開くことです。メール本文の要約は短すぎて、条項のどの部分に触れたのかが読み取れません。リンク先のポリシーページには、対象となる条件と例外が具体的に書かれています。
私が習慣にしているのは、リンク先のページを開いたら、自分のアプリが「該当する」と判断された条件を一文で書き出すことです。
- ❌「広告ポリシーに違反した」
- ⭕「全画面広告を、ユーザーが操作を開始していない画面遷移中に表示した」
後者まで落とし込めないうちは、まだ原因を掴めていません。この一文が書けないまま修正を始めると、直したつもりの箇所が的外れで、再申請でまた同じ条項に当たります。
app.json に書いた権限リストは、権限を減らしてくれない
Rork で生成した Expo アプリで最も多いのが、権限まわりです。そして、ここには構造的な誤解があります。
app.json の android.permissions を短く書き直せば権限が減ると考えてしまうのですが、これは正しくありません。
// app.json — これは「追加するもの」の指定であって、除去の指定ではありません
{
"expo": {
"android": {
"permissions": ["INTERNET", "VIBRATE"]
}
}
}
Expo のビルドでは、依存ライブラリがそれぞれ自前の AndroidManifest.xml や config plugin を持っていて、ビルド時にそれらがマージされます。マージで入ってくる権限は、permissions 配列を短くしても消えません。
消すための指定は別にあります。
// app.json — ライブラリが差し込む権限を明示的に取り除く
{
"expo": {
"android": {
"permissions": ["INTERNET", "VIBRATE"],
"blockedPermissions": [
"android.permission.RECORD_AUDIO",
"android.permission.ACCESS_FINE_LOCATION",
"com.google.android.gms.permission.AD_ID"
]
}
}
}
blockedPermissions は、マージ時に tools:node="remove" を付けて権限を打ち消す指定です。ポイントは、短縮名ではなく完全修飾名で書くこと。permissions 側は "RECORD_AUDIO" のような短縮名を受け付けますが、blockedPermissions は "android.permission.RECORD_AUDIO" まで書かないと一致しません。ここで静かに効かないケースをよく見ます。
誰がその権限を入れたのかを、推測せずに突き止める
権限を消す前に、まず「今どうなっているか」を確定させます。憶測で blockedPermissions に並べても、外れていれば時間が溶けるだけです。
Gradle のマニフェストマージャーは、マージの経過をレポートとして残します。
npx expo prebuild -p android
cd android && ./gradlew :app:processReleaseManifest
# どのライブラリがどの権限を注入したかが行単位で残っています
grep -n "AD_ID" app/build/outputs/logs/manifest-merger-release-report.txt
このレポートには ADDED from [com.google.android.gms:play-services-ads:...] AndroidManifest.xml:26:5-84 のような形で、注入元のライブラリと行番号が出ます。ここまで見て初めて、消すべきか残すべきかを判断できます。
出来上がった AAB 側から確認する方法もあります。
# 実際に配布される成果物に何が焼き込まれたかを見る
bundletool build-apks --bundle=app-release.aab --output=app.apks --mode=universal
unzip -o app.apks -d apks_out
aapt2 dump permissions apks_out/universal.apk
私は再申請の直前に、必ずこちらを通しています。設定ファイルを直したことと、成果物がそうなっていることは別だからです。EAS Build のキャッシュが効いていて、直したはずの blockedPermissions が反映されていなかった、ということが実際にありました。
| 確認したいこと | 見る場所 | 分かること |
| 自分が書いた権限 | android/app/src/main/AndroidManifest.xml | prebuild 後の自アプリ分のみ |
| 誰が注入したか | manifest-merger-release-report.txt | 注入元ライブラリと行番号 |
| 最終的にどうなったか | AAB を aapt2 dump permissions | 配布物に焼き込まれた実体 |
AD_ID は、宣言し忘れても、混入しても止まる
権限の中でも扱いが特殊なのが広告 ID の権限です。方向が二つあり、逆を踏むと止まります。
Android 13 以降をターゲットにするアプリが広告 ID を使う場合、com.google.android.gms.permission.AD_ID の宣言が必要です。宣言せずに取得しようとすると、値はエラーにならず、ゼロ埋めの文字列に置き換わります。 例外が飛ばないので、広告収益が落ちてから気づくことになります。
一方で、子どもを対象に含むアプリでは逆になります。Google Play の Families ポリシーは、子どもや年齢不明のユーザーについて広告 ID を含む識別子を送信しないことを求めています。この場合、AD_ID は宣言してはいけません。
| アプリの対象 | 広告 | AD_ID | あわせて必要なこと |
| 13歳以上のみ | AdMob あり | 宣言する | データセーフティで「デバイスまたはその他の ID」を申告 |
| 子どもを含む(Families) | AdMob あり | 外す | child-directed 扱いのタグ付け + GMA SDK を 20.6.0 以降に |
| 子どものみ | 広告なし | 外す | API level 33 以降を対象にし、ライブラリ由来の混入も断つ |
| 13歳以上のみ | 広告なし | 外す | 分析 SDK が持ち込んでいないかを確認 |
child-directed 扱いのタグ付けは、広告をリクエストする前に一度だけ設定します。
// ads/configure.ts
// 広告リクエストより前に呼ぶ。あとから設定しても既存のリクエストには効きません。
import mobileAds, { MaxAdContentRating } from "react-native-google-mobile-ads";
export async function configureAdsForFamilies() {
await mobileAds().setRequestConfiguration({
maxAdContentRating: MaxAdContentRating.G,
tagForChildDirectedTreatment: true, // COPPA 対象として扱う
tagForUnderAgeOfConsent: true, // 同意年齢未満として扱う
});
await mobileAds().initialize();
}
ゼロ埋めが起きていることに、収益より先に気づく
宣言漏れが厄介なのは、静かに失敗することです。広告 ID の取得は成功したように見えて、返ってくる値が 00000000-0000-0000-0000-000000000000 に置き換わります。
このとき何が起きるかというと、パーソナライズ広告の配信対象から外れます。在庫が非パーソナライズのみに絞られるため、eCPM が落ちます。私が運用している広告ありのアプリで、宣言漏れのまま出してしまった版と、直した次の版を同じ曜日構成で比べたときは、eCPM でおおよそ 30〜40% の差がありました。アプリの構成や地域比率で変わる数字ですので、そのまま当てはめるものではありませんが、桁として無視できる差ではないということです。
問題は、この落ち方がクラッシュにもエラーログにも出ないことです。気づくのは数日後、収益ダッシュボードを見たときになります。
そこで、起動時に一度だけ値の形を検査しています。
// ads/verifyAdId.ts
// ゼロ埋めは例外を投げません。形を見て、こちらから気づきに行きます。
const ZERO_AAID = "00000000-0000-0000-0000-000000000000";
export function isAdIdZeroed(advertisingId: string | null): boolean {
if (!advertisingId) return true;
return advertisingId === ZERO_AAID;
}
// 使用例(開発ビルドでのみ警告を出す)
// if (__DEV__ && isAdIdZeroed(await getAdvertisingId())) {
// console.warn("[ads] AAID がゼロ埋めです。AD_ID の宣言か、対象 API level を確認してください。");
// }
ユーザーが広告 ID をリセットした場合も同じ値が返るため、これ単体で宣言漏れと断定はできません。宣言の有無を先ほどのマージレポートで確認したうえで、こちらは「早めに気づくための網」として使っています。子ども向けで意図的に外している場合は、ゼロ埋めが正しい状態ですので、この検査自体が不要です。
マニフェストから消しても違反が続くとき
ここが一番厄介な部分です。AD_ID を blockedPermissions で外し、マージレポートでも消えていることを確認したのに、再申請でまた Families ポリシー違反になることがあります。
原因は、権限の宣言を経由せずにリフレクションで広告 ID を取りに行く SDK が混ざっているケースです。マニフェスト上には痕跡が残らないため、権限を見ている限り永遠に見つかりません。
このときは依存ツリー側から当たります。
# 広告 ID 取得系のクラスを参照している依存を洗い出す
cd android && ./gradlew :app:dependencies --configuration releaseRuntimeClasspath > deps.txt
grep -iE "ads|advertis|analytics|attribution|facebook|appsflyer" deps.txt
心当たりのある SDK を一つずつ外してビルドし、内部テストに上げて挙動を確かめる、という地味な作業になります。私はここで丸一日を使ったことがありますが、当たりを付ける順番は「広告 → 計測 → SNS ログイン」で概ね合っています。
なお、データセーフティセクションの記入そのもので詰まっている場合は、データセーフティ未入力でリジェクトされたときの直し方に SDK ごとの対応表をまとめてありますので、そちらをご覧ください。
広告の「出し方」が条項に触れているとき
権限が正しくても、表示の仕方で止まることがあります。よく見るのは次の形です。
- 画面遷移の最中に全画面広告が出る(ユーザーが操作を始めていない)
- 閉じるボタンが小さい、または広告の外にはみ出している
- 操作ボタンの真下に広告があり、誤タップを誘発する
- 広告とコンテンツの境目が視覚的に分からない
頻度の制限だけを入れて安心してしまいがちですが、条項が問題にしているのは頻度そのものより「予期しないタイミング」です。時間の間隔に加えて、遷移中は出さないという条件を足しておくと安全側に倒せます。
// ads/interstitialGate.ts
// 「一定時間空いている」だけでなく「ユーザーが操作を終えた直後である」ことも条件にします。
const AD_MIN_INTERVAL_MS = 60_000;
let lastShownAt = 0;
let isNavigating = false;
export function markNavigationStart() {
isNavigating = true;
}
export function markNavigationEnd() {
isNavigating = false;
}
export function canShowInterstitial(): boolean {
// 遷移アニメーション中は、ユーザーの操作意図と広告のタップ位置が重なります
if (isNavigating) return false;
return Date.now() - lastShownAt >= AD_MIN_INTERVAL_MS;
}
export function notifyInterstitialShown() {
lastShownAt = Date.now();
}
なぜ遷移中を弾くのかというと、遷移アニメーションの終わり際に全画面広告が重なると、ユーザーが押そうとしていた場所に広告のボタンが滑り込む形になるためです。頻度を守っていても、この一点で誤タップ誘発と判断されます。
閉じるボタンについては、48dp 以上の大きさを確保し、他の操作要素から 48dp 以上離すことを基準にしています。AdMob が自前で描画する部分は触れませんが、自分で組んだ導線側は調整できます。
12人・14日 — 新規の個人アカウントに課されるクローズドテスト
ポリシー違反とは別枠ですが、公開そのものが進まない要因としてこれが増えています。
2023年11月13日以降に作成された個人デベロッパーアカウントは、製品版へのアクセスを申請する前に、クローズドテストを行う必要があります。条件は、12人以上のテスターが、継続して14日以上オプトインしている状態です。
この要件は当初20人でしたが、2024年12月11日に12人へ引き下げられました。古い記事が20人のまま残っていることが多いので、数字を見たら日付を確認してください。法人として登記された組織アカウントは対象外です。
運用してみて分かったのは、詰まる場所が「人数」ではなく「継続」だという点です。
- テスターがリンクを開いただけではオプトインになりません。Play ストアからインストールまで到達している必要があります
- 途中で一人でも抜けると、そこで日数のカウントが揺らぎます
- 14日は「テストを開始してから」ではなく「12人が揃った状態が続いてから」で数えるのが安全です
私の場合は、余裕を持って15〜16人に声をかけ、開始から3日目と10日目に一度ずつ、インストールが残っているかを確認する形に落ち着きました。ここを甘く見て14日目に人数が足りず、最初から数え直しになったことがあります。
| 状況 | 対象 | 必要なこと |
| 2023-11-13 以降に作成した個人アカウント | 対象 | 12人以上 × 14日連続のオプトイン |
| それ以前に作成した個人アカウント | 対象外 | 通常の申請フロー |
| 法人登記済みの組織アカウント | 対象外 | 通常の申請フロー |
テスト期間は待ち時間ではなく、Pre-launch Report が実機で自動テストした結果を読める時間でもあります。権限のエラーやクラッシュはここで拾えるので、14日を検証に充てると、製品版申請での差し戻しが減ります。
異議申し立ては、条項の言葉で書く
修正しても解除されない場合、あるいは明らかに誤検知の場合は、異議申し立てを出します。
書き方には型があります。感情を込めず、条項の言葉に沿って、事実だけを短く並べます。
- どの条項で、いつ、どのバージョンが止まったか(バージョンコードまで書きます)
- その条項に対して、何をどう変えたか(設定名・ファイル名・値を具体的に)
- 変わったことをどう確認したか(マージレポートの該当行、
aapt2 dump の出力など)
- 誤検知だと考える場合は、その根拠(条項の適用条件と自アプリの状態の差分)
「対応しました」「問題ないはずです」といった書き方は、審査側に判断材料を渡していないため、同じ結果が返ってきます。3番目の「どう確認したか」を入れられるかどうかで、返信の質が変わります。 出力の抜粋を数行貼るだけで十分です。
再申請の前に、私が必ず通している三つ
長く運用していると、チェックリストは短くなっていきます。今残っているのは三つだけです。
- AAB から
aapt2 dump permissions を通し、想定外の権限がゼロであることを目で見る(設定を直したことと、成果物がそうなっていることは別です)
- AD_ID の方向が、対象年齢と一致していることを確認する(宣言するのか外すのか、逆を踏んでいないか)
- 内部テストトラックに上げてから製品版に回す(数分で終わり、ポリシー側の事前チェックが効きます)
次に手を動かすなら、まず一つ目です。手元の最新の AAB に対して aapt2 dump permissions を通してみてください。書いた覚えのない権限が並んでいたら、それが次のリジェクトの候補です。
私自身、いまだにマージレポートを読み違えることがあります。完璧な手順を持っているわけではありませんが、原因の場所を先に確定させてから直す、という順番だけは崩さないようにしています。お読みいただきありがとうございました。