diff --git a/components/browser/engine-gecko-beta/src/main/java/mozilla/components/browser/engine/gecko/fetch/GeckoViewFetchClient.kt b/components/browser/engine-gecko-beta/src/main/java/mozilla/components/browser/engine/gecko/fetch/GeckoViewFetchClient.kt index 6a699bc4019..d92db64730f 100644 --- a/components/browser/engine-gecko-beta/src/main/java/mozilla/components/browser/engine/gecko/fetch/GeckoViewFetchClient.kt +++ b/components/browser/engine-gecko-beta/src/main/java/mozilla/components/browser/engine/gecko/fetch/GeckoViewFetchClient.kt @@ -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 @@ -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 diff --git a/components/browser/engine-gecko-nightly/src/androidTest/java/mozilla/components/browser/engine/gecko/fetch/geckoview/GeckoViewFetchTestCases.kt b/components/browser/engine-gecko-nightly/src/androidTest/java/mozilla/components/browser/engine/gecko/fetch/geckoview/GeckoViewFetchTestCases.kt index 570ebdc7999..bce9fc174ec 100644 --- a/components/browser/engine-gecko-nightly/src/androidTest/java/mozilla/components/browser/engine/gecko/fetch/geckoview/GeckoViewFetchTestCases.kt +++ b/components/browser/engine-gecko-nightly/src/androidTest/java/mozilla/components/browser/engine/gecko/fetch/geckoview/GeckoViewFetchTestCases.kt @@ -130,4 +130,10 @@ class GeckoViewFetchTestCases : mozilla.components.tooling.fetch.tests.FetchTest override fun getThrowsIOExceptionWhenHostNotReachable() { super.getThrowsIOExceptionWhenHostNotReachable() } + + @Test + @UiThreadTest + override fun getDataUri() { + super.getDataUri() + } } diff --git a/components/browser/engine-gecko-nightly/src/main/java/mozilla/components/browser/engine/gecko/fetch/GeckoViewFetchClient.kt b/components/browser/engine-gecko-nightly/src/main/java/mozilla/components/browser/engine/gecko/fetch/GeckoViewFetchClient.kt index 6a699bc4019..d92db64730f 100644 --- a/components/browser/engine-gecko-nightly/src/main/java/mozilla/components/browser/engine/gecko/fetch/GeckoViewFetchClient.kt +++ b/components/browser/engine-gecko-nightly/src/main/java/mozilla/components/browser/engine/gecko/fetch/GeckoViewFetchClient.kt @@ -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 @@ -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 diff --git a/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/fetch/GeckoViewFetchClient.kt b/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/fetch/GeckoViewFetchClient.kt index 6a699bc4019..d92db64730f 100644 --- a/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/fetch/GeckoViewFetchClient.kt +++ b/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/fetch/GeckoViewFetchClient.kt @@ -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 @@ -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 diff --git a/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Client.kt b/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Client.kt index 5c13b50eea9..d3e49aca45b 100644 --- a/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Client.kt +++ b/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Client.kt @@ -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 /** @@ -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. */ @@ -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:" + } } diff --git a/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Request.kt b/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Request.kt index 805a4a07759..839c96e3a64 100644 --- a/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Request.kt +++ b/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Request.kt @@ -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:") diff --git a/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Response.kt b/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Response.kt index e2f4b7477fd..aeff0594f16 100644 --- a/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Response.kt +++ b/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Response.kt @@ -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" } } diff --git a/components/feature/downloads/src/main/java/mozilla/components/feature/downloads/manager/FetchDownloadManager.kt b/components/feature/downloads/src/main/java/mozilla/components/feature/downloads/manager/FetchDownloadManager.kt index d7fe20e4a52..eb6184cbccb 100644 --- a/components/feature/downloads/src/main/java/mozilla/components/feature/downloads/manager/FetchDownloadManager.kt +++ b/components/feature/downloads/src/main/java/mozilla/components/feature/downloads/manager/FetchDownloadManager.kt @@ -55,8 +55,8 @@ class FetchDownloadManager( * @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 diff --git a/components/lib/fetch-httpurlconnection/build.gradle b/components/lib/fetch-httpurlconnection/build.gradle index 413540a186e..1af871a6e34 100644 --- a/components/lib/fetch-httpurlconnection/build.gradle +++ b/components/lib/fetch-httpurlconnection/build.gradle @@ -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 diff --git a/components/lib/fetch-httpurlconnection/src/main/java/mozilla/components/lib/fetch/httpurlconnection/HttpURLConnectionClient.kt b/components/lib/fetch-httpurlconnection/src/main/java/mozilla/components/lib/fetch/httpurlconnection/HttpURLConnectionClient.kt index 3f3ac936b7e..9a88fd13928 100644 --- a/components/lib/fetch-httpurlconnection/src/main/java/mozilla/components/lib/fetch/httpurlconnection/HttpURLConnectionClient.kt +++ b/components/lib/fetch-httpurlconnection/src/main/java/mozilla/components/lib/fetch/httpurlconnection/HttpURLConnectionClient.kt @@ -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 @@ -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) diff --git a/components/lib/fetch-httpurlconnection/src/test/java/mozilla/components/lib/fetch/httpurlconnection/HttpUrlConnectionFetchTestCases.kt b/components/lib/fetch-httpurlconnection/src/test/java/mozilla/components/lib/fetch/httpurlconnection/HttpUrlConnectionFetchTestCases.kt index 2aea416000f..c4fa0967a49 100644 --- a/components/lib/fetch-httpurlconnection/src/test/java/mozilla/components/lib/fetch/httpurlconnection/HttpUrlConnectionFetchTestCases.kt +++ b/components/lib/fetch-httpurlconnection/src/test/java/mozilla/components/lib/fetch/httpurlconnection/HttpUrlConnectionFetchTestCases.kt @@ -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() diff --git a/components/lib/fetch-httpurlconnection/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/components/lib/fetch-httpurlconnection/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 00000000000..cf1c399ea81 --- /dev/null +++ b/components/lib/fetch-httpurlconnection/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1,2 @@ +mock-maker-inline +// This allows mocking final classes (classes are final by default in Kotlin) diff --git a/components/lib/fetch-okhttp/src/main/java/mozilla/components/lib/fetch/okhttp/OkHttpClient.kt b/components/lib/fetch-okhttp/src/main/java/mozilla/components/lib/fetch/okhttp/OkHttpClient.kt index 1b0ab9bbd7c..06a9ee9bc90 100644 --- a/components/lib/fetch-okhttp/src/main/java/mozilla/components/lib/fetch/okhttp/OkHttpClient.kt +++ b/components/lib/fetch-okhttp/src/main/java/mozilla/components/lib/fetch/okhttp/OkHttpClient.kt @@ -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 @@ -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) diff --git a/components/tooling/fetch-tests/src/main/java/mozilla/components/tooling/fetch/tests/FetchTestCases.kt b/components/tooling/fetch-tests/src/main/java/mozilla/components/tooling/fetch/tests/FetchTestCases.kt index 4f083fc658b..14f09c8b594 100644 --- a/components/tooling/fetch-tests/src/main/java/mozilla/components/tooling/fetch/tests/FetchTestCases.kt +++ b/components/tooling/fetch-tests/src/main/java/mozilla/components/tooling/fetch/tests/FetchTestCases.kt @@ -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