From cc8d3b886b6235da8232d4b25a01968b3f62c6dc Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Fri, 26 Jun 2026 12:32:43 +0900 Subject: [PATCH 1/5] [webview_flutter_tizen] Implement clearLocalStorage and onHttpError Add Tizen native implementations for two previously unimplemented APIs: - clearLocalStorage: clears web local storage via ewk_context_web_storage_delete_all. - onHttpError: reports HTTP error status codes (>= 400) to the navigation delegate via the policy,response,decide callback. Bump version to 0.10.1. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/webview_flutter/CHANGELOG.md | 5 ++++ .../webview_flutter/example/lib/main.dart | 10 +++---- .../lib/src/tizen_webview.dart | 4 +++ .../lib/src/tizen_webview_controller.dart | 27 ++++++++++------- packages/webview_flutter/pubspec.yaml | 2 +- packages/webview_flutter/tizen/src/webview.cc | 30 +++++++++++++++++++ packages/webview_flutter/tizen/src/webview.h | 1 + 7 files changed, 62 insertions(+), 17 deletions(-) diff --git a/packages/webview_flutter/CHANGELOG.md b/packages/webview_flutter/CHANGELOG.md index aa5a97dde..98fddfed2 100644 --- a/packages/webview_flutter/CHANGELOG.md +++ b/packages/webview_flutter/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.10.1 + +* Implement `clearLocalStorage`. +* Implement `onHttpError` for the navigation delegate. + ## 0.10.0 * Update minimum supported SDK version to Flutter 3.32/Dart 3.8. 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..f51a95e1d 100644 --- a/packages/webview_flutter/tizen/src/webview.cc +++ b/packages/webview_flutter/tizen/src/webview.cc @@ -429,6 +429,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); @@ -580,6 +582,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_)))); @@ -871,6 +877,30 @@ void WebView::OnNavigationPolicy(void* data, Evas_Object* obj, 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_)); diff --git a/packages/webview_flutter/tizen/src/webview.h b/packages/webview_flutter/tizen/src/webview.h index 688f97ddc..ac47a9bd6 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); From 71037f3c143aa24e298a514385ae0230571bd7b1 Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Fri, 26 Jun 2026 12:32:49 +0900 Subject: [PATCH 2/5] [webview_flutter_tizen] Add integration tests based on upstream v4.13.1 Port the remaining runnable upstream test cases from webview_flutter v4.13.1: - NavigationDelegate > onHttpError - NavigationDelegate > onHttpError is not called when no HTTP error is received - clearLocalStorage These pass thanks to the new clearLocalStorage and onHttpError implementations. The other upstream test cases remain omitted because they cannot run on Tizen: window.open/new-window behavior, HTTP basic auth, and media playback policy are not supported by the engine. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../webview_flutter_test.dart | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) 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..cd28a8410 100644 --- a/packages/webview_flutter/example/integration_test/webview_flutter_test.dart +++ b/packages/webview_flutter/example/integration_test/webview_flutter_test.dart @@ -443,6 +443,68 @@ Future main() async { expect(currentUrl, isNot(contains('youtube.com'))); }); + testWidgets('onHttpError', (WidgetTester tester) async { + final Completer errorCompleter = + Completer(); + + final WebViewController controller = WebViewController(); + unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); + unawaited( + controller.setNavigationDelegate( + NavigationDelegate( + onHttpError: (HttpResponseError error) { + errorCompleter.complete(error); + }, + ), + ), + ); + + unawaited(controller.loadRequest(Uri.parse('$prefixUrl/favicon.ico'))); + + await tester.pumpWidget(WebViewWidget(controller: controller)); + + 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(); @@ -541,6 +603,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 { From 764f459acb0499e27b041e52708c05c28b35dfa2 Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Fri, 26 Jun 2026 14:09:54 +0900 Subject: [PATCH 3/5] [webview_flutter_tizen] Align integration test async style with upstream The test file wrapped most controller setup calls (setJavaScriptMode, setNavigationDelegate, loadRequest, etc.) in unawaited(), a leftover from an older upstream version. Match the upstream v4.13.1 style by awaiting those calls instead, keeping unawaited() only where upstream does (the request server loop and the two onHttpError tests). No behavior change; the full suite still passes on the device. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../webview_flutter_test.dart | 206 ++++++++---------- 1 file changed, 85 insertions(+), 121 deletions(-) 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 cd28a8410..7f40a6a1a 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)); @@ -319,23 +299,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 +330,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 +354,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 +377,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. @@ -509,26 +477,24 @@ 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) async { - NavigationDecision decision = NavigationDecision.prevent; - decision = await Future.delayed( - const Duration(milliseconds: 10), - () => NavigationDecision.navigate, - ); - return decision; - }, - ), + 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)); - unawaited(controller.loadRequest(Uri.parse(blankPageEncoded))); + await controller.loadRequest(Uri.parse(blankPageEncoded)); await pageLoaded.future; // Wait for initial page load. @@ -545,13 +511,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)); @@ -579,9 +543,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)); From 438e3449dacf772c9a3ca201d7357046a6c660b4 Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Mon, 29 Jun 2026 11:20:19 +0900 Subject: [PATCH 4/5] [webview_flutter_tizen] Stabilize scroll position integration test getScrollPosition() settles asynchronously after scrollTo/scrollBy, so reading it once right after the call was flaky (more so on software-GL rendering such as emulators). Poll the scroll position until it reaches the expected value, with a timeout, so the test waits for the value to settle instead of failing on a transient stale read. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../webview_flutter_test.dart | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) 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 7f40a6a1a..1068d5898 100644 --- a/packages/webview_flutter/example/integration_test/webview_flutter_test.dart +++ b/packages/webview_flutter/example/integration_test/webview_flutter_test.dart @@ -276,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); }); From 4ee5d4624fad1a064096cf29af1200a6602de77c Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Thu, 9 Jul 2026 19:49:21 +0900 Subject: [PATCH 5/5] [webview_flutter_tizen] Fix disposal races and TV emulator teardown crash Rework WebView::Dispose() to tear resources down safely: - Detach all engine callbacks (including the missing "policy,response,decide") and defer evas_object_del() until the embedder's UnregisterTexture completion callback, so the engine-owned TBM surfaces are not freed while a raster-thread frame is still reading them (flutter-tizen/embedder#182). - Add is_alive_/is_disposing_ guards so async callbacks arriving after disposal no longer touch the destroyed WebView. - On the Tizen 10.0 TV emulator (TV_PROFILE + x86_64), hide the stopped view instead of deleting it to avoid a SIGSEGV in chromium-efl's ~SelectionControllerEfl(); revert once the engine fix ships. Verified on the TV 10.0 emulator: example integration tests previously crashed on WebView disposal and now pass 19/19. --- packages/webview_flutter/CHANGELOG.md | 2 + packages/webview_flutter/README.md | 2 +- packages/webview_flutter/tizen/src/webview.cc | 165 +++++++++++++++--- packages/webview_flutter/tizen/src/webview.h | 8 + 4 files changed, 152 insertions(+), 25 deletions(-) diff --git a/packages/webview_flutter/CHANGELOG.md b/packages/webview_flutter/CHANGELOG.md index 98fddfed2..13febe4ce 100644 --- a/packages/webview_flutter/CHANGELOG.md +++ b/packages/webview_flutter/CHANGELOG.md @@ -2,6 +2,8 @@ * 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 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/tizen/src/webview.cc b/packages/webview_flutter/tizen/src/webview.cc index f51a95e1d..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. @@ -454,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) { @@ -751,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(); @@ -770,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(); @@ -871,14 +987,14 @@ 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) { +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); @@ -892,8 +1008,7 @@ void WebView::OnResponsePolicy(void* data, Evas_Object* obj, return; } flutter::EncodableMap args = { - {flutter::EncodableValue("url"), - flutter::EncodableValue(url ? url : "")}, + {flutter::EncodableValue("url"), flutter::EncodableValue(url ? url : "")}, {flutter::EncodableValue("statusCode"), flutter::EncodableValue(status_code)}, }; @@ -926,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 ac47a9bd6..e0999f3ee 100644 --- a/packages/webview_flutter/tizen/src/webview.h +++ b/packages/webview_flutter/tizen/src/webview.h @@ -118,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; };