Skip to content
This repository has been archived by the owner on Feb 22, 2023. It is now read-only.

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

Merged
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/webview_flutter/webview_flutter/android/build.gradle
Expand Up @@ -37,5 +37,7 @@ android {
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'
}
}
Expand Up @@ -17,6 +17,7 @@
import android.webkit.WebView;
import android.webkit.WebViewClient;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import io.flutter.plugin.common.BinaryMessenger;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
Expand All @@ -28,6 +29,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;
Expand All @@ -36,6 +38,7 @@ public class FlutterWebView implements PlatformView, MethodCallHandler {

// 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) {
Expand Down Expand Up @@ -93,37 +96,33 @@ public void onProgressChanged(WebView view, int progress) {
(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);
methodChannel = createMethodChannel(messenger, id, 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);
Expand All @@ -134,6 +133,68 @@ public void onProgressChanged(WebView view, int progress) {
}
}

/**
* 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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should pass a Map<String, Object> object here. I think a configuration object is better and we can make a static method to map the Map to a configuration object. Might be a good idea to already pass a configuration object to the FlutterWebView constructor.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, but then we should directly pass it into the FlutterWebView constructor. I can make that happen.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking into this more detailed, changing this to a configuration object requires quite a large refactor as the params also contain a second Map<String, Object> collection containing several web settings.

This means we need to change a lot of code that currently validates how to handle different situations (bases on if a key is part of the params or web settings hashmap or not). These changes are not relevant for the problem this PR is trying to solve. So for now I think it would be better to leave it as is and maybe do a separate PR on updating this if needed.

Boolean usesHybridComposition = (Boolean) params.get("usesHybridComposition");
mvanbeusekom marked this conversation as resolved.
Show resolved Hide resolved
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();
}

/**
* Creates a {@link MethodChannel} used to handle communication with the Flutter application about
* the {@link FlutterWebView} instance.
*
* <p><strong>Important:</strong> This method is visible for testing purposes only and should
* never be called from outside this class.
*
* @param messenger a {@link BinaryMessenger} to facilitate communication with Flutter.
* @param id an identifier used to create a unique channel name and identify communication with a
* particular {@link FlutterWebView} instance.
* @param handler a {@link MethodCallHandler} responsible for handling messages that arrive from
* the Flutter application.
* @return The {@link MethodChannel} configured using the supplied {@link BinaryMessenger} and
* {@link MethodCallHandler}.
*/
@VisibleForTesting
static MethodChannel createMethodChannel(
BinaryMessenger messenger, int id, MethodCallHandler handler) {
MethodChannel methodChannel = new MethodChannel(messenger, "plugins.flutter.io/webview_" + id);
methodChannel.setMethodCallHandler(handler);

return methodChannel;
}

@Override
public View getView() {
return webView;
Expand Down Expand Up @@ -369,7 +430,9 @@ private void applySettings(Map<String, Object> settings) {
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);
Expand Down
Expand Up @@ -12,11 +12,11 @@
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;
Expand Down
@@ -0,0 +1,140 @@
// 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;

/** 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(WebChromeClient webChromeClient) {
mvanbeusekom marked this conversation as resolved.
Show resolved Hide resolved
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;
}
}
Expand Up @@ -46,7 +46,7 @@ public static void registerWith(io.flutter.plugin.common.PluginRegistry.Registra
.platformViewRegistry()
.registerViewFactory(
"plugins.flutter.io/webview",
new WebViewFactory(registrar.messenger(), registrar.view()));
new FlutterWebViewFactory(registrar.messenger(), registrar.view()));
new FlutterCookieManager(registrar.messenger());
}

Expand All @@ -56,7 +56,8 @@ public void onAttachedToEngine(FlutterPluginBinding binding) {
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);
}

Expand Down
@@ -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;
}
}