Your app needs to pick a photo from the gallery. Or save a file that other apps can open. Or read contacts. All of these go through Android’s storage and content provider system.

The way Android handles file access has changed significantly. Scoped storage, the photo picker, and MediaStore have replaced the old “read everything on the device” approach.

In this tutorial, you will learn how to work with files, media, and content providers the modern way.

Prerequisites: You should know Compose basics and runtime permissions. Check Compose Tutorial #19: Permissions if needed.


How File Access Changed

Before Android 10, apps could read and write any file on the device with READ_EXTERNAL_STORAGE and WRITE_EXTERNAL_STORAGE. This was a privacy and security problem.

Android 10 (API 29) introduced scoped storage. Now each app has its own private directory and can only access shared media through specific APIs:

StorageAccessUse Case
App-specific (internal)Always availableDatabases, preferences, cache
App-specific (external)Always availableLarge files only your app uses
MediaStoreNeeds permissionPhotos, videos, audio shared with other apps
Storage Access FrameworkUser picks filesDocuments, PDFs, arbitrary files
FileProviderVia content URISharing your files with other apps

App-Specific Storage

Every app gets private storage that no other app can access. No permissions needed.

// Internal storage — always available
val internalFile = File(context.filesDir, "notes.txt")
internalFile.writeText("Hello from internal storage")

// Cache — system may delete when space is low
val cacheFile = File(context.cacheDir, "temp.json")
cacheFile.writeText("""{"status": "cached"}""")

// External app-specific — more space, but removable
val externalFile = File(context.getExternalFilesDir(null), "backup.db")

These files are deleted when the user uninstalls the app.


Photo Picker — The Modern Way to Pick Images

The photo picker is the recommended way to let users select photos and videos. It was introduced in Android 13 and is available on Android 11+ via Google Play services updates.

No permissions needed. The user picks what they want to share with your app.

Single Photo

@Composable
fun SinglePhotoPicker() {
    var imageUri by remember { mutableStateOf<Uri?>(null) }

    val launcher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.PickVisualMedia()
    ) { uri ->
        imageUri = uri
    }

    Column(
        modifier = Modifier.fillMaxSize().padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Button(onClick = {
            launcher.launch(
                PickVisualMediaRequest(
                    ActivityResultContracts.PickVisualMedia.ImageOnly
                )
            )
        }) {
            Text("Pick a Photo")
        }

        imageUri?.let { uri ->
            AsyncImage(
                model = uri,
                contentDescription = "Selected photo",
                modifier = Modifier
                    .fillMaxWidth()
                    .height(300.dp)
                    .padding(top = 16.dp),
                contentScale = ContentScale.Crop
            )
        }
    }
}

Multiple Photos

@Composable
fun MultiplePhotoPicker() {
    var imageUris by remember { mutableStateOf<List<Uri>>(emptyList()) }

    val launcher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = 5)
    ) { uris ->
        imageUris = uris
    }

    Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
        Button(onClick = {
            launcher.launch(
                PickVisualMediaRequest(
                    ActivityResultContracts.PickVisualMedia.ImageAndVideo
                )
            )
        }) {
            Text("Pick Photos (max 5)")
        }

        LazyVerticalGrid(
            columns = GridCells.Fixed(3),
            modifier = Modifier.padding(top = 16.dp),
            horizontalArrangement = Arrangement.spacedBy(4.dp),
            verticalArrangement = Arrangement.spacedBy(4.dp)
        ) {
            items(imageUris) { uri ->
                AsyncImage(
                    model = uri,
                    contentDescription = null,
                    modifier = Modifier
                        .aspectRatio(1f)
                        .clip(RoundedCornerShape(8.dp)),
                    contentScale = ContentScale.Crop
                )
            }
        }
    }
}

Photo Picker in Android 16

Android 16 enhances the photo picker with the ability to embed it directly into your app’s view hierarchy. This lets you build a more seamless image selection experience without launching a separate activity.


MediaStore — Accessing Shared Media

