Mobile Development 22 min read

Understanding and Adapting Scoped Storage in Android 10+

This article explains Android's scoped storage model introduced in Android 10, details how it changes file access, provides migration strategies for new and legacy data, and includes practical code examples for handling media files, legacy storage flags, and common file‑operation errors across Android versions.

Sohu Tech Products
Sohu Tech Products
Sohu Tech Products
Understanding and Adapting Scoped Storage in Android 10+

Scoped Storage Concept

To give users better control over their files and reduce clutter, Android 10 introduced a new storage paradigm called scoped storage. Apps targeting API level 29 or higher are, by default, granted partitioned access to external storage, which fundamentally changes how files are stored and accessed on external media.

Scoped storage also improves privacy by limiting apps to specific directories without requiring any storage‑related permissions. Apps can access files in their app‑specific external directory via getExternalFilesDir() and shared media (photos, videos, audio) via the MediaStore API without requesting storage permissions.

Adapting to Scoped Storage

Why Adapt

Under scoped storage, the public area of external storage (e.g., the SD card root) is inaccessible; attempts to read or write there will throw errors. Disabling scoped storage is only possible by keeping the app’s targetSdkVersion below 29, which is unrealistic for modern apps. Even with targetSdkVersion 29, the model can be disabled only for legacy installs or updates, and Android 12 will enforce scoped storage for all apps targeting API 31.

How to Adapt

Adaptation consists of two parts: handling new data and migrating old data.

Storing New Data

Identify which data is private (store in the app‑specific external directory) and which data is shared (store via MediaStore). The following code shows how to save a shared video using MediaStore, which is the recommended approach for Android 10 and above.

/**
 * Save a shared media resource. Must first create a Uri in MediaStore that represents the video.
 * This is the official recommendation for scoped storage because direct File access is prohibited.
 */
static Uri getSaveToGalleryVideoUri(Context context, String videoName, String mimeType, String subDir) {
    ContentValues values = new ContentValues();
    values.put(MediaStore.Video.Media.DISPLAY_NAME, videoName);
    values.put(MediaStore.Video.Media.MIME_TYPE, mimeType);
    values.put(MediaStore.Video.Media.DATE_MODIFIED, System.currentTimeMillis() / 1000);
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        values.put(MediaStore.Video.Media.RELATIVE_PATH, Environment.DIRECTORY_MOVIES + subDir);
    }
    Uri uri = context.getContentResolver().insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values);
    printMediaInfo(context, uri);
    return uri;
}

After obtaining the Uri, write the video data to it. The system automatically places the file in the appropriate /Movies directory.

For devices below API 29, the same method works, but developers often fall back to direct file paths. The code below demonstrates a version‑aware approach that first tries the MediaStore Uri and, if unavailable, falls back to a direct path while still registering the file with MediaStore using the deprecated DATA column.

public static FileOutputStream getSaveToGalleryVideoOutputStream(@NonNull Context context, @NonNull String videoName, @NonNull String mimeType) throws FileNotFoundException {
    Uri uri = SHScopedStorageManager.querySpecialVideoUri(context, videoName);
    if (uri != null) {
        ParcelFileDescriptor pfd = context.getContentResolver().openFileDescriptor(uri, "w");
        return new FileOutputStream(pfd.getFileDescriptor());
    }
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        uri = getSaveToGalleryVideoUri(context, videoName, mimeType);
        if (uri == null) return null;
        ParcelFileDescriptor pfd = context.getContentResolver().openFileDescriptor(uri, "w");
        return new FileOutputStream(pfd.getFileDescriptor());
    } else {
        // Build a direct file path for older versions
        if (TextUtils.isEmpty(videoName)) {
            videoName = String.valueOf(System.currentTimeMillis());
        }
        if (!TextUtils.isEmpty(mimeType)) {
            String extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
            videoName = videoName.contains(".") ? videoName.substring(0, videoName.lastIndexOf('.')) + "." + extension : videoName + "." + extension;
        }
        String rootPath = getSaveToGalleryVideoPath();
        String videoPath = rootPath.endsWith(File.separator) ? rootPath + videoName : rootPath + File.separator + videoName;
        ContentValues values = new ContentValues();
        values.put(MediaStore.Video.Media.DISPLAY_NAME, videoName);
        values.put(MediaStore.Video.Media.MIME_TYPE, mimeType);
        values.put(MediaStore.Video.Media.DATA, videoPath);
        values.put(MediaStore.Video.Media.DATE_MODIFIED, System.currentTimeMillis() / 1000);
        uri = context.getContentResolver().insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values);
        if (uri == null) return null;
        SHScopedStorageManager.printMediaInfo(context, uri);
        ParcelFileDescriptor pfd = context.getContentResolver().openFileDescriptor(uri, "w");
        return new FileOutputStream(pfd.getFileDescriptor());
    }
}

This unified API works on both legacy and scoped storage models, ensuring shared videos are visible to the gallery and other apps.

Migrating Old Data

When an app switches to scoped storage, previously stored files in public external directories become inaccessible. Migration moves those files into locations compatible with scoped storage. The article outlines two migration scenarios:

Shared data migration: move videos from a custom folder (e.g., /storage/emulated/0/shvdownload/video/VideoGallery ) to the public /Movies/SHVideo directory.

Private data migration: move app‑specific data from a generic external folder to /Android/data/ package /files/data . On Android 11, Files.move may fail with DirectoryNotEmptyException , so a copy‑then‑delete strategy is recommended.

java.nio.file.DirectoryNotEmptyException: /storage/emulated/0/xxx/data
    at sun.nio.fs.UnixCopyFile.move(UnixCopyFile.java:498)
    ...

A robust move utility checks the Android version and chooses between Files.move , File.renameTo , or a manual copy‑directory implementation.

private boolean moveData(File source, File target) {
    long start = System.currentTimeMillis();
    if (target.exists() && target.isDirectory() && (target.list() == null || target.list().length == 0)) {
        target.delete();
    }
    boolean isSuccess;
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        Path src = source.toPath();
        Path dst = target.toPath();
        if (target.exists()) {
            isSuccess = copyDir(source, target);
        } else {
            try {
                Files.move(src, dst);
                isSuccess = true;
            } catch (IOException e) {
                isSuccess = copyDir(source, target);
            }
        }
    } else {
        if (target.exists()) {
            isSuccess = copyDir(source, target);
        } else {
            isSuccess = source.renameTo(target);
        }
    }
    long end = System.currentTimeMillis();
    LogUtils.i(TAG, "moveData took " + (end - start) + "ms from " + source.getAbsolutePath() + " to " + target.getAbsolutePath());
    return isSuccess;
}

Understanding requestLegacyExternalStorage and preserveLegacyExternalStorage

requestLegacyExternalStorage was added in Android 10 to allow apps targeting API 29 to opt‑out of scoped storage on first install. preserveLegacyExternalStorage was introduced in Android 11 to keep the legacy model for apps that were already using it when they receive an update. When targetSdkVersion is 30, the former flag is ignored on Android 11, and only preserveLegacyExternalStorage can retain legacy behavior for updates; a fresh install on Android 11 always uses scoped storage.

Common Errors Accessing Public SD Card Areas Under Scoped Storage

File API

createNewFile() on a public directory throws java.io.IOException: No such file or directory when the app runs under scoped storage.

listFiles() returns null for public directories even if files exist.

FileOutputStream / FileInputStream

Instantiating streams with paths in the public area results in FileNotFoundException because the files are not accessible.

java.io.FileNotFoundException: /storage/emulated/0/xxx/SharePic/1603277403193.jpg: open failed: ENOENT
    at libcore.io.IoBridge.open(IoBridge.java:496)
    ...

References

Links to official Android documentation, best‑practice articles, and community guides on scoped storage, MediaStore usage, and data migration are provided for further reading.

Data MigrationAndroidMediaStoreScoped StorageFile APILegacy Storage
Sohu Tech Products
Written by

Sohu Tech Products

A knowledge-sharing platform for Sohu's technology products. As a leading Chinese internet brand with media, video, search, and gaming services and over 700 million users, Sohu continuously drives tech innovation and practice. We’ll share practical insights and tech news here.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.