TL;DR
- Expo (SDK 56) の iOS アプリが Guideline 2.5.4 でリジェクト。理由は「
Info.plistのUIBackgroundModesにaudioを宣言しているのにバックグラウンドで音が鳴らない」 -
app.jsonを grep してもUIBackgroundModesは 1 件も出てこない。入れていたのはexpo-audioの config plugin で、enableBackgroundPlaybackの 既定値がtrueだった - Windows では
expo prebuildが通らないが、npx expo config --type introspect --jsonなら生成後のInfo.plist/ entitlements をビルド前に確認できる - 直したあと今度は
aps-environmentで EAS ビルドが落ちた。expo-notificationsはapp.jsonのpluginsに書かなくても config plugin が自動適用される(versionedExpoSDKPackages) - 環境: Expo SDK
56.0.0/expo 56.0.11/expo-audio 56.0.12/expo-notifications 56.0.22/@expo/prebuild-config 56.0.15/ Windows 11 / EAS Build
何が起きたか
個人開発の記録系アプリ(React Native + Expo、expo-router 構成)を App Store に初回提出したところ、却下されました。Resolution Center の指摘は次のとおりです(原文)。
The app declares support for audio in the UIBackgroundModes key in the Info.plist, but we are unable to play any audible content when the app is running in the background.
Next Steps: If the app has a feature that requires persistent audio, reply to this message and add a screen recording ... If the app does not have a feature that requires persistent audio, it would be appropriate to remove the "audio" setting from the UIBackgroundModes key.
該当ガイドラインはこちら。
2.5.4 Multitasking apps may only use background services for their intended purposes: VoIP, audio playback, location, task completion, local notifications, etc.
— App Store Review Guidelines(2026-07-25 取得)
指摘は完全に正当でした。このアプリはバックグラウンドで音を鳴らしません。鳴らすのはフォアグラウンドでレストタイマー終了時に効果音を一度だけで、コード上も明示的に切ってあります。
// レストタイマー側の実装(抜粋)
await setAudioModeAsync({ /* ... */, shouldPlayInBackground: false });
そもそも iOS はバックグラウンドで JS タイマーを止めるため、setInterval に依存しているこの実装では背面で player.play() が呼ばれること自体がありません。使っていない権限を宣言だけしていた状態です。
そして app.json には UIBackgroundModes が無い
まず疑ったのは app.json の ios.infoPlist です。ところが、
grep -rn "UIBackgroundModes" app.json app.config.* src/
# → 1件もヒットしない
ios.infoPlist キー自体を書いていませんでした。Expo の managed workflow では Info.plist はビルド時に生成されるので、「書いていないのに入っている」=生成側の誰かが入れているということになります。
犯人: expo-audio の config plugin
app.json の記述はこうでした。
{
"expo": {
"plugins": [
"expo-router",
"expo-font",
"expo-audio",
"expo-tracking-transparency"
]
}
}
"expo-audio" と文字列だけ書いています。オプションを渡していないので、plugin 側の既定値がそのまま効きます。型定義を見ると答えが書いてありました。
// node_modules/expo-audio/plugin/build/withAudio.d.ts
export type Props = {
/**
* Whether to enable background audio recording.
* @default false
*/
enableBackgroundRecording?: boolean;
/**
* Whether to enable background audio playback.
* @default true // ← これ
*/
enableBackgroundPlayback?: boolean;
};
実装側も素直です。
// node_modules/expo-audio/plugin/build/withAudio.js
const withAudio = (config, {
microphonePermission,
recordAudioAndroid = true,
enableBackgroundRecording = false,
enableBackgroundPlayback = true, // ← 既定で true
} = {}) => {
IOSConfig.Permissions.createPermissionsPlugin({
NSMicrophoneUsageDescription: MICROPHONE_USAGE,
})(config, { NSMicrophoneUsageDescription: microphonePermission });
if (enableBackgroundRecording || enableBackgroundPlayback) {
config = withInfoPlist(config, (config) => {
if (!Array.isArray(config.modResults.UIBackgroundModes)) {
config.modResults.UIBackgroundModes = [];
}
if (!config.modResults.UIBackgroundModes.includes('audio')) {
config.modResults.UIBackgroundModes.push('audio');
}
return config;
});
}
// ... Android 側の権限・フォアグラウンドサービス設定が続く
};
つまり "expo-audio" と 1 行書いただけで UIBackgroundModes: ["audio"] が付く。音を鳴らすためではなく「バックグラウンド再生をやりたい人が多いだろう」という既定値です。効果音を一度鳴らすだけの用途には過剰でした。
ついでに NSMicrophoneUsageDescription(マイク使用許可の文言)も既定で入っています。録音していないアプリにマイク権限の文言が付くのも、それはそれで審査で説明を求められうる状態です。
Windows で「生成後の Info.plist」を見る方法
「plugin が何を書き込んだか」を目視できれば一発なのですが、npx expo prebuild -p ios は Windows では通りません(iOS ネイティブプロジェクトの生成に macOS のツールチェーンが要る)。
ここで使えるのが expo config --type introspect です。config plugin の mod チェーンを実際に評価した結果を JSON で返してくれます。prebuild せず、Windows でも動きます。
npx expo config --type introspect --json
出力から見たいところだけ抜くとこうなります。
npx expo config --type introspect --json \
| node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{
const j=JSON.parse(s);
console.log('UIBackgroundModes:', JSON.stringify(j.ios?.infoPlist?.UIBackgroundModes));
console.log('entitlements :', JSON.stringify(j.ios?.entitlements));
console.log('Microphone :', JSON.stringify(j.ios?.infoPlist?.NSMicrophoneUsageDescription));
})"
修正前("expo-audio" と文字列だけ書いた状態=審査に出したビルドと同じ設定)
UIBackgroundModes: ["audio"]
entitlements : {"aps-environment":"development"}
Microphone : "Allow $(PRODUCT_NAME) to access your microphone"
Apple の指摘どおり audio が入っていることを、リジェクト後に手元で再現できました。
修正: plugin にオプションを渡す
[
"expo-audio",
{
"microphonePermission": false,
"recordAudioAndroid": false,
"enableBackgroundPlayback": false,
"enableBackgroundRecording": false
}
]
microphonePermission: false は「文言を設定しない」ではなく NSMicrophoneUsageDescription そのものを付けない指定です(string | false 型)。
修正後
UIBackgroundModes: undefined
Microphone : undefined
UIBackgroundModes ごと消えました。アプリの挙動は 1 ミリも変わりません。元から shouldPlayInBackground: false で、バックグラウンド再生などしていなかったからです。宣言だけを消したことになります。
第二の罠: 今度は aps-environment でビルドが落ちる
バックグラウンドで休憩終了に気づけないのは体験として困るので、expo-notifications のローカル通知に置き換えました。ガイドライン 2.5.4 の本文自体が local notifications を正当な用途として挙げています。
このとき「ローカル通知だけなら config plugin は不要」と判断して、app.json の plugins には expo-notifications を追加しませんでした。プッシュ通知を使わないのに aps-environment entitlement が付くのを避けたかったためです。
結果、EAS のビルドが失敗しました。
errorCode: XCODE_BUILD_ERROR
- Provisioning profile "*[expo] com.example.app AppStore ..." doesn't include
the Push Notifications capability.
- ... doesn't include the aps-environment entitlement.
plugins に書いていないのに aps-environment が付いています。理由は @expo/prebuild-config が一部の Expo SDK パッケージの plugin を自動適用するからでした。
// node_modules/@expo/prebuild-config/build/plugins/withDefaultPlugins.js
const versionedExpoSDKPackages = [
'react-native-maps', 'expo-ads-admob', 'expo-apple-authentication',
'expo-contacts', 'expo-notifications', 'expo-updates',
'expo-navigation-bar', 'expo-document-picker', 'expo-system-ui',
'expo-inline-modules'
];
package.json に依存として入っていれば、app.json の plugins に書かなくても plugin が走ります。 そして expo-notifications の plugin は無条件に書き込みます。
// node_modules/expo-notifications/plugin/build/withNotificationsIOS.js
const withNotificationsIOS = (config, { mode = 'development', ... }) => {
config = withEntitlementsPlist(config, (config) => {
if (!config.modResults['aps-environment']) {
config.modResults['aps-environment'] = mode; // ← 既定 'development'
}
return config;
});
// ...
};
先ほどの introspect 出力に entitlements: {"aps-environment":"development"} が混ざっていたのはこれです。plugins 配列だけを見て「適用される plugin の一覧」を判断すると間違えます。
対応: aps-environment を削除する config plugin を自作する
選択肢は 2 つありました。
- App ID に Push Notifications capability を追加してプロファイルを作り直す
-
aps-environmentを消す
1 は採りませんでした。 このアプリはプッシュ通知を一切使いません(getDevicePushTokenAsync / getExpoPushTokenAsync を呼ぶ箇所がゼロ)。使わない機能を宣言するのは、まさに今回リジェクトされた 2.5.4 と同じ穴です。同じ理由で二度目の却下を食らうのは避けたいところです。
config plugin は自作できるので、削除する側を書きます。
// plugins/with-no-aps-environment.js
const { withEntitlementsPlist } = require('expo/config-plugins');
module.exports = function withNoApsEnvironment(config) {
return withEntitlementsPlist(config, (cfg) => {
delete cfg.modResults['aps-environment'];
return cfg;
});
};
"plugins": [
"...",
"./plugins/with-no-aps-environment"
]
置く場所が重要です。 自動適用された expo-notifications の mod より後に走らせる必要があるので、plugins 配列の末尾に置きます(後から登録した mod が後に実行されるため)。手元では末尾に置いた場合に期待どおり消えることを introspect で確認しました。
検証:
UIBackgroundModes: undefined
entitlements : {}
ローカル通知は問題なく動きます。requestPermissionsAsync / scheduleNotificationAsync は aps-environment を必要としません。リモート登録は registerForRemoteNotifications() 経由でしか走らず、それを呼ぶ API をこのアプリは使っていないためです。
その後のビルドは成功しました。
教訓とチェックリスト
1. config plugin の既定値は「多数派にとって便利な設定」であって、あなたのアプリに正しい設定ではない
"expo-audio" のように文字列だけで書ける手軽さの裏で、Info.plist と AndroidManifest.xml に権限が足されます。plugin を足したら node_modules/<pkg>/plugin/build/*.d.ts の @default を読むのが確実です。
2. plugins 配列は「適用される plugin の全リスト」ではない
versionedExpoSDKPackages に載っているパッケージは、依存にあるだけで適用されます。
3. 提出前に生成物を機械的に確認する
macOS が無くても Windows で確認できます。
npx expo config --type introspect --json > introspect.json
見るべき最低限:
| 見る場所 | 期待 |
|---|---|
ios.infoPlist.UIBackgroundModes |
実際に使っている機能だけ。使っていないなら存在しないこと |
ios.entitlements |
使わない capability が入っていないこと |
ios.infoPlist.NS*UsageDescription |
実際に要求する権限の分だけ |
android.permissions |
同上 |
4. Apple が見ているのは「実装」ではなく「宣言」
コード上でバックグラウンド再生を切っていても、Info.plist が audio を宣言していれば審査対象になります。宣言と実装を一致させるのがガイドライン 2.5.4 の要求です。
提出前チェックを 1 コマンドにしておく
毎回目視するのが面倒なら、package.json に入れておくと楽です。
"scripts": {
"check:plist": "expo config --type introspect --json > introspect.json && node -e \"const j=require('./introspect.json');console.log('UIBackgroundModes:',JSON.stringify(j.ios?.infoPlist?.UIBackgroundModes));console.log('entitlements :',JSON.stringify(j.ios?.entitlements))\""
}
eas build の前に npm run check:plist を挟むだけで、「宣言した覚えのない宣言」がビルドに乗る前に気づけます。
同じ「書いていないのに入っている」で時間を溶かす人が減れば幸いです。