MediaStore is the database for all media files on the device. Use it when you need to browse all photos, videos, or audio files — not just pick one.

Permissions

<!-- For apps targeting Android 13+ -->
<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" />

Reading Images

class MediaRepository(private val context: Context) {

    suspend fun getImages(): List<MediaItem> = withContext(Dispatchers.IO) {
        val images = mutableListOf<MediaItem>()

        val projection = arrayOf(
            MediaStore.Images.Media._ID,
            MediaStore.Images.Media.DISPLAY_NAME,
            MediaStore.Images.Media.SIZE,
            MediaStore.Images.Media.DATE_ADDED
        )

        val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} DESC"

        context.contentResolver.query(
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
            projection,
            null,
            null,
            sortOrder
        )?.use { cursor ->
            val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
            val nameColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)
            val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE)

            while (cursor.moveToNext()) {
                val id = cursor.getLong(idColumn)
                val name = cursor.getString(nameColumn)
                val size = cursor.getLong(sizeColumn)

                val contentUri = ContentUris.withAppendedId(
                    MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id
                )

                images.add(MediaItem(id, name, size, contentUri))
            }
        }

        images
    }
}

data class MediaItem(
    val id: Long,
    val name: String,
    val size: Long,
    val uri: Uri
)

Saving an Image to MediaStore

suspend fun saveImage(context: Context, bitmap: Bitmap, fileName: String): Uri? {
    return withContext(Dispatchers.IO) {
        val contentValues = ContentValues().apply {
            put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
            put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
            put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/MyApp")
            put(MediaStore.Images.Media.IS_PENDING, 1)
        }

        val resolver = context.contentResolver
        val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)

        uri?.let {
            resolver.openOutputStream(it)?.use { stream ->
                bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream)
            }

            contentValues.clear()
            contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)
            resolver.update(it, contentValues, null, null)
        }

        uri
    }
}

The IS_PENDING flag prevents other apps from seeing the file until you finish writing it.


Storage Access Framework

When you need to let the user pick or create arbitrary files (PDFs, documents, ZIPs), use the Storage Access Framework (SAF).

Picking a Document

@Composable
fun DocumentPicker() {
    var documentName by remember { mutableStateOf<String?>(null) }

    val context = LocalContext.current
    val launcher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.OpenDocument()
    ) { uri ->
        uri?.let {
            // Read the display name from ContentResolver
            val cursor = context.contentResolver.query(uri, null, null, null, null)
            documentName = cursor?.use {
                if (it.moveToFirst()) {
                    val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
                    if (nameIndex != -1) it.getString(nameIndex) else uri.lastPathSegment
                } else null
            } ?: uri.lastPathSegment
        }
    }

    Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
        Button(onClick = {
            launcher.launch(arrayOf("application/pdf", "text/plain"))
        }) {
            Text("Pick a Document")
        }

        documentName?.let {
            Text("Selected: $it", modifier = Modifier.padding(top = 8.dp))
        }
    }
}

Creating a Document

@Composable
fun DocumentCreator() {
    val context = LocalContext.current

    val launcher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.CreateDocument("text/plain")
    ) { uri ->
        uri?.let {
            context.contentResolver.openOutputStream(it)?.use { stream ->
                stream.write("Hello from my app!".toByteArray())
            }
        }
    }

    Button(onClick = {
        launcher.launch("notes.txt")
    }) {
        Text("Create Document")
    }
}

Persisting Access

By default, SAF URIs expire when the app restarts. To keep access:

context.contentResolver.takePersistableUriPermission(
    uri,
    Intent.FLAG_GRANT_READ_URI_PERMISSION
)

FileProvider — Sharing Files With Other Apps

When your app needs to share a file with another app (like sharing an image via a messaging app), use FileProvider.

Setup

Add to AndroidManifest.xml:

<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>

Create res/xml/file_paths.xml:

<?xml version="1.0" encoding="utf-8"?>
<paths>
    <files-path name="internal_files" path="shared/" />
    <cache-path name="cache_files" path="images/" />
    <external-files-path name="external_files" path="exports/" />
