diff --git a/packages/webview_flutter/CHANGELOG.md b/packages/webview_flutter/CHANGELOG.md index aa5a97dde..13febe4ce 100644 --- a/packages/webview_flutter/CHANGELOG.md +++ b/packages/webview_flutter/CHANGELOG.md @@ -1,3 +1,10 @@ +## 0.10.1 + +* Implement `clearLocalStorage`. +* Implement `onHttpError` for the navigation delegate. +* Fix races and use-after-frees on WebView disposal, and avoid a web engine + teardown crash on the Tizen 10.0 TV emulator. + ## 0.10.0 * Update minimum supported SDK version to Flutter 3.32/Dart 3.8. diff --git a/packages/webview_flutter/README.md b/packages/webview_flutter/README.md index 3dd1b9a1d..6dc6ef0dc 100644 --- a/packages/webview_flutter/README.md +++ b/packages/webview_flutter/README.md @@ -23,7 +23,7 @@ This package is not an _endorsed_ implementation of `webview_flutter`. Therefore ```yaml dependencies: webview_flutter: ^4.13.1 - webview_flutter_tizen: ^0.10.0 + webview_flutter_tizen: ^0.10.1 ``` ## Example diff --git a/packages/webview_flutter/example/integration_test/webview_flutter_test.dart b/packages/webview_flutter/example/integration_test/webview_flutter_test.dart index d861f9994..1068d5898 100644 --- a/packages/webview_flutter/example/integration_test/webview_flutter_test.dart +++ b/packages/webview_flutter/example/integration_test/webview_flutter_test.dart @@ -44,12 +44,10 @@ Future main() async { final Completer pageFinished = Completer(); final WebViewController controller = WebViewController(); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate(onPageFinished: (_) => pageFinished.complete()), - ), + await controller.setNavigationDelegate( + NavigationDelegate(onPageFinished: (_) => pageFinished.complete()), ); - unawaited(controller.loadRequest(Uri.parse(primaryUrl))); + await controller.loadRequest(Uri.parse(primaryUrl)); await tester.pumpWidget(WebViewWidget(controller: controller)); @@ -63,13 +61,11 @@ Future main() async { final Completer pageFinished = Completer(); final WebViewController controller = WebViewController(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate(onPageFinished: (_) => pageFinished.complete()), - ), + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate(onPageFinished: (_) => pageFinished.complete()), ); - unawaited(controller.loadRequest(Uri.parse(primaryUrl))); + await controller.loadRequest(Uri.parse(primaryUrl)); await tester.pumpWidget(WebViewWidget(controller: controller)); @@ -89,16 +85,14 @@ Future main() async { final StreamController pageLoads = StreamController(); final WebViewController controller = WebViewController(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate(onPageFinished: (String url) => pageLoads.add(url)), - ), + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate(onPageFinished: (String url) => pageLoads.add(url)), ); await tester.pumpWidget(WebViewWidget(controller: controller)); - unawaited(controller.loadRequest(Uri.parse(headersUrl), headers: headers)); + await controller.loadRequest(Uri.parse(headersUrl), headers: headers); await pageLoads.stream.firstWhere((String url) => url == headersUrl); @@ -113,11 +107,9 @@ Future main() async { testWidgets('JavascriptChannel', (WidgetTester tester) async { final Completer pageFinished = Completer(); final WebViewController controller = WebViewController(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate(onPageFinished: (_) => pageFinished.complete()), - ), + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate(onPageFinished: (_) => pageFinished.complete()), ); final Completer channelCompleter = Completer(); @@ -178,14 +170,12 @@ Future main() async { final Completer pageFinished = Completer(); final WebViewController controller = WebViewController(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate(onPageFinished: (_) => pageFinished.complete()), - ), + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate(onPageFinished: (_) => pageFinished.complete()), ); - unawaited(controller.setUserAgent('Custom_User_Agent1')); - unawaited(controller.loadRequest(Uri.parse('about:blank'))); + await controller.setUserAgent('Custom_User_Agent1'); + await controller.loadRequest(Uri.parse('about:blank')); await tester.pumpWidget(WebViewWidget(controller: controller)); @@ -210,16 +200,12 @@ Future main() async { final Completer pageLoaded = Completer(); final WebViewController controller = WebViewController(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate(onPageFinished: (_) => pageLoaded.complete()), - ), + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate(onPageFinished: (_) => pageLoaded.complete()), ); - unawaited( - controller.loadRequest( - Uri.parse('data:text/html;charset=utf-8;base64,$getTitleTestBase64'), - ), + await controller.loadRequest( + Uri.parse('data:text/html;charset=utf-8;base64,$getTitleTestBase64'), ); await tester.pumpWidget(WebViewWidget(controller: controller)); @@ -265,18 +251,12 @@ Future main() async { final Completer pageLoaded = Completer(); final WebViewController controller = WebViewController(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate(onPageFinished: (_) => pageLoaded.complete()), - ), + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate(onPageFinished: (_) => pageLoaded.complete()), ); - unawaited( - controller.loadRequest( - Uri.parse( - 'data:text/html;charset=utf-8;base64,$scrollTestPageBase64', - ), - ), + await controller.loadRequest( + Uri.parse('data:text/html;charset=utf-8;base64,$scrollTestPageBase64'), ); await tester.pumpWidget(WebViewWidget(controller: controller)); @@ -296,14 +276,30 @@ Future main() async { expect(scrollPos.dx, isNot(X_SCROLL)); expect(scrollPos.dy, isNot(Y_SCROLL)); + // The scroll position settles asynchronously, so poll until it reaches + // the expected value (with a timeout) instead of reading it once. This + // keeps the test stable on slower software-GL rendering (e.g. emulators). + Future pollScrollPosition(int expectedX, int expectedY) async { + Offset pos = await controller.getScrollPosition(); + for ( + int i = 0; + i < 20 && (pos.dx != expectedX || pos.dy != expectedY); + i++ + ) { + await Future.delayed(const Duration(milliseconds: 100)); + pos = await controller.getScrollPosition(); + } + return pos; + } + await controller.scrollTo(X_SCROLL, Y_SCROLL); - scrollPos = await controller.getScrollPosition(); + scrollPos = await pollScrollPosition(X_SCROLL, Y_SCROLL); expect(scrollPos.dx, X_SCROLL); expect(scrollPos.dy, Y_SCROLL); // Check scrollBy() (on top of scrollTo()) await controller.scrollBy(X_SCROLL, Y_SCROLL); - scrollPos = await controller.getScrollPosition(); + scrollPos = await pollScrollPosition(X_SCROLL * 2, Y_SCROLL * 2); expect(scrollPos.dx, X_SCROLL * 2); expect(scrollPos.dy, Y_SCROLL * 2); }); @@ -319,23 +315,21 @@ Future main() async { Completer pageLoaded = Completer(); final WebViewController controller = WebViewController(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate( - onPageFinished: (_) => pageLoaded.complete(), - onNavigationRequest: (NavigationRequest navigationRequest) { - return (navigationRequest.url.contains('youtube.com')) - ? NavigationDecision.prevent - : NavigationDecision.navigate; - }, - ), + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate( + onPageFinished: (_) => pageLoaded.complete(), + onNavigationRequest: (NavigationRequest navigationRequest) { + return (navigationRequest.url.contains('youtube.com')) + ? NavigationDecision.prevent + : NavigationDecision.navigate; + }, ), ); await tester.pumpWidget(WebViewWidget(controller: controller)); - unawaited(controller.loadRequest(Uri.parse(blankPageEncoded))); + await controller.loadRequest(Uri.parse(blankPageEncoded)); await pageLoaded.future; // Wait for initial page load. @@ -352,19 +346,15 @@ Future main() async { Completer(); final WebViewController controller = WebViewController(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate( - onWebResourceError: (WebResourceError error) { - errorCompleter.complete(error); - }, - ), + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate( + onWebResourceError: (WebResourceError error) { + errorCompleter.complete(error); + }, ), ); - unawaited( - controller.loadRequest(Uri.parse('https://www.notawebsite..com')), - ); + await controller.loadRequest(Uri.parse('https://www.notawebsite..com')); await tester.pumpWidget(WebViewWidget(controller: controller)); @@ -380,21 +370,17 @@ Future main() async { final Completer pageFinishCompleter = Completer(); final WebViewController controller = WebViewController(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate( - onPageFinished: (_) => pageFinishCompleter.complete(), - onWebResourceError: (WebResourceError error) { - errorCompleter.complete(error); - }, - ), + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate( + onPageFinished: (_) => pageFinishCompleter.complete(), + onWebResourceError: (WebResourceError error) { + errorCompleter.complete(error); + }, ), ); - unawaited( - controller.loadRequest( - Uri.parse('data:text/html;charset=utf-8;base64,PCFET0NUWVBFIGh0bWw+'), - ), + await controller.loadRequest( + Uri.parse('data:text/html;charset=utf-8;base64,PCFET0NUWVBFIGh0bWw+'), ); await tester.pumpWidget(WebViewWidget(controller: controller)); @@ -407,23 +393,21 @@ Future main() async { Completer pageLoaded = Completer(); final WebViewController controller = WebViewController(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate( - onPageFinished: (_) => pageLoaded.complete(), - onNavigationRequest: (NavigationRequest navigationRequest) { - return (navigationRequest.url.contains('youtube.com')) - ? NavigationDecision.prevent - : NavigationDecision.navigate; - }, - ), + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate( + onPageFinished: (_) => pageLoaded.complete(), + onNavigationRequest: (NavigationRequest navigationRequest) { + return (navigationRequest.url.contains('youtube.com')) + ? NavigationDecision.prevent + : NavigationDecision.navigate; + }, ), ); await tester.pumpWidget(WebViewWidget(controller: controller)); - unawaited(controller.loadRequest(Uri.parse(blankPageEncoded))); + await controller.loadRequest(Uri.parse(blankPageEncoded)); await pageLoaded.future; // Wait for initial page load. @@ -443,30 +427,90 @@ Future main() async { expect(currentUrl, isNot(contains('youtube.com'))); }); - testWidgets('supports asynchronous decisions', (WidgetTester tester) async { - Completer pageLoaded = Completer(); + testWidgets('onHttpError', (WidgetTester tester) async { + final Completer errorCompleter = + Completer(); final WebViewController controller = WebViewController(); unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); unawaited( controller.setNavigationDelegate( NavigationDelegate( - onPageFinished: (_) => pageLoaded.complete(), - onNavigationRequest: (NavigationRequest navigationRequest) async { - NavigationDecision decision = NavigationDecision.prevent; - decision = await Future.delayed( - const Duration(milliseconds: 10), - () => NavigationDecision.navigate, - ); - return decision; + onHttpError: (HttpResponseError error) { + errorCompleter.complete(error); }, ), ), ); + unawaited(controller.loadRequest(Uri.parse('$prefixUrl/favicon.ico'))); + await tester.pumpWidget(WebViewWidget(controller: controller)); - unawaited(controller.loadRequest(Uri.parse(blankPageEncoded))); + final HttpResponseError error = await errorCompleter.future; + + expect(error, isNotNull); + expect(error.response?.statusCode, 404); + }); + + testWidgets('onHttpError is not called when no HTTP error is received', ( + WidgetTester tester, + ) async { + const String testPage = ''' + + + + + + '''; + + final Completer errorCompleter = + Completer(); + final Completer pageFinishCompleter = Completer(); + + final WebViewController controller = WebViewController(); + unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); + unawaited( + controller.setNavigationDelegate( + NavigationDelegate( + onPageFinished: (_) => pageFinishCompleter.complete(), + onHttpError: (HttpResponseError error) { + errorCompleter.complete(error); + }, + ), + ), + ); + + unawaited(controller.loadHtmlString(testPage)); + + await tester.pumpWidget(WebViewWidget(controller: controller)); + + expect(errorCompleter.future, doesNotComplete); + await pageFinishCompleter.future; + }); + + testWidgets('supports asynchronous decisions', (WidgetTester tester) async { + Completer pageLoaded = Completer(); + + final WebViewController controller = WebViewController(); + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate( + onPageFinished: (_) => pageLoaded.complete(), + onNavigationRequest: (NavigationRequest navigationRequest) async { + NavigationDecision decision = NavigationDecision.prevent; + decision = await Future.delayed( + const Duration(milliseconds: 10), + () => NavigationDecision.navigate, + ); + return decision; + }, + ), + ); + + await tester.pumpWidget(WebViewWidget(controller: controller)); + + await controller.loadRequest(Uri.parse(blankPageEncoded)); await pageLoaded.future; // Wait for initial page load. @@ -483,13 +527,11 @@ Future main() async { final WebViewController controller = WebViewController(); final Completer urlChangeCompleter = Completer(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate(onPageFinished: (_) => pageLoaded.complete()), - ), + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate(onPageFinished: (_) => pageLoaded.complete()), ); - unawaited(controller.loadRequest(Uri.parse(blankPageEncoded))); + await controller.loadRequest(Uri.parse(blankPageEncoded)); await tester.pumpWidget(WebViewWidget(controller: controller)); @@ -517,9 +559,9 @@ Future main() async { ); final WebViewController controller = WebViewController(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited(controller.setNavigationDelegate(navigationDelegate)); - unawaited(controller.loadRequest(Uri.parse(primaryUrl))); + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate(navigationDelegate); + await controller.loadRequest(Uri.parse(primaryUrl)); await tester.pumpWidget(WebViewWidget(controller: controller)); @@ -541,6 +583,41 @@ Future main() async { await expectLater(urlChangeCompleter.future, completion(secondaryUrl)); }); }); + + testWidgets('clearLocalStorage', (WidgetTester tester) async { + Completer pageLoadCompleter = Completer(); + + final WebViewController controller = WebViewController(); + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate(onPageFinished: (_) => pageLoadCompleter.complete()), + ); + await controller.loadRequest(Uri.parse(primaryUrl)); + + await tester.pumpWidget(WebViewWidget(controller: controller)); + + await pageLoadCompleter.future; + pageLoadCompleter = Completer(); + + await controller.runJavaScript('localStorage.setItem("myCat", "Tom");'); + final String myCatItem = + await controller.runJavaScriptReturningResult( + 'localStorage.getItem("myCat");', + ) + as String; + expect(myCatItem, 'Tom'); + + await controller.clearLocalStorage(); + + // Reload page to have changes take effect. + await controller.reload(); + await pageLoadCompleter.future; + + final Object nullItem = await controller.runJavaScriptReturningResult( + 'localStorage.getItem("myCat");', + ); + expect(nullItem, ''); + }); } class ResizableWebView extends StatefulWidget { diff --git a/packages/webview_flutter/example/lib/main.dart b/packages/webview_flutter/example/lib/main.dart index 36e248fb6..7899aa526 100644 --- a/packages/webview_flutter/example/lib/main.dart +++ b/packages/webview_flutter/example/lib/main.dart @@ -190,10 +190,9 @@ Page resource error: debugPrint('allowing navigation to ${request.url}'); return NavigationDecision.navigate; }, - // Note: onHttpError is not implemented by TizenWebview. - // onHttpError: (HttpResponseError error) { - // debugPrint('Error occurred on page: ${error.response?.statusCode}'); - // }, + onHttpError: (HttpResponseError error) { + debugPrint('Error occurred on page: ${error.response?.statusCode}'); + }, onUrlChange: (UrlChange change) { debugPrint('url change to ${change.url}'); }, @@ -491,8 +490,7 @@ class SampleMenu extends StatelessWidget { Future _onClearCache(BuildContext context) async { await webViewController.clearCache(); - // This is unimplemented in webview_flutter_tizen. - // await webViewController.clearLocalStorage(); + await webViewController.clearLocalStorage(); if (context.mounted) { ScaffoldMessenger.of( context, diff --git a/packages/webview_flutter/lib/src/tizen_webview.dart b/packages/webview_flutter/lib/src/tizen_webview.dart index 236dd4821..ca3a0758b 100644 --- a/packages/webview_flutter/lib/src/tizen_webview.dart +++ b/packages/webview_flutter/lib/src/tizen_webview.dart @@ -151,6 +151,10 @@ class TizenWebView { /// Clears all caches used by the [WebView]. Future clearCache() => _invokeChannelMethod('clearCache'); + /// Clears the local storage used by the [WebView]. + Future clearLocalStorage() => + _invokeChannelMethod('clearLocalStorage'); + /// Sets the JavaScript execution mode to be used by the webview. Future setJavaScriptMode(int javaScriptMode) => _invokeChannelMethod('javaScriptMode', javaScriptMode); diff --git a/packages/webview_flutter/lib/src/tizen_webview_controller.dart b/packages/webview_flutter/lib/src/tizen_webview_controller.dart index b364fa7a1..b6909e1bc 100644 --- a/packages/webview_flutter/lib/src/tizen_webview_controller.dart +++ b/packages/webview_flutter/lib/src/tizen_webview_controller.dart @@ -218,12 +218,7 @@ class TizenWebViewController extends PlatformWebViewController { Future clearCache() => _webview.clearCache(); @override - Future clearLocalStorage() { - throw UnimplementedError( - 'This version of `TizenWebViewController` currently has no ' - 'implementation.', - ); - } + Future clearLocalStorage() => _webview.clearLocalStorage(); @override Future setPlatformNavigationDelegate( @@ -477,6 +472,7 @@ class TizenNavigationDelegate extends PlatformNavigationDelegate { WebResourceErrorCallback? _onWebResourceError; NavigationRequestCallback? _onNavigationRequest; UrlChangeCallback? _onUrlChange; + HttpResponseErrorCallback? _onHttpError; /// Called when [TizenView] is created. void createNavigationDelegateChannel(int viewId) { @@ -525,6 +521,20 @@ class TizenNavigationDelegate extends PlatformNavigationDelegate { _onUrlChange!(UrlChange(url: arguments['url']! as String)); } return null; + case 'onHttpError': + if (_onHttpError != null) { + final Uri uri = Uri.parse(arguments['url']! as String); + _onHttpError!( + HttpResponseError( + request: WebResourceRequest(uri: uri), + response: WebResourceResponse( + uri: uri, + statusCode: arguments['statusCode']! as int, + ), + ), + ); + } + return null; } throw MissingPluginException( @@ -580,10 +590,7 @@ class TizenNavigationDelegate extends PlatformNavigationDelegate { @override Future setOnHttpError(HttpResponseErrorCallback onHttpError) async { - throw UnimplementedError( - 'This version of `TizenNavigationDelegate` currently has no ' - 'implementation for `setOnHttpError`', - ); + _onHttpError = onHttpError; } @override diff --git a/packages/webview_flutter/pubspec.yaml b/packages/webview_flutter/pubspec.yaml index fc461b335..d619ea013 100644 --- a/packages/webview_flutter/pubspec.yaml +++ b/packages/webview_flutter/pubspec.yaml @@ -2,7 +2,7 @@ name: webview_flutter_tizen description: Tizen implementation of the webview_flutter plugin. homepage: https://github.com/flutter-tizen/plugins repository: https://github.com/flutter-tizen/plugins/tree/master/packages/webview_flutter -version: 0.10.0 +version: 0.10.1 environment: sdk: ^3.8.0 diff --git a/packages/webview_flutter/tizen/src/webview.cc b/packages/webview_flutter/tizen/src/webview.cc index 9bc0e73ad..d1be2db7f 100644 --- a/packages/webview_flutter/tizen/src/webview.cc +++ b/packages/webview_flutter/tizen/src/webview.cc @@ -4,6 +4,7 @@ #include "webview.h" +#include #include #include #include @@ -44,9 +45,19 @@ std::string ConvertLogLevelToString(Ewk_Console_Message_Level level) { class NavigationRequestResult : public FlMethodResult { public: - NavigationRequestResult(WebView* webview) : webview_(webview) {} + // |alive| is the WebView's is_alive_ flag. Dart resolves the + // "navigationRequest" method call asynchronously (it round-trips through + // the Dart navigation delegate), so this result's completion can run well + // after the WebView that created it has been disposed; |webview_| would + // then be a dangling pointer. Checking |alive| before dereferencing it + // avoids a use-after-free in that case. + NavigationRequestResult(WebView* webview, std::shared_ptr alive) + : webview_(webview), alive_(std::move(alive)) {} void SuccessInternal(const flutter::EncodableValue* should_load) override { + if (!*alive_) { + return; + } if (std::holds_alternative(*should_load)) { if (std::get(*should_load)) { webview_->Resume(); @@ -60,16 +71,23 @@ class NavigationRequestResult : public FlMethodResult { const std::string& error_message, const flutter::EncodableValue* error_details) override { LOG_ERROR("The request unexpectedly completed with an error."); + if (!*alive_) { + return; + } webview_->Stop(); } void NotImplementedInternal() override { LOG_ERROR("The target method was unexpectedly unimplemented."); + if (!*alive_) { + return; + } webview_->Stop(); } private: WebView* webview_; + std::shared_ptr alive_; }; template @@ -173,33 +191,111 @@ void WebView::Dispose() { if (disposed_) { return; } + disposed_ = true; - texture_registrar_->UnregisterTexture(GetTextureId(), nullptr); - - if (webview_instance_) { - evas_object_smart_callback_del(webview_instance_, - "offscreen,frame,rendered", + // A Dart "navigationRequest" reply can still arrive after Dispose() has + // run. The reply handler checks this flag and returns early, instead of + // using a WebView that no longer exists. + *is_alive_ = false; + + Evas_Object* instance = webview_instance_; + webview_instance_ = nullptr; + + if (instance) { + // Detach every callback registered in InitWebView() and + // RegisterJavaScriptChannelName(). The engine instance lives until the + // deferred evas_object_del() below, while this WebView is destroyed + // right after Dispose(). Without detaching, the engine could invoke + // these callbacks on the already-destroyed WebView during that window. + evas_object_smart_callback_del(instance, "offscreen,frame,rendered", &WebView::OnFrameRendered); - evas_object_smart_callback_del(webview_instance_, "load,started", + evas_object_smart_callback_del(instance, "load,started", &WebView::OnLoadStarted); - evas_object_smart_callback_del(webview_instance_, "load,finished", + evas_object_smart_callback_del(instance, "load,finished", &WebView::OnLoadFinished); - evas_object_smart_callback_del(webview_instance_, "load,progress", + evas_object_smart_callback_del(instance, "load,progress", &WebView::OnProgress); - evas_object_smart_callback_del(webview_instance_, "load,error", + evas_object_smart_callback_del(instance, "load,error", &WebView::OnLoadError); - evas_object_smart_callback_del(webview_instance_, "console,message", + evas_object_smart_callback_del(instance, "console,message", &WebView::OnConsoleMessage); - evas_object_smart_callback_del(webview_instance_, - "policy,navigation,decide", + evas_object_smart_callback_del(instance, "policy,navigation,decide", &WebView::OnNavigationPolicy); - evas_object_smart_callback_del(webview_instance_, "url,changed", + evas_object_smart_callback_del(instance, "policy,response,decide", + &WebView::OnResponsePolicy); + evas_object_smart_callback_del(instance, "url,changed", &WebView::OnUrlChange); - evas_object_del(webview_instance_); + EwkInternalApiBinding::GetInstance().view.OnJavaScriptAlert( + instance, nullptr, nullptr); + EwkInternalApiBinding::GetInstance().view.OnJavaScriptConfirm( + instance, nullptr, nullptr); + EwkInternalApiBinding::GetInstance().view.OnJavaScriptPrompt( + instance, nullptr, nullptr); + evas_object_data_del(instance, kEwkInstance); + + // Cancel any in-flight load and pause the page so it stops running while + // the deferred teardown below is pending. + ewk_view_stop(instance); + ewk_view_suspend(instance); + } + + // Stop handing out engine-owned TBM surfaces to the raster thread, and + // detach the buffer pool so its GPU surface descriptors outlive this + // object for any raster-thread frame still in flight. + std::unique_ptr pool; + { + std::lock_guard lock(mutex_); + is_disposing_ = true; + working_surface_ = nullptr; + candidate_surface_ = nullptr; + rendered_surface_ = nullptr; + pool = std::move(tbm_pool_); } + // The TBM surfaces backing the texture are owned by the web engine and are + // freed when the engine view is deleted. Deleting the view while an + // in-flight raster-thread frame is still reading one of those surfaces is a + // use-after-free (the emulator's SW rendering path reads the buffer on the + // CPU), crashing with SIGSEGV during EWebView teardown. The embedder tears + // the external texture down on the render thread only after any in-flight + // frame callback has completed and then invokes this completion callback, + // so evas_object_del() (and the release of the descriptor-owning buffer + // pool) is deferred until then. The callback fires on the render thread; + // evas_object_del() must run on the main thread, so hop back via + // ecore_main_loop_thread_safe_call_async(). + struct TeardownContext { + Evas_Object* instance; + std::unique_ptr pool; + }; + auto* context = new TeardownContext{instance, std::move(pool)}; + texture_registrar_->UnregisterTexture(GetTextureId(), [context]() { + ecore_main_loop_thread_safe_call_async( + [](void* data) { + auto* context = static_cast(data); + if (context->instance) { +#if defined(TV_PROFILE) && (defined(__x86_64__) || defined(__i386__)) + // On the Tizen 10.0 TV emulator image (the only TV + x86_64 + // target), deleting the ewk view crashes with SIGSEGV inside + // chromium-efl's ~SelectionControllerEfl(): after unsubscribing + // VCONFKEY_LANGSET it calls HideHandleAndContextMenu() -> + // CancelContextMenu(), which dereferences the WebContents that is + // already being destructed. That is engine code this plugin + // cannot fix, so hide the (already stopped and suspended) view + // and intentionally leak it instead of crashing. All other + // targets (arm/arm64 devices, the 32-bit x86 emulator, and the + // common-profile x86_64 emulator, whose engines are unaffected) + // delete normally. + evas_object_hide(context->instance); +#else + evas_object_del(context->instance); +#endif + } + delete context; + }, + context); + }); + // ewk_shutdown(); - disposed_ = true; } void WebView::Offset(double left, double top) { @@ -331,9 +427,17 @@ bool WebView::SendKey(const char* key, const char* string, const char* compose, return true; } -void WebView::Resume() { ewk_view_resume(webview_instance_); } +void WebView::Resume() { + if (webview_instance_) { + ewk_view_resume(webview_instance_); + } +} -void WebView::Stop() { ewk_view_stop(webview_instance_); } +void WebView::Stop() { + if (webview_instance_) { + ewk_view_stop(webview_instance_); + } +} void WebView::SetDirection(int direction) { // TODO: Implement if necessary. @@ -429,6 +533,8 @@ bool WebView::InitWebView() { &WebView::OnConsoleMessage, this); evas_object_smart_callback_add(webview_instance_, "policy,navigation,decide", &WebView::OnNavigationPolicy, this); + evas_object_smart_callback_add(webview_instance_, "policy,response,decide", + &WebView::OnResponsePolicy, this); evas_object_smart_callback_add(webview_instance_, "url,changed", &WebView::OnUrlChange, this); @@ -452,6 +558,12 @@ void WebView::HandleWebViewMethodCall(const FlMethodCall& method_call, const std::string& method_name = method_call.method_name(); const flutter::EncodableValue* arguments = method_call.arguments(); + if (disposed_) { + result->Error("Invalid operation", + "The webview instance has been disposed."); + return; + } + if (method_name == "setEnginePolicy") { const auto* engine_policy = std::get_if(arguments); if (engine_policy) { @@ -580,6 +692,10 @@ void WebView::HandleWebViewMethodCall(const FlMethodCall& method_call, Ewk_Context* context = ewk_view_context_get(webview_instance_); ewk_context_resource_cache_clear(context); result->Success(); + } else if (method_name == "clearLocalStorage") { + Ewk_Context* context = ewk_view_context_get(webview_instance_); + ewk_context_web_storage_delete_all(context); + result->Success(); } else if (method_name == "getTitle") { result->Success(flutter::EncodableValue( std::string(ewk_view_title_get(webview_instance_)))); @@ -745,6 +861,9 @@ void WebView::HandleCookieMethodCall(const FlMethodCall& method_call, FlutterDesktopGpuSurfaceDescriptor* WebView::ObtainGpuSurface(size_t width, size_t height) { std::lock_guard lock(mutex_); + if (is_disposing_ || !tbm_pool_) { + return nullptr; + } if (!candidate_surface_) { if (rendered_surface_) { return rendered_surface_->GpuSurface(); @@ -764,6 +883,9 @@ void WebView::OnFrameRendered(void* data, Evas_Object* obj, void* event_info) { WebView* webview = static_cast(data); std::lock_guard lock(webview->mutex_); + if (webview->is_disposing_ || !webview->tbm_pool_) { + return; + } if (!webview->working_surface_) { webview->working_surface_ = webview->tbm_pool_->GetAvailableBuffer(); webview->working_surface_->UseExternalBuffer(); @@ -865,12 +987,35 @@ void WebView::OnNavigationPolicy(void* data, Evas_Object* obj, {flutter::EncodableValue("isForMainFrame"), flutter::EncodableValue(true)}, }; - auto result = std::make_unique(webview); + auto result = + std::make_unique(webview, webview->is_alive_); webview->navigation_delegate_channel_->InvokeMethod( "navigationRequest", std::make_unique(args), std::move(result)); } +void WebView::OnResponsePolicy(void* data, Evas_Object* obj, void* event_info) { + WebView* webview = static_cast(data); + Ewk_Policy_Decision* policy_decision = + static_cast(event_info); + int status_code = + ewk_policy_decision_response_status_code_get(policy_decision); + const char* url = ewk_policy_decision_url_get(policy_decision); + ewk_policy_decision_use(policy_decision); + + // HTTP error status codes (4xx, 5xx) are reported to the navigation delegate. + if (!webview->has_navigation_delegate_ || status_code < 400) { + return; + } + flutter::EncodableMap args = { + {flutter::EncodableValue("url"), flutter::EncodableValue(url ? url : "")}, + {flutter::EncodableValue("statusCode"), + flutter::EncodableValue(status_code)}, + }; + webview->navigation_delegate_channel_->InvokeMethod( + "onHttpError", std::make_unique(args)); +} + void WebView::OnUrlChange(void* data, Evas_Object* obj, void* event_info) { WebView* webview = static_cast(data); std::string url = std::string(ewk_view_url_get(webview->webview_instance_)); @@ -896,7 +1041,9 @@ void WebView::OnJavaScriptMessage(Evas_Object* obj, if (obj) { WebView* webview = static_cast(evas_object_data_get(obj, kEwkInstance)); - if (webview->webview_channel_) { + // The data key is removed in Dispose(), so a message arriving during the + // deferred teardown yields nullptr here rather than a dangling pointer. + if (webview && webview->webview_channel_) { std::string channel_name(message.name); std::string message_body(static_cast(message.body)); diff --git a/packages/webview_flutter/tizen/src/webview.h b/packages/webview_flutter/tizen/src/webview.h index 688f97ddc..e0999f3ee 100644 --- a/packages/webview_flutter/tizen/src/webview.h +++ b/packages/webview_flutter/tizen/src/webview.h @@ -82,6 +82,7 @@ class WebView : public PlatformView { static void OnConsoleMessage(void* data, Evas_Object* obj, void* event_info); static void OnNavigationPolicy(void* data, Evas_Object* obj, void* event_info); + static void OnResponsePolicy(void* data, Evas_Object* obj, void* event_info); static void OnUrlChange(void* data, Evas_Object* obj, void* event_info); static void OnEvaluateJavaScript(Evas_Object* obj, const char* result_value, void* user_data); @@ -117,6 +118,14 @@ class WebView : public PlatformView { std::mutex mutex_; std::unique_ptr tbm_pool_; bool disposed_ = false; + // Set under mutex_ at the start of Dispose(). The raster thread checks it + // in ObtainGpuSurface() and stops being handed engine-owned TBM surfaces + // that are about to be freed by the deferred evas_object_del(). + bool is_disposing_ = false; + // Set to false at the start of Dispose(). A pending "navigationRequest" + // reply from Dart (resolved asynchronously) captures a copy and checks it + // before dereferencing this WebView, avoiding a use-after-free. + std::shared_ptr is_alive_ = std::make_shared(true); Ewk_Mouse_Button_Type mouse_button_type_ = (Ewk_Mouse_Button_Type)0; bool scrollbar_enabled_ = true; };