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