DEV Community

Robin for Capawesome

Posted on Originally published at capawesome.io

Android Scoped Storage in Capacitor Apps: What Still Works

Directory.ExternalStorage in @capacitor/filesystem is documented as inaccessible on Android 11 and newer, and no permission brings it back. Code that wrote a PDF into Download/ in 2020 still compiles and fails at runtime on every current device. That is not a plugin bug, it is the storage model Android enforces for every app that targets Android 11 (API level 30) or higher.

This is the condensed version of our longer write-up, Android Scoped Storage in Capacitor Apps, Explained. One disclosure first. The Capacitor File Manager plugin in the code samples is ours, and it is part of Capawesome Insiders, a paid subscription. The Android behavior below applies either way.

What Android changed, and when

Scoped storage limits an app to its own directory on external storage plus the media files it created itself. Android made it the default for apps that target Android 10 (API level 29) and higher, so an app can no longer walk the shared volume and read whatever it finds there.

Android 10 still allowed an opt-out. An app could set android:requestLegacyExternalStorage="true" in its manifest and keep the old behavior. Android 11 (API level 30) ignores that flag as soon as your app targets Android 11, and the same page states that both WRITE_EXTERNAL_STORAGE and the privileged WRITE_MEDIA_STORAGE permission stop providing additional access at that target level.

You cannot sit this out. Since 31 August 2026, Google Play requires new apps and app updates to target Android 16 (API level 36) or higher, six releases past the point where the legacy flag became dead configuration.

The Download directory is the one everybody asks about. Android 11 removed it from what the Storage Access Framework hands out. The system picker still lists it, but Google's own test instructions describe the expected result as the directory showing up with "the action button associated with the directory grayed out". The internal storage root and the roots of reliable SD card volumes are blocked the same way. For a file that has to land there, use the MediaStore.Downloads collection instead.

What still works in @capacitor/filesystem

Most of the plugin is unaffected. Scoped storage restricts shared storage, not the app sandbox, so app-specific directories behave as before Android 10 and need no permission.

Directory Status on Android 11 and newer
Directory.Data Works. Maps to getFilesDir(), private to the app.
Directory.Cache Works. Maps to getCacheDir(), may be reclaimed by the system.
Directory.Library Works. Maps to getFilesDir() on Android.
Directory.External Works. App-specific directory on the shared volume.
Directory.ExternalCache Works. App-specific cache on the shared volume.
Directory.Documents Partly. Your app only sees files and folders it created itself.
Directory.ExternalStorage Gone. Documented as not accessible on Android 11 or newer.

Two issues in ionic-team/capacitor-filesystem have tracked this gap for years without a resolution. #28, asking the plugin to accommodate scoped storage, was opened on 30 December 2020. #37 asks which Directory member to use for which purpose now that half the enum behaves differently per Android version. Until one of them lands, treat @capacitor/filesystem as a sandbox API that stops at the boundary of shared storage.

Why MANAGE_EXTERNAL_STORAGE is the wrong fix

MANAGE_EXTERNAL_STORAGE is the "all files access" permission Android 11 introduced, and it does restore broad access to shared storage. It is also the declaration most likely to stall a Play Store review. Google Play has evaluated apps that declare it under a dedicated policy since May 2021, and Google's guidance is to request it only when the app cannot do its job through the Storage Access Framework or the MediaStore API.

The permitted uses are narrow and tied to core functionality. File managers, backup tools, anti-virus apps, and similar categories where browsing arbitrary files is the product. An expense app that drops receipts into a folder is not in that group, and reviewers treat document management as a use case the Storage Access Framework already covers. The permission does not widen the Storage Access Framework either, so it buys you nothing there.

How the Storage Access Framework replaces shared paths

The Storage Access Framework turns "give me a path" into "let the user grant me a folder". Your app fires an ACTION_OPEN_DOCUMENT_TREE intent, available since Android 5.0 (API level 21), and the user picks a directory in the system picker. What comes back is a tree URI covering that directory and everything beneath it, and nothing else.

That grant expires when the device restarts. Calling takePersistableUriPermission() with the read and write flags makes it survive reboots and app restarts, which turns a one-off picker result into a folder your app keeps using. Android drops it again if the document behind it is moved or deleted, and the user then has to pick a folder again.

Nothing in this flow needs a permission in your manifest. The user's choice in the system picker is the permission.

Persisting a folder with the File Manager plugin

The Capacitor File Manager plugin puts that flow behind two methods, so you never touch a tree URI or a permission flag yourself. Let the user pick a directory with pickDirectory() from the Capacitor File Picker plugin, then pass the result to persistDirectoryAccess(...).

import { FileManager } from '@capawesome-team/capacitor-file-manager';
import { FilePicker } from '@capawesome/capacitor-file-picker';

const pickExportFolder = async () => {
  const result = await FilePicker.pickDirectory();
  const { directory } = await FileManager.persistDirectoryAccess({
    uri: result.path,
    bookmark: result.bookmark,
  });
  return directory;
};
Enter fullscreen mode Exit fullscreen mode

The bookmark value is the iOS half of the same idea, a security-scoped bookmark. Android ignores it, because the persisted tree URI already carries the grant.

Do not store the returned URI yourself, because it can change between app launches. Read the current list on startup with getPersistedDirectories() instead, which drops directories whose document no longer exists. Every method of the plugin accepts a persisted URI. Build the URI of a file inside the folder with getUri(...) by passing parentUri instead of directory, then copy, move or read as usual.

import { Directory, FileManager } from '@capawesome-team/capacitor-file-manager';

const exportReport = async (directoryUri: string) => {
  const { uri: sourceUri } = await FileManager.getUri({
    path: 'report.pdf',
    directory: Directory.Cache,
  });
  const { uri: targetUri } = await FileManager.getUri({
    path: 'exports/report.pdf',
    parentUri: directoryUri,
  });
  const { uri } = await FileManager.copyFile({ uri: sourceUri, toUri: targetUri });
  return uri;
};
Enter fullscreen mode Exit fullscreen mode

Two things to watch. Constructing the URI of an entry that does not exist yet works only for path-structured document providers such as local device storage, so a cloud provider mounted into the picker may reject it. And copyFile(...) returns the URI of the created file, which differs from the one you requested if the provider renamed the file to avoid a collision.

Which directory or API should you use?

Pick by who owns the file and who needs to see it, not by which directory name sounds closest to the old one.

What you need What to use
App data that must survive updates and stay private Directory.Data
Files you can regenerate at any time Directory.Cache
Large app-owned files on the shared volume Directory.External
A user-visible folder your app keeps using across launches pickDirectory() plus persistDirectoryAccess(...)
A single file the user selects once pickFiles(...) from the File Picker plugin
Photos, videos or audio in the gallery MediaStore collections (platform API)
A download the user finds in Download/ The MediaStore.Downloads collection
Browsing arbitrary files across the device MANAGE_EXTERNAL_STORAGE, only as core functionality

Conclusion

If your app writes outside its own sandbox on Android, stop hunting for a directory constant that still works. Grep the project for Directory.ExternalStorage and Directory.Documents, move those call sites to a picked and persisted directory, and keep Directory.Data and Directory.Cache for everything the user never has to see. That split holds for every Android version you still support, so you do the work once.

The full guide goes deeper on the permission history, and our Capacitor file handling guide covers reading, writing and sharing files without running out of memory. If your case does not fit a row of the table above, drop it in the comments.

Top comments (0)