diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExecutorTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExecutorTest.kt new file mode 100644 index 000000000000..f9e6b07eca2d --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExecutorTest.kt @@ -0,0 +1,240 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.SystemClock + +import android.support.test.filters.MediumTest +import android.support.test.filters.SdkSuppress +import android.support.test.runner.AndroidJUnit4 + +import java.net.URI + +import java.nio.ByteBuffer +import java.nio.CharBuffer +import java.nio.charset.Charset + +import java.util.concurrent.CountDownLatch + +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.* + +import org.json.JSONObject +import org.junit.* + +import org.junit.rules.ExpectedException +import org.junit.runner.RunWith + +import org.mozilla.geckoview.GeckoWebExecutor +import org.mozilla.geckoview.WebRequest +import org.mozilla.geckoview.WebRequestError +import org.mozilla.geckoview.WebResponse + +import org.mozilla.geckoview.test.util.Environment +import org.mozilla.geckoview.test.util.HttpBin +import org.mozilla.geckoview.test.util.RuntimeCreator + +@MediumTest +@RunWith(AndroidJUnit4::class) +class WebExecutorTest { + companion object { + val TEST_ENDPOINT: String = "http://localhost:4242" + } + + lateinit var executor: GeckoWebExecutor + lateinit var server: HttpBin + val env = Environment() + + @get:Rule val thrown = ExpectedException.none() + + @Before + fun setup() { + // Using @UiThreadTest here does not seem to block + // the tests which are not using @UiThreadTest, so we do that + // ourselves here as GeckoRuntime needs to be initialized + // on the UI thread. + val latch = CountDownLatch(1) + Handler(Looper.getMainLooper()).post { + executor = GeckoWebExecutor(RuntimeCreator.getRuntime()) + server = HttpBin(URI.create(TEST_ENDPOINT)) + server.start() + latch.countDown() + } + + latch.await() + } + + @After + fun cleanup() { + server.stop() + } + + private fun fetch(request: WebRequest): WebResponse { + return fetch(request, GeckoWebExecutor.FETCH_FLAGS_NONE) + } + + private fun fetch(request: WebRequest, @GeckoWebExecutor.FetchFlags flags: Int): WebResponse { + return executor.fetch(request, flags).poll(env.defaultTimeoutMillis) + } + + fun String.toDirectByteBuffer(): ByteBuffer { + val chars = CharBuffer.wrap(this) + val buffer = ByteBuffer.allocateDirect(this.length) + Charset.forName("UTF-8").newEncoder().encode(chars, buffer, true) + + return buffer + } + + fun WebResponse.getJSONBody(): JSONObject { + return JSONObject(Charset.forName("UTF-8").decode(body).toString()) + } + + @Test + fun smoke() { + val uri = "$TEST_ENDPOINT/anything" + val bodyString = "This is the POST data" + val referrer = "http://foo/bar" + + val request = WebRequest.Builder(uri) + .method("POST") + .header("Header1", "Clobbered") + .header("Header1", "Value") + .addHeader("Header2", "Value1") + .addHeader("Header2", "Value2") + .referrer(referrer) + .body(bodyString.toDirectByteBuffer()) + .build() + + val response = fetch(request) + + assertThat("URI should match", response.uri, equalTo(uri)) + assertThat("Status could should match", response.statusCode, equalTo(200)) + assertThat("Content type should match", response.headers["Content-Type"], equalTo("application/json")) + assertThat("Redirected should match", response.redirected, equalTo(false)) + + val body = response.getJSONBody() + assertThat("Method should match", body.getString("method"), equalTo("POST")) + assertThat("Headers should match", body.getJSONObject("headers").getString("Header1"), equalTo("Value")) + assertThat("Headers should match", body.getJSONObject("headers").getString("Header2"), equalTo("Value1, Value2")) + assertThat("Referrer should match", body.getJSONObject("headers").getString("Referer"), equalTo(referrer)) + assertThat("Data should match", body.getString("data"), equalTo(bodyString)); + } + + @Test + fun test404() { + val response = fetch(WebRequest("$TEST_ENDPOINT/status/404")) + assertThat("Status code should match", response.statusCode, equalTo(404)) + } + + @Test + fun testRedirect() { + val response = fetch(WebRequest("$TEST_ENDPOINT/redirect-to?url=/status/200")) + + assertThat("URI should match", response.uri, equalTo(TEST_ENDPOINT +"/status/200")) + assertThat("Redirected should match", response.redirected, equalTo(true)) + assertThat("Status code should match", response.statusCode, equalTo(200)) + } + + @Test + fun testRedirectLoop() { + thrown.expect(equalTo(WebRequestError(WebRequestError.ERROR_REDIRECT_LOOP, WebRequestError.ERROR_CATEGORY_NETWORK))) + fetch(WebRequest("$TEST_ENDPOINT/redirect/100")) + } + + @Test + fun testAuth() { + // We don't support authentication yet, but want to make sure it doesn't do anything + // silly like try to prompt the user. + val response = fetch(WebRequest("$TEST_ENDPOINT/basic-auth/foo/bar")) + assertThat("Status code should match", response.statusCode, equalTo(401)) + } + + @Test + fun testSslError() { + val uri = if (env.isAutomation) { + "https://expired.example.com/" + } else { + "https://expired.badssl.com/" + } + + thrown.expect(equalTo(WebRequestError(WebRequestError.ERROR_SECURITY_BAD_CERT, WebRequestError.ERROR_CATEGORY_SECURITY))) + fetch(WebRequest(uri)) + } + + @Test + fun testCookies() { + val uptimeMillis = SystemClock.uptimeMillis() + val response = fetch(WebRequest("$TEST_ENDPOINT/cookies/set/uptimeMillis/$uptimeMillis")) + + // We get redirected to /cookies which returns the cookies that were sent in the request + assertThat("URI should match", response.uri, equalTo("$TEST_ENDPOINT/cookies")) + assertThat("Status code should match", response.statusCode, equalTo(200)) + + val body = response.getJSONBody() + assertThat("Body should match", + body.getJSONObject("cookies").getString("uptimeMillis"), + equalTo(uptimeMillis.toString())) + + val anotherBody = fetch(WebRequest("$TEST_ENDPOINT/cookies")).getJSONBody() + assertThat("Body should match", + anotherBody.getJSONObject("cookies").getString("uptimeMillis"), + equalTo(uptimeMillis.toString())) + } + + @Test + fun testAnonymous() { + // Ensure a cookie is set for the test server + testCookies(); + + val response = fetch(WebRequest("$TEST_ENDPOINT/cookies"), + GeckoWebExecutor.FETCH_FLAGS_ANONYMOUS) + + assertThat("Status code should match", response.statusCode, equalTo(200)) + val cookies = response.getJSONBody().getJSONObject("cookies") + assertThat("Cookies should be empty", cookies.length(), equalTo(0)) + } + + @Test + fun testSpeculativeConnect() { + // We don't have a way to know if it succeeds or not, but at least we can ensure + // it doesn't explode. + executor.speculativeConnect("http://localhost") + + // This is just a fence to ensure the above actually ran. + fetch(WebRequest("$TEST_ENDPOINT/cookies")) + } + + @Test + fun testResolveV4() { + val addresses = executor.resolve("localhost").poll() + assertThat("Addresses should not be null", + addresses, notNullValue()) + assertThat("First address should be loopback", + addresses.first().isLoopbackAddress, equalTo(true)) + assertThat("First address size should be 4", + addresses.first().address.size, equalTo(4)) + } + + @Test + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.LOLLIPOP) + fun testResolveV6() { + val addresses = executor.resolve("ip6-localhost").poll() + assertThat("Addresses should not be null", + addresses, notNullValue()) + assertThat("First address should be loopback", + addresses.first().isLoopbackAddress, equalTo(true)) + assertThat("First address size should be 16", + addresses.first().address.size, equalTo(16)) + } + + @Test + fun testResolveError() { + thrown.expect(equalTo(WebRequestError(WebRequestError.ERROR_UNKNOWN_HOST, WebRequestError.ERROR_CATEGORY_URI))); + executor.resolve("this should not resolve").poll() + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoResult.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoResult.java index bfad977d3179..39d0b85b9dd8 100644 --- a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoResult.java +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoResult.java @@ -1,5 +1,6 @@ package org.mozilla.geckoview; +import org.mozilla.gecko.annotation.WrapForJNI; import org.mozilla.gecko.util.ThreadUtils; import android.os.Handler; @@ -180,6 +181,7 @@ public class GeckoResult { * Construct an incomplete GeckoResult. Call {@link #complete(Object)} or * {@link #completeExceptionally(Throwable)} in order to fulfill the result. */ + @WrapForJNI public GeckoResult() { if (ThreadUtils.isOnUiThread()) { mHandler = ThreadUtils.getUiHandler(); @@ -218,6 +220,7 @@ public class GeckoResult { * @param Type for the result. * @return The completed {@link GeckoResult} */ + @WrapForJNI public static @NonNull GeckoResult fromValue(@Nullable final U value) { final GeckoResult result = new GeckoResult<>(); result.complete(value); @@ -232,6 +235,7 @@ public class GeckoResult { * @param Type for the result if the result had been completed without exception. * @return The completed {@link GeckoResult} */ + @WrapForJNI public static @NonNull GeckoResult fromException(@NonNull final Throwable error) { final GeckoResult result = new GeckoResult<>(); result.completeExceptionally(error); @@ -501,6 +505,7 @@ public class GeckoResult { * @param value The value used to complete the result. * @throws IllegalStateException If the result is already completed. */ + @WrapForJNI public synchronized void complete(final T value) { if (mComplete) { throw new IllegalStateException("result is already complete"); @@ -520,6 +525,7 @@ public class GeckoResult { * @param exception The {@link Throwable} used to complete the result. * @throws IllegalStateException If the result is already completed. */ + @WrapForJNI public synchronized void completeExceptionally(@NonNull final Throwable exception) { if (mComplete) { throw new IllegalStateException("result is already complete"); diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoWebExecutor.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoWebExecutor.java new file mode 100644 index 000000000000..4a555da31432 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoWebExecutor.java @@ -0,0 +1,159 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * 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 org.mozilla.geckoview; + +import android.support.annotation.IntDef; +import android.support.annotation.NonNull; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +import java.net.InetAddress; +import java.nio.ByteBuffer; +import java.util.List; + +import org.mozilla.gecko.GeckoThread; +import org.mozilla.gecko.annotation.WrapForJNI; + +/** + * GeckoWebExecutor is responsible for fetching a {@link WebRequest} and delivering + * a {@link WebResponse} to the caller via {@link #fetch(WebRequest)}. Example: + *
+ *     final GeckoWebExecutor executor = new GeckoWebExecutor();
+ *
+ *     final GeckoResult<WebResponse> response = executor.fetch(
+ *             new WebRequest.Builder("https://example.org/json")
+ *             .header("Accept", "application/json")
+ *             .build());
+ *
+ *     response.then(response -> {
+ *         // Do something with response
+ *     });
+ * 
+ */ +public class GeckoWebExecutor { + // We don't use this right now because we access GeckoThread directly, but + // it's future-proofing for a world where we allow multiple GeckoRuntimes. + private final GeckoRuntime mRuntime; + + @WrapForJNI(dispatchTo = "gecko", stubName = "Fetch") + private static native void nativeFetch(WebRequest request, int flags, GeckoResult result); + + @WrapForJNI(dispatchTo = "gecko", stubName = "Resolve") + private static native void nativeResolve(String host, GeckoResult result); + + @WrapForJNI(calledFrom = "gecko", exceptionMode = "nsresult") + private static ByteBuffer createByteBuffer(int capacity) { + return ByteBuffer.allocateDirect(capacity); + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef({FETCH_FLAGS_NONE, FETCH_FLAGS_ANONYMOUS}) + public @interface FetchFlags {}; + + /** + * No special treatment. + */ + public static final int FETCH_FLAGS_NONE = 0; + + /** + * Don't send cookies or other user data along with the request. + */ + @WrapForJNI + public static final int FETCH_FLAGS_ANONYMOUS = 1; + + /** + * Create a new GeckoWebExecutor instance. + * + * @param runtime A GeckoRuntime instance + */ + public GeckoWebExecutor(final @NonNull GeckoRuntime runtime) { + mRuntime = runtime; + } + + /** + * Send the given {@link WebRequest}. + * + * @param request A {@link WebRequest} instance + * @return A GeckoResult which will be completed with a {@link WebResponse} + * @throws IllegalArgumentException if request is null or otherwise unusable. + */ + public @NonNull GeckoResult fetch(final @NonNull WebRequest request) { + return fetch(request, FETCH_FLAGS_NONE); + } + + /** + * Send the given {@link WebRequest} with specified flags. + * + * @param request A {@link WebRequest} instance + * @param flags The specified flags. One or more of {@link FetchFlags}. + * @return A GeckoResult which will be completed with a {@link WebResponse} + * @throws IllegalArgumentException if request is null or otherwise unusable. + */ + public @NonNull GeckoResult fetch(final @NonNull WebRequest request, + final @FetchFlags int flags) { + if (request.body != null && !request.body.isDirect()) { + throw new IllegalArgumentException("Request body must be a direct ByteBuffer"); + } + + if (request.cacheMode < WebRequest.CACHE_MODE_FIRST || + request.cacheMode > WebRequest.CACHE_MODE_LAST) + { + throw new IllegalArgumentException("Unknown cache mode"); + } + + // We don't need to fully validate the URI here, just a sanity check + if (!request.uri.toLowerCase().startsWith("http")) { + throw new IllegalArgumentException("URI scheme must be http or https"); + } + + final GeckoResult result = new GeckoResult<>(); + + if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) { + nativeFetch(request, flags, result); + } else { + GeckoThread.queueNativeCallUntil(GeckoThread.State.PROFILE_READY, this, + "nativeFetch", WebRequest.class, request, flags, + GeckoResult.class, result); + } + + return result; + } + + /** + * Resolves the specified host name. + * + * @param host An Internet host name, e.g. mozilla.org. + * @return A {@link GeckoResult} which will be fulfilled with a {@link List} + * of {@link InetAddress}. + */ + public GeckoResult resolve(final @NonNull String host) { + final GeckoResult result = new GeckoResult<>(); + + if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) { + nativeResolve(host, result); + } else { + GeckoThread.queueNativeCallUntil(GeckoThread.State.PROFILE_READY, this, + "nativeResolve", String.class, host, + GeckoResult.class, result); + } + return result; + } + + /** + * This causes a speculative connection to be made to the host + * in the specified URI. This is useful if an app thinks it may + * be making a request to that host in the near future. If no request + * is made, the connection will be cleaned up after an unspecified + * amount of time. + * + * @param uri A URI String. + */ + public void speculativeConnect(final @NonNull String uri) { + GeckoThread.speculativeConnect(uri); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebMessage.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebMessage.java new file mode 100644 index 000000000000..cb43c4f2c06d --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebMessage.java @@ -0,0 +1,147 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * 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 org.mozilla.geckoview; + +import org.mozilla.gecko.annotation.WrapForJNI; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import android.support.v4.util.ArrayMap; + +import java.nio.ByteBuffer; + +import java.util.Collections; +import java.util.Map; + +/** + * This is an abstract base class for HTTP request and response types. + */ +@WrapForJNI +public abstract class WebMessage { + + /** + * The URI for the request or response. + */ + public final @NonNull String uri; + + /** + * An unmodifiable Map of headers. Defaults to an empty instance. + */ + public final @NonNull Map headers; + + /** + * The body of the request or response. Must be a directly-allocated ByteBuffer. + * May be null. + */ + public final @Nullable ByteBuffer body; + + /* package */ WebMessage(Builder builder) { + uri = builder.mUri; + headers = Collections.unmodifiableMap(builder.mHeaders); + + if (builder.mBody != null) { + body = builder.mBody.asReadOnlyBuffer(); + } else { + body = null; + } + } + + // This is only used via JNI. + private String[] getHeaderKeys() { + String[] keys = new String[headers.size()]; + headers.keySet().toArray(keys); + return keys; + } + + // This is only used via JNI. + private String[] getHeaderValues() { + String[] values = new String[headers.size()]; + headers.values().toArray(values); + return values; + } + + /** + * This is a Builder used by subclasses of {@link WebMessage}. + */ + public static abstract class Builder { + /* package */ String mUri; + /* package */ Map mHeaders = new ArrayMap<>(); + /* package */ ByteBuffer mBody; + + /** + * Construct a Builder instance with the specified URI. + * + * @param uri A URI String. + */ + /* package */ Builder(final @NonNull String uri) { + uri(uri); + } + + /** + * Set the URI + * + * @param uri A URI String + * @return This Builder instance. + */ + public @NonNull Builder uri(final @NonNull String uri) { + mUri = uri; + return this; + } + + /** + * Set a HTTP header. This may be called multiple times for additional headers. If an + * existing header of the same name exists, it will be replaced by this value. + * + * @param key The key for the HTTP header, e.g. "Content-Type". + * @param value The value for the HTTP header, e.g. "application/json". + * @return This Builder instance. + */ + public @NonNull Builder header(final @NonNull String key, final @NonNull String value) { + mHeaders.put(key, value); + return this; + } + + /** + * Add a HTTP header. This may be called multiple times for additional headers. If an + * existing header of the same name exists, the values will be merged. + * + * @param key The key for the HTTP header, e.g. "Content-Type". + * @param value The value for the HTTP header, e.g. "application/json". + * @return This Builder instance. + */ + public @NonNull Builder addHeader(final @NonNull String key, final @NonNull String value) { + final String existingValue = mHeaders.get(key); + if (existingValue != null) { + final StringBuilder builder = new StringBuilder(existingValue); + builder.append(", "); + builder.append(value); + mHeaders.put(key, builder.toString()); + } else { + mHeaders.put(key, value); + } + + return this; + } + + /** + * Set the body. + * + * @param buffer A {@link ByteBuffer} with the data. + * Must be allocated directly via {@link ByteBuffer#allocateDirect(int)}. + * @return This Builder instance. + */ + public @NonNull Builder body(final @Nullable ByteBuffer buffer) { + if (buffer != null && !buffer.isDirect()) { + throw new IllegalArgumentException("body must be directly allocated"); + } + mBody = buffer; + return this; + } + } +} + diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequest.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequest.java new file mode 100644 index 000000000000..6ae8cb55fc59 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequest.java @@ -0,0 +1,194 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * 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 org.mozilla.geckoview; + +import org.mozilla.gecko.annotation.WrapForJNI; + +import android.support.annotation.IntDef; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +import java.nio.ByteBuffer; + +/** + * WebRequest represents an HTTP[S] request. The typical pattern is to create instances of this + * class via {@link WebRequest.Builder}, and fetch responses via {@link GeckoWebExecutor#fetch(WebRequest)}. + */ +@WrapForJNI +public class WebRequest extends WebMessage { + /** + * The HTTP method for the request. Defaults to "GET". + */ + public final @NonNull String method; + + /** + * The cache mode for the request. See {@link #CACHE_MODE_DEFAULT}. + * These modes match those from the DOM Fetch API. + * + * @see DOM Fetch API cache modes + */ + public final @CacheMode int cacheMode; + + /** + * The value of the Referer header for this request. + */ + public final @Nullable String referrer; + + @Retention(RetentionPolicy.SOURCE) + @IntDef({CACHE_MODE_DEFAULT, CACHE_MODE_NO_STORE, + CACHE_MODE_RELOAD, CACHE_MODE_NO_CACHE, + CACHE_MODE_FORCE_CACHE, CACHE_MODE_ONLY_IF_CACHED}) + public @interface CacheMode {}; + + /** + * Default cache mode. Normal caching rules apply. + */ + public static final int CACHE_MODE_DEFAULT = 1; + + /** + * The response will be fetched from the server without looking in + * the cache, and will not update the cache with the downloaded response. + */ + public static final int CACHE_MODE_NO_STORE = 2; + + /** + * The response will be fetched from the server without looking in + * the cache. The cache will be updated with the downloaded response. + */ + public static final int CACHE_MODE_RELOAD = 3; + + /** + * Forces a conditional request to the server if there is a cache match. + */ + public static final int CACHE_MODE_NO_CACHE = 4; + + /** + * If a response is found in the cache, it will be returned, whether it's + * fresh or not. If there is no match, a normal request will be made + * and the cache will be updated with the downloaded response. + */ + public static final int CACHE_MODE_FORCE_CACHE = 5; + + /** + * If a response is found in the cache, it will be returned, whether it's + * fresh or not. If there is no match from the cache, 504 Gateway Timeout + * will be returned. + */ + public static final int CACHE_MODE_ONLY_IF_CACHED = 6; + + /* package */ static final int CACHE_MODE_FIRST = CACHE_MODE_DEFAULT; + /* package */ static final int CACHE_MODE_LAST = CACHE_MODE_ONLY_IF_CACHED; + + /** + * Constructs a WebRequest with the specified URI. + * @param uri A URI String, e.g. https://mozilla.org + */ + public WebRequest(final @NonNull String uri) { + this(new Builder(uri)); + } + + /** + * Constructs a new WebRequest from a {@link WebRequest.Builder}. + */ + /* package */ WebRequest(@NonNull Builder builder) { + super(builder); + method = builder.mMethod; + cacheMode = builder.mCacheMode; + referrer = builder.mReferrer; + } + + /** + * Builder offers a convenient way for constructing {@link WebRequest} instances. + */ + public static class Builder extends WebMessage.Builder { + /* package */ String mMethod = "GET"; + /* package */ int mCacheMode = CACHE_MODE_DEFAULT; + /* package */ String mReferrer; + + /** + * Construct a Builder instance with the specified URI. + * + * @param uri A URI String. + */ + public Builder(final @NonNull String uri) { + super(uri); + } + + @Override + public @NonNull Builder uri(@NonNull String uri) { + super.uri(uri); + return this; + } + + @Override + public @NonNull Builder header(final @NonNull String key, final @NonNull String value) { + super.header(key, value); + return this; + } + + @Override + public @NonNull Builder addHeader(final @NonNull String key, final @NonNull String value) { + super.addHeader(key, value); + return this; + } + + @Override + public @NonNull Builder body(@Nullable ByteBuffer buffer) { + super.body(buffer); + return this; + } + + /** + * Set the HTTP method. + * + * @param method The HTTP method String. + * @return This Builder instance. + */ + public @NonNull Builder method(final @NonNull String method) { + mMethod = method; + return this; + } + + /** + * Set the cache mode. + * + * @param mode One of {@link CacheMode}. + * @return This Builder instance. + */ + public @NonNull Builder cacheMode(final @CacheMode int mode) { + if (mode < CACHE_MODE_FIRST || mode > CACHE_MODE_LAST) { + throw new IllegalArgumentException("Unknown cache mode"); + } + mCacheMode = mode; + return this; + } + + /** + * Set the HTTP Referer header. + * + * @param referrer A URI String + * @return This Builder instance. + */ + public @NonNull Builder referrer(final @Nullable String referrer) { + mReferrer = referrer; + return this; + } + + /** + * @return A {@link WebRequest} constructed with the values from this Builder instance. + */ + public @NonNull WebRequest build() { + if (mUri == null) { + throw new IllegalStateException("Must set URI"); + } + return new WebRequest(this); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebResponse.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebResponse.java new file mode 100644 index 000000000000..35ad7b4821e4 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebResponse.java @@ -0,0 +1,108 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * 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 org.mozilla.geckoview; + +import org.mozilla.gecko.annotation.WrapForJNI; + +import android.support.annotation.NonNull; + +import java.nio.ByteBuffer; + +/** + * WebResponse represents an HTTP[S] response. It is normally created + * by {@link GeckoWebExecutor#fetch(WebRequest)}. + */ +@WrapForJNI +public class WebResponse extends WebMessage { + /** + * The HTTP status code for the response, e.g. 200. + */ + public final int statusCode; + + /** + * A boolean indicating whether or not this response is + * the result of a redirection. + */ + public final boolean redirected; + + /* package */ WebResponse(Builder builder) { + super(builder); + this.statusCode = builder.mStatusCode; + this.redirected = builder.mRedirected; + } + + /** + * Builder offers a convenient way to create WebResponse instances. + */ + @WrapForJNI + public static class Builder extends WebMessage.Builder { + /* package */ int mStatusCode; + /* package */ boolean mRedirected; + + /** + * Constructs a new Builder instance with the specified URI. + * + * @param uri A URI String. + */ + public Builder(final @NonNull String uri) { + super(uri); + } + + @Override + public @NonNull Builder uri(final @NonNull String uri) { + super.uri(uri); + return this; + } + + @Override + public @NonNull Builder header(final @NonNull String key, final @NonNull String value) { + super.header(key, value); + return this; + } + + @Override + public @NonNull Builder addHeader(final @NonNull String key, final @NonNull String value) { + super.addHeader(key, value); + return this; + } + + @Override + public @NonNull Builder body(final @NonNull ByteBuffer buffer) { + super.body(buffer); + return this; + } + + /** + * Set the HTTP status code, e.g. 200. + * + * @param code A int representing the HTTP status code. + * @return This Builder instance. + */ + public @NonNull Builder statusCode(int code) { + mStatusCode = code; + return this; + } + + /** + * Set whether or not this response was the result of a redirect. + * + * @param redirected A boolean representing whether or not the request was redirected. + * @return This Builder instance. + */ + public @NonNull Builder redirected(final boolean redirected) { + mRedirected = redirected; + return this; + } + + /** + * @return A {@link WebResponse} constructed with the values from this Builder instance. + */ + public @NonNull WebResponse build() { + return new WebResponse(this); + } + } +} diff --git a/widget/android/WebExecutorSupport.cpp b/widget/android/WebExecutorSupport.cpp new file mode 100644 index 000000000000..0ce3b2288605 --- /dev/null +++ b/widget/android/WebExecutorSupport.cpp @@ -0,0 +1,501 @@ +/* -*- Mode: c++; c-basic-offset: 2; tab-width: 20; indent-tabs-mode: nil; -*- + * 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/. */ + +#include + +#include "WebExecutorSupport.h" + +#include "nsIHttpChannel.h" +#include "nsIHttpChannelInternal.h" +#include "nsIHttpHeaderVisitor.h" +#include "nsIInputStream.h" +#include "nsIStreamLoader.h" +#include "nsINSSErrorsService.h" +#include "nsIUploadChannel2.h" + +#include "nsIDNSService.h" +#include "nsIDNSListener.h" +#include "nsIDNSRecord.h" + +#include "mozilla/net/DNS.h" // for NetAddr + +#include "nsNetUtil.h" // for NS_NewURI, NS_NewChannel, NS_NewStreamLoader + +#include "InetAddress.h" // for java::sdk::InetAddress + +namespace mozilla { +using namespace net; + +namespace widget { + +static void CompleteWithError(java::GeckoResult::Param aResult, nsresult aStatus) +{ + nsCOMPtr errSvc = do_GetService("@mozilla.org/nss_errors_service;1"); + MOZ_ASSERT(errSvc); + + uint32_t errorClass; + nsresult rv = errSvc->GetErrorClass(aStatus, &errorClass); + if (NS_FAILED(rv)) { + errorClass = 0; + } + + java::WebRequestError::LocalRef error = + java::WebRequestError::FromGeckoError(int64_t(aStatus), NS_ERROR_GET_MODULE(aStatus), errorClass); + + aResult->CompleteExceptionally(error.Cast()); +} + +class ByteBufferStream final : public nsIInputStream +{ +public: + NS_DECL_THREADSAFE_ISUPPORTS + + ByteBufferStream(jni::ByteBuffer::Param buffer) + : mBuffer(buffer) + , mPosition(0) + , mClosed(false) + { + MOZ_ASSERT(mBuffer); + MOZ_ASSERT(mBuffer->Address()); + } + + NS_IMETHOD + Close() override + { + mClosed = true; + return NS_OK; + } + + NS_IMETHOD + Available(uint64_t *aResult) override + { + if (mClosed) { + return NS_BASE_STREAM_CLOSED; + } + + *aResult = (mBuffer->Capacity() - mPosition); + return NS_OK; + } + + NS_IMETHOD + Read(char *aBuf, uint32_t aCount, uint32_t *aCountRead) override + { + if (mClosed) { + return NS_BASE_STREAM_CLOSED; + } + + *aCountRead = uint32_t(std::min(uint64_t(mBuffer->Capacity() - mPosition), + uint64_t(aCount))); + + if (*aCountRead > 0) { + memcpy(aBuf, (char*)mBuffer->Address(), *aCountRead); + mPosition += *aCountRead; + } + + return NS_OK; + } + + NS_IMETHOD + ReadSegments(nsWriteSegmentFun aWriter, + void *aClosure, + uint32_t aCount, + uint32_t *aResult) override + { + return NS_ERROR_NOT_IMPLEMENTED; + } + + NS_IMETHOD + IsNonBlocking(bool *aResult) override + { + *aResult = false; + return NS_OK; + } + +protected: + virtual ~ByteBufferStream(){} + + const jni::ByteBuffer::GlobalRef mBuffer; + uint64_t mPosition; + bool mClosed; +}; + +NS_IMPL_ISUPPORTS(ByteBufferStream, nsIInputStream) + +class HeaderVisitor final : public nsIHttpHeaderVisitor +{ +public: + NS_DECL_THREADSAFE_ISUPPORTS + + HeaderVisitor(java::WebResponse::Builder::Param aBuilder) + : mBuilder(aBuilder) + { + } + + NS_IMETHOD + VisitHeader(const nsACString& aHeader, const nsACString& aValue) override + { + mBuilder->Header(aHeader, aValue); + return NS_OK; + } + +private: + virtual ~HeaderVisitor(){} + + const java::WebResponse::Builder::GlobalRef mBuilder; +}; + +NS_IMPL_ISUPPORTS(HeaderVisitor, nsIHttpHeaderVisitor) + +class LoaderListener final : public nsIStreamLoaderObserver + , public nsIRequestObserver +{ +public: + NS_DECL_THREADSAFE_ISUPPORTS + + LoaderListener(java::GeckoResult::Param aResult) + : mResult(aResult) + { + MOZ_ASSERT(mResult); + } + + NS_IMETHOD + OnStreamComplete(nsIStreamLoader* aLoader, nsISupports* aContext, + nsresult aStatus, uint32_t aResultLength, + const uint8_t* aResult) override + { + nsresult rv = HandleWebResponse(aLoader, aStatus, aResultLength, aResult); + if (NS_FAILED(rv)) { + CompleteWithError(mResult, rv); + } + + return NS_OK; + } + + NS_IMETHOD + OnStartRequest(nsIRequest* aRequest, nsISupports* aContext) override + { + return NS_OK; + } + + NS_IMETHOD + OnStopRequest(nsIRequest* aRequest, nsISupports* aContext, + nsresult aStatusCode) override + { + return NS_OK; + } + +private: + NS_IMETHOD + HandleWebResponse(nsIStreamLoader* aLoader, + nsresult aStatus, uint32_t aBodyLength, + const uint8_t* aBody) + { + NS_ENSURE_SUCCESS(aStatus, aStatus); + + nsCOMPtr request; + nsresult rv = aLoader->GetRequest(getter_AddRefs(request)); + NS_ENSURE_SUCCESS(rv, rv); + + nsCOMPtr channel = do_QueryInterface(request, &rv); + NS_ENSURE_SUCCESS(rv, rv); + + // URI + nsCOMPtr uri; + rv = channel->GetURI(getter_AddRefs(uri)); + NS_ENSURE_SUCCESS(rv, rv); + + nsAutoCString spec; + rv = uri->GetSpec(spec); + NS_ENSURE_SUCCESS(rv, rv); + + java::WebResponse::Builder::LocalRef builder = + java::WebResponse::Builder::New(spec); + + // Status code + uint32_t statusCode; + rv = channel->GetResponseStatus(&statusCode); + NS_ENSURE_SUCCESS(rv, rv); + + builder->StatusCode(statusCode); + + // Headers + RefPtr visitor = new HeaderVisitor(builder); + rv = channel->VisitResponseHeaders(visitor); + NS_ENSURE_SUCCESS(rv, rv); + + // Redirected + nsCOMPtr loadInfo; + rv = channel->GetLoadInfo(getter_AddRefs(loadInfo)); + NS_ENSURE_SUCCESS(rv, rv); + + builder->Redirected(!loadInfo->RedirectChain().IsEmpty()); + + // Body + if (aBodyLength) { + jni::ByteBuffer::LocalRef buffer; + + rv = java::GeckoWebExecutor::CreateByteBuffer(aBodyLength, &buffer); + NS_ENSURE_SUCCESS(rv, NS_ERROR_OUT_OF_MEMORY); + + MOZ_ASSERT(buffer->Address()); + MOZ_ASSERT(buffer->Capacity() == aBodyLength); + + memcpy(buffer->Address(), aBody, aBodyLength); + builder->Body(buffer); + } + + mResult->Complete(builder->Build()); + return NS_OK; + } + + virtual ~LoaderListener() {} + + const java::GeckoResult::GlobalRef mResult; +}; + +NS_IMPL_ISUPPORTS(LoaderListener, nsIStreamLoaderObserver, nsIRequestObserver) + + +class DNSListener final : public nsIDNSListener +{ +public: + NS_DECL_THREADSAFE_ISUPPORTS + + DNSListener(const nsCString& aHost, java::GeckoResult::Param aResult) + : mHost(aHost), mResult(aResult) + { + } + + NS_IMETHOD + OnLookupComplete(nsICancelable* aRequest, nsIDNSRecord* aRecord, + nsresult aStatus) override + { + if (NS_FAILED(aStatus)) { + CompleteWithError(mResult, aStatus); + return NS_OK; + } + + nsresult rv = CompleteWithRecord(aRecord); + if (NS_FAILED(rv)) { + CompleteWithError(mResult, rv); + return NS_OK; + } + + return NS_OK; + } + + NS_IMETHOD + OnLookupByTypeComplete(nsICancelable* aRequest, nsIDNSByTypeRecord* aRecord, + nsresult aStatus) override + { + MOZ_ASSERT_UNREACHABLE("unxpected nsIDNSListener callback"); + return NS_ERROR_UNEXPECTED; + } + +private: + nsresult + CompleteWithRecord(nsIDNSRecord* aRecord) + { + nsTArray addrs; + nsresult rv = aRecord->GetAddresses(addrs); + NS_ENSURE_SUCCESS(rv, rv); + + jni::ByteArray::LocalRef bytes; + auto objects = jni::ObjectArray::New(addrs.Length()); + for (size_t i = 0; i < addrs.Length(); i++) { + const auto& addr = addrs[i]; + if (addr.raw.family == AF_INET) { + bytes = + jni::ByteArray::New(reinterpret_cast(&addr.inet.ip), 4); + } else if (addr.raw.family == AF_INET6) { + bytes = + jni::ByteArray::New(reinterpret_cast(&addr.inet6.ip), 16); + } else { + // We don't handle this, skip it. + continue; + } + + objects->SetElement(i, java::sdk::InetAddress::GetByAddress(mHost, bytes)); + } + + mResult->Complete(objects); + return NS_OK; + } + + virtual ~DNSListener(){} + + const nsCString mHost; + const java::GeckoResult::GlobalRef mResult; +}; + +NS_IMPL_ISUPPORTS(DNSListener, nsIDNSListener) + +static nsresult +ConvertCacheMode(int32_t mode, int32_t& result) +{ + switch (mode) { + case java::WebRequest::CACHE_MODE_DEFAULT: + result = nsIHttpChannelInternal::FETCH_CACHE_MODE_DEFAULT; + break; + case java::WebRequest::CACHE_MODE_NO_STORE: + result = nsIHttpChannelInternal::FETCH_CACHE_MODE_NO_STORE; + break; + case java::WebRequest::CACHE_MODE_RELOAD: + result = nsIHttpChannelInternal::FETCH_CACHE_MODE_RELOAD; + break; + case java::WebRequest::CACHE_MODE_NO_CACHE: + result = nsIHttpChannelInternal::FETCH_CACHE_MODE_NO_CACHE; + break; + case java::WebRequest::CACHE_MODE_FORCE_CACHE: + result = nsIHttpChannelInternal::FETCH_CACHE_MODE_FORCE_CACHE; + break; + case java::WebRequest::CACHE_MODE_ONLY_IF_CACHED: + result = nsIHttpChannelInternal::FETCH_CACHE_MODE_ONLY_IF_CACHED; + break; + default: + return NS_ERROR_UNEXPECTED; + } + + return NS_OK; +} + +nsresult WebExecutorSupport::CreateStreamLoader(java::WebRequest::Param aRequest, + int32_t aFlags, + java::GeckoResult::Param aResult) +{ + const auto req = java::WebRequest::LocalRef(aRequest); + const auto reqBase = + java::WebMessage::LocalRef(req.Cast()); + + nsCOMPtr uri; + nsresult rv = NS_NewURI(getter_AddRefs(uri), reqBase->Uri()->ToString()); + NS_ENSURE_SUCCESS(rv, NS_ERROR_MALFORMED_URI); + + nsCOMPtr channel; + rv = NS_NewChannel(getter_AddRefs(channel), + uri, + nsContentUtils::GetSystemPrincipal(), + nsILoadInfo::SEC_ALLOW_CROSS_ORIGIN_DATA_IS_NULL, + nsIContentPolicy::TYPE_OTHER); + NS_ENSURE_SUCCESS(rv, rv); + + if (aFlags & java::GeckoWebExecutor::FETCH_FLAGS_ANONYMOUS) { + channel->SetLoadFlags(nsIRequest::LOAD_ANONYMOUS); + } + + nsCOMPtr httpChannel(do_QueryInterface(channel, &rv)); + NS_ENSURE_SUCCESS(rv, rv); + + // Method + rv = httpChannel->SetRequestMethod(aRequest->Method()->ToCString()); + NS_ENSURE_SUCCESS(rv, rv); + + // Headers + const auto keys = reqBase->GetHeaderKeys(); + const auto values = reqBase->GetHeaderValues(); + for (size_t i = 0; i < keys->Length(); i++) { + const auto key = jni::String::LocalRef(keys->GetElement(i)); + const auto value = jni::String::LocalRef(values->GetElement(i)); + + // We clobber any duplicate keys here because we've already merged them + // in the upstream WebRequest. + rv = httpChannel->SetRequestHeader(key->ToCString(), value->ToCString(), + false /* merge */); + NS_ENSURE_SUCCESS(rv, rv); + } + + // Body + const auto body = reqBase->Body(); + if (body) { + nsCOMPtr stream = new ByteBufferStream(body); + + nsCOMPtr uploadChannel(do_QueryInterface(channel, &rv)); + NS_ENSURE_SUCCESS(rv, rv); + + rv = uploadChannel->ExplicitSetUploadStream(stream, EmptyCString(), -1, + aRequest->Method()->ToCString(), false); + NS_ENSURE_SUCCESS(rv, rv); + } + + // Referrer + RefPtr referrerUri; + const auto referrer = req->Referrer(); + if (referrer) { + rv = NS_NewURI(getter_AddRefs(referrerUri), referrer->ToString()); + NS_ENSURE_SUCCESS(rv, NS_ERROR_MALFORMED_URI); + } + + rv = httpChannel->SetReferrer(referrerUri); + NS_ENSURE_SUCCESS(rv, rv); + + // Cache mode + nsCOMPtr internalChannel(do_QueryInterface(channel, &rv)); + NS_ENSURE_SUCCESS(rv, rv); + + int32_t cacheMode; + rv = ConvertCacheMode(req->CacheMode(), cacheMode); + NS_ENSURE_SUCCESS(rv, rv); + + rv = internalChannel->SetFetchCacheMode(cacheMode); + NS_ENSURE_SUCCESS(rv, rv); + + // We don't have any UI + rv = internalChannel->SetBlockAuthPrompt(true); + NS_ENSURE_SUCCESS(rv, rv); + + // All done, set up the stream loader + RefPtr listener = new LoaderListener(aResult); + + nsCOMPtr loader; + rv = NS_NewStreamLoader(getter_AddRefs(loader), listener); + NS_ENSURE_SUCCESS(rv, rv); + + // Finally, open the channel + rv = httpChannel->AsyncOpen2(loader); + NS_ENSURE_SUCCESS(rv, rv); + + return NS_OK; +} + +void WebExecutorSupport::Fetch(jni::Object::Param aRequest, int32_t aFlags, + jni::Object::Param aResult) +{ + const auto request = java::WebRequest::LocalRef(aRequest); + auto result = java::GeckoResult::LocalRef(aResult); + + nsresult rv = CreateStreamLoader(request, aFlags, result); + if (NS_FAILED(rv)) { + CompleteWithError(result, rv); + } +} + +static nsresult ResolveHost(nsCString& host, java::GeckoResult::Param result) +{ + nsresult rv; + nsCOMPtr dns = do_GetService(NS_DNSSERVICE_CONTRACTID, &rv); + NS_ENSURE_SUCCESS(rv, rv); + + nsCOMPtr cancelable; + RefPtr listener = new DNSListener(host, result); + rv = dns->AsyncResolveNative(host, 0, listener, + nullptr /* aListenerTarget */, + OriginAttributes(), + getter_AddRefs(cancelable)); + return rv; +} + +void WebExecutorSupport::Resolve(jni::String::Param aUri, jni::Object::Param aResult) +{ + auto result = java::GeckoResult::LocalRef(aResult); + + nsCString uri = aUri->ToCString(); + nsresult rv = ResolveHost(uri, result); + if (NS_FAILED(rv)) { + CompleteWithError(result, rv); + } +} + +} // widget +} // mozilla diff --git a/widget/android/WebExecutorSupport.h b/widget/android/WebExecutorSupport.h new file mode 100644 index 000000000000..c20cae5672af --- /dev/null +++ b/widget/android/WebExecutorSupport.h @@ -0,0 +1,30 @@ +/* -*- Mode: c++; c-basic-offset: 2; tab-width: 20; indent-tabs-mode: nil; -*- + * 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/. */ + +#ifndef WebExecutorSupport_h__ +#define WebExecutorSupport_h__ + +#include "GeneratedJNINatives.h" + +namespace mozilla { +namespace widget { + +class WebExecutorSupport final + : public java::GeckoWebExecutor::Natives +{ +public: + static void Fetch(jni::Object::Param request, int32_t flags, jni::Object::Param result); + static void Resolve(jni::String::Param aUri, jni::Object::Param result); + +protected: + static nsresult CreateStreamLoader(java::WebRequest::Param aRequest, + int32_t aFlags, + java::GeckoResult::Param aResult); +}; + +} // namespace widget +} // namespace mozilla + +#endif // WebExecutorSupport_h__ diff --git a/widget/android/bindings/InetAddress-classes.txt b/widget/android/bindings/InetAddress-classes.txt new file mode 100644 index 000000000000..6690e2708bf2 --- /dev/null +++ b/widget/android/bindings/InetAddress-classes.txt @@ -0,0 +1,3 @@ +# We only want getByAddress(String, byte[]) +[java.net.InetAddress = skip:true] +getByAddress(Ljava/lang/String;[B)Ljava/net/InetAddress; = skip:false diff --git a/widget/android/bindings/moz.build b/widget/android/bindings/moz.build index 62b3c0f42dd1..b3cd1108eb21 100644 --- a/widget/android/bindings/moz.build +++ b/widget/android/bindings/moz.build @@ -14,6 +14,7 @@ generated = [ 'AndroidBuild', 'AndroidInputType', 'AndroidRect', + 'InetAddress', 'JavaBuiltins', 'KeyEvent', 'MediaCodec', diff --git a/widget/android/moz.build b/widget/android/moz.build index 3b20d9da48e6..7b3b8fcf672b 100644 --- a/widget/android/moz.build +++ b/widget/android/moz.build @@ -61,6 +61,7 @@ UNIFIED_SOURCES += [ 'nsWidgetFactory.cpp', 'nsWindow.cpp', 'ScreenHelperAndroid.cpp', + 'WebExecutorSupport.cpp', ] include('/ipc/chromium/chromium-config.mozbuild') diff --git a/widget/android/nsAppShell.cpp b/widget/android/nsAppShell.cpp index 7792f8fec4f5..d0b143b5eec7 100644 --- a/widget/android/nsAppShell.cpp +++ b/widget/android/nsAppShell.cpp @@ -71,6 +71,7 @@ #include "fennec/Telemetry.h" #include "fennec/ThumbnailHelper.h" #include "ScreenHelperAndroid.h" +#include "WebExecutorSupport.h" #ifdef DEBUG_ANDROID_EVENTS #define EVLOG(args...) ALOG(args) @@ -433,6 +434,7 @@ nsAppShell::nsAppShell() mozilla::GeckoScreenOrientation::Init(); mozilla::GeckoSystemStateListener::Init(); mozilla::PrefsHelper::Init(); + mozilla::widget::WebExecutorSupport::Init(); nsWindow::InitNatives(); if (jni::IsFennec()) {