diff --git a/packages/webview_flutter/CHANGELOG.md b/packages/webview_flutter/CHANGELOG.md index 889a6a144424..7a3256a350b3 100644 --- a/packages/webview_flutter/CHANGELOG.md +++ b/packages/webview_flutter/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.3.4 + +* Support specifying navigation delegates that can prevent navigations from being executed. + ## 0.3.3+2 * Exclude LongPress handler from semantics tree since it does nothing. diff --git a/packages/webview_flutter/android/build.gradle b/packages/webview_flutter/android/build.gradle index 098c2f4bf388..45ab74d37938 100644 --- a/packages/webview_flutter/android/build.gradle +++ b/packages/webview_flutter/android/build.gradle @@ -44,4 +44,8 @@ android { lintOptions { disable 'InvalidPackage' } + + dependencies { + implementation 'androidx.webkit:webkit:1.0.0' + } } diff --git a/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java b/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java index 44fe31fcf758..070ba74a9669 100644 --- a/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java +++ b/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java @@ -4,7 +4,9 @@ package io.flutter.plugins.webviewflutter; +import android.annotation.TargetApi; import android.content.Context; +import android.os.Build; import android.view.View; import android.webkit.WebStorage; import android.webkit.WebView; @@ -21,6 +23,7 @@ public class FlutterWebView implements PlatformView, MethodCallHandler { private static final String JS_CHANNEL_NAMES_FIELD = "javascriptChannelNames"; private final WebView webView; private final MethodChannel methodChannel; + private final FlutterWebViewClient flutterWebViewClient; @SuppressWarnings("unchecked") FlutterWebView(Context context, BinaryMessenger messenger, int id, Map params) { @@ -31,12 +34,15 @@ public class FlutterWebView implements PlatformView, MethodCallHandler { methodChannel = new MethodChannel(messenger, "plugins.flutter.io/webview_" + id); methodChannel.setMethodCallHandler(this); + flutterWebViewClient = new FlutterWebViewClient(methodChannel); applySettings((Map) params.get("settings")); if (params.containsKey(JS_CHANNEL_NAMES_FIELD)) { registerJavaScriptChannelNames((List) params.get(JS_CHANNEL_NAMES_FIELD)); } + webView.setWebViewClient(flutterWebViewClient); + if (params.containsKey("initialUrl")) { String url = (String) params.get("initialUrl"); webView.loadUrl(url); @@ -135,6 +141,7 @@ private void updateSettings(MethodCall methodCall, Result result) { result.success(null); } + @TargetApi(Build.VERSION_CODES.KITKAT) private void evaluateJavaScript(MethodCall methodCall, final Result result) { String jsString = (String) methodCall.arguments; if (jsString == null) { @@ -178,6 +185,9 @@ private void applySettings(Map settings) { case "jsMode": updateJsMode((Integer) settings.get(key)); break; + case "hasNavigationDelegate": + flutterWebViewClient.setHasNavigationDelegate((boolean) settings.get(key)); + break; default: throw new IllegalArgumentException("Unknown WebView setting: " + key); } diff --git a/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewClient.java b/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewClient.java new file mode 100644 index 000000000000..fe4482c154b0 --- /dev/null +++ b/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewClient.java @@ -0,0 +1,124 @@ +// Copyright 2019 The Chromium 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.annotation.TargetApi; +import android.os.Build; +import android.util.Log; +import android.webkit.WebResourceRequest; +import android.webkit.WebView; +import androidx.webkit.WebViewClientCompat; +import io.flutter.plugin.common.MethodChannel; +import java.util.HashMap; +import java.util.Map; + +// We need to use WebViewClientCompat to get +// shouldOverrideUrlLoading(WebView view, WebResourceRequest request) +// invoked by the webview on older Android devices, without it pages that use iframes will +// be broken when a navigationDelegate is set on Android version earlier than N. +class FlutterWebViewClient extends WebViewClientCompat { + private static final String TAG = "FlutterWebViewClient"; + private final MethodChannel methodChannel; + private boolean hasNavigationDelegate; + + FlutterWebViewClient(MethodChannel methodChannel) { + this.methodChannel = methodChannel; + } + + void setHasNavigationDelegate(boolean hasNavigationDelegate) { + this.hasNavigationDelegate = hasNavigationDelegate; + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + @Override + public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { + if (!hasNavigationDelegate) { + return false; + } + notifyOnNavigationRequest( + request.getUrl().toString(), request.getRequestHeaders(), view, request.isForMainFrame()); + // We must make a synchronous decision here whether to allow the navigation or not, + // if the Dart code has set a navigation delegate we want that delegate to decide whether + // to navigate or not, and as we cannot get a response from the Dart delegate synchronously we + // return true here to block the navigation, if the Dart delegate decides to allow the + // navigation the plugin will later make an addition loadUrl call for this url. + // + // Since we cannot call loadUrl for a subframe, we currently only allow the delegate to stop + // navigations that target the main frame, if the request is not for the main frame + // we just return false to allow the navigation. + // + // For more details see: https://github.com/flutter/flutter/issues/25329#issuecomment-464863209 + return request.isForMainFrame(); + } + + @Override + public boolean shouldOverrideUrlLoading(WebView view, String url) { + if (!hasNavigationDelegate) { + return false; + } + // This version of shouldOverrideUrlLoading is only invoked by the webview on devices with + // webview versions earlier than 67(it is also invoked when hasNavigationDelegate is false). + // On these devices we cannot tell whether the navigation is targeted to the main frame or not. + // We proceed assuming that the navigation is targeted to the main frame. If the page had any + // frames they will be loaded in the main frame instead. + Log.w( + TAG, + "Using a navigationDelegate with an old webview implementation, pages with frames or iframes will not work"); + notifyOnNavigationRequest(url, null, view, true); + return true; + } + + private void notifyOnNavigationRequest( + String url, Map headers, WebView webview, boolean isMainFrame) { + HashMap args = new HashMap<>(); + args.put("url", url); + args.put("isForMainFrame", isMainFrame); + if (isMainFrame) { + methodChannel.invokeMethod( + "navigationRequest", args, new OnNavigationRequestResult(url, headers, webview)); + } else { + methodChannel.invokeMethod("navigationRequest", args); + } + } + + private static class OnNavigationRequestResult implements MethodChannel.Result { + private final String url; + private final Map headers; + private final WebView webView; + + private OnNavigationRequestResult(String url, Map headers, WebView webView) { + this.url = url; + this.headers = headers; + this.webView = webView; + } + + @Override + public void success(Object shouldLoad) { + Boolean typedShouldLoad = (Boolean) shouldLoad; + if (typedShouldLoad) { + loadUrl(); + } + } + + @Override + public void error(String errorCode, String s1, Object o) { + throw new IllegalStateException("navigationRequest calls must succeed"); + } + + @Override + public void notImplemented() { + throw new IllegalStateException( + "navigationRequest must be implemented by the webview method channel"); + } + + private void loadUrl() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + webView.loadUrl(url, headers); + } else { + webView.loadUrl(url); + } + } + } +} diff --git a/packages/webview_flutter/example/android/gradle.properties b/packages/webview_flutter/example/android/gradle.properties index 8bd86f680510..ad8917e962e5 100644 --- a/packages/webview_flutter/example/android/gradle.properties +++ b/packages/webview_flutter/example/android/gradle.properties @@ -1 +1,2 @@ org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true \ No newline at end of file diff --git a/packages/webview_flutter/example/lib/main.dart b/packages/webview_flutter/example/lib/main.dart index f688b602a359..7d6ce10a56ad 100644 --- a/packages/webview_flutter/example/lib/main.dart +++ b/packages/webview_flutter/example/lib/main.dart @@ -3,11 +3,27 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:webview_flutter/webview_flutter.dart'; void main() => runApp(MaterialApp(home: WebViewExample())); +const String kNavigationExamplePage = ''' + +Navigation Delegate Example + +

+The navigation delegate is set to block navigation to the youtube website. +

+ + + +'''; + class WebViewExample extends StatelessWidget { final Completer _controller = Completer(); @@ -37,6 +53,14 @@ class WebViewExample extends StatelessWidget { javascriptChannels: [ _toasterJavascriptChannel(context), ].toSet(), + navigationDelegate: (NavigationRequest request) { + if (request.url.startsWith('https://www.youtube.com/')) { + print('blocking navigation to $request}'); + return NavigationDecision.prevent; + } + print('allowing navigation to $request'); + return NavigationDecision.navigate; + }, ); }), floatingActionButton: favoriteButton(), @@ -76,12 +100,12 @@ class WebViewExample extends StatelessWidget { enum MenuOptions { showUserAgent, - toast, listCookies, clearCookies, addToCache, listCache, clearCache, + navigationDelegate, } class SampleMenu extends StatelessWidget { @@ -102,13 +126,6 @@ class SampleMenu extends StatelessWidget { case MenuOptions.showUserAgent: _onShowUserAgent(controller.data, context); break; - case MenuOptions.toast: - Scaffold.of(context).showSnackBar( - SnackBar( - content: Text('You selected: $value'), - ), - ); - break; case MenuOptions.listCookies: _onListCookies(controller.data, context); break; @@ -124,6 +141,9 @@ class SampleMenu extends StatelessWidget { case MenuOptions.clearCache: _onClearCache(controller.data, context); break; + case MenuOptions.navigationDelegate: + _onNavigationDelegateExample(controller.data, context); + break; } }, itemBuilder: (BuildContext context) => >[ @@ -132,10 +152,6 @@ class SampleMenu extends StatelessWidget { child: const Text('Show user agent'), enabled: controller.hasData, ), - const PopupMenuItem( - value: MenuOptions.toast, - child: Text('Make a toast'), - ), const PopupMenuItem( value: MenuOptions.listCookies, child: Text('List cookies'), @@ -156,6 +172,10 @@ class SampleMenu extends StatelessWidget { value: MenuOptions.clearCache, child: Text('Clear cache'), ), + const PopupMenuItem( + value: MenuOptions.navigationDelegate, + child: Text('Navigation Delegate example'), + ), ], ); }, @@ -218,6 +238,13 @@ class SampleMenu extends StatelessWidget { )); } + void _onNavigationDelegateExample( + WebViewController controller, BuildContext context) async { + final String contentBase64 = + base64Encode(const Utf8Encoder().convert(kNavigationExamplePage)); + controller.loadUrl('data:text/html;base64,$contentBase64'); + } + Widget _getCookieList(String cookies) { if (cookies == null || cookies == '""') { return Container(); diff --git a/packages/webview_flutter/lib/webview_flutter.dart b/packages/webview_flutter/lib/webview_flutter.dart index 4f0285273df7..8833cb4f9a61 100644 --- a/packages/webview_flutter/lib/webview_flutter.dart +++ b/packages/webview_flutter/lib/webview_flutter.dart @@ -33,6 +33,39 @@ class JavascriptMessage { /// Callback type for handling messages sent from Javascript running in a web view. typedef void JavascriptMessageHandler(JavascriptMessage message); +/// Information about a navigation action that is about to be executed. +class NavigationRequest { + NavigationRequest._({this.url, this.isForMainFrame}); + + /// The URL that will be loaded if the navigation is executed. + final String url; + + /// Whether the navigation request is to be loaded as the main frame. + final bool isForMainFrame; + + @override + String toString() { + return '$runtimeType(url: $url, isForMainFrame: $isForMainFrame)'; + } +} + +/// A decision on how to handle a navigation request. +enum NavigationDecision { + /// Prevent the navigation from taking place. + prevent, + + /// Allow the navigation to take place. + navigate, +} + +/// Decides how to handle a specific navigation request. +/// +/// The returned [NavigationDecision] determines how the navigation described by +/// `navigation` should be handled. +/// +/// See also: [WebView.navigationDelegate]. +typedef NavigationDecision NavigationDelegate(NavigationRequest navigation); + final RegExp _validChannelNames = RegExp('^[a-zA-Z_][a-zA-Z0-9]*\$'); /// A named channel for receiving messaged from JavaScript code running inside a web view. @@ -78,6 +111,7 @@ class WebView extends StatefulWidget { this.initialUrl, this.javascriptMode = JavascriptMode.disabled, this.javascriptChannels, + this.navigationDelegate, this.gestureRecognizers, }) : assert(javascriptMode != null), super(key: key); @@ -131,6 +165,30 @@ class WebView extends StatefulWidget { /// A null value is equivalent to an empty set. final Set javascriptChannels; + /// A delegate function that decides how to handle navigation actions. + /// + /// When a navigation is initiated by the WebView (e.g when a user clicks a link) + /// this delegate is called and has to decide how to proceed with the navigation. + /// + /// See [NavigationDecision] for possible decisions the delegate can take. + /// + /// When null all navigation actions are allowed. + /// + /// Caveats on Android: + /// + /// * Navigation actions targeted to the main frame can be intercepted, + /// navigation actions targeted to subframes are allowed regardless of the value + /// returned by this delegate. + /// * Setting a navigationDelegate makes the WebView treat all navigations as if they were + /// triggered by a user gesture, this disables some of Chromium's security mechanisms. + /// A navigationDelegate should only be set when loading trusted content. + /// * On Android WebView versions earlier than 67(most devices running at least Android L+ should have + /// a later version): + /// * When a navigationDelegate is set pages with frames are not properly handled by the + /// webview, and frames will be opened in the main frame. + /// * When a navigationDelegate is set HTTP requests do not include the HTTP referer header. + final NavigationDelegate navigationDelegate; + @override State createState() => _WebViewState(); } @@ -197,6 +255,7 @@ class _WebViewState extends State { final WebViewController controller = await _controller.future; controller._updateSettings(settings); controller._updateJavascriptChannels(widget.javascriptChannels); + controller._navigationDelegate = widget.navigationDelegate; } void _onPlatformViewCreated(int id) { @@ -204,6 +263,7 @@ class _WebViewState extends State { id, _WebSettings.fromWidget(widget), widget.javascriptChannels, + widget.navigationDelegate, ); _controller.complete(controller); if (widget.onWebViewCreated != null) { @@ -261,27 +321,35 @@ class _CreationParams { class _WebSettings { _WebSettings({ this.javascriptMode, + this.hasNavigationDelegate, }); static _WebSettings fromWidget(WebView widget) { - return _WebSettings(javascriptMode: widget.javascriptMode); + return _WebSettings( + javascriptMode: widget.javascriptMode, + hasNavigationDelegate: widget.navigationDelegate != null, + ); } final JavascriptMode javascriptMode; + final bool hasNavigationDelegate; Map toMap() { return { 'jsMode': javascriptMode.index, + 'hasNavigationDelegate': hasNavigationDelegate, }; } Map updatesMap(_WebSettings newSettings) { - if (javascriptMode == newSettings.javascriptMode) { - return null; + final Map updates = {}; + if (javascriptMode != newSettings.javascriptMode) { + updates['jsMode'] = newSettings.javascriptMode.index; } - return { - 'jsMode': newSettings.javascriptMode.index, - }; + if (hasNavigationDelegate != newSettings.hasNavigationDelegate) { + updates['hasNavigationDelegate'] = newSettings.hasNavigationDelegate; + } + return updates; } } @@ -291,29 +359,48 @@ class _WebSettings { /// callback for a [WebView] widget. class WebViewController { WebViewController._( - int id, this._settings, Set javascriptChannels) - : _channel = MethodChannel('plugins.flutter.io/webview_$id') { + int id, + this._settings, + Set javascriptChannels, + this._navigationDelegate, + ) : _channel = MethodChannel('plugins.flutter.io/webview_$id') { _updateJavascriptChannelsFromSet(javascriptChannels); _channel.setMethodCallHandler(_onMethodCall); } final MethodChannel _channel; + NavigationDelegate _navigationDelegate; + _WebSettings _settings; // Maps a channel name to a channel. Map _javascriptChannels = {}; - Future _onMethodCall(MethodCall call) async { + Future _onMethodCall(MethodCall call) async { switch (call.method) { case 'javascriptChannelMessage': final String channel = call.arguments['channel']; final String message = call.arguments['message']; _javascriptChannels[channel] .onMessageReceived(JavascriptMessage(message)); - break; + return true; + case 'navigationRequest': + final NavigationRequest request = NavigationRequest._( + url: call.arguments['url'], + isForMainFrame: call.arguments['isForMainFrame'], + ); + + // _navigationDelegate can be null if the widget was rebuilt with no + // navigation delegate after a navigation happened and just before we + // got the navigationRequest message. + final bool allowNavigation = _navigationDelegate == null || + _navigationDelegate(request) == NavigationDecision.navigate; + return allowNavigation; } + throw MissingPluginException( + '${call.method} was invoked but has no handler'); } /// Loads the specified URL. @@ -417,7 +504,7 @@ class WebViewController { Future _updateSettings(_WebSettings setting) async { final Map updateMap = _settings.updatesMap(setting); - if (updateMap == null) { + if (updateMap == null || updateMap.isEmpty) { return null; } _settings = setting; diff --git a/packages/webview_flutter/pubspec.yaml b/packages/webview_flutter/pubspec.yaml index 8307e1fc7206..84484a2bcbec 100644 --- a/packages/webview_flutter/pubspec.yaml +++ b/packages/webview_flutter/pubspec.yaml @@ -1,6 +1,6 @@ name: webview_flutter description: A Flutter plugin that provides a WebView widget on Android and iOS. -version: 0.3.3+2 +version: 0.3.4 author: Flutter Team homepage: https://github.com/flutter/plugins/tree/master/packages/webview_flutter diff --git a/packages/webview_flutter/test/webview_flutter_test.dart b/packages/webview_flutter/test/webview_flutter_test.dart index 4fcdc7f4e015..8635875ab7a6 100644 --- a/packages/webview_flutter/test/webview_flutter_test.dart +++ b/packages/webview_flutter/test/webview_flutter_test.dart @@ -575,6 +575,57 @@ void main() { expect(ttsMessagesReceived, ['Hello', 'World']); }); + + group('navigationDelegate', () { + testWidgets('hasNavigationDelegate', (WidgetTester tester) async { + await tester.pumpWidget(const WebView( + initialUrl: 'https://youtube.com', + )); + + final FakePlatformWebView platformWebView = + fakePlatformViewsController.lastCreatedView; + + expect(platformWebView.hasNavigationDelegate, false); + + await tester.pumpWidget(WebView( + initialUrl: 'https://youtube.com', + navigationDelegate: (NavigationRequest r) => null, + )); + + expect(platformWebView.hasNavigationDelegate, true); + }); + + testWidgets('Block navigation', (WidgetTester tester) async { + final List navigationRequests = []; + + await tester.pumpWidget(WebView( + initialUrl: 'https://youtube.com', + navigationDelegate: (NavigationRequest request) { + navigationRequests.add(request); + // Only allow navigating to https://flutter.dev + return request.url == 'https://flutter.dev' + ? NavigationDecision.navigate + : NavigationDecision.prevent; + })); + + final FakePlatformWebView platformWebView = + fakePlatformViewsController.lastCreatedView; + + expect(platformWebView.hasNavigationDelegate, true); + + platformWebView.fakeNavigate('https://www.google.com'); + // The navigation delegate only allows navigation to https://flutter.dev + // so we should still be in https://youtube.com. + expect(platformWebView.currentUrl, 'https://youtube.com'); + expect(navigationRequests.length, 1); + expect(navigationRequests[0].url, 'https://www.google.com'); + expect(navigationRequests[0].isForMainFrame, true); + + platformWebView.fakeNavigate('https://flutter.dev'); + await tester.pump(); + expect(platformWebView.currentUrl, 'https://flutter.dev'); + }); + }); } class FakePlatformWebView { @@ -585,12 +636,14 @@ class FakePlatformWebView { history.add(initialUrl); currentPosition++; } - javascriptMode = JavascriptMode.values[params['settings']['jsMode']]; } if (params.containsKey('javascriptChannelNames')) { javascriptChannelNames = List.from(params['javascriptChannelNames']); } + javascriptMode = JavascriptMode.values[params['settings']['jsMode']]; + hasNavigationDelegate = + params['settings']['hasNavigationDelegate'] ?? false; channel = MethodChannel( 'plugins.flutter.io/webview_$id', const StandardMethodCodec()); channel.setMockMethodCallHandler(onMethodCall); @@ -607,20 +660,21 @@ class FakePlatformWebView { JavascriptMode javascriptMode; List javascriptChannelNames; + bool hasNavigationDelegate; + Future onMethodCall(MethodCall call) { switch (call.method) { case 'loadUrl': final String url = call.arguments; - history = history.sublist(0, currentPosition + 1); - history.add(url); - currentPosition++; - amountOfReloadsOnCurrentUrl = 0; + _loadUrl(url); return Future.sync(() {}); case 'updateSettings': - if (call.arguments['jsMode'] == null) { - break; + if (call.arguments['jsMode'] != null) { + javascriptMode = JavascriptMode.values[call.arguments['jsMode']]; + } + if (call.arguments['hasNavigationDelegate'] != null) { + hasNavigationDelegate = call.arguments['hasNavigationDelegate']; } - javascriptMode = JavascriptMode.values[call.arguments['jsMode']]; break; case 'canGoBack': return Future.sync(() => currentPosition > 0); @@ -672,6 +726,36 @@ class FakePlatformWebView { BinaryMessages.handlePlatformMessage( channel.name, data, (ByteData data) {}); } + + // Fakes a main frame navigation that was initiated by the webview, e.g when + // the user clicks a link in the currently loaded page. + void fakeNavigate(String url) { + if (!hasNavigationDelegate) { + print('no navigation delegate'); + _loadUrl(url); + return; + } + final StandardMethodCodec codec = const StandardMethodCodec(); + final Map arguments = { + 'url': url, + 'isForMainFrame': true + }; + final ByteData data = + codec.encodeMethodCall(MethodCall('navigationRequest', arguments)); + BinaryMessages.handlePlatformMessage(channel.name, data, (ByteData data) { + final bool allow = codec.decodeEnvelope(data); + if (allow) { + _loadUrl(url); + } + }); + } + + void _loadUrl(String url) { + history = history.sublist(0, currentPosition + 1); + history.add(url); + currentPosition++; + amountOfReloadsOnCurrentUrl = 0; + } } class _FakePlatformViewsController {