diff --git a/packages/flutter_inappwebview/CHANGELOG.md b/packages/flutter_inappwebview/CHANGELOG.md index 39d9ed658..e2de3f121 100644 --- a/packages/flutter_inappwebview/CHANGELOG.md +++ b/packages/flutter_inappwebview/CHANGELOG.md @@ -1,3 +1,15 @@ +## 0.2.0 + +- Fix `onTitleChanged` to also fire when the page's title changes after the + initial load (e.g. when JavaScript updates `document.title`), instead of + only once when loading finishes. +- Fix a race where `getUrl()` could return the URL of a navigation that was + cancelled via `shouldOverrideUrlLoading`, and skip the + `shouldOverrideUrlLoading` round-trip for app-initiated navigations + (`loadUrl`, `goBack`, `reload`, etc.). +- Fix `scrollBy`/`getScrollX`/`getScrollY` occasionally returning a stale + scroll position right after `scrollTo`/`scrollBy`. + ## 0.1.1 - Fix a crash when a webview is disposed. diff --git a/packages/flutter_inappwebview/README.md b/packages/flutter_inappwebview/README.md index 456d45fb9..6ecfa54d3 100644 --- a/packages/flutter_inappwebview/README.md +++ b/packages/flutter_inappwebview/README.md @@ -26,7 +26,7 @@ Add the internet privilege to the app manifest: ```yaml dependencies: flutter_inappwebview: ^6.1.5 - flutter_inappwebview_tizen: ^0.1.1 + flutter_inappwebview_tizen: ^0.2.0 ``` ```dart diff --git a/packages/flutter_inappwebview/example/assets/test_assets/load_file_test.html b/packages/flutter_inappwebview/example/assets/test_assets/load_file_test.html new file mode 100644 index 000000000..bb80a8033 --- /dev/null +++ b/packages/flutter_inappwebview/example/assets/test_assets/load_file_test.html @@ -0,0 +1,10 @@ + + + + + Load file test + + +

Loaded from asset

+ + diff --git a/packages/flutter_inappwebview/example/integration_test/flutter_inappwebview_test.dart b/packages/flutter_inappwebview/example/integration_test/flutter_inappwebview_test.dart index 58232c234..517cbbc71 100644 --- a/packages/flutter_inappwebview/example/integration_test/flutter_inappwebview_test.dart +++ b/packages/flutter_inappwebview/example/integration_test/flutter_inappwebview_test.dart @@ -3,7 +3,9 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:convert'; import 'dart:io'; +import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; @@ -44,11 +46,13 @@ void main() { late String firstUrl; late String secondUrl; late String blockedUrl; + late String echoPostUrl; + late String slowUrl; setUpAll(() async { server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); unawaited( - server.forEach((HttpRequest request) { + server.forEach((HttpRequest request) async { request.response.headers.contentType = ContentType.html; switch (request.uri.path) { case '/first': @@ -57,18 +61,26 @@ void main() { request.response.write(_htmlPage('Second page')); case '/blocked': request.response.write(_htmlPage('Blocked page')); + case '/echo-post': + final String body = await utf8.decoder.bind(request).join(); + request.response.write('

$body

