expo: Expo-media-library createAssetAsync calls error in Android 11 even with MediaLibrary Permission.

Summary

Expo-media-library createAssetAsync calls error “Unable to copy file into external storage.” in Android 11 even with MediaLibrary Permission. MediaLibrary does not require MANAGE_EXTERNAL_STORAGE permission. But always show error message in Android 11. Works fine in others. Both SDK 40 and SDK 41 give the same result.

Is there a way to replace it with StorageAccessFramework? Isn’t SAF only able to create string or empty files? I tried to grant access to the DCIM folder using SAF, but createAssetAsync still doesn’t work.

Managed or bare workflow? If you have ios/ or android/ directories in your project, the answer is bare!

managed

What platform(s) does this occur on?

Android

SDK Version (managed workflow only)

40.0.0, 41.0.0.

Environment

Expo CLI 4.1.6 environment info: System: OS: Windows 10 10.0.19041 Binaries: Node: 14.15.1 - C:\Program Files\nodejs\node.EXE Yarn: 1.22.10 - C:\Users\Administrator\AppData\Roaming\npm\yarn.CMD npm: 6.14.9 - C:\Users\Administrator\AppData\Roaming\npm\npm.CMD IDEs: Android Studio: Version 4.1.0.0 AI-201.8743.12.41.6953283 npmPackages: expo: ^40.0.0 => 40.0.1 (And 41.0.0) react: 16.13.1 => 16.13.1 react-dom: 16.13.1 => 16.13.1 react-native: https://github.com/expo/react-native/archive/sdk-40.0.1.tar.gz => 0.63.2 react-native-web: ~0.13.12 => 0.13.18 Expo Workflow: managed

Device info: Samsung Galaxy S21 Ultra 5G / Android 11 / Internal storage only

Reproducible demo or steps to reproduce from a blank project

import * as MediaLibrary from "expo-media-library";
import * as FileSystem from "expo-file-system";

static copyToLibraryAsync = async (localUri) => {
    // For example: localUri = FileSystem.cacheDirectory + "image.jpg"
    console.log(localUri)
    // file:///data/user/0/host.exp.exponent/cache/ExperienceData/%2540username%252Fappname/image.jpg

    const permissions = await MediaLibrary.getPermissionsAsync();
    console.log(permissions); // { canAskAgain: true, expires: "never", granted: true, status: "granted" }

    try {
        await MediaLibrary.createAssetAsync(localUri)
        // await MediaLibrary.saveToLibraryAsync(localUri); // Same error message
    } catch (e) {
        console.log(e) //  [Error: Unable to copy file into external storage.]
    }
}

About this issue

  • Original URL
  • State: closed
  • Created 3 years ago
  • Reactions: 9
  • Comments: 23 (2 by maintainers)

Most upvoted comments

To save time of people having the same problem as me, this is my code. I hope this helps someone. Works fine on sdk41 & android 11.

import AsyncStorage from "@react-native-async-storage/async-storage";
import * as FileSystem from "expo-file-system";
import * as MediaLibrary from "expo-media-library";

static settings = async (newSettings) => {
        return new Promise(async (resolve, reject) => {
            try {
                let settings = (await AsyncStorage.getItem("settings").then((result) => JSON.parse(result))) || {};
                if (newSettings) {
                    settings = Object.assign(settings, newSettings);
                    await AsyncStorage.setItem("settings", JSON.stringify(settings));
                }
                return resolve(settings);
            } catch (e) {
                console.log("Error in settings", e);
                return resolve({});
            }
        });
    };

static getDirectoryPermissions = async (onDirectoryChange) => {
        return new Promise(async (resolve, reject) => {
            try {
                const initial = FileSystem.StorageAccessFramework.getUriForDirectoryInRoot();
                onDirectoryChange({isSelecting: true}) //For handle appStateChange and loading
                const permissions = await FileSystem.StorageAccessFramework.requestDirectoryPermissionsAsync(initial);
                this.settings({ downloadsFolder: permissions.granted ? permissions.directoryUri : null });
                // Unfortunately, StorageAccessFramework has no way to read a previously specified folder without popping up a selector.
                // Save the address to avoid asking for the download folder every time
                onDirectoryChange({downloadsFolder: permissions.granted ? permissions.directoryUri : null, isSelecting: false})
                return resolve(permissions.granted ? permissions.directoryUri : null);
            } catch (e) {
                console.log("Error in getDirectoryPermissions", e);
                onDirectoryChange({downloadsFolder: null})
                return resolve(null);
            }
        });
    };