</paths>

Sharing a File

fun shareFile(context: Context, file: File) {
    val uri = FileProvider.getUriForFile(
        context,
        "${context.packageName}.fileprovider",
        file
    )

    val shareIntent = Intent(Intent.ACTION_SEND).apply {
        type = "image/jpeg"
        putExtra(Intent.EXTRA_STREAM, uri)
        addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
    }

    context.startActivity(Intent.createChooser(shareIntent, "Share Image"))
}

Content Providers — Sharing Data Between Apps

Content providers are Android’s standard interface for sharing structured data between apps. You rarely create your own — but you often use system providers for contacts, calendar, and media.

Reading Contacts

class ContactsRepository(private val context: Context) {

    suspend fun getContacts(): List<Contact> = withContext(Dispatchers.IO) {
        val contacts = mutableListOf<Contact>()

        val projection = arrayOf(
            ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,
            ContactsContract.CommonDataKinds.Phone.NUMBER
        )

        context.contentResolver.query(
            ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
            projection,
            null,
            null,
            ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME + " ASC"
        )?.use { cursor ->
            val nameIndex = cursor.getColumnIndex(
                ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME
            )
            val numberIndex = cursor.getColumnIndex(
                ContactsContract.CommonDataKinds.Phone.NUMBER
            )

            while (cursor.moveToNext()) {
                contacts.add(
                    Contact(
                        name = cursor.getString(nameIndex),
                        phone = cursor.getString(numberIndex)
                    )
                )
            }
        }

        contacts
    }
}

data class Contact(val name: String, val phone: String)

Don’t forget the permission:

<uses-permission android:name="android.permission.READ_CONTACTS" />

Creating Your Own Content Provider

Create a content provider when other apps need to access your data. This is rare for most apps, but required for some integrations.

class NotesProvider : ContentProvider() {

    companion object {
        const val AUTHORITY = "com.example.app.provider"
        val CONTENT_URI: Uri = Uri.parse("content://$AUTHORITY/notes")
    }

    private lateinit var database: AppDatabase

    override fun onCreate(): Boolean {
        database = Room.databaseBuilder(
            context!!,
            AppDatabase::class.java,
            "notes.db"
        ).build()
        return true
    }

    override fun query(
        uri: Uri,
        projection: Array<out String>?,
        selection: String?,
        selectionArgs: Array<out String>?,
        sortOrder: String?
    ): Cursor? {
        val db = database.openHelper.readableDatabase
        val orderBy = sortOrder ?: "created_at DESC"
        val whereClause = if (selection != null) "WHERE $selection" else ""
        val cursor = db.query(
            "SELECT * FROM notes $whereClause ORDER BY $orderBy"
        )
        cursor.setNotificationUri(context!!.contentResolver, uri)
        return cursor
    }

    override fun insert(uri: Uri, values: ContentValues?): Uri? {
        val db = database.openHelper.writableDatabase
        val id = db.insert("notes", SQLiteDatabase.CONFLICT_REPLACE, values!!)
        context!!.contentResolver.notifyChange(uri, null)
        return ContentUris.withAppendedId(uri, id)
    }

    override fun getType(uri: Uri): String = "vnd.android.cursor.dir/vnd.example.notes"

    override fun delete(uri: Uri, selection: String?, args: Array<out String>?): Int = 0
    override fun update(uri: Uri, values: ContentValues?, sel: String?, args: Array<out String>?): Int = 0
}

Declare it in the manifest:

<provider
    android:name=".provider.NotesProvider"
    android:authorities="com.example.app.provider"
    android:exported="true"
    android:readPermission="com.example.app.READ_NOTES" />

Download Manager

For downloading large files, use Android’s built-in DownloadManager:

fun downloadFile(context: Context, url: String, fileName: String) {
    val request = DownloadManager.Request(Uri.parse(url)).apply {
        setTitle("Downloading $fileName")
        setDescription("Please wait...")
        setNotificationVisibility(
            DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED
        )
        setDestinationInExternalPublicDir(
            Environment.DIRECTORY_DOWNLOADS,
            fileName
        )
        setAllowedOverMetered(true)
    }

    val downloadManager = context.getSystemService(DownloadManager::class.java)
    downloadManager.enqueue(request)
}