'); + case '/slow': + await Future.delayed(const Duration(seconds: 5)); + request.response.write(_htmlPage('Slow page')); case '/favicon.ico': request.response.statusCode = HttpStatus.notFound; default: fail('unexpected request: ${request.method} ${request.uri}'); } - request.response.close(); + await request.response.close(); }), ); final String baseUrl = 'http://${server.address.address}:${server.port}'; firstUrl = '$baseUrl/first'; secondUrl = '$baseUrl/second'; blockedUrl = '$baseUrl/blocked'; + echoPostUrl = '$baseUrl/echo-post'; + slowUrl = '$baseUrl/slow'; }); tearDownAll(() => server.close(force: true)); @@ -286,16 +298,378 @@ document.cookie; ); expect(cookieAfter.toString(), isNot(contains('tizen_inappwebview=1'))); }); + + testWidgets('getProgress reports 100 once the page finishes loading', ( + WidgetTester tester, + ) async { + final StreamController loadStops = + StreamController.broadcast(); + addTearDown(loadStops.close); + + final InAppWebViewController controller = await _pumpWebView( + tester, + initialUrl: firstUrl, + onLoadStop: (_, WebUri? url) { + if (url != null) { + loadStops.add(url.toString()); + } + }, + ); + await _waitForValue(loadStops.stream, firstUrl); + + expect(await controller.getProgress(), 100); + }); + + testWidgets('reload reloads the currently displayed page', ( + WidgetTester tester, + ) async { + final StreamController loadStops = + StreamController.broadcast(); + addTearDown(loadStops.close); + + final InAppWebViewController controller = await _pumpWebView( + tester, + initialUrl: firstUrl, + onLoadStop: (_, WebUri? url) { + if (url != null) { + loadStops.add(url.toString()); + } + }, + ); + await _waitForValue(loadStops.stream, firstUrl); + + final Future reloaded = loadStops.stream.first.timeout( + const Duration(seconds: 10), + ); + await controller.reload(); + expect(await reloaded, firstUrl); + }); + + testWidgets('loadUrl navigates to a new URL', (WidgetTester tester) async { + final StreamController loadStops = + StreamController.broadcast(); + addTearDown(loadStops.close); + + final InAppWebViewController controller = await _pumpWebView( + tester, + initialUrl: firstUrl, + onLoadStop: (_, WebUri? url) { + if (url != null) { + loadStops.add(url.toString()); + } + }, + ); + await _waitForValue(loadStops.stream, firstUrl); + + final Future secondLoad = _waitForValue( + loadStops.stream, + secondUrl, + ); + await controller.loadUrl(urlRequest: URLRequest(url: WebUri(secondUrl))); + expect(await secondLoad, secondUrl); + expect((await controller.getUrl()).toString(), secondUrl); + }); + + testWidgets('postUrl and loadUrl submit an HTTP POST request body', ( + WidgetTester tester, + ) async { + final StreamController loadStops = + StreamController.broadcast(); + addTearDown(loadStops.close); + + final InAppWebViewController controller = await _pumpWebView( + tester, + onLoadStop: (_, WebUri? url) { + if (url != null) { + loadStops.add(url.toString()); + } + }, + ); + + final Future firstPost = _waitForValue( + loadStops.stream, + echoPostUrl, + ); + await controller.postUrl( + url: WebUri(echoPostUrl), + postData: Uint8List.fromList(utf8.encode('name=postUrl')), + ); + await firstPost; + expect( + await _waitForCondition( + () => controller.evaluateJavascript( + source: "document.querySelector('p')?.textContent", + ), + (Object? value) => value == 'name=postUrl', + ), + 'name=postUrl', + ); + + final Future secondPost = loadStops.stream.first.timeout( + const Duration(seconds: 10), + ); + await controller.loadUrl( + urlRequest: URLRequest( + url: WebUri(echoPostUrl), + method: 'POST', + body: Uint8List.fromList(utf8.encode('name=loadUrl')), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + ), + ); + expect(await secondPost, echoPostUrl); + expect( + await _waitForCondition( + () => controller.evaluateJavascript( + source: "document.querySelector('p')?.textContent", + ), + (Object? value) => value == 'name=loadUrl', + ), + 'name=loadUrl', + ); + }); + + testWidgets('loadFile loads a bundled asset file', ( + WidgetTester tester, + ) async { + final StreamController loadStops = + StreamController.broadcast(); + addTearDown(loadStops.close); + + final InAppWebViewController controller = await _pumpWebView( + tester, + onLoadStop: (_, WebUri? url) { + if (url != null) { + loadStops.add(url.toString()); + } + }, + ); + + final Future fileLoaded = loadStops.stream.firstWhere( + (String url) => url.endsWith('load_file_test.html'), + ); + await controller.loadFile( + assetFilePath: 'assets/test_assets/load_file_test.html', + ); + await fileLoaded.timeout(const Duration(seconds: 10)); + + expect( + await _waitForCondition( + () => controller.evaluateJavascript(source: "document.title"), + (Object? value) => value == 'Load file test', + ), + 'Load file test', + ); + expect( + await controller.evaluateJavascript( + source: "document.querySelector('h1').textContent", + ), + 'Loaded from asset', + ); + }); + + testWidgets('programmatic scroll updates and reports the scroll position', ( + WidgetTester tester, + ) async { + final InAppWebViewController controller = await _pumpWebView(tester); + await _loadFixture(controller); + + await controller.scrollTo(x: 0, y: 0); + + const int scrollX = 30; + const int scrollY = 40; + await controller.scrollTo(x: scrollX, y: scrollY); + expect(await controller.getScrollX(), scrollX); + expect(await controller.getScrollY(), scrollY); + + await controller.scrollBy(x: scrollX, y: scrollY); + expect(await controller.getScrollX(), scrollX * 2); + expect(await controller.getScrollY(), scrollY * 2); + }); + + testWidgets('onScrollChanged fires when the scroll position changes', ( + WidgetTester tester, + ) async { + final Completer scrollChanged = Completer(); + final InAppWebViewController controller = await _pumpWebView( + tester, + onScrollChanged: (_, int x, int y) { + if (x == 50 && y == 60 && !scrollChanged.isCompleted) { + scrollChanged.complete(); + } + }, + ); + await _loadFixture(controller); + + await controller.scrollTo(x: 50, y: 60); + await scrollChanged.future.timeout(const Duration(seconds: 10)); + }); + + testWidgets('onTitleChanged fires when document.title changes', ( + WidgetTester tester, + ) async { + final Completer titleChanged = Completer(); + final InAppWebViewController controller = await _pumpWebView( + tester, + onTitleChanged: (_, String? title) { + if (title == 'updated title' && !titleChanged.isCompleted) { + titleChanged.complete(); + } + }, + ); + await _loadFixture(controller); + + await controller.evaluateJavascript( + source: "document.title = 'updated title';", + ); + await titleChanged.future.timeout(const Duration(seconds: 10)); + }); + + testWidgets('stopLoading interrupts an in-flight page load', ( + WidgetTester tester, + ) async { + final StreamController loadStops = + StreamController.broadcast(); + addTearDown(loadStops.close); + + await _pumpWebView( + tester, + initialUrl: slowUrl, + onLoadStart: (InAppWebViewController controller, WebUri? url) { + controller.stopLoading(); + }, + onLoadStop: (_, WebUri? url) { + if (url != null) { + loadStops.add(url.toString()); + } + }, + ); + + final Future slowLoad = _waitForValue( + loadStops.stream, + slowUrl, + timeout: const Duration(seconds: 2), + ); + await expectLater(slowLoad, throwsA(isA())); + }); + + testWidgets('clearAllCache completes without throwing', ( + WidgetTester tester, + ) async { + await expectLater( + InAppWebViewController.clearAllCache(includeDiskFiles: true), + completes, + ); + }); + + testWidgets('zoomBy triggers onZoomScaleChanged', ( + WidgetTester tester, + ) async { + final Completer zoomRatio = Completer(); + final InAppWebViewController controller = await _pumpWebView( + tester, + onZoomScaleChanged: (_, double oldScale, double newScale) { + if (!zoomRatio.isCompleted) { + zoomRatio.complete(newScale / oldScale); + } + }, + ); + await _loadFixture(controller); + + await controller.zoomBy(zoomFactor: 2); + expect(await zoomRatio.future.timeout(const Duration(seconds: 10)), 2); + }); + + testWidgets( + 'onReceivedError reports a host lookup failure for an unresolvable URL', + (WidgetTester tester) async { + final Completer receivedError = + Completer(); + + await _pumpWebView( + tester, + initialUrl: 'http://this-domain-does-not-exist.invalid/', + onReceivedError: (_, WebResourceRequest __, WebResourceError error) { + if (!receivedError.isCompleted) { + receivedError.complete(error); + } + }, + ); + + final WebResourceError error = await receivedError.future.timeout( + const Duration(seconds: 10), + ); + expect(error.type, WebResourceErrorType.HOST_LOOKUP); + }, + ); + + testWidgets('onReceivedError is not raised for a successful page load', ( + WidgetTester tester, + ) async { + final StreamController loadStops = + StreamController.broadcast(); + final Completer receivedError = Completer(); + addTearDown(loadStops.close); + + await _pumpWebView( + tester, + initialUrl: firstUrl, + onLoadStop: (_, WebUri? url) { + if (url != null) { + loadStops.add(url.toString()); + } + }, + onReceivedError: (_, WebResourceRequest __, WebResourceError ___) { + receivedError.complete(); + }, + ); + await _waitForValue(loadStops.stream, firstUrl); + + await expectLater( + receivedError.future.timeout(const Duration(seconds: 1)), + throwsA(isA()), + ); + }); + + testWidgets('setSettings applies updated webview settings', ( + WidgetTester tester, + ) async { + final InAppWebViewController controller = await _pumpWebView(tester); + await _loadFixture(controller); + + await expectLater( + controller.setSettings( + settings: InAppWebViewSettings( + javaScriptEnabled: true, + supportZoom: true, + ), + ), + completes, + ); + expect( + await controller.evaluateJavascript( + source: "document.querySelector('h1').textContent", + ), + 'Fixture Page', + ); + }); } Future _pumpWebView( WidgetTester tester, { String initialUrl = 'about:blank', InAppWebViewSettings? initialSettings, + void Function(InAppWebViewController, WebUri?)? onLoadStart, void Function(InAppWebViewController, WebUri?)? onLoadStop, void Function(InAppWebViewController, int)? onProgressChanged, void Function(InAppWebViewController, ConsoleMessage)? onConsoleMessage, void Function(InAppWebViewController, WebUri?, bool?)? onUpdateVisitedHistory, + void Function(InAppWebViewController, int, int)? onScrollChanged, + void Function(InAppWebViewController, String?)? onTitleChanged, + void Function(InAppWebViewController, double, double)? onZoomScaleChanged, + void Function(InAppWebViewController, WebResourceRequest, WebResourceError)? + onReceivedError, Future Function(InAppWebViewController, JsAlertRequest)? onJsAlert, Future Function(InAppWebViewController, JsConfirmRequest)? @@ -319,10 +693,15 @@ Future _pumpWebView( initialSettings: initialSettings, initialUrlRequest: URLRequest(url: WebUri(initialUrl)), onWebViewCreated: controllerCompleter.complete, + onLoadStart: onLoadStart, onLoadStop: onLoadStop, onProgressChanged: onProgressChanged, onConsoleMessage: onConsoleMessage, onUpdateVisitedHistory: onUpdateVisitedHistory, + onScrollChanged: onScrollChanged, + onTitleChanged: onTitleChanged, + onZoomScaleChanged: onZoomScaleChanged, + onReceivedError: onReceivedError, onJsAlert: onJsAlert, onJsConfirm: onJsConfirm, onJsPrompt: onJsPrompt, @@ -383,6 +762,25 @@ Future _waitForValue( return stream.firstWhere((T event) => event == value).timeout(timeout); } +Future _waitForCondition( + Future Function() poll, + bool Function(Object? value) isReady, { + Duration timeout = const Duration(seconds: 10), +}) async { + Object? lastResult; + final DateTime end = DateTime.now().add(timeout); + + while (DateTime.now().isBefore(end)) { + lastResult = await poll(); + if (isReady(lastResult)) { + return lastResult; + } + await Future.delayed(const Duration(milliseconds: 200)); + } + + throw TimeoutException('Condition not met. Last result: $lastResult'); +} + String _htmlPage(String title) { return ''' diff --git a/packages/flutter_inappwebview/example/pubspec.yaml b/packages/flutter_inappwebview/example/pubspec.yaml index ccb0a802b..0bf1815d4 100644 --- a/packages/flutter_inappwebview/example/pubspec.yaml +++ b/packages/flutter_inappwebview/example/pubspec.yaml @@ -31,3 +31,5 @@ dev_dependencies: flutter: uses-material-design: true + assets: + - assets/test_assets/ diff --git a/packages/flutter_inappwebview/pubspec.yaml b/packages/flutter_inappwebview/pubspec.yaml index b9e860a4c..88f9d2090 100644 --- a/packages/flutter_inappwebview/pubspec.yaml +++ b/packages/flutter_inappwebview/pubspec.yaml @@ -2,7 +2,7 @@ name: flutter_inappwebview_tizen description: Tizen implementation of the flutter_inappwebview plugin. homepage: https://github.com/flutter-tizen/plugins repository: https://github.com/flutter-tizen/plugins/tree/master/packages/flutter_inappwebview -version: 0.1.1 +version: 0.2.0 environment: sdk: ">=3.8.0 <4.0.0" diff --git a/packages/flutter_inappwebview/tizen/src/webview.cc b/packages/flutter_inappwebview/tizen/src/webview.cc index 323947569..baca23f55 100644 --- a/packages/flutter_inappwebview/tizen/src/webview.cc +++ b/packages/flutter_inappwebview/tizen/src/webview.cc @@ -307,9 +307,23 @@ void WebView::StopNavigation() { if (disposed_ || !webview_instance_) { return; } + is_navigation_cancelled_ = true; + if (!pending_navigation_revert_url_.empty()) { + committed_url_ = pending_navigation_revert_url_; + } + ewk_view_resume(webview_instance_); ewk_view_stop(webview_instance_); } +bool WebView::NavigateProgrammatically(const std::function& ewk_call) { + is_programmatic_navigation_ = true; + const bool started = ewk_call(); + if (!started) { + is_programmatic_navigation_ = false; + } + return started; +} + void WebView::Dispose() { if (disposed_) { return; @@ -354,6 +368,8 @@ void WebView::Dispose() { &WebView::OnNavigationPolicy); evas_object_smart_callback_del(webview_instance_, "url,changed", &WebView::OnUrlChange); + evas_object_smart_callback_del(webview_instance_, "title,changed", + &WebView::OnTitleChange); auto& ewk_view = EwkInternalApiBinding::GetInstance().view; if (ewk_view.OnJavaScriptAlert) { ewk_view.OnJavaScriptAlert(webview_instance_, nullptr, nullptr); @@ -489,6 +505,9 @@ bool WebView::SendKey(const char* key, const char* string, const char* compose, if (strcmp(key, "XF86Back") == 0 && !is_down) { if (ewk_view_back_possible(webview_instance_)) { + // Not wrapped in NavigateProgrammatically: this is a user-initiated + // navigation (remote Back key), so it must still reach + // shouldOverrideUrlLoading via OnNavigationPolicy. ewk_view_back(webview_instance_); return true; } @@ -620,6 +639,8 @@ bool WebView::InitWebView() { &WebView::OnNavigationPolicy, this); evas_object_smart_callback_add(webview_instance_, "url,changed", &WebView::OnUrlChange, this); + evas_object_smart_callback_add(webview_instance_, "title,changed", + &WebView::OnTitleChange, this); Resize(width_, height_); evas_object_show(webview_instance_); @@ -685,7 +706,10 @@ void WebView::ApplyInitialParams(const flutter::EncodableValue& params) { std::string url = std::string("file://") + res_path + "flutter_assets/" + initial_file; free(res_path); - ewk_view_url_set(webview_instance_, url.c_str()); + NavigateProgrammatically([this, &url] { + ewk_view_url_set(webview_instance_, url.c_str()); + return true; + }); return; } } @@ -697,8 +721,11 @@ void WebView::ApplyInitialParams(const flutter::EncodableValue& params) { std::string base_url = "about:blank"; if (GetValueFromEncodableMap(initial_data, "data", &data)) { GetValueFromEncodableMap(initial_data, "baseUrl", &base_url); - ewk_view_html_string_load(webview_instance_, data.c_str(), - base_url.c_str(), nullptr); + NavigateProgrammatically([this, &data, &base_url] { + ewk_view_html_string_load(webview_instance_, data.c_str(), + base_url.c_str(), nullptr); + return true; + }); return; } } @@ -708,7 +735,10 @@ void WebView::ApplyInitialParams(const flutter::EncodableValue& params) { &url_request)) { std::string url; if (GetValueFromEncodableMap(url_request, "url", &url) && !url.empty()) { - ewk_view_url_set(webview_instance_, url.c_str()); + NavigateProgrammatically([this, &url] { + ewk_view_url_set(webview_instance_, url.c_str()); + return true; + }); } } } @@ -768,16 +798,22 @@ void WebView::HandleWebViewMethodCall(const FlMethodCall& method_call, } const auto ewk_method = method == "POST" ? EWK_HTTP_METHOD_POST : EWK_HTTP_METHOD_GET; - bool ret = ewk_view_url_request_set( - webview_instance_, url.c_str(), ewk_method, ewk_headers, - body.empty() ? nullptr : reinterpret_cast(body.data())); + const bool ret = NavigateProgrammatically([&] { + return ewk_view_url_request_set( + webview_instance_, url.c_str(), ewk_method, ewk_headers, + body.empty() ? nullptr + : reinterpret_cast(body.data())); + }); eina_hash_free(ewk_headers); if (!ret) { result->Error("Operation failed", "Failed to load URL request."); return; } } else { - ewk_view_url_set(webview_instance_, url.c_str()); + NavigateProgrammatically([this, &url] { + ewk_view_url_set(webview_instance_, url.c_str()); + return true; + }); } result->Success(); } else if (method_name == "postUrl") { @@ -791,9 +827,11 @@ void WebView::HandleWebViewMethodCall(const FlMethodCall& method_call, if (!body.empty()) { body.push_back('\0'); } - const bool ret = ewk_view_url_request_set( - webview_instance_, url.c_str(), EWK_HTTP_METHOD_POST, nullptr, - body.empty() ? nullptr : reinterpret_cast(body.data())); + const bool ret = NavigateProgrammatically([&] { + return ewk_view_url_request_set( + webview_instance_, url.c_str(), EWK_HTTP_METHOD_POST, nullptr, + body.empty() ? nullptr : reinterpret_cast(body.data())); + }); if (ret) { result->Success(); } else { @@ -806,8 +844,15 @@ void WebView::HandleWebViewMethodCall(const FlMethodCall& method_call, return; } GetValueFromEncodableMap(arguments, "baseUrl", &base_url); - ewk_view_html_string_load(webview_instance_, data.c_str(), base_url.c_str(), - nullptr); + // ewk_view_html_string_load() doesn't go through OnNavigationPolicy, so + // a stale cancellation from an earlier navigation would otherwise never + // clear and getUrl() would keep returning the pre-cancellation URL. + is_navigation_cancelled_ = false; + NavigateProgrammatically([this, &data, &base_url] { + ewk_view_html_string_load(webview_instance_, data.c_str(), + base_url.c_str(), nullptr); + return true; + }); result->Success(); } else if (method_name == "loadFile") { std::string file_path; @@ -827,7 +872,10 @@ void WebView::HandleWebViewMethodCall(const FlMethodCall& method_call, url = std::string("file://") + res_path + "flutter_assets/" + file_path; free(res_path); } - ewk_view_url_set(webview_instance_, url.c_str()); + NavigateProgrammatically([this, &url] { + ewk_view_url_set(webview_instance_, url.c_str()); + return true; + }); result->Success(); } else if (method_name == "canGoBack") { result->Success(flutter::EncodableValue( @@ -836,18 +884,28 @@ void WebView::HandleWebViewMethodCall(const FlMethodCall& method_call, result->Success(flutter::EncodableValue( static_cast(ewk_view_forward_possible(webview_instance_)))); } else if (method_name == "goBack") { - ewk_view_back(webview_instance_); + NavigateProgrammatically( + [this] { return static_cast(ewk_view_back(webview_instance_)); }); result->Success(); } else if (method_name == "goForward") { - ewk_view_forward(webview_instance_); + NavigateProgrammatically([this] { + return static_cast(ewk_view_forward(webview_instance_)); + }); result->Success(); } else if (method_name == "reload") { - ewk_view_reload(webview_instance_); + NavigateProgrammatically([this] { + ewk_view_reload(webview_instance_); + return true; + }); result->Success(); } else if (method_name == "getUrl") { - const char* url = ewk_view_url_get(webview_instance_); - result->Success(url ? flutter::EncodableValue(url) - : flutter::EncodableValue()); + if (is_navigation_cancelled_ && !committed_url_.empty()) { + result->Success(flutter::EncodableValue(committed_url_)); + } else { + const char* url = ewk_view_url_get(webview_instance_); + result->Success(url ? flutter::EncodableValue(url) + : flutter::EncodableValue()); + } } else if (method_name == "getTitle") { const char* title = ewk_view_title_get(webview_instance_); result->Success(title ? flutter::EncodableValue(std::string(title)) @@ -886,11 +944,20 @@ void WebView::HandleWebViewMethodCall(const FlMethodCall& method_call, } if (method_name == "scrollTo") { ewk_view_scroll_set(webview_instance_, x, y); + target_scroll_x_ = x; + target_scroll_y_ = y; } else { - ewk_view_scroll_by(webview_instance_, x, y); + int32_t current_x = 0, current_y = 0; + ewk_view_scroll_pos_get(webview_instance_, ¤t_x, ¤t_y); + int32_t base_x = (target_scroll_x_ >= 0) ? target_scroll_x_ : current_x; + int32_t base_y = (target_scroll_y_ >= 0) ? target_scroll_y_ : current_y; + target_scroll_x_ = base_x + x; + target_scroll_y_ = base_y + y; + ewk_view_scroll_set(webview_instance_, target_scroll_x_, + target_scroll_y_); } - int32_t new_x = 0, new_y = 0; - ewk_view_scroll_pos_get(webview_instance_, &new_x, &new_y); + int32_t new_x = target_scroll_x_; + int32_t new_y = target_scroll_y_; flutter::EncodableMap args = { {flutter::EncodableValue("x"), flutter::EncodableValue(new_x)}, {flutter::EncodableValue("y"), flutter::EncodableValue(new_y)}, @@ -901,6 +968,14 @@ void WebView::HandleWebViewMethodCall(const FlMethodCall& method_call, } else if (method_name == "getScrollX" || method_name == "getScrollY") { int32_t x = 0, y = 0; ewk_view_scroll_pos_get(webview_instance_, &x, &y); + if (target_scroll_x_ >= 0) { + x = target_scroll_x_; + target_scroll_x_ = -1; + } + if (target_scroll_y_ >= 0) { + y = target_scroll_y_; + target_scroll_y_ = -1; + } result->Success( flutter::EncodableValue(method_name == "getScrollX" ? x : y)); } else if (method_name == "zoomBy") { @@ -1003,6 +1078,9 @@ void WebView::OnFrameRendered(void* data, Evas_Object* obj, void* event_info) { void WebView::OnLoadStarted(void* data, Evas_Object* obj, void* event_info) { WebView* webview = static_cast(data); + webview->is_programmatic_navigation_ = false; + webview->target_scroll_x_ = -1; + webview->target_scroll_y_ = -1; flutter::EncodableMap args = { {flutter::EncodableValue("url"), flutter::EncodableValue(GetViewUrl(webview->webview_instance_))}}; @@ -1012,6 +1090,7 @@ void WebView::OnLoadStarted(void* data, Evas_Object* obj, void* event_info) { void WebView::OnLoadFinished(void* data, Evas_Object* obj, void* event_info) { WebView* webview = static_cast(data); + webview->is_programmatic_navigation_ = false; flutter::EncodableMap args = { {flutter::EncodableValue("url"), flutter::EncodableValue(GetViewUrl(webview->webview_instance_))}}; @@ -1040,6 +1119,9 @@ void WebView::OnProgress(void* data, Evas_Object* obj, void* event_info) { void WebView::OnLoadError(void* data, Evas_Object* obj, void* event_info) { WebView* webview = static_cast(data); + webview->is_programmatic_navigation_ = false; + webview->target_scroll_x_ = -1; + webview->target_scroll_y_ = -1; Ewk_Error* error = static_cast(event_info); std::string url = ewk_error_url_get(error) ? std::string(ewk_error_url_get(error)) : ""; @@ -1080,6 +1162,26 @@ void WebView::OnNavigationPolicy(void* data, Evas_Object* obj, WebView* webview = static_cast(data); Ewk_Policy_Decision* policy_decision = static_cast(event_info); + + // A new navigation decision means any previous cancellation is stale: + // getUrl() should stop overriding with the old committed_url_ snapshot. + webview->is_navigation_cancelled_ = false; + + if (webview->is_programmatic_navigation_) { + webview->is_programmatic_navigation_ = false; + ewk_policy_decision_use(policy_decision); + return; + } + + // Snapshot the URL EWK is displaying before accepting the navigation + // below. EWK can fire "url,changed" for the new (possibly-to-be-cancelled) + // URL as soon as ewk_policy_decision_use() runs, racing with the async + // shouldOverrideUrlLoading round-trip. StopNavigation() reverts getUrl() + // using this snapshot rather than whatever "url,changed" reported last, so + // that race can't leave getUrl() stuck on a cancelled URL. + const std::string url_before_navigation = + GetViewUrl(webview->webview_instance_); + // Always accept the navigation on its original frame so iframe loads stay // in their iframe. The view is then suspended while we wait for the Dart // shouldOverrideUrlLoading response and either resumed (allow) or stopped @@ -1089,6 +1191,8 @@ void WebView::OnNavigationPolicy(void* data, Evas_Object* obj, return; } + webview->pending_navigation_revert_url_ = url_before_navigation; + const char* url_cstr = ewk_policy_decision_url_get(policy_decision); const std::string url = url_cstr ? std::string(url_cstr) : std::string(); ewk_view_suspend(webview->webview_instance_); @@ -1103,15 +1207,34 @@ void WebView::OnNavigationPolicy(void* data, Evas_Object* obj, void WebView::OnUrlChange(void* data, Evas_Object* obj, void* event_info) { WebView* webview = static_cast(data); + if (webview->is_navigation_cancelled_) { + // Stale "url,changed" for the navigation we just cancelled (EWK can fire + // it before or after ewk_view_stop() takes effect); getUrl() is already + // pinned to committed_url_ and must not be overwritten with this URL. + return; + } + webview->committed_url_ = GetViewUrl(webview->webview_instance_); flutter::EncodableMap args = { {flutter::EncodableValue("url"), - flutter::EncodableValue(GetViewUrl(webview->webview_instance_))}, + flutter::EncodableValue(webview->committed_url_)}, {flutter::EncodableValue("isReload"), flutter::EncodableValue(false)}}; webview->webview_channel_->InvokeMethod( "onUpdateVisitedHistory", std::make_unique(args)); } +void WebView::OnTitleChange(void* data, Evas_Object* obj, void* event_info) { + WebView* webview = static_cast(data); + const char* title = static_cast(event_info); + if (!title) { + return; + } + flutter::EncodableMap args = { + {flutter::EncodableValue("title"), flutter::EncodableValue(title)}}; + webview->webview_channel_->InvokeMethod( + "onTitleChanged", std::make_unique(args)); +} + void WebView::OnEvaluateJavaScript(Evas_Object* obj, const char* result_value, void* user_data) { FlMethodResult* result = static_cast(user_data); diff --git a/packages/flutter_inappwebview/tizen/src/webview.h b/packages/flutter_inappwebview/tizen/src/webview.h index cf42b38f0..6bfe23c59 100644 --- a/packages/flutter_inappwebview/tizen/src/webview.h +++ b/packages/flutter_inappwebview/tizen/src/webview.h @@ -15,6 +15,7 @@ #include #include +#include #include #include #include @@ -83,6 +84,14 @@ class WebView : public PlatformView { bool InitWebView(); + // Runs an EWK call that starts a navigation the app itself requested + // (loadUrl, goBack, reload, ...), marking it as programmatic first so the + // next OnNavigationPolicy skips the shouldOverrideUrlLoading round-trip for + // it. If `ewk_call` reports the navigation never started, the flag is + // cleared immediately instead of leaking into some later, unrelated + // navigation. Returns whatever `ewk_call` returned. + bool NavigateProgrammatically(const std::function& ewk_call); + static void OnFrameRendered(void* data, Evas_Object* obj, void* event_info); static void OnLoadStarted(void* data, Evas_Object* obj, void* event_info); static void OnLoadFinished(void* data, Evas_Object* obj, void* event_info); @@ -92,6 +101,7 @@ class WebView : public PlatformView { static void OnNavigationPolicy(void* data, Evas_Object* obj, void* event_info); static void OnUrlChange(void* data, Evas_Object* obj, void* event_info); + static void OnTitleChange(void* data, Evas_Object* obj, void* event_info); static void OnEvaluateJavaScript(Evas_Object* obj, const char* result_value, void* user_data); static Eina_Bool OnJavaScriptAlertDialog(Evas_Object* o, const char* message, @@ -127,6 +137,12 @@ class WebView : public PlatformView { bool texture_registered_ = false; bool disposed_ = false; Ewk_Mouse_Button_Type mouse_button_type_ = (Ewk_Mouse_Button_Type)0; + bool is_programmatic_navigation_ = false; + bool is_navigation_cancelled_ = false; + std::string committed_url_; + std::string pending_navigation_revert_url_; + int32_t target_scroll_x_ = -1; + int32_t target_scroll_y_ = -1; static std::set instances_; static std::mutex instances_mutex_;