diff --git a/packages/webview_flutter/webview_flutter_wkwebview/lib/src/v4/src/webkit_navigation_delegate.dart b/packages/webview_flutter/webview_flutter_wkwebview/lib/src/v4/src/webkit_navigation_delegate.dart new file mode 100644 index 000000000000..10b1fab52e03 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/lib/src/v4/src/webkit_navigation_delegate.dart @@ -0,0 +1,161 @@ +// 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. + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:webview_flutter_platform_interface/v4/webview_flutter_platform_interface.dart'; + +import '../../foundation/foundation.dart'; +import '../../web_kit/web_kit.dart'; +import 'webkit_proxy.dart'; + +/// An implementation of [WebResourceError] with the WebKit API. +class WebKitWebResourceError extends WebResourceError { + WebKitWebResourceError._(this._nsError) + : super( + errorCode: _nsError.code, + description: _nsError.localizedDescription, + errorType: _toWebResourceErrorType(_nsError.code), + ); + + static WebResourceErrorType? _toWebResourceErrorType(int code) { + switch (code) { + case WKErrorCode.unknown: + return WebResourceErrorType.unknown; + case WKErrorCode.webContentProcessTerminated: + return WebResourceErrorType.webContentProcessTerminated; + case WKErrorCode.webViewInvalidated: + return WebResourceErrorType.webViewInvalidated; + case WKErrorCode.javaScriptExceptionOccurred: + return WebResourceErrorType.javaScriptExceptionOccurred; + case WKErrorCode.javaScriptResultTypeIsUnsupported: + return WebResourceErrorType.javaScriptResultTypeIsUnsupported; + } + + return null; + } + + /// A string representing the domain of the error. + String? get domain => _nsError.domain; + + final NSError _nsError; +} + +/// An implementation of [PlatformNavigationDelegate] with the WebKit API. +class WebKitNavigationDelegate extends PlatformNavigationDelegate { + /// Constructs a [WebKitNavigationDelegate]. + WebKitNavigationDelegate( + super.params, { + @visibleForTesting WebKitProxy webKitProxy = const WebKitProxy(), + }) : super.implementation() { + final WeakReference weakThis = + WeakReference(this); + navigationDelegate = webKitProxy.createNavigationDelegate( + didFinishNavigation: (WKWebView webView, String? url) { + if (weakThis.target?._onPageFinished != null) { + weakThis.target!._onPageFinished!(url ?? ''); + } + }, + didStartProvisionalNavigation: (WKWebView webView, String? url) { + if (weakThis.target?._onPageStarted != null) { + weakThis.target!._onPageStarted!(url ?? ''); + } + }, + decidePolicyForNavigationAction: ( + WKWebView webView, + WKNavigationAction action, + ) async { + if (weakThis.target?._onNavigationRequest != null) { + final bool allow = await weakThis.target!._onNavigationRequest!( + url: action.request.url, + isForMainFrame: action.targetFrame.isMainFrame, + ); + return allow + ? WKNavigationActionPolicy.allow + : WKNavigationActionPolicy.cancel; + } + return WKNavigationActionPolicy.allow; + }, + didFailNavigation: (WKWebView webView, NSError error) { + if (weakThis.target?._onWebResourceError != null) { + weakThis.target!._onWebResourceError!( + WebKitWebResourceError._(error), + ); + } + }, + didFailProvisionalNavigation: (WKWebView webView, NSError error) { + if (weakThis.target?._onWebResourceError != null) { + weakThis.target!._onWebResourceError!( + WebKitWebResourceError._(error), + ); + } + }, + webViewWebContentProcessDidTerminate: (WKWebView webView) { + if (weakThis.target?._onWebResourceError != null) { + weakThis.target!._onWebResourceError!( + WebKitWebResourceError._( + const NSError( + code: WKErrorCode.webContentProcessTerminated, + // Value from https://developer.apple.com/documentation/webkit/wkerrordomain?language=objc. + domain: 'WKErrorDomain', + localizedDescription: '', + ), + ), + ); + } + }, + ); + } + + // Used to set `WKWebView.setNavigationDelegate` in `WebKitWebViewController`. + /// WebKit class that handles navigation changes and tracking navigation + /// requests. + late final WKNavigationDelegate navigationDelegate; + + void Function(String url)? _onPageFinished; + void Function(String url)? _onPageStarted; + void Function(int progress)? _onProgress; + void Function(WebResourceError error)? _onWebResourceError; + FutureOr Function({required String url, required bool isForMainFrame})? + _onNavigationRequest; + + // `WKWebView` in `WebKitWebViewController` uses this to track loading + // progress. This can't be done with `WKNavigationDelegate`. + /// Callback method that receives progress of a loading page. + void Function(int progress)? get onProgress => _onProgress; + + @override + Future setOnPageFinished( + void Function(String url) onPageFinished, + ) async { + _onPageFinished = onPageFinished; + } + + @override + Future setOnPageStarted(void Function(String url) onPageStarted) async { + _onPageStarted = onPageStarted; + } + + @override + Future setOnProgress(void Function(int progress) onProgress) async { + _onProgress = onProgress; + } + + @override + Future setOnWebResourceError( + void Function(WebResourceError error) onWebResourceError, + ) async { + _onWebResourceError = onWebResourceError; + } + + @override + Future setOnNavigationRequest( + FutureOr Function({required String url, required bool isForMainFrame}) + onNavigationRequest, + ) async { + _onNavigationRequest = onNavigationRequest; + } +} diff --git a/packages/webview_flutter/webview_flutter_wkwebview/lib/src/v4/src/webkit_proxy.dart b/packages/webview_flutter/webview_flutter_wkwebview/lib/src/v4/src/webkit_proxy.dart index 48e6faf9abd7..013eae0516cd 100644 --- a/packages/webview_flutter/webview_flutter_wkwebview/lib/src/v4/src/webkit_proxy.dart +++ b/packages/webview_flutter/webview_flutter_wkwebview/lib/src/v4/src/webkit_proxy.dart @@ -20,6 +20,7 @@ class WebKitProxy { this.createWebView = WKWebView.new, this.createWebViewConfiguration = WKWebViewConfiguration.new, this.createScriptMessageHandler = WKScriptMessageHandler.new, + this.createNavigationDelegate = WKNavigationDelegate.new, }); /// Constructs a [WKWebView]. @@ -44,4 +45,20 @@ class WebKitProxy { ) didReceiveScriptMessage, }) createScriptMessageHandler; + + /// Constructs a [WKNavigationDelegate]. + final WKNavigationDelegate Function({ + void Function(WKWebView webView, String? url)? didFinishNavigation, + void Function(WKWebView webView, String? url)? + didStartProvisionalNavigation, + Future Function( + WKWebView webView, + WKNavigationAction navigationAction, + )? + decidePolicyForNavigationAction, + void Function(WKWebView webView, NSError error)? didFailNavigation, + void Function(WKWebView webView, NSError error)? + didFailProvisionalNavigation, + void Function(WKWebView webView)? webViewWebContentProcessDidTerminate, + }) createNavigationDelegate; } diff --git a/packages/webview_flutter/webview_flutter_wkwebview/lib/src/v4/src/webkit_webview_controller.dart b/packages/webview_flutter/webview_flutter_wkwebview/lib/src/v4/src/webkit_webview_controller.dart index 9d76b5cda04d..dc0693e5f9a5 100644 --- a/packages/webview_flutter/webview_flutter_wkwebview/lib/src/v4/src/webkit_webview_controller.dart +++ b/packages/webview_flutter/webview_flutter_wkwebview/lib/src/v4/src/webkit_webview_controller.dart @@ -13,6 +13,7 @@ import 'package:webview_flutter_platform_interface/v4/webview_flutter_platform_i import '../../common/weak_reference_utils.dart'; import '../../foundation/foundation.dart'; import '../../web_kit/web_kit.dart'; +import 'webkit_navigation_delegate.dart'; import 'webkit_proxy.dart'; /// Object specifying creation parameters for a [WebKitWebViewController]. @@ -46,8 +47,29 @@ class WebKitWebViewController extends PlatformWebViewController { ? params : WebKitWebViewControllerCreationParams .fromPlatformWebViewControllerCreationParams(params)) { + final WeakReference weakThis = + WeakReference(this); _webView = webKitProxy.createWebView( - (params as WebKitWebViewControllerCreationParams)._configuration); + (params as WebKitWebViewControllerCreationParams)._configuration, + observeValue: ( + String keyPath, + NSObject object, + Map change, + ) { + if (weakThis.target?._onProgress != null) { + final double progress = + change[NSKeyValueChangeKey.newValue]! as double; + weakThis.target!._onProgress!((progress * 100).round()); + } + }, + ); + _webView.addObserver( + _webView, + keyPath: 'estimatedProgress', + options: { + NSKeyValueObservingOptions.newValue, + }, + ); } late final WKWebView _webView; @@ -56,6 +78,7 @@ class WebKitWebViewController extends PlatformWebViewController { {}; bool _zoomEnabled = true; + void Function(int progress)? _onProgress; @override Future loadFile(String absoluteFilePath) { @@ -270,6 +293,14 @@ class WebKitWebViewController extends PlatformWebViewController { } } + @override + Future setPlatformNavigationDelegate( + covariant WebKitNavigationDelegate handler, + ) { + _onProgress = handler.onProgress; + return _webView.setNavigationDelegate(handler.navigationDelegate); + } + Future _disableZoom() { const WKUserScript userScript = WKUserScript( "var meta = document.createElement('meta');\n" diff --git a/packages/webview_flutter/webview_flutter_wkwebview/lib/src/v4/src/webkit_webview_platform.dart b/packages/webview_flutter/webview_flutter_wkwebview/lib/src/v4/src/webkit_webview_platform.dart index b808086d56f8..6ffe386a5e28 100644 --- a/packages/webview_flutter/webview_flutter_wkwebview/lib/src/v4/src/webkit_webview_platform.dart +++ b/packages/webview_flutter/webview_flutter_wkwebview/lib/src/v4/src/webkit_webview_platform.dart @@ -4,9 +4,10 @@ import 'package:webview_flutter_platform_interface/v4/webview_flutter_platform_interface.dart'; +import 'webkit_navigation_delegate.dart'; import 'webkit_webview_controller.dart'; -/// Implementation of [WebViewPlatform] using the WebKit Api. +/// Implementation of [WebViewPlatform] using the WebKit API. class WebKitWebViewPlatform extends WebViewPlatform { @override WebKitWebViewController createPlatformWebViewController( @@ -14,4 +15,11 @@ class WebKitWebViewPlatform extends WebViewPlatform { ) { return WebKitWebViewController(params); } + + @override + WebKitNavigationDelegate createPlatformNavigationDelegate( + PlatformNavigationDelegateCreationParams params, + ) { + return WebKitNavigationDelegate(params); + } } diff --git a/packages/webview_flutter/webview_flutter_wkwebview/lib/src/v4/webview_flutter_wkwebview.dart b/packages/webview_flutter/webview_flutter_wkwebview/lib/src/v4/webview_flutter_wkwebview.dart index 2a593fb5a088..6c5cd93f11d7 100644 --- a/packages/webview_flutter/webview_flutter_wkwebview/lib/src/v4/webview_flutter_wkwebview.dart +++ b/packages/webview_flutter/webview_flutter_wkwebview/lib/src/v4/webview_flutter_wkwebview.dart @@ -4,4 +4,6 @@ library webview_flutter_wkwebview; +export 'src/webkit_navigation_delegate.dart'; +export 'src/webkit_webview_controller.dart'; export 'src/webkit_webview_controller.dart'; diff --git a/packages/webview_flutter/webview_flutter_wkwebview/test/v4/webkit_navigation_delegate_test.dart b/packages/webview_flutter/webview_flutter_wkwebview/test/v4/webkit_navigation_delegate_test.dart new file mode 100644 index 000000000000..fb57c988ec32 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/test/v4/webkit_navigation_delegate_test.dart @@ -0,0 +1,201 @@ +// 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. + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:webview_flutter_platform_interface/v4/webview_flutter_platform_interface.dart'; +import 'package:webview_flutter_wkwebview/src/foundation/foundation.dart'; +import 'package:webview_flutter_wkwebview/src/v4/src/webkit_proxy.dart'; +import 'package:webview_flutter_wkwebview/src/v4/webview_flutter_wkwebview.dart'; +import 'package:webview_flutter_wkwebview/src/web_kit/web_kit.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + + group('WebKitNavigationDelegate', () { + test('setOnPageFinished', () { + final WebKitNavigationDelegate webKitDelgate = WebKitNavigationDelegate( + const PlatformNavigationDelegateCreationParams(), + webKitProxy: const WebKitProxy( + createNavigationDelegate: CapturingNavigationDelegate.new, + ), + ); + + late final String callbackUrl; + webKitDelgate.setOnPageFinished((String url) => callbackUrl = url); + + CapturingNavigationDelegate.lastCreatedDelegate.didFinishNavigation!( + WKWebView.detached(), + 'https://www.google.com', + ); + + expect(callbackUrl, 'https://www.google.com'); + }); + + test('setOnPageStarted', () { + final WebKitNavigationDelegate webKitDelgate = WebKitNavigationDelegate( + const PlatformNavigationDelegateCreationParams(), + webKitProxy: const WebKitProxy( + createNavigationDelegate: CapturingNavigationDelegate.new, + ), + ); + + late final String callbackUrl; + webKitDelgate.setOnPageStarted((String url) => callbackUrl = url); + + CapturingNavigationDelegate + .lastCreatedDelegate.didStartProvisionalNavigation!( + WKWebView.detached(), + 'https://www.google.com', + ); + + expect(callbackUrl, 'https://www.google.com'); + }); + + test('onWebResourceError from didFailNavigation', () { + final WebKitNavigationDelegate webKitDelgate = WebKitNavigationDelegate( + const PlatformNavigationDelegateCreationParams(), + webKitProxy: const WebKitProxy( + createNavigationDelegate: CapturingNavigationDelegate.new, + ), + ); + + late final WebKitWebResourceError callbackError; + void onWebResourceError(WebResourceError error) { + callbackError = error as WebKitWebResourceError; + } + + webKitDelgate.setOnWebResourceError(onWebResourceError); + + CapturingNavigationDelegate.lastCreatedDelegate.didFailNavigation!( + WKWebView.detached(), + const NSError( + code: WKErrorCode.webViewInvalidated, + domain: 'domain', + localizedDescription: 'my desc', + ), + ); + + expect(callbackError.description, 'my desc'); + expect(callbackError.errorCode, WKErrorCode.webViewInvalidated); + expect(callbackError.domain, 'domain'); + expect(callbackError.errorType, WebResourceErrorType.webViewInvalidated); + }); + + test('onWebResourceError from didFailProvisionalNavigation', () { + final WebKitNavigationDelegate webKitDelgate = WebKitNavigationDelegate( + const PlatformNavigationDelegateCreationParams(), + webKitProxy: const WebKitProxy( + createNavigationDelegate: CapturingNavigationDelegate.new, + ), + ); + + late final WebKitWebResourceError callbackError; + void onWebResourceError(WebResourceError error) { + callbackError = error as WebKitWebResourceError; + } + + webKitDelgate.setOnWebResourceError(onWebResourceError); + + CapturingNavigationDelegate + .lastCreatedDelegate.didFailProvisionalNavigation!( + WKWebView.detached(), + const NSError( + code: WKErrorCode.webViewInvalidated, + domain: 'domain', + localizedDescription: 'my desc', + ), + ); + + expect(callbackError.description, 'my desc'); + expect(callbackError.errorCode, WKErrorCode.webViewInvalidated); + expect(callbackError.domain, 'domain'); + expect(callbackError.errorType, WebResourceErrorType.webViewInvalidated); + }); + + test('onWebResourceError from webViewWebContentProcessDidTerminate', () { + final WebKitNavigationDelegate webKitDelgate = WebKitNavigationDelegate( + const PlatformNavigationDelegateCreationParams(), + webKitProxy: const WebKitProxy( + createNavigationDelegate: CapturingNavigationDelegate.new, + ), + ); + + late final WebKitWebResourceError callbackError; + void onWebResourceError(WebResourceError error) { + callbackError = error as WebKitWebResourceError; + } + + webKitDelgate.setOnWebResourceError(onWebResourceError); + + CapturingNavigationDelegate + .lastCreatedDelegate.webViewWebContentProcessDidTerminate!( + WKWebView.detached(), + ); + + expect(callbackError.description, ''); + expect(callbackError.errorCode, WKErrorCode.webContentProcessTerminated); + expect(callbackError.domain, 'WKErrorDomain'); + expect( + callbackError.errorType, + WebResourceErrorType.webContentProcessTerminated, + ); + }); + + test('onNavigationRequest from decidePolicyForNavigationAction', () { + final WebKitNavigationDelegate webKitDelgate = WebKitNavigationDelegate( + const PlatformNavigationDelegateCreationParams(), + webKitProxy: const WebKitProxy( + createNavigationDelegate: CapturingNavigationDelegate.new, + ), + ); + + late final String callbackUrl; + late final bool callbackIsMainFrame; + FutureOr onNavigationRequest({ + required String url, + required bool isForMainFrame, + }) { + callbackUrl = url; + callbackIsMainFrame = isForMainFrame; + return true; + } + + webKitDelgate.setOnNavigationRequest(onNavigationRequest); + + expect( + CapturingNavigationDelegate + .lastCreatedDelegate.decidePolicyForNavigationAction!( + WKWebView.detached(), + const WKNavigationAction( + request: NSUrlRequest(url: 'https://www.google.com'), + targetFrame: WKFrameInfo(isMainFrame: false), + ), + ), + completion(WKNavigationActionPolicy.allow), + ); + + expect(callbackUrl, 'https://www.google.com'); + expect(callbackIsMainFrame, isFalse); + }); + }); +} + +// Records the last created instance of itself. +class CapturingNavigationDelegate extends WKNavigationDelegate { + CapturingNavigationDelegate({ + super.didFinishNavigation, + super.didStartProvisionalNavigation, + super.didFailNavigation, + super.didFailProvisionalNavigation, + super.decidePolicyForNavigationAction, + super.webViewWebContentProcessDidTerminate, + }) : super.detached() { + lastCreatedDelegate = this; + } + static CapturingNavigationDelegate lastCreatedDelegate = + CapturingNavigationDelegate(); +} diff --git a/packages/webview_flutter/webview_flutter_wkwebview/test/v4/webkit_webview_controller_test.dart b/packages/webview_flutter/webview_flutter_wkwebview/test/v4/webkit_webview_controller_test.dart index 71bda575aa52..3f0e7f217fa9 100644 --- a/packages/webview_flutter/webview_flutter_wkwebview/test/v4/webkit_webview_controller_test.dart +++ b/packages/webview_flutter/webview_flutter_wkwebview/test/v4/webkit_webview_controller_test.dart @@ -705,6 +705,88 @@ void main() { "var head = document.getElementsByTagName('head')[0];head.appendChild(meta);", ); }); + + test('setPlatformNavigationDelegate', () { + final MockWKWebView mockWebView = MockWKWebView(); + + final WebKitWebViewController controller = createControllerWithMocks( + createMockWebView: (_, {dynamic observeValue}) => mockWebView, + ); + + final WebKitNavigationDelegate navigationDelegate = + WebKitNavigationDelegate( + const PlatformNavigationDelegateCreationParams(), + webKitProxy: const WebKitProxy( + createNavigationDelegate: CapturingNavigationDelegate.new, + ), + ); + + controller.setPlatformNavigationDelegate(navigationDelegate); + + verify( + mockWebView.setNavigationDelegate( + CapturingNavigationDelegate.lastCreatedDelegate, + ), + ); + }); + + test('setPlatformNavigationDelegate onProgress', () async { + final MockWKWebView mockWebView = MockWKWebView(); + + late final void Function( + String keyPath, + NSObject object, + Map change, + ) webViewObserveValue; + + final WebKitWebViewController controller = createControllerWithMocks( + createMockWebView: ( + _, { + void Function( + String keyPath, + NSObject object, + Map change, + )? + observeValue, + }) { + webViewObserveValue = observeValue!; + return mockWebView; + }, + ); + + verify( + mockWebView.addObserver( + mockWebView, + keyPath: 'estimatedProgress', + options: { + NSKeyValueObservingOptions.newValue, + }, + ), + ); + + final WebKitNavigationDelegate navigationDelegate = + WebKitNavigationDelegate( + const PlatformNavigationDelegateCreationParams(), + webKitProxy: const WebKitProxy( + createNavigationDelegate: CapturingNavigationDelegate.new, + ), + ); + + late final int callbackProgress; + navigationDelegate.setOnProgress( + (int progress) => callbackProgress = progress, + ); + + await controller.setPlatformNavigationDelegate(navigationDelegate); + + webViewObserveValue( + 'estimatedProgress', + mockWebView, + {NSKeyValueChangeKey.newValue: 0.0}, + ); + + expect(callbackProgress, 0); + }); }); group('WebKitJavaScriptChannelParams', () { @@ -744,3 +826,19 @@ void main() { }); }); } + +// Records the last created instance of itself. +class CapturingNavigationDelegate extends WKNavigationDelegate { + CapturingNavigationDelegate({ + super.didFinishNavigation, + super.didStartProvisionalNavigation, + super.didFailNavigation, + super.didFailProvisionalNavigation, + super.decidePolicyForNavigationAction, + super.webViewWebContentProcessDidTerminate, + }) : super.detached() { + lastCreatedDelegate = this; + } + static CapturingNavigationDelegate lastCreatedDelegate = + CapturingNavigationDelegate(); +}