Browse Source

Synchronously decide if Android WebView should load a URL or not. (#1590)

This solves a class of issues when the WebView loses "context"
that a subsequent page load is the same as what was attempted
to be loaded previously. This solves a bug where a HTTP redirect
in combination with history manipulations causes a user to be
stuck and prevented from going back. Since WebView requests are
allowed to happen normally, debugging the WebView and tracking
redirects and page load initiators is more accurate and easier.
This will also bypass bridge latency and provide a faster navigation.

To do this, we must lock in the shouldOverrideUrlLoading callback
and send an event to JS. Currently, this callback is ran on
the main UI thread, of which we have no control over. This is
problematic as using the bridge in most ways seems to require
the main UI thread, which will cause a deadlock. However, using
BatchedBridge for Java->JS and a synchronous method for JS->Java
doesn't cause any problems. Additionally, it's been designed so
that if WebView suddenly runs the callback on a different thread
allowing for concurrency, it will continue to work.
Daniel Vicory 3 years ago
parent
commit
4d4b5e2387
No account linked to committer's email address

+ 61
- 18
android/src/main/java/com/reactnativecommunity/webview/RNCWebViewManager.java View File

@@ -4,7 +4,6 @@ import android.annotation.SuppressLint;
4 4
 import android.annotation.TargetApi;
5 5
 import android.app.DownloadManager;
6 6
 import android.content.Context;
7
-import android.content.Intent;
8 7
 import android.content.pm.ActivityInfo;
9 8
 import android.content.pm.PackageManager;
10 9
 import android.graphics.Bitmap;
@@ -14,8 +13,7 @@ import android.net.http.SslError;
14 13
 import android.net.Uri;
15 14
 import android.os.Build;
16 15
 import android.os.Environment;
17
-import androidx.annotation.RequiresApi;
18
-import androidx.core.content.ContextCompat;
16
+import android.os.SystemClock;
19 17
 import android.text.TextUtils;
20 18
 import android.util.Log;
21 19
 import android.view.Gravity;
@@ -41,6 +39,12 @@ import android.webkit.WebView;
41 39
 import android.webkit.WebViewClient;
42 40
 import android.widget.FrameLayout;
43 41
 
42
+import androidx.annotation.Nullable;
43
+import androidx.annotation.RequiresApi;
44
+import androidx.core.content.ContextCompat;
45
+import androidx.core.util.Pair;
46
+
47
+import com.facebook.common.logging.FLog;
44 48
 import com.facebook.react.views.scroll.ScrollEvent;
45 49
 import com.facebook.react.views.scroll.ScrollEventType;
46 50
 import com.facebook.react.views.scroll.OnScrollDispatchHelper;
@@ -64,6 +68,7 @@ import com.facebook.react.uimanager.annotations.ReactProp;
64 68
 import com.facebook.react.uimanager.events.ContentSizeChangeEvent;
65 69
 import com.facebook.react.uimanager.events.Event;
66 70
 import com.facebook.react.uimanager.events.EventDispatcher;
71
+import com.reactnativecommunity.webview.RNCWebViewModule.ShouldOverrideUrlLoadingLock.ShouldOverrideCallbackState;
67 72
 import com.reactnativecommunity.webview.events.TopLoadingErrorEvent;
68 73
 import com.reactnativecommunity.webview.events.TopHttpErrorEvent;
69 74
 import com.reactnativecommunity.webview.events.TopLoadingFinishEvent;
@@ -84,8 +89,7 @@ import java.util.ArrayList;
84 89
 import java.util.HashMap;
85 90
 import java.util.Locale;
86 91
 import java.util.Map;
87
-
88
-import javax.annotation.Nullable;
92
+import java.util.concurrent.atomic.AtomicReference;
89 93
 
90 94
 /**
91 95
  * Manages instances of {@link WebView}
@@ -113,6 +117,7 @@ import javax.annotation.Nullable;
113 117
  */
114 118
 @ReactModule(name = RNCWebViewManager.REACT_CLASS)
115 119
 public class RNCWebViewManager extends SimpleViewManager<WebView> {
120
+  private static final String TAG = "RNCWebViewManager";
116 121
 
117 122
   public static final int COMMAND_GO_BACK = 1;
118 123
   public static final int COMMAND_GO_FORWARD = 2;
@@ -136,6 +141,7 @@ public class RNCWebViewManager extends SimpleViewManager<WebView> {
136 141
   // Use `webView.loadUrl("about:blank")` to reliably reset the view
137 142
   // state and release page resources (including any running JavaScript).
138 143
   protected static final String BLANK_URL = "about:blank";
144
+  protected static final int SHOULD_OVERRIDE_URL_LOADING_TIMEOUT = 250;
139 145
   protected WebViewConfig mWebViewConfig;
140 146
 
141 147
   protected RNCWebChromeClient mWebChromeClient = null;
@@ -806,15 +812,52 @@ public class RNCWebViewManager extends SimpleViewManager<WebView> {
806 812
 
807 813
     @Override
808 814
     public boolean shouldOverrideUrlLoading(WebView view, String url) {
809
-      progressChangedFilter.setWaitingForCommandLoadUrl(true);
810
-      dispatchEvent(
811
-        view,
812
-        new TopShouldStartLoadWithRequestEvent(
813
-          view.getId(),
814
-          createWebViewEvent(view, url)));
815
-      return true;
816
-    }
815
+      final RNCWebView rncWebView = (RNCWebView) view;
816
+      final boolean isJsDebugging = ((ReactContext) view.getContext()).getJavaScriptContextHolder().get() == 0;
817 817
 
818
+      if (!isJsDebugging && rncWebView.mCatalystInstance != null) {
819
+        final Pair<Integer, AtomicReference<ShouldOverrideCallbackState>> lock = RNCWebViewModule.shouldOverrideUrlLoadingLock.getNewLock();
820
+        final int lockIdentifier = lock.first;
821
+        final AtomicReference<ShouldOverrideCallbackState> lockObject = lock.second;
822
+
823
+        final WritableMap event = createWebViewEvent(view, url);
824
+        event.putInt("lockIdentifier", lockIdentifier);
825
+        rncWebView.sendDirectMessage("onShouldStartLoadWithRequest", event);
826
+
827
+        try {
828
+          assert lockObject != null;
829
+          synchronized (lockObject) {
830
+            final long startTime = SystemClock.elapsedRealtime();
831
+            while (lockObject.get() == ShouldOverrideCallbackState.UNDECIDED) {
832
+              if (SystemClock.elapsedRealtime() - startTime > SHOULD_OVERRIDE_URL_LOADING_TIMEOUT) {
833
+                FLog.w(TAG, "Did not receive response to shouldOverrideUrlLoading in time, defaulting to allow loading.");
834
+                RNCWebViewModule.shouldOverrideUrlLoadingLock.removeLock(lockIdentifier);
835
+                return false;
836
+              }
837
+              lockObject.wait(SHOULD_OVERRIDE_URL_LOADING_TIMEOUT);
838
+            }
839
+          }
840
+        } catch (InterruptedException e) {
841
+          FLog.e(TAG, "shouldOverrideUrlLoading was interrupted while waiting for result.", e);
842
+          RNCWebViewModule.shouldOverrideUrlLoadingLock.removeLock(lockIdentifier);
843
+          return false;
844
+        }
845
+
846
+        final boolean shouldOverride = lockObject.get() == ShouldOverrideCallbackState.SHOULD_OVERRIDE;
847
+        RNCWebViewModule.shouldOverrideUrlLoadingLock.removeLock(lockIdentifier);
848
+
849
+        return shouldOverride;
850
+      } else {
851
+        FLog.w(TAG, "Couldn't use blocking synchronous call for onShouldStartLoadWithRequest due to debugging or missing Catalyst instance, falling back to old event-and-load.");
852
+        progressChangedFilter.setWaitingForCommandLoadUrl(true);
853
+        dispatchEvent(
854
+          view,
855
+          new TopShouldStartLoadWithRequestEvent(
856
+            view.getId(),
857
+            createWebViewEvent(view, url)));
858
+        return true;
859
+      }
860
+    }
818 861
 
819 862
     @TargetApi(Build.VERSION_CODES.N)
820 863
     @Override
@@ -1164,6 +1207,7 @@ public class RNCWebViewManager extends SimpleViewManager<WebView> {
1164 1207
      */
1165 1208
     public RNCWebView(ThemedReactContext reactContext) {
1166 1209
       super(reactContext);
1210
+      this.createCatalystInstance();
1167 1211
       progressChangedFilter = new ProgressChangedFilter();
1168 1212
     }
1169 1213
 
@@ -1272,7 +1316,6 @@ public class RNCWebViewManager extends SimpleViewManager<WebView> {
1272 1316
 
1273 1317
       if (enabled) {
1274 1318
         addJavascriptInterface(createRNCWebViewBridge(this), JAVASCRIPT_INTERFACE);
1275
-        this.createCatalystInstance();
1276 1319
       } else {
1277 1320
         removeJavascriptInterface(JAVASCRIPT_INTERFACE);
1278 1321
       }
@@ -1328,7 +1371,7 @@ public class RNCWebViewManager extends SimpleViewManager<WebView> {
1328 1371
             data.putString("data", message);
1329 1372
 
1330 1373
             if (mCatalystInstance != null) {
1331
-              mContext.sendDirectMessage(data);
1374
+              mContext.sendDirectMessage("onMessage", data);
1332 1375
             } else {
1333 1376
               dispatchEvent(webView, new TopMessageEvent(webView.getId(), data));
1334 1377
             }
@@ -1339,21 +1382,21 @@ public class RNCWebViewManager extends SimpleViewManager<WebView> {
1339 1382
         eventData.putString("data", message);
1340 1383
 
1341 1384
         if (mCatalystInstance != null) {
1342
-          this.sendDirectMessage(eventData);
1385
+          this.sendDirectMessage("onMessage", eventData);
1343 1386
         } else {
1344 1387
           dispatchEvent(this, new TopMessageEvent(this.getId(), eventData));
1345 1388
         }
1346 1389
       }
1347 1390
     }
1348 1391
 
1349
-    protected void sendDirectMessage(WritableMap data) {
1392
+    protected void sendDirectMessage(final String method, WritableMap data) {
1350 1393
       WritableNativeMap event = new WritableNativeMap();
1351 1394
       event.putMap("nativeEvent", data);
1352 1395
 
1353 1396
       WritableNativeArray params = new WritableNativeArray();
1354 1397
       params.pushMap(event);
1355 1398
 
1356
-      mCatalystInstance.callFunction(messagingModuleName, "onMessage", params);
1399
+      mCatalystInstance.callFunction(messagingModuleName, method, params);
1357 1400
     }
1358 1401
 
1359 1402
     protected void onScrollChanged(int x, int y, int oldX, int oldY) {

+ 44
- 0
android/src/main/java/com/reactnativecommunity/webview/RNCWebViewModule.java View File

@@ -12,9 +12,11 @@ import android.os.Environment;
12 12
 import android.os.Parcelable;
13 13
 import android.provider.MediaStore;
14 14
 
15
+import androidx.annotation.Nullable;
15 16
 import androidx.annotation.RequiresApi;
16 17
 import androidx.core.content.ContextCompat;
17 18
 import androidx.core.content.FileProvider;
19
+import androidx.core.util.Pair;
18 20
 
19 21
 import android.util.Log;
20 22
 import android.webkit.MimeTypeMap;
@@ -35,6 +37,8 @@ import java.io.File;
35 37
 import java.io.IOException;
36 38
 import java.util.ArrayList;
37 39
 import java.util.Arrays;
40
+import java.util.HashMap;
41
+import java.util.concurrent.atomic.AtomicReference;
38 42
 
39 43
 import static android.app.Activity.RESULT_OK;
40 44
 
@@ -50,6 +54,35 @@ public class RNCWebViewModule extends ReactContextBaseJavaModule implements Acti
50 54
   private File outputVideo;
51 55
   private DownloadManager.Request downloadRequest;
52 56
 
57
+  protected static class ShouldOverrideUrlLoadingLock {
58
+    protected enum ShouldOverrideCallbackState {
59
+      UNDECIDED,
60
+      SHOULD_OVERRIDE,
61
+      DO_NOT_OVERRIDE,
62
+    }
63
+
64
+    private int nextLockIdentifier = 0;
65
+    private final HashMap<Integer, AtomicReference<ShouldOverrideCallbackState>> shouldOverrideLocks = new HashMap<>();
66
+
67
+    public synchronized Pair<Integer, AtomicReference<ShouldOverrideCallbackState>> getNewLock() {
68
+      final int lockIdentifier = nextLockIdentifier++;
69
+      final AtomicReference<ShouldOverrideCallbackState> shouldOverride = new AtomicReference<>(ShouldOverrideCallbackState.UNDECIDED);
70
+      shouldOverrideLocks.put(lockIdentifier, shouldOverride);
71
+      return new Pair<>(lockIdentifier, shouldOverride);
72
+    }
73
+
74
+    @Nullable
75
+    public synchronized AtomicReference<ShouldOverrideCallbackState> getLock(Integer lockIdentifier) {
76
+      return shouldOverrideLocks.get(lockIdentifier);
77
+    }
78
+
79
+    public synchronized void removeLock(Integer lockIdentifier) {
80
+      shouldOverrideLocks.remove(lockIdentifier);
81
+    }
82
+  }
83
+
84
+  protected static final ShouldOverrideUrlLoadingLock shouldOverrideUrlLoadingLock = new ShouldOverrideUrlLoadingLock();
85
+
53 86
   private enum MimeType {
54 87
     DEFAULT("*/*"),
55 88
     IMAGE("image"),
@@ -105,6 +138,17 @@ public class RNCWebViewModule extends ReactContextBaseJavaModule implements Acti
105 138
     promise.resolve(result);
106 139
   }
107 140
 
141
+  @ReactMethod(isBlockingSynchronousMethod = true)
142
+  public void onShouldStartLoadWithRequestCallback(final boolean shouldStart, final int lockIdentifier) {
143
+    final AtomicReference<ShouldOverrideUrlLoadingLock.ShouldOverrideCallbackState> lockObject = shouldOverrideUrlLoadingLock.getLock(lockIdentifier);
144
+    if (lockObject != null) {
145
+      synchronized (lockObject) {
146
+        lockObject.set(shouldStart ? ShouldOverrideUrlLoadingLock.ShouldOverrideCallbackState.DO_NOT_OVERRIDE : ShouldOverrideUrlLoadingLock.ShouldOverrideCallbackState.SHOULD_OVERRIDE);
147
+        lockObject.notify();
148
+      }
149
+    }
150
+  }
151
+
108 152
   public void onActivityResult(Activity activity, int requestCode, int resultCode, Intent data) {
109 153
 
110 154
     if (filePathCallback == null && filePathCallbackLegacy == null) {

+ 7
- 3
src/WebView.android.tsx View File

@@ -77,6 +77,7 @@ class WebView extends React.Component<AndroidWebViewProps, State> {
77 77
     lastErrorEvent: null,
78 78
   };
79 79
 
80
+  onShouldStartLoadWithRequest: ReturnType<typeof createOnShouldStartLoadWithRequest> | null = null;
80 81
 
81 82
   webViewRef = React.createRef<NativeWebViewAndroid>();
82 83
 
@@ -280,8 +281,11 @@ class WebView extends React.Component<AndroidWebViewProps, State> {
280 281
   onShouldStartLoadWithRequestCallback = (
281 282
     shouldStart: boolean,
282 283
     url: string,
284
+    lockIdentifier?: number,
283 285
   ) => {
284
-    if (shouldStart) {
286
+    if (lockIdentifier) {
287
+      NativeModules.RNCWebView.onShouldStartLoadWithRequestCallback(shouldStart, lockIdentifier);
288
+    } else if (shouldStart) {
285 289
       UIManager.dispatchViewManagerCommand(
286 290
         this.getWebViewHandle(),
287 291
         this.getCommands().loadUrl,
@@ -338,7 +342,7 @@ class WebView extends React.Component<AndroidWebViewProps, State> {
338 342
     const NativeWebView
339 343
       = (nativeConfig.component as typeof NativeWebViewAndroid) || RNCWebView;
340 344
 
341
-    const onShouldStartLoadWithRequest = createOnShouldStartLoadWithRequest(
345
+    this.onShouldStartLoadWithRequest = createOnShouldStartLoadWithRequest(
342 346
       this.onShouldStartLoadWithRequestCallback,
343 347
       // casting cause it's in the default props
344 348
       originWhitelist as readonly string[],
@@ -358,7 +362,7 @@ class WebView extends React.Component<AndroidWebViewProps, State> {
358 362
         onHttpError={this.onHttpError}
359 363
         onRenderProcessGone={this.onRenderProcessGone}
360 364
         onMessage={this.onMessage}
361
-        onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
365
+        onShouldStartLoadWithRequest={this.onShouldStartLoadWithRequest}
362 366
         ref={this.webViewRef}
363 367
         // TODO: find a better way to type this.
364 368
         source={resolveAssetSource(source as ImageSourcePropType)}