RORK LABJP
TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude CodeSIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App StoreMAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessageNATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core MLSEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the roundTOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude CodeSIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App StoreMAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessageNATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core MLSEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Articles/Dev Tools
Dev Tools/2026-04-23Intermediate

Why Your Rork App Can't Save Images to the Camera Roll — A Complete Fix

Image and video saving often breaks the moment a Rork-generated app leaves the simulator. This guide walks through the permission model, expo-media-library quirks, and Android 13+ scoped storage changes that reliably trip people up.

Rork515expo-media-library3troubleshooting65iOS109Android43permissions7

"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_IMAGES and READ_MEDIA_VIDEO are in the permissions array
  • It is fine to leave READ_EXTERNAL_STORAGE in place, but remove it if you do not need legacy coverage
  • Verify your targetSdkVersion is 33 or higher through expo-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.downloadAsync matches the actual bytes (a .jpg that 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 uses Linking.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." createAssetAsync does not accept these directly. Run the buffer through expo-image-manipulator to 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.createAssetAsync can see the updated scope.
  • Build a production APK with eas build --profile production --platform android and install it directly. I have been burned more than once by issues that only reproduce in the production build because expo-build-properties settings 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.

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.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

Dev Tools2026-05-10
When expo-image-picker won't launch in your Rork app — Info.plist and permission fixes that actually worked
Your Rork-generated app's pick image button works in the simulator but does nothing on TestFlight builds. Walk through the four real-world causes I keep hitting: missing Info.plist keys, wrong permission order, iOS 14 Limited Photo Access, and Android 13+ media permissions.
Dev Tools2026-05-04
Microphone and Audio Recording Not Working in Rork — A Symptom-Based Troubleshooting Guide
When microphone or audio recording stops working in a Rork-generated app, the root cause is often not obvious. This guide walks through common failure patterns — permissions, audio mode setup, simulator limits, and async pitfalls — with working code fixes.
Dev Tools2026-04-18
Rork App Works on iOS but Breaks on Android: A Practical Guide to Fixing Platform Differences
Troubleshoot Rork apps that work on iOS but break on Android. Covers shadows, fonts, permissions, keyboard, and status bar with code examples.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →