Over a year ago I typed up an implementation of OkHttp’s RequestBody
that supports Android’s content://
URIs. I called it ContentUriRequestBody
and made it available as a Gist.
The other day a comment on the Gist made me realize that this code might be helpful to more people than the person for whom it was originally created; probably more so when accompanied by some form of explanation. So here we go.
Uploading a document
Using Android’s Storage Access Framework to allow the user to select a document is fairly straight-forward. We do it like this:
val openDocumentIntent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
type = "*/*"
addCategory(Intent.CATEGORY_OPENABLE)
}
startActivityForResult(openDocumentIntent, REQUEST_UPLOAD_DOCUMENT)
When the user has selected a document we get the result in onActivityResult()
. The data field of the result Intent
contains a content://
URI that can be used to access its contents.
We’ll get back to this in a minute. First, let’s look at how to upload a file using an HTTP POST request with OkHttp.
val request = Request.Builder()
.url("https://domain.example/upload")
.post(requestBody)
.build()
val response = okHttpClient.newCall(request).execute()
So, we need a RequestBody
instance that provides the content to be uploaded. If we look closer, we notice it is an abstract class and none of the implementations that come with OkHttp seem to fit our use case. But that can easily be fixed. There’s only two functions we need to implement:
class ContentUriRequestBody(
private val contentResolver: ContentResolver,
private val contentUri: Uri
) : RequestBody() {
override fun contentType(): MediaType? {
val contentType = contentResolver.getType(contentUri)
return contentType?.toMediaTypeOrNull()
}
override fun writeTo(bufferedSink: BufferedSink) {
val inputStream = contentResolver.openInputStream(contentUri)
?: throw IOException("Couldn't open content URI for reading")
inputStream.source().use { source ->
bufferedSink.writeAll(source)
}
}
}
The contentType()
function provides the media type (also called MIME type, or content type) for the content, e.g. image/jpeg
or text/plain
. Our content://
URI can be queried for the media type of the document. This is done by calling ContentResolver.getType(contentUri)
.
To send the actual bytes that make up the document the writeTo()
function is called by OkHttp. Using ContentResolver.openInputStream()
gives us a java.io.InputStream
instance to read the data from.
OkHttp is also using streams to read data from and write data to the network. But not the java.io
API. Instead it is built on top of a library called Okio, that implements the same concept, but in a much nicer way. Fortunately, Okio comes with an easy way to treat java.io.InputStream
and java.io.OutputStream
like the corresponding types in the Okio world, Source
and Sink
.
When calling the writeTo()
function, OkHttp is handing us a BufferedSink
and expects us to write the data to it. Since the document could be very large, we don’t want to read all of the bytes into memory and the write them to the BufferedSink
. Instead we do what streams were designed to support: read a small amount of bytes from the document into memory, then write them to the BufferedSink
so OkHttp can send them over the network. This step is repeated until we have read and subsequently written all of the document bytes. But because copying data from a source stream to a destination stream is such a common operation, there’s functionality in Okio that does all the heavy lifting for us. All we need to do is call the extension function source()
to turn the InputStream
we got from ContentResolver.openInputStream()
into a Source
. Then we call BufferedSink.writeAll(Source)
and Okio will copy all bytes from the source to the sink.
And that’s about it. This is more or less everything you need to send content referenced via a content://
URI as body of an HTTP request using OkHttp.
Writing downloaded content to a document
Downloading content and writing it to a document is even easier because it doesn’t require us to extend a class. Displaying the system UI to allow the user to select a location and name for a new document is done like this:
val openDocumentIntent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
type = "image/jpeg"
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_TITLE, "kitten.jpeg")
}
startActivityForResult(openDocumentIntent, REQUEST_DOWNLOAD_DOCUMENT)
Again, we get a content URI in the result Intent delivered to onActivityResult()
. We can then make an HTTP request and write the response to the document.
val request = Request.Builder()
.url("https://placekitten.com/300/300")
.build()
okHttpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) {
response.body!!.source().use { bufferedSource ->
val outputStream = contentResolver.openOutputStream(contentUri)
?: throw IOException("Couldn't open content URI")
outputStream.sink().use { sink ->
bufferedSource.readAll(sink)
}
}
}
}
It works similar to uploading, only this time we get a java.io.OutputStream
instance when calling ContentResolver.openOutputStream()
with our content://
URI. Again, there’s a handy extension function to turn this into one of Okio’s types, in this case Sink
. The API surface of Okio’s Source
and Sink
types is rather limited. The really useful functionality comes with BufferedSource
and BufferedSink
. We could easily turn our Sink
into a BufferedSink
by using the extension function buffer()
. Then we’d be able to call BufferedSink.writeAll(Source)
like we did when uploading a document. But because OkHttp is already handing us a BufferedSource
, we can also call BufferedSource.readAll(Sink)
to copy all bytes from the source to the sink.
Sample app
I hope this post has helped you to understand how to make OkHttp play nice with content://
URIs. Of course code snippets in blog posts always seem to be missing some crucial bits of information. So I created a small sample app that you can play around with: OkHttpWithContentUri