個人開発でApp Storeリリースフローを学ぶ(続編)― 3回のリジェクトと審査通過までにやったこと
前回の記事で、iOS(UIKit)+ FastAPI + Kubernetesで作ったMinecraftサーバー監視アプリ「MineWatch」の全体設計を紹介しました。あの記事の最後にこう書いていました。
審査が通ったら、審査で指摘された点や実際の提出フローの詳細も別記事にまとめる予定です。
その後、Guideline 5.1.2(i)(トラッキング)で2回、4.1(c)(Copycat)で1回リジェクトされ、都合3回の提出を経てようやく審査を通過しました。この記事は「アプリの機能紹介」の続きではなく、実際に審査で何を指摘され、どう直したかの記録です。
TL;DR
- 独自のカスタム同意画面を作って「トラッキングしません」と明記していたが、Guideline 5.1.2(i)で2回連続リジェクトされた。原因はSDK側(Firebase Analyticsの依存)にIDFA関連コードが混入していたこと。
- 最終的にApple公式の AppTrackingTransparency(ATT)システムダイアログへ一本化することで解決した。実際にはトラッキングしていないのに許可ダイアログを出す、という一見矛盾した実装だが、審査対応としてはこれが最短だった。
- ATT実装では
willConnectToではなくsceneDidBecomeActiveで呼ばないとダイアログが出ないという、実機でしか踏めない罠があった。 - App Checkの認証エラー(401)とトラッキング検証エラー(403)を混同していたバグを、実機テスト中に発見して修正した。
- Google OAuthの同意画面側でも、Appleとは別に「安全なフローの使用」=App Checkの実装・強制を要求してくることが分かり、両者は無関係ではなかった。
- App Store ConnectのApp Privacy申告(トラッキングに使用されるデータ)でも、バイナリの実装内容との整合性を問われて申告をやり直す一幕があった。
1回目・2回目のリジェクト:カスタム同意画面とIDFAの罠
MineWatchは匿名の利用状況(Firebase Analytics)とクラッシュレポート(Firebase Crashlytics)の送信可否をユーザーに選ばせるため、独自のカスタム同意画面を実装していました。文言には「トラッキング」「追跡」「広告」「パーソナライズ」といった単語を避け、収集する情報と用途(品質改善のみ)を具体的に明記していたつもりでした。
それでも Guideline 5.1.2(i) で2回連続リジェクトされました。文言を見直しても直らない。ここで方針を変えて、実装そのもの(独自の同意画面と、使用しているSDKの構成)を疑うことにしました。
原因は2つありました。
- 使っていた
FirebaseAnalyticsパッケージが、実際にはIDFAを一切呼び出していなくてもGoogleAppMeasurementIdentitySupport(IDFA関連コード)をバイナリに含んでいた。Appleの静的解析は「トラッキング機能を持つのにATT未実装」と判定しうる。 - 独自の同意画面は、Appleの目線では「ATTの代替にはならない」もの。
対応として、IDFA非対応の FirebaseAnalyticsCore product に切り替え、クリーンビルドで GoogleAppMeasurementIdentitySupport.framework が含まれないことを確認しました。ここまでで2回目の提出です。
サブタイトルでもリジェクトされた(4.1(c) Copycat)
これとは別に、サブタイトルの Minecraftサーバーの死活監視 が Guideline 4.1(c) でリジェクトされました。「サブタイトルへのブランド名(Minecraft)使用がCopycatと判定される」という理由です。本文の説明文中で「Minecraftサーバーに対応」と書くのは互換性の説明として許容される一方、サブタイトル欄という検索・一覧表示に直結する場所でのブランド名使用はNGという線引きでした。ゲームサーバーの死活監視 に変更して解消しています。
3回目提出前の判断:ATTダイアログへの一本化
2回連続のリジェクトを受けて、独自の同意画面を完全に廃止し、AppleのATTシステムダイアログのみに一本化することにしました。実装としては次の3点です。
-
ConsentViewController(カスタム同意画面)を削除 -
TrackingPermission(新規)がATTrackingManagerを薄くラップし、起動直後に呼ぶ。他の説明画面は挟まない(二重のプロンプトと取られるのを避けるため) - クラッシュレポートの送信可否だけは、ATTとは無関係な設定画面のスイッチとして残す(Appleの定義する「トラッキング」に該当しないため)
ここで実装上、記録として残しておきたい矛盾があります。MineWatchは実際にはIDFAを使わず、Appleの定義する「トラッキング」を行っていません。 ATTダイアログを出すこと自体が「トラッキングしないのに許可を求める」という不自然さを生みますが、2回連続リジェクトを受けての実務的な判断として、Appleの指摘(Next Steps)に文字どおり従うことを優先しました。この判断が、後述のApp Privacy申告のやり直しにもつながります。
ハマりどころ:willConnectTo ではダイアログが出ない
ATTの許可ダイアログを scene(_:willConnectTo:options:) で呼び出すコードを書いて、シミュレータで動作確認 → 一見問題なさそうに見えたのですが、実機(iPhone 15 Plus)で試すとダイアログが一切出ないという現象に遭遇しました。
原因は、willConnectTo の時点ではまだシーンが .active 状態に遷移していないことでした。ATTrackingManager.requestTrackingAuthorization() は非アクティブなシーンから呼んでも例外を出さずに黙って .notDetermined を返すだけなので、症状に気づきにくいバグです。
// NG: シーンがまだ .active になっていないため、
// ダイアログが呼ばれるが表示されない
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {
Task { await TrackingPermission.requestIfNeeded() }
}
// OK
func sceneDidBecomeActive(_ scene: UIScene) {
Task { await TrackingPermission.requestIfNeeded() }
}
さらに厄介だったのは、CLIからのアプリ起動(xcrun simctl launch や xcrun devicectl device process launch)では、コードを直しても再現しないことです。CLI起動は本物の「アイコンタップによる起動」と同じフォアグラウンド遷移を作らないため、sceneDidBecomeActive に移しても検証できません。実際にホーム画面のアイコンをタップして起動しないと、この不具合の再現も修正確認もできませんでした。
App Checkの401と403を混同していたバグ
実機でのATTダイアログ検証中に、別の不具合を見つけました。App Attest(本物のアプリからのリクエストかを検証する仕組み)が失敗しているのに、アプリ側は「ログインし直してください」という文言を出していたのです。
バックエンド(FastAPI)は当時、Firebase IDトークンの検証失敗(=「誰か」が分からない)とApp Checkの検証失敗(=「本物のアプリか」が分からない)を、どちらも401で返していました。iOS側はこの2つを区別できず、App Check由来の失敗にも「再ログインしてください」と案内していたため、ユーザーが再ログインしても直らないという状態になっていました。
対応として、App Check失敗のレスポンスを403に分離しました。
# 401: 「誰か」が分からない(Firebase IDトークンの検証失敗)
# → 再ログインで直る可能性がある
# 403: 「本物のアプリか」が分からない(App Checkの検証失敗)
# → 再ログインしても直らない。別の案内が必要
iOS側もこれに合わせて RepositoryError に .appCheckFailed ケースを追加し、401とは異なる文言を出すようにしました。ステータスコードの意味を厳密に分けるのは地味な作業ですが、クライアント側の「間違った案内」を防ぐには効果が大きかった修正です。
Google OAuthの同意画面でも「安全なフロー」を要求された
ATT対応と並行して、Googleサインインが エラー 400: invalid_request / We cannot verify the authenticity of this app で失敗する現象にも遭遇しました。最初は「OAuth同意画面のブランディング未検証(アプリ名の不一致、ホームページでの目的説明不足)」が原因だと考え、ホームページに「MineWatchとは」セクションを追加し、Search Console verificationも通して解消したつもりでした。
しかしブランディングが検証済みになった後も、同じエラーが解消しません。Google Cloud Consoleの「プロジェクト診断」を確認したところ、「安全なフローの使用」という項目に警告が出ていました。
Googleの公式ドキュメントを確認すると、iOSアプリがこの項目で「安全」と判定される条件として 「App Checkの実装と強制(enforce)」 が明記されていました。つまり、AppleのATT対応とは別ルートで、App Checkが正常に機能しているかどうかがGoogleサインインの可否にも影響していたわけです。
このセッション中、実機での頻繁な再インストール検証によってApp Attestのアテステーション試行回数がAppleの上限に達し、App Checkのトークン取得自体が失敗し続ける状態になっていました(App attestation failed. Too many attempts.)。Debugビルドは実機でもシミュレータと同様にApp Check Debug Providerを使うよう切り替えることで、この問題を回避しています(Releaseビルドは引き続き本物のApp Attestを使用し、本番のセキュリティ水準は変えていません)。
App CheckはFirebaseの機能という認識でいましたが、Google側の外部サービス(この場合はOAuth同意画面の審査ロジック)からも参照される、想像より横断的な仕組みだと分かったのは収穫でした。
App Store ConnectのApp Privacy申告でも一悶着
3回目の提出で最後に引っかかったのが、App Store ConnectのApp Privacy(栄養ラベル)申告です。ATT用の NSUserTrackingUsageDescription がバイナリに含まれている状態で「トラッキングしない」と申告しようとしたところ、ASCから次のように止められました。
アプリにはNSUserTrackingUsageDescriptionが含まれており、これはユーザをトラッキングする許可を要求する場合があることを示します。審査に提出するには、アプリのプライバシーに関する回答を更新し、このアプリから収集されたデータがトラッキング目的で使用されることを示すか、アプリバイナリを更新して新しいビルドをアップロードします。
先述の「実際にはトラッキングしていないのにATTダイアログは出す」という矛盾が、ここで現実のブロッカーとして表面化した形です。ATTの実装を撤回すればまた5.1.2(i)のリジェクトに逆戻りする可能性が高いため、最終的には 利用状況データ(Firebase Analytics)を「Data Used to Track You」として申告し直す選択をしました。IDFA非対応のSDK構成でも、Google Analytics系のSDKはAppleのトラッキング定義に該当すると判定されるケースが多いという実例に基づく判断です。
あわせて、申告作業中にクラッシュデータの方にも誤って「トラッキング目的に使用」のチェックが入っていたことに気づき、外しました。クラッシュレポートはATTの許可状態と無関係な独立スイッチのため、Review Notesの説明(トラッキングに該当しないためATTの対象外)と整合させる必要がありました。
審査通過、そして得られた教訓
3回目の提出で無事審査を通過し、MineWatchはApp Storeに公開されました。
振り返って一番の学びは、Apple・Google・自社バックエンドという3つの審査/検証系が、思ったより密に絡み合っているということです。
- ATTダイアログの実装場所を1行間違えただけで、実機でしか再現しない不具合になる
- App Checkという1つの仕組みが、Firebase Auth・自社API・Google OAuthの3か所から別々に参照され、どれか1つが壊れると別のところで別のエラーとして表面化する
- 「実際の挙動」と「申告内容」を一致させる作業は、コードを直すだけでは終わらない
「アプリを作る」より「アプリを審査に通し続ける」方が難しい、という前回の記事の結びは、3回リジェクトされてみてより実感を伴うものになりました。
未経験から学べます!一緒に挑戦していきましょう![]()
noteもやってます↓