1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

app.json に書いていない UIBackgroundModes で App Store にリジェクトされた ― 犯人は expo-audio の config plugin だった

1
Posted at

TL;DR

  • Expo (SDK 56) の iOS アプリが Guideline 2.5.4 でリジェクト。理由は「Info.plistUIBackgroundModesaudio を宣言しているのにバックグラウンドで音が鳴らない」
  • 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-notificationsapp.jsonplugins に書かなくても 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.jsonios.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.jsonplugins には 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.jsonplugins に書かなくても 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 つありました。

  1. App ID に Push Notifications capability を追加してプロファイルを作り直す
  2. 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 / scheduleNotificationAsyncaps-environment を必要としません。リモート登録は registerForRemoteNotifications() 経由でしか走らず、それを呼ぶ API をこのアプリは使っていないためです。

その後のビルドは成功しました。

教訓とチェックリスト

1. config plugin の既定値は「多数派にとって便利な設定」であって、あなたのアプリに正しい設定ではない

"expo-audio" のように文字列だけで書ける手軽さの裏で、Info.plistAndroidManifest.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.plistaudio を宣言していれば審査対象になります。宣言と実装を一致させるのがガイドライン 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 を挟むだけで、「宣言した覚えのない宣言」がビルドに乗る前に気づけます


同じ「書いていないのに入っている」で時間を溶かす人が減れば幸いです。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?