diff --git a/ReactAndroid/src/main/java/com/facebook/react/modules/network/HappyEyeballs.java b/ReactAndroid/src/main/java/com/facebook/react/modules/network/HappyEyeballs.java new file mode 100644 index 000000000000..e5255f044309 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/network/HappyEyeballs.java @@ -0,0 +1,219 @@ +package com.facebook.react.modules.network; + +import android.os.Handler; + +import androidx.annotation.NonNull; + +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReferenceArray; + +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.Connection; +import okhttp3.Dns; +import okhttp3.EventListener; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; + +/** + * Simplistic HappyEyeballs implementation for OkHttp3. It currently is lacking preferential address + * selection based on previous requests in the same network. + * */ + +public class HappyEyeballs { + private final OkHttpClient.Builder mClientBuilder; + private final Request.Builder mRequestBuilder; + private final Callback mCallback; + + private List mOrderedAddresses; + private final AtomicInteger mSelectedConnectionIndex; + private final AtomicReferenceArray mCalls; + private final AtomicInteger mFailedCallsCount; + + private final Handler mHandler; + private Runnable mNextRequest; + + private static final int INITIAL_SELECTED_CONNECTION_INDEX = -1; + private static final int MAX_ADDRESSES = 8; + + HappyEyeballs(OkHttpClient.Builder clientBuilder, Request.Builder requestBuilder, Callback callback) { + mClientBuilder = clientBuilder; + mRequestBuilder = requestBuilder; + mCallback = callback; + + mSelectedConnectionIndex = new AtomicInteger(INITIAL_SELECTED_CONNECTION_INDEX); + mCalls = new AtomicReferenceArray<>(MAX_ADDRESSES); + mFailedCallsCount = new AtomicInteger(0); + + mHandler = new Handler(); + + // the runnable ensures thread safety of mOrderedAddresses + request(0, () -> queueNextRequest(1, 400)); + } + + private void request(int index, Runnable onResolveAddresses) { + OkHttpClient client = mClientBuilder + .dns(new NthAddress(index, onResolveAddresses)) + .eventListener(new SelectConnectionOnAcquire(index)) + .build(); + + Call call = client + .newCall(mRequestBuilder.build()); + + mCalls.set(index, call); + + // one of the previous connections resolved -> no need to enqueue this one + if (mSelectedConnectionIndex.get() != INITIAL_SELECTED_CONNECTION_INDEX) { + return; + } + + call.enqueue(new Callback() { + @Override + public void onFailure(@NonNull Call call, @NonNull IOException e) { + // check if DNS resolution or a step before failed + if (mOrderedAddresses == null) { + mCallback.onFailure(call, e); + return; + } + + int failedCallsCount = mFailedCallsCount.incrementAndGet(); + boolean isLastCall = failedCallsCount == mOrderedAddresses.size(); + + if (isLastCall || mSelectedConnectionIndex.get() == index) { + mCallback.onFailure(call, e); + } + } + + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException { + if (mSelectedConnectionIndex.get() != index) { + return; + } + + mCallback.onResponse(call, response); + } + }); + } + + private void queueNextRequest(int index, int delayMillis) { + if ( + mOrderedAddresses == null // DNS resolution or a step before failed + || mOrderedAddresses.size() <= index + ) { + return; + } + + Runnable nextRequest = () -> { + request(index, null); + queueNextRequest(index + 1, Math.min( + Math.round(1.5f * delayMillis), + 2000 // see https://datatracker.ietf.org/doc/html/rfc8305#section-5 + )); + }; + + mHandler.postDelayed(nextRequest, delayMillis); + mNextRequest = nextRequest; + } + + private final class NthAddress implements Dns { + private final int mAddressIndex; + private final Runnable mOnResolveDNS; + + NthAddress(int addressIndex, Runnable onResolveDNS) { + mAddressIndex = addressIndex; + mOnResolveDNS = onResolveDNS; + } + + public List lookup(@NotNull String hostname) throws UnknownHostException { + if (mOrderedAddresses == null) { + try { + mOrderedAddresses = getOrderedAddresses(hostname); + } finally { + if (mOnResolveDNS != null) { + mOnResolveDNS.run(); + } + } + } + + if (mOrderedAddresses.size() <= mAddressIndex) { + throw new UnknownHostException(); + } + + return mOrderedAddresses.subList(mAddressIndex, mAddressIndex + 1); + } + + private List getOrderedAddresses(@NotNull String hostname) throws UnknownHostException { + List addresses = Dns.SYSTEM.lookup(hostname); + + List inet4Addresses = new ArrayList<>(); + List inet6Addresses = new ArrayList<>(); + for (InetAddress address : addresses) { + if (address instanceof Inet4Address) { + inet4Addresses.add((Inet4Address) address); + } else { + inet6Addresses.add((Inet6Address) address); + } + } + + List zippedAddresses = new ArrayList<>(); + for (int i = 0; i < Math.min( + Math.max(inet4Addresses.size(), inet6Addresses.size()), + MAX_ADDRESSES / 2 + ); i++) { + if (inet6Addresses.size() > i) zippedAddresses.add(inet6Addresses.get(i)); + if (inet4Addresses.size() > i) zippedAddresses.add(inet4Addresses.get(i)); + } + + return zippedAddresses; + } + } + + private final class SelectConnectionOnAcquire extends EventListener { + private final int mConnectionIndex; + + SelectConnectionOnAcquire(int connectionIndex) { + mConnectionIndex = connectionIndex; + } + + @Override + public void connectStart(@NonNull Call call, @NonNull InetSocketAddress inetSocketAddress, @NonNull Proxy proxy) { + // combat runnable race conditions + if (mSelectedConnectionIndex.get() != INITIAL_SELECTED_CONNECTION_INDEX) { + call.cancel(); + } + } + + @Override + public void connectionAcquired(@NonNull Call call, @NonNull Connection connection) { + boolean isSelectedConnection = + mSelectedConnectionIndex.compareAndSet(INITIAL_SELECTED_CONNECTION_INDEX, mConnectionIndex); + + if (isSelectedConnection) { + mHandler.removeCallbacks(mNextRequest); + + for (int i = 0; i < MAX_ADDRESSES; i++) { + if (i == mConnectionIndex) { + continue; + } + + Call other = mCalls.get(i); + if (other != null) { + other.cancel(); + } + } + } + } + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkingModule.java b/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkingModule.java index 9c97c468db14..60306320ee5d 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkingModule.java +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkingModule.java @@ -10,6 +10,7 @@ import android.net.Uri; import android.os.Bundle; import android.util.Base64; + import androidx.annotation.Nullable; import com.facebook.common.logging.FLog; import com.facebook.fbreact.specs.NativeNetworkingAndroidSpec; @@ -24,6 +25,7 @@ import com.facebook.react.common.network.OkHttpCallUtil; import com.facebook.react.module.annotations.ReactModule; import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter; + import java.io.IOException; import java.io.InputStream; import java.nio.charset.Charset; @@ -32,6 +34,7 @@ import java.util.List; import java.util.Set; import java.util.concurrent.TimeUnit; + import okhttp3.Call; import okhttp3.Callback; import okhttp3.CookieJar; @@ -120,8 +123,10 @@ public interface ResponseHandler { for (NetworkInterceptorCreator networkInterceptorCreator : networkInterceptorCreators) { clientBuilder.addNetworkInterceptor(networkInterceptorCreator.create()); } + client = clientBuilder.build(); } + mClient = client; mCookieHandler = new ForwardingCookieHandler(reactContext); mCookieJarContainer = (CookieJarContainer) mClient.cookieJar(); @@ -353,7 +358,6 @@ public void onProgress( if (timeout != mClient.connectTimeoutMillis()) { clientBuilder.connectTimeout(timeout, TimeUnit.MILLISECONDS); } - OkHttpClient client = clientBuilder.build(); Headers requestHeaders = extractHeaders(headers, data); if (requestHeaders == null) { @@ -448,109 +452,106 @@ public void onProgress( method, wrapRequestBodyWithProgressEmitter(requestBody, eventEmitter, requestId)); addRequest(requestId); - client - .newCall(requestBuilder.build()) - .enqueue( - new Callback() { - @Override - public void onFailure(Call call, IOException e) { - if (mShuttingDown) { - return; - } - removeRequest(requestId); - String errorMessage = - e.getMessage() != null - ? e.getMessage() - : "Error while executing request: " + e.getClass().getSimpleName(); - ResponseUtil.onRequestError(eventEmitter, requestId, errorMessage, e); + new HappyEyeballs(clientBuilder, requestBuilder, new Callback() { + @Override + public void onFailure(Call call, IOException e) { + if (mShuttingDown) { + return; + } + removeRequest(requestId); + String errorMessage = + e.getMessage() != null + ? e.getMessage() + : "Error while executing request: " + e.getClass().getSimpleName(); + ResponseUtil.onRequestError(eventEmitter, requestId, errorMessage, e); + } + + @Override + public void onResponse(Call call, Response response) throws IOException { + if (mShuttingDown) { + return; + } + removeRequest(requestId); + // Before we touch the body send headers to JS + ResponseUtil.onResponseReceived( + eventEmitter, + requestId, + response.code(), + translateHeaders(response.headers()), + response.request().url().toString()); + + try { + // OkHttp implements something called transparent gzip, which mean that it will + // automatically add the Accept-Encoding gzip header and handle decoding + // internally. + // The issue is that it won't handle decoding if the user provides a + // Accept-Encoding + // header. This is also undesirable considering that iOS does handle the decoding + // even + // when the header is provided. To make sure this works in all cases, handle gzip + // body + // here also. This works fine since OKHttp will remove the Content-Encoding header + // if + // it used transparent gzip. + // See + // https://github.com/square/okhttp/blob/5b37cda9e00626f43acf354df145fd452c3031f1/okhttp/src/main/java/okhttp3/internal/http/BridgeInterceptor.java#L76-L111 + ResponseBody responseBody = response.body(); + if ("gzip".equalsIgnoreCase(response.header("Content-Encoding")) + && responseBody != null) { + GzipSource gzipSource = new GzipSource(responseBody.source()); + String contentType = response.header("Content-Type"); + responseBody = + ResponseBody.create( + contentType != null ? MediaType.parse(contentType) : null, + -1L, + Okio.buffer(gzipSource)); + } + + // Check if a handler is registered + for (ResponseHandler handler : mResponseHandlers) { + if (handler.supports(responseType)) { + WritableMap res = handler.toResponseData(responseBody); + ResponseUtil.onDataReceived(eventEmitter, requestId, res); + ResponseUtil.onRequestSuccess(eventEmitter, requestId); + return; } + } - @Override - public void onResponse(Call call, Response response) throws IOException { - if (mShuttingDown) { - return; - } - removeRequest(requestId); - // Before we touch the body send headers to JS - ResponseUtil.onResponseReceived( - eventEmitter, - requestId, - response.code(), - translateHeaders(response.headers()), - response.request().url().toString()); - - try { - // OkHttp implements something called transparent gzip, which mean that it will - // automatically add the Accept-Encoding gzip header and handle decoding - // internally. - // The issue is that it won't handle decoding if the user provides a - // Accept-Encoding - // header. This is also undesirable considering that iOS does handle the decoding - // even - // when the header is provided. To make sure this works in all cases, handle gzip - // body - // here also. This works fine since OKHttp will remove the Content-Encoding header - // if - // it used transparent gzip. - // See - // https://github.com/square/okhttp/blob/5b37cda9e00626f43acf354df145fd452c3031f1/okhttp/src/main/java/okhttp3/internal/http/BridgeInterceptor.java#L76-L111 - ResponseBody responseBody = response.body(); - if ("gzip".equalsIgnoreCase(response.header("Content-Encoding")) - && responseBody != null) { - GzipSource gzipSource = new GzipSource(responseBody.source()); - String contentType = response.header("Content-Type"); - responseBody = - ResponseBody.create( - contentType != null ? MediaType.parse(contentType) : null, - -1L, - Okio.buffer(gzipSource)); - } - - // Check if a handler is registered - for (ResponseHandler handler : mResponseHandlers) { - if (handler.supports(responseType)) { - WritableMap res = handler.toResponseData(responseBody); - ResponseUtil.onDataReceived(eventEmitter, requestId, res); - ResponseUtil.onRequestSuccess(eventEmitter, requestId); - return; - } - } - - // If JS wants progress updates during the download, and it requested a text - // response, - // periodically send response data updates to JS. - if (useIncrementalUpdates && responseType.equals("text")) { - readWithProgress(eventEmitter, requestId, responseBody); - ResponseUtil.onRequestSuccess(eventEmitter, requestId); - return; - } - - // Otherwise send the data in one big chunk, in the format that JS requested. - String responseString = ""; - if (responseType.equals("text")) { - try { - responseString = responseBody.string(); - } catch (IOException e) { - if (response.request().method().equalsIgnoreCase("HEAD")) { - // The request is an `HEAD` and the body is empty, - // the OkHttp will produce an exception. - // Ignore the exception to not invalidate the request in the - // Javascript layer. - // Introduced to fix issue #7463. - } else { - ResponseUtil.onRequestError(eventEmitter, requestId, e.getMessage(), e); - } - } - } else if (responseType.equals("base64")) { - responseString = Base64.encodeToString(responseBody.bytes(), Base64.NO_WRAP); - } - ResponseUtil.onDataReceived(eventEmitter, requestId, responseString); - ResponseUtil.onRequestSuccess(eventEmitter, requestId); - } catch (IOException e) { + // If JS wants progress updates during the download, and it requested a text + // response, + // periodically send response data updates to JS. + if (useIncrementalUpdates && responseType.equals("text")) { + readWithProgress(eventEmitter, requestId, responseBody); + ResponseUtil.onRequestSuccess(eventEmitter, requestId); + return; + } + + // Otherwise send the data in one big chunk, in the format that JS requested. + String responseString = ""; + if (responseType.equals("text")) { + try { + responseString = responseBody.string(); + } catch (IOException e) { + if (response.request().method().equalsIgnoreCase("HEAD")) { + // The request is an `HEAD` and the body is empty, + // the OkHttp will produce an exception. + // Ignore the exception to not invalidate the request in the + // Javascript layer. + // Introduced to fix issue #7463. + } else { ResponseUtil.onRequestError(eventEmitter, requestId, e.getMessage(), e); } } - }); + } else if (responseType.equals("base64")) { + responseString = Base64.encodeToString(responseBody.bytes(), Base64.NO_WRAP); + } + ResponseUtil.onDataReceived(eventEmitter, requestId, responseString); + ResponseUtil.onRequestSuccess(eventEmitter, requestId); + } catch (IOException e) { + ResponseUtil.onRequestError(eventEmitter, requestId, e.getMessage(), e); + } + } + }); } private RequestBody wrapRequestBodyWithProgressEmitter(