static downloadFilesAsync = async (files, onDirectoryChange) => {
        // files = [{url: "url", fileName: "new file name" + "extension", mimeType: is_video ? "video/mp4" : "image/jpg"}]
        // onDirectoryChange = () => {cb_something_like_setState()}
        return new Promise(async (resolve, reject) => {
            try {
                const mediaLibraryPermission = await this.getMediaLibraryPermission()
                if (mediaLibraryPermission !== "granted") {
                    return resolve({status: "error"})
                }
                let settings = await this.settings();
                // Unfortunately, StorageAccessFramework has no way to read a previously specified folder without popping up a selector.
                // Save the address to avoid asking for the download folder every time
                const androidSDK = Platform.constants.Version
                
                if (Platform.OS === "android" && androidSDK >= 30 && !settings.downloadsFolder) {
                    //Except for Android 11, using the media library works stably
                    settings.downloadsFolder = await this.getDirectoryPermissions(onDirectoryChange)
                }
                const results = await Promise.all(
                    files.map(async (file) => {
                        try {
                        if (file.url.includes("https://")) {
                            // Remote file
                            const { uri, status, headers, md5 } = await FileSystem.downloadAsync(
                                file.url,
                                FileSystem.cacheDirectory + file.name
                            );
                            file.url = uri; //local file(file:///data/~~~/content.jpg)
                            // The document says to exclude the extension, but without the extension, the saved file cannot be viewed in the Gallery app.
                        }
                        if (Platform.OS === "android" && settings.downloadsFolder) {
                            // Creating files using SAF
                            // I think this code should be in the documentation as an example
                            const fileString = await FileSystem.readAsStringAsync(file.url, { encoding: FileSystem.EncodingType.Base64 });
                            const newFile = await FileSystem.StorageAccessFramework.createFileAsync(
                                settings.downloadsFolder,
                                file.name,
                                file.mimeType
                            );
                            await FileSystem.writeAsStringAsync(newFile, fileString, { encoding: FileSystem.EncodingType.Base64 });
                        } else {
                            // Creating files using MediaLibrary
                            const asset = await MediaLibrary.createAssetAsync(file.url);
                        }
                        return Promise.resolve({status: "ok"});
                    } catch (e) {
                        console.log(e)
                        return Promise.resolve({status: "error"});
                    }
                    })
                );
                return resolve({ status: results.every((result) => result.status === "ok") ? "ok" : "error" });
            } catch (e) {
                console.log("Error in downloadFilesAsync", e)
                return resolve({ status: "error" });
            }
        });
    };

This is because the Media library is able to save image/video/audio assets so it will fail with other file types. This is a bit more complicated if you want to save some documents (pdf/csv etc.) to the downloads folder. Actually I worked around it like this:

  if (Platform.OS === 'android') {
    const downloadDir = SAF.getUriForDirectoryInRoot('Download');
    const permission = await SAF.requestDirectoryPermissionsAsync(downloadDir);

    if (!permission.granted) {
      return false;
    }

    const destinationUri = await SAF.createFileAsync(permission.directoryUri, filename, mimeType);
    // TODO: Error in SAF file: Destination '[...] /Download/SelectedDir' directory cannot be created
    // await SAF.copyAsync({ from: uri, to: destinationUri });
    await SAF.writeAsStringAsync(destinationUri, await readAsStringAsync(uri));
    return true;
  } else if (Platform.OS === 'ios') {
    await Sharing.shareAsync(uri, {
      mimeType,
      UTI: uti,
    });
    return true;
  }

However, there is one disadvantage:

there’s no way to download a single file and save it to the Downloads folder on Android. With the current API, we open a popup where a user needs to create a subdirectory inside Downloads (because SAF doesn’t give permission for Downloads dir itself, for security reasons) and select it for our app to create the file inside that subdir.

Ideally, the ACTION_CREATE_DOCUMENT intent should be used for this purpose, but it’s not yet supported in Expo. On iOS, the expo-sharing with correct UTI solves such problems.

Hi,

I have exactly the same issue ! 👎 I download a file then when I call createAssetAsync I have the following error : Unable to copy file into external storage.

Thanks in advance for help!

The error is located in CreateAsset.java line 97.

