[webview_flutter] Refactored creation of Android WebView for testability. (#4178)

diff --git a/packages/webview_flutter/webview_flutter/android/build.gradle b/packages/webview_flutter/webview_flutter/android/build.gradle
index 45f769b..41c702f 100644
--- a/packages/webview_flutter/webview_flutter/android/build.gradle
+++ b/packages/webview_flutter/webview_flutter/android/build.gradle
@@ -37,5 +37,7 @@
         implementation 'androidx.annotation:annotation:1.0.0'
         implementation 'androidx.webkit:webkit:1.0.0'
         testImplementation 'junit:junit:4.12'
+        testImplementation 'org.mockito:mockito-inline:3.11.1'
+        testImplementation 'androidx.test:core:1.3.0'
     }
 }
diff --git a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java
index ebc7c31..a3b681f 100644
--- a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java
+++ b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java
@@ -17,7 +17,7 @@
 import android.webkit.WebView;
 import android.webkit.WebViewClient;
 import androidx.annotation.NonNull;
-import io.flutter.plugin.common.BinaryMessenger;
+import androidx.annotation.VisibleForTesting;
 import io.flutter.plugin.common.MethodCall;
 import io.flutter.plugin.common.MethodChannel;
 import io.flutter.plugin.common.MethodChannel.MethodCallHandler;
@@ -28,6 +28,7 @@
 import java.util.Map;
 
 public class FlutterWebView implements PlatformView, MethodCallHandler {
+
   private static final String JS_CHANNEL_NAMES_FIELD = "javascriptChannelNames";
   private final WebView webView;
   private final MethodChannel methodChannel;
@@ -36,6 +37,7 @@
 
   // Verifies that a url opened by `Window.open` has a secure url.
   private class FlutterWebChromeClient extends WebChromeClient {
+
     @Override
     public boolean onCreateWindow(
         final WebView view, boolean isDialog, boolean isUserGesture, Message resultMsg) {
@@ -83,8 +85,7 @@
   @SuppressWarnings("unchecked")
   FlutterWebView(
       final Context context,
-      BinaryMessenger messenger,
-      int id,
+      MethodChannel methodChannel,
       Map<String, Object> params,
       View containerView) {
 
@@ -93,37 +94,34 @@
         (DisplayManager) context.getSystemService(Context.DISPLAY_SERVICE);
     displayListenerProxy.onPreWebViewInitialization(displayManager);
 
-    Boolean usesHybridComposition = (Boolean) params.get("usesHybridComposition");
     webView =
-        (usesHybridComposition)
-            ? new WebView(context)
-            : new InputAwareWebView(context, containerView);
+        createWebView(
+            new WebViewBuilder(context, containerView), params, new FlutterWebChromeClient());
 
     displayListenerProxy.onPostWebViewInitialization(displayManager);
 
     platformThreadHandler = new Handler(context.getMainLooper());
-    // Allow local storage.
-    webView.getSettings().setDomStorageEnabled(true);
-    webView.getSettings().setJavaScriptCanOpenWindowsAutomatically(true);
 
-    // Multi windows is set with FlutterWebChromeClient by default to handle internal bug: b/159892679.
-    webView.getSettings().setSupportMultipleWindows(true);
-    webView.setWebChromeClient(new FlutterWebChromeClient());
-
-    methodChannel = new MethodChannel(messenger, "plugins.flutter.io/webview_" + id);
-    methodChannel.setMethodCallHandler(this);
+    this.methodChannel = methodChannel;
+    this.methodChannel.setMethodCallHandler(this);
 
     flutterWebViewClient = new FlutterWebViewClient(methodChannel);
     Map<String, Object> settings = (Map<String, Object>) params.get("settings");
-    if (settings != null) applySettings(settings);
+    if (settings != null) {
+      applySettings(settings);
+    }
 
     if (params.containsKey(JS_CHANNEL_NAMES_FIELD)) {
       List<String> names = (List<String>) params.get(JS_CHANNEL_NAMES_FIELD);
-      if (names != null) registerJavaScriptChannelNames(names);
+      if (names != null) {
+        registerJavaScriptChannelNames(names);
+      }
     }
 
     Integer autoMediaPlaybackPolicy = (Integer) params.get("autoMediaPlaybackPolicy");
-    if (autoMediaPlaybackPolicy != null) updateAutoMediaPlaybackPolicy(autoMediaPlaybackPolicy);
+    if (autoMediaPlaybackPolicy != null) {
+      updateAutoMediaPlaybackPolicy(autoMediaPlaybackPolicy);
+    }
     if (params.containsKey("userAgent")) {
       String userAgent = (String) params.get("userAgent");
       updateUserAgent(userAgent);
@@ -134,6 +132,44 @@
     }
   }
 
+  /**
+   * Creates a {@link android.webkit.WebView} and configures it according to the supplied
+   * parameters.
+   *
+   * <p>The {@link WebView} is configured with the following predefined settings:
+   *
+   * <ul>
+   *   <li>always enable the DOM storage API;
+   *   <li>always allow JavaScript to automatically open windows;
+   *   <li>always allow support for multiple windows;
+   *   <li>always use the {@link FlutterWebChromeClient} as web Chrome client.
+   * </ul>
+   *
+   * <p><strong>Important:</strong> This method is visible for testing purposes only and should
+   * never be called from outside this class.
+   *
+   * @param webViewBuilder a {@link WebViewBuilder} which is responsible for building the {@link
+   *     WebView}.
+   * @param params creation parameters received over the method channel.
+   * @param webChromeClient an implementation of WebChromeClient This value may be null.
+   * @return The new {@link android.webkit.WebView} object.
+   */
+  @VisibleForTesting
+  static WebView createWebView(
+      WebViewBuilder webViewBuilder, Map<String, Object> params, WebChromeClient webChromeClient) {
+    boolean usesHybridComposition = Boolean.TRUE.equals(params.get("usesHybridComposition"));
+    webViewBuilder
+        .setUsesHybridComposition(usesHybridComposition)
+        .setDomStorageEnabled(true) // Always enable DOM storage API.
+        .setJavaScriptCanOpenWindowsAutomatically(
+            true) // Always allow automatically opening of windows.
+        .setSupportMultipleWindows(true) // Always support multiple windows.
+        .setWebChromeClient(
+            webChromeClient); // Always use {@link FlutterWebChromeClient} as web Chrome client.
+
+    return webViewBuilder.build();
+  }
+
   @Override
   public View getView() {
     return webView;
@@ -369,7 +405,9 @@
       switch (key) {
         case "jsMode":
           Integer mode = (Integer) settings.get(key);
-          if (mode != null) updateJsMode(mode);
+          if (mode != null) {
+            updateJsMode(mode);
+          }
           break;
         case "hasNavigationDelegate":
           final boolean hasNavigationDelegate = (boolean) settings.get(key);
diff --git a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewFactory.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewFactory.java
similarity index 71%
rename from packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewFactory.java
rename to packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewFactory.java
index 22de668..8fe5810 100644
--- a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewFactory.java
+++ b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewFactory.java
@@ -7,16 +7,17 @@
 import android.content.Context;
 import android.view.View;
 import io.flutter.plugin.common.BinaryMessenger;
+import io.flutter.plugin.common.MethodChannel;
 import io.flutter.plugin.common.StandardMessageCodec;
 import io.flutter.plugin.platform.PlatformView;
 import io.flutter.plugin.platform.PlatformViewFactory;
 import java.util.Map;
 
-public final class WebViewFactory extends PlatformViewFactory {
+public final class FlutterWebViewFactory extends PlatformViewFactory {
   private final BinaryMessenger messenger;
   private final View containerView;
 
-  WebViewFactory(BinaryMessenger messenger, View containerView) {
+  FlutterWebViewFactory(BinaryMessenger messenger, View containerView) {
     super(StandardMessageCodec.INSTANCE);
     this.messenger = messenger;
     this.containerView = containerView;
@@ -26,6 +27,7 @@
   @Override
   public PlatformView create(Context context, int id, Object args) {
     Map<String, Object> params = (Map<String, Object>) args;
-    return new FlutterWebView(context, messenger, id, params, containerView);
+    MethodChannel methodChannel = new MethodChannel(messenger, "plugins.flutter.io/webview_" + id);
+    return new FlutterWebView(context, methodChannel, params, containerView);
   }
 }
diff --git a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewBuilder.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewBuilder.java
new file mode 100644
index 0000000..6b8cc51
--- /dev/null
+++ b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewBuilder.java
@@ -0,0 +1,141 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package io.flutter.plugins.webviewflutter;
+
+import android.content.Context;
+import android.view.View;
+import android.webkit.WebChromeClient;
+import android.webkit.WebSettings;
+import android.webkit.WebView;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+/** Builder used to create {@link android.webkit.WebView} objects. */
+public class WebViewBuilder {
+
+  /** Factory used to create a new {@link android.webkit.WebView} instance. */
+  static class WebViewFactory {
+
+    /**
+     * Creates a new {@link android.webkit.WebView} instance.
+     *
+     * @param context an Activity Context to access application assets. This value cannot be null.
+     * @param usesHybridComposition If {@code false} a {@link InputAwareWebView} instance is
+     *     returned.
+     * @param containerView must be supplied when the {@code useHybridComposition} parameter is set
+     *     to {@code false}. Used to create an InputConnection on the WebView's dedicated input, or
+     *     IME, thread (see also {@link InputAwareWebView})
+     * @return A new instance of the {@link android.webkit.WebView} object.
+     */
+    static WebView create(Context context, boolean usesHybridComposition, View containerView) {
+      return usesHybridComposition
+          ? new WebView(context)
+          : new InputAwareWebView(context, containerView);
+    }
+  }
+
+  private final Context context;
+  private final View containerView;
+
+  private boolean enableDomStorage;
+  private boolean javaScriptCanOpenWindowsAutomatically;
+  private boolean supportMultipleWindows;
+  private boolean usesHybridComposition;
+  private WebChromeClient webChromeClient;
+
+  /**
+   * Constructs a new {@link WebViewBuilder} object with a custom implementation of the {@link
+   * WebViewFactory} object.
+   *
+   * @param context an Activity Context to access application assets. This value cannot be null.
+   * @param containerView must be supplied when the {@code useHybridComposition} parameter is set to
+   *     {@code false}. Used to create an InputConnection on the WebView's dedicated input, or IME,
+   *     thread (see also {@link InputAwareWebView})
+   */
+  WebViewBuilder(@NonNull final Context context, View containerView) {
+    this.context = context;
+    this.containerView = containerView;
+  }
+
+  /**
+   * Sets whether the DOM storage API is enabled. The default value is {@code false}.
+   *
+   * @param flag {@code true} is {@link android.webkit.WebView} should use the DOM storage API.
+   * @return This builder. This value cannot be {@code null}.
+   */
+  public WebViewBuilder setDomStorageEnabled(boolean flag) {
+    this.enableDomStorage = flag;
+    return this;
+  }
+
+  /**
+   * Sets whether JavaScript is allowed to open windows automatically. This applies to the
+   * JavaScript function {@code window.open()}. The default value is {@code false}.
+   *
+   * @param flag {@code true} if JavaScript is allowed to open windows automatically.
+   * @return This builder. This value cannot be {@code null}.
+   */
+  public WebViewBuilder setJavaScriptCanOpenWindowsAutomatically(boolean flag) {
+    this.javaScriptCanOpenWindowsAutomatically = flag;
+    return this;
+  }
+
+  /**
+   * Sets whether the {@link WebView} supports multiple windows. If set to {@code true}, {@link
+   * WebChromeClient#onCreateWindow} must be implemented by the host application. The default is
+   * {@code false}.
+   *
+   * @param flag {@code true} if multiple windows are supported.
+   * @return This builder. This value cannot be {@code null}.
+   */
+  public WebViewBuilder setSupportMultipleWindows(boolean flag) {
+    this.supportMultipleWindows = flag;
+    return this;
+  }
+
+  /**
+   * Sets whether the hybrid composition should be used.
+   *
+   * <p>If set to {@code true} a standard {@link WebView} is created. If set to {@code false} the
+   * {@link WebViewBuilder} will create a {@link InputAwareWebView} to workaround issues using the
+   * {@link WebView} on Android versions below N.
+   *
+   * @param flag {@code true} if uses hybrid composition. The default is {@code false}.
+   * @return This builder. This value cannot be {@code null}
+   */
+  public WebViewBuilder setUsesHybridComposition(boolean flag) {
+    this.usesHybridComposition = flag;
+    return this;
+  }
+
+  /**
+   * Sets the chrome handler. This is an implementation of WebChromeClient for use in handling
+   * JavaScript dialogs, favicons, titles, and the progress. This will replace the current handler.
+   *
+   * @param webChromeClient an implementation of WebChromeClient This value may be null.
+   * @return This builder. This value cannot be {@code null}.
+   */
+  public WebViewBuilder setWebChromeClient(@Nullable WebChromeClient webChromeClient) {
+    this.webChromeClient = webChromeClient;
+    return this;
+  }
+
+  /**
+   * Build the {@link android.webkit.WebView} using the current settings.
+   *
+   * @return The {@link android.webkit.WebView} using the current settings.
+   */
+  public WebView build() {
+    WebView webView = WebViewFactory.create(context, usesHybridComposition, containerView);
+
+    WebSettings webSettings = webView.getSettings();
+    webSettings.setDomStorageEnabled(enableDomStorage);
+    webSettings.setJavaScriptCanOpenWindowsAutomatically(javaScriptCanOpenWindowsAutomatically);
+    webSettings.setSupportMultipleWindows(supportMultipleWindows);
+    webView.setWebChromeClient(webChromeClient);
+
+    return webView;
+  }
+}
diff --git a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewFlutterPlugin.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewFlutterPlugin.java
index dc329e2..268d35a 100644
--- a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewFlutterPlugin.java
+++ b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewFlutterPlugin.java
@@ -46,7 +46,7 @@
         .platformViewRegistry()
         .registerViewFactory(
             "plugins.flutter.io/webview",
-            new WebViewFactory(registrar.messenger(), registrar.view()));
+            new FlutterWebViewFactory(registrar.messenger(), registrar.view()));
     new FlutterCookieManager(registrar.messenger());
   }
 
@@ -56,7 +56,8 @@
     binding
         .getPlatformViewRegistry()
         .registerViewFactory(
-            "plugins.flutter.io/webview", new WebViewFactory(messenger, /*containerView=*/ null));
+            "plugins.flutter.io/webview",
+            new FlutterWebViewFactory(messenger, /*containerView=*/ null));
     flutterCookieManager = new FlutterCookieManager(messenger);
   }
 
diff --git a/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewTest.java b/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewTest.java
new file mode 100644
index 0000000..96cbdec
--- /dev/null
+++ b/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewTest.java
@@ -0,0 +1,61 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package io.flutter.plugins.webviewflutter;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.webkit.WebChromeClient;
+import android.webkit.WebView;
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.Before;
+import org.junit.Test;
+
+public class FlutterWebViewTest {
+  private WebChromeClient mockWebChromeClient;
+  private WebViewBuilder mockWebViewBuilder;
+  private WebView mockWebView;
+
+  @Before
+  public void before() {
+    mockWebChromeClient = mock(WebChromeClient.class);
+    mockWebViewBuilder = mock(WebViewBuilder.class);
+    mockWebView = mock(WebView.class);
+
+    when(mockWebViewBuilder.setDomStorageEnabled(anyBoolean())).thenReturn(mockWebViewBuilder);
+    when(mockWebViewBuilder.setJavaScriptCanOpenWindowsAutomatically(anyBoolean()))
+        .thenReturn(mockWebViewBuilder);
+    when(mockWebViewBuilder.setSupportMultipleWindows(anyBoolean())).thenReturn(mockWebViewBuilder);
+    when(mockWebViewBuilder.setUsesHybridComposition(anyBoolean())).thenReturn(mockWebViewBuilder);
+    when(mockWebViewBuilder.setWebChromeClient(any(WebChromeClient.class)))
+        .thenReturn(mockWebViewBuilder);
+
+    when(mockWebViewBuilder.build()).thenReturn(mockWebView);
+  }
+
+  @Test
+  public void createWebView_should_create_webview_with_default_configuration() {
+    FlutterWebView.createWebView(
+        mockWebViewBuilder, createParameterMap(false), mockWebChromeClient);
+
+    verify(mockWebViewBuilder, times(1)).setDomStorageEnabled(true);
+    verify(mockWebViewBuilder, times(1)).setJavaScriptCanOpenWindowsAutomatically(true);
+    verify(mockWebViewBuilder, times(1)).setSupportMultipleWindows(true);
+    verify(mockWebViewBuilder, times(1)).setUsesHybridComposition(false);
+    verify(mockWebViewBuilder, times(1)).setWebChromeClient(mockWebChromeClient);
+  }
+
+  private Map<String, Object> createParameterMap(boolean usesHybridComposition) {
+    Map<String, Object> params = new HashMap<>();
+    params.put("usesHybridComposition", usesHybridComposition);
+
+    return params;
+  }
+}
diff --git a/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewBuilderTest.java b/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewBuilderTest.java
new file mode 100644
index 0000000..48fbce2
--- /dev/null
+++ b/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewBuilderTest.java
@@ -0,0 +1,99 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package io.flutter.plugins.webviewflutter;
+
+import static org.junit.Assert.assertNotNull;
+import static org.mockito.Mockito.*;
+
+import android.content.Context;
+import android.view.View;
+import android.webkit.WebChromeClient;
+import android.webkit.WebSettings;
+import android.webkit.WebView;
+import io.flutter.plugins.webviewflutter.WebViewBuilder.WebViewFactory;
+import java.io.IOException;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.MockedStatic;
+import org.mockito.MockedStatic.Verification;
+
+public class WebViewBuilderTest {
+  private Context mockContext;
+  private View mockContainerView;
+  private WebView mockWebView;
+  private MockedStatic<WebViewFactory> mockedStaticWebViewFactory;
+
+  @Before
+  public void before() {
+    mockContext = mock(Context.class);
+    mockContainerView = mock(View.class);
+    mockWebView = mock(WebView.class);
+    mockedStaticWebViewFactory = mockStatic(WebViewFactory.class);
+
+    mockedStaticWebViewFactory
+        .when(
+            new Verification() {
+              @Override
+              public void apply() {
+                WebViewFactory.create(mockContext, false, mockContainerView);
+              }
+            })
+        .thenReturn(mockWebView);
+  }
+
+  @After
+  public void after() {
+    mockedStaticWebViewFactory.close();
+  }
+
+  @Test
+  public void ctor_test() {
+    WebViewBuilder builder = new WebViewBuilder(mockContext, mockContainerView);
+
+    assertNotNull(builder);
+  }
+
+  @Test
+  public void build_should_set_values() throws IOException {
+    WebSettings mockWebSettings = mock(WebSettings.class);
+    WebChromeClient mockWebChromeClient = mock(WebChromeClient.class);
+
+    when(mockWebView.getSettings()).thenReturn(mockWebSettings);
+
+    WebViewBuilder builder =
+        new WebViewBuilder(mockContext, mockContainerView)
+            .setDomStorageEnabled(true)
+            .setJavaScriptCanOpenWindowsAutomatically(true)
+            .setSupportMultipleWindows(true)
+            .setWebChromeClient(mockWebChromeClient);
+
+    WebView webView = builder.build();
+
+    assertNotNull(webView);
+    verify(mockWebSettings).setDomStorageEnabled(true);
+    verify(mockWebSettings).setJavaScriptCanOpenWindowsAutomatically(true);
+    verify(mockWebSettings).setSupportMultipleWindows(true);
+    verify(mockWebView).setWebChromeClient(mockWebChromeClient);
+  }
+
+  @Test
+  public void build_should_use_default_values() throws IOException {
+    WebSettings mockWebSettings = mock(WebSettings.class);
+    WebChromeClient mockWebChromeClient = mock(WebChromeClient.class);
+
+    when(mockWebView.getSettings()).thenReturn(mockWebSettings);
+
+    WebViewBuilder builder = new WebViewBuilder(mockContext, mockContainerView);
+
+    WebView webView = builder.build();
+
+    assertNotNull(webView);
+    verify(mockWebSettings).setDomStorageEnabled(false);
+    verify(mockWebSettings).setJavaScriptCanOpenWindowsAutomatically(false);
+    verify(mockWebSettings).setSupportMultipleWindows(false);
+    verify(mockWebView).setWebChromeClient(null);
+  }
+}