DownloadManager handles retries, shows a notification, and works even if your app is killed.


Here is a complete image gallery screen that combines the photo picker with MediaStore:

@HiltViewModel
class GalleryViewModel @Inject constructor(
    private val mediaRepository: MediaRepository
) : ViewModel() {

    private val _images = MutableStateFlow<List<MediaItem>>(emptyList())
    val images: StateFlow<List<MediaItem>> = _images.asStateFlow()

    private val _isLoading = MutableStateFlow(false)
    val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()

    init {
        loadImages()
    }

    fun loadImages() {
        viewModelScope.launch {
            _isLoading.value = true
            _images.value = mediaRepository.getImages()
            _isLoading.value = false
        }
    }
}

@Composable
fun GalleryScreen(viewModel: GalleryViewModel = hiltViewModel()) {
    val images by viewModel.images.collectAsStateWithLifecycle()
    val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()

    val addPhotoLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.PickVisualMedia()
    ) { uri ->
        if (uri != null) {
            viewModel.loadImages() // Refresh the gallery
        }
    }

    Scaffold(
        floatingActionButton = {
            FloatingActionButton(
                onClick = {
                    addPhotoLauncher.launch(
                        PickVisualMediaRequest(
                            ActivityResultContracts.PickVisualMedia.ImageOnly
                        )
                    )
                }
            ) {
                Icon(Icons.Default.Add, contentDescription = "Add photo")
            }
        }
    ) { padding ->
        if (isLoading) {
            Box(
                modifier = Modifier.fillMaxSize().padding(padding),
                contentAlignment = Alignment.Center
            ) {
                CircularProgressIndicator()
            }
        } else {
            LazyVerticalGrid(
                columns = GridCells.Fixed(3),
                modifier = Modifier
                    .fillMaxSize()
                    .padding(padding),
                contentPadding = PaddingValues(4.dp),
                horizontalArrangement = Arrangement.spacedBy(4.dp),
                verticalArrangement = Arrangement.spacedBy(4.dp)
            ) {
                items(images, key = { it.id }) { image ->
                    AsyncImage(
                        model = image.uri,
                        contentDescription = image.name,
                        modifier = Modifier
                            .aspectRatio(1f)
                            .clip(RoundedCornerShape(4.dp)),
                        contentScale = ContentScale.Crop
                    )
                }
            }
        }
    }
}

Common Mistakes

1. Using File Paths Instead of Content URIs

Never pass raw file paths between apps. Always use content URIs from FileProvider or MediaStore. Raw paths fail with FileUriExposedException on Android 7+.

2. Forgetting IS_PENDING for MediaStore Inserts

Without IS_PENDING, other apps can see your file before you finish writing it. This can cause corrupted thumbnails in the gallery.

3. Not Handling Permission Denial

If the user denies READ_MEDIA_IMAGES, your MediaStore queries return empty results. Show an explanation and offer the photo picker as an alternative (it needs no permissions).

4. Storing URIs Without Persisting Permissions

SAF URIs expire. If you save a URI to your database, you must call takePersistableUriPermission(). Otherwise, accessing the URI after a restart throws a SecurityException.

5. Reading Files on the Main Thread

Always use withContext(Dispatchers.IO) for file operations. Even small files can cause ANRs if the storage is slow (SD card, network storage).


What’s Next?

In this tutorial, you learned:

  • How scoped storage changed file access on Android
  • Using the photo picker for image selection (no permissions needed)
  • Reading and writing media with MediaStore
  • Picking and creating documents with Storage Access Framework
  • Sharing files between apps with FileProvider
  • Reading system data like contacts with content providers
  • Creating your own content provider

Next up: Android Tutorial #14: Deep Links and App Links — where you will learn how to open specific screens from URLs and set up verified App Links.



This is part 13 of the Android Development Tutorial series.