diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4dada59..17731c9 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -16,6 +16,7 @@ + diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart index 3711100..f51703e 100644 --- a/lib/config/app_config.dart +++ b/lib/config/app_config.dart @@ -41,7 +41,8 @@ class AppConfig { }) { final uri = Uri.parse(url); final queryParameters = Map.from(uri.queryParameters) - ..['flutter_shell'] = '1'; + ..['flutter_shell'] = '1' + ..['shell_cache_bust'] = DateTime.now().millisecondsSinceEpoch.toString(); final trimmedName = (appName ?? AppConfig.appName).trim(); if (trimmedName.isNotEmpty) { diff --git a/lib/main.dart b/lib/main.dart index b9f8da0..2959a2b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,6 +17,100 @@ const _shellSubText = Color(0xFF8E9AB0); const _resumeCoverDuration = Duration(milliseconds: 700); const _shellBrandingChannel = MethodChannel('io.openim.flutter.im_webview_app/shell_branding'); +const _shellMediaPermissionBridgeScript = r''' +(() => { + if (window.__openimShellMediaBridgeInstalled) { + return; + } + + const channel = window.OpenIMFlutterShell; + const mediaDevices = navigator.mediaDevices; + if (!channel || typeof channel.postMessage !== 'function') { + return; + } + if (!mediaDevices || typeof mediaDevices.getUserMedia !== 'function') { + return; + } + + const originalGetUserMedia = mediaDevices.getUserMedia.bind(mediaDevices); + const resultEventName = 'openim-shell-media-permission-result'; + + const createRequestId = () => { + if (window.crypto && typeof window.crypto.randomUUID === 'function') { + return window.crypto.randomUUID(); + } + return `${Date.now()}-${Math.random().toString(36).slice(2)}`; + }; + + const createPermissionError = () => { + try { + return new DOMException('Permission denied', 'NotAllowedError'); + } catch (_) { + const error = new Error('Permission denied'); + error.name = 'NotAllowedError'; + return error; + } + }; + + const requestNativePermissions = (constraints) => + new Promise((resolve) => { + const requestId = createRequestId(); + let timer; + + const cleanup = () => { + if (timer) { + window.clearTimeout(timer); + } + window.removeEventListener(resultEventName, listener); + }; + + const listener = (event) => { + const detail = event.detail; + if (!detail || detail.requestId !== requestId) { + return; + } + cleanup(); + resolve(detail); + }; + + timer = window.setTimeout(() => { + cleanup(); + resolve(undefined); + }, 30000); + + window.addEventListener(resultEventName, listener); + channel.postMessage( + JSON.stringify({ + type: 'requestMediaPermissions', + requestId, + audio: Boolean(constraints && constraints.audio), + video: Boolean(constraints && constraints.video), + }), + ); + }); + + const wrappedGetUserMedia = async (constraints) => { + const result = await requestNativePermissions(constraints || {}); + if (result && result.granted) { + return originalGetUserMedia(constraints); + } + throw createPermissionError(); + }; + + try { + Object.defineProperty(mediaDevices, 'getUserMedia', { + configurable: true, + writable: true, + value: wrappedGetUserMedia, + }); + } catch (_) { + mediaDevices.getUserMedia = wrappedGetUserMedia; + } + + window.__openimShellMediaBridgeInstalled = true; + window.dispatchEvent(new CustomEvent('openim-shell-media-bridge-ready')); +})(); +'''; const _stopWebMediaScript = r''' (() => { try { @@ -192,6 +286,7 @@ class _H5ShellPageState extends State with WidgetsBindingObserver { }, onPageFinished: (_) { unawaited(_syncShellBranding()); + unawaited(_injectShellMediaPermissionBridge()); if (mounted) { setState(() { _progress = 100; @@ -275,12 +370,10 @@ class _H5ShellPageState extends State with WidgetsBindingObserver { permissions.add(Permission.camera); } - final statuses = permissions.isEmpty - ? [] - : await Future.wait( - permissions.map((permission) => permission.request())); + final statusesByPermission = await _requestPermissions(permissions); + final statuses = statusesByPermission.values; final granted = statuses.every( - (status) => status.isGranted || status.isLimited, + _isPermissionAllowed, ); final permanentlyDenied = statuses.any((status) => status.isPermanentlyDenied); @@ -337,6 +430,10 @@ class _H5ShellPageState extends State with WidgetsBindingObserver { return _runJavaScriptSafely(script); } + Future _injectShellMediaPermissionBridge() { + return _runJavaScriptSafely(_shellMediaPermissionBridgeScript); + } + Future _stopWebMedia() { return _runJavaScriptSafely(_stopWebMediaScript); } @@ -368,7 +465,7 @@ class _H5ShellPageState extends State with WidgetsBindingObserver { Future _handleWebViewPermissionRequest( WebViewPermissionRequest request, ) async { - final permissions = []; + final permissions = {}; if (request.types.contains(WebViewPermissionResourceType.camera)) { permissions.add(Permission.camera); } @@ -376,9 +473,17 @@ class _H5ShellPageState extends State with WidgetsBindingObserver { permissions.add(Permission.microphone); } - final allowed = permissions.isEmpty || - await Future.wait(permissions.map(_requestPermission)) - .then((results) => results.every((allowed) => allowed)); + debugPrint( + '[H5Shell] WebView media permission request: ' + '${request.types.map((type) => type.name).join(', ')}', + ); + + final statusesByPermission = await _requestPermissions(permissions); + final allowed = statusesByPermission.values.every(_isPermissionAllowed); + debugPrint( + '[H5Shell] WebView media permission result: ' + '${statusesByPermission.map((permission, status) => MapEntry(permission.toString(), status.toString()))}', + ); if (allowed) { await request.grant(); @@ -387,11 +492,43 @@ class _H5ShellPageState extends State with WidgetsBindingObserver { } } - Future _requestPermission(Permission permission) async { - final status = await permission.request(); + bool _isPermissionAllowed(PermissionStatus status) { return status.isGranted || status.isLimited; } + Future> _requestPermissions( + Set permissions, + ) async { + if (permissions.isEmpty) { + return const {}; + } + + final pending = []; + final statuses = {}; + + for (final permission in permissions) { + final status = await permission.status; + if (_isPermissionAllowed(status) || + status.isPermanentlyDenied || + status.isRestricted) { + statuses[permission] = status; + } else { + pending.add(permission); + } + } + + if (pending.isNotEmpty) { + statuses.addAll(await pending.request()); + } + + return statuses; + } + + Future _requestPermission(Permission permission) async { + final statuses = await _requestPermissions({permission}); + return statuses.values.every(_isPermissionAllowed); + } + Future _handleBackNavigation() async { await _stopWebMedia(); if (await _controller.canGoBack()) {