From 93931da4144ac7841f12a8fd2bd758c7b629585b Mon Sep 17 00:00:00 2001 From: "Park, Woocheol" Date: Fri, 6 Mar 2026 14:46:45 +0900 Subject: [PATCH 1/2] feat(js-bridge): add bridgeEvents API and typed JS handler helpers --- .../in_app_webview_controller.dart | 150 ++++++++++++++++++ .../plugin_scripts_js/JavaScriptBridgeJS.java | 36 +++++ .../PluginScriptsJS/JavaScriptBridgeJS.swift | 42 +++++ .../plugin_scripts_js/javascript_bridge_js.h | 50 +++++- .../PluginScriptsJS/JavaScriptBridgeJS.swift | 42 +++++ .../platform_inappwebview_controller.dart | 86 ++++++++++ .../platform_inappwebview_controller.g.dart | 62 ++++++++ .../lib/assets/web/web_support.js | 33 ++++ .../plugin_scripts_js/javascript_bridge_js.h | 40 ++++- 9 files changed, 537 insertions(+), 4 deletions(-) diff --git a/flutter_inappwebview/lib/src/in_app_webview/in_app_webview_controller.dart b/flutter_inappwebview/lib/src/in_app_webview/in_app_webview_controller.dart index d673c18491..502d8c38ab 100644 --- a/flutter_inappwebview/lib/src/in_app_webview/in_app_webview_controller.dart +++ b/flutter_inappwebview/lib/src/in_app_webview/in_app_webview_controller.dart @@ -1,3 +1,5 @@ +import 'dart:async'; +import 'dart:convert'; import 'dart:core'; import 'package:flutter/services.dart'; @@ -9,6 +11,91 @@ import '../web_storage/web_storage.dart'; import 'android/in_app_webview_controller.dart'; import 'apple/in_app_webview_controller.dart'; +const String _kBridgeEventsHandlerPrefix = + "__flutter_inappwebview_bridge_event__handler__:"; +const String _kBridgeEventsCustomEventPrefix = + "__flutter_inappwebview_bridge_event__dispatch__:"; + +typedef InAppWebViewBridgeEventListener = + FutureOr Function(dynamic data); + +/// High-level event bridge built on top of the existing JavaScript bridge. +/// +/// This API never bypasses the native JavaScript bridge security checks because +/// JavaScript -> Dart communication still uses `callHandler`. +class InAppWebViewBridgeEvents { + InAppWebViewBridgeEvents._(this._controller); + + final InAppWebViewController _controller; + + static String _buildHandlerName(String eventName) { + return _kBridgeEventsHandlerPrefix + eventName; + } + + static String _buildCustomEventName(String eventName) { + return _kBridgeEventsCustomEventPrefix + eventName; + } + + static void _validateEventName(String eventName) { + if (eventName.isEmpty) { + throw ArgumentError.value(eventName, "eventName", "cannot be empty"); + } + } + + /// Emits an event from Dart to JavaScript listeners. + Future emit(String eventName, [dynamic data]) async { + _validateEventName(eventName); + final encodedEventName = jsonEncode(_buildCustomEventName(eventName)); + final encodedData = jsonEncode(data); + await _controller.evaluateJavascript( + source: + """ + (function() { + var eventName = $encodedEventName; + var detail = $encodedData; + window.dispatchEvent(new CustomEvent(eventName, { detail: detail })); + })(); + """, + ); + } + + /// Registers one Dart listener for a JavaScript event. + /// + /// Calling this again with the same [eventName] replaces the previous + /// listener. + void on({ + required String eventName, + required InAppWebViewBridgeEventListener listener, + }) { + _validateEventName(eventName); + final handlerName = _buildHandlerName(eventName); + _controller.removeJavaScriptHandler(handlerName: handlerName); + _controller.addJavaScriptHandler( + handlerName: handlerName, + callback: (JavaScriptHandlerFunctionData handlerData) { + final data = handlerData.args.isNotEmpty ? handlerData.args[0] : null; + return listener(data); + }, + ); + } + + /// Removes a Dart listener previously added through [on]. + Function? off(String eventName) { + _validateEventName(eventName); + return _controller.removeJavaScriptHandler( + handlerName: _buildHandlerName(eventName), + ); + } + + /// Returns true if a Dart listener exists for [eventName]. + bool hasListener(String eventName) { + _validateEventName(eventName); + return _controller.hasJavaScriptHandler( + handlerName: _buildHandlerName(eventName), + ); + } +} + ///{@macro flutter_inappwebview_platform_interface.PlatformInAppWebViewController} /// ///{@macro flutter_inappwebview_platform_interface.PlatformInAppWebViewController.supported_platforms} @@ -38,6 +125,11 @@ class InAppWebViewController { /// Implementation of [PlatformInAppWebViewController] for the current platform. final PlatformInAppWebViewController platform; + /// High-level Dart/JavaScript event bridge. + late final InAppWebViewBridgeEvents bridgeEvents = InAppWebViewBridgeEvents._( + this, + ); + ///{@macro flutter_inappwebview_platform_interface.PlatformInAppWebViewController.webStorage} /// ///{@macro flutter_inappwebview_platform_interface.PlatformInAppWebViewController.webStorage.supported_platforms} @@ -274,6 +366,64 @@ class InAppWebViewController { bool hasJavaScriptHandler({required String handlerName}) => platform.hasJavaScriptHandler(handlerName: handlerName); + ///{@macro flutter_inappwebview_platform_interface.PlatformInAppWebViewController.addSerializedJavaScriptHandler} + /// + ///{@macro flutter_inappwebview_platform_interface.PlatformInAppWebViewController.addSerializedJavaScriptHandler.supported_platforms} + void addSerializedJavaScriptHandler({ + required String handlerName, + required TRequest Function(Object? raw) deserialize, + required FutureOr Function( + TRequest request, + JavaScriptHandlerFunctionData meta, + ) + callback, + Object? Function(TResponse value)? serialize, + }) { + addJavaScriptHandler( + handlerName: handlerName, + callback: (JavaScriptHandlerFunctionData data) async { + final raw = data.args.isNotEmpty ? data.args[0] : null; + final request = deserialize(raw); + final response = await callback(request, data); + return serialize != null ? serialize(response) : response; + }, + ); + } + + ///{@macro flutter_inappwebview_platform_interface.PlatformInAppWebViewController.addJsonJavaScriptHandler} + /// + ///{@macro flutter_inappwebview_platform_interface.PlatformInAppWebViewController.addJsonJavaScriptHandler.supported_platforms} + void addJsonJavaScriptHandler({ + required String handlerName, + required TRequest Function(Map json) fromJson, + required FutureOr Function( + TRequest request, + JavaScriptHandlerFunctionData meta, + ) + callback, + Map Function(TResponse value)? toJson, + }) { + addSerializedJavaScriptHandler( + handlerName: handlerName, + deserialize: (raw) { + dynamic decoded = raw; + if (decoded is String && decoded.isNotEmpty) { + decoded = jsonDecode(decoded); + } + if (decoded is Map) { + return fromJson(Map.from(decoded)); + } + throw ArgumentError.value( + raw, + "raw", + "Expected a JSON object for handler $handlerName.", + ); + }, + callback: callback, + serialize: toJson, + ); + } + ///{@macro flutter_inappwebview_platform_interface.PlatformInAppWebViewController.takeScreenshot} /// ///{@macro flutter_inappwebview_platform_interface.PlatformInAppWebViewController.takeScreenshot.supported_platforms} diff --git a/flutter_inappwebview_android/android/src/main/java/com/pichillilorenzo/flutter_inappwebview_android/plugin_scripts_js/JavaScriptBridgeJS.java b/flutter_inappwebview_android/android/src/main/java/com/pichillilorenzo/flutter_inappwebview_android/plugin_scripts_js/JavaScriptBridgeJS.java index d9d4552045..7ca6fce80b 100644 --- a/flutter_inappwebview_android/android/src/main/java/com/pichillilorenzo/flutter_inappwebview_android/plugin_scripts_js/JavaScriptBridgeJS.java +++ b/flutter_inappwebview_android/android/src/main/java/com/pichillilorenzo/flutter_inappwebview_android/plugin_scripts_js/JavaScriptBridgeJS.java @@ -25,6 +25,8 @@ public static String get_JAVASCRIPT_BRIDGE_NAME() { public static final String JAVASCRIPT_BRIDGE_JS_PLUGIN_SCRIPT_GROUP_NAME = "IN_APP_WEBVIEW_JAVASCRIPT_BRIDGE_JS_PLUGIN_SCRIPT"; private static final String VAR_JAVASCRIPT_BRIDGE_BRIDGE_SECRET = "$IN_APP_WEBVIEW_JAVASCRIPT_BRIDGE_BRIDGE_SECRET"; + private static final String BRIDGE_EVENTS_HANDLER_PREFIX = "__flutter_inappwebview_bridge_event__handler__:"; + private static final String BRIDGE_EVENTS_DOM_EVENT_PREFIX = "__flutter_inappwebview_bridge_event__dispatch__:"; public static PluginScript JAVASCRIPT_BRIDGE_JS_PLUGIN_SCRIPT(@NonNull String expectedBridgeSecret, @Nullable Set allowedOriginRules, @@ -49,6 +51,39 @@ public static String WEB_MESSAGE_CHANNELS_VARIABLE_NAME() { return "window." + get_JAVASCRIPT_BRIDGE_NAME() + "._webMessageChannels"; } + public static String BRIDGE_EVENTS_JS_SOURCE() { + return "(function(window) {" + + " var bridge = window." + get_JAVASCRIPT_BRIDGE_NAME() + ";" + + " if (bridge == null || bridge.callHandler == null || bridge.bridgeEvents != null) { return; }" + + " var handlerPrefix = '" + BRIDGE_EVENTS_HANDLER_PREFIX + "';" + + " var domEventPrefix = '" + BRIDGE_EVENTS_DOM_EVENT_PREFIX + "';" + + " var listeners = {};" + + " bridge.bridgeEvents = {" + + " on: function(eventName, callback) {" + + " var eventKey = domEventPrefix + String(eventName);" + + " var handler = function(e) { callback(e.detail); };" + + " listeners[eventKey] = listeners[eventKey] || [];" + + " listeners[eventKey].push({ callback: callback, handler: handler });" + + " window.addEventListener(eventKey, handler);" + + " }," + + " off: function(eventName, callback) {" + + " var eventKey = domEventPrefix + String(eventName);" + + " var eventListeners = listeners[eventKey] || [];" + + " for (var i = eventListeners.length - 1; i >= 0; i--) {" + + " if (callback == null || eventListeners[i].callback === callback) {" + + " window.removeEventListener(eventKey, eventListeners[i].handler);" + + " eventListeners.splice(i, 1);" + + " }" + + " }" + + " if (eventListeners.length === 0) { delete listeners[eventKey]; }" + + " }," + + " emit: function(eventName, data) {" + + " return bridge.callHandler(handlerPrefix + String(eventName), data);" + + " }" + + " };" + + "})(window);"; + } + public static String UTIL_JS_SOURCE() { return JAVASCRIPT_UTIL_VAR_NAME() + " = {" + " support: {" + @@ -352,6 +387,7 @@ public static String JAVASCRIPT_BRIDGE_JS_SOURCE() { " })(window);" + "}" + "if (window." + get_JAVASCRIPT_BRIDGE_NAME() + " != null) {" + + " " + BRIDGE_EVENTS_JS_SOURCE() + " " + UTIL_JS_SOURCE() + "}"; } diff --git a/flutter_inappwebview_ios/ios/flutter_inappwebview_ios/Sources/flutter_inappwebview_ios/PluginScriptsJS/JavaScriptBridgeJS.swift b/flutter_inappwebview_ios/ios/flutter_inappwebview_ios/Sources/flutter_inappwebview_ios/PluginScriptsJS/JavaScriptBridgeJS.swift index 0e42a37342..0787adcb98 100644 --- a/flutter_inappwebview_ios/ios/flutter_inappwebview_ios/Sources/flutter_inappwebview_ios/PluginScriptsJS/JavaScriptBridgeJS.swift +++ b/flutter_inappwebview_ios/ios/flutter_inappwebview_ios/Sources/flutter_inappwebview_ios/PluginScriptsJS/JavaScriptBridgeJS.swift @@ -19,6 +19,8 @@ public class JavaScriptBridgeJS { public static let JAVASCRIPT_BRIDGE_JS_PLUGIN_SCRIPT_GROUP_NAME = "IN_APP_WEBVIEW_JAVASCRIPT_BRIDGE_JS_PLUGIN_SCRIPT" public static let VAR_JAVASCRIPT_BRIDGE_BRIDGE_SECRET = "$IN_APP_WEBVIEW_JAVASCRIPT_BRIDGE_BRIDGE_SECRET" + public static let BRIDGE_EVENTS_HANDLER_PREFIX = "__flutter_inappwebview_bridge_event__handler__:" + public static let BRIDGE_EVENTS_DOM_EVENT_PREFIX = "__flutter_inappwebview_bridge_event__dispatch__:" public static func JAVASCRIPT_BRIDGE_JS_PLUGIN_SCRIPT(expectedBridgeSecret: String, allowedOriginRules: [String]?, forMainFrameOnly: Bool) -> PluginScript { let source = JAVASCRIPT_BRIDGE_JS_SOURCE().replacingOccurrences(of: VAR_JAVASCRIPT_BRIDGE_BRIDGE_SECRET, with: expectedBridgeSecret) @@ -73,6 +75,7 @@ public class JavaScriptBridgeJS { }); }; })(window); + \(BRIDGE_EVENTS_JS_SOURCE()) \(WebMessageListenerJS.WEB_MESSAGE_LISTENER_JS_SOURCE()) \(UTIL_JS_SOURCE()) """ @@ -84,6 +87,45 @@ public class JavaScriptBridgeJS { return "window.\(get_JAVASCRIPT_BRIDGE_NAME())._Util" } + public static func BRIDGE_EVENTS_JS_SOURCE() -> String { + return """ + (function(window) { + var bridge = window.\(get_JAVASCRIPT_BRIDGE_NAME()); + if (bridge == null || bridge.callHandler == null || bridge.bridgeEvents != null) { + return; + } + var handlerPrefix = '\(BRIDGE_EVENTS_HANDLER_PREFIX)'; + var domEventPrefix = '\(BRIDGE_EVENTS_DOM_EVENT_PREFIX)'; + var listeners = {}; + bridge.bridgeEvents = { + on: function(eventName, callback) { + var eventKey = domEventPrefix + String(eventName); + var handler = function(e) { callback(e.detail); }; + listeners[eventKey] = listeners[eventKey] || []; + listeners[eventKey].push({ callback: callback, handler: handler }); + window.addEventListener(eventKey, handler); + }, + off: function(eventName, callback) { + var eventKey = domEventPrefix + String(eventName); + var eventListeners = listeners[eventKey] || []; + for (var i = eventListeners.length - 1; i >= 0; i--) { + if (callback == null || eventListeners[i].callback === callback) { + window.removeEventListener(eventKey, eventListeners[i].handler); + eventListeners.splice(i, 1); + } + } + if (eventListeners.length === 0) { + delete listeners[eventKey]; + } + }, + emit: function(eventName, data) { + return bridge.callHandler(handlerPrefix + String(eventName), data); + } + }; + })(window); + """ + } + /* https://github.com/github/fetch/blob/master/fetch.js */ diff --git a/flutter_inappwebview_linux/linux/plugin_scripts_js/javascript_bridge_js.h b/flutter_inappwebview_linux/linux/plugin_scripts_js/javascript_bridge_js.h index 37f752cf8a..d360b95894 100644 --- a/flutter_inappwebview_linux/linux/plugin_scripts_js/javascript_bridge_js.h +++ b/flutter_inappwebview_linux/linux/plugin_scripts_js/javascript_bridge_js.h @@ -34,6 +34,52 @@ class JavaScriptBridgeJS { inline static const std::string VAR_JAVASCRIPT_BRIDGE_BRIDGE_SECRET = "$IN_APP_WEBVIEW_JAVASCRIPT_BRIDGE_BRIDGE_SECRET"; + inline static const std::string BRIDGE_EVENTS_HANDLER_PREFIX = + "__flutter_inappwebview_bridge_event__handler__:"; + inline static const std::string BRIDGE_EVENTS_DOM_EVENT_PREFIX = + "__flutter_inappwebview_bridge_event__dispatch__:"; + + static std::string BRIDGE_EVENTS_JS_SOURCE() { + return R"JS( +(function(window) { + var bridge = window.)JS" + + get_JAVASCRIPT_BRIDGE_NAME() + R"JS(; + if (bridge == null || bridge.callHandler == null || bridge.bridgeEvents != null) { + return; + } + var handlerPrefix = ')JS" + + BRIDGE_EVENTS_HANDLER_PREFIX + R"JS('; + var domEventPrefix = ')JS" + + BRIDGE_EVENTS_DOM_EVENT_PREFIX + R"JS('; + var listeners = {}; + bridge.bridgeEvents = { + on: function(eventName, callback) { + var eventKey = domEventPrefix + String(eventName); + var handler = function(e) { callback(e.detail); }; + listeners[eventKey] = listeners[eventKey] || []; + listeners[eventKey].push({ callback: callback, handler: handler }); + window.addEventListener(eventKey, handler); + }, + off: function(eventName, callback) { + var eventKey = domEventPrefix + String(eventName); + var eventListeners = listeners[eventKey] || []; + for (var i = eventListeners.length - 1; i >= 0; i--) { + if (callback == null || eventListeners[i].callback === callback) { + window.removeEventListener(eventKey, eventListeners[i].handler); + eventListeners.splice(i, 1); + } + } + if (eventListeners.length === 0) { + delete listeners[eventKey]; + } + }, + emit: function(eventName, data) { + return bridge.callHandler(handlerPrefix + String(eventName), data); + } + }; +})(window); +)JS"; + } /** * Returns the JavaScript variable name for the window ID. @@ -73,7 +119,7 @@ window.)JS" + _postMessage.call = window.Function.prototype.call; } catch (_) { return; } - window.)JS" + +window.)JS" + get_JAVASCRIPT_BRIDGE_NAME() + R"JS(.callHandler = function() { var _windowId = )JS" + WINDOW_ID_VARIABLE_JS_SOURCE() + R"JS(; @@ -87,7 +133,7 @@ window.)JS" + }); }; })(window); -)JS"; +)JS" + BRIDGE_EVENTS_JS_SOURCE(); } /** diff --git a/flutter_inappwebview_macos/macos/flutter_inappwebview_macos/Sources/flutter_inappwebview_macos/PluginScriptsJS/JavaScriptBridgeJS.swift b/flutter_inappwebview_macos/macos/flutter_inappwebview_macos/Sources/flutter_inappwebview_macos/PluginScriptsJS/JavaScriptBridgeJS.swift index 0e42a37342..0787adcb98 100644 --- a/flutter_inappwebview_macos/macos/flutter_inappwebview_macos/Sources/flutter_inappwebview_macos/PluginScriptsJS/JavaScriptBridgeJS.swift +++ b/flutter_inappwebview_macos/macos/flutter_inappwebview_macos/Sources/flutter_inappwebview_macos/PluginScriptsJS/JavaScriptBridgeJS.swift @@ -19,6 +19,8 @@ public class JavaScriptBridgeJS { public static let JAVASCRIPT_BRIDGE_JS_PLUGIN_SCRIPT_GROUP_NAME = "IN_APP_WEBVIEW_JAVASCRIPT_BRIDGE_JS_PLUGIN_SCRIPT" public static let VAR_JAVASCRIPT_BRIDGE_BRIDGE_SECRET = "$IN_APP_WEBVIEW_JAVASCRIPT_BRIDGE_BRIDGE_SECRET" + public static let BRIDGE_EVENTS_HANDLER_PREFIX = "__flutter_inappwebview_bridge_event__handler__:" + public static let BRIDGE_EVENTS_DOM_EVENT_PREFIX = "__flutter_inappwebview_bridge_event__dispatch__:" public static func JAVASCRIPT_BRIDGE_JS_PLUGIN_SCRIPT(expectedBridgeSecret: String, allowedOriginRules: [String]?, forMainFrameOnly: Bool) -> PluginScript { let source = JAVASCRIPT_BRIDGE_JS_SOURCE().replacingOccurrences(of: VAR_JAVASCRIPT_BRIDGE_BRIDGE_SECRET, with: expectedBridgeSecret) @@ -73,6 +75,7 @@ public class JavaScriptBridgeJS { }); }; })(window); + \(BRIDGE_EVENTS_JS_SOURCE()) \(WebMessageListenerJS.WEB_MESSAGE_LISTENER_JS_SOURCE()) \(UTIL_JS_SOURCE()) """ @@ -84,6 +87,45 @@ public class JavaScriptBridgeJS { return "window.\(get_JAVASCRIPT_BRIDGE_NAME())._Util" } + public static func BRIDGE_EVENTS_JS_SOURCE() -> String { + return """ + (function(window) { + var bridge = window.\(get_JAVASCRIPT_BRIDGE_NAME()); + if (bridge == null || bridge.callHandler == null || bridge.bridgeEvents != null) { + return; + } + var handlerPrefix = '\(BRIDGE_EVENTS_HANDLER_PREFIX)'; + var domEventPrefix = '\(BRIDGE_EVENTS_DOM_EVENT_PREFIX)'; + var listeners = {}; + bridge.bridgeEvents = { + on: function(eventName, callback) { + var eventKey = domEventPrefix + String(eventName); + var handler = function(e) { callback(e.detail); }; + listeners[eventKey] = listeners[eventKey] || []; + listeners[eventKey].push({ callback: callback, handler: handler }); + window.addEventListener(eventKey, handler); + }, + off: function(eventName, callback) { + var eventKey = domEventPrefix + String(eventName); + var eventListeners = listeners[eventKey] || []; + for (var i = eventListeners.length - 1; i >= 0; i--) { + if (callback == null || eventListeners[i].callback === callback) { + window.removeEventListener(eventKey, eventListeners[i].handler); + eventListeners.splice(i, 1); + } + } + if (eventListeners.length === 0) { + delete listeners[eventKey]; + } + }, + emit: function(eventName, data) { + return bridge.callHandler(handlerPrefix + String(eventName), data); + } + }; + })(window); + """ + } + /* https://github.com/github/fetch/blob/master/fetch.js */ diff --git a/flutter_inappwebview_platform_interface/lib/src/in_app_webview/platform_inappwebview_controller.dart b/flutter_inappwebview_platform_interface/lib/src/in_app_webview/platform_inappwebview_controller.dart index ab32425e45..1ea796f2c8 100644 --- a/flutter_inappwebview_platform_interface/lib/src/in_app_webview/platform_inappwebview_controller.dart +++ b/flutter_inappwebview_platform_interface/lib/src/in_app_webview/platform_inappwebview_controller.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:collection'; import 'dart:convert'; import 'dart:core'; @@ -1360,6 +1361,91 @@ abstract class PlatformInAppWebViewController extends PlatformInterface ); } + ///{@template flutter_inappwebview_platform_interface.PlatformInAppWebViewController.addSerializedJavaScriptHandler} + ///Adds a JavaScript handler with user-defined serialization functions. + /// + ///This helper wraps [addJavaScriptHandler] and does not change the underlying + ///JavaScript bridge behavior or security checks. + ///{@endtemplate} + /// + ///{@macro flutter_inappwebview_platform_interface.PlatformInAppWebViewController.addSerializedJavaScriptHandler.supported_platforms} + @SupportedPlatforms( + platforms: [ + AndroidPlatform(), + IOSPlatform(), + MacOSPlatform(), + WebPlatform(), + WindowsPlatform(), + ], + ) + void addSerializedJavaScriptHandler({ + required String handlerName, + required TRequest Function(Object? raw) deserialize, + required FutureOr Function( + TRequest request, + JavaScriptHandlerFunctionData meta, + ) + callback, + Object? Function(TResponse value)? serialize, + }) { + addJavaScriptHandler( + handlerName: handlerName, + callback: (JavaScriptHandlerFunctionData data) async { + final raw = data.args.isNotEmpty ? data.args[0] : null; + final request = deserialize(raw); + final response = await callback(request, data); + return serialize != null ? serialize(response) : response; + }, + ); + } + + ///{@template flutter_inappwebview_platform_interface.PlatformInAppWebViewController.addJsonJavaScriptHandler} + ///Adds a JavaScript handler with `fromJson` / `toJson` conversion. + /// + ///This is a convenience wrapper around [addSerializedJavaScriptHandler]. + ///{@endtemplate} + /// + ///{@macro flutter_inappwebview_platform_interface.PlatformInAppWebViewController.addJsonJavaScriptHandler.supported_platforms} + @SupportedPlatforms( + platforms: [ + AndroidPlatform(), + IOSPlatform(), + MacOSPlatform(), + WebPlatform(), + WindowsPlatform(), + ], + ) + void addJsonJavaScriptHandler({ + required String handlerName, + required TRequest Function(Map json) fromJson, + required FutureOr Function( + TRequest request, + JavaScriptHandlerFunctionData meta, + ) + callback, + Map Function(TResponse value)? toJson, + }) { + addSerializedJavaScriptHandler( + handlerName: handlerName, + deserialize: (raw) { + dynamic decoded = raw; + if (decoded is String && decoded.isNotEmpty) { + decoded = jsonDecode(decoded); + } + if (decoded is Map) { + return fromJson(Map.from(decoded)); + } + throw ArgumentError.value( + raw, + "raw", + "Expected a JSON object for handler $handlerName.", + ); + }, + callback: callback, + serialize: toJson, + ); + } + ///{@template flutter_inappwebview_platform_interface.PlatformInAppWebViewController.takeScreenshot} ///Takes a screenshot of the WebView's visible viewport and returns a [Uint8List]. Returns `null` if it wasn't be able to take it. /// diff --git a/flutter_inappwebview_platform_interface/lib/src/in_app_webview/platform_inappwebview_controller.g.dart b/flutter_inappwebview_platform_interface/lib/src/in_app_webview/platform_inappwebview_controller.g.dart index 5f9a37e56a..d6cd5c3391 100644 --- a/flutter_inappwebview_platform_interface/lib/src/in_app_webview/platform_inappwebview_controller.g.dart +++ b/flutter_inappwebview_platform_interface/lib/src/in_app_webview/platform_inappwebview_controller.g.dart @@ -163,6 +163,48 @@ enum PlatformInAppWebViewControllerMethod { ///{@endtemplate} addJavaScriptHandler, + ///Can be used to check if the [PlatformInAppWebViewController.addJsonJavaScriptHandler] method is supported at runtime. + /// + ///{@template flutter_inappwebview_platform_interface.PlatformInAppWebViewController.addJsonJavaScriptHandler.supported_platforms} + /// + ///**Officially Supported Platforms/Implementations**: + ///- Android WebView + ///- iOS WKWebView + ///- macOS WKWebView + ///- Web \ but requires same origin + ///- Windows WebView2 + /// + ///**Parameters - Officially Supported Platforms/Implementations**: + ///- [handlerName]: all platforms + ///- [fromJson]: all platforms + ///- [callback]: all platforms + ///- [toJson]: all platforms + /// + ///Use the [PlatformInAppWebViewController.isMethodSupported] method to check if this method is supported at runtime. + ///{@endtemplate} + addJsonJavaScriptHandler, + + ///Can be used to check if the [PlatformInAppWebViewController.addSerializedJavaScriptHandler] method is supported at runtime. + /// + ///{@template flutter_inappwebview_platform_interface.PlatformInAppWebViewController.addSerializedJavaScriptHandler.supported_platforms} + /// + ///**Officially Supported Platforms/Implementations**: + ///- Android WebView + ///- iOS WKWebView + ///- macOS WKWebView + ///- Web \ but requires same origin + ///- Windows WebView2 + /// + ///**Parameters - Officially Supported Platforms/Implementations**: + ///- [handlerName]: all platforms + ///- [deserialize]: all platforms + ///- [callback]: all platforms + ///- [serialize]: all platforms + /// + ///Use the [PlatformInAppWebViewController.isMethodSupported] method to check if this method is supported at runtime. + ///{@endtemplate} + addSerializedJavaScriptHandler, + ///Can be used to check if the [PlatformInAppWebViewController.addUserScript] method is supported at runtime. /// ///{@template flutter_inappwebview_platform_interface.PlatformInAppWebViewController.addUserScript.supported_platforms} @@ -2571,6 +2613,26 @@ extension _PlatformInAppWebViewControllerMethodSupported TargetPlatform.macOS, TargetPlatform.windows, ].contains(platform ?? defaultTargetPlatform); + case PlatformInAppWebViewControllerMethod.addJsonJavaScriptHandler: + return kIsWeb && platform == null + ? true + : ((kIsWeb && platform != null) || !kIsWeb) && + [ + TargetPlatform.android, + TargetPlatform.iOS, + TargetPlatform.macOS, + TargetPlatform.windows, + ].contains(platform ?? defaultTargetPlatform); + case PlatformInAppWebViewControllerMethod.addSerializedJavaScriptHandler: + return kIsWeb && platform == null + ? true + : ((kIsWeb && platform != null) || !kIsWeb) && + [ + TargetPlatform.android, + TargetPlatform.iOS, + TargetPlatform.macOS, + TargetPlatform.windows, + ].contains(platform ?? defaultTargetPlatform); case PlatformInAppWebViewControllerMethod.addUserScript: return kIsWeb && platform == null ? true diff --git a/flutter_inappwebview_web/lib/assets/web/web_support.js b/flutter_inappwebview_web/lib/assets/web/web_support.js index fe9282c0fe..a58e3d8f1e 100644 --- a/flutter_inappwebview_web/lib/assets/web/web_support.js +++ b/flutter_inappwebview_web/lib/assets/web/web_support.js @@ -84,6 +84,39 @@ ); } }; + const bridge = iframe.contentWindow[javaScriptBridgeName]; + if (bridge != null && bridge.callHandler != null && bridge.bridgeEvents == null) { + const handlerPrefix = "__flutter_inappwebview_bridge_event__handler__:"; + const domEventPrefix = "__flutter_inappwebview_bridge_event__dispatch__:"; + const listeners = {}; + bridge.bridgeEvents = { + on: function(eventName, callback) { + const eventKey = domEventPrefix + String(eventName); + const handler = function(e) { + callback(e.detail); + }; + listeners[eventKey] = listeners[eventKey] || []; + listeners[eventKey].push({ callback: callback, handler: handler }); + iframe.contentWindow.addEventListener(eventKey, handler); + }, + off: function(eventName, callback) { + const eventKey = domEventPrefix + String(eventName); + const eventListeners = listeners[eventKey] || []; + for (let i = eventListeners.length - 1; i >= 0; i--) { + if (callback == null || eventListeners[i].callback === callback) { + iframe.contentWindow.removeEventListener(eventKey, eventListeners[i].handler); + eventListeners.splice(i, 1); + } + } + if (eventListeners.length === 0) { + delete listeners[eventKey]; + } + }, + emit: function(eventName, data) { + return bridge.callHandler(handlerPrefix + String(eventName), data); + } + }; + } } } catch (e) { console.log(e); diff --git a/flutter_inappwebview_windows/windows/plugin_scripts_js/javascript_bridge_js.h b/flutter_inappwebview_windows/windows/plugin_scripts_js/javascript_bridge_js.h index 468e95a0ee..2cda5066c2 100644 --- a/flutter_inappwebview_windows/windows/plugin_scripts_js/javascript_bridge_js.h +++ b/flutter_inappwebview_windows/windows/plugin_scripts_js/javascript_bridge_js.h @@ -27,6 +27,42 @@ namespace flutter_inappwebview_plugin inline static const std::string JAVASCRIPT_BRIDGE_JS_PLUGIN_SCRIPT_GROUP_NAME = "IN_APP_WEBVIEW_JAVASCRIPT_BRIDGE_JS_PLUGIN_SCRIPT"; inline static const std::string VAR_JAVASCRIPT_BRIDGE_BRIDGE_SECRET = "$IN_APP_WEBVIEW_JAVASCRIPT_BRIDGE_BRIDGE_SECRET"; + inline static const std::string BRIDGE_EVENTS_HANDLER_PREFIX = "__flutter_inappwebview_bridge_event__handler__:"; + inline static const std::string BRIDGE_EVENTS_DOM_EVENT_PREFIX = "__flutter_inappwebview_bridge_event__dispatch__:"; + + static std::string BRIDGE_EVENTS_JS_SOURCE() + { + return "(function(window) { \ + var bridge = window." + get_JAVASCRIPT_BRIDGE_NAME() + "; \ + if (bridge == null || bridge.callHandler == null || bridge.bridgeEvents != null) { return; } \ + var handlerPrefix = '" + BRIDGE_EVENTS_HANDLER_PREFIX + "'; \ + var domEventPrefix = '" + BRIDGE_EVENTS_DOM_EVENT_PREFIX + "'; \ + var listeners = {}; \ + bridge.bridgeEvents = { \ + on: function(eventName, callback) { \ + var eventKey = domEventPrefix + String(eventName); \ + var handler = function(e) { callback(e.detail); }; \ + listeners[eventKey] = listeners[eventKey] || []; \ + listeners[eventKey].push({ callback: callback, handler: handler }); \ + window.addEventListener(eventKey, handler); \ + }, \ + off: function(eventName, callback) { \ + var eventKey = domEventPrefix + String(eventName); \ + var eventListeners = listeners[eventKey] || []; \ + for (var i = eventListeners.length - 1; i >= 0; i--) { \ + if (callback == null || eventListeners[i].callback === callback) { \ + window.removeEventListener(eventKey, eventListeners[i].handler); \ + eventListeners.splice(i, 1); \ + } \ + } \ + if (eventListeners.length === 0) { delete listeners[eventKey]; } \ + }, \ + emit: function(eventName, data) { \ + return bridge.callHandler(handlerPrefix + String(eventName), data); \ + } \ + }; \ + })(window);"; + } static std::string JAVASCRIPT_BRIDGE_JS_SOURCE() { @@ -78,7 +114,7 @@ namespace flutter_inappwebview_plugin } catch(e) { resolve(); }\ });\ };\ - })(window);"; + })(window);" + BRIDGE_EVENTS_JS_SOURCE(); } static std::string PLATFORM_READY_JS_SOURCE() @@ -111,4 +147,4 @@ namespace flutter_inappwebview_plugin }; } -#endif //FLUTTER_INAPPWEBVIEW_PLUGIN_JAVASCRIPT_BRIDGE_JS_H_ \ No newline at end of file +#endif //FLUTTER_INAPPWEBVIEW_PLUGIN_JAVASCRIPT_BRIDGE_JS_H_ From 121386883d45f6dad96f67e82255258639d0b8a9 Mon Sep 17 00:00:00 2001 From: "Park, Woocheol" Date: Fri, 6 Mar 2026 14:46:58 +0900 Subject: [PATCH 2/2] test(example): add bridgeEvents coverage and controllers demo section --- .../bridge_events_smoke_test.dart | 266 +++++++++++ .../in_app_webview/bridge_events.dart | 291 ++++++++++++ .../integration_test/in_app_webview/main.dart | 2 + .../screens/advanced/controllers_screen.dart | 423 ++++++++++++++++++ 4 files changed, 982 insertions(+) create mode 100644 flutter_inappwebview/example/integration_test/bridge_events_smoke_test.dart create mode 100644 flutter_inappwebview/example/integration_test/in_app_webview/bridge_events.dart diff --git a/flutter_inappwebview/example/integration_test/bridge_events_smoke_test.dart b/flutter_inappwebview/example/integration_test/bridge_events_smoke_test.dart new file mode 100644 index 0000000000..390ceda96a --- /dev/null +++ b/flutter_inappwebview/example/integration_test/bridge_events_smoke_test.dart @@ -0,0 +1,266 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +class _BridgeUserRequest { + final String name; + final int age; + + const _BridgeUserRequest({required this.name, required this.age}); + + factory _BridgeUserRequest.fromJson(Map json) { + return _BridgeUserRequest( + name: json['name'] as String? ?? '', + age: (json['age'] as num?)?.toInt() ?? 0, + ); + } +} + +class _BridgeUserResponse { + final bool ok; + final String message; + + const _BridgeUserResponse({required this.ok, required this.message}); + + Map toJson() => {'ok': ok, 'message': message}; +} + +Future _waitWithTimeout(Future future, String label) { + return future.timeout( + const Duration(seconds: 20), + onTimeout: () => throw TimeoutException('Timed out: $label'), + ); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('bridgeEvents and addJsonJavaScriptHandler smoke test', ( + WidgetTester tester, + ) async { + final supportsBridgeEvents = + InAppWebViewController.isMethodSupported( + PlatformInAppWebViewControllerMethod.addJavaScriptHandler, + ) && + InAppWebViewController.isMethodSupported( + PlatformInAppWebViewControllerMethod.removeJavaScriptHandler, + ) && + InAppWebViewController.isMethodSupported( + PlatformInAppWebViewControllerMethod.hasJavaScriptHandler, + ) && + InAppWebViewController.isMethodSupported( + PlatformInAppWebViewControllerMethod.evaluateJavascript, + ); + + if (!supportsBridgeEvents) { + return; + } + + const jsToDartEvent = 'smoke_js_to_dart'; + const dartToJsEvent = 'smoke_dart_to_js'; + const ackHandler = 'smokeAck'; + const typedHandler = 'smokeTyped'; + const typedResultHandler = 'smokeTypedResult'; + + final controllerCompleter = Completer(); + final loadedCompleter = Completer(); + final jsToDartPayloadCompleter = Completer>(); + final dartToJsAckCompleter = Completer>(); + final typedResultCompleter = Completer>(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: InAppWebView( + key: GlobalKey(), + initialUrlRequest: URLRequest(url: WebUri('about:blank')), + onWebViewCreated: (controller) { + controllerCompleter.complete(controller); + + controller.bridgeEvents.on( + eventName: jsToDartEvent, + listener: (data) { + if (!jsToDartPayloadCompleter.isCompleted && data is Map) { + jsToDartPayloadCompleter.complete( + Map.from(data), + ); + } + return {'ok': true}; + }, + ); + + controller.addJavaScriptHandler( + handlerName: ackHandler, + callback: (JavaScriptHandlerFunctionData data) { + final payload = data.args.isNotEmpty ? data.args[0] : null; + if (!dartToJsAckCompleter.isCompleted && payload is Map) { + dartToJsAckCompleter.complete( + Map.from(payload), + ); + } + return null; + }, + ); + + controller.addJsonJavaScriptHandler< + _BridgeUserRequest, + _BridgeUserResponse + >( + handlerName: typedHandler, + fromJson: _BridgeUserRequest.fromJson, + callback: (request, meta) async { + return _BridgeUserResponse( + ok: true, + message: 'updated:${request.name}:${request.age}', + ); + }, + toJson: (value) => value.toJson(), + ); + + controller.addJavaScriptHandler( + handlerName: typedResultHandler, + callback: (JavaScriptHandlerFunctionData data) { + dynamic payload = data.args.isNotEmpty ? data.args[0] : null; + if (payload is String && payload.isNotEmpty) { + try { + payload = jsonDecode(payload); + } catch (_) { + // Keep original value when decoding is not possible. + } + } + if (!typedResultCompleter.isCompleted && payload is Map) { + typedResultCompleter.complete( + Map.from(payload), + ); + } + return null; + }, + ); + }, + onLoadStop: (controller, url) { + if (!loadedCompleter.isCompleted) { + loadedCompleter.complete(); + } + }, + ), + ), + ), + ); + + final controller = await _waitWithTimeout( + controllerCompleter.future, + 'webview controller creation', + ); + await _waitWithTimeout(loadedCompleter.future, 'page load'); + + final bridgeName = await InAppWebViewController.getJavaScriptBridgeName(); + final encodedBridgeName = jsonEncode(bridgeName); + + await controller.evaluateJavascript( + source: + """ + (function() { + function run() { + var bridge = window[$encodedBridgeName]; + if (bridge == null || bridge.bridgeEvents == null) { + return; + } + bridge.bridgeEvents.emit('${jsToDartEvent}', { + message: 'from_js', + value: 7 + }); + } + var bridge = window[$encodedBridgeName]; + if (bridge != null && bridge._platformReady) { + run(); + return; + } + window.addEventListener('flutterInAppWebViewPlatformReady', run, { once: true }); + })(); + """, + ); + + final jsToDartPayload = await _waitWithTimeout( + jsToDartPayloadCompleter.future, + 'js -> dart payload', + ); + expect(jsToDartPayload['message'], 'from_js'); + expect(jsToDartPayload['value'], 7); + + await controller.evaluateJavascript( + source: + """ + (function() { + function register() { + var bridge = window[$encodedBridgeName]; + if (bridge == null || bridge.bridgeEvents == null) { + return; + } + bridge.bridgeEvents.off('${dartToJsEvent}'); + bridge.bridgeEvents.on('${dartToJsEvent}', function(data) { + bridge.callHandler('${ackHandler}', data); + }); + } + var bridge = window[$encodedBridgeName]; + if (bridge != null && bridge._platformReady) { + register(); + return; + } + window.addEventListener('flutterInAppWebViewPlatformReady', register, { once: true }); + })(); + """, + ); + + await controller.bridgeEvents.emit(dartToJsEvent, { + 'message': 'from_dart', + 'value': 99, + }); + + final dartToJsAck = await _waitWithTimeout( + dartToJsAckCompleter.future, + 'dart -> js ack', + ); + expect(dartToJsAck['message'], 'from_dart'); + expect(dartToJsAck['value'], 99); + + await controller.evaluateJavascript( + source: + """ + (function() { + function run() { + var bridge = window[$encodedBridgeName]; + if (bridge == null || bridge.callHandler == null) { + return; + } + var request = JSON.stringify({name: 'Alice', age: 31}); + bridge.callHandler('${typedHandler}', request).then(function(result) { + bridge.callHandler('${typedResultHandler}', result); + }); + } + var bridge = window[$encodedBridgeName]; + if (bridge != null && bridge._platformReady) { + run(); + return; + } + window.addEventListener('flutterInAppWebViewPlatformReady', run, { once: true }); + })(); + """, + ); + + final typedResult = await _waitWithTimeout( + typedResultCompleter.future, + 'typed handler result', + ); + expect(typedResult['ok'], true); + expect(typedResult['message'], 'updated:Alice:31'); + + controller.bridgeEvents.off(jsToDartEvent); + controller.removeJavaScriptHandler(handlerName: ackHandler); + controller.removeJavaScriptHandler(handlerName: typedResultHandler); + controller.removeJavaScriptHandler(handlerName: typedHandler); + }); +} diff --git a/flutter_inappwebview/example/integration_test/in_app_webview/bridge_events.dart b/flutter_inappwebview/example/integration_test/in_app_webview/bridge_events.dart new file mode 100644 index 0000000000..d639f6015b --- /dev/null +++ b/flutter_inappwebview/example/integration_test/in_app_webview/bridge_events.dart @@ -0,0 +1,291 @@ +part of 'main.dart'; + +class _BridgeEventsUserRequest { + final String name; + final int age; + + const _BridgeEventsUserRequest({required this.name, required this.age}); + + factory _BridgeEventsUserRequest.fromJson(Map json) { + return _BridgeEventsUserRequest( + name: json["name"] as String? ?? "", + age: json["age"] as int? ?? 0, + ); + } +} + +class _BridgeEventsUserResponse { + final bool ok; + final String message; + + const _BridgeEventsUserResponse({required this.ok, required this.message}); + + Map toJson() { + return {"ok": ok, "message": message}; + } +} + +void bridgeEvents() { + final shouldSkip = + !InAppWebViewController.isMethodSupported( + PlatformInAppWebViewControllerMethod.addJavaScriptHandler, + ) || + !InAppWebViewController.isMethodSupported( + PlatformInAppWebViewControllerMethod.removeJavaScriptHandler, + ) || + !InAppWebViewController.isMethodSupported( + PlatformInAppWebViewControllerMethod.hasJavaScriptHandler, + ) || + !InAppWebViewController.isMethodSupported( + PlatformInAppWebViewControllerMethod.evaluateJavascript, + ); + + skippableGroup('Bridge Events', () { + skippableTestWidgets('bridgeEvents.emit JS -> Dart', ( + WidgetTester tester, + ) async { + final controllerCompleter = Completer(); + final pageLoaded = Completer(); + final jsPayloadReceived = Completer>(); + const eventName = "js_to_dart_ping"; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: InAppWebView( + key: GlobalKey(), + initialUrlRequest: URLRequest(url: TEST_URL_ABOUT_BLANK), + onWebViewCreated: (controller) { + controllerCompleter.complete(controller); + controller.bridgeEvents.on( + eventName: eventName, + listener: (data) { + if (!jsPayloadReceived.isCompleted && data is Map) { + jsPayloadReceived.complete(Map.from(data)); + } + return {"ok": true}; + }, + ); + }, + onLoadStop: (controller, url) { + if (!pageLoaded.isCompleted) { + pageLoaded.complete(); + } + }, + ), + ), + ); + + final controller = await controllerCompleter.future; + await pageLoaded.future; + + expect(controller.bridgeEvents.hasListener(eventName), true); + + final bridgeName = await InAppWebViewController.getJavaScriptBridgeName(); + final encodedBridgeName = jsonEncode(bridgeName); + final encodedEventName = jsonEncode(eventName); + await controller.evaluateJavascript( + source: + """ + (function() { + var payload = {message: 'hello_from_js', value: 7}; + function sendEvent() { + var bridge = window[$encodedBridgeName]; + if (bridge == null || bridge.bridgeEvents == null) { + return; + } + bridge.bridgeEvents.emit($encodedEventName, payload); + } + var bridge = window[$encodedBridgeName]; + if (bridge != null && bridge._platformReady) { + sendEvent(); + return; + } + window.addEventListener('flutterInAppWebViewPlatformReady', sendEvent, { once: true }); + })(); + """, + ); + + final payload = await jsPayloadReceived.future; + expect(payload["message"], "hello_from_js"); + expect(payload["value"], 7); + + controller.bridgeEvents.off(eventName); + expect(controller.bridgeEvents.hasListener(eventName), false); + }); + + skippableTestWidgets('bridgeEvents.emit Dart -> JS', ( + WidgetTester tester, + ) async { + final controllerCompleter = Completer(); + final pageLoaded = Completer(); + final dartPayloadReceivedInJs = Completer>(); + const eventName = "dart_to_js_ping"; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: InAppWebView( + key: GlobalKey(), + initialUrlRequest: URLRequest(url: TEST_URL_ABOUT_BLANK), + onWebViewCreated: (controller) { + controllerCompleter.complete(controller); + controller.addJavaScriptHandler( + handlerName: "bridgeEventsAck", + callback: (JavaScriptHandlerFunctionData data) { + if (!dartPayloadReceivedInJs.isCompleted && + data.args.isNotEmpty && + data.args[0] is Map) { + dartPayloadReceivedInJs.complete( + Map.from(data.args[0]), + ); + } + return null; + }, + ); + }, + onLoadStop: (controller, url) { + if (!pageLoaded.isCompleted) { + pageLoaded.complete(); + } + }, + ), + ), + ); + + final controller = await controllerCompleter.future; + await pageLoaded.future; + + final bridgeName = await InAppWebViewController.getJavaScriptBridgeName(); + final encodedBridgeName = jsonEncode(bridgeName); + final encodedEventName = jsonEncode(eventName); + await controller.evaluateJavascript( + source: + """ + (function() { + function registerListener() { + var bridge = window[$encodedBridgeName]; + if (bridge == null || bridge.bridgeEvents == null) { + return; + } + bridge.bridgeEvents.on($encodedEventName, function(data) { + bridge.callHandler('bridgeEventsAck', data); + }); + } + var bridge = window[$encodedBridgeName]; + if (bridge != null && bridge._platformReady) { + registerListener(); + return; + } + window.addEventListener('flutterInAppWebViewPlatformReady', registerListener, { once: true }); + })(); + """, + ); + + await controller.bridgeEvents.emit(eventName, { + "message": "hello_from_dart", + "value": 99, + }); + + final payload = await dartPayloadReceivedInJs.future; + expect(payload["message"], "hello_from_dart"); + expect(payload["value"], 99); + }); + + skippableTestWidgets('addJsonJavaScriptHandler', ( + WidgetTester tester, + ) async { + final controllerCompleter = Completer(); + final pageLoaded = Completer(); + final requestCompleter = Completer<_BridgeEventsUserRequest>(); + final resultCompleter = Completer>(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: InAppWebView( + key: GlobalKey(), + initialUrlRequest: URLRequest(url: TEST_URL_ABOUT_BLANK), + onWebViewCreated: (controller) { + controllerCompleter.complete(controller); + + controller.addJsonJavaScriptHandler< + _BridgeEventsUserRequest, + _BridgeEventsUserResponse + >( + handlerName: "typedBridgeEvents", + fromJson: _BridgeEventsUserRequest.fromJson, + callback: (request, meta) async { + if (!requestCompleter.isCompleted) { + requestCompleter.complete(request); + } + return _BridgeEventsUserResponse( + ok: true, + message: "updated:${request.name}:${request.age}", + ); + }, + toJson: (value) => value.toJson(), + ); + + controller.addJavaScriptHandler( + handlerName: "typedBridgeEventsResult", + callback: (JavaScriptHandlerFunctionData data) { + if (!resultCompleter.isCompleted && + data.args.isNotEmpty && + data.args[0] is Map) { + resultCompleter.complete( + Map.from(data.args[0]), + ); + } + return null; + }, + ); + }, + onLoadStop: (controller, url) { + if (!pageLoaded.isCompleted) { + pageLoaded.complete(); + } + }, + ), + ), + ); + + final controller = await controllerCompleter.future; + await pageLoaded.future; + + final bridgeName = await InAppWebViewController.getJavaScriptBridgeName(); + final encodedBridgeName = jsonEncode(bridgeName); + await controller.evaluateJavascript( + source: + """ + (function() { + function run() { + var bridge = window[$encodedBridgeName]; + if (bridge == null) { + return; + } + var input = JSON.stringify({name: 'Alice', age: 31}); + bridge.callHandler('typedBridgeEvents', input).then(function(result) { + bridge.callHandler('typedBridgeEventsResult', result); + }); + } + var bridge = window[$encodedBridgeName]; + if (bridge != null && bridge._platformReady) { + run(); + return; + } + window.addEventListener('flutterInAppWebViewPlatformReady', run, { once: true }); + })(); + """, + ); + + final request = await requestCompleter.future; + final result = await resultCompleter.future; + + expect(request.name, "Alice"); + expect(request.age, 31); + expect(result["ok"], true); + expect(result["message"], "updated:Alice:31"); + }); + }, skip: shouldSkip); +} diff --git a/flutter_inappwebview/example/integration_test/in_app_webview/main.dart b/flutter_inappwebview/example/integration_test/in_app_webview/main.dart index 50487cb9e6..35387bddff 100644 --- a/flutter_inappwebview/example/integration_test/in_app_webview/main.dart +++ b/flutter_inappwebview/example/integration_test/in_app_webview/main.dart @@ -47,6 +47,7 @@ part 'is_secure_context.dart'; part 'javascript_code_evaluation.dart'; part 'javascript_dialogs.dart'; part 'javascript_handler.dart'; +part 'bridge_events.dart'; part 'load_data.dart'; part 'load_file.dart'; part 'load_file_url.dart'; @@ -121,6 +122,7 @@ void main() { loadUrl(); loadFileUrl(); javascriptHandler(); + bridgeEvents(); resizeWebView(); setCustomUserAgent(); videoPlaybackPolicy(); diff --git a/flutter_inappwebview/example/lib/screens/advanced/controllers_screen.dart b/flutter_inappwebview/example/lib/screens/advanced/controllers_screen.dart index a8b9c3b26a..b5923b5ec8 100644 --- a/flutter_inappwebview/example/lib/screens/advanced/controllers_screen.dart +++ b/flutter_inappwebview/example/lib/screens/advanced/controllers_screen.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; @@ -23,6 +24,31 @@ class ControllersScreen extends StatefulWidget { State createState() => _ControllersScreenState(); } +class _BridgeEventsUserRequest { + final String name; + final int age; + + const _BridgeEventsUserRequest({required this.name, required this.age}); + + factory _BridgeEventsUserRequest.fromJson(Map json) { + return _BridgeEventsUserRequest( + name: json['name'] as String? ?? '', + age: (json['age'] as num?)?.toInt() ?? 0, + ); + } +} + +class _BridgeEventsUserResponse { + final bool ok; + final String message; + + const _BridgeEventsUserResponse({required this.ok, required this.message}); + + Map toJson() { + return {'ok': ok, 'message': message}; + } +} + class _ControllersScreenState extends State { final TextEditingController _searchController = TextEditingController(); final TextEditingController _messageController = TextEditingController(); @@ -54,6 +80,16 @@ class _ControllersScreenState extends State { final Map _selectedHistoryIndex = {}; static const int _maxHistoryEntries = 3; + static const String _bridgeEventFromJs = 'controllers_js_to_dart'; + static const String _bridgeEventFromDart = 'controllers_dart_to_js'; + static const String _bridgeEventAckHandler = 'controllersBridgeEventsAck'; + static const String _bridgeTypedHandler = 'controllersBridgeTypedUser'; + static const String _bridgeTypedResultHandler = + 'controllersBridgeTypedResult'; + static const String _bridgeJsToDartLabel = 'bridgeEvents.emit (JS -> Dart)'; + static const String _bridgeDartToJsLabel = 'bridgeEvents.emit (Dart -> JS)'; + static const String _bridgeTypedLabel = 'addJsonJavaScriptHandler'; + SupportedPlatform? get _currentPlatform { if (kIsWeb) return SupportedPlatform.web; if (Platform.isAndroid) return SupportedPlatform.android; @@ -135,6 +171,15 @@ class _ControllersScreenState extends State { @override void dispose() { + final controller = _webViewController; + if (controller != null) { + controller.bridgeEvents.off(_bridgeEventFromJs); + controller.removeJavaScriptHandler(handlerName: _bridgeEventAckHandler); + controller.removeJavaScriptHandler( + handlerName: _bridgeTypedResultHandler, + ); + controller.removeJavaScriptHandler(handlerName: _bridgeTypedHandler); + } _searchController.dispose(); _messageController.dispose(); _findInteractionController?.dispose(); @@ -559,6 +604,297 @@ class _ControllersScreenState extends State { ); } + bool get _isBridgeEventsSupported { + return InAppWebViewController.isMethodSupported( + PlatformInAppWebViewControllerMethod.addJavaScriptHandler, + ) && + InAppWebViewController.isMethodSupported( + PlatformInAppWebViewControllerMethod.removeJavaScriptHandler, + ) && + InAppWebViewController.isMethodSupported( + PlatformInAppWebViewControllerMethod.hasJavaScriptHandler, + ) && + InAppWebViewController.isMethodSupported( + PlatformInAppWebViewControllerMethod.evaluateJavascript, + ); + } + + void _registerBridgeEventsHandlers(InAppWebViewController controller) { + if (!_isBridgeEventsSupported) { + return; + } + + controller.bridgeEvents.on( + eventName: _bridgeEventFromJs, + listener: (data) { + _recordMethodResult( + _bridgeJsToDartLabel, + 'Received payload from JavaScript.', + isError: false, + value: data, + ); + return { + 'ok': true, + 'eventName': _bridgeEventFromJs, + 'receivedBy': 'dart', + }; + }, + ); + + controller.addJavaScriptHandler( + handlerName: _bridgeEventAckHandler, + callback: (JavaScriptHandlerFunctionData data) { + final payload = data.args.isNotEmpty ? data.args[0] : null; + _recordMethodResult( + _bridgeDartToJsLabel, + 'JavaScript listener acknowledged the event.', + isError: false, + value: payload, + ); + return null; + }, + ); + + controller.addJsonJavaScriptHandler< + _BridgeEventsUserRequest, + _BridgeEventsUserResponse + >( + handlerName: _bridgeTypedHandler, + fromJson: _BridgeEventsUserRequest.fromJson, + callback: (request, meta) async { + return _BridgeEventsUserResponse( + ok: true, + message: 'updated:${request.name}:${request.age}', + ); + }, + toJson: (response) => response.toJson(), + ); + + controller.addJavaScriptHandler( + handlerName: _bridgeTypedResultHandler, + callback: (JavaScriptHandlerFunctionData data) { + dynamic payload = data.args.isNotEmpty ? data.args[0] : null; + if (payload is String && payload.isNotEmpty) { + try { + payload = jsonDecode(payload); + } catch (_) { + // Keep original value when decoding is not possible. + } + } + _recordMethodResult( + _bridgeTypedLabel, + 'Typed handler response received.', + isError: false, + value: payload, + ); + return null; + }, + ); + } + + Future _emitBridgeEventFromJs() async { + final controller = _webViewController; + if (controller == null || !_webViewReady) { + _recordMethodResult( + _bridgeJsToDartLabel, + 'WebView is not ready.', + isError: true, + ); + return; + } + if (!_isBridgeEventsSupported) { + _recordMethodResult( + _bridgeJsToDartLabel, + 'Required JavaScript bridge methods are not supported.', + isError: true, + ); + return; + } + + final bridgeName = await InAppWebViewController.getJavaScriptBridgeName(); + final encodedBridgeName = jsonEncode(bridgeName); + final encodedEventName = jsonEncode(_bridgeEventFromJs); + await controller.evaluateJavascript( + source: + """ + (function() { + function emit() { + var bridge = window[$encodedBridgeName]; + if (bridge == null || bridge.bridgeEvents == null) { + return; + } + window.__controllersBridgeSequence = + (window.__controllersBridgeSequence || 0) + 1; + bridge.bridgeEvents.emit($encodedEventName, { + message: 'hello_from_js', + sequence: window.__controllersBridgeSequence, + sentAt: new Date().toISOString() + }); + } + var bridge = window[$encodedBridgeName]; + if (bridge != null && bridge._platformReady) { + emit(); + return; + } + window.addEventListener( + 'flutterInAppWebViewPlatformReady', + emit, + { once: true } + ); + })(); + """, + ); + _recordMethodResult( + _bridgeJsToDartLabel, + 'JavaScript emit requested.', + isError: false, + ); + } + + Future _emitBridgeEventFromDart() async { + final controller = _webViewController; + if (controller == null || !_webViewReady) { + _recordMethodResult( + _bridgeDartToJsLabel, + 'WebView is not ready.', + isError: true, + ); + return; + } + if (!_isBridgeEventsSupported) { + _recordMethodResult( + _bridgeDartToJsLabel, + 'Required JavaScript bridge methods are not supported.', + isError: true, + ); + return; + } + + final bridgeName = await InAppWebViewController.getJavaScriptBridgeName(); + final encodedBridgeName = jsonEncode(bridgeName); + final encodedEventName = jsonEncode(_bridgeEventFromDart); + final encodedAckHandlerName = jsonEncode(_bridgeEventAckHandler); + + await controller.evaluateJavascript( + source: + """ + (function() { + function registerListener() { + var bridge = window[$encodedBridgeName]; + if (bridge == null || bridge.bridgeEvents == null) { + return; + } + bridge.bridgeEvents.off($encodedEventName); + bridge.bridgeEvents.on($encodedEventName, function(data) { + bridge.callHandler($encodedAckHandlerName, { + source: 'javascript', + eventName: $encodedEventName, + data: data + }); + }); + } + var bridge = window[$encodedBridgeName]; + if (bridge != null && bridge._platformReady) { + registerListener(); + return; + } + window.addEventListener( + 'flutterInAppWebViewPlatformReady', + registerListener, + { once: true } + ); + })(); + """, + ); + + await controller.bridgeEvents.emit(_bridgeEventFromDart, { + 'message': 'hello_from_dart', + 'sentAt': DateTime.now().toIso8601String(), + }); + + _recordMethodResult( + _bridgeDartToJsLabel, + 'Dart emit requested.', + isError: false, + ); + } + + Future _invokeTypedBridgeHandlerFromJs() async { + final controller = _webViewController; + if (controller == null || !_webViewReady) { + _recordMethodResult( + _bridgeTypedLabel, + 'WebView is not ready.', + isError: true, + ); + return; + } + if (!_isBridgeEventsSupported) { + _recordMethodResult( + _bridgeTypedLabel, + 'Required JavaScript bridge methods are not supported.', + isError: true, + ); + return; + } + + final bridgeName = await InAppWebViewController.getJavaScriptBridgeName(); + final encodedBridgeName = jsonEncode(bridgeName); + final encodedTypedHandler = jsonEncode(_bridgeTypedHandler); + final encodedTypedResultHandler = jsonEncode(_bridgeTypedResultHandler); + + await controller.evaluateJavascript( + source: + """ + (function() { + function run() { + var bridge = window[$encodedBridgeName]; + if (bridge == null || bridge.callHandler == null) { + return; + } + var payload = JSON.stringify({name: 'Alice', age: 31}); + bridge.callHandler($encodedTypedHandler, payload) + .then(function(result) { + bridge.callHandler($encodedTypedResultHandler, result); + }) + .catch(function(error) { + bridge.callHandler($encodedTypedResultHandler, { + ok: false, + message: String(error) + }); + }); + } + var bridge = window[$encodedBridgeName]; + if (bridge != null && bridge._platformReady) { + run(); + return; + } + window.addEventListener( + 'flutterInAppWebViewPlatformReady', + run, + { once: true } + ); + })(); + """, + ); + _recordMethodResult( + _bridgeTypedLabel, + 'Typed JavaScript handler invocation requested.', + isError: false, + ); + } + + void _clearBridgeEventsHistory() { + setState(() { + _methodHistory.remove(_bridgeJsToDartLabel); + _methodHistory.remove(_bridgeDartToJsLabel); + _methodHistory.remove(_bridgeTypedLabel); + _selectedHistoryIndex.remove(_bridgeJsToDartLabel); + _selectedHistoryIndex.remove(_bridgeDartToJsLabel); + _selectedHistoryIndex.remove(_bridgeTypedLabel); + }); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -629,6 +965,8 @@ class _ControllersScreenState extends State { const SizedBox(height: 16), _buildWebMessageChannelSection(), const SizedBox(height: 16), + _buildBridgeEventsSection(), + const SizedBox(height: 16), _buildPrintJobSection(), const SizedBox(height: 16), const EventLogCard(), @@ -656,6 +994,8 @@ class _ControllersScreenState extends State { const SizedBox(height: 16), _buildWebMessageChannelSection(), const SizedBox(height: 16), + _buildBridgeEventsSection(), + const SizedBox(height: 16), _buildPrintJobSection(), const SizedBox(height: 16), const EventLogCard(), @@ -682,6 +1022,7 @@ class _ControllersScreenState extends State { pullToRefreshController: _pullToRefreshController, onWebViewCreated: (controller) { _webViewController = controller; + _registerBridgeEventsHandlers(controller); }, onLoadStop: (controller, url) { setState(() => _webViewReady = true); @@ -1287,6 +1628,88 @@ class _ControllersScreenState extends State { ); } + Widget _buildBridgeEventsSection() { + final supportedPlatforms = SupportCheckHelper.supportedPlatformsForMethod( + method: PlatformInAppWebViewControllerMethod.addJavaScriptHandler, + checker: InAppWebViewController.isMethodSupported, + ); + + return Card( + child: ExpansionTile( + title: Row( + children: [ + const Text( + 'Bridge Events', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(width: 8), + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SupportBadgesRow( + supportedPlatforms: supportedPlatforms, + compact: true, + ), + ), + ), + ], + ), + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Validate bridgeEvents (on/off/emit) and addJsonJavaScriptHandler without bypassing JavaScript bridge security checks.', + ), + const SizedBox(height: 12), + if (!_isBridgeEventsSupported) + Text( + 'Required JavaScript bridge methods are not supported on this platform.', + style: TextStyle(color: Colors.red.shade700), + ), + if (!_isBridgeEventsSupported) const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ElevatedButton( + onPressed: (_webViewReady && _isBridgeEventsSupported) + ? _emitBridgeEventFromJs + : null, + child: const Text('JS -> Dart Emit'), + ), + ElevatedButton( + onPressed: (_webViewReady && _isBridgeEventsSupported) + ? _emitBridgeEventFromDart + : null, + child: const Text('Dart -> JS Emit'), + ), + ElevatedButton( + onPressed: (_webViewReady && _isBridgeEventsSupported) + ? _invokeTypedBridgeHandlerFromJs + : null, + child: const Text('Typed Handler'), + ), + TextButton( + onPressed: _clearBridgeEventsHistory, + child: const Text('Clear Results'), + ), + ], + ), + const SizedBox(height: 12), + _buildMethodHistory(_bridgeJsToDartLabel), + _buildMethodHistory(_bridgeDartToJsLabel), + _buildMethodHistory(_bridgeTypedLabel), + ], + ), + ), + ], + ), + ); + } + Widget _buildMethodChip( String label, Set supportedPlatforms,