Commit b6f27c09 authored by Christian Sadilek's avatar Christian Sadilek
Browse files

Closes #1328: Provide GeckoView based implementation of concept-fetch

parent 3abc0819
......@@ -46,6 +46,8 @@ object Dependencies {
const val testing_mockwebserver = "com.squareup.okhttp3:mockwebserver:${Versions.mockwebserver}"
const val androidx_test_core = "androidx.test:core-ktx:${Versions.androidx_test}"
const val androidx_test_runner = "androidx.test:runner:${Versions.androidx_test}"
const val androidx_test_rules = "androidx.test:rules:${Versions.androidx_test}"
const val support_annotations = "com.android.support:support-annotations:${Versions.support_libraries}"
const val support_cardview = "com.android.support:cardview-v7:${Versions.support_libraries}"
......
......@@ -11,6 +11,7 @@ android {
defaultConfig {
minSdkVersion config.minSdkVersion
targetSdkVersion config.targetSdkVersion
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
......@@ -20,6 +21,10 @@ android {
}
}
packagingOptions {
exclude 'META-INF/proguard/androidx-annotations.pro'
}
android {
compileOptions {
sourceCompatibility 1.8
......@@ -30,24 +35,29 @@ android {
dependencies {
implementation project(':concept-engine')
implementation project(':concept-fetch')
implementation project(':support-ktx')
implementation project(path: ':support-utils')
implementation Dependencies.kotlin_stdlib
implementation Dependencies.kotlin_coroutines
// We only compile against the ARM artifact. External module will decide which module to provide by build configuration.
// As the Kotlin/Java API is the same for all ABIs it is not important which one we import here.
compileOnly Gecko.geckoview_nightly_arm
testImplementation Gecko.geckoview_nightly_arm
implementation Dependencies.kotlin_stdlib
implementation Dependencies.kotlin_coroutines
testImplementation Dependencies.androidx_test_core
testImplementation Dependencies.testing_junit
testImplementation Dependencies.testing_robolectric
testImplementation Dependencies.testing_mockito
testImplementation Dependencies.testing_mockwebserver
testImplementation project(':support-test')
testImplementation project(':tooling-fetch-tests')
androidTestImplementation Dependencies.androidx_test_core
androidTestImplementation Dependencies.androidx_test_runner
androidTestImplementation Dependencies.androidx_test_rules
androidTestImplementation Gecko.geckoview_nightly_arm
androidTestImplementation project(':tooling-fetch-tests')
}
apply from: '../../../publish.gradle'
......
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package mozilla.components.lib.fetch.geckoview
import androidx.test.annotation.UiThreadTest
import androidx.test.core.app.ApplicationProvider
import androidx.test.filters.MediumTest
import mozilla.components.browser.engine.gecko.fetch.GeckoViewFetchClient
import mozilla.components.concept.fetch.Client
import org.junit.Assert.assertTrue
import org.junit.Ignore
import org.junit.Test
@MediumTest
@Suppress("TooManyFunctions")
class GeckoViewFetchTestCases : mozilla.components.tooling.fetch.tests.FetchTestCases() {
override fun createNewClient(): Client = GeckoViewFetchClient(ApplicationProvider.getApplicationContext())
@Test
@UiThreadTest
fun clientInstance() {
assertTrue(createNewClient() is GeckoViewFetchClient)
}
@Test
@UiThreadTest
override fun get200WithDefaultHeaders() {
super.get200WithDefaultHeaders()
}
@Test
@UiThreadTest
override fun get200WithGzippedBody() {
super.get200WithGzippedBody()
}
@Test
@UiThreadTest
override fun get200OverridingDefaultHeaders() {
super.get200OverridingDefaultHeaders()
}
@Test
@UiThreadTest
override fun get200WithUserAgent() {
super.get200WithUserAgent()
}
@Test
@UiThreadTest
override fun get200WithDuplicatedCacheControlRequestHeaders() {
super.get200WithDuplicatedCacheControlRequestHeaders()
}
@Test
@UiThreadTest
override fun get200WithDuplicatedCacheControlResponseHeaders() {
super.get200WithDuplicatedCacheControlResponseHeaders()
}
@Test
@UiThreadTest
override fun get200WithHeaders() {
super.get200WithHeaders()
}
@Test
@UiThreadTest
override fun get200WithReadTimeout() {
super.get200WithReadTimeout()
}
@Test
@UiThreadTest
override fun get200WithStringBody() {
super.get200WithStringBody()
}
@Test
@UiThreadTest
override fun get302FollowRedirects() {
super.get302FollowRedirects()
}
@Test
@UiThreadTest
@Ignore("Blocked on: https://bugzilla.mozilla.org/show_bug.cgi?id=1526327")
override fun get302FollowRedirectsDisabled() {
super.get302FollowRedirectsDisabled()
}
@Test
@UiThreadTest
override fun get404WithBody() {
super.get404WithBody()
}
@Test
@UiThreadTest
override fun post200WithBody() {
super.post200WithBody()
}
@Test
@UiThreadTest
@Ignore("Blocked on: https://bugzilla.mozilla.org/show_bug.cgi?id=1526322")
override fun put201FileUpload() {
super.put201FileUpload()
}
}
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package mozilla.components.browser.engine.gecko.fetch
import android.content.Context
import android.support.annotation.VisibleForTesting
import mozilla.components.concept.fetch.Client
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 org.mozilla.geckoview.GeckoRuntime
import org.mozilla.geckoview.GeckoWebExecutor
import org.mozilla.geckoview.WebRequest
import org.mozilla.geckoview.WebResponse
import java.io.IOException
import java.io.InputStream
import java.net.SocketTimeoutException
import java.nio.ByteBuffer
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
/**
* GeckoView ([GeckoWebExecutor]) based implementation of [Client].
*/
class GeckoViewFetchClient(
context: Context,
runtime: GeckoRuntime = GeckoRuntime.getDefault(context),
private val maxReadTimeOut: Pair<Long, TimeUnit> = Pair(MAX_READ_TIMEOUT_MINUTES, TimeUnit.MINUTES)
) : Client() {
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal var executor: GeckoWebExecutor = GeckoWebExecutor(runtime)
@Throws(IOException::class)
override fun fetch(request: Request): Response {
val webRequest = with(request) {
WebRequest.Builder(url)
.method(method.name)
.addHeadersFrom(request, defaultHeaders)
.addBodyFrom(request)
.build()
}
val readTimeOut = request.readTimeout ?: maxReadTimeOut
val readTimeOutMillis = readTimeOut.let { (timeout, unit) ->
unit.toMillis(timeout)
}
try {
val webResponse = executor.fetch(webRequest).poll(readTimeOutMillis)
return webResponse?.toResponse() ?: throw IOException("Fetch failed with null response")
} catch (e: TimeoutException) {
throw SocketTimeoutException()
}
}
companion object {
const val MAX_READ_TIMEOUT_MINUTES = 5L
}
}
private fun WebRequest.Builder.addHeadersFrom(request: Request, defaultHeaders: Headers): WebRequest.Builder {
defaultHeaders.filter { header ->
request.headers?.contains(header.name) != true
}.forEach { header ->
addHeader(header.name, header.value)
}
request.headers?.forEach { header ->
addHeader(header.name, header.value)
}
return this
}
private fun WebRequest.Builder.addBodyFrom(request: Request): WebRequest.Builder {
request.body?.let { body ->
body.useStream { inStream ->
val bytes = inStream.readBytes()
val buffer = ByteBuffer.allocateDirect(bytes.size)
buffer.put(bytes)
this.body(buffer)
}
}
return this
}
private fun WebResponse.toResponse(): Response {
return Response(
uri,
statusCode,
translateHeaders(this),
body?.let { Response.Body(ByteBufferInputStream(it)) } ?: Response.Body.empty()
)
}
private fun translateHeaders(webResponse: WebResponse): Headers {
val headers = MutableHeaders()
webResponse.headers.forEach { (k, v) ->
v.split(",").forEach { headers.append(k, it.trim()) }
}
return headers
}
class ByteBufferInputStream(private var buf: ByteBuffer) : InputStream() {
@Throws(IOException::class)
@Suppress("MagicNumber")
override fun read(): Int {
return if (!buf.hasRemaining()) -1 else (buf.get().toInt() and 0xFF)
}
@Throws(IOException::class)
override fun read(bytes: ByteArray, off: Int, len: Int): Int {
var len = len
if (!buf.hasRemaining()) {
return -1
}
len = Math.min(len, buf.remaining())
buf.get(bytes, off, len)
return len
}
}
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package mozilla.components.browser.engine.gecko.fetch
import androidx.test.core.app.ApplicationProvider
import mozilla.components.concept.fetch.Client
import mozilla.components.concept.fetch.Request
import mozilla.components.support.test.any
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers.anyLong
import org.mockito.Mockito.`when`
import org.mockito.Mockito.mock
import org.mozilla.geckoview.GeckoResult
import org.mozilla.geckoview.GeckoRuntime
import org.mozilla.geckoview.GeckoWebExecutor
import org.mozilla.geckoview.WebResponse
import org.robolectric.RobolectricTestRunner
import java.io.IOException
import java.nio.ByteBuffer
import java.util.concurrent.TimeoutException
/**
* We can't run standard JVM unit tests for GWE. Therefore, we provide both
* instrumented tests as well as these unit tests which mock both requests
* and responses. While these tests guard our logic to map responses to our
* concept-fetch abstractions, they are not sufficient to guard the full
* functionality of [GeckoViewFetchClient]. That's why end-to-end tests are
* provided in instrumented tests.
*/
@RunWith(RobolectricTestRunner::class)
class GeckoViewFetchUnitTestCases : mozilla.components.tooling.fetch.tests.FetchTestCases() {
override fun createNewClient(): Client {
val client = GeckoViewFetchClient(ApplicationProvider.getApplicationContext(), mock(GeckoRuntime::class.java))
geckoWebExecutor?.let { client.executor = it }
return client
}
override fun createWebServer(): MockWebServer {
return mockWebServer ?: super.createWebServer()
}
private var geckoWebExecutor: GeckoWebExecutor? = null
private var mockWebServer: MockWebServer? = null
@Before
fun setup() {
geckoWebExecutor = null
}
@Test
fun clientInstance() {
assertTrue(createNewClient() is GeckoViewFetchClient)
}
@Test
override fun get200WithDefaultHeaders() {
val server = mock(MockWebServer::class.java)
`when`(server.url(any())).thenReturn(mock(HttpUrl::class.java))
val host = server.url("/").host()
val port = server.url("/").port()
val headerMap = mapOf(
"Host" to "$host:$port",
"Accept" to "*/*",
"Accept-Language" to "*/*",
"Accept-Encoding" to "gzip",
"Connection" to "keep-alive",
"User-Agent" to "test")
mockRequest(headerMap)
mockResponse(200)
super.get200WithDefaultHeaders()
}
@Test
override fun get200WithDuplicatedCacheControlRequestHeaders() {
val headerMap = mapOf("Cache-Control" to "no-cache, no-store")
mockRequest(headerMap)
mockResponse(200)
super.get200WithDuplicatedCacheControlRequestHeaders()
}
@Test
override fun get200WithDuplicatedCacheControlResponseHeaders() {
val responseHeaderMap = mapOf(
"Cache-Control" to "no-cache, no-store",
"Content-Length" to "16"
)
mockResponse(200, responseHeaderMap)
super.get200WithDuplicatedCacheControlResponseHeaders()
}
@Test
override fun get200OverridingDefaultHeaders() {
val headerMap = mapOf(
"Accept" to "text/html",
"Accept-Encoding" to "deflate",
"User-Agent" to "SuperBrowser/1.0",
"Connection" to "close")
mockRequest(headerMap)
mockResponse(200)
super.get200OverridingDefaultHeaders()
}
@Test
override fun get200WithGzippedBody() {
val responseHeaderMap = mapOf("Content-Encoding" to "gzip")
mockRequest()
mockResponse(200, responseHeaderMap, "This is compressed")
super.get200WithGzippedBody()
}
@Test
override fun get200WithHeaders() {
val requestHeaders = mapOf(
"Accept" to "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Encoding" to "gzip, deflate",
"Accept-Language" to "en-US,en;q=0.5",
"Connection" to "keep-alive",
"User-Agent" to "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:65.0) Gecko/20100101 Firefox/65.0"
)
mockRequest(requestHeaders)
mockResponse(200)
super.get200WithHeaders()
}
@Test
override fun get200WithReadTimeout() {
mockRequest()
mockResponse(200)
val geckoResult = mock(GeckoResult::class.java)
`when`(geckoResult.poll(anyLong())).thenThrow(TimeoutException::class.java)
`when`(geckoWebExecutor!!.fetch(any())).thenReturn(geckoResult as GeckoResult<WebResponse>)
super.get200WithReadTimeout()
}
@Test
override fun get200WithStringBody() {
mockRequest()
mockResponse(200, body = "Hello World")
super.get200WithStringBody()
}
@Test
override fun get200WithUserAgent() {
mockRequest(mapOf("User-Agent" to "MozacFetch/"))
mockResponse(200)
super.get200WithUserAgent()
}
@Test
@Ignore("Covered by instrumented tests")
override fun get302FollowRedirects() {
super.get302FollowRedirects()
}
@Test
@Ignore("Covered by instrumented tests")
override fun get302FollowRedirectsDisabled() {
super.get302FollowRedirectsDisabled()
}
@Test
override fun get404WithBody() {
mockRequest()
mockResponse(404, body = "Error")
super.get404WithBody()
}
@Test
override fun post200WithBody() {
mockRequest(method = "POST", body = "Hello World")
mockResponse(200)
super.post200WithBody()
}
@Test
override fun put201FileUpload() {
mockRequest(method = "PUT", headerMap = mapOf("Content-Type" to "image/png"), body = "I am an image file!")
mockResponse(201, headerMap = mapOf("Location" to "/your-image.png"), body = "Thank you!")
super.put201FileUpload()
}
@Test(expected = IOException::class)
fun pollReturningNull() {
mockResponse(200)
val geckoResult = mock(GeckoResult::class.java)
`when`(geckoResult.poll(anyLong())).thenReturn(null)
`when`(geckoWebExecutor!!.fetch(any())).thenReturn(geckoResult as GeckoResult<WebResponse>)
val request = mock(Request::class.java)
`when`(request.url).thenReturn("https://mozilla.org")
`when`(request.method).thenReturn(Request.Method.GET)
createNewClient().fetch(request)
}
@Test
fun byteBufferInputStream() {
val value = "test"
val buffer = ByteBuffer.allocateDirect(value.length)
buffer.put(value.toByteArray())
buffer.rewind()
var stream = ByteBufferInputStream(buffer)
assertEquals('t'.toInt(), stream.read())
assertEquals('e'.toInt(), stream.read())
assertEquals('s'.toInt(), stream.read())
assertEquals('t'.toInt(), stream.read())
assertEquals(-1, stream.read())
val array = ByteArray(4)
buffer.rewind()
stream = ByteBufferInputStream(buffer)
stream.read(array, 0, 4)
assertEquals('t'.toByte(), array[0])
assertEquals('e'.toByte(), array[1])
assertEquals('s'.toByte(), array[2])
assertEquals('t'.toByte(), array[3])
}
private fun mockRequest(headerMap: Map<String, String>? = null, body: String? = null, method: String = "GET") {
val server = mock(MockWebServer::class.java)
`when`(server.url(any())).thenReturn(mock(HttpUrl::class.java))