Hi, here is the Snack to reproduce the error on Android 11. With Samsung A12 (Android 11), the .jpeg file is saved successfully, but .xlsx file throws the error.

https://snack.expo.io/@remato/anxious-churros

Any other ideas how to download .xlsx files to user-accessible location would be appreciated, because this is blocking us from using SDK41.

One way to do this on Android 11 without using media-library is using StorageAccessFramework, but this includes so many steps and is too complicated for users:

  • user has to browse a location
  • there is no way to add any helper text to the folder browsing
  • in Android 11, user can not save to Downloads, but has to create a new folder

This is because the Media library is able to save image/video/audio assets so it will fail with other file types. This is a bit more complicated if you want to save some documents (pdf/csv etc.) to the downloads folder. Actually I worked around it like this:

  if (Platform.OS === 'android') {
    const downloadDir = SAF.getUriForDirectoryInRoot('Download');
    const permission = await SAF.requestDirectoryPermissionsAsync(downloadDir);

    if (!permission.granted) {
      return false;
    }

    const destinationUri = await SAF.createFileAsync(permission.directoryUri, filename, mimeType);
    // TODO: Error in SAF file: Destination '[...] /Download/SelectedDir' directory cannot be created
    // await SAF.copyAsync({ from: uri, to: destinationUri });
    await SAF.writeAsStringAsync(destinationUri, await readAsStringAsync(uri));
    return true;
  } else if (Platform.OS === 'ios') {
    await Sharing.shareAsync(uri, {
      mimeType,
      UTI: uti,
    });
    return true;
  }

However, there is one disadvantage:

there’s no way to download a single file and save it to the Downloads folder on Android. With the current API, we open a popup where a user needs to create a subdirectory inside Downloads (because SAF doesn’t give permission for Downloads dir itself, for security reasons) and select it for our app to create the file inside that subdir. Ideally, the ACTION_CREATE_DOCUMENT intent should be used for this purpose, but it’s not yet supported in Expo. On iOS, the expo-sharing with correct UTI solves such problems.

Hello @barthap thanks for the quick response,

I’m trying to save the .xlsx file, its save in the Download/Data folder with a proper name when opening that file gets corrupted.

Reproducable example : snack expo Code:

const downloadExcel = async () => {
    setError(null);
    const fileUrl =
      'https://filesamples.com/samples/document/xlsx/sample1.xlsx';
    const fileName = 'sample1.xlsx';
    FileSystem.downloadAsync(fileUrl, FileSystem.documentDirectory + fileName)
      .then(({ uri }) => {
        console.log('Finished downloading to ', uri);
        // saveFile(uri);
        // saveF(uri);
        saveData(uri);
      })
      .catch((error) => {
        setError(error);
      });
  };

  const saveData = async (uri: string) => {
    try {
      setLoading(true);
      console.log({ uri });
      if (uri) {
        const mimeType =
          'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
        const filename = 'test.xlsx';
        if (Platform.OS === 'android') {
          const downloadDir =
            FileSystem.StorageAccessFramework.getUriForDirectoryInRoot(
              'Download/Data'
            );
          const permission =
            await FileSystem.StorageAccessFramework.requestDirectoryPermissionsAsync(
              downloadDir
            );
          console.log({ permission });
          if (!permission.granted) {
            return false;
          }
          const destinationUri =
            await FileSystem.StorageAccessFramework.createFileAsync(
              permission.directoryUri,
              filename,
              mimeType
            );
          console.log({ destinationUri });
          // TODO: Error in SAF file: Destination '[...] /Download/SelectedDir' directory cannot be created
          // await StorageAccessFramework.copyAsync({ from: uri, to: destinationUri });
          const contents =
            await FileSystem.StorageAccessFramework.readAsStringAsync(uri);
          console.log({ contents });
          const resp =
            await FileSystem.StorageAccessFramework.writeAsStringAsync(
              destinationUri,
              contents
            );
          console.log({ resp });
          return true;
        } else if (Platform.OS === 'ios') {
          // Need to add dependancy
          // await Sharing.shareAsync(uri, {
          //   mimeType,
          //   // UTI: uti,
          // });
          return true;
        }
      }
    } catch (error) {
      console.log({ error });
    } finally {
      setLoading(false);
    }
  };

To save time of people having the same problem as me, this is my code. I hope this helps someone. Works fine on sdk41 & android 11.

