The most-read sentence in a mobile app is not in the app. It is the line in the system permission dialog, read by someone who has had the app installed for ninety seconds and is deciding whether to continue.
In an Expo project that sentence lives in app.json, next to the icon path and the bundle identifier, in a file that mostly contains build configuration. It is easy to treat it as build configuration. It is the highest-stakes copy in the product.
Here is Munchable's, in full:
["expo-camera", {
"cameraPermission": "Munchable uses your camera to scan barcodes and photograph ingredient labels. Photos are used only to read the label and are never stored.",
"microphonePermission": false,
"recordAudioAndroid": false
}]
Three lines, and only one of them is prose. The other two are the interesting ones.
A config plugin will ask for things you did not ask for
Expo config plugins generate the native manifests. expo-camera is a camera library, and a camera library on a phone is also a video library, so by default it arranges for the microphone: a usage description on iOS, RECORD_AUDIO on Android.
This app never records video. It reads a barcode and photographs a label. So a microphone permission here would be a request for something no code path uses, and it costs real trust: a person reading "Munchable would like to access the microphone" is not thinking about a config plugin's defaults, they are wondering what the food app wants to listen to. It also has to be declared and justified on the store listing, which means defending a capability that does not exist.
The two false flags turn both off. The result is an Android permissions array with exactly one entry:
"permissions": ["android.permission.CAMERA"]
The explicit array is itself the point. Left out, the manifest is whatever the plugins decided, and "whatever the plugins decided" is not something you can review in a pull request. Written out, a permission appearing in that array is a diff, and a diff is something a reviewer, even a future me, can argue with.
What is not in it matters more than what is: no location, no contacts, no notifications, no photo library. The app has no notifications plugin at all, so there is nothing to grant, nothing to revoke, and nothing to quietly start using in version 1.1. A permission never requested is the only privacy guarantee that cannot regress without a visible change to the build.
One sentence, three claims, three systems
The camera string makes three promises. Each one has to be true somewhere in the code, and they are not in the same place.
"to scan barcodes and photograph ingredient labels" bounds what the camera is for. The camera is mounted on exactly two screens, and both of them are doing one of those two things.
"Photos are used only to read the label" is about the server. An uploaded image goes to the extraction step, the ingredient text and product name are read out of it, and the text is what the rest of the product consumes. The image is not an input to anything else.
"and are never stored" is the load-bearing one, and keeping it took three separate decisions:
- On the device, a captured frame lives in the OS managed cache and in memory. It is never written to the photo library, and it is never logged. The crop step holds the full-resolution still a few seconds longer than the shutter did, because you photograph the whole panel and then draw a box on it, and only the box is downscaled for upload. Same cache file, slightly longer life, same rule.
- In transit it is sent once, to one endpoint, and the response is text.
- On the server there is nowhere to put it. The app has no object storage: no bucket, no CDN for user media, no signed upload URLs. That absence is visible from the outside in a place you would not expect, which I wrote about in our licenses page lists no licenses.
That last one is the only version of "never stored" I would be willing to print in a system dialog. A retention policy is a promise about what code does. No storage subsystem is a promise about what code can do, and the second kind survives contact with a hurried future change.
The same sentence, written twice, on purpose
A permission string has about 150 characters before it gets truncated or skimmed, so it cannot carry the detail. The long version has to exist somewhere a person can actually read it, and for this app that is munchable.app/privacy, which says the photograph is sent once to the extraction step, that the text is read out of it, and that the app does not ask for your location, your contacts or your photo library beyond the image you choose to send.
Two copies of a claim is normally a drift risk, and this is the exception, because the two have different jobs: the dialog has to be readable in the moment of decision, and the legal page has to be complete. What keeps them honest is that neither is a marketing statement. Both are descriptions of code, which means the test for either one is to go and read the code, not to go and ask whoever wrote the copy.
The house style applies to the dialog too: no em dashes (there is a unit test for that one), no emoji, plain sentences. A permission dialog that sounds excited is a permission dialog that sounds like it wants something.
The small print I nearly got wrong
Two things in that config were not obvious from the docs:
-
microphonePermission: falseandrecordAudioAndroid: falseare separate flags because they are separate platforms' mechanisms. Setting only the first leavesRECORD_AUDIOin the Android manifest, which is the one a store listing surfaces. - The strings are consumed at prebuild. Editing the sentence and pushing an over-the-air update changes nothing, because the manifest is baked into the binary. A better permission sentence is a new build, which is a good reason to get it right before the first submission rather than after.
Related reading, if the storage angle is the interesting part: erasure is easy when the sensitive data was never on your server.
Top comments (2)
the web version: your privacy page is the permission string, the enforceable part is your csp and the actual outbound requests. config plugins that over-ask have a web cousin too, analytics bundles that fire requests your snippet never asked for. promises print in product copy, enforcement lives in config.
iOS has the same trap one level up with the tracking prompt: the purpose string is the only part of that dialog you control, and it's usually the last thing anyone writes before submitting.