From 274c5c78c4933a09335fca0e2fdc266c80fe4017 Mon Sep 17 00:00:00 2001 From: Alexander Blom Date: Mon, 23 Nov 2015 02:16:51 -0800 Subject: [PATCH] Support cookies on Android Summary: This adds a persistent cookie store that shares cookies with WebView. Add a `ForwardingCookieHandler` to OkHttp that uses the underlying Android webkit `CookieManager`. Use a `LazyCookieHandler` to defer initialization of `CookieManager` as this will in turn trigger initialization of the Chromium stack in KitKat+ which takes some time. This was we will incur this cost on a background network thread instead of during startup. Also add a `clearCookies()` method to the network module. Add a cookies example to the XHR example. This example should also work for iOS (except for the clear cookies part). They are for now just scoped to Android. Closes #2792. public Reviewed By: andreicoman11 Differential Revision: D2615550 fb-gh-sync-id: ff726a35f0fc3c7124d2f755448fe24c9d1caf21 --- Examples/UIExplorer/XHRExample.android.js | 7 + Examples/UIExplorer/XHRExampleCookies.js | 128 ++++++++++ Libraries/Network/RCTNetworking.android.js | 4 + .../react/modules/fresco/FrescoModule.java | 3 +- .../network/ForwardingCookieHandler.java | 230 ++++++++++++++++++ .../modules/network/NetworkingModule.java | 26 +- .../modules/network/OkHttpClientProvider.java | 36 ++- 7 files changed, 419 insertions(+), 15 deletions(-) create mode 100644 Examples/UIExplorer/XHRExampleCookies.js create mode 100644 ReactAndroid/src/main/java/com/facebook/react/modules/network/ForwardingCookieHandler.java diff --git a/Examples/UIExplorer/XHRExample.android.js b/Examples/UIExplorer/XHRExample.android.js index ab272d133..ad4bbf772 100644 --- a/Examples/UIExplorer/XHRExample.android.js +++ b/Examples/UIExplorer/XHRExample.android.js @@ -27,6 +27,8 @@ var { } = React; var XHRExampleHeaders = require('./XHRExampleHeaders'); +var XHRExampleCookies = require('./XHRExampleCookies'); + // TODO t7093728 This is a simlified XHRExample.ios.js. // Once we have Camera roll, Toast, Intent (for opening URLs) @@ -284,6 +286,11 @@ exports.examples = [{ render() { return ; } +}, { + title: 'Cookies', + render() { + return ; + } }]; var styles = StyleSheet.create({ diff --git a/Examples/UIExplorer/XHRExampleCookies.js b/Examples/UIExplorer/XHRExampleCookies.js new file mode 100644 index 000000000..310accc19 --- /dev/null +++ b/Examples/UIExplorer/XHRExampleCookies.js @@ -0,0 +1,128 @@ +/** + * The examples provided by Facebook are for non-commercial testing and + * evaluation purposes only. + * + * Facebook reserves all rights not expressly granted. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL + * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + * @flow + */ +'use strict'; + +var React = require('react-native'); +var { + StyleSheet, + Text, + TouchableHighlight, + View, +} = React; + +var RCTNetworking = require('RCTNetworking'); + +class XHRExampleCookies extends React.Component { + constructor(props: any) { + super(props); + this.cancelled = false; + this.state = { + status: '', + a: 1, + b: 2, + }; + } + + setCookie(domain: string) { + var {a, b} = this.state; + var url = `https://${domain}/cookies/set?a=${a}&b=${b}`; + fetch(url).then((response) => { + this.setStatus(`Cookies a=${a}, b=${b} set`); + }); + + this.setState({ + status: 'Setting cookies...', + a: a + 1, + b: b + 2, + }); + } + + getCookies(domain: string) { + fetch(`https://${domain}/cookies`).then((response) => { + return response.json(); + }).then((data) => { + this.setStatus(`Got cookies ${JSON.stringify(data.cookies)} from server`); + }); + + this.setStatus('Getting cookies...'); + } + + clearCookies() { + RCTNetworking.clearCookies((cleared) => { + this.setStatus('Cookies cleared, had cookies=' + cleared); + }); + } + + setStatus(status: string) { + this.setState({status}); + } + + render() { + return ( + + + + Set cookie + + + + + Set cookie (EU) + + + + + Get cookies + + + + + Get cookies (EU) + + + + + Clear cookies + + + {this.state.status} + + ); + } +} + +var styles = StyleSheet.create({ + wrapper: { + borderRadius: 5, + marginBottom: 5, + }, + button: { + backgroundColor: '#eeeeee', + padding: 8, + }, +}); + +module.exports = XHRExampleCookies; diff --git a/Libraries/Network/RCTNetworking.android.js b/Libraries/Network/RCTNetworking.android.js index e8c4be0bd..351524eb9 100644 --- a/Libraries/Network/RCTNetworking.android.js +++ b/Libraries/Network/RCTNetworking.android.js @@ -40,6 +40,10 @@ class RCTNetworking { static abortRequest(requestId) { RCTNetworkingNative.abortRequest(requestId); } + + static clearCookies(callback) { + RCTNetworkingNative.clearCookies(callback); + } } module.exports = RCTNetworking; diff --git a/ReactAndroid/src/main/java/com/facebook/react/modules/fresco/FrescoModule.java b/ReactAndroid/src/main/java/com/facebook/react/modules/fresco/FrescoModule.java index 14b95c0ae..8a980cb46 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/modules/fresco/FrescoModule.java +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/fresco/FrescoModule.java @@ -80,7 +80,8 @@ public class FrescoModule extends ReactContextBaseJavaModule implements } Context context = this.getReactApplicationContext().getApplicationContext(); - OkHttpClient okHttpClient = OkHttpClientProvider.getOkHttpClient(); + OkHttpClient okHttpClient = + OkHttpClientProvider.getCookieAwareOkHttpClient(getReactApplicationContext()); ImagePipelineConfig.Builder builder = OkHttpImagePipelineConfigFactory.newBuilder(context, okHttpClient); diff --git a/ReactAndroid/src/main/java/com/facebook/react/modules/network/ForwardingCookieHandler.java b/ReactAndroid/src/main/java/com/facebook/react/modules/network/ForwardingCookieHandler.java new file mode 100644 index 000000000..d24540cea --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/network/ForwardingCookieHandler.java @@ -0,0 +1,230 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +package com.facebook.react.modules.network; + +import javax.annotation.Nullable; + +import java.io.IOException; +import java.net.CookieHandler; +import java.net.URI; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import android.annotation.TargetApi; +import android.content.Context; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.text.TextUtils; +import android.webkit.CookieManager; +import android.webkit.CookieSyncManager; +import android.webkit.ValueCallback; + +import com.facebook.react.bridge.Callback; +import com.facebook.react.bridge.GuardedAsyncTask; +import com.facebook.react.bridge.GuardedResultAsyncTask; +import com.facebook.react.bridge.ReactContext; + +/** + * Cookie handler that forwards all cookies to the WebView CookieManager. + * + * This class relies on CookieManager to persist cookies to disk so cookies may be lost if the + * application is terminated before it syncs. + */ +public class ForwardingCookieHandler extends CookieHandler { + private static final String VERSION_ZERO_HEADER = "Set-cookie"; + private static final String VERSION_ONE_HEADER = "Set-cookie2"; + private static final String COOKIE_HEADER = "Cookie"; + + // As CookieManager was synchronous before API 21 this class emulates the async behavior on <21. + private static final boolean USES_LEGACY_STORE = Build.VERSION.SDK_INT < 21; + + private final CookieSaver mCookieSaver; + private final ReactContext mContext; + private @Nullable CookieManager mCookieManager; + + public ForwardingCookieHandler(ReactContext context) { + mContext = context; + mCookieSaver = new CookieSaver(); + } + + @Override + public Map> get(URI uri, Map> headers) + throws IOException { + String cookies = getCookieManager().getCookie(uri.toString()); + if (TextUtils.isEmpty(cookies)) { + return Collections.emptyMap(); + } + + return Collections.singletonMap(COOKIE_HEADER, Collections.singletonList(cookies)); + } + + @Override + public void put(URI uri, Map> headers) throws IOException { + String url = uri.toString(); + for (Map.Entry> entry : headers.entrySet()) { + String key = entry.getKey(); + if (key != null && isCookieHeader(key)) { + addCookies(url, entry.getValue()); + } + } + } + + public void clearCookies(final Callback callback) { + if (USES_LEGACY_STORE) { + new GuardedResultAsyncTask(mContext) { + @Override + protected Boolean doInBackgroundGuarded() { + getCookieManager().removeAllCookie(); + mCookieSaver.onCookiesModified(); + return true; + } + + @Override + protected void onPostExecuteGuarded(Boolean result) { + callback.invoke(result); + } + }.execute(); + } else { + clearCookiesAsync(callback); + } + } + + private void clearCookiesAsync(final Callback callback) { + getCookieManager().removeAllCookies( + new ValueCallback() { + @Override + public void onReceiveValue(Boolean value) { + mCookieSaver.onCookiesModified(); + callback.invoke(value); + } + }); + } + + public void destroy() { + if (USES_LEGACY_STORE) { + getCookieManager().removeExpiredCookie(); + mCookieSaver.persistCookies(); + } + } + + private void addCookies(final String url, final List cookies) { + if (USES_LEGACY_STORE) { + runInBackground( + new Runnable() { + @Override + public void run() { + for (String cookie : cookies) { + getCookieManager().setCookie(url, cookie); + } + mCookieSaver.onCookiesModified(); + } + }); + } else { + for (String cookie : cookies) { + addCookieAsync(url, cookie); + } + mCookieSaver.onCookiesModified(); + } + } + + @TargetApi(21) + private void addCookieAsync(String url, String cookie) { + getCookieManager().setCookie(url, cookie, null); + } + + private static boolean isCookieHeader(String name) { + return name.equalsIgnoreCase(VERSION_ZERO_HEADER) || name.equalsIgnoreCase(VERSION_ONE_HEADER); + } + + private void runInBackground(final Runnable runnable) { + new GuardedAsyncTask(mContext) { + @Override + protected void doInBackgroundGuarded(Void... params) { + runnable.run(); + } + }.execute(); + } + + /** + * Instantiating CookieManager in KitKat+ will load the Chromium task taking a 100ish ms so we + * do it lazily to make sure it's done on a background thread as needed. + */ + private CookieManager getCookieManager() { + if (mCookieManager == null) { + possiblyWorkaroundSyncManager(mContext); + mCookieManager = CookieManager.getInstance(); + + if (USES_LEGACY_STORE) { + mCookieManager.removeExpiredCookie(); + } + } + + return mCookieManager; + } + + private static void possiblyWorkaroundSyncManager(Context context) { + if (USES_LEGACY_STORE) { + // This is to work around a bug where CookieManager may fail to instantiate if + // CookieSyncManager has never been created. Note that the sync() may not be required but is + // here of legacy reasons. + CookieSyncManager syncManager = CookieSyncManager.createInstance(context); + syncManager.sync(); + } + } + + /** + * Responsible for flushing cookies to disk. Flushes to disk with a maximum delay of 30 seconds. + * This class is only active if we are on API < 21. + */ + private class CookieSaver { + private static final int MSG_PERSIST_COOKIES = 1; + + private static final int TIMEOUT = 30 * 1000; // 30 seconds + + private final Handler mHandler; + + public CookieSaver() { + mHandler = new Handler(Looper.getMainLooper(), new Handler.Callback() { + @Override + public boolean handleMessage(Message msg) { + if (msg.what == MSG_PERSIST_COOKIES) { + persistCookies(); + return true; + } else { + return false; + } + } + }); + } + + public void onCookiesModified() { + if (USES_LEGACY_STORE) { + mHandler.sendEmptyMessageDelayed(MSG_PERSIST_COOKIES, TIMEOUT); + } + } + + public void persistCookies() { + mHandler.removeMessages(MSG_PERSIST_COOKIES); + runInBackground( + new Runnable() { + @Override + public void run() { + if (USES_LEGACY_STORE) { + CookieSyncManager syncManager = CookieSyncManager.getInstance(); + syncManager.sync(); + } else { + flush(); + } + } + }); + } + + @TargetApi(21) + private void flush() { + getCookieManager().flush(); + } + } +} 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 4ddbf1f60..db316e371 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 @@ -14,6 +14,7 @@ import javax.annotation.Nullable; import java.io.IOException; import java.io.InputStream; import java.io.Reader; +import java.net.CookieHandler; import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.GuardedAsyncTask; @@ -72,19 +73,19 @@ public final class NetworkingModule extends ReactContextBaseJavaModule { } /** - * @param reactContext the ReactContext of the application + * @param context the ReactContext of the application */ - public NetworkingModule(ReactApplicationContext reactContext) { - this(reactContext, null, OkHttpClientProvider.getOkHttpClient()); + public NetworkingModule(final ReactApplicationContext context) { + this(context, null, OkHttpClientProvider.getCookieAwareOkHttpClient(context)); } /** - * @param reactContext the ReactContext of the application + * @param context the ReactContext of the application * @param defaultUserAgent the User-Agent header that will be set for all requests where the * caller does not provide one explicitly */ - public NetworkingModule(ReactApplicationContext reactContext, String defaultUserAgent) { - this(reactContext, defaultUserAgent, OkHttpClientProvider.getOkHttpClient()); + public NetworkingModule(ReactApplicationContext context, String defaultUserAgent) { + this(context, defaultUserAgent, OkHttpClientProvider.getCookieAwareOkHttpClient(context)); } public NetworkingModule(ReactApplicationContext reactContext, OkHttpClient client) { @@ -100,6 +101,11 @@ public final class NetworkingModule extends ReactContextBaseJavaModule { public void onCatalystInstanceDestroy() { mShuttingDown = true; mClient.cancel(null); + + CookieHandler cookieHandler = mClient.getCookieHandler(); + if (cookieHandler instanceof ForwardingCookieHandler) { + ((ForwardingCookieHandler) cookieHandler).destroy(); + } } @ReactMethod @@ -311,6 +317,14 @@ public final class NetworkingModule extends ReactContextBaseJavaModule { }.execute(); } + @ReactMethod + public void clearCookies(com.facebook.react.bridge.Callback callback) { + CookieHandler cookieHandler = mClient.getCookieHandler(); + if (cookieHandler instanceof ForwardingCookieHandler) { + ((ForwardingCookieHandler) cookieHandler).clearCookies(callback); + } + } + private @Nullable MultipartBuilder constructMultipartBody( ReadableArray body, String contentType, diff --git a/ReactAndroid/src/main/java/com/facebook/react/modules/network/OkHttpClientProvider.java b/ReactAndroid/src/main/java/com/facebook/react/modules/network/OkHttpClientProvider.java index fb7002013..699ffa525 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/modules/network/OkHttpClientProvider.java +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/network/OkHttpClientProvider.java @@ -9,7 +9,12 @@ package com.facebook.react.modules.network; +import javax.annotation.Nullable; + import java.util.concurrent.TimeUnit; + +import com.facebook.react.bridge.ReactContext; + import com.squareup.okhttp.OkHttpClient; /** @@ -19,18 +24,33 @@ import com.squareup.okhttp.OkHttpClient; public class OkHttpClientProvider { // Centralized OkHttpClient for all networking requests. - private static OkHttpClient sClient; + private static @Nullable OkHttpClient sClient; + private static ForwardingCookieHandler sCookieHandler; public static OkHttpClient getOkHttpClient() { if (sClient == null) { - // TODO: #7108751 plug in stetho - sClient = new OkHttpClient(); - - // No timeouts by default - sClient.setConnectTimeout(0, TimeUnit.MILLISECONDS); - sClient.setReadTimeout(0, TimeUnit.MILLISECONDS); - sClient.setWriteTimeout(0, TimeUnit.MILLISECONDS); + sClient = createClient(); } return sClient; } + + public static OkHttpClient getCookieAwareOkHttpClient(ReactContext context) { + if (sCookieHandler == null) { + sCookieHandler = new ForwardingCookieHandler(context); + getOkHttpClient().setCookieHandler(sCookieHandler); + } + return getOkHttpClient(); + } + + private static OkHttpClient createClient() { + // TODO: #7108751 plug in stetho + OkHttpClient client = new OkHttpClient(); + + // No timeouts by default + client.setConnectTimeout(0, TimeUnit.MILLISECONDS); + client.setReadTimeout(0, TimeUnit.MILLISECONDS); + client.setWriteTimeout(0, TimeUnit.MILLISECONDS); + + return client; + } }