●MAX — Rork Max generates native Swift for every Apple platform, from iPhone to Vision Pro●NATIVE — It reaches native capabilities like AR/LiDAR, Metal 3D, Dynamic Island, Live Activities, and HealthKit●PUBLISH — Publish to the App Store in two clicks; Rork Max is $200/month●EXPO — Standard Rork builds iOS and Android together via React Native (Expo) and is free to start●PROMPT — Describe your app idea in plain English and Rork generates deployable, store-ready code●PRICE — Standard Rork's paid plans start at $25/month: build with it first, then consider Max for native features●MAX — Rork Max generates native Swift for every Apple platform, from iPhone to Vision Pro●NATIVE — It reaches native capabilities like AR/LiDAR, Metal 3D, Dynamic Island, Live Activities, and HealthKit●PUBLISH — Publish to the App Store in two clicks; Rork Max is $200/month●EXPO — Standard Rork builds iOS and Android together via React Native (Expo) and is free to start●PROMPT — Describe your app idea in plain English and Rork generates deployable, store-ready code●PRICE — Standard Rork's paid plans start at $25/month: build with it first, then consider Max for native features
Why "Save to Photos" Fails on Only Some Devices in Your Rork App — expo-media-library Permission Scopes and Save Design
Your Rork app's image-saving feature works perfectly on your phone but fails for a subset of users. The real cause is expo-media-library permission scopes. Here is a save flow and album design that handles writeOnly permission, iOS limited access, and Android 14 partial access.
It never fails on my own iPhone, yet the reviews say "it won't save"
When you run a few wallpaper apps as an indie developer, an odd review shows up every so often: "I tap the button and nothing appears in Photos." On my own devices, it never fails — not during development, not before submission. And yet, one star at a time, the "it won't save" reports keep accumulating.
When I chased it on real devices, the cause was almost never a failed image download or a full disk. It was the permission scope of expo-media-library ending up in a state I hadn't anticipated, depending on the OS version and on a choice the user made in a permission dialog at some point in the past.
What makes this mismatch nasty is that it's hard to reproduce on a developer's device. Most developers tap "Allow Access to All Photos" on first launch and then develop with full access forever after. Real users, meanwhile, are a mix: some picked "Selected Photos Only," some chose "Allow only a portion" on Android 14, and some declined out of suspicion because the app demanded read access when all they wanted to do was save. Here, I'll share how to design a save-only permission correctly and build a save flow that absorbs these device differences.
Why saving succeeds on one device and fails on another
A expo-media-library grant is not a simple allow/deny binary. Each OS has tiers, and those tiers have grown finer year by year.
iOS holds photo-library access in four states: full access, limited access (the user can see only the photos they picked), denied, and "add-only." That last one — add-only — lets you save but never read existing photos. It is a save-only permission, and it is the key to this whole problem.
Android branches further by version. Android 12 and earlier used WRITE_EXTERNAL_STORAGE, Android 13 introduced READ_MEDIA_IMAGES, and Android 14 added partial access (READ_MEDIA_VISUAL_USER_SELECTED), the equivalent of "selected photos only." The tricky part: if your app only writes an image it created into Pictures/ via MediaStore, read permission is actually unnecessary from Android 10 onward. Despite that, if your implementation requests read permission anyway, you ask save-only users for a heavy grant, and your denial rate climbs.
Put plainly, three combinations decide whether a save succeeds.
Factor
Typical developer
The user who gets stuck
iOS access state
Full access
Limited / add-only / denied
Android version
Latest test device
Behavior differs on 13 and 14
Breadth of permission requested
Read + write together
Wants only to save, declines read
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦If reviews say "can't save" while it always works on your own device, you'll be able to isolate the per-device permission-scope differences and find the real cause
✦You'll get a save flow and album-creation code that handles writeOnly permission, iOS limited access, and Android 14 partial access, ready to drop into your own app
✦You'll design a save flow that doesn't swallow permission denials or album failures, and instead routes users to a path they can recover from themselves
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
This is the single most important design decision. If your app only "saves an image to Photos" — wallpapers, stickers, generated images — you have no need to read the user's library. expo-media-library has a writeOnly option that requests a save-only grant: on iOS this is "add-only," and on Android it is the minimum permission needed to write.
import * as MediaLibrary from "expo-media-library";// Request only the save permission.// On iOS this is add-only; on Android, the minimum needed to write.async function ensureSavePermission(): Promise<boolean> { // Check the current state first (don't pop a dialog every time) const current = await MediaLibrary.getPermissionsAsync(true); // true = writeOnly if (current.granted) return true; // Only request when undecided or a re-prompt is still allowed if (current.canAskAgain) { const next = await MediaLibrary.requestPermissionsAsync(true); // writeOnly return next.granted; } // canAskAgain false = user permanently denied. The dialog will never show again. return false;}
The first argument to getPermissionsAsync(true) and requestPermissionsAsync(true) is the writeOnly flag. Omit it and request with the default (read + write), and even a save-only app triggers the iOS full-access confirmation or the Android read permission — which, from the user's side, reads as "why does a wallpaper app want to see all my photos?" I once shipped this as a combined read/write request, and the denial rate on the save button was noticeably high. After switching to writeOnly, the dialog text itself changed to "Allow adding to Photos?" and the grant rate went up.
Make the intent explicit in app.json (or app.config) as well.
{ "expo": { "plugins": [ [ "expo-media-library", { "photosPermission": "Used to add your saved wallpaper to your Photos.", "savePhotosPermission": "Used to save wallpapers to your Photos.", "isAccessMediaLocationEnabled": false } ] ] }}
savePhotosPermission is the text shown in the iOS "add-only" dialog. Scoping that string to the save use case helps both your grant rate and your review explanation.
The save itself — download first, then hand it to createAssetAsync
You cannot pass a remote image URL straight to createAssetAsync. You must download it to local storage first and pass that file path. Drop it into a temp file with expo-file-system, save, then clean up.
import * as FileSystem from "expo-file-system";import * as MediaLibrary from "expo-media-library";type SaveResult = | { ok: true } | { ok: false; reason: "permission" | "download" | "save" };async function saveRemoteImage(remoteUrl: string): Promise<SaveResult> { // 1. Secure the save (writeOnly) permission const allowed = await ensureSavePermission(); if (!allowed) return { ok: false, reason: "permission" }; // 2. Download to a temp file const fileName = `wallpaper-${Date.now()}.jpg`; const localPath = FileSystem.cacheDirectory + fileName; let downloaded; try { downloaded = await FileSystem.downloadAsync(remoteUrl, localPath); if (downloaded.status !== 200) { return { ok: false, reason: "download" }; } } catch { return { ok: false, reason: "download" }; } // 3. Save to the photo library try { await MediaLibrary.createAssetAsync(downloaded.uri); return { ok: true }; } catch { return { ok: false, reason: "save" }; } finally { // 4. Always clean up the temp file, success or not await FileSystem.deleteAsync(downloaded.uri, { idempotent: true }); }}
The point is that it returns the failure reason split into permission / download / save. Collapse all of that into one catch that shows only "Save failed," and you lose any ability to isolate the cause. I first swallowed this in a single try-catch, and I could never tell whether the review's "won't save" was permission or network — I had to go back and re-instrument logging. Splitting the reason into a type lets you write the UX branch below directly.
The trap when you want to put it in an album
If you only "save," the above is enough. But if you want to collect images into an album dedicated to your app, you add a step. Pass the asset from createAssetAsync to createAlbumAsync or addAssetsToAlbumAsync — and here the iOS/Android difference surfaces.
async function saveToAppAlbum(localUri: string, albumName: string) { const asset = await MediaLibrary.createAssetAsync(localUri); const album = await MediaLibrary.getAlbumAsync(albumName); if (album == null) { // Create the album if it doesn't exist yet await MediaLibrary.createAlbumAsync(albumName, asset, false); } else { // Add to the existing album. // The third argument copyAsset only matters on iOS. // false moves the original asset, so it can disappear from "Recents" in Photos. await MediaLibrary.addAssetsToAlbumAsync([asset], album, true); }}
The final copyAsset argument of createAlbumAsync and addAssetsToAlbumAsync acts only on iOS. Set it true and the asset is duplicated into the album, so it also stays in the user's "Recents." Set it false and it's a move, which generates a different complaint: "I saved it but can't find it in my camera roll." On Android, MediaStore's structure ignores this argument and always treats it as an add. Verify only on Android, ship with false, and you'll get "it vanished" reports from iOS users — that was the exact order in which I tripped over it. If you run albums, make copyAsset default to true.
Under iOS limited access (only the photos the user selected are allowed), adding to an album may not behave as expected. Leaning on save-only writeOnly keeps you largely clear of read-side limited-access effects too, which is another reason writeOnly is the safe choice.
Save at the device's resolution
For an image used full-screen, like a wallpaper, downsampling to the device's logical resolution before saving avoids leaving needlessly huge files in Photos. Downsample with expo-image-manipulator, then save.
import * as ImageManipulator from "expo-image-manipulator";import { Dimensions, PixelRatio } from "react-native";async function downsampleForDevice(localUri: string): Promise<string> { const { width } = Dimensions.get("screen"); const scale = PixelRatio.get(); // Use the device's physical pixels as the guide const targetWidth = Math.round(width * scale); const result = await ImageManipulator.manipulateAsync( localUri, [{ resize: { width: targetWidth } }], // height is computed from the aspect ratio { compress: 0.9, format: ImageManipulator.SaveFormat.JPEG } ); return result.uri;}
Here I multiply Dimensions.get("screen") by PixelRatio.get() to estimate physical pixels. I've personally done the yearly work of tracking new iPhone resolutions across several wallpaper apps, and optimizing the save size per device cuts the "it eats storage" complaints from space-conscious users. To avoid upscaling when the source is smaller than the screen, add a branch that skips the manipulate when the resize width would exceed the original width — that prevents blur.
Don't dead-end the user when permission is denied
Once canAskAgain is false — the user permanently denied — calling requestPermissionsAsync no matter how many times produces no dialog. If your app just says "please allow it" and stops there, the user has nowhere to tap and hits a dead end. You need a path straight to the Settings app.
import { Alert, Linking } from "react-native";async function handleSavePress(remoteUrl: string) { const result = await saveRemoteImage(remoteUrl); if (result.ok) { // Quiet success feedback (a toast, etc.) return; } if (result.reason === "permission") { Alert.alert( "Saving to Photos isn't allowed", "Allow adding photos in Settings to enable saving.", [ { text: "Later", style: "cancel" }, { text: "Open Settings", onPress: () => Linking.openSettings() }, ] ); return; } if (result.reason === "download") { Alert.alert("Couldn't fetch the image", "Check your connection and try again."); return; } Alert.alert("Save failed", "Please wait a moment and try again.");}
Linking.openSettings() opens the app's own settings screen. The key is varying the copy and next action per failure reason. Just routing permission to Settings and network to retry — same "failure," very different odds that the user recovers on their own. Splitting the reason into a type back in the save flow was precisely so this branch could be written directly.
A device verification matrix
This class of bug doesn't appear on a single device. At minimum, run the following combinations on a real device or simulator and you'll close out almost all of the "won't save" reviews.
Device / state
Behavior to confirm
iOS, add-only granted
writeOnly save succeeds without requesting library read
iOS, limited access
Save succeeds; with copyAsset=true on album add, it stays in Recents
iOS, permanently denied
A path to the Settings app appears
Android 13
Save succeeds without over-requesting read permission
Android 14, partial access
Save-only passes, unaffected by partial access
Airplane mode
Fails with the download reason; a retry path appears
What I learned running several apps in parallel is that the quality of a save feature is decided not by "speed on success" but by "not swallowing failures." If you monitor crash-free rates, check once whether you're swallowing exceptions around createAssetAsync — that is, whether failures are vanishing silently. Silent failures aren't counted as crashes, so they show up only in reviews.
As a first step, check whether your app's save flow is calling requestPermissionsAsync() without writeOnly — just that one line. If you only save yet request read + write together, adding true alone will lower your denial rate.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.