import AsyncStorage from "@react-native-async-storage/async-storage";
import * as FileSystem from "expo-file-system";
import * as MediaLibrary from "expo-media-library";

static settings = async (newSettings) => {
        return new Promise(async (resolve, reject) => {
            try {
                let settings = (await AsyncStorage.getItem("settings").then((result) => JSON.parse(result))) || {};
                if (newSettings) {
                    settings = Object.assign(settings, newSettings);
                    await AsyncStorage.setItem("settings", JSON.stringify(settings));
                }
                return resolve(settings);
            } catch (e) {
                console.log("Error in settings", e);
                return resolve({});
            }
        });
    };

static getDirectoryPermissions = async (onDirectoryChange) => {
        return new Promise(async (resolve, reject) => {
            try {
                const initial = FileSystem.StorageAccessFramework.getUriForDirectoryInRoot();
                onDirectoryChange({isSelecting: true}) //For handle appStateChange and loading
                const permissions = await FileSystem.StorageAccessFramework.requestDirectoryPermissionsAsync(initial);
                this.settings({ downloadsFolder: permissions.granted ? permissions.directoryUri : null });
                // Unfortunately, StorageAccessFramework has no way to read a previously specified folder without popping up a selector.
                // Save the address to avoid asking for the download folder every time
                onDirectoryChange({downloadsFolder: permissions.granted ? permissions.directoryUri : null, isSelecting: false})
                return resolve(permissions.granted ? permissions.directoryUri : null);
            } catch (e) {
                console.log("Error in getDirectoryPermissions", e);
                onDirectoryChange({downloadsFolder: null})
                return resolve(null);
            }
        });
    };

static downloadFilesAsync = async (files, onDirectoryChange) => {
        // files = [{url: "url", fileName: "new file name" + "extension", mimeType: is_video ? "video/mp4" : "image/jpg"}]
        // onDirectoryChange = () => {cb_something_like_setState()}
        return new Promise(async (resolve, reject) => {
            try {
                const mediaLibraryPermission = await this.getMediaLibraryPermission()
                if (mediaLibraryPermission !== "granted") {
                    return resolve({status: "error"})
                }
                let settings = await this.settings();
                // Unfortunately, StorageAccessFramework has no way to read a previously specified folder without popping up a selector.
                // Save the address to avoid asking for the download folder every time
                const androidSDK = Platform.constants.Version
                
                if (Platform.OS === "android" && androidSDK >= 30 && !settings.downloadsFolder) {
                    //Except for Android 11, using the media library works stably
                    settings.downloadsFolder = await this.getDirectoryPermissions(onDirectoryChange)
                }
                const results = await Promise.all(
                    files.map(async (file) => {
                        try {
                        if (file.url.includes("https://")) {
                            // Remote file
                            const { uri, status, headers, md5 } = await FileSystem.downloadAsync(
                                file.url,
                                FileSystem.cacheDirectory + file.name
                            );
                            file.url = uri; //local file(file:///data/~~~/content.jpg)
                            // The document says to exclude the extension, but without the extension, the saved file cannot be viewed in the Gallery app.
                        }
                        if (Platform.OS === "android" && settings.downloadsFolder) {
                            // Creating files using SAF
                            // I think this code should be in the documentation as an example
                            const fileString = await FileSystem.readAsStringAsync(file.url, { encoding: FileSystem.EncodingType.Base64 });
                            const newFile = await FileSystem.StorageAccessFramework.createFileAsync(
                                settings.downloadsFolder,
                                file.name,
                                file.mimeType
                            );
                            await FileSystem.writeAsStringAsync(newFile, fileString, { encoding: FileSystem.EncodingType.Base64 });
                        } else {
                            // Creating files using MediaLibrary
                            const asset = await MediaLibrary.createAssetAsync(file.url);
                        }
                        return Promise.resolve({status: "ok"});
                    } catch (e) {
                        console.log(e)
                        return Promise.resolve({status: "error"});
                    }
                    })
                );
                return resolve({ status: results.every((result) => result.status === "ok") ? "ok" : "error" });
            } catch (e) {
                console.log("Error in downloadFilesAsync", e)
                return resolve({ status: "error" });
            }
        });
    };

Thank you soo much, this worked for me!

I have the same problem @durgesh2007 did you find any solution

Is there any progress with this? I’ve updated to expo 42 and the issue is still here.

Hi- can someone share a Snack link that we can use to reproduce this error?