Skip to content

Commit

Permalink
Closes mozilla-mobile#6314: Support fetching / downloading data URIs
Browse files Browse the repository at this point in the history
  • Loading branch information
csadilek committed Apr 2, 2020
1 parent ca13af0 commit 07f15fb
Show file tree
Hide file tree
Showing 14 changed files with 118 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import mozilla.components.concept.fetch.Headers
import mozilla.components.concept.fetch.MutableHeaders
import mozilla.components.concept.fetch.Request
import mozilla.components.concept.fetch.Response
import mozilla.components.concept.fetch.isDataUri

import org.mozilla.geckoview.GeckoRuntime
import org.mozilla.geckoview.GeckoWebExecutor
Expand Down Expand Up @@ -39,6 +40,10 @@ class GeckoViewFetchClient(

@Throws(IOException::class)
override fun fetch(request: Request): Response {
if (request.isDataUri()) {
return fetchDataUri(request)
}

val webRequest = request.toWebRequest(defaultHeaders)

val readTimeOut = request.readTimeout ?: maxReadTimeOut
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,10 @@ class GeckoViewFetchTestCases : mozilla.components.tooling.fetch.tests.FetchTest
override fun getThrowsIOExceptionWhenHostNotReachable() {
super.getThrowsIOExceptionWhenHostNotReachable()
}

@Test
@UiThreadTest
override fun getDataUri() {
super.getDataUri()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import mozilla.components.concept.fetch.Headers
import mozilla.components.concept.fetch.MutableHeaders
import mozilla.components.concept.fetch.Request
import mozilla.components.concept.fetch.Response
import mozilla.components.concept.fetch.isDataUri

import org.mozilla.geckoview.GeckoRuntime
import org.mozilla.geckoview.GeckoWebExecutor
Expand Down Expand Up @@ -39,6 +40,10 @@ class GeckoViewFetchClient(

@Throws(IOException::class)
override fun fetch(request: Request): Response {
if (request.isDataUri()) {
return fetchDataUri(request)
}

val webRequest = request.toWebRequest(defaultHeaders)

val readTimeOut = request.readTimeout ?: maxReadTimeOut
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import mozilla.components.concept.fetch.Headers
import mozilla.components.concept.fetch.MutableHeaders
import mozilla.components.concept.fetch.Request
import mozilla.components.concept.fetch.Response
import mozilla.components.concept.fetch.isDataUri

import org.mozilla.geckoview.GeckoRuntime
import org.mozilla.geckoview.GeckoWebExecutor
Expand Down Expand Up @@ -39,6 +40,10 @@ class GeckoViewFetchClient(

@Throws(IOException::class)
override fun fetch(request: Request): Response {
if (request.isDataUri()) {
return fetchDataUri(request)
}

val webRequest = request.toWebRequest(defaultHeaders)

val readTimeOut = request.readTimeout ?: maxReadTimeOut
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

package mozilla.components.concept.fetch

import android.util.Base64
import mozilla.components.concept.fetch.Response.Companion.CONTENT_LENGTH_HEADER
import mozilla.components.concept.fetch.Response.Companion.CONTENT_TYPE_HEADER
import java.io.ByteArrayInputStream
import java.io.IOException

/**
Expand Down Expand Up @@ -40,6 +44,44 @@ abstract class Client {
@Throws(IOException::class)
abstract fun fetch(request: Request): Response

/**
* Generates a [Response] by decoding a base64 encoded data URI.
*
* @param request The [Request] for the data URI.
* @return The generated [Response] including the decoded bytes as body.
*/
@Suppress("TooGenericExceptionCaught")
protected fun fetchDataUri(request: Request): Response {
if (!request.isDataUri()) {
throw IOException("Not a data URI")
}

val dataUri = request.url
if (!dataUri.contains(DATA_URI_BASE64_EXT)) {
throw IOException("Data URI must be base64 encoded")
}

return try {
val contentType = dataUri.substringAfter(DATA_URI_SCHEME).substringBefore(DATA_URI_BASE64_EXT)
val bytes = Base64.decode(dataUri.substring(dataUri.lastIndexOf(',') + 1), Base64.DEFAULT)
val headers = MutableHeaders().apply {
set(CONTENT_LENGTH_HEADER, bytes.size.toString())
if (contentType.isNotEmpty()) {
set(CONTENT_TYPE_HEADER, contentType)
}
}

Response(
dataUri,
Response.SUCCESS,
headers,
Response.Body(ByteArrayInputStream(bytes), contentType)
)
} catch (e: Exception) {
throw IOException("Failed to decode data URI")
}
}

/**
* List of default headers that should be added to every request unless overridden by the headers in the request.
*/
Expand All @@ -61,4 +103,9 @@ abstract class Client {
// We expect all clients to support and use keep-alive by default.
"Connection" to "keep-alive"
)

companion object {
const val DATA_URI_BASE64_EXT = ";base64"
const val DATA_URI_SCHEME = "data:"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,8 @@ data class Request(
OMIT
}
}

/**
* Checks whether or not the request is for a data URI.
*/
fun Request.isDataUri() = url.startsWith("data:")
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ data class Response(
companion object {
val SUCCESS_STATUS_RANGE = 200..299
val CLIENT_ERROR_STATUS_RANGE = 400..499
const val SUCCESS = 200
const val CONTENT_TYPE_HEADER = "Content-Type"
const val CONTENT_LENGTH_HEADER = "Content-Length"
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ class FetchDownloadManager<T : AbstractFetchDownloadService>(
* @return the id reference of the scheduled download.
*/
override fun download(download: DownloadState, cookie: String): Long? {
if (!download.isScheme(listOf("http", "https"))) {
// We are ignoring everything that is not http or https. This is a limitation of
if (!download.isScheme(listOf("http", "https", "data"))) {
// We are ignoring everything that is not http(s) or data. This is a limitation of
// GeckoView: https://bugzilla.mozilla.org/show_bug.cgi?id=1501735 and
// https://bugzilla.mozilla.org/show_bug.cgi?id=1432949
return null
Expand Down
3 changes: 2 additions & 1 deletion components/lib/fetch-httpurlconnection/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ dependencies {

implementation project(':concept-fetch')

testImplementation Dependencies.testing_junit
testImplementation Dependencies.androidx_test_core
testImplementation Dependencies.androidx_test_junit
testImplementation Dependencies.testing_robolectric
testImplementation Dependencies.testing_mockito

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import mozilla.components.concept.fetch.Headers
import mozilla.components.concept.fetch.MutableHeaders
import mozilla.components.concept.fetch.Request
import mozilla.components.concept.fetch.Response
import mozilla.components.concept.fetch.isDataUri
import mozilla.components.lib.fetch.httpurlconnection.HttpURLConnectionClient.Companion.getOrCreateCookieManager
import java.io.FileNotFoundException
import java.io.IOException
Expand All @@ -25,6 +26,10 @@ import java.util.zip.GZIPInputStream
class HttpURLConnectionClient : Client() {
@Throws(IOException::class)
override fun fetch(request: Request): Response {
if (request.isDataUri()) {
return fetchDataUri(request)
}

val connection = (URL(request.url).openConnection() as HttpURLConnection)

connection.setupWith(request)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,21 @@

package mozilla.components.lib.fetch.httpurlconnection

import androidx.test.ext.junit.runners.AndroidJUnit4
import mozilla.components.concept.fetch.Client
import mozilla.components.concept.fetch.Request
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.spy
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL

@RunWith(AndroidJUnit4::class)
class HttpUrlConnectionFetchTestCases : mozilla.components.tooling.fetch.tests.FetchTestCases() {
override fun createNewClient(): Client = HttpURLConnectionClient()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
mock-maker-inline
// This allows mocking final classes (classes are final by default in Kotlin)
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import mozilla.components.concept.fetch.Headers
import mozilla.components.concept.fetch.MutableHeaders
import mozilla.components.concept.fetch.Request
import mozilla.components.concept.fetch.Response
import mozilla.components.concept.fetch.isDataUri
import mozilla.components.lib.fetch.okhttp.OkHttpClient.Companion.CACHE_MAX_SIZE
import mozilla.components.lib.fetch.okhttp.OkHttpClient.Companion.getOrCreateCookieManager
import okhttp3.Cache
Expand All @@ -30,6 +31,10 @@ class OkHttpClient(
private val context: Context? = null
) : Client() {
override fun fetch(request: Request): Response {
if (request.isDataUri()) {
return fetchDataUri(request)
}

val requestClient = client.rebuildFor(request, context)

val requestBuilder = createRequestBuilderWithBody(request)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,25 @@ abstract class FetchTestCases {
}
}

@Test
open fun getDataUri() {
val client = createNewClient()
val response = client.fetch(Request(url = "data:text/plain;charset=utf-8;base64,SGVsbG8sIFdvcmxkIQ=="))
assertEquals("13", response.headers["Content-Length"])
assertEquals("text/plain;charset=utf-8", response.headers["Content-Type"])
assertEquals("Hello, World!", response.body.string())

val responseNoCharset = client.fetch(Request(url = "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ=="))
assertEquals("13", responseNoCharset.headers["Content-Length"])
assertEquals("text/plain", responseNoCharset.headers["Content-Type"])
assertEquals("Hello, World!", responseNoCharset.body.string())

val responseNoContentType = client.fetch(Request(url = "data:;base64,SGVsbG8sIFdvcmxkIQ=="))
assertEquals("13", responseNoContentType.headers["Content-Length"])
assertNull(responseNoContentType.headers["Content-Type"])
assertEquals("Hello, World!", responseNoContentType.body.string())
}

private inline fun withServerResponding(
vararg responses: MockResponse,
crossinline block: MockWebServer.(Client) -> Unit
Expand Down

0 comments on commit 07f15fb

Please sign in to comment.