diff --git a/packages/webview_flutter/CHANGELOG.md b/packages/webview_flutter/CHANGELOG.md index 88e49f8d08bd..3f4fa4f1db21 100644 --- a/packages/webview_flutter/CHANGELOG.md +++ b/packages/webview_flutter/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.3.20 + +* Added support for receiving web resource loading errors. See `WebView.onWebResourceError`. + ## 0.3.19+10 * Replace deprecated `getFlutterEngine` call on Android. 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 index b660a721d0db..f61a9d39b85f 100644 --- 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 @@ -4,17 +4,21 @@ package io.flutter.plugins.webviewflutter; +import android.annotation.SuppressLint; import android.annotation.TargetApi; import android.graphics.Bitmap; import android.os.Build; import android.util.Log; import android.view.KeyEvent; +import android.webkit.WebResourceError; import android.webkit.WebResourceRequest; import android.webkit.WebView; import android.webkit.WebViewClient; +import androidx.webkit.WebResourceErrorCompat; import androidx.webkit.WebViewClientCompat; import io.flutter.plugin.common.MethodChannel; import java.util.HashMap; +import java.util.Locale; import java.util.Map; // We need to use WebViewClientCompat to get @@ -30,6 +34,47 @@ class FlutterWebViewClient { this.methodChannel = methodChannel; } + private static String errorCodeToString(int errorCode) { + switch (errorCode) { + case WebViewClient.ERROR_AUTHENTICATION: + return "authentication"; + case WebViewClient.ERROR_BAD_URL: + return "badUrl"; + case WebViewClient.ERROR_CONNECT: + return "connect"; + case WebViewClient.ERROR_FAILED_SSL_HANDSHAKE: + return "failedSslHandshake"; + case WebViewClient.ERROR_FILE: + return "file"; + case WebViewClient.ERROR_FILE_NOT_FOUND: + return "fileNotFound"; + case WebViewClient.ERROR_HOST_LOOKUP: + return "hostLookup"; + case WebViewClient.ERROR_IO: + return "io"; + case WebViewClient.ERROR_PROXY_AUTHENTICATION: + return "proxyAuthentication"; + case WebViewClient.ERROR_REDIRECT_LOOP: + return "redirectLoop"; + case WebViewClient.ERROR_TIMEOUT: + return "timeout"; + case WebViewClient.ERROR_TOO_MANY_REQUESTS: + return "tooManyRequests"; + case WebViewClient.ERROR_UNKNOWN: + return "unknown"; + case WebViewClient.ERROR_UNSAFE_RESOURCE: + return "unsafeResource"; + case WebViewClient.ERROR_UNSUPPORTED_AUTH_SCHEME: + return "unsupportedAuthScheme"; + case WebViewClient.ERROR_UNSUPPORTED_SCHEME: + return "unsupportedScheme"; + } + + final String message = + String.format(Locale.getDefault(), "Could not find a string for errorCode: %d", errorCode); + throw new IllegalArgumentException(message); + } + @TargetApi(Build.VERSION_CODES.LOLLIPOP) private boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { if (!hasNavigationDelegate) { @@ -79,6 +124,14 @@ private void onPageFinished(WebView view, String url) { methodChannel.invokeMethod("onPageFinished", args); } + private void onWebResourceError(final int errorCode, final String description) { + final Map args = new HashMap<>(); + args.put("errorCode", errorCode); + args.put("description", description); + args.put("errorType", FlutterWebViewClient.errorCodeToString(errorCode)); + methodChannel.invokeMethod("onWebResourceError", args); + } + private void notifyOnNavigationRequest( String url, Map headers, WebView webview, boolean isMainFrame) { HashMap args = new HashMap<>(); @@ -123,6 +176,20 @@ public void onPageFinished(WebView view, String url) { FlutterWebViewClient.this.onPageFinished(view, url); } + @TargetApi(Build.VERSION_CODES.M) + @Override + public void onReceivedError( + WebView view, WebResourceRequest request, WebResourceError error) { + FlutterWebViewClient.this.onWebResourceError( + error.getErrorCode(), error.getDescription().toString()); + } + + @Override + public void onReceivedError( + WebView view, int errorCode, String description, String failingUrl) { + FlutterWebViewClient.this.onWebResourceError(errorCode, description); + } + @Override public void onUnhandledKeyEvent(WebView view, KeyEvent event) { // Deliberately empty. Occasionally the webview will mark events as having failed to be @@ -154,6 +221,22 @@ public void onPageFinished(WebView view, String url) { FlutterWebViewClient.this.onPageFinished(view, url); } + // This method is only called when the WebViewFeature.RECEIVE_WEB_RESOURCE_ERROR feature is + // enabled. The deprecated method is called when a device doesn't support this. + @SuppressLint("RequiresFeature") + @Override + public void onReceivedError( + WebView view, WebResourceRequest request, WebResourceErrorCompat error) { + FlutterWebViewClient.this.onWebResourceError( + error.getErrorCode(), error.getDescription().toString()); + } + + @Override + public void onReceivedError( + WebView view, int errorCode, String description, String failingUrl) { + FlutterWebViewClient.this.onWebResourceError(errorCode, description); + } + @Override public void onUnhandledKeyEvent(WebView view, KeyEvent event) { // Deliberately empty. Occasionally the webview will mark events as having failed to be diff --git a/packages/webview_flutter/example/test_driver/webview_flutter_e2e.dart b/packages/webview_flutter/example/test_driver/webview_flutter_e2e.dart index 5ce50b8b41d3..d34ee772479a 100644 --- a/packages/webview_flutter/example/test_driver/webview_flutter_e2e.dart +++ b/packages/webview_flutter/example/test_driver/webview_flutter_e2e.dart @@ -4,12 +4,14 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:webview_flutter/platform_interface.dart'; import 'package:webview_flutter/webview_flutter.dart'; import 'package:e2e/e2e.dart'; @@ -576,6 +578,52 @@ void main() { expect(currentUrl, 'https://www.google.com/'); }); + testWidgets('onWebResourceError', (WidgetTester tester) async { + final Completer errorCompleter = + Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: 'https://www.notawebsite..com', + onWebResourceError: (WebResourceError error) { + errorCompleter.complete(error); + }, + ), + ), + ); + + final WebResourceError error = await errorCompleter.future; + expect(error, isNotNull); + + if (Platform.isIOS) expect(error.domain, isNotNull); + if (Platform.isAndroid) expect(error.errorType, isNotNull); + }); + + testWidgets('onWebResourceError is not called with valid url', + (WidgetTester tester) async { + final Completer errorCompleter = + Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: + 'data:text/html;charset=utf-8;base64,PCFET0NUWVBFIGh0bWw+', + onWebResourceError: (WebResourceError error) { + errorCompleter.complete(error); + }, + ), + ), + ); + + expect(errorCompleter.future, doesNotComplete); + }); + testWidgets('can block requests', (WidgetTester tester) async { final Completer controllerCompleter = Completer(); diff --git a/packages/webview_flutter/ios/Classes/FLTWKNavigationDelegate.m b/packages/webview_flutter/ios/Classes/FLTWKNavigationDelegate.m index 3e9d2762c7fa..dd9608d57ac0 100644 --- a/packages/webview_flutter/ios/Classes/FLTWKNavigationDelegate.m +++ b/packages/webview_flutter/ios/Classes/FLTWKNavigationDelegate.m @@ -5,10 +5,10 @@ #import "FLTWKNavigationDelegate.h" @implementation FLTWKNavigationDelegate { - FlutterMethodChannel* _methodChannel; + FlutterMethodChannel *_methodChannel; } -- (instancetype)initWithChannel:(FlutterMethodChannel*)channel { +- (instancetype)initWithChannel:(FlutterMethodChannel *)channel { self = [super init]; if (self) { _methodChannel = channel; @@ -18,18 +18,18 @@ - (instancetype)initWithChannel:(FlutterMethodChannel*)channel { #pragma mark - WKNavigationDelegate conformance -- (void)webView:(WKWebView*)webView didStartProvisionalNavigation:(WKNavigation*)navigation { +- (void)webView:(WKWebView *)webView didStartProvisionalNavigation:(WKNavigation *)navigation { [_methodChannel invokeMethod:@"onPageStarted" arguments:@{@"url" : webView.URL.absoluteString}]; } -- (void)webView:(WKWebView*)webView - decidePolicyForNavigationAction:(WKNavigationAction*)navigationAction +- (void)webView:(WKWebView *)webView + decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler { if (!self.hasDartNavigationDelegate) { decisionHandler(WKNavigationActionPolicyAllow); return; } - NSDictionary* arguments = @{ + NSDictionary *arguments = @{ @"url" : navigationAction.request.URL.absoluteString, @"isForMainFrame" : @(navigationAction.targetFrame.isMainFrame) }; @@ -56,13 +56,52 @@ - (void)webView:(WKWebView*)webView decisionHandler(WKNavigationActionPolicyAllow); return; } - NSNumber* typedResult = result; + NSNumber *typedResult = result; decisionHandler([typedResult boolValue] ? WKNavigationActionPolicyAllow : WKNavigationActionPolicyCancel); }]; } -- (void)webView:(WKWebView*)webView didFinishNavigation:(WKNavigation*)navigation { +- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation { [_methodChannel invokeMethod:@"onPageFinished" arguments:@{@"url" : webView.URL.absoluteString}]; } + ++ (id)errorCodeToString:(NSUInteger)code { + switch (code) { + case WKErrorUnknown: + return @"unknown"; + case WKErrorWebContentProcessTerminated: + return @"webContentProcessTerminated"; + case WKErrorWebViewInvalidated: + return @"webViewInvalidated"; + case WKErrorJavaScriptExceptionOccurred: + return @"javaScriptExceptionOccurred"; + case WKErrorJavaScriptResultTypeIsUnsupported: + return @"javaScriptResultTypeIsUnsupported"; + } + + return [NSNull null]; +} + +- (void)onWebResourceError:(NSError *)error { + [_methodChannel invokeMethod:@"onWebResourceError" + arguments:@{ + @"errorCode" : @(error.code), + @"domain" : error.domain, + @"description" : error.description, + @"errorType" : [FLTWKNavigationDelegate errorCodeToString:error.code], + }]; +} + +- (void)webView:(WKWebView *)webView + didFailNavigation:(WKNavigation *)navigation + withError:(NSError *)error { + [self onWebResourceError:error]; +} + +- (void)webView:(WKWebView *)webView + didFailProvisionalNavigation:(WKNavigation *)navigation + withError:(NSError *)error { + [self onWebResourceError:error]; +} @end diff --git a/packages/webview_flutter/lib/platform_interface.dart b/packages/webview_flutter/lib/platform_interface.dart index aaf63c24f08a..68d990dcf10b 100644 --- a/packages/webview_flutter/lib/platform_interface.dart +++ b/packages/webview_flutter/lib/platform_interface.dart @@ -28,6 +28,117 @@ abstract class WebViewPlatformCallbacksHandler { /// Invoked by [WebViewPlatformController] when a page has finished loading. void onPageFinished(String url); + + /// Report web resource loading error to the host application. + void onWebResourceError(WebResourceError error); +} + +/// Possible error type categorizations used by [WebResourceError]. +enum WebResourceErrorType { + /// User authentication failed on server. + authentication, + + /// Malformed URL. + badUrl, + + /// Failed to connect to the server. + connect, + + /// Failed to perform SSL handshake. + failedSslHandshake, + + /// Generic file error. + file, + + /// File not found. + fileNotFound, + + /// Server or proxy hostname lookup failed. + hostLookup, + + /// Failed to read or write to the server. + io, + + /// User authentication failed on proxy. + proxyAuthentication, + + /// Too many redirects. + redirectLoop, + + /// Connection timed out. + timeout, + + /// Too many requests during this load. + tooManyRequests, + + /// Generic error. + unknown, + + /// Resource load was canceled by Safe Browsing. + unsafeResource, + + /// Unsupported authentication scheme (not basic or digest). + unsupportedAuthScheme, + + /// Unsupported URI scheme. + unsupportedScheme, + + /// The web content process was terminated. + webContentProcessTerminated, + + /// The web view was invalidated. + webViewInvalidated, + + /// A JavaScript exception occurred. + javaScriptExceptionOccurred, + + /// The result of JavaScript execution could not be returned. + javaScriptResultTypeIsUnsupported, +} + +/// Error returned in `WebView.onWebResourceError` when a web resource loading error has occurred. +class WebResourceError { + /// Creates a new [WebResourceError] + /// + /// A user should not need to instantiate this class, but will receive one in + /// [WebResourceErrorCallback]. + WebResourceError({ + @required this.errorCode, + @required this.description, + this.domain, + this.errorType, + }) : assert(errorCode != null), + assert(description != null); + + /// Raw code of the error from the respective platform. + /// + /// On Android, the error code will be a constant from a + /// [WebViewClient](https://developer.android.com/reference/android/webkit/WebViewClient#summary) and + /// will have a corresponding [errorType]. + /// + /// On iOS, the error code will be a constant from `NSError.code` in + /// Objective-C. See + /// https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/ErrorHandlingCocoa/ErrorObjectsDomains/ErrorObjectsDomains.html + /// for more information on error handling on iOS. Some possible error codes + /// can be found at https://developer.apple.com/documentation/webkit/wkerrorcode?language=objc. + final int errorCode; + + /// The domain of where to find the error code. + /// + /// This field is only available on iOS and represents a "domain" from where + /// the [errorCode] is from. This value is taken directly from an `NSError` + /// in Objective-C. See + /// https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/ErrorHandlingCocoa/ErrorObjectsDomains/ErrorObjectsDomains.html + /// for more information on error handling on iOS. + final String domain; + + /// Description of the error that can be used to communicate the problem to the user. + final String description; + + /// The type this error can be categorized as. + /// + /// This will never be `null` on Android, but can be `null` on iOS. + final WebResourceErrorType errorType; } /// Interface for talking to the webview's platform implementation. diff --git a/packages/webview_flutter/lib/src/webview_method_channel.dart b/packages/webview_flutter/lib/src/webview_method_channel.dart index 03d392ef5f8b..492b809c3ba2 100644 --- a/packages/webview_flutter/lib/src/webview_method_channel.dart +++ b/packages/webview_flutter/lib/src/webview_method_channel.dart @@ -43,9 +43,28 @@ class MethodChannelWebViewPlatform implements WebViewPlatformController { case 'onPageStarted': _platformCallbacksHandler.onPageStarted(call.arguments['url']); return null; + case 'onWebResourceError': + _platformCallbacksHandler.onWebResourceError( + WebResourceError( + errorCode: call.arguments['errorCode'], + description: call.arguments['description'], + domain: call.arguments['domain'], + errorType: call.arguments['errorType'] == null + ? null + : WebResourceErrorType.values.firstWhere( + (WebResourceErrorType type) { + return type.toString() == + '$WebResourceErrorType.${call.arguments['errorType']}'; + }, + ), + ), + ); + return null; } + throw MissingPluginException( - '${call.method} was invoked but has no handler'); + '${call.method} was invoked but has no handler', + ); } @override diff --git a/packages/webview_flutter/lib/webview_flutter.dart b/packages/webview_flutter/lib/webview_flutter.dart index a91a9ac827ae..0cc1ee3452a6 100644 --- a/packages/webview_flutter/lib/webview_flutter.dart +++ b/packages/webview_flutter/lib/webview_flutter.dart @@ -79,6 +79,9 @@ typedef void PageStartedCallback(String url); /// Signature for when a [WebView] has finished loading a page. typedef void PageFinishedCallback(String url); +/// Signature for when a [WebView] has failed to load a resource. +typedef void WebResourceErrorCallback(WebResourceError error); + /// Specifies possible restrictions on automatic media playback. /// /// This is typically used in [WebView.initialMediaPlaybackPolicy]. @@ -147,6 +150,7 @@ class WebView extends StatefulWidget { this.gestureRecognizers, this.onPageStarted, this.onPageFinished, + this.onWebResourceError, this.debuggingEnabled = false, this.gestureNavigationEnabled = false, this.userAgent, @@ -277,6 +281,12 @@ class WebView extends StatefulWidget { /// [WebViewController.evaluateJavascript] can assume this. final PageFinishedCallback onPageFinished; + /// Invoked when a web resource has failed to load. + /// + /// This can be called for any resource (iframe, image, etc.), not just for + /// the main page. + final WebResourceErrorCallback onWebResourceError; + /// Controls whether WebView debugging is enabled. /// /// Setting this to true enables [WebView debugging on Android](https://developers.google.com/web/tools/chrome-devtools/remote-debugging/). @@ -482,6 +492,13 @@ class _PlatformCallbacksHandler implements WebViewPlatformCallbacksHandler { } } + @override + void onWebResourceError(WebResourceError error) { + if (_widget.onWebResourceError != null) { + _widget.onWebResourceError(error); + } + } + void _updateJavascriptChannelsFromSet(Set channels) { _javascriptChannels.clear(); if (channels == null) { diff --git a/packages/webview_flutter/pubspec.yaml b/packages/webview_flutter/pubspec.yaml index a3091ce7fcd2..47db37b45e93 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.19+9 +version: 0.3.20 homepage: https://github.com/flutter/plugins/tree/master/packages/webview_flutter environment: