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..fb114e27d5fc 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 @@ -9,9 +9,13 @@ import android.os.Build; import android.util.Log; import android.view.KeyEvent; +import android.webkit.WebResourceError; import android.webkit.WebResourceRequest; +import android.webkit.WebResourceResponse; import android.webkit.WebView; import android.webkit.WebViewClient; +import androidx.annotation.NonNull; +import androidx.webkit.WebResourceErrorCompat; import androidx.webkit.WebViewClientCompat; import io.flutter.plugin.common.MethodChannel; import java.util.HashMap; @@ -92,6 +96,14 @@ private void notifyOnNavigationRequest( } } + private void onReceiveError(WebView view, int code, String description, String url) { + Map args = new HashMap<>(); + args.put("url", url); + args.put("code", code); + args.put("description", description); + methodChannel.invokeMethod("onPageReceiveError", args); + } + // This method attempts to avoid using WebViewClientCompat due to bug // https://bugs.chromium.org/p/chromium/issues/detail?id=925887. Also, see // https://github.com/flutter/flutter/issues/29446. @@ -129,6 +141,32 @@ public void onUnhandledKeyEvent(WebView view, KeyEvent event) { // handled even though they were handled. We don't want to propagate those as they're not // truly lost. } + + @TargetApi(Build.VERSION_CODES.M) + @Override + public void onReceivedError( + WebView view, WebResourceRequest request, WebResourceError error) { + FlutterWebViewClient.this.onReceiveError( + view, + error.getErrorCode(), + error.getDescription().toString(), + request.getUrl().toString()); + } + + @TargetApi(Build.VERSION_CODES.M) + @Override + public void onReceivedHttpError( + WebView view, WebResourceRequest request, WebResourceResponse errorResponse) { + FlutterWebViewClient.this.onReceiveError( + view, errorResponse.getStatusCode(), null, request.getUrl().toString()); + } + + @SuppressWarnings("deprecation") + @Override + public void onReceivedError( + WebView view, int errorCode, String description, String failingUrl) { + FlutterWebViewClient.this.onReceiveError(view, errorCode, description, failingUrl); + } }; } @@ -160,6 +198,37 @@ public void onUnhandledKeyEvent(WebView view, KeyEvent event) { // handled even though they were handled. We don't want to propagate those as they're not // truly lost. } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + @Override + public void onReceivedHttpError( + @NonNull WebView view, + @NonNull WebResourceRequest request, + @NonNull WebResourceResponse errorResponse) { + FlutterWebViewClient.this.onReceiveError( + view, errorResponse.getStatusCode(), null, request.getUrl().toString()); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + @Override + public void onReceivedError( + @NonNull WebView view, + @NonNull WebResourceRequest request, + @NonNull WebResourceErrorCompat error) { + //TODO: is really need to check WebViewFeature.isFeatureSupported() and api version. + FlutterWebViewClient.this.onReceiveError( + view, + error.getErrorCode(), + error.getDescription().toString(), + request.getUrl().toString()); + } + + @SuppressWarnings("deprecation") + @Override + public void onReceivedError( + WebView view, int errorCode, String description, String failingUrl) { + FlutterWebViewClient.this.onReceiveError(view, errorCode, description, failingUrl); + } }; } diff --git a/packages/webview_flutter/example/lib/main.dart b/packages/webview_flutter/example/lib/main.dart index 59c87a25dedf..e97e577baa5a 100644 --- a/packages/webview_flutter/example/lib/main.dart +++ b/packages/webview_flutter/example/lib/main.dart @@ -50,7 +50,7 @@ class _WebViewExampleState extends State { // to allow calling Scaffold.of(context) so we can show a snackbar. body: Builder(builder: (BuildContext context) { return WebView( - initialUrl: 'https://flutter.dev', + initialUrl: 'https://google.com', javascriptMode: JavascriptMode.unrestricted, onWebViewCreated: (WebViewController webViewController) { _controller.complete(webViewController); @@ -74,6 +74,13 @@ class _WebViewExampleState extends State { onPageFinished: (String url) { print('Page finished loading: $url'); }, + onPageReceiveError: (String url, int code, String message) { + Scaffold.of(context).showSnackBar(SnackBar( + content: Text( + 'Code: $code, Url: $url, Message: $message', + ), + )); + }, gestureNavigationEnabled: true, ); }), diff --git a/packages/webview_flutter/ios/Classes/FLTWKNavigationDelegate.m b/packages/webview_flutter/ios/Classes/FLTWKNavigationDelegate.m index 3e9d2762c7fa..e60b36769f3e 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) }; @@ -37,7 +37,8 @@ - (void)webView:(WKWebView*)webView arguments:arguments result:^(id _Nullable result) { if ([result isKindOfClass:[FlutterError class]]) { - NSLog(@"navigationRequest has unexpectedly completed with an error, " + NSLog(@"navigationRequest has unexpectedly completed with an " + @"error, " @"allowing navigation."); decisionHandler(WKNavigationActionPolicyAllow); return; @@ -50,19 +51,64 @@ - (void)webView:(WKWebView*)webView return; } if (![result isKindOfClass:[NSNumber class]]) { - NSLog(@"navigationRequest unexpectedly returned a non boolean value: " + NSLog(@"navigationRequest unexpectedly returned a non boolean " + @"value: " @"%@, allowing navigation.", result); 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}]; } + +- (void)webView:(WKWebView *)webView + didFailNavigation:(WKNavigation *)navigation + withError:(NSError *)error { + NSDictionary *arguments = @{ + @"url" : webView.URL.absoluteString ?: [NSNull null], + @"code" : [NSNumber numberWithLong:error.code], + @"description" : [error localizedDescription], + }; + + [_methodChannel invokeMethod:@"onPageReceiveError" arguments:arguments]; +} + +- (void)webView:(WKWebView *)webView + didFailProvisionalNavigation:(WKNavigation *)navigation + withError:(NSError *)error { + NSDictionary *arguments = @{ + @"url" : error.userInfo[NSURLErrorFailingURLStringErrorKey], + @"code" : [NSNumber numberWithLong:error.code], + @"description" : [error localizedDescription], + }; + + [_methodChannel invokeMethod:@"onPageReceiveError" arguments:arguments]; +} + +- (void)webView:(WKWebView *)webView + decidePolicyForNavigationResponse:(WKNavigationResponse *)navigationResponse + decisionHandler:(void (^)(WKNavigationResponsePolicy))decisionHandler { + if ([navigationResponse.response isKindOfClass:[NSHTTPURLResponse class]]) { + NSHTTPURLResponse *response = (NSHTTPURLResponse *)navigationResponse.response; + if (response.statusCode >= 400 && response.statusCode < 600) { + NSDictionary *arguments = @{ + @"url" : response.URL.absoluteString ?: [NSNull null], + @"code" : [NSNumber numberWithLong:response.statusCode], + @"description" : [NSHTTPURLResponse localizedStringForStatusCode:response.statusCode], + }; + + [_methodChannel invokeMethod:@"onPageReceiveError" arguments:arguments]; + } + } + + decisionHandler(WKNavigationResponsePolicyAllow); +} + @end diff --git a/packages/webview_flutter/lib/platform_interface.dart b/packages/webview_flutter/lib/platform_interface.dart index aaf63c24f08a..4398adf159f1 100644 --- a/packages/webview_flutter/lib/platform_interface.dart +++ b/packages/webview_flutter/lib/platform_interface.dart @@ -28,6 +28,9 @@ abstract class WebViewPlatformCallbacksHandler { /// Invoked by [WebViewPlatformController] when a page has finished loading. void onPageFinished(String url); + + /// Invoked by [WebViewPlatformController] when platform's webview return error. + void onPageReceiveError({String url, int code, String description}); } /// 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..cd3b17136e5d 100644 --- a/packages/webview_flutter/lib/src/webview_method_channel.dart +++ b/packages/webview_flutter/lib/src/webview_method_channel.dart @@ -43,6 +43,12 @@ class MethodChannelWebViewPlatform implements WebViewPlatformController { case 'onPageStarted': _platformCallbacksHandler.onPageStarted(call.arguments['url']); return null; + case 'onPageReceiveError': + _platformCallbacksHandler.onPageReceiveError( + url: call.arguments['url'], + code: call.arguments['code'], + description: call.arguments['description']); + return null; } throw MissingPluginException( '${call.method} was invoked but has no handler'); diff --git a/packages/webview_flutter/lib/webview_flutter.dart b/packages/webview_flutter/lib/webview_flutter.dart index a91a9ac827ae..c026db3d2355 100644 --- a/packages/webview_flutter/lib/webview_flutter.dart +++ b/packages/webview_flutter/lib/webview_flutter.dart @@ -79,6 +79,13 @@ 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] receive a error. +/// Code may be NSURLErrorDomain code or const from Android WebViewClient or http status code. +/// Description is optional +typedef void PageReceiveErrorCallback(String url, int code, String description); + +final RegExp _validChannelNames = RegExp('^[a-zA-Z_][a-zA-Z0-9_]*\$'); + /// Specifies possible restrictions on automatic media playback. /// /// This is typically used in [WebView.initialMediaPlaybackPolicy]. @@ -98,8 +105,6 @@ enum AutoMediaPlaybackPolicy { always_allow, } -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. class JavascriptChannel { /// Constructs a Javascript channel. @@ -152,6 +157,7 @@ class WebView extends StatefulWidget { this.userAgent, this.initialMediaPlaybackPolicy = AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, + this.onPageReceiveError, }) : assert(javascriptMode != null), assert(initialMediaPlaybackPolicy != null), super(key: key); @@ -290,6 +296,9 @@ class WebView extends StatefulWidget { /// By default `debuggingEnabled` is false. final bool debuggingEnabled; + /// Invoked when a webview return error. + final PageReceiveErrorCallback onPageReceiveError; + /// The value used for the HTTP User-Agent: request header. /// A Boolean value indicating whether horizontal swipe gestures will trigger back-forward list navigations. /// @@ -482,6 +491,13 @@ class _PlatformCallbacksHandler implements WebViewPlatformCallbacksHandler { } } + @override + void onPageReceiveError({String url, int code, String description}) { + if (_widget.onPageReceiveError != null) { + _widget.onPageReceiveError(url, code, description); + } + } + void _updateJavascriptChannelsFromSet(Set channels) { _javascriptChannels.clear(); if (channels == null) { diff --git a/packages/webview_flutter/test/webview_flutter_test.dart b/packages/webview_flutter/test/webview_flutter_test.dart index 10c7bbb460e0..7591b0e102a6 100644 --- a/packages/webview_flutter/test/webview_flutter_test.dart +++ b/packages/webview_flutter/test/webview_flutter_test.dart @@ -889,6 +889,128 @@ void main() { expect(platformWebView.userAgent, 'UA'); }); + + group('$PageReceiveErrorCallback', () { + testWidgets('onPageReceiveError is not null', (WidgetTester tester) async { + String returnedUrl; + int returnedCode; + String returnedDescription; + + await tester.pumpWidget(WebView( + initialUrl: 'https://youtube.com', + onPageReceiveError: (String url, int code, String description) { + returnedUrl = url; + returnedCode = code; + returnedDescription = description; + }, + )); + + final FakePlatformWebView platformWebView = + fakePlatformViewsController.lastCreatedView; + + platformWebView.fakeOnPageReceiveErrorCallback(404, "description"); + + expect(platformWebView.currentUrl, returnedUrl); + expect(404, returnedCode); + expect("description", returnedDescription); + }); + + testWidgets('onPageReceiveError is null', (WidgetTester tester) async { + await tester.pumpWidget(const WebView( + initialUrl: 'https://youtube.com', + onPageReceiveError: null, + )); + + final FakePlatformWebView platformWebView = + fakePlatformViewsController.lastCreatedView; + + platformWebView.fakeOnPageReceiveErrorCallback(404, "description"); + }); + + testWidgets('onPageReceiveError changed', (WidgetTester tester) async { + String returnedUrl; + int returnedCode; + String returnedDescription; + + await tester.pumpWidget(WebView( + initialUrl: 'https://youtube.com', + onPageReceiveError: (String url, int code, String description) {}, + )); + + await tester.pumpWidget(WebView( + initialUrl: 'https://youtube.com', + onPageReceiveError: (String url, int code, String description) { + returnedUrl = url; + returnedCode = code; + returnedDescription = description; + }, + )); + + final FakePlatformWebView platformWebView = + fakePlatformViewsController.lastCreatedView; + + platformWebView.fakeOnPageReceiveErrorCallback(404, "description"); + + expect(platformWebView.currentUrl, returnedUrl); + expect(404, returnedCode); + expect("description", returnedDescription); + }); + }); + + group('$PageStartedCallback', () { + testWidgets('onPageStarted is not null', (WidgetTester tester) async { + String returnedUrl; + + await tester.pumpWidget(WebView( + initialUrl: 'https://youtube.com', + onPageStarted: (String url) { + returnedUrl = url; + }, + )); + + final FakePlatformWebView platformWebView = + fakePlatformViewsController.lastCreatedView; + + platformWebView.fakeOnPageStartedCallback(); + + expect(platformWebView.currentUrl, returnedUrl); + }); + + testWidgets('onPageStarted is null', (WidgetTester tester) async { + await tester.pumpWidget(const WebView( + initialUrl: 'https://youtube.com', + onPageStarted: null, + )); + + final FakePlatformWebView platformWebView = + fakePlatformViewsController.lastCreatedView; + + platformWebView.fakeOnPageStartedCallback(); + }); + + testWidgets('onPageStarted changed', (WidgetTester tester) async { + String returnedUrl; + + await tester.pumpWidget(WebView( + initialUrl: 'https://youtube.com', + onPageStarted: (String url) {}, + )); + + await tester.pumpWidget(WebView( + initialUrl: 'https://youtube.com', + onPageStarted: (String url) { + returnedUrl = url; + }, + )); + + final FakePlatformWebView platformWebView = + fakePlatformViewsController.lastCreatedView; + + platformWebView.fakeOnPageStartedCallback(); + + expect(platformWebView.currentUrl, returnedUrl); + }); + }); } class FakePlatformWebView { @@ -1063,6 +1185,28 @@ class FakePlatformWebView { ); } + void fakeOnPageReceiveErrorCallback(int code, String description) { + final StandardMethodCodec codec = const StandardMethodCodec(); + + final ByteData data = codec.encodeMethodCall(MethodCall( + 'onPageReceiveError', + { + 'url': currentUrl, + 'code': code, + 'description': description + }, + )); + + // TODO(hterkelsen): Remove this when defaultBinaryMessages is in stable. + // https://github.com/flutter/flutter/issues/33446 + // ignore: deprecated_member_use + BinaryMessages.handlePlatformMessage( + channel.name, + data, + (ByteData data) {}, + ); + } + void _loadUrl(String url) { history = history.sublist(0, currentPosition + 1); history.add(url);