Scoped Storage Stories: Storing via MediaStore
Android 10 is greatly restricting access to external storage
via filesystem APIs. Instead, we need to use other APIs to work with content.
This is the eighth post in a series where we will explore how to work with those
alternatives, now looking at MediaStore
options.
The Storage Access Framework offers the user the most flexibility of where your app’s content should be placed. One key downside is that it requires the user to interact with some system UI to choose where to place the content.
getExternalFilesDir()
and related Context
methods avoid that UI. However,
the files that you create in these locations get removed when the app is uninstalled.
That may not be appropriate for all types of content, as the user may get irritated
if uninstalling the app deletes “their” files.
The third option is MediaStore
. As with the Storage Access Framework, content
you store via MediaStore
will remain after the app is uninstalled. And, like
getExternalFilesDir()
and kin, you are not forced to display some system UI.
However, MediaStore
is restricted (mostly) to just media: images, audio files, and video files.
The basic recipe for storing content using MediaStore
is:
-
Create a
ContentValues
describing the content -
insert()
that into aMediaStore
collection using aContentResolver
-
Use the
Uri
thatinsert()
returns to open anOutputStream
(again usingContentResolver
) -
Write your content to that
OutputStream
This is definitely more complicated than simply using a File
, but it’s not that bad.
This sample app
(from Elements of Android Q) lets you download
archived conference videos from my Web server. The app is set up to use
Environment.getExternalStoragePublicDirectory()
on older devices but use
MediaStore
on Android 10 and above.
First, you need a collection Uri
from the MediaStore
, representing where
you want to store the content. Conference videos are videos (surprise!), so
the VideoRepository
uses MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
to get a Uri
representing the storage location for video content on external
storage. getContentUri()
is a new method in Android 10, and you can use it to
work both with external and removable storage.
The VideoRepository
has a downloadQ()
function that derives a URL of a video from
a filename and downloads the video into that collection:
private suspend fun downloadQ(filename: String): Uri =
withContext(Dispatchers.IO) {
val url = URL_BASE + filename
val response = ok.newCall(Request.Builder().url(url).build()).execute()
if (response.isSuccessful) {
val values = ContentValues().apply {
put(MediaStore.Video.Media.DISPLAY_NAME, filename)
put(MediaStore.Video.Media.RELATIVE_PATH, "Movies/ConferenceVideos")
put(MediaStore.Video.Media.MIME_TYPE, "video/mp4")
put(MediaStore.Video.Media.IS_PENDING, 1)
}
val resolver = context.contentResolver
val uri = resolver.insert(collection, values)
uri?.let {
resolver.openOutputStream(uri)?.use { outputStream ->
val sink = Okio.buffer(Okio.sink(outputStream))
response.body()?.source()?.let { sink.writeAll(it) }
sink.close()
}
values.clear()
values.put(MediaStore.Video.Media.IS_PENDING, 0)
resolver.update(uri, values, null, null)
} ?: throw RuntimeException("MediaStore failed for some reason")
uri
} else {
throw RuntimeException("OkHttp failed for some reason")
}
}
This sample uses coroutines, so all the
work is wrapped in a Dispatchers.IO
coroutine context, to arrange for it to be
performed on a backgorund thread.
downloadQ()
gets the URL based on the filename, then uses OkHttp to make the
HTTP GET request to download it.
If we get a 200 OK
response, we then set up a ContentValues
representing this new
bit of media. The DISPLAY_NAME
is just our filename, and the MIME_TYPE
is tied
to the type of media (in this case, an MP4 video). RELATIVE_PATH
is new to Android 10
and allows you to specify a sub-folder within the collection where this content should
be placed – in this case, we are asking for a ConferenceVideos
folder within
Movies
. IS_PENDING
is also new to Android 10, where a value of 1
means that
the content is not yet ready for use, as we are still downloading it.
After we insert()
that ContentValues
using a ContentResolver
, we have a Uri
representing where the MediaStore
wants us to place the actual content. We can
use the ContentResolver
and openOutputStream()
to get an OutputStream
on
that location. From there, we use Okio to slurp down the video content and stream it
out to the, um, stream. In a streaming fashion. Streamily.
Finally, we replace the ContentValues
content with IS_PENDING
set to 0
and use the ContentResolver
to update()
the values associated with our Uri
.
This flip IS_PENDING
to false, meaning that the content is ready for use.
This function can then be called from a viewmodel or other place that has a suitable
coroutine scope (e.g., viewModelScope
).
The entire series of “Scoped Storage Stories” posts includes posts on:
- The basics of using the Storage Access Framework
- Getting durable access to the selected content
- Working with
DocumentFile
for individual documents - Working with document trees
- Working with
DocumentsContract
- Problems with the SAF API
- A specific problem with
listFiles()
onDocumentFile
- Storing content using
MediaStore
- Reading content from the
MediaStore
- Modifying
MediaStore
content from other apps - Limitations of
MediaStore.Downloads
- The undocumented
Documents
option - More on
RecoverableSecurityException
- How to modify more metadata in
MediaStore