アプリを提出して、もう通ったと思った頃に Apple から1通のメールが届く。「あなたのアプリは理由が宣言されていない API を使っています」という、あの ITMS で始まる警告です。私自身、個人開発で複数のアプリを App Store と Google Play に出していますが、この通知を最初に受け取ったときは、何を直せばいいのか見当がつかず手が止まりました。
Rork が出力するのは Expo(React Native)のアプリで、内部では多くのネイティブ API とサードパーティ SDK が動いています。Apple は2024年以降、一部の API について「使う理由」をプライバシーマニフェストで宣言することを必須化しました。宣言が無いと、いまは警告メールですが、いずれリジェクトに格上げされます。Rork のプロジェクトで、この対応を自分のコード側とSDK側の両面から進めていきます。
何が求められているのか
Apple が要求しているのは大きく二つです。
アプリが収集するデータの種類と用途を宣言する「プライバシーマニフェスト」(PrivacyInfo.xcprivacy)の同梱。
「Required Reason API」と呼ばれる特定の API について、なぜ使うのかを所定のコードで宣言すること。
Required Reason API は、ファイルのタイムスタンプ、ディスク空き容量、システム起動時刻、UserDefaults といった、フィンガープリンティングに悪用されうる API 群です。正規の用途で使っているなら、決められた理由コードを宣言するだけで通ります。問題は、これらを「自分では直接呼んでいないのに、依存ライブラリが呼んでいる」ケースが大半だという点です。
この二つは別々の要求です。片方だけ整えても、もう片方の不足は残ります。後半でそれぞれを分けて書いていきます。
まず警告メールの読み方を押さえる
提出後に届くメールには、たとえば「NSPrivacyAccessedAPICategoryUserDefaults の理由が宣言されていません」といった形で、不足している API カテゴリが列挙されます。ここを読み飛ばさないことが対処の出発点です。
私が最初にやったのは、メールに並んだカテゴリ名をそのまま控え、それぞれが「自分のコード由来か、SDK由来か」を切り分ける作業でした。UserDefaults のようにアプリ本体でも使うものは自分側、広告 SDK が内部で触っているものは SDK 側、と仕分けると、直す場所が一気に明確になります。
踏むカテゴリは、機能から逆算して先に洗い出せる
警告メールが来てから調べ始めると、提出のやり直しが1往復増えます。ただ、自分のアプリが何を踏むかは、実は事前にかなりの精度で分かります。Rork が生成するコードは Expo のモジュール群に乗っているので、「この機能を使っているなら、このカテゴリを宣言することになる」という対応が決まっているからです。
私が手元の Rork プロジェクトで整理した対応が次の表です。
アプリ側でやっていること
踏む API カテゴリ
該当する理由コード
実務上の注意
設定値やフラグの保存(自分では直接触らず、Expo モジュールや SDK が内部で NSUserDefaults を使う)
UserDefaults
CA92.1
自覚がないまま踏む筆頭。ほぼ全てのアプリが該当します
ウィジェットや共有拡張と App Group で設定を共有
UserDefaults
1C8F.1
アプリ本体だけの保存とは理由コードが変わります
ダウンロード済みファイルの更新時刻を見て再取得を判断
FileTimestamp
C617.1
自アプリのコンテナ内のファイルに限る場合のコード
ユーザーが選んだ写真や書類の日時を画面に表示
FileTimestamp
DDA9.1
表示が目的なら、内部判断用とは別のコードになります
保存前に空き容量を確認して書き込み失敗を避ける
DiskSpace
E174.1
キャッシュ削除の判断に使う場合も同じコードです
ストレージ使用量を設定画面に表示
DiskSpace
85F4.1
「見せる」用途は別枠。両方やるなら両方宣言します
起動からの経過時間で計測やクールダウン判定
SystemBootTime
35F9.1
解析 SDK が裏で踏んでいることも多いカテゴリです
この表は、あくまで「その用途で使っているなら」という前提つきです。同じカテゴリでも用途が違えば理由コードは変わりますし、実態と違うコードを選ぶのは虚偽申告になります。表を写すのではなく、自分の実装がどの行に当たるかを一度確かめてから採用してください。
app.config.tsにマニフェストを書く
Expo では、ネイティブの PrivacyInfo.xcprivacy を直接編集するのではなく、app.config.ts(または app.json)の iOS 設定に宣言を書くのが本筋です。こうしておくと、Rork のプロジェクトを再ビルドするたびにマニフェストが自動で同梱され、手作業の貼り直しが要りません。
// app.config.ts
export default {
expo: {
ios: {
privacyManifests: {
NSPrivacyAccessedAPITypes: [
{
NSPrivacyAccessedAPIType: "NSPrivacyAccessedAPICategoryUserDefaults" ,
// CA92.1 = アプリ自身のためだけの UserDefaults 読み書き
NSPrivacyAccessedAPITypeReasons: [ "CA92.1" ],
},
{
NSPrivacyAccessedAPIType: "NSPrivacyAccessedAPICategoryFileTimestamp" ,
// C617.1 = 自アプリのコンテナ内ファイルのタイムスタンプ参照
NSPrivacyAccessedAPITypeReasons: [ "C617.1" ],
},
{
NSPrivacyAccessedAPIType: "NSPrivacyAccessedAPICategoryDiskSpace" ,
// E174.1 = 書き込み前の空き容量確認
NSPrivacyAccessedAPITypeReasons: [ "E174.1" ],
},
{
NSPrivacyAccessedAPIType: "NSPrivacyAccessedAPICategorySystemBootTime" ,
// 35F9.1 = アプリ内イベント間の経過時間の計測
NSPrivacyAccessedAPITypeReasons: [ "35F9.1" ],
},
],
},
},
} ,
} ;
理由コードは Apple が定義した一覧から、自分の用途に合うものを選びます。NSPrivacyAccessedAPITypeReasons が配列になっているのは、同じカテゴリを複数の用途で使う場合があるからです。たとえば空き容量を「保存前の確認」と「設定画面での表示」の両方に使っているなら、E174.1 と 85F4.1 の両方を並べます。片方しか書かずに両方の使い方をしていると、それはそれで宣言漏れになります。
収集データの申告は、Required Reason APIとは別枠です
ここまで扱ったのは、冒頭で挙げた二つの要求のうち二つ目だけです。もう一つ、NSPrivacyCollectedDataTypes による「何を集めているか」の申告が残っています。Required Reason API の警告が消えても、こちらが空のままだと申告としては不完全です。
AdMob で収益化している Rork アプリなら、少なくとも広告識別子とクラッシュ情報あたりが対象になります。
// app.config.ts — ios.privacyManifests の中身
privacyManifests : {
NSPrivacyTracking : true ,
NSPrivacyTrackingDomains : [ "googleads.g.doubleclick.net" ],
NSPrivacyCollectedDataTypes : [
{
NSPrivacyCollectedDataType: "NSPrivacyCollectedDataTypeDeviceID" ,
NSPrivacyCollectedDataTypeLinked: false ,
NSPrivacyCollectedDataTypeTracking: true ,
NSPrivacyCollectedDataTypePurposes: [
"NSPrivacyCollectedDataTypePurposeThirdPartyAdvertising" ,
],
},
{
NSPrivacyCollectedDataType: "NSPrivacyCollectedDataTypeCrashData" ,
NSPrivacyCollectedDataTypeLinked: false ,
NSPrivacyCollectedDataTypeTracking: false ,
NSPrivacyCollectedDataTypePurposes: [
"NSPrivacyCollectedDataTypePurposeAppFunctionality" ,
],
},
],
NSPrivacyAccessedAPITypes : [
// 前掲の Required Reason API 宣言をここに置きます
],
}
Linked はユーザーの識別情報と紐づくか、Tracking は他社のデータと突き合わせて追跡に使うか、という意味です。広告識別子を第三者広告のために渡しているなら Tracking は true になります。
ここで一つ、実際に挙動が変わる落とし穴があります。NSPrivacyTrackingDomains に書いたドメインは、ATT(App Tracking Transparency)の許可が下りていない状態では OS が接続を遮断します。つまり宣言は書けば安全というものではなく、書いた瞬間からネットワークの振る舞いが変わります。広告のフィルレートが下がったように見えて原因がここだった、という順序で気づくと切り分けに時間を取られます。ドメインを書くなら、ATT の同意フローが実装済みで、想定どおり出ていることを先に確認してください。
もう一点。このマニフェストは、App Store Connect の「アプリのプライバシー」の回答を置き換えるものではありません。両方が必要で、しかも食い違っていると審査で指摘されます。マニフェストに書くのは自分のコードが集める分で、SDK の分は SDK 側のマニフェストが持ちます。一方 App Store Connect の回答は、SDK が集める分も含めた全体を申告する必要があります。この非対称は分かりにくいところなので、私は app.config.ts を直したらその足で App Store Connect の回答も見直す、という順序を固定してしまっています。
サードパーティSDK側の確認を省かない
ここがいちばん見落とされる落とし穴です。自分の app.config.ts をいくら整えても、AdMob や解析 SDK が自前のプライバシーマニフェストを持っていなければ、警告は消えません。
対処の手順はこうです。
使っている SDK のバージョンを上げる。多くの主要 SDK は、対応版でマニフェストを同梱済みです。AdMob(Google Mobile Ads SDK)も、ある時期以降のバージョンで対応しています。
SDK の更新で対応しきれない場合は、その SDK が触っている API カテゴリを、自分のマニフェストに追記して理由を宣言します。
ビルド後の .app の中に各 SDK の .xcprivacy が含まれているかを確認します。
私の場合、警告が消えなかった原因は古い解析ライブラリの取り残しでした。SDK を1つ上げただけで2件の警告が同時に消えたので、まず依存の更新から当たるのが効率的だと感じています。
提出前に自分で検証する
提出してから警告を待つのは時間の無駄です。私はアーカイブを書き出したあと、ローカルで同梱状況を確かめてから提出するようにしています。ビルド成果物の中を覗いて、アプリ本体と主要 SDK のそれぞれにプライバシーマニフェストが入っているかを目視します。
# 書き出した .app の中のマニフェストを一覧する
find MyApp.app -name "*.xcprivacy" -print
# 例: MyApp.app/PrivacyInfo.xcprivacy
# MyApp.app/Frameworks/GoogleMobileAds.framework/PrivacyInfo.xcprivacy
ここで自分のアプリ本体のマニフェストが出てこなければ、app.config.ts の記述がビルドに反映されていません。SDK 側のものが無ければ、その SDK が未対応か、バージョンが古いということです。この一手間を入れてから、私は警告メールをほぼ受け取らなくなりました。
目視をやめて、exit codeを返す形にする
とはいえ目視は忘れます。リリース前は他にも見るところが多く、一覧を眺めて「入っているように見えた」で通過してしまう回が出てきます。私は途中から、次のスクリプトを挟むようにしました。
#!/usr/bin/env bash
# verify-privacy-manifests.sh — 提出前の同梱チェック
# 使い方: ./verify-privacy-manifests.sh path/to/MyApp.app
set -euo pipefail
APP_PATH = " ${1 :? . app のパスを渡してください } "
REQUIRED_SDKS = ( "GoogleMobileAds" "ExpoModulesCore" )
missing = 0
APP_MANIFEST = "${ APP_PATH }/PrivacyInfo.xcprivacy"
if [ ! -f "${ APP_MANIFEST }" ]; then
echo "NG: アプリ本体のマニフェストがありません(app.config.ts が反映されていない)"
missing = 1
else
echo "OK: アプリ本体 PrivacyInfo.xcprivacy"
# ファイルはあるが宣言が0件、という状態を弾く
if ! /usr/libexec/PlistBuddy -c "Print :NSPrivacyAccessedAPITypes:0" \
"${ APP_MANIFEST }" > /dev/null 2>&1 ; then
echo "NG: NSPrivacyAccessedAPITypes が空です(同梱されても警告は消えません)"
missing = 1
fi
fi
for sdk in "${ REQUIRED_SDKS [ @ ]}" ; do
if find "${ APP_PATH }/Frameworks" -path "*${ sdk }*" -name "*.xcprivacy" 2> /dev/null | grep -q . ; then
echo "OK: ${ sdk }"
else
echo "NG: ${ sdk } のマニフェストが見つかりません(バージョンが古い可能性)"
missing = 1
fi
done
exit "${ missing }"
なぜこの形にしているかを補足します。
set -euo pipefail を置いているのは、引数の渡し忘れや途中の失敗を「通過」として扱わせないためです。チェックスクリプトが黙って成功を返すのがいちばん危険で、そうなるくらいなら落ちてほしいという判断です。
REQUIRED_SDKS を配列にしているのは、SDK を1つ足したときの変更を1行で済ませるためです。Rork のプロジェクトは機能追加のたびに依存が増えるので、ここを書き換えやすくしておくと運用が続きます。
PlistBuddy で1件目の要素を読んでいるのは、マニフェストが同梱されていても中身が空というケースを弾くためです。app.config.ts の書き方を間違えると、ファイルだけ生成されて宣言が入らないことがあり、find の一覧では正常に見えてしまいます。実際、私が最初に取りこぼしたのはこのパターンでした。
exit "${missing}" で終了コードを返しておくと、いまは手で叩いていても、あとで EAS Build の post-build フックや CI に載せるときに書き直さずに済みます。
一度作り込めば運用は軽い
プライバシーマニフェストは、最初の1回こそ調べごとが多いものの、app.config.ts に正しく書いてしまえば以降のビルドで自動的に同梱され続けます。新しい機能を足したときに新しい Required Reason API を踏むかどうかだけ、リリース前に確認すれば十分です。
審査で止まらないことは、地味ですが収益に直結します。提出のやり直しで数日失うと、その分だけ AdMob の表示も課金のチャンスも後ろ倒しになります。次のリリースでは、書き出した .app に検証スクリプトを1回通すところから始めてみてください。落ちなければそのまま提出、落ちれば原因はスクリプトが名指ししてくれます。
審査で足止めを食う数日ほど惜しいものはありません。この記事が、その数日を取り戻す助けになれば嬉しく思います。