Declaring Them
iOS purpose strings and Android manifest entries are written in your app config and baked into the native build, not into JavaScript.
"ios": { "infoPlist": { "NSCameraUsageDescription": "why" } }, "android": { "permissions": ["android.permission.CAMERA"] }Requesting a permission at runtime is only half of it. The native app must declare up front which permissions it may ever ask for, and on iOS it must say why in plain language. iOS reads a purpose string from Info.plist, such as NSCameraUsageDescription or NSLocationWhenInUseUsageDescription, and prints it inside its own dialog. Android reads <uses-permission> entries from AndroidManifest.xml.
You write app config, not native files
In an Expo project you do not edit those files by hand. You describe them in app.json or app.config.ts, and the native projects are generated from that. The shorthand is a config plugin option: ["expo-camera", { "cameraPermission": "..." }] writes the iOS string and adds the Android CAMERA entry for you. The longhand is writing the same things yourself under ios.infoPlist and android.permissions. Both produce the same native output; the plugin form just knows the key names.
Removing what a library added
A dependency can add permissions you never wanted, and a store listing shows them. android.blockedPermissions strips named permissions out of the merged manifest.
What goes wrong
A missing iOS purpose string does not throw a JavaScript error. The system terminates the app the moment the API is touched. And review reads these strings: "This app needs camera access" is the kind of line that gets a build rejected, while "Fieldnotes attaches a photo to a note" does not. Android 13 added a runtime notification permission, POST_NOTIFICATIONS, which must be declared as well.
Example
// app.config.ts - typed app config, read at build time rather than at runtime.
import { ExpoConfig, ConfigContext } from 'expo/config';
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: 'Fieldnotes',
slug: 'fieldnotes',
ios: {
bundleIdentifier: 'com.example.fieldnotes',
infoPlist: {
// Longhand: the raw Info.plist key, printed inside the iOS dialog.
NSLocationWhenInUseUsageDescription:
'Fieldnotes tags each note with the place you wrote it.',
},
},
android: {
package: 'com.example.fieldnotes',
// Longhand again: manifest entries, written out by name.
permissions: ['android.permission.ACCESS_FINE_LOCATION'],
// Strip a permission a dependency added that this app never uses.
blockedPermissions: ['android.permission.RECORD_AUDIO'],
},
plugins: [
// Shorthand for the same thing: the plugin writes
// NSCameraUsageDescription and adds android.permission.CAMERA.
['expo-camera', { cameraPermission: 'Fieldnotes attaches a photo to a note.' }],
],
});When to use it
- Adding camera and photo library access to an app that previously only showed text.
- Removing a microphone permission a video library pulled into the Android manifest.
- Rewriting vague purpose strings after an App Store rejection.
More examples
The same declarations in static app.json
A static `app.json` holds exactly the same fields as the TypeScript version; use it until you need a value computed at build time, such as a different bundle identifier per environment. Note the duplication: if a plugin already writes a key, setting the same key under `infoPlist` is redundant, and the two must not disagree.
{
"expo": {
"name": "Fieldnotes",
"slug": "fieldnotes",
"ios": {
"bundleIdentifier": "com.example.fieldnotes",
"infoPlist": {
"NSPhotoLibraryUsageDescription": "Fieldnotes lets you attach a photo you already took."
}
},
"android": {
"package": "com.example.fieldnotes",
"permissions": ["android.permission.POST_NOTIFICATIONS"]
},
"plugins": [
[
"expo-image-picker",
{
"photosPermission": "Fieldnotes lets you attach a photo you already took.",
"cameraPermission": "Fieldnotes takes a photo for the note you are writing."
}
]
]
}
}What prebuild generates
Worth reading once so the config stops feeling like magic. These files are regenerated from app config, so hand edits are lost on the next prebuild. When a permission appears in a store listing and you cannot find where it came from, search the generated manifest and then block it in app config.
<!-- ios/Fieldnotes/Info.plist, generated from app config -->
<key>NSCameraUsageDescription</key>
<string>Fieldnotes attaches a photo to a note.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Fieldnotes tags each note with the place you wrote it.</string>
<!-- android/app/src/main/AndroidManifest.xml, generated from app config -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- blockedPermissions removed RECORD_AUDIO, which expo-camera would otherwise add -->A string that passes review and one that does not
The difference is whether the sentence names the feature. Apple's reviewers read these strings in the dialog, and a generic one is a common reason for a rejected build. Write them when you add the dependency, not the night before submission.
// Rejected: states the obvious, explains nothing.
const weak = 'This app requires access to your camera.';
// Accepted: names the feature and what happens to the data.
const clear = 'Fieldnotes uses the camera to scan a receipt. Photos stay on this device.';
// A purpose string is one sentence the user reads under time pressure.
console.log(weak.length < clear.length);
// Output:
// true
Discussion