Android Scoped Storage Adaptation: A Detailed Guide
Android Scoped Storage Adaptation: A Detailed Guide
Introduction
Starting with Android 10 (API 29), Google introduced Scoped Storage, a fundamental shift in how apps access shared external storage. Android 11 (API 30) enforced it further, and Android 13 (API 33) added the photo picker and granular media permissions. For long-term app stability and compliance with Google Play policies, every Android developer must understand and adapt to these storage access rules.
This article provides a deep dive into Scoped Storage: what changed, why, how to read/write media files, how to access non-media documents, how to share files between apps, and how to migrate legacy codebases cleanly.
1. The Legacy Model: Why It Changed
1.1 Pre-Android 10 Behavior
Before Scoped Storage, an app holding the READ_EXTERNAL_STORAGE (and optionally WRITE_EXTERNAL_STORAGE) permission could freely traverse the entire shared external storage (/sdcard/). Apps commonly:
- Created arbitrary folders at the root of external storage (e.g.,
/sdcard/MyApp/). - Listed and read files belonging to other apps.
- Wrote anywhere, including over other apps’ data.
1.2 Problems
- Privacy leakage: A file manager or any app could read photos, downloads, and documents created by other apps without any meaningful consent flow.
- Clutter: Every app dumped its own top-level directory, making user-visible storage messy.
- Uninstall residue: App-specific folders in shared storage were not cleaned up on uninstall.
- Security ambiguity: A single coarse permission granted access to all media types at once.
Scoped Storage addresses these issues by giving each app an isolated, app-specific directory and restricting access to shared collections.
2. Core Concepts of Scoped Storage
2.1 App-Specific Directories
Each app gets private directories on external storage that require no permission to access:
Context.getExternalFilesDir(null)→/sdcard/Android/data/<package>/files/Context.getExternalCacheDir()→/sdcard/Android/data/<package>/cache/
These directories are wiped automatically on uninstall. Use them for files your app owns and that should not appear in the user’s gallery.
2.2 Shared Media Collections
Media files (images, videos, audio) are exposed through MediaStore content providers, organized by collection:
MediaStore.Images.Media.EXTERNAL_CONTENT_URIMediaStore.Video.Media.EXTERNAL_CONTENT_URIMediaStore.Audio.Media.EXTERNAL_CONTENT_URI
Access to these collections is gated by granular media permissions (Android 13+).
2.3 Documents and Other Files
Non-media files (PDFs, ZIPs, custom formats) in shared storage are accessed via SAF (Storage Access Framework) — the system document picker (ACTION_OPEN_DOCUMENT, ACTION_CREATE_DOCUMENT). There is no broad filesystem access for these.
3. Permission Model Across Android Versions
3.1 Version-by-Version Summary
| Android Version | API | Behavior |
|---|---|---|
| 9 and below | ≤28 | Legacy storage: full access with READ/WRITE_EXTERNAL_STORAGE. |
| 10 | 29 | Scoped Storage introduced; opt-out via requestLegacyExternalStorage="true" in manifest. |
| 11 | 30 | Scoped Storage enforced; legacy flag ignored. Broad access no longer possible. |
| 12 | 31 | Same enforcement; minor API refinements. |
| 13 | 33 | Granular media permissions replace READ_EXTERNAL_STORAGE for media. |
| 14 | 34 | Additional constraints on access to media metadata in some cases. |
3.2 Android 13+ Granular Permissions
Instead of one READ_EXTERNAL_STORAGE, you now request:
READ_MEDIA_IMAGESREAD_MEDIA_VIDEOREAD_MEDIA_AUDIO
If your app only needs to read images, request only READ_MEDIA_IMAGES. The system grants only what is requested, and the user sees a more meaningful permission dialog.
3.3 Declaring Permissions
<manifest ...>
<!-- Android 13+ granular media permissions -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<!-- For apps targeting <= API 32, fall back to the legacy permission -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<!-- Only needed if you write media that is not your own -->
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
</manifest>
3.4 Runtime Permission Request (Kotlin)
import android.Manifest
import android.os.Build
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.Fragment
class MediaPermissionFragment : Fragment() {
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { result ->
val allGranted = result.values.all { it }
if (allGranted) {
// proceed to load media
} else {
// show rationale or deny UI
}
}
fun requestMediaPermissions() {
val permissions = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> arrayOf(
Manifest.permission.READ_MEDIA_IMAGES,
Manifest.permission.READ_MEDIA_VIDEO,
Manifest.permission.READ_MEDIA_AUDIO
)
else -> arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE)
}
permissionLauncher.launch(permissions)
}
}
4. Reading Media Files via MediaStore
4.1 Querying the Images Collection
import android.content.ContentUris
import android.provider.MediaStore
import android.content.Context
import android.net.Uri
data class ImageItem(val id: Long, val displayName: String, val uri: Uri)
fun loadImages(context: Context): List<ImageItem> {
val collection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
} else {
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
}
val projection = arrayOf(
MediaStore.Images.Media._ID,
MediaStore.Images.Media.DISPLAY_NAME
)
val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} DESC"
val items = mutableListOf<ImageItem>()
context.contentResolver.query(collection, projection, null, null, sortOrder)?.use { cursor ->
val idCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
val nameCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)
while (cursor.moveToNext()) {
val id = cursor.getLong(idCol)
val name = cursor.getString(nameCol)
val uri = ContentUris.withAppendedId(collection, id)
items.add(ImageItem(id, name, uri))
}
}
return items
}
4.2 Opening the File Content
Once you have the Uri, open it via ContentResolver — never attempt to convert it to a raw filesystem path.
fun loadBitmap(context: Context, uri: Uri): Bitmap? {
return context.contentResolver.openInputStream(uri)?.use { input ->
BitmapFactory.decodeStream(input)
}
}
4.3 Why Not Use File Paths?
On Android 10+ the DATA column of MediaStore (which historically held the file path) is either empty or inaccessible for files not owned by your app. Relying on file paths will break. Always use Uri + ContentResolver.
5. Writing Media Files via MediaStore
5.1 Inserting a New Image
import android.content.ContentValues
import android.os.Build
import android.provider.MediaStore
import java.io.OutputStream
fun saveImageToGallery(
context: Context,
displayName: String,
mimeType: String,
relativePath: String = "Pictures/MyApp",
writeBytes: (OutputStream) -> Unit
): Uri? {
val collection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
} else {
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
}
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, displayName)
put(MediaStore.Images.Media.MIME_TYPE, mimeType)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.Images.Media.RELATIVE_PATH, relativePath)
put(MediaStore.Images.Media.IS_PENDING, 1)
}
}
val uri = context.contentResolver.insert(collection, values) ?: return null
context.contentResolver.openOutputStream(uri)?.use { out ->
writeBytes(out)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
values.clear()
values.put(MediaStore.Images.Media.IS_PENDING, 0)
context.contentResolver.update(uri, values, null, null)
}
return uri
}
5.2 The IS_PENDING Pattern
On Android 10+, set IS_PENDING = 1 before writing, then flip it to 0 once the file is complete. While pending, the file is invisible to other apps — preventing half-written files from showing up in the gallery.
5.3 Writing on Android 9 and Below
For older versions, write to a path under Environment.getExternalStoragePublicDirectory(DIRECTORY_PICTURES). This requires WRITE_EXTERNAL_STORAGE and is the legacy path that Scoped Storage replaces.
6. App-Specific Storage (No Permission Required)
6.1 When to Use It
Use app-specific directories for files that:
- Are internal to your app (caches, downloaded assets, exported reports the user does not need to browse manually).
- Should be removed when the app is uninstalled.
- Do not need to appear in the system gallery or file picker.
6.2 Writing to App-Specific Storage
import java.io.File
fun writeAppFile(context: Context, fileName: String, content: ByteArray): File {
val dir = context.getExternalFilesDir(null) ?: File(context.filesDir, "external").apply { mkdirs() }
val file = File(dir, fileName)
file.writeBytes(content)
return file
}
No permission is required. The path resolves to /sdcard/Android/data/<package>/files/.
6.3 Cache Directory
val cacheFile = File(context.externalCacheDir, "temp.tmp")
Files in the cache directory can be evicted by the system under storage pressure; do not store anything irreplaceable here.
7. Storage Access Framework (SAF) for Documents
7.1 Opening a Document
import android.content.Intent
import androidx.activity.result.contract.ActivityResultContracts
private val openDocLauncher = registerForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri: Uri? ->
uri?.let {
// persist permission to access it later
requireActivity().contentResolver.takePersistableUriPermission(
it, Intent.FLAG_GRANT_READ_URI_PERMISSION
)
// read the document
}
}
fun pickPdf() {
openDocLauncher.launch(arrayOf("application/pdf"))
}
7.2 Creating a Document
private val createDocLauncher = registerForActivityResult(
ActivityResultContracts.CreateDocument("text/plain")
) { uri: Uri? ->
uri?.let {
requireActivity().contentResolver.openOutputStream(it)?.use { out ->
out.write("Hello SAF".toByteArray())
}
}
}
fun createNote() {
createDocLauncher.launch("note.txt")
}
7.3 Persisting Uri Permissions
By default, the URI permission granted by SAF expires when the process dies. To retain access across restarts, call takePersistableUriPermission and store the URI string (e.g., in SharedPreferences or Room). On the next launch, decode the string and use it directly.
8. The Android 13 Photo Picker
8.1 Overview
Android 13 introduced a system Photo Picker — a standardized UI for selecting photos and videos that requires no permission at all. It is the recommended way to let users pick media for in-app use (avatars, attachments, etc.).
8.2 Launching the Picker
import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia
private val pickerLauncher = registerForActivityResult(
ActivityResultContracts.PickVisualMedia()
) { uri: Uri? ->
uri?.let { /* use the selected media */ }
}
fun pickSingleImage() {
pickerLauncher.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly))
}
8.3 Multiple Selection
private val multiPickerLauncher = registerForActivityResult(
ActivityResultContracts.PickMultipleVisualMedia(10)
) { uris: List<Uri> ->
uris.forEach { /* process each */ }
}
8.4 Backporting
The Photo Picker is backported via Google Play services to devices running Android 4.4 (API 19) and later, so you can use it broadly without waiting for OS upgrades.
9. Sharing Files Between Apps
9.1 FileProvider
To share a file (in your app-specific storage or cache) with another app, expose it via FileProvider. Never pass raw file:// URIs on Android 7+.
Manifest:
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
res/xml/file_paths.xml:
<paths>
<external-files-path name="app_files" path="." />
<cache-path name="app_cache" path="." />
</paths>
Sharing:
import androidx.core.content.FileProvider
fun shareImage(context: Context, file: File) {
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "image/jpeg"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, "Share image"))
}
10. Opting Out of Scoped Storage (Android 10 Only)
10.1 The Legacy Flag
On Android 10, you can temporarily opt out by setting in AndroidManifest.xml:
<application
android:requestLegacyExternalStorage="true"
...>
10.2 When Is This Acceptable?
- As a short-term migration aid while you refactor to MediaStore/SAF.
- Only on apps targeting API 29.
10.3 When Does It Stop Working?
On Android 11+, the flag is ignored if your app targets API 30 or higher. The system grants legacy behavior only if the app was previously installed on a pre-Android 11 device and is being upgraded — and even then, only for files in the app’s own legacy directories. New installs get full Scoped Storage regardless.
Conclusion: Treat the flag as a one-release bridge, not a long-term solution.
11. Migration Strategy for Existing Apps
11.1 Audit Your File Access
Search the codebase for:
Environment.getExternalStorageDirectory()new File("/sdcard/...")MediaStore.MediaColumns.DATA(the file path column)- Hard-coded paths in native code (NDK).
Each of these is a Scoped Storage breakage waiting to happen.
11.2 Reclassify Each File Use
For every file operation, decide which bucket it belongs to:
| Use Case | Solution |
|---|---|
| App-only cache / temp files | getExternalCacheDir() |
| App-owned user-visible files | getExternalFilesDir() (uninstall-cleaned) |
| Photos/videos the user expects in Gallery | MediaStore insert with RELATIVE_PATH |
| Reading user-selected documents | SAF (ACTION_OPEN_DOCUMENT) |
| Picking a photo for in-app use | Photo Picker |
| Sharing a file with another app | FileProvider |
11.3 Replace Paths with URIs
Anywhere you currently pass String path or File, switch to Uri and ContentResolver. This is the single most impactful refactor.
11.4 Handle Backward Compatibility
Branch on Build.VERSION.SDK_INT:
- API ≥ 33: granular media permissions, Photo Picker.
- API 29–32: MediaStore with
RELATIVE_PATH,IS_PENDING. - API ≤ 28: legacy
Environment.getExternalStoragePublicDirectory()withREAD/WRITE_EXTERNAL_STORAGE.
Wrap these branches in a StorageRepository so the rest of the app is version-agnostic.
12. Common Pitfalls
- Using
MediaStore.MediaColumns.DATA: Returns null or unusable paths on Android 10+. Always use the_ID+ContentUris.withAppendedIdpattern. - Forgetting
IS_PENDING: Other apps see half-written files in the gallery. Always toggle pending state. - Requesting
READ_EXTERNAL_STORAGEon Android 13+: It is ignored. Use granular media permissions. - Calling
Environment.getExternalStorageDirectory(): Deprecated and returns a path you can no longer write to. UsegetExternalFilesDir()or MediaStore. - Passing
file://URIs to other apps: TriggersFileUriExposedException. UseFileProvider. - Not persisting SAF URI permissions: The URI becomes unusable after process death. Call
takePersistableUriPermission. - Writing to
Pictures/without MediaStore: Direct file writes to shared media directories are rejected on Android 10+ unless you own the file. Use MediaStore insert. - Assuming the Photo Picker requires permission: It does not. Drop permission requests when using it.
13. Testing Storage Code
13.1 Robolectric
Robolectric supports ContentResolver queries against MediaStore in unit tests, though support for media volumes is limited. Prefer instrumentation tests for full fidelity.
13.2 Instrumentation Tests
Use ApplicationProvider.getApplicationContext() and write real files to getExternalFilesDir(). For MediaStore, insert a test image, verify it appears in queries, then delete it in @After.
13.3 Testing Across Versions
Run the same test suite on emulators at API 28, 29, 30, 33, and 34. Storage behavior differs at each boundary, and the only reliable way to catch regressions is to run on real API levels.
14. Best Practices Summary
- Use
Uri+ContentResolvereverywhere; abandon raw file paths for shared storage. - Default to app-specific storage for files the user does not need to browse.
- Use MediaStore for media the user expects to see in Gallery.
- Use SAF for arbitrary user-selected documents.
- Use the Photo Picker for in-app media selection — no permission needed.
- Request granular permissions on Android 13+; fall back to legacy on older versions.
- Use
FileProviderto share files with other apps. - Branch on
Build.VERSION.SDK_INTand encapsulate the logic in a repository. - Do not rely on
requestLegacyExternalStoragebeyond a single migration release. - Test on multiple API levels — storage behavior changes at every recent boundary.
Conclusion
Scoped Storage is not a temporary restriction — it is the new permanent model for external storage on Android. Fighting it with legacy flags or path hacks will only delay breakage and risk Play Store rejection. The correct path forward is to embrace MediaStore for media, SAF for documents, app-specific directories for private data, the Photo Picker for user selection, and FileProvider for sharing.
Adapt early, classify every file operation deliberately, and encapsulate version differences behind a clean repository abstraction. Doing so future-proofs your app against the next storage changes Google introduces — and there will be more.
Written as a technical reference for Android developers adapting their apps to modern storage rules.
- 点赞
- 收藏
- 关注作者
评论(0)