You tap the "pick image" button in your Rork-generated app and absolutely nothing happens — but only on TestFlight, not in the simulator. I lost a full day to that exact pattern recently.
I have been shipping individual mobile apps since 2014, mostly wallpaper-style apps that have crossed 50 million cumulative downloads. Image picker has shipped in more than ten of them, and I still get bitten by Info.plist whenever I trust the AI-generated expo-image-picker snippet without double-checking the platform configuration. The four causes below cover almost every "won't launch" report I have seen, so working through them in order tends to unblock most teams.
Cause 1: Info.plist keys are missing (the first wall on iOS)
Since iOS 10, accessing the photo library or camera requires a usage-description string in Info.plist. Apps generated by Expo or Rork often start out without those keys at all on the first build.
The signature of this failure is that the simulator works fine, but on a real device — especially via TestFlight — tapping the button does nothing or the app crashes outright.
Add the keys under ios.infoPlist in app.json.
{
"expo": {
"ios": {
"infoPlist": {
"NSPhotoLibraryUsageDescription": "We use your photo library so you can choose a profile picture.",
"NSCameraUsageDescription": "We use the camera so you can take a profile picture.",
"NSPhotoLibraryAddUsageDescription": "We save images you create back to your photo library."
}
}
}
}Editing the file alone does not propagate the change. With Expo Go, restart the dev server; with a native build, you have to run EAS Build again. When someone tells me "I changed app.json and nothing happened," nine times out of ten they simply have not rebuilt the binary.
Cause 2: Calling launchImageLibraryAsync without requesting permission first
A common pattern in Rork-generated code is to call launchImageLibraryAsync directly without prompting for permission. Some platforms silently return an empty result in that case, which looks exactly like a button that does nothing.
The correct order is to request permission, handle the rejection path, and only then launch the picker.
import * as ImagePicker from 'expo-image-picker';
import { Alert, Linking } from 'react-native';
async function pickImage() {
// 1. Request permission first; bail out clearly on rejection.
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') {
Alert.alert(
'Photo access not allowed',
'Please grant photo access from the Settings app.',
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Open Settings', onPress: () => Linking.openSettings() },
]
);
return;
}
// 2. Launch the picker after the permission is granted.
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
quality: 0.8,
});
if (!result.canceled) {
// Expected output: result.assets[0].uri begins with file://
console.log(result.assets[0].uri);
}
}The most important detail is what happens when the user denies permission. Once a user taps "Don't Allow," the OS prompt never appears again, so your app has to provide an alternative path back to Settings. Skip that and you end up with one-star reviews that simply read "the button does nothing." During the years my wallpaper apps were peaking on AdMob, I learned the cost of that oversight the hard way.
Cause 3: The iOS 14 Limited Photo Access trap
iOS 14 introduced Limited Photo Access — the option that lets the user share only selected photos. When the user picks "Selected Photos Only," your app can only see the photos they explicitly authorized.
requestMediaLibraryPermissionsAsync() returns granted even in Limited mode, so on paper everything looks fine. From the user's side, however, the picker shows almost no images, and they file a "the picker is broken" support ticket. I have shipped that bug into multiple apps before noticing.
If you want to provide a flow back to "All Photos," you need the iOS native API PHPhotoLibrary.shared().presentLimitedLibraryPicker(from:). Expo's stock module does not wrap it. For a solo developer, my pragmatic answer has been to design the experience so that Limited mode does not break the app:
- Show a friendly "no photos selected" message when the result is empty.
- Document the "All Photos" toggle in Settings with screenshots.
- Recommend "All Photos" during onboarding when the feature really needs it.
For the broader question of how to design permission flows, troubleshooting permission dialogs that never appear in your Rork app is worth pairing with this article.
Cause 4: Android 13+ media permission changes
Until Android 12, READ_EXTERNAL_STORAGE was enough to read photos. Android 13 (API level 33) split that into READ_MEDIA_IMAGES, READ_MEDIA_VIDEO, and READ_MEDIA_AUDIO.
Expo SDK 49+ ships an expo-image-picker that follows the new permissions internally, but if android.permissions in your app.json still references the old ones, you may see build-time warnings or a Play Store reviewer flagging your app for requesting unnecessary permissions.
{
"expo": {
"android": {
"permissions": [
"android.permission.READ_MEDIA_IMAGES",
"android.permission.READ_MEDIA_VIDEO"
]
}
}
}A subtler win on Android 13+ is the system-provided Photo Picker, which removes the need for any of those permissions altogether. expo-image-picker already routes through it for simple selections, so a basic profile-photo flow can ship with no permission request at all. If your picker "won't launch," it is also worth asking whether the runtime permission request is even running in the first place.
When you want to step back and look at the whole native build pipeline, working with Expo prebuild and custom native modules and fixing environment variable configuration errors in Rork help spot the cases where Info.plist alone is not the whole story.
One thing to do first
If only one fix fits in your day, it is this: add NSPhotoLibraryUsageDescription to ios.infoPlist in app.json, then rebuild with EAS Build. I have lost count of how many times that single move has solved the problem for me.
If this saves you a few hours of the same dead end I walked into, that is more than enough for me. Thank you for reading.