File system access is an essential part of making an application. Up until Android 10 any app could request file storage permissions and have access to all files stored on a user's device even if they needed access to just one folder. This was a huge security issue which was exploited time and again. To fix this, Google introduced Scoped Storage API in Android 10 --- a way for apps to access only the folders required with user's explicit permission.
Since the release of Android 10, Google has made Scoped Storage the default way to access files on an Android device but up until Android 11, it was possible to override this by putting android:requestLegacyExternalStorage="true"
in AndroidManifest.xml
.
However, this is changing fast since the start of 2021. Google is enforcing all apps to use Scoped Storage to store or read files on a user's device. Some apps can still get all files access if the pass this criteria.
How scoped storage works?
Scoped Storage API gives user full control over which app access which directories. Whenever an app needs to write files on user's device, the user must explicitly grant permission to store files to that specific folder. The app can then read/write only to that directory and it's subdirectories.
Android Scoped Storage for React Native
At the start of 2021 I came across Google's policy update:
Apps that run on Android 11 but target Android 10 (API level 29) can still request the
requestLegacyExternalStorage
attribute. This flag allows apps to temporarily opt out of the changes associated with scoped storage, such as granting access to different directories and different types of media files. After you update your app to target Android 11, the system ignores therequestLegacyExternalStorage
flag.
Since Notesnook requires access to device storage for storing backups, note attachments, recovery keys etc., it was obvious that we will have to migrate away from legacy storage on Android sooner or later. Being proactive, I did a quick search on Google and Github expecting to find someone having already fixed the issue. Suffice it to say, I was disappointed --- reading/writing files is already a hassle in React Native and with this upcoming change, it'll only get harder. In any case, I took the matter into my own hands and created react-native-scoped-storage.
react-native-scoped-storage makes it incredibly easy to access the Scoped Storage APIs in Android. If you want a quick way to migrate from legacy storage to Scoped Storage, this library is currently the only way in React Native.
In any case, let's see how the Scoped Storage API works and how you can use it in your own apps.
Getting permissions to read/write files
Goals:
- Ask the user to give us permission to a folder where we can store files without interruption.
- We only want to do this once; not every session.
With the goals in mind, first step is to request permssion to a directory in user's phone:
import * as ScopedStorage from 'react-native-scoped-storage';
import AsyncStorage from '@react-native-async-storage/async-storage';
let dir = await ScopedStorage.openDocumentTree(true);
This does all the heavy lifting for you. Running the app, you'll see something similar to this:
By default, Android limits the number of directories an app can get access to. To avoid hitting that limit, we will store the information about this directory in AsyncStorage
for later use.
// ....
if (dir) {
// Save information about the directory in AsyncStorage.
await AsyncStorage.setItem('userDataDirectory', JSON.stringify(dir));
}
Permissions for this directory are now persisted. If we want to read/write a file later, we can simply fetch information about the directory from AsyncStorage
like so:
let dir = await AsyncStorage.getItem('userDataDirectory');
if (dir) dir = JSON.parse(dir);
This works for most cases but the system can revoke these permissions at anytime. To be safe, we should first check if we still have permission before doing anything:
const persistedUris = await ScopedStorage.getPersistedUriPermissions(); // List of all uris where the app has access to read/write
if (persistedUris.indexOf(dir.uri) !== -1) {
// Write our file
} else {
let dir = await ScopedStorage.openDocumentTree(true);
// Store the new directory data in Async Storage then save our file
}
Summing it all up:
import * as ScopedStorage from 'react-native-scoped-storage';
import AsyncStorage from '@react-native-async-storage/async-storage';
async function requestPermission(directoryId: string) {
dir = await ScopedStorage.openDocumentTree(true);
if (!dir) return null; // User cancelled
await AsyncStorage.setItem(directoryId, JSON.stringify(dir));
return dir;
}
export async function getAndroidDir(directoryId: string) {
try {
let dir = await AsyncStorage.getItem(directoryId); // Check if dir exists already
if (!dir) dir = await requestPermission(id);
// request new permissions & save the dir;
else dir = JSON.parse(dir);
const persistedUris = await ScopedStorage.getPersistedUriPermissions(); // list all persisted uris
if (persistedUris.indexOf(dir.uri) !== -1) return dir; // Verify we still have permission
return await requestPermission(id); // request new permissions & save the dir;
} catch (e) {
console.log(e);
return null;
}
}
Revoking permissions to a specific folder
Most apps don't need to revoke permissions once granted but if you need that, there's an API for that as well:
await ScopedStorage.releasePersistableUriPermission(dir.uri);
Directories you can no longer access
As usualy, there are some directories you cannot get permission to read/write in:
- The root directory of the internal storage volume.
- The root directory of each SD card volume that the device manufacturer considers to be reliable, regardless of whether the card is emulated or removable. A reliable volume is one that an app can successfully access most of the time.
- The root of the Download directory. (You can get permission to a subfolder inside Download).
source: developer.android.com
Writing a file in background
Most often apps require access to the file system to store things in background without disturbing the user. If you have permissions to a directory, this is as simple as doing:
let text = 'hello world';
await ScopedStorage.writeFile(dir.uri, 'helloworld.txt', 'text/plain', text);
Asking user to save a file
You can save a file direcly after requesting permissions to a folder. However, in some cases you just need to save a file and its great if user can decide (and remember) where it is stored. An example use case is saving a file to Downloads folder or root of the app.
let file = await ScopedStorage.createDocument(
'helloworld.txt',
'text/plain',
'hello world'
);
The best thing about Scoped Storage API is that it makes it possible for a user to give permissions to Cloud directories making it possible for any app to sync it's data to the Cloud without any external dependency.
Asking user to select a file
We can also select a file from storage and read it's contents with scoped storage.
let file = await ScopedStorage.openDocument();
Conclusion
Prior to Android 10 it was really simple to read/write data on user's device. However due to policy changes by Google, we will need migrate our apps to use Scoped Storage so that we can prevent our apps from a broken user experience. However this access will be limited to specific folders on user's device.
You can play around with the exaxmple app and checkout the complete documentation of the library on Github repo.