"I shipped an image-generation app built with Rork, and my App Store reviews are now full of 'the save button does nothing'." This exact story has landed in my inbox more than a few times. The culprit is almost always the same: the media library permission flow and the save API do not quite match what the latest SDK expects, even though the Rork-generated code looks fine on the surface.
After debugging this across three of my own apps, I've found a workflow that holds up on both iOS and Android, including Android 13+ scoped storage. The goal of this article is to save you the weekend I lost to it.
Three Moments Where "Working Code" Stops Working
The bug reports usually fall into three buckets:
- Saving works on the simulator but silently fails once the app is on TestFlight
- iOS works fine, but Android throws
Permission denied - Android 12 and older work, but Android 13+ refuses to save
All three trace back to the fact that the OS permission model has been evolving faster than most tutorials have caught up with. Rork's generated code reflects the current best practice, but if your app.json or package versions drift out of sync, you end up stuck on an older API.
Step 1: Align Your Dependency Versions
expo-media-library pairs with expo-file-system and sometimes expo-image-picker. When versions drift, everything builds but crashes the first time you call into the native module on a real device.
# Run this in your Rork project root (Expo SDK 51+)
npx expo install expo-media-library expo-file-system expo-image-picker
# Expected output: "added 3 packages" or "up to date".
# ⚠️ Avoid plain `npm install` here — it will happily pick versions incompatible with the SDK.The reason expo install matters is that it pins the compatible major/minor range for your current SDK. Using npm install expo-media-library@latest can pull in a version that throws Invariant Violation: Native module RNCMediaLibrary cannot be null. at startup.
Step 2: Put the Permission Strings in app.json
Expo Managed Workflow expects you to configure permissions through app.json instead of editing Info.plist and AndroidManifest.xml directly. Skipping this step leads to the infamous "requests permissions without disclosing purpose" App Store rejection.
{
"expo": {
"ios": {
"infoPlist": {
"NSPhotoLibraryAddUsageDescription": "We save the images and videos you generate to your photo library.",
"NSPhotoLibraryUsageDescription": "We access your photo library so you can edit and share existing photos."
}
},
"android": {
"permissions": [
"android.permission.READ_MEDIA_IMAGES",
"android.permission.READ_MEDIA_VIDEO"
]
},
"plugins": [
[
"expo-media-library",
{
"photosPermission": "Save generated media to your photo library.",
"savePhotosPermission": "Save generated media to your photo library.",
"isAccessMediaLocationEnabled": true
}
]
]
}
}There is an important nuance between NSPhotoLibraryAddUsageDescription (write-only) and NSPhotoLibraryUsageDescription (read + write). If your app only exports media, write-only is what you want — asking for both when you only save can itself cause a rejection. Match the permissions to the real behavior, not to what feels safer.
Step 3: Write a Save Helper That Works on Both Platforms
The most reliable pattern is: download to the cache first, then hand the local path to MediaLibrary.createAssetAsync. Passing a raw HTTPS URL to the library works on iOS today, but Android throws ERR_UNEXPECTED if the server response lacks proper headers.
import * as MediaLibrary from 'expo-media-library';
import * as FileSystem from 'expo-file-system';
import { Alert, Platform } from 'react-native';
/**
* Save a remote image to the device's photo library.
* @param remoteUrl URL of the image to save
* @param filename Output filename with an extension
* @returns true on success, false if the user declined permission
*/
export async function saveRemoteImageToLibrary(
remoteUrl: string,
filename: string,
): Promise<boolean> {
// 1) Ask for permission (write-only on iOS is enough for saving)
const { status } = await MediaLibrary.requestPermissionsAsync(
true, // writeOnly: true — requests only the lighter iOS scope
['photo'], // granularPermissions: photos only
);
if (status !== 'granted') {
Alert.alert(
'Permission Needed',
'Please allow access to add photos from the Settings app.',
);
return false;
}
// 2) Download into the cache directory first
const localPath = `${FileSystem.cacheDirectory}${filename}`;
const downloaded = await FileSystem.downloadAsync(remoteUrl, localPath);
if (downloaded.status !== 200) {
throw new Error(`Download failed: HTTP ${downloaded.status}`);
}
// 3) Register the local file as a library asset.
// Always pass a local path — Android often fails with a raw HTTPS URL.
await MediaLibrary.createAssetAsync(downloaded.uri);
// 4) Clean up the temp file (older devices are tight on space)
if (Platform.OS === 'ios') {
await FileSystem.deleteAsync(downloaded.uri, { idempotent: true });
}
return true;
}The reason we skip the cache cleanup on Android is that createAssetAsync there copies rather than moves. Deleting too early has, in rare cases, left me unable to recover the source when the asset registration itself failed. On iOS the library keeps its own copy, so deleting is always safe.
Step 4: Cover the Android 13 Scoped Storage Changes
Starting with Android 13, READ_EXTERNAL_STORAGE is gone. It is replaced by READ_MEDIA_IMAGES, READ_MEDIA_VIDEO, and READ_MEDIA_AUDIO. Expo Media Library 15+ handles this transparently, but a stale app.json can leave the dialog silently broken.
- Confirm both
READ_MEDIA_IMAGESandREAD_MEDIA_VIDEOare in thepermissionsarray - It is fine to leave
READ_EXTERNAL_STORAGEin place, but remove it if you do not need legacy coverage - Verify your
targetSdkVersionis 33 or higher throughexpo-build-properties - Always test on an Android Emulator running API level 33+ — API 30 and below still use the old permissions and hide the bug
Submitting to Google Play without these in order typically shows up as a Pre-launch warning about insufficient permission usage. Running npx expo prebuild --clean locally and inspecting the generated AndroidManifest.xml gives you a sanity check before uploading.
Step 5: Debugging When It Still Does Not Work
If you have done all of the above and saves still fail, triage in this order:
- Log the full result of
MediaLibrary.getPermissionsAsync()so you can tell granted-with-write-only from fully-denied - Reproduce on a real device — simulator photo libraries behave differently from production hardware
- Check that the filename contains no Japanese characters or emoji — iOS tolerates them, Android occasionally does not
- Make sure the file extension from
FileSystem.downloadAsyncmatches the actual bytes (a.jpgthat is really WebP is silently skipped on Android) - Test against an EAS Build binary, not just the dev client — native modules are packaged differently between the two
When the bug only reproduces in the production EAS Build, no amount of JavaScript log inspection will help. My companion article on diagnosing on-device crashes in Rork apps walks through how to pull native logs in that case.
Three Questions That Keep Coming Up
- "The permission dialog never shows — it just returns
denied." Once the user has declined, iOS and Android both stop showing the prompt. You need a fallback that usesLinking.openSettings()to route them into the system settings screen. - "The save succeeds, but the image is missing from the gallery." On iOS the asset lands in "Recents" by default. On Android, third-party gallery apps sometimes take a few seconds to notice the new file and occasionally need a manual refresh.
- "HEIC and WebP refuse to save."
createAssetAsyncdoes not accept these directly. Run the buffer throughexpo-image-manipulatorto convert to JPEG or PNG first.
Permission handling in React Native changes roughly once a year alongside React Native and Expo releases. If you want a deeper systematic treatment, the Expo team's own documentation combined with reviewing the open source expo-media-library tests is still the most up-to-date reference I have found.
A Quick Sanity-Check Routine Before Every Release
Over time I have built up a very short ritual I run before shipping anything that touches media saving. It takes less than ten minutes and has caught every regression I would have otherwise released.
- Trigger the save flow on an iOS physical device with permissions already granted, and confirm the photo shows up in the default "Recents" album within a second.
- Revoke photo library access for the app from iOS Settings, relaunch, and verify that my fallback copy and the
Linking.openSettings()call both work. - On an Android 13+ physical device, run the same two scenarios. Pay particular attention to the moment right after granting permission — the very first save after a permission change sometimes needs an extra frame before
MediaLibrary.createAssetAsynccan see the updated scope. - Build a production APK with
eas build --profile production --platform androidand install it directly. I have been burned more than once by issues that only reproduce in the production build becauseexpo-build-propertiessettings were only applied there.
Adding this routine to your pre-release checklist is the single cheapest way to avoid shipping a broken save button. It also doubles as a useful demo clip to record and attach to your App Store submission notes, which reviewers occasionally appreciate when an app requests photo permissions.
Wrap-up
Save-button failures almost never come from the code you wrote — they come from the configuration and timing around it. The single most valuable thing you can do today is to open your app.json and confirm that expo-media-library appears inside the plugins section. Dropping in the snippet from Step 2 resolves the majority of the reports I see.
If you are fighting image and media issues more broadly, my fix guide for image and media loading errors and permission troubleshooting walkthrough both cover adjacent ground that often lines up with a save bug.