diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5c024cc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +## Unreleased + +### 媒体能力 +- `web_shell_core` 新增 `pickVideo`、`captureVideo` JS Bridge 能力 +- 文件选择增加图片、视频与混合媒体 `accept` 识别,并按场景切换到更合适的系统选择器 +- 广义媒体选择默认返回原始文件参数,避免在文件上传场景里被图片压缩逻辑干扰 + +### 平台与配置 +- Android 权限声明补充音频、用户可选媒体、震动与常亮相关能力 +- 启动配置与升级配置解析增强,补充上下文安全判断和更完整的错误日志 + +### 文档与质量 +- 更新 `web_shell_core` README 的桥接能力说明 +- 调整 lint 依赖与分析配置 +- 扩充媒体相关测试覆盖,包括录像、媒体过滤和 WebView 文件选择分支 diff --git a/analysis_options.yaml b/analysis_options.yaml index f13d6ae..7e34066 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,6 +1,6 @@ # Dart 分析器配置,用于在开发阶段发现错误、警告和代码规范问题。 # 可通过 `flutter analyze` 执行静态检查。 -include: package:flutter_lints/flutter.yaml +include: package:lints/recommended.yaml linter: # 如需自定义规则,可在此处开启或关闭指定 lint。 diff --git a/packages/web_shell_core/CHANGELOG.md b/packages/web_shell_core/CHANGELOG.md index 3575eb2..242868a 100644 --- a/packages/web_shell_core/CHANGELOG.md +++ b/packages/web_shell_core/CHANGELOG.md @@ -2,10 +2,23 @@ ## Unreleased -### 文档 -- 更新多品牌生成器文档,补充 `bootstrap_config_url`、`upgrade_config_url`、`preferred_orientations` 配置说明 -- 更新核心库接入文档,说明本地默认启动配置、远程启动配置缓存、独立升级配置与方向控制 -- 更新架构文档,补充启动配置与升级配置拆分后的启动流程 +### 新增 +- JS Bridge 新增 `pickVideo` 与 `captureVideo` 能力,支持图库选视频、相机录像与多选视频 +- `pickFile`/WebView 文件选择增加图片、视频与混合媒体 `accept` 识别,广义媒体类型会优先走系统媒体选择器 +- AndroidManifest 补充音频、用户可选媒体、震动与常亮相关权限声明 + +### 变更 +- 图片与混合媒体的文件选择在广义 `accept` 场景下默认保留原始文件,不再附带压缩参数 +- 启动配置与升级配置模型补充更完整的字段说明、异常日志与上下文安全检查 +- README 更新 JS Bridge action 数量与媒体能力说明 + +### 测试 +- 扩充媒体选择、文件过滤、WebView 文件上传与录像权限相关测试覆盖 +- 补充测试钩子与平台桩实现,便于验证图片/视频/文件选择分支 + +### 工程 +- 根目录分析配置切换为 `package:lints/recommended.yaml` +- 增加 `lints` 与 `very_good_analysis` 依赖以支持新的 lint 规则与测试辅助 ## 0.0.1 diff --git a/packages/web_shell_core/README.md b/packages/web_shell_core/README.md index 7b2335d..e73a866 100644 --- a/packages/web_shell_core/README.md +++ b/packages/web_shell_core/README.md @@ -8,9 +8,9 @@ Android 平板专用 H5 壳核心库。所有品牌应用共享此库,只需 |---|---| | **WebView 引擎** | 自动兼容低版本 Android WebView,支持 texture / hybrid 双渲染模式自动切换 | | **启动恢复** | 看门狗超时检测 → 渲染模式降级 → 深度清理 → 自动重试 | -| **JS Bridge** | `window.AppShell` 协议,当前支持 12 个 Action | +| **JS Bridge** | `window.AppShell` 协议,当前支持 16 个 Action | | **旧相机兼容** | Monkey-patch `openCamera` / `captureImage` 兼容老 H5 页面 | -| **媒体服务** | 相机拍照 · 图库选图 · 文件选择 · base64 / dataUrl / uri 三种序列化格式 | +| **媒体服务** | 相机拍照/录像 · 图库选图/选视频/混合媒体选择 · 文件选择 · base64 / dataUrl / uri 三种序列化格式 | | **权限服务** | camera · microphone · location · photos · videos · storage 统一映射 | | **导航服务** | URL scheme 白名单路由,非 WebView 协议自动跳转外部应用 | | **壳层 UI** | 启动加载动画 · 错误恢复页 · 进度条 · 不支持平台兜底页 | @@ -106,6 +106,8 @@ WebShellPage.initState() - `pickImage` - `captureImage` +- `pickVideo` +- `captureVideo` - `pickFile` - `openExternal` - `requestPermissions` diff --git a/packages/web_shell_core/android/src/main/AndroidManifest.xml b/packages/web_shell_core/android/src/main/AndroidManifest.xml index e9c4757..3123406 100644 --- a/packages/web_shell_core/android/src/main/AndroidManifest.xml +++ b/packages/web_shell_core/android/src/main/AndroidManifest.xml @@ -24,8 +24,14 @@ + + + + + + reloadUpgradeRuntimeConfigAndCheckVersion( if (remoteConfig == null) { return null; } + if (!context.mounted) { + return remoteConfig; + } await ShellUpgradeService.instance.checkVersion( context, diff --git a/packages/web_shell_core/lib/src/bridge/bridge_protocol.dart b/packages/web_shell_core/lib/src/bridge/bridge_protocol.dart index d68cbe5..3977d9e 100644 --- a/packages/web_shell_core/lib/src/bridge/bridge_protocol.dart +++ b/packages/web_shell_core/lib/src/bridge/bridge_protocol.dart @@ -87,6 +87,8 @@ String _buildAppShellBridgeScript() { version: '$_appShellBridgeVersion', pickImage: (options = {}) => send('pickImage', options), captureImage: (options = {}) => send('captureImage', options), + pickVideo: (options = {}) => send('pickVideo', options), + captureVideo: (options = {}) => send('captureVideo', options), pickFile: (options = {}) => send('pickFile', options), openExternal: (url) => send('openExternal', { url }), requestPermissions: (types = []) => send('requestPermissions', { types }), diff --git a/packages/web_shell_core/lib/src/engine/recovery.dart b/packages/web_shell_core/lib/src/engine/recovery.dart index fc4e448..d9e6b21 100644 --- a/packages/web_shell_core/lib/src/engine/recovery.dart +++ b/packages/web_shell_core/lib/src/engine/recovery.dart @@ -33,10 +33,13 @@ String _parseRawErrorDescription(String description) { return '请稍后重新加载页面。'; } final lower = cleanDesc.toLowerCase(); - if (lower.contains('err_internet_disconnected') || lower.contains('err_address_unreachable') || lower.contains('err_name_not_resolved')) { + if (lower.contains('err_internet_disconnected') || + lower.contains('err_address_unreachable') || + lower.contains('err_name_not_resolved')) { return '没有成功连接到服务器,请检查网络后重试。'; } - if (lower.contains('err_connection_timed_out') || lower.contains('err_timed_out')) { + if (lower.contains('err_connection_timed_out') || + lower.contains('err_timed_out')) { return '当前网络较慢,请稍后重新加载。'; } if (lower.contains('err_cert_') || lower.contains('err_ssl_')) { diff --git a/packages/web_shell_core/lib/src/services/config_service.dart b/packages/web_shell_core/lib/src/services/config_service.dart index adc7255..aad928b 100644 --- a/packages/web_shell_core/lib/src/services/config_service.dart +++ b/packages/web_shell_core/lib/src/services/config_service.dart @@ -2,6 +2,26 @@ part of '../../core_app.dart'; /// 壳应用的启动配置模型。 class ShellBootstrapConfig { + /// 创建一份启动配置。 + const ShellBootstrapConfig({ + this.initialUrl, + this.preferredOrientations, + this.bootstrapConfigUrl, + this.upgradeConfigUrl, + }); + + /// 从 JSON 映射解析启动配置。 + factory ShellBootstrapConfig.fromJson(Map json) { + return ShellBootstrapConfig( + initialUrl: json['initialUrl']?.toString(), + preferredOrientations: _parsePreferredOrientations( + json['preferredOrientations'] ?? json['orientations'], + ), + bootstrapConfigUrl: json['bootstrapConfigUrl']?.toString(), + upgradeConfigUrl: json['upgradeConfigUrl']?.toString(), + ); + } + /// 远程或本地配置指定的首页地址。 final String? initialUrl; @@ -14,24 +34,6 @@ class ShellBootstrapConfig { /// 可选的远程升级配置地址。 final String? upgradeConfigUrl; - ShellBootstrapConfig({ - this.initialUrl, - this.preferredOrientations, - this.bootstrapConfigUrl, - this.upgradeConfigUrl, - }); - - factory ShellBootstrapConfig.fromJson(Map json) { - return ShellBootstrapConfig( - initialUrl: json['initialUrl']?.toString(), - preferredOrientations: _parsePreferredOrientations( - json['preferredOrientations'] ?? json['orientations'], - ), - bootstrapConfigUrl: json['bootstrapConfigUrl']?.toString(), - upgradeConfigUrl: json['upgradeConfigUrl']?.toString(), - ); - } - static List? _parsePreferredOrientations(Object? value) { if (value is! List) { return null; @@ -81,8 +83,8 @@ class ShellBootstrapConfigService { try { final content = await rootBundle.loadString(assetPath); return _parseConfigString(content); - } catch (e) { - debugPrint('读取 WebShell 本地启动配置失败: $e'); + } on Object catch (error, stackTrace) { + debugPrint('读取 WebShell 本地启动配置失败: $error\n$stackTrace'); return null; } } @@ -103,8 +105,11 @@ class ShellBootstrapConfigService { return _parseConfigString(content); } return _loadFromCache(); - } catch (e) { - debugPrint('获取 WebShell 启动配置失败(网络异常/超时),尝试读取缓存: $e'); + } on Object catch (error, stackTrace) { + debugPrint( + '获取 WebShell 启动配置失败(网络异常/超时),尝试读取缓存: ' + '$error\n$stackTrace', + ); return _loadFromCache(); } } @@ -118,8 +123,8 @@ class ShellBootstrapConfigService { debugPrint('成功读取到本地缓存的 WebShell 启动配置'); return _parseConfigString(cachedContent); } - } catch (e) { - debugPrint('读取 WebShell 启动配置缓存失败: $e'); + } on Object catch (error, stackTrace) { + debugPrint('读取 WebShell 启动配置缓存失败: $error\n$stackTrace'); } return null; } @@ -134,8 +139,8 @@ class ShellBootstrapConfigService { : jsonMap; return ShellBootstrapConfig.fromJson(data); } - } catch (e) { - debugPrint('解析 WebShell 启动配置异常: $e'); + } on Object catch (error, stackTrace) { + debugPrint('解析 WebShell 启动配置异常: $error\n$stackTrace'); } return null; } diff --git a/packages/web_shell_core/lib/src/services/media_service.dart b/packages/web_shell_core/lib/src/services/media_service.dart index 9b27e64..8b43fee 100644 --- a/packages/web_shell_core/lib/src/services/media_service.dart +++ b/packages/web_shell_core/lib/src/services/media_service.dart @@ -3,10 +3,31 @@ part of '../../core_app.dart'; const double _pickedImageMaxWidth = 1600; const double _pickedImageMaxHeight = 1600; const int _pickedImageQuality = 85; +const Set _imageFileExtensions = { + 'png', + 'jpg', + 'jpeg', + 'webp', + 'gif', + 'bmp', + 'heic', + 'heif', +}; +const Set _videoFileExtensions = { + 'mp4', + 'mov', + 'avi', + 'wmv', + 'mkv', + 'm4v', + 'webm', + '3gp', +}; Future _pickCameraImage( ImagePicker imagePicker, { bool showPermissionAlert = false, + bool preserveOriginal = false, WebViewController? controller, }) async { final cameraStatus = await Permission.camera.request(); @@ -18,6 +39,9 @@ Future _pickCameraImage( } try { + if (preserveOriginal) { + return await imagePicker.pickImage(source: ImageSource.camera); + } return await imagePicker.pickImage( source: ImageSource.camera, imageQuality: _pickedImageQuality, @@ -33,6 +57,111 @@ Future _pickCameraImage( } } +Future _pickCameraVideo( + ImagePicker imagePicker, { + bool showPermissionAlert = false, + WebViewController? controller, +}) async { + final statuses = await [ + Permission.camera, + Permission.microphone, + ].request(); + + final granted = statuses.values.every((status) => status.isGranted); + + if (!granted) { + if (showPermissionAlert && controller != null) { + await _showWebAlert(controller, '请先在系统设置中允许相机和麦克风权限'); + } + return null; + } + + try { + return await imagePicker.pickVideo( + source: ImageSource.camera, + ); + } on Object catch (error, stackTrace) { + debugPrint('调用相机录像失败:$error\n$stackTrace'); + if (showPermissionAlert && controller != null) { + await _showWebAlert(controller, '无法打开系统相机录像,请稍后重试'); + } + return null; + } +} + +Future> _pickGalleryImages( + ImagePicker imagePicker, { + required bool multiple, + bool preserveOriginal = false, +}) async { + if (multiple) { + if (preserveOriginal) { + return imagePicker.pickMultiImage(); + } + return imagePicker.pickMultiImage( + imageQuality: _pickedImageQuality, + maxWidth: _pickedImageMaxWidth, + maxHeight: _pickedImageMaxHeight, + ); + } + + if (preserveOriginal) { + final file = await imagePicker.pickImage(source: ImageSource.gallery); + return file == null ? const [] : [file]; + } + + final file = await imagePicker.pickImage( + source: ImageSource.gallery, + imageQuality: _pickedImageQuality, + maxWidth: _pickedImageMaxWidth, + maxHeight: _pickedImageMaxHeight, + ); + return file == null ? const [] : [file]; +} + +Future> _pickGalleryVideos( + ImagePicker imagePicker, { + required bool multiple, +}) async { + if (multiple) { + return imagePicker.pickMultiVideo(); + } + + final file = await imagePicker.pickVideo( + source: ImageSource.gallery, + ); + return file == null ? const [] : [file]; +} + +Future> _pickGalleryMedia( + ImagePicker imagePicker, { + required bool multiple, + bool preserveOriginal = false, +}) async { + if (multiple) { + if (preserveOriginal) { + return imagePicker.pickMultipleMedia(); + } + return imagePicker.pickMultipleMedia( + imageQuality: _pickedImageQuality, + maxWidth: _pickedImageMaxWidth, + maxHeight: _pickedImageMaxHeight, + ); + } + + if (preserveOriginal) { + final file = await imagePicker.pickMedia(); + return file == null ? const [] : [file]; + } + + final file = await imagePicker.pickMedia( + imageQuality: _pickedImageQuality, + maxWidth: _pickedImageMaxWidth, + maxHeight: _pickedImageMaxHeight, + ); + return file == null ? const [] : [file]; +} + Future>> _serializeXFiles( List files, { required String responseType, @@ -100,6 +229,24 @@ List _xFilesToUriStrings(List files) { return files.map((file) => Uri.file(file.path).toString()).toList(); } +List _acceptTypesFromValue(Object? value) { + final rawTypes = switch (value) { + final String stringValue => stringValue.split(','), + final Iterable iterableValue => iterableValue.expand((item) { + final stringValue = item.toString(); + return stringValue.contains(',') + ? stringValue.split(',') + : [stringValue]; + }), + _ => const [], + }; + + return rawTypes + .map((type) => type.trim().toLowerCase()) + .where((type) => type.isNotEmpty) + .toList(); +} + bool _acceptsImages(List acceptTypes) { return acceptTypes .map((type) => type.trim()) @@ -121,16 +268,123 @@ bool _acceptsOnlyImages(List acceptTypes) { bool _isImageAcceptType(String acceptType) { final value = acceptType.toLowerCase(); return value.startsWith('image/') || - const { - '.png', - '.jpg', - '.jpeg', - '.webp', - '.gif', - '.bmp', - '.heic', - '.heif', - }.contains(value); + _imageFileExtensions.map((extension) => '.$extension').contains(value); +} + +bool _acceptsVideos(List acceptTypes) { + return acceptTypes + .map((type) => type.trim()) + .where((type) => type.isNotEmpty) + .any(_isVideoAcceptType); +} + +bool _acceptsOnlyVideos(List acceptTypes) { + final normalizedTypes = acceptTypes + .map((type) => type.trim()) + .where((type) => type.isNotEmpty) + .toList(); + if (normalizedTypes.isEmpty) { + return false; + } + return normalizedTypes.every(_isVideoAcceptType); +} + +bool _isVideoAcceptType(String acceptType) { + final value = acceptType.toLowerCase(); + return value.startsWith('video/') || + _videoFileExtensions.map((extension) => '.$extension').contains(value); +} + +bool _acceptsOnlyMedia(List acceptTypes) { + final normalizedTypes = acceptTypes + .map((type) => type.trim()) + .where((type) => type.isNotEmpty) + .toList(); + if (normalizedTypes.isEmpty) { + return false; + } + return normalizedTypes.every( + (type) => _isImageAcceptType(type) || _isVideoAcceptType(type), + ); +} + +List _allowedExtensionsForAcceptTypes(List acceptTypes) { + final extensions = {}; + for (final acceptType in acceptTypes) { + extensions.addAll(_extensionsForAcceptType(acceptType)); + } + return extensions.toList()..sort(); +} + +List _extensionsForAcceptType(String acceptType) { + final value = acceptType.trim().toLowerCase(); + if (value.isEmpty) { + return const []; + } + if (value.startsWith('.')) { + return [value.substring(1)]; + } + return switch (value) { + 'image/*' => _imageFileExtensions.toList(), + 'video/*' => _videoFileExtensions.toList(), + 'image/png' => const ['png'], + 'image/jpeg' => const ['jpg', 'jpeg'], + 'image/webp' => const ['webp'], + 'image/gif' => const ['gif'], + 'image/bmp' => const ['bmp'], + 'image/heic' => const ['heic'], + 'image/heif' => const ['heif'], + 'video/mp4' => const ['mp4'], + 'video/quicktime' => const ['mov'], + 'video/x-msvideo' => const ['avi'], + 'video/x-ms-wmv' => const ['wmv'], + 'video/x-matroska' => const ['mkv'], + 'video/x-m4v' => const ['m4v'], + 'video/webm' => const ['webm'], + 'video/3gpp' => const ['3gp'], + 'application/pdf' => const ['pdf'], + 'text/plain' => const ['txt'], + 'application/vnd.android.package-archive' => const ['apk'], + _ => const [], + }; +} + +({FileType type, List? allowedExtensions}) +_filePickerFilterForAcceptTypes( + List acceptTypes, +) { + final normalizedTypes = _acceptTypesFromValue(acceptTypes); + if (normalizedTypes.isEmpty) { + return (type: FileType.any, allowedExtensions: null); + } + if (!_shouldUseMediaPickerForFileSelection(normalizedTypes)) { + final allowedExtensions = _allowedExtensionsForAcceptTypes(normalizedTypes); + if (allowedExtensions.isNotEmpty) { + return (type: FileType.custom, allowedExtensions: allowedExtensions); + } + return (type: FileType.any, allowedExtensions: null); + } + if (_acceptsOnlyImages(normalizedTypes)) { + return (type: FileType.image, allowedExtensions: null); + } + if (_acceptsOnlyVideos(normalizedTypes)) { + return (type: FileType.video, allowedExtensions: null); + } + if (_acceptsOnlyMedia(normalizedTypes)) { + return (type: FileType.media, allowedExtensions: null); + } + + return (type: FileType.any, allowedExtensions: null); +} + +bool _shouldUseMediaPickerForFileSelection(List acceptTypes) { + final normalizedTypes = _acceptTypesFromValue(acceptTypes); + if (normalizedTypes.isEmpty) { + return false; + } + return normalizedTypes.every( + (type) => type == 'image/*' || type == 'video/*', + ); } String _guessMimeType(String fileName) { @@ -156,6 +410,30 @@ String _guessMimeType(String fileName) { if (lower.endsWith('.heif')) { return 'image/heif'; } + if (lower.endsWith('.mp4')) { + return 'video/mp4'; + } + if (lower.endsWith('.mov')) { + return 'video/quicktime'; + } + if (lower.endsWith('.avi')) { + return 'video/x-msvideo'; + } + if (lower.endsWith('.wmv')) { + return 'video/x-ms-wmv'; + } + if (lower.endsWith('.mkv')) { + return 'video/x-matroska'; + } + if (lower.endsWith('.m4v')) { + return 'video/x-m4v'; + } + if (lower.endsWith('.webm')) { + return 'video/webm'; + } + if (lower.endsWith('.3gp')) { + return 'video/3gpp'; + } if (lower.endsWith('.pdf')) { return 'application/pdf'; } @@ -176,6 +454,155 @@ Future _showWebAlert(WebViewController controller, String message) async { } } +Future?> _pickAcceptedMediaFiles( + ImagePicker imagePicker, { + required List acceptTypes, + required bool capture, + required bool multiple, + WebViewController? controller, +}) async { + if (!_shouldUseMediaPickerForFileSelection(acceptTypes)) { + return null; + } + + if (capture) { + if (_acceptsOnlyImages(acceptTypes)) { + return [ + if (await _pickCameraImage( + imagePicker, + showPermissionAlert: true, + preserveOriginal: true, + controller: controller, + ) + case final XFile file) + file, + ]; + } + if (_acceptsOnlyVideos(acceptTypes)) { + return [ + if (await _pickCameraVideo( + imagePicker, + showPermissionAlert: true, + controller: controller, + ) + case final XFile file) + file, + ]; + } + return null; + } + + if (_acceptsOnlyImages(acceptTypes)) { + return _pickGalleryImages( + imagePicker, + multiple: multiple, + preserveOriginal: true, + ); + } + if (_acceptsOnlyVideos(acceptTypes)) { + return _pickGalleryVideos( + imagePicker, + multiple: multiple, + ); + } + if (_acceptsOnlyMedia(acceptTypes)) { + return _pickGalleryMedia( + imagePicker, + multiple: multiple, + preserveOriginal: true, + ); + } + return null; +} + +Future _pickFilesFromBridgePayload( + Map payload, { + required ImagePicker imagePicker, + WebViewController? controller, +}) async { + final multiple = _boolValue(payload['multiple']); + final responseType = (payload['responseType'] ?? 'uri').toString(); + final includeBinary = responseType == 'dataUrl' || responseType == 'base64'; + final acceptTypes = _acceptTypesFromValue( + payload['acceptTypes'] ?? payload['accept'], + ); + final capture = _boolValue(payload['capture']); + + final mediaFiles = await _pickAcceptedMediaFiles( + imagePicker, + acceptTypes: acceptTypes, + capture: capture, + multiple: multiple, + controller: controller, + ); + if (mediaFiles != null) { + final serialized = await _serializeXFiles( + mediaFiles, + responseType: responseType, + ); + return multiple ? serialized : serialized.firstOrNull; + } + + final pickerFilter = _filePickerFilterForAcceptTypes(acceptTypes); + final result = await FilePicker.platform.pickFiles( + allowMultiple: multiple, + type: pickerFilter.type, + allowedExtensions: pickerFilter.allowedExtensions, + withData: includeBinary, + ); + + if (result == null) { + return multiple ? >[] : null; + } + + final serialized = await _serializePlatformFiles( + result.files, + responseType: responseType, + ); + return multiple ? serialized : serialized.firstOrNull; +} + +Future> _selectFilesForWebView( + FileSelectorParams params, { + required ImagePicker imagePicker, + WebViewController? controller, +}) async { + if (params.mode == FileSelectorMode.save) { + return []; + } + + final acceptTypes = _acceptTypesFromValue(params.acceptTypes); + final multiple = params.mode == FileSelectorMode.openMultiple; + + final mediaFiles = await _pickAcceptedMediaFiles( + imagePicker, + acceptTypes: acceptTypes, + capture: params.isCaptureEnabled, + multiple: multiple, + controller: controller, + ); + if (mediaFiles != null) { + return _xFilesToUriStrings(mediaFiles); + } + + final pickerFilter = _filePickerFilterForAcceptTypes(acceptTypes); + final result = await FilePicker.platform.pickFiles( + allowMultiple: multiple, + type: pickerFilter.type, + allowedExtensions: pickerFilter.allowedExtensions, + ); + + if (result == null) { + return []; + } + + return result.files + .map((file) => file.path) + .whereType() + .map((path) => Uri.file(path).toString()) + .toList(); +} + bool _boolValue(Object? value, {bool defaultValue = false}) { return switch (value) { final bool boolValue => boolValue, diff --git a/packages/web_shell_core/lib/src/services/upgrade_service.dart b/packages/web_shell_core/lib/src/services/upgrade_service.dart index 5526ab9..d261b01 100644 --- a/packages/web_shell_core/lib/src/services/upgrade_service.dart +++ b/packages/web_shell_core/lib/src/services/upgrade_service.dart @@ -2,13 +2,7 @@ part of '../../core_app.dart'; /// 升级明细配置,对应升级接口中的 `upgrade` 字段。 class ShellUpgradeReleaseConfig { - final String? versionName; - final int? version; - final int? isForce; - final String? remark; - final String? filePath; - final int? fileSize; - + /// 创建一份升级明细配置。 const ShellUpgradeReleaseConfig({ this.versionName, this.version, @@ -18,6 +12,7 @@ class ShellUpgradeReleaseConfig { this.fileSize, }); + /// 从 JSON 映射解析升级明细配置。 factory ShellUpgradeReleaseConfig.fromJson(Map json) { return ShellUpgradeReleaseConfig( versionName: _readShellUpgradeString(json['versionName']), @@ -29,6 +24,24 @@ class ShellUpgradeReleaseConfig { ); } + /// 升级版本名称。 + final String? versionName; + + /// 升级版本号。 + final int? version; + + /// 是否强制升级。 + final int? isForce; + + /// 升级说明。 + final String? remark; + + /// 安装包下载地址。 + final String? filePath; + + /// 安装包大小,单位为 KB。 + final int? fileSize; + /// 兼容已经解码好的 Map,或 `data.config.upgrade` 这种 JSON 字符串。 static ShellUpgradeReleaseConfig? fromDynamic(dynamic value) { if (value == null) { @@ -61,8 +74,8 @@ class ShellUpgradeReleaseConfig { Map.from(decoded), ); } - } catch (e) { - debugPrint('解析 WebShell 升级配置 upgrade 字段异常: $e'); + } on Object catch (error, stackTrace) { + debugPrint('解析 WebShell 升级配置 upgrade 字段异常: $error\n$stackTrace'); } return null; } @@ -75,29 +88,7 @@ class ShellUpgradeReleaseConfig { /// 2. 升级与启动配置都放在 `data.config` 这个 JSON 字符串中; /// 3. `data.config` 解开后,内部再拆为 `upgrade` 和 `config` 两部分。 class ShellUpgradeConfig { - final bool? success; - final String? responseCode; - final String? msg; - final int? id; - final int? upgradeFileId; - final int? installFileId; - final bool? isLatest; - final int? appId; - final String? releaseCode; - final String? releaseTime; - final bool? releaseStatus; - final String? upgradeOssUrl; - final bool? isAllowRollback; - final String? rawConfig; - final String? configDescribe; - final int? describeType; - final int? environment; - final String? createdTime; - final ShellUpgradeReleaseConfig? upgrade; - final ShellBootstrapConfig? config; - final String? installOssUrl; - final String? describe; - + /// 创建一份远程升级配置。 const ShellUpgradeConfig({ this.success, this.responseCode, @@ -123,36 +114,9 @@ class ShellUpgradeConfig { this.config, }); - String? get versionName => upgrade?.versionName; - - int? get version => upgrade?.version; - - int? get isForce => upgrade?.isForce; - - String? get remark => describe ?? upgrade?.remark; - - String? get filePath => - _readShellUpgradeString(installOssUrl ?? upgrade?.filePath); - - int? get fileSize => upgrade?.fileSize; - - bool get hasStructuredPayload => upgrade != null || config != null; - - /// 仅使用远端 `upgrade.version` 与本地 buildNumber 做整数比较。 + /// 从接口响应 JSON 中解析升级配置。 /// - /// 返回 `true` 表示远端版本更高,需要触发升级提示; - /// 如果远端未下发版本号,则直接视为无需升级。 - bool shouldOfferUpgrade({required int localVersion}) { - final remoteVersion = upgrade?.version; - if (remoteVersion == null) { - return false; - } - return remoteVersion > localVersion; - } - - /// 解析接口响应,并从 `data.config` 中拆出: - /// - `upgrade`:升级弹窗所需信息 - /// - `config`:启动期运行时配置,如 initialUrl / preferredOrientations + /// 会同时拆出升级信息和运行时启动配置。 factory ShellUpgradeConfig.fromJson(Map json) { final data = json['data']; final dataMap = data is Map @@ -196,6 +160,106 @@ class ShellUpgradeConfig { createdTime: _readShellUpgradeString(dataMap['createdTime']), ); } + + /// 接口返回的 success 标记。 + final bool? success; + + /// 接口返回码。 + final String? responseCode; + + /// 接口返回消息。 + final String? msg; + + /// 发布记录 ID。 + final int? id; + + /// 升级包文件 ID。 + final int? upgradeFileId; + + /// 安装包文件 ID。 + final int? installFileId; + + /// 服务端标记的是否已是最新版本。 + final bool? isLatest; + + /// 应用 ID。 + final int? appId; + + /// 发布编号。 + final String? releaseCode; + + /// 发布时间。 + final String? releaseTime; + + /// 发布状态。 + final bool? releaseStatus; + + /// 升级资源地址。 + final String? upgradeOssUrl; + + /// 是否允许回滚。 + final bool? isAllowRollback; + + /// 原始 `data.config` 字符串。 + final String? rawConfig; + + /// 服务端下发的配置描述。 + final String? configDescribe; + + /// 描述类型。 + final int? describeType; + + /// 所属环境标识。 + final int? environment; + + /// 创建时间。 + final String? createdTime; + + /// 结构化升级信息。 + final ShellUpgradeReleaseConfig? upgrade; + + /// 结构化运行时启动配置。 + final ShellBootstrapConfig? config; + + /// 安装包 OSS 地址。 + final String? installOssUrl; + + /// 服务端直接下发的展示文案。 + final String? describe; + + /// 供升级弹窗展示的版本名称。 + String? get versionName => upgrade?.versionName; + + /// 供升级比较使用的版本号。 + int? get version => upgrade?.version; + + /// 供升级弹窗识别的强更标记。 + int? get isForce => upgrade?.isForce; + + /// 优先取升级明细说明,顶层描述仅作兜底文案。 + String? get remark => upgrade?.remark ?? describe; + + /// 优先取安装地址,否则回退到升级明细中的文件地址。 + String? get filePath => + _readShellUpgradeString(installOssUrl ?? upgrade?.filePath); + + /// 安装包大小,单位为 KB。 + int? get fileSize => upgrade?.fileSize; + + /// 是否至少解析出一份结构化升级或启动配置。 + bool get hasStructuredPayload => upgrade != null || config != null; + + /// 仅使用远端 `upgrade.version` 与本地 buildNumber 做整数比较。 + /// + /// 返回 `true` 表示远端版本更高,需要触发升级提示; + /// 如果远端未下发版本号,则直接视为无需升级。 + bool shouldOfferUpgrade({required int localVersion}) { + final remoteVersion = upgrade?.version; + if (remoteVersion == null) { + return false; + } + return remoteVersion > localVersion; + } } String? _readShellUpgradeString(dynamic value) { @@ -254,17 +318,19 @@ Map? _readShellUpgradeEmbeddedConfig(String? value) { if (decoded is Map) { return Map.from(decoded); } - } catch (e) { - debugPrint('解析 WebShell 升级配置 data.config 异常: $e'); + } on Object catch (error, stackTrace) { + debugPrint('解析 WebShell 升级配置 data.config 异常: $error\n$stackTrace'); } return null; } /// 管理壳应用的升级逻辑,依赖 yx_app_upgrade_flutter。 class ShellUpgradeService { - static final ShellUpgradeService instance = ShellUpgradeService._(); ShellUpgradeService._(); + /// 单例入口。 + static final ShellUpgradeService instance = ShellUpgradeService._(); + String? _configUrl; Future Function()? _localBuildNumberResolver; @@ -293,6 +359,9 @@ class ShellUpgradeService { } final remoteConfig = await _fetchConfig(configUrl); + if (!context.mounted) { + return; + } if (remoteConfig == null) { if (showNoUpdateToast) { _showToastFromBridge(context, {'message': '未获取到版本配置'}); @@ -300,7 +369,7 @@ class ShellUpgradeService { return; } - UpgradeAuxiliaryUtils.instance.initiateVersionCheck( + await UpgradeAuxiliaryUtils.instance.initiateVersionCheck( // coverage:ignore-line context, showNoUpdateToast: showNoUpdateToast, @@ -329,7 +398,8 @@ class ShellUpgradeService { } if (!remoteConfig.shouldOfferUpgrade(localVersion: localBuildNumber)) { debugPrint( - 'WebShell 远端版本(${remoteConfig.version}) <= 本地版本($localBuildNumber),跳过升级提示', + 'WebShell 远端版本(${remoteConfig.version}) ' + '<= 本地版本($localBuildNumber),跳过升级提示', ); return null; } @@ -347,14 +417,20 @@ class ShellUpgradeService { try { final appInfo = await AppUpgradePlugin().getAppInfo(); return int.tryParse((appInfo['buildNumber'] ?? '').trim()); - } catch (e) { - debugPrint('获取 WebShell 本地版本号异常: $e'); + } on Object catch (error, stackTrace) { + debugPrint('获取 WebShell 本地版本号异常: $error\n$stackTrace'); return null; } } + /// 仅供测试注入本地版本号解析逻辑。 @visibleForTesting - void debugSetLocalBuildNumberResolver(Future Function()? resolver) { + Future Function()? get debugLocalBuildNumberResolver => + _localBuildNumberResolver; + + /// 仅供测试注入本地版本号解析逻辑。 + @visibleForTesting + set debugLocalBuildNumberResolver(Future Function()? resolver) { _localBuildNumberResolver = resolver; } @@ -373,8 +449,10 @@ class ShellUpgradeService { } final content = utf8.decode(response.bodyBytes); return _parseConfigString(content); - } catch (e) { - debugPrint('获取 WebShell 升级配置失败(网络异常/超时): $e'); + } on Object catch (error, stackTrace) { + debugPrint( + '获取 WebShell 升级配置失败(网络异常/超时): $error\n$stackTrace', + ); return null; } } @@ -396,8 +474,8 @@ class ShellUpgradeService { ); return config.hasStructuredPayload ? config : null; } - } catch (e) { - debugPrint('解析 WebShell 升级配置异常: $e'); + } on Object catch (error, stackTrace) { + debugPrint('解析 WebShell 升级配置异常: $error\n$stackTrace'); } return null; } diff --git a/packages/web_shell_core/lib/src/testing/test_hooks.dart b/packages/web_shell_core/lib/src/testing/test_hooks.dart index 1c54888..0b471d8 100644 --- a/packages/web_shell_core/lib/src/testing/test_hooks.dart +++ b/packages/web_shell_core/lib/src/testing/test_hooks.dart @@ -93,8 +93,12 @@ class ShellCoreTestHooks { } /// 为测试注入本地构建号解析逻辑。 - void setLocalBuildNumberResolver(Future Function()? resolver) { - ShellUpgradeService.instance.debugSetLocalBuildNumberResolver(resolver); + Future Function()? get localBuildNumberResolver => + ShellUpgradeService.instance.debugLocalBuildNumberResolver; + + /// 为测试注入本地构建号解析逻辑。 + set localBuildNumberResolver(Future Function()? resolver) { + ShellUpgradeService.instance.debugLocalBuildNumberResolver = resolver; } /// 为测试设置升级配置地址。 @@ -209,6 +213,19 @@ class ShellCoreTestHooks { ); } + /// 执行桥接与文件选择器共用的相机录像流程。 + Future pickCameraVideo( + ImagePicker imagePicker, { + bool showPermissionAlert = false, + WebViewController? controller, + }) { + return _pickCameraVideo( + imagePicker, + showPermissionAlert: showPermissionAlert, + controller: controller, + ); + } + /// 将选中的文件转换成 `file://` URI 字符串。 List xFilesToUriStrings(List files) { return _xFilesToUriStrings(files); @@ -225,6 +242,83 @@ class ShellCoreTestHooks { /// 判断单个 accept 标记是否应视为图片类型。 bool isImageAcceptType(String acceptType) => _isImageAcceptType(acceptType); + /// 判断传入的 accept 列表是否允许视频。 + bool acceptsVideos(List acceptTypes) => _acceptsVideos(acceptTypes); + + /// 判断传入的 accept 列表是否只允许视频。 + bool acceptsOnlyVideos(List acceptTypes) { + return _acceptsOnlyVideos(acceptTypes); + } + + /// 判断传入的 accept 列表是否只允许图片或视频。 + bool acceptsOnlyMedia(List acceptTypes) { + return _acceptsOnlyMedia(acceptTypes); + } + + /// 判断单个 accept 标记是否应视为视频类型。 + bool isVideoAcceptType(String acceptType) => _isVideoAcceptType(acceptType); + + /// 将桥接 payload 中的 accept 值归一化为列表。 + List acceptTypesFromValue(Object? value) { + return _acceptTypesFromValue(value); + } + + /// 将 accept 列表映射为 FilePicker 过滤配置。 + ({FileType type, List? allowedExtensions}) + filePickerFilterForAcceptTypes( + List acceptTypes, + ) { + return _filePickerFilterForAcceptTypes(acceptTypes); + } + + /// 判断通用文件选择是否适合走媒体选择器快捷路径。 + bool shouldUseMediaPickerForFileSelection(List acceptTypes) { + return _shouldUseMediaPickerForFileSelection(acceptTypes); + } + + /// 执行通用文件选择的媒体快捷路径判断。 + Future?> pickAcceptedMediaFilesForFileSelection( + ImagePicker imagePicker, { + required List acceptTypes, + required bool capture, + required bool multiple, + WebViewController? controller, + }) { + return _pickAcceptedMediaFiles( + imagePicker, + acceptTypes: acceptTypes, + capture: capture, + multiple: multiple, + controller: controller, + ); + } + + /// 执行桥接 `pickFile` 的文件选择逻辑。 + Future pickFilesFromBridgePayload( + Map payload, { + required ImagePicker imagePicker, + WebViewController? controller, + }) { + return _pickFilesFromBridgePayload( + payload, + imagePicker: imagePicker, + controller: controller, + ); + } + + /// 执行 WebView 文件选择逻辑。 + Future> selectFilesForWebView( + FileSelectorParams params, { + required ImagePicker imagePicker, + WebViewController? controller, + }) { + return _selectFilesForWebView( + params, + imagePicker: imagePicker, + controller: controller, + ); + } + /// 根据文件名推断 MIME 类型。 String guessMimeType(String fileName) => _guessMimeType(fileName); diff --git a/packages/web_shell_core/lib/src/ui/shell_page.dart b/packages/web_shell_core/lib/src/ui/shell_page.dart index 08025e9..ecd2a81 100644 --- a/packages/web_shell_core/lib/src/ui/shell_page.dart +++ b/packages/web_shell_core/lib/src/ui/shell_page.dart @@ -730,6 +730,16 @@ class _WebShellPageState extends State source: ImageSource.camera, payload: payload, ); + case 'pickVideo': + data = await _pickVideosFromBridge( + source: ImageSource.gallery, + payload: payload, + ); + case 'captureVideo': + data = await _pickVideosFromBridge( + source: ImageSource.camera, + payload: payload, + ); case 'pickFile': data = await _pickFilesFromBridge(payload); case 'openExternal': @@ -747,10 +757,7 @@ class _WebShellPageState extends State data = _runtimeConfigSnapshotFromBridge( await reloadUpgradeRuntimeConfigAndCheckVersion( context, - showNoUpdateToast: _boolValue( - payload['showNoUpdateToast'], - defaultValue: false, - ), + showNoUpdateToast: _boolValue(payload['showNoUpdateToast']), ), ); case 'goBack': @@ -804,33 +811,50 @@ class _WebShellPageState extends State source == ImageSource.gallery && _boolValue(payload['multiple']); final responseType = (payload['responseType'] ?? 'dataUrl').toString(); - var files = []; - if (source == ImageSource.camera) { - final file = await _pickCameraImage( - _imagePicker, - showPermissionAlert: true, - controller: _controller, - ); - if (file != null) { - files = [file]; - } - } else if (multiple) { - files = await _imagePicker.pickMultiImage( - imageQuality: _pickedImageQuality, - maxWidth: _pickedImageMaxWidth, - maxHeight: _pickedImageMaxHeight, - ); - } else { - final file = await _imagePicker.pickImage( - source: ImageSource.gallery, - imageQuality: _pickedImageQuality, - maxWidth: _pickedImageMaxWidth, - maxHeight: _pickedImageMaxHeight, - ); - if (file != null) { - files = [file]; - } - } + final files = source == ImageSource.camera + ? [ + if (await _pickCameraImage( + _imagePicker, + showPermissionAlert: true, + controller: _controller, + ) + case final XFile file) + file, + ] + : await _pickGalleryImages( + _imagePicker, + multiple: multiple, + ); + + final serialized = await _serializeXFiles( + files, + responseType: responseType, + ); + return multiple ? serialized : serialized.firstOrNull; + } + + Future _pickVideosFromBridge({ + required ImageSource source, + required Map payload, + }) async { + final multiple = + source == ImageSource.gallery && _boolValue(payload['multiple']); + final responseType = (payload['responseType'] ?? 'uri').toString(); + + final files = source == ImageSource.camera + ? [ + if (await _pickCameraVideo( + _imagePicker, + showPermissionAlert: true, + controller: _controller, + ) + case final XFile file) + file, + ] + : await _pickGalleryVideos( + _imagePicker, + multiple: multiple, + ); final serialized = await _serializeXFiles( files, @@ -840,25 +864,11 @@ class _WebShellPageState extends State } Future _pickFilesFromBridge(Map payload) async { - final responseType = (payload['responseType'] ?? 'uri').toString(); - final includeBinary = responseType == 'dataUrl' || responseType == 'base64'; - - final result = await FilePicker.platform.pickFiles( - allowMultiple: _boolValue(payload['multiple']), - withData: includeBinary, + return _pickFilesFromBridgePayload( + payload, + imagePicker: _imagePicker, + controller: _controller, ); - - if (result == null) { - return _boolValue(payload['multiple']) ? >[] : null; - } - - final serialized = await _serializePlatformFiles( - result.files, - responseType: responseType, - ); - return _boolValue(payload['multiple']) - ? serialized - : serialized.firstOrNull; } Future _openExternalFromBridge(Map payload) async { @@ -921,50 +931,11 @@ class _WebShellPageState extends State } try { - final acceptsImgs = _acceptsImages(params.acceptTypes); - final imagesOnly = _acceptsOnlyImages(params.acceptTypes); - - if (params.isCaptureEnabled && acceptsImgs) { - final capturedImage = await _pickCameraImage(_imagePicker); - return _xFilesToUriStrings( - capturedImage == null ? const [] : [capturedImage], - ); - } - - if (imagesOnly) { - if (params.mode == FileSelectorMode.openMultiple) { - final images = await _imagePicker.pickMultiImage( - imageQuality: _pickedImageQuality, - maxWidth: _pickedImageMaxWidth, - maxHeight: _pickedImageMaxHeight, - ); - return _xFilesToUriStrings(images); - } - - final image = await _imagePicker.pickImage( - source: ImageSource.gallery, - imageQuality: _pickedImageQuality, - maxWidth: _pickedImageMaxWidth, - maxHeight: _pickedImageMaxHeight, - ); - return _xFilesToUriStrings( - image == null ? const [] : [image], - ); - } - - final result = await FilePicker.platform.pickFiles( - allowMultiple: params.mode == FileSelectorMode.openMultiple, + return _selectFilesForWebView( + params, + imagePicker: _imagePicker, + controller: _controller, ); - - if (result == null) { - return []; - } - - return result.files - .map((file) => file.path) - .whereType() - .map((path) => Uri.file(path).toString()) - .toList(); } on Object catch (error, stackTrace) { debugPrint('处理文件选择失败:$error\n$stackTrace'); return []; diff --git a/packages/web_shell_core/pubspec.yaml b/packages/web_shell_core/pubspec.yaml index 296fe5e..caea2c5 100644 --- a/packages/web_shell_core/pubspec.yaml +++ b/packages/web_shell_core/pubspec.yaml @@ -31,6 +31,7 @@ dev_dependencies: flutter_test: sdk: flutter image_picker_platform_interface: ^2.11.1 + shared_preferences_platform_interface: ^2.4.1 url_launcher_platform_interface: ^2.3.2 very_good_analysis: ^10.2.0 webview_flutter_platform_interface: ^2.14.0 diff --git a/packages/web_shell_core/test/web_shell_core_test.dart b/packages/web_shell_core/test/web_shell_core_test.dart index bfdd8d3..70641a2 100644 --- a/packages/web_shell_core/test/web_shell_core_test.dart +++ b/packages/web_shell_core/test/web_shell_core_test.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'dart:typed_data'; + import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -16,6 +16,7 @@ import 'package:url_launcher_platform_interface/link.dart'; import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; import 'package:web_shell_core/core_app.dart'; import 'package:webview_flutter/webview_flutter.dart'; +import 'package:webview_flutter_android/webview_flutter_android.dart'; import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; const MethodChannel _platformChannel = SystemChannels.platform; @@ -49,9 +50,9 @@ Future _startJsonServer( 'application/json; charset=utf-8', ); request.response.write(body); - } catch (error) { - request.response.statusCode = error is int - ? error + } on Object catch (error) { + request.response.statusCode = error is _HttpStatusException + ? error.statusCode : HttpStatus.internalServerError; } finally { await request.response.close(); @@ -70,6 +71,7 @@ void main() { late _FakeWebViewPlatform fakeWebViewPlatform; late _FakeImagePickerPlatform fakeImagePickerPlatform; + late _FakeFilePicker fakeFilePicker; late _FakeUrlLauncherPlatform fakeUrlLauncherPlatform; late List platformCalls; late List platformMethodCalls; @@ -84,10 +86,12 @@ void main() { cameraPermissionGranted = true; fakeWebViewPlatform = _FakeWebViewPlatform(); fakeImagePickerPlatform = _FakeImagePickerPlatform(); + fakeFilePicker = _FakeFilePicker(); fakeUrlLauncherPlatform = _FakeUrlLauncherPlatform(); WebViewPlatform.instance = fakeWebViewPlatform; ImagePickerPlatform.instance = fakeImagePickerPlatform; + FilePicker.platform = fakeFilePicker; UrlLauncherPlatform.instance = fakeUrlLauncherPlatform; shellCoreTestHooks.initializeEnvironment(_testEnvironment); @@ -439,6 +443,8 @@ void main() { expect(script, contains('window.AppShell')); expect(script, contains('pickImage')); expect(script, contains('captureImage')); + expect(script, contains('pickVideo')); + expect(script, contains('captureVideo')); expect(script, contains('pickFile')); expect(script, contains('requestPermissions')); expect(script, contains(shellCoreTestHooks.legacyCameraCompatScript)); @@ -686,6 +692,215 @@ void main() { expect(platformController.javaScriptCalls.single, contains('无法打开系统相机')); }); + test('录像权限通过时会调用 image picker', () async { + final tempDirectory = await Directory.systemTemp.createTemp('video-ok'); + final file = File('${tempDirectory.path}/camera.mp4') + ..writeAsBytesSync([1, 2, 3]); + addTearDown(() async { + if (tempDirectory.existsSync()) { + await tempDirectory.delete(recursive: true); + } + }); + fakeImagePickerPlatform.nextVideo = XFile(file.path, name: 'camera.mp4'); + + final result = await shellCoreTestHooks.pickCameraVideo(ImagePicker()); + + expect(result, isNotNull); + expect(result!.path, file.path); + expect(fakeImagePickerPlatform.lastSource, ImageSource.camera); + expect(fakeImagePickerPlatform.lastMethod, 'getVideo'); + }); + + test('录像权限拒绝时返回 null 并可提示 Web alert', () async { + cameraPermissionGranted = false; + final platformController = _FakePlatformWebViewController( + const PlatformWebViewControllerCreationParams(), + ); + final controller = WebViewController.fromPlatform(platformController); + + final result = await shellCoreTestHooks.pickCameraVideo( + ImagePicker(), + showPermissionAlert: true, + controller: controller, + ); + + expect(result, isNull); + expect( + platformController.javaScriptCalls.single, + contains('请先在系统设置中允许相机和麦克风权限'), + ); + }); + + test('录像打开失败时返回 null 并可提示 Web alert', () async { + fakeImagePickerPlatform.shouldThrow = true; + final platformController = _FakePlatformWebViewController( + const PlatformWebViewControllerCreationParams(), + ); + final controller = WebViewController.fromPlatform(platformController); + + final result = await shellCoreTestHooks.pickCameraVideo( + ImagePicker(), + showPermissionAlert: true, + controller: controller, + ); + + expect(result, isNull); + expect(platformController.javaScriptCalls.single, contains('无法打开系统相机录像')); + }); + + test('视频 accept、过滤配置与 MIME 判断正确', () { + expect(shellCoreTestHooks.acceptsVideos([' video/* ']), isTrue); + expect( + shellCoreTestHooks.acceptsOnlyVideos(['.mp4', '.mov']), + isTrue, + ); + expect( + shellCoreTestHooks.acceptsOnlyMedia(['image/*', 'video/*']), + isTrue, + ); + expect(shellCoreTestHooks.isVideoAcceptType('video/webm'), isTrue); + expect(shellCoreTestHooks.isVideoAcceptType('.3gp'), isTrue); + expect(shellCoreTestHooks.guessMimeType('lesson.mp4'), 'video/mp4'); + expect(shellCoreTestHooks.guessMimeType('lesson.mov'), 'video/quicktime'); + expect(shellCoreTestHooks.guessMimeType('lesson.webm'), 'video/webm'); + + final parsedAcceptTypes = shellCoreTestHooks.acceptTypesFromValue( + ' image/* , video/* , .pdf ', + ); + expect(parsedAcceptTypes, ['image/*', 'video/*', '.pdf']); + + final mixedFilter = shellCoreTestHooks.filePickerFilterForAcceptTypes( + ['image/*', '.pdf'], + ); + expect(mixedFilter.type, FileType.custom); + expect( + mixedFilter.allowedExtensions, + containsAll(['png', 'jpg', 'jpeg', 'pdf']), + ); + + final mediaFilter = shellCoreTestHooks.filePickerFilterForAcceptTypes( + ['image/*', 'video/*'], + ); + expect(mediaFilter.type, FileType.media); + expect(mediaFilter.allowedExtensions, isNull); + }); + + test('通用文件选择的广义图片 accept 会保留原始文件参数', () async { + fakeImagePickerPlatform.nextImage = XFile( + '/tmp/source.png', + name: 'source.png', + ); + + final result = await shellCoreTestHooks + .pickAcceptedMediaFilesForFileSelection( + ImagePicker(), + acceptTypes: ['image/*'], + capture: false, + multiple: false, + ); + + expect(result, isNotNull); + expect(fakeImagePickerPlatform.lastMethod, 'getImageFromSource'); + expect(fakeImagePickerPlatform.lastImageOptions?.imageQuality, isNull); + expect(fakeImagePickerPlatform.lastImageOptions?.maxWidth, isNull); + expect(fakeImagePickerPlatform.lastImageOptions?.maxHeight, isNull); + }); + + test('通用文件选择的广义混合媒体 accept 会保留原始文件参数', () async { + fakeImagePickerPlatform.nextMedia = [ + XFile('/tmp/source.mov', name: 'source.mov'), + ]; + + final result = await shellCoreTestHooks + .pickAcceptedMediaFilesForFileSelection( + ImagePicker(), + acceptTypes: ['image/*', 'video/*'], + capture: false, + multiple: false, + ); + + expect(result, isNotNull); + expect(fakeImagePickerPlatform.lastMethod, 'getMedia'); + expect( + fakeImagePickerPlatform.lastMediaOptions?.imageOptions.imageQuality, + isNull, + ); + expect( + fakeImagePickerPlatform.lastMediaOptions?.imageOptions.maxWidth, + isNull, + ); + expect( + fakeImagePickerPlatform.lastMediaOptions?.imageOptions.maxHeight, + isNull, + ); + }); + + test('严格图片 accept 不会误走媒体选择器快捷路径', () async { + final result = await shellCoreTestHooks + .pickAcceptedMediaFilesForFileSelection( + ImagePicker(), + acceptTypes: ['image/png'], + capture: false, + multiple: false, + ); + + expect(result, isNull); + expect( + shellCoreTestHooks.shouldUseMediaPickerForFileSelection( + ['image/png'], + ), + isFalse, + ); + expect(fakeImagePickerPlatform.lastMethod, isNull); + }); + + test('pickFile 遇到严格图片 accept 会走 FilePicker 自定义过滤', () async { + fakeFilePicker.nextResult = FilePickerResult([ + PlatformFile( + name: 'source.png', + path: '/tmp/source.png', + size: 10, + ), + ]); + + final result = await shellCoreTestHooks.pickFilesFromBridgePayload( + { + 'accept': 'image/png', + 'responseType': 'uri', + }, + imagePicker: ImagePicker(), + ); + + expect(result, isA>()); + expect(fakeFilePicker.lastType, FileType.custom); + expect(fakeFilePicker.lastAllowedExtensions, ['png']); + expect(fakeImagePickerPlatform.lastMethod, isNull); + }); + + test('WebView 文件选择遇到严格图片 accept 会走 FilePicker 自定义过滤', () async { + fakeFilePicker.nextResult = FilePickerResult([ + PlatformFile( + name: 'source.png', + path: '/tmp/source.png', + size: 10, + ), + ]); + + final result = await shellCoreTestHooks.selectFilesForWebView( + const FileSelectorParams( + isCaptureEnabled: false, + mode: FileSelectorMode.open, + acceptTypes: ['image/png'], + ), + imagePicker: ImagePicker(), + ); + + expect(result, ['file:///tmp/source.png']); + expect(fakeFilePicker.lastType, FileType.custom); + expect(fakeFilePicker.lastAllowedExtensions, ['png']); + expect(fakeImagePickerPlatform.lastMethod, isNull); + }); + test('权限类型映射正确', () { expect(shellCoreTestHooks.permissionForType('camera'), Permission.camera); expect( @@ -1369,7 +1584,9 @@ void main() { final invalidUri = await shellCoreTestHooks.fetchBootstrapConfig('://'); expect(invalidUri?.initialUrl, 'https://remote.example.com'); - final notFoundUri = await _startJsonServer((_) async => throw 404); + final notFoundUri = await _startJsonServer( + (_) async => throw const _HttpStatusException(404), + ); final notFound = await _runWithRealHttpClient( () => shellCoreTestHooks.fetchBootstrapConfig(notFoundUri.toString()), ); @@ -1412,12 +1629,14 @@ void main() { expect(script, contains('window.AppShell')); }); - test('脚本暴露全部 14 个 Action', () { + test('脚本暴露全部 16 个 Action', () { final script = shellCoreTestHooks.buildAppShellBridgeScript(); for (final action in [ 'pickImage', 'captureImage', + 'pickVideo', + 'captureVideo', 'pickFile', 'openExternal', 'requestPermissions', @@ -1638,7 +1857,6 @@ void main() { isNull, ); }); - }); group('Phase 2: 新增 Bridge Action', () { @@ -1724,9 +1942,9 @@ void main() { try { await shellCoreTestHooks.getDeviceInfoFromBridge(); // 如果 mock 正好能工作,也属正常 - } catch (e) { + } on Object catch (error) { // MissingPluginException 或类型转换错误均可预期 - expect(e, isNotNull); + expect(error, isNotNull); } }); @@ -2064,8 +2282,9 @@ void main() { ), ); addTearDown(() { - shellCoreTestHooks.initializeEnvironment(_testEnvironment); - shellCoreTestHooks.setupUpgradeConfigUrl(null); + shellCoreTestHooks + ..initializeEnvironment(_testEnvironment) + ..setupUpgradeConfigUrl(null); }); late BuildContext pageContext; @@ -2359,7 +2578,9 @@ void main() { expect(await shellCoreTestHooks.fetchUpgradeConfig('%%%'), isNull); - final notFoundUri = await _startJsonServer((_) async => throw 404); + final notFoundUri = await _startJsonServer( + (_) async => throw const _HttpStatusException(404), + ); expect( await _runWithRealHttpClient( () => shellCoreTestHooks.fetchUpgradeConfig(notFoundUri.toString()), @@ -2487,8 +2708,9 @@ void main() { ), ); addTearDown(() { - shellCoreTestHooks.initializeEnvironment(_testEnvironment); - shellCoreTestHooks.setupUpgradeConfigUrl(null); + shellCoreTestHooks + ..initializeEnvironment(_testEnvironment) + ..setupUpgradeConfigUrl(null); }); final successUri = await _startJsonServer( @@ -2632,9 +2854,9 @@ void main() { }); test('升级配置异步解析闭包按本地版本决定是否返回升级信息', () async { - addTearDown(() => shellCoreTestHooks.setLocalBuildNumberResolver(null)); + addTearDown(() => shellCoreTestHooks.localBuildNumberResolver = null); - shellCoreTestHooks.setLocalBuildNumberResolver(() async => 200); + shellCoreTestHooks.localBuildNumberResolver = () async => 200; final version = await shellCoreTestHooks.resolveUpgradeConfig( const ShellUpgradeConfig( upgrade: ShellUpgradeReleaseConfig( @@ -2649,7 +2871,7 @@ void main() { expect(version?.versionBuildNumber, 300); expect(version?.downloadUrl, 'https://example.com/app.apk'); - shellCoreTestHooks.setLocalBuildNumberResolver(() async => 300); + shellCoreTestHooks.localBuildNumberResolver = () async => 300; final sameVersion = await shellCoreTestHooks.resolveUpgradeConfig( const ShellUpgradeConfig( upgrade: ShellUpgradeReleaseConfig( @@ -2661,7 +2883,7 @@ void main() { ); expect(sameVersion, isNull); - shellCoreTestHooks.setLocalBuildNumberResolver(() async => null); + shellCoreTestHooks.localBuildNumberResolver = () async => null; final fallbackVersion = await shellCoreTestHooks.resolveUpgradeConfig( const ShellUpgradeConfig( upgrade: ShellUpgradeReleaseConfig( @@ -2752,8 +2974,17 @@ const List kTransparentImage = [ 0x82, ]; +class _HttpStatusException implements Exception { + const _HttpStatusException(this.statusCode); + + final int statusCode; +} + class _RealHttpOverrides extends HttpOverrides { @override + // Flutter test 会注入自己的 HttpOverrides,这里显式走默认实现, + // 让网络测试可以访问本地临时 HTTP 服务。 + // ignore: unnecessary_overrides HttpClient createHttpClient(SecurityContext? context) { return super.createHttpClient(context); } @@ -2805,20 +3036,107 @@ class _FakeUrlLauncherPlatform extends UrlLauncherPlatform { class _FakeImagePickerPlatform extends ImagePickerPlatform { XFile? nextImage; + XFile? nextVideo; + List nextImages = []; + List nextVideos = []; + List nextMedia = []; bool shouldThrow = false; ImageSource? lastSource; + String? lastMethod; + ImagePickerOptions? lastImageOptions; + MultiImagePickerOptions? lastMultiImageOptions; + MediaOptions? lastMediaOptions; @override Future getImageFromSource({ required ImageSource source, ImagePickerOptions options = const ImagePickerOptions(), }) async { + lastMethod = 'getImageFromSource'; lastSource = source; + lastImageOptions = options; if (shouldThrow) { throw PlatformException(code: 'pick-failed'); } return nextImage; } + + @override + Future> getMultiImageWithOptions({ + MultiImagePickerOptions options = const MultiImagePickerOptions(), + }) async { + lastMethod = 'getMultiImageWithOptions'; + lastMultiImageOptions = options; + if (shouldThrow) { + throw PlatformException(code: 'pick-failed'); + } + return nextImages; + } + + @override + Future> getMedia({required MediaOptions options}) async { + lastMethod = 'getMedia'; + lastMediaOptions = options; + if (shouldThrow) { + throw PlatformException(code: 'pick-failed'); + } + return nextMedia; + } + + @override + Future getVideo({ + required ImageSource source, + CameraDevice preferredCameraDevice = CameraDevice.rear, + Duration? maxDuration, + }) async { + lastMethod = 'getVideo'; + lastSource = source; + if (shouldThrow) { + throw PlatformException(code: 'pick-failed'); + } + return nextVideo; + } + + @override + Future> getMultiVideoWithOptions({ + MultiVideoPickerOptions options = const MultiVideoPickerOptions(), + }) async { + lastMethod = 'getMultiVideoWithOptions'; + if (shouldThrow) { + throw PlatformException(code: 'pick-failed'); + } + return nextVideos; + } +} + +class _FakeFilePicker extends FilePicker { + FilePickerResult? nextResult; + FileType? lastType; + List? lastAllowedExtensions; + bool? lastAllowMultiple; + bool? lastWithData; + + @override + Future pickFiles({ + String? dialogTitle, + String? initialDirectory, + FileType type = FileType.any, + List? allowedExtensions, + void Function(FilePickerStatus status)? onFileLoading, + bool allowCompression = false, + int compressionQuality = 0, + bool allowMultiple = false, + bool withData = false, + bool withReadStream = false, + bool lockParentWindow = false, + bool readSequential = false, + }) async { + lastType = type; + lastAllowedExtensions = allowedExtensions; + lastAllowMultiple = allowMultiple; + lastWithData = withData; + return nextResult; + } } class _FakeWebViewPlatform extends WebViewPlatform { diff --git a/pubspec.lock b/pubspec.lock index 29f5dca..111100c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.19.1" + lints: + dependency: "direct dev" + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.1.1" path: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 0a51e4a..c6afe2d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,3 +8,6 @@ environment: dependencies: yaml: ^3.1.2 + +dev_dependencies: + lints: ^5.1.0