feat: extend media bridge and document changes

This commit is contained in:
Max 2026-04-02 13:38:15 +08:00
parent 40d1b179d0
commit c1d2db1d60
17 changed files with 1181 additions and 230 deletions

17
CHANGELOG.md Normal file
View File

@ -0,0 +1,17 @@
# Changelog
## Unreleased
### 媒体能力
- `web_shell_core` 新增 `pickVideo`、`captureVideo` JS Bridge 能力
- 文件选择增加图片、视频与混合媒体 `accept` 识别,并按场景切换到更合适的系统选择器
- 广义媒体选择默认返回原始文件参数,避免在文件上传场景里被图片压缩逻辑干扰
### 平台与配置
- Android 权限声明补充音频、用户可选媒体、震动与常亮相关能力
- 启动配置与升级配置解析增强,补充上下文安全判断和更完整的错误日志
### 文档与质量
- 更新 `web_shell_core` README 的桥接能力说明
- 调整 lint 依赖与分析配置
- 扩充媒体相关测试覆盖,包括录像、媒体过滤和 WebView 文件选择分支

View File

@ -1,6 +1,6 @@
# Dart 分析器配置,用于在开发阶段发现错误、警告和代码规范问题。 # Dart 分析器配置,用于在开发阶段发现错误、警告和代码规范问题。
# 可通过 `flutter analyze` 执行静态检查。 # 可通过 `flutter analyze` 执行静态检查。
include: package:flutter_lints/flutter.yaml include: package:lints/recommended.yaml
linter: linter:
# 如需自定义规则,可在此处开启或关闭指定 lint。 # 如需自定义规则,可在此处开启或关闭指定 lint。

View File

@ -2,10 +2,23 @@
## Unreleased ## 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 ## 0.0.1

View File

@ -8,9 +8,9 @@ Android 平板专用 H5 壳核心库。所有品牌应用共享此库,只需
|---|---| |---|---|
| **WebView 引擎** | 自动兼容低版本 Android WebView支持 texture / hybrid 双渲染模式自动切换 | | **WebView 引擎** | 自动兼容低版本 Android WebView支持 texture / hybrid 双渲染模式自动切换 |
| **启动恢复** | 看门狗超时检测 → 渲染模式降级 → 深度清理 → 自动重试 | | **启动恢复** | 看门狗超时检测 → 渲染模式降级 → 深度清理 → 自动重试 |
| **JS Bridge** | `window.AppShell` 协议,当前支持 12 个 Action | | **JS Bridge** | `window.AppShell` 协议,当前支持 16 个 Action |
| **旧相机兼容** | Monkey-patch `openCamera` / `captureImage` 兼容老 H5 页面 | | **旧相机兼容** | Monkey-patch `openCamera` / `captureImage` 兼容老 H5 页面 |
| **媒体服务** | 相机拍照 · 图库选图 · 文件选择 · base64 / dataUrl / uri 三种序列化格式 | | **媒体服务** | 相机拍照/录像 · 图库选图/选视频/混合媒体选择 · 文件选择 · base64 / dataUrl / uri 三种序列化格式 |
| **权限服务** | camera · microphone · location · photos · videos · storage 统一映射 | | **权限服务** | camera · microphone · location · photos · videos · storage 统一映射 |
| **导航服务** | URL scheme 白名单路由,非 WebView 协议自动跳转外部应用 | | **导航服务** | URL scheme 白名单路由,非 WebView 协议自动跳转外部应用 |
| **壳层 UI** | 启动加载动画 · 错误恢复页 · 进度条 · 不支持平台兜底页 | | **壳层 UI** | 启动加载动画 · 错误恢复页 · 进度条 · 不支持平台兜底页 |
@ -106,6 +106,8 @@ WebShellPage.initState()
- `pickImage` - `pickImage`
- `captureImage` - `captureImage`
- `pickVideo`
- `captureVideo`
- `pickFile` - `pickFile`
- `openExternal` - `openExternal`
- `requestPermissions` - `requestPermissions`

View File

@ -24,8 +24,14 @@
<!-- 媒体与硬件交互 (permission_handler 所需) --> <!-- 媒体与硬件交互 (permission_handler 所需) -->
<uses-permission android:name="android.permission.CAMERA"/> <uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/> <uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"/>
<!-- 网页特权 API 支持 (震动、屏幕常亮) -->
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<!-- 旧版存储权限 --> <!-- 旧版存储权限 -->
<uses-permission <uses-permission

View File

@ -112,6 +112,9 @@ Future<ShellUpgradeConfig?> reloadUpgradeRuntimeConfigAndCheckVersion(
if (remoteConfig == null) { if (remoteConfig == null) {
return null; return null;
} }
if (!context.mounted) {
return remoteConfig;
}
await ShellUpgradeService.instance.checkVersion( await ShellUpgradeService.instance.checkVersion(
context, context,

View File

@ -87,6 +87,8 @@ String _buildAppShellBridgeScript() {
version: '$_appShellBridgeVersion', version: '$_appShellBridgeVersion',
pickImage: (options = {}) => send('pickImage', options), pickImage: (options = {}) => send('pickImage', options),
captureImage: (options = {}) => send('captureImage', options), captureImage: (options = {}) => send('captureImage', options),
pickVideo: (options = {}) => send('pickVideo', options),
captureVideo: (options = {}) => send('captureVideo', options),
pickFile: (options = {}) => send('pickFile', options), pickFile: (options = {}) => send('pickFile', options),
openExternal: (url) => send('openExternal', { url }), openExternal: (url) => send('openExternal', { url }),
requestPermissions: (types = []) => send('requestPermissions', { types }), requestPermissions: (types = []) => send('requestPermissions', { types }),

View File

@ -33,10 +33,13 @@ String _parseRawErrorDescription(String description) {
return '请稍后重新加载页面。'; return '请稍后重新加载页面。';
} }
final lower = cleanDesc.toLowerCase(); 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 '没有成功连接到服务器,请检查网络后重试。'; 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 '当前网络较慢,请稍后重新加载。'; return '当前网络较慢,请稍后重新加载。';
} }
if (lower.contains('err_cert_') || lower.contains('err_ssl_')) { if (lower.contains('err_cert_') || lower.contains('err_ssl_')) {

View File

@ -2,6 +2,26 @@ part of '../../core_app.dart';
/// ///
class ShellBootstrapConfig { class ShellBootstrapConfig {
///
const ShellBootstrapConfig({
this.initialUrl,
this.preferredOrientations,
this.bootstrapConfigUrl,
this.upgradeConfigUrl,
});
/// JSON
factory ShellBootstrapConfig.fromJson(Map<String, dynamic> json) {
return ShellBootstrapConfig(
initialUrl: json['initialUrl']?.toString(),
preferredOrientations: _parsePreferredOrientations(
json['preferredOrientations'] ?? json['orientations'],
),
bootstrapConfigUrl: json['bootstrapConfigUrl']?.toString(),
upgradeConfigUrl: json['upgradeConfigUrl']?.toString(),
);
}
/// ///
final String? initialUrl; final String? initialUrl;
@ -14,24 +34,6 @@ class ShellBootstrapConfig {
/// ///
final String? upgradeConfigUrl; final String? upgradeConfigUrl;
ShellBootstrapConfig({
this.initialUrl,
this.preferredOrientations,
this.bootstrapConfigUrl,
this.upgradeConfigUrl,
});
factory ShellBootstrapConfig.fromJson(Map<String, dynamic> json) {
return ShellBootstrapConfig(
initialUrl: json['initialUrl']?.toString(),
preferredOrientations: _parsePreferredOrientations(
json['preferredOrientations'] ?? json['orientations'],
),
bootstrapConfigUrl: json['bootstrapConfigUrl']?.toString(),
upgradeConfigUrl: json['upgradeConfigUrl']?.toString(),
);
}
static List<DeviceOrientation>? _parsePreferredOrientations(Object? value) { static List<DeviceOrientation>? _parsePreferredOrientations(Object? value) {
if (value is! List) { if (value is! List) {
return null; return null;
@ -81,8 +83,8 @@ class ShellBootstrapConfigService {
try { try {
final content = await rootBundle.loadString(assetPath); final content = await rootBundle.loadString(assetPath);
return _parseConfigString(content); return _parseConfigString(content);
} catch (e) { } on Object catch (error, stackTrace) {
debugPrint('读取 WebShell 本地启动配置失败: $e'); debugPrint('读取 WebShell 本地启动配置失败: $error\n$stackTrace');
return null; return null;
} }
} }
@ -103,8 +105,11 @@ class ShellBootstrapConfigService {
return _parseConfigString(content); return _parseConfigString(content);
} }
return _loadFromCache(); return _loadFromCache();
} catch (e) { } on Object catch (error, stackTrace) {
debugPrint('获取 WebShell 启动配置失败(网络异常/超时),尝试读取缓存: $e'); debugPrint(
'获取 WebShell 启动配置失败(网络异常/超时),尝试读取缓存: '
'$error\n$stackTrace',
);
return _loadFromCache(); return _loadFromCache();
} }
} }
@ -118,8 +123,8 @@ class ShellBootstrapConfigService {
debugPrint('成功读取到本地缓存的 WebShell 启动配置'); debugPrint('成功读取到本地缓存的 WebShell 启动配置');
return _parseConfigString(cachedContent); return _parseConfigString(cachedContent);
} }
} catch (e) { } on Object catch (error, stackTrace) {
debugPrint('读取 WebShell 启动配置缓存失败: $e'); debugPrint('读取 WebShell 启动配置缓存失败: $error\n$stackTrace');
} }
return null; return null;
} }
@ -134,8 +139,8 @@ class ShellBootstrapConfigService {
: jsonMap; : jsonMap;
return ShellBootstrapConfig.fromJson(data); return ShellBootstrapConfig.fromJson(data);
} }
} catch (e) { } on Object catch (error, stackTrace) {
debugPrint('解析 WebShell 启动配置异常: $e'); debugPrint('解析 WebShell 启动配置异常: $error\n$stackTrace');
} }
return null; return null;
} }

View File

@ -3,10 +3,31 @@ part of '../../core_app.dart';
const double _pickedImageMaxWidth = 1600; const double _pickedImageMaxWidth = 1600;
const double _pickedImageMaxHeight = 1600; const double _pickedImageMaxHeight = 1600;
const int _pickedImageQuality = 85; const int _pickedImageQuality = 85;
const Set<String> _imageFileExtensions = <String>{
'png',
'jpg',
'jpeg',
'webp',
'gif',
'bmp',
'heic',
'heif',
};
const Set<String> _videoFileExtensions = <String>{
'mp4',
'mov',
'avi',
'wmv',
'mkv',
'm4v',
'webm',
'3gp',
};
Future<XFile?> _pickCameraImage( Future<XFile?> _pickCameraImage(
ImagePicker imagePicker, { ImagePicker imagePicker, {
bool showPermissionAlert = false, bool showPermissionAlert = false,
bool preserveOriginal = false,
WebViewController? controller, WebViewController? controller,
}) async { }) async {
final cameraStatus = await Permission.camera.request(); final cameraStatus = await Permission.camera.request();
@ -18,6 +39,9 @@ Future<XFile?> _pickCameraImage(
} }
try { try {
if (preserveOriginal) {
return await imagePicker.pickImage(source: ImageSource.camera);
}
return await imagePicker.pickImage( return await imagePicker.pickImage(
source: ImageSource.camera, source: ImageSource.camera,
imageQuality: _pickedImageQuality, imageQuality: _pickedImageQuality,
@ -33,6 +57,111 @@ Future<XFile?> _pickCameraImage(
} }
} }
Future<XFile?> _pickCameraVideo(
ImagePicker imagePicker, {
bool showPermissionAlert = false,
WebViewController? controller,
}) async {
final statuses = await <Permission>[
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<List<XFile>> _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 <XFile>[] : <XFile>[file];
}
final file = await imagePicker.pickImage(
source: ImageSource.gallery,
imageQuality: _pickedImageQuality,
maxWidth: _pickedImageMaxWidth,
maxHeight: _pickedImageMaxHeight,
);
return file == null ? const <XFile>[] : <XFile>[file];
}
Future<List<XFile>> _pickGalleryVideos(
ImagePicker imagePicker, {
required bool multiple,
}) async {
if (multiple) {
return imagePicker.pickMultiVideo();
}
final file = await imagePicker.pickVideo(
source: ImageSource.gallery,
);
return file == null ? const <XFile>[] : <XFile>[file];
}
Future<List<XFile>> _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 <XFile>[] : <XFile>[file];
}
final file = await imagePicker.pickMedia(
imageQuality: _pickedImageQuality,
maxWidth: _pickedImageMaxWidth,
maxHeight: _pickedImageMaxHeight,
);
return file == null ? const <XFile>[] : <XFile>[file];
}
Future<List<Map<String, dynamic>>> _serializeXFiles( Future<List<Map<String, dynamic>>> _serializeXFiles(
List<XFile> files, { List<XFile> files, {
required String responseType, required String responseType,
@ -100,6 +229,24 @@ List<String> _xFilesToUriStrings(List<XFile> files) {
return files.map((file) => Uri.file(file.path).toString()).toList(); return files.map((file) => Uri.file(file.path).toString()).toList();
} }
List<String> _acceptTypesFromValue(Object? value) {
final rawTypes = switch (value) {
final String stringValue => stringValue.split(','),
final Iterable<dynamic> iterableValue => iterableValue.expand((item) {
final stringValue = item.toString();
return stringValue.contains(',')
? stringValue.split(',')
: <String>[stringValue];
}),
_ => const <String>[],
};
return rawTypes
.map((type) => type.trim().toLowerCase())
.where((type) => type.isNotEmpty)
.toList();
}
bool _acceptsImages(List<String> acceptTypes) { bool _acceptsImages(List<String> acceptTypes) {
return acceptTypes return acceptTypes
.map((type) => type.trim()) .map((type) => type.trim())
@ -121,16 +268,123 @@ bool _acceptsOnlyImages(List<String> acceptTypes) {
bool _isImageAcceptType(String acceptType) { bool _isImageAcceptType(String acceptType) {
final value = acceptType.toLowerCase(); final value = acceptType.toLowerCase();
return value.startsWith('image/') || return value.startsWith('image/') ||
const <String>{ _imageFileExtensions.map((extension) => '.$extension').contains(value);
'.png', }
'.jpg',
'.jpeg', bool _acceptsVideos(List<String> acceptTypes) {
'.webp', return acceptTypes
'.gif', .map((type) => type.trim())
'.bmp', .where((type) => type.isNotEmpty)
'.heic', .any(_isVideoAcceptType);
'.heif', }
}.contains(value);
bool _acceptsOnlyVideos(List<String> 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<String> 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<String> _allowedExtensionsForAcceptTypes(List<String> acceptTypes) {
final extensions = <String>{};
for (final acceptType in acceptTypes) {
extensions.addAll(_extensionsForAcceptType(acceptType));
}
return extensions.toList()..sort();
}
List<String> _extensionsForAcceptType(String acceptType) {
final value = acceptType.trim().toLowerCase();
if (value.isEmpty) {
return const <String>[];
}
if (value.startsWith('.')) {
return <String>[value.substring(1)];
}
return switch (value) {
'image/*' => _imageFileExtensions.toList(),
'video/*' => _videoFileExtensions.toList(),
'image/png' => const <String>['png'],
'image/jpeg' => const <String>['jpg', 'jpeg'],
'image/webp' => const <String>['webp'],
'image/gif' => const <String>['gif'],
'image/bmp' => const <String>['bmp'],
'image/heic' => const <String>['heic'],
'image/heif' => const <String>['heif'],
'video/mp4' => const <String>['mp4'],
'video/quicktime' => const <String>['mov'],
'video/x-msvideo' => const <String>['avi'],
'video/x-ms-wmv' => const <String>['wmv'],
'video/x-matroska' => const <String>['mkv'],
'video/x-m4v' => const <String>['m4v'],
'video/webm' => const <String>['webm'],
'video/3gpp' => const <String>['3gp'],
'application/pdf' => const <String>['pdf'],
'text/plain' => const <String>['txt'],
'application/vnd.android.package-archive' => const <String>['apk'],
_ => const <String>[],
};
}
({FileType type, List<String>? allowedExtensions})
_filePickerFilterForAcceptTypes(
List<String> 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<String> acceptTypes) {
final normalizedTypes = _acceptTypesFromValue(acceptTypes);
if (normalizedTypes.isEmpty) {
return false;
}
return normalizedTypes.every(
(type) => type == 'image/*' || type == 'video/*',
);
} }
String _guessMimeType(String fileName) { String _guessMimeType(String fileName) {
@ -156,6 +410,30 @@ String _guessMimeType(String fileName) {
if (lower.endsWith('.heif')) { if (lower.endsWith('.heif')) {
return 'image/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')) { if (lower.endsWith('.pdf')) {
return 'application/pdf'; return 'application/pdf';
} }
@ -176,6 +454,155 @@ Future<void> _showWebAlert(WebViewController controller, String message) async {
} }
} }
Future<List<XFile>?> _pickAcceptedMediaFiles(
ImagePicker imagePicker, {
required List<String> acceptTypes,
required bool capture,
required bool multiple,
WebViewController? controller,
}) async {
if (!_shouldUseMediaPickerForFileSelection(acceptTypes)) {
return null;
}
if (capture) {
if (_acceptsOnlyImages(acceptTypes)) {
return <XFile>[
if (await _pickCameraImage(
imagePicker,
showPermissionAlert: true,
preserveOriginal: true,
controller: controller,
)
case final XFile file)
file,
];
}
if (_acceptsOnlyVideos(acceptTypes)) {
return <XFile>[
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<Object?> _pickFilesFromBridgePayload(
Map<String, dynamic> 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 ? <Map<String, dynamic>>[] : null;
}
final serialized = await _serializePlatformFiles(
result.files,
responseType: responseType,
);
return multiple ? serialized : serialized.firstOrNull;
}
Future<List<String>> _selectFilesForWebView(
FileSelectorParams params, {
required ImagePicker imagePicker,
WebViewController? controller,
}) async {
if (params.mode == FileSelectorMode.save) {
return <String>[];
}
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 <String>[];
}
return result.files
.map((file) => file.path)
.whereType<String>()
.map((path) => Uri.file(path).toString())
.toList();
}
bool _boolValue(Object? value, {bool defaultValue = false}) { bool _boolValue(Object? value, {bool defaultValue = false}) {
return switch (value) { return switch (value) {
final bool boolValue => boolValue, final bool boolValue => boolValue,

View File

@ -2,13 +2,7 @@ part of '../../core_app.dart';
/// `upgrade` /// `upgrade`
class ShellUpgradeReleaseConfig { class ShellUpgradeReleaseConfig {
final String? versionName; ///
final int? version;
final int? isForce;
final String? remark;
final String? filePath;
final int? fileSize;
const ShellUpgradeReleaseConfig({ const ShellUpgradeReleaseConfig({
this.versionName, this.versionName,
this.version, this.version,
@ -18,6 +12,7 @@ class ShellUpgradeReleaseConfig {
this.fileSize, this.fileSize,
}); });
/// JSON
factory ShellUpgradeReleaseConfig.fromJson(Map<String, dynamic> json) { factory ShellUpgradeReleaseConfig.fromJson(Map<String, dynamic> json) {
return ShellUpgradeReleaseConfig( return ShellUpgradeReleaseConfig(
versionName: _readShellUpgradeString(json['versionName']), 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 /// Map `data.config.upgrade` JSON
static ShellUpgradeReleaseConfig? fromDynamic(dynamic value) { static ShellUpgradeReleaseConfig? fromDynamic(dynamic value) {
if (value == null) { if (value == null) {
@ -61,8 +74,8 @@ class ShellUpgradeReleaseConfig {
Map<String, dynamic>.from(decoded), Map<String, dynamic>.from(decoded),
); );
} }
} catch (e) { } on Object catch (error, stackTrace) {
debugPrint('解析 WebShell 升级配置 upgrade 字段异常: $e'); debugPrint('解析 WebShell 升级配置 upgrade 字段异常: $error\n$stackTrace');
} }
return null; return null;
} }
@ -75,29 +88,7 @@ class ShellUpgradeReleaseConfig {
/// 2. `data.config` JSON /// 2. `data.config` JSON
/// 3. `data.config` `upgrade` `config` /// 3. `data.config` `upgrade` `config`
class ShellUpgradeConfig { 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({ const ShellUpgradeConfig({
this.success, this.success,
this.responseCode, this.responseCode,
@ -123,36 +114,9 @@ class ShellUpgradeConfig {
this.config, this.config,
}); });
String? get versionName => upgrade?.versionName; /// JSON
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
/// ///
/// `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<String, dynamic> json) { factory ShellUpgradeConfig.fromJson(Map<String, dynamic> json) {
final data = json['data']; final data = json['data'];
final dataMap = data is Map<String, dynamic> final dataMap = data is Map<String, dynamic>
@ -196,6 +160,106 @@ class ShellUpgradeConfig {
createdTime: _readShellUpgradeString(dataMap['createdTime']), 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) { String? _readShellUpgradeString(dynamic value) {
@ -254,17 +318,19 @@ Map<String, dynamic>? _readShellUpgradeEmbeddedConfig(String? value) {
if (decoded is Map) { if (decoded is Map) {
return Map<String, dynamic>.from(decoded); return Map<String, dynamic>.from(decoded);
} }
} catch (e) { } on Object catch (error, stackTrace) {
debugPrint('解析 WebShell 升级配置 data.config 异常: $e'); debugPrint('解析 WebShell 升级配置 data.config 异常: $error\n$stackTrace');
} }
return null; return null;
} }
/// yx_app_upgrade_flutter /// yx_app_upgrade_flutter
class ShellUpgradeService { class ShellUpgradeService {
static final ShellUpgradeService instance = ShellUpgradeService._();
ShellUpgradeService._(); ShellUpgradeService._();
///
static final ShellUpgradeService instance = ShellUpgradeService._();
String? _configUrl; String? _configUrl;
Future<int?> Function()? _localBuildNumberResolver; Future<int?> Function()? _localBuildNumberResolver;
@ -293,6 +359,9 @@ class ShellUpgradeService {
} }
final remoteConfig = await _fetchConfig(configUrl); final remoteConfig = await _fetchConfig(configUrl);
if (!context.mounted) {
return;
}
if (remoteConfig == null) { if (remoteConfig == null) {
if (showNoUpdateToast) { if (showNoUpdateToast) {
_showToastFromBridge(context, {'message': '未获取到版本配置'}); _showToastFromBridge(context, {'message': '未获取到版本配置'});
@ -300,7 +369,7 @@ class ShellUpgradeService {
return; return;
} }
UpgradeAuxiliaryUtils.instance.initiateVersionCheck( await UpgradeAuxiliaryUtils.instance.initiateVersionCheck(
// coverage:ignore-line // coverage:ignore-line
context, context,
showNoUpdateToast: showNoUpdateToast, showNoUpdateToast: showNoUpdateToast,
@ -329,7 +398,8 @@ class ShellUpgradeService {
} }
if (!remoteConfig.shouldOfferUpgrade(localVersion: localBuildNumber)) { if (!remoteConfig.shouldOfferUpgrade(localVersion: localBuildNumber)) {
debugPrint( debugPrint(
'WebShell 远端版本(${remoteConfig.version}) <= 本地版本($localBuildNumber),跳过升级提示', 'WebShell 远端版本(${remoteConfig.version}) '
'<= 本地版本($localBuildNumber),跳过升级提示',
); );
return null; return null;
} }
@ -347,14 +417,20 @@ class ShellUpgradeService {
try { try {
final appInfo = await AppUpgradePlugin().getAppInfo(); final appInfo = await AppUpgradePlugin().getAppInfo();
return int.tryParse((appInfo['buildNumber'] ?? '').trim()); return int.tryParse((appInfo['buildNumber'] ?? '').trim());
} catch (e) { } on Object catch (error, stackTrace) {
debugPrint('获取 WebShell 本地版本号异常: $e'); debugPrint('获取 WebShell 本地版本号异常: $error\n$stackTrace');
return null; return null;
} }
} }
///
@visibleForTesting @visibleForTesting
void debugSetLocalBuildNumberResolver(Future<int?> Function()? resolver) { Future<int?> Function()? get debugLocalBuildNumberResolver =>
_localBuildNumberResolver;
///
@visibleForTesting
set debugLocalBuildNumberResolver(Future<int?> Function()? resolver) {
_localBuildNumberResolver = resolver; _localBuildNumberResolver = resolver;
} }
@ -373,8 +449,10 @@ class ShellUpgradeService {
} }
final content = utf8.decode(response.bodyBytes); final content = utf8.decode(response.bodyBytes);
return _parseConfigString(content); return _parseConfigString(content);
} catch (e) { } on Object catch (error, stackTrace) {
debugPrint('获取 WebShell 升级配置失败(网络异常/超时): $e'); debugPrint(
'获取 WebShell 升级配置失败(网络异常/超时): $error\n$stackTrace',
);
return null; return null;
} }
} }
@ -396,8 +474,8 @@ class ShellUpgradeService {
); );
return config.hasStructuredPayload ? config : null; return config.hasStructuredPayload ? config : null;
} }
} catch (e) { } on Object catch (error, stackTrace) {
debugPrint('解析 WebShell 升级配置异常: $e'); debugPrint('解析 WebShell 升级配置异常: $error\n$stackTrace');
} }
return null; return null;
} }

View File

@ -93,8 +93,12 @@ class ShellCoreTestHooks {
} }
/// ///
void setLocalBuildNumberResolver(Future<int?> Function()? resolver) { Future<int?> Function()? get localBuildNumberResolver =>
ShellUpgradeService.instance.debugSetLocalBuildNumberResolver(resolver); ShellUpgradeService.instance.debugLocalBuildNumberResolver;
///
set localBuildNumberResolver(Future<int?> Function()? resolver) {
ShellUpgradeService.instance.debugLocalBuildNumberResolver = resolver;
} }
/// ///
@ -209,6 +213,19 @@ class ShellCoreTestHooks {
); );
} }
///
Future<XFile?> pickCameraVideo(
ImagePicker imagePicker, {
bool showPermissionAlert = false,
WebViewController? controller,
}) {
return _pickCameraVideo(
imagePicker,
showPermissionAlert: showPermissionAlert,
controller: controller,
);
}
/// `file://` URI /// `file://` URI
List<String> xFilesToUriStrings(List<XFile> files) { List<String> xFilesToUriStrings(List<XFile> files) {
return _xFilesToUriStrings(files); return _xFilesToUriStrings(files);
@ -225,6 +242,83 @@ class ShellCoreTestHooks {
/// accept /// accept
bool isImageAcceptType(String acceptType) => _isImageAcceptType(acceptType); bool isImageAcceptType(String acceptType) => _isImageAcceptType(acceptType);
/// accept
bool acceptsVideos(List<String> acceptTypes) => _acceptsVideos(acceptTypes);
/// accept
bool acceptsOnlyVideos(List<String> acceptTypes) {
return _acceptsOnlyVideos(acceptTypes);
}
/// accept
bool acceptsOnlyMedia(List<String> acceptTypes) {
return _acceptsOnlyMedia(acceptTypes);
}
/// accept
bool isVideoAcceptType(String acceptType) => _isVideoAcceptType(acceptType);
/// payload accept
List<String> acceptTypesFromValue(Object? value) {
return _acceptTypesFromValue(value);
}
/// accept FilePicker
({FileType type, List<String>? allowedExtensions})
filePickerFilterForAcceptTypes(
List<String> acceptTypes,
) {
return _filePickerFilterForAcceptTypes(acceptTypes);
}
///
bool shouldUseMediaPickerForFileSelection(List<String> acceptTypes) {
return _shouldUseMediaPickerForFileSelection(acceptTypes);
}
///
Future<List<XFile>?> pickAcceptedMediaFilesForFileSelection(
ImagePicker imagePicker, {
required List<String> acceptTypes,
required bool capture,
required bool multiple,
WebViewController? controller,
}) {
return _pickAcceptedMediaFiles(
imagePicker,
acceptTypes: acceptTypes,
capture: capture,
multiple: multiple,
controller: controller,
);
}
/// `pickFile`
Future<Object?> pickFilesFromBridgePayload(
Map<String, dynamic> payload, {
required ImagePicker imagePicker,
WebViewController? controller,
}) {
return _pickFilesFromBridgePayload(
payload,
imagePicker: imagePicker,
controller: controller,
);
}
/// WebView
Future<List<String>> selectFilesForWebView(
FileSelectorParams params, {
required ImagePicker imagePicker,
WebViewController? controller,
}) {
return _selectFilesForWebView(
params,
imagePicker: imagePicker,
controller: controller,
);
}
/// MIME /// MIME
String guessMimeType(String fileName) => _guessMimeType(fileName); String guessMimeType(String fileName) => _guessMimeType(fileName);

View File

@ -730,6 +730,16 @@ class _WebShellPageState extends State<WebShellPage>
source: ImageSource.camera, source: ImageSource.camera,
payload: payload, payload: payload,
); );
case 'pickVideo':
data = await _pickVideosFromBridge(
source: ImageSource.gallery,
payload: payload,
);
case 'captureVideo':
data = await _pickVideosFromBridge(
source: ImageSource.camera,
payload: payload,
);
case 'pickFile': case 'pickFile':
data = await _pickFilesFromBridge(payload); data = await _pickFilesFromBridge(payload);
case 'openExternal': case 'openExternal':
@ -747,10 +757,7 @@ class _WebShellPageState extends State<WebShellPage>
data = _runtimeConfigSnapshotFromBridge( data = _runtimeConfigSnapshotFromBridge(
await reloadUpgradeRuntimeConfigAndCheckVersion( await reloadUpgradeRuntimeConfigAndCheckVersion(
context, context,
showNoUpdateToast: _boolValue( showNoUpdateToast: _boolValue(payload['showNoUpdateToast']),
payload['showNoUpdateToast'],
defaultValue: false,
),
), ),
); );
case 'goBack': case 'goBack':
@ -804,33 +811,50 @@ class _WebShellPageState extends State<WebShellPage>
source == ImageSource.gallery && _boolValue(payload['multiple']); source == ImageSource.gallery && _boolValue(payload['multiple']);
final responseType = (payload['responseType'] ?? 'dataUrl').toString(); final responseType = (payload['responseType'] ?? 'dataUrl').toString();
var files = <XFile>[]; final files = source == ImageSource.camera
if (source == ImageSource.camera) { ? <XFile>[
final file = await _pickCameraImage( if (await _pickCameraImage(
_imagePicker, _imagePicker,
showPermissionAlert: true, showPermissionAlert: true,
controller: _controller, controller: _controller,
)
case final XFile file)
file,
]
: await _pickGalleryImages(
_imagePicker,
multiple: multiple,
); );
if (file != null) {
files = <XFile>[file]; final serialized = await _serializeXFiles(
} files,
} else if (multiple) { responseType: responseType,
files = await _imagePicker.pickMultiImage(
imageQuality: _pickedImageQuality,
maxWidth: _pickedImageMaxWidth,
maxHeight: _pickedImageMaxHeight,
); );
} else { return multiple ? serialized : serialized.firstOrNull;
final file = await _imagePicker.pickImage( }
source: ImageSource.gallery,
imageQuality: _pickedImageQuality, Future<Object?> _pickVideosFromBridge({
maxWidth: _pickedImageMaxWidth, required ImageSource source,
maxHeight: _pickedImageMaxHeight, required Map<String, dynamic> payload,
}) async {
final multiple =
source == ImageSource.gallery && _boolValue(payload['multiple']);
final responseType = (payload['responseType'] ?? 'uri').toString();
final files = source == ImageSource.camera
? <XFile>[
if (await _pickCameraVideo(
_imagePicker,
showPermissionAlert: true,
controller: _controller,
)
case final XFile file)
file,
]
: await _pickGalleryVideos(
_imagePicker,
multiple: multiple,
); );
if (file != null) {
files = <XFile>[file];
}
}
final serialized = await _serializeXFiles( final serialized = await _serializeXFiles(
files, files,
@ -840,25 +864,11 @@ class _WebShellPageState extends State<WebShellPage>
} }
Future<Object?> _pickFilesFromBridge(Map<String, dynamic> payload) async { Future<Object?> _pickFilesFromBridge(Map<String, dynamic> payload) async {
final responseType = (payload['responseType'] ?? 'uri').toString(); return _pickFilesFromBridgePayload(
final includeBinary = responseType == 'dataUrl' || responseType == 'base64'; payload,
imagePicker: _imagePicker,
final result = await FilePicker.platform.pickFiles( controller: _controller,
allowMultiple: _boolValue(payload['multiple']),
withData: includeBinary,
); );
if (result == null) {
return _boolValue(payload['multiple']) ? <Map<String, dynamic>>[] : null;
}
final serialized = await _serializePlatformFiles(
result.files,
responseType: responseType,
);
return _boolValue(payload['multiple'])
? serialized
: serialized.firstOrNull;
} }
Future<bool> _openExternalFromBridge(Map<String, dynamic> payload) async { Future<bool> _openExternalFromBridge(Map<String, dynamic> payload) async {
@ -921,50 +931,11 @@ class _WebShellPageState extends State<WebShellPage>
} }
try { try {
final acceptsImgs = _acceptsImages(params.acceptTypes); return _selectFilesForWebView(
final imagesOnly = _acceptsOnlyImages(params.acceptTypes); params,
imagePicker: _imagePicker,
if (params.isCaptureEnabled && acceptsImgs) { controller: _controller,
final capturedImage = await _pickCameraImage(_imagePicker);
return _xFilesToUriStrings(
capturedImage == null ? const <XFile>[] : <XFile>[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 <XFile>[] : <XFile>[image],
);
}
final result = await FilePicker.platform.pickFiles(
allowMultiple: params.mode == FileSelectorMode.openMultiple,
);
if (result == null) {
return <String>[];
}
return result.files
.map((file) => file.path)
.whereType<String>()
.map((path) => Uri.file(path).toString())
.toList();
} on Object catch (error, stackTrace) { } on Object catch (error, stackTrace) {
debugPrint('处理文件选择失败:$error\n$stackTrace'); debugPrint('处理文件选择失败:$error\n$stackTrace');
return <String>[]; return <String>[];

View File

@ -31,6 +31,7 @@ dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
image_picker_platform_interface: ^2.11.1 image_picker_platform_interface: ^2.11.1
shared_preferences_platform_interface: ^2.4.1
url_launcher_platform_interface: ^2.3.2 url_launcher_platform_interface: ^2.3.2
very_good_analysis: ^10.2.0 very_good_analysis: ^10.2.0
webview_flutter_platform_interface: ^2.14.0 webview_flutter_platform_interface: ^2.14.0

View File

@ -1,7 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.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:url_launcher_platform_interface/url_launcher_platform_interface.dart';
import 'package:web_shell_core/core_app.dart'; import 'package:web_shell_core/core_app.dart';
import 'package:webview_flutter/webview_flutter.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'; import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart';
const MethodChannel _platformChannel = SystemChannels.platform; const MethodChannel _platformChannel = SystemChannels.platform;
@ -49,9 +50,9 @@ Future<Uri> _startJsonServer(
'application/json; charset=utf-8', 'application/json; charset=utf-8',
); );
request.response.write(body); request.response.write(body);
} catch (error) { } on Object catch (error) {
request.response.statusCode = error is int request.response.statusCode = error is _HttpStatusException
? error ? error.statusCode
: HttpStatus.internalServerError; : HttpStatus.internalServerError;
} finally { } finally {
await request.response.close(); await request.response.close();
@ -70,6 +71,7 @@ void main() {
late _FakeWebViewPlatform fakeWebViewPlatform; late _FakeWebViewPlatform fakeWebViewPlatform;
late _FakeImagePickerPlatform fakeImagePickerPlatform; late _FakeImagePickerPlatform fakeImagePickerPlatform;
late _FakeFilePicker fakeFilePicker;
late _FakeUrlLauncherPlatform fakeUrlLauncherPlatform; late _FakeUrlLauncherPlatform fakeUrlLauncherPlatform;
late List<String> platformCalls; late List<String> platformCalls;
late List<MethodCall> platformMethodCalls; late List<MethodCall> platformMethodCalls;
@ -84,10 +86,12 @@ void main() {
cameraPermissionGranted = true; cameraPermissionGranted = true;
fakeWebViewPlatform = _FakeWebViewPlatform(); fakeWebViewPlatform = _FakeWebViewPlatform();
fakeImagePickerPlatform = _FakeImagePickerPlatform(); fakeImagePickerPlatform = _FakeImagePickerPlatform();
fakeFilePicker = _FakeFilePicker();
fakeUrlLauncherPlatform = _FakeUrlLauncherPlatform(); fakeUrlLauncherPlatform = _FakeUrlLauncherPlatform();
WebViewPlatform.instance = fakeWebViewPlatform; WebViewPlatform.instance = fakeWebViewPlatform;
ImagePickerPlatform.instance = fakeImagePickerPlatform; ImagePickerPlatform.instance = fakeImagePickerPlatform;
FilePicker.platform = fakeFilePicker;
UrlLauncherPlatform.instance = fakeUrlLauncherPlatform; UrlLauncherPlatform.instance = fakeUrlLauncherPlatform;
shellCoreTestHooks.initializeEnvironment(_testEnvironment); shellCoreTestHooks.initializeEnvironment(_testEnvironment);
@ -439,6 +443,8 @@ void main() {
expect(script, contains('window.AppShell')); expect(script, contains('window.AppShell'));
expect(script, contains('pickImage')); expect(script, contains('pickImage'));
expect(script, contains('captureImage')); expect(script, contains('captureImage'));
expect(script, contains('pickVideo'));
expect(script, contains('captureVideo'));
expect(script, contains('pickFile')); expect(script, contains('pickFile'));
expect(script, contains('requestPermissions')); expect(script, contains('requestPermissions'));
expect(script, contains(shellCoreTestHooks.legacyCameraCompatScript)); expect(script, contains(shellCoreTestHooks.legacyCameraCompatScript));
@ -686,6 +692,215 @@ void main() {
expect(platformController.javaScriptCalls.single, contains('无法打开系统相机')); 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(<int>[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(<String>[' video/* ']), isTrue);
expect(
shellCoreTestHooks.acceptsOnlyVideos(<String>['.mp4', '.mov']),
isTrue,
);
expect(
shellCoreTestHooks.acceptsOnlyMedia(<String>['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, <String>['image/*', 'video/*', '.pdf']);
final mixedFilter = shellCoreTestHooks.filePickerFilterForAcceptTypes(
<String>['image/*', '.pdf'],
);
expect(mixedFilter.type, FileType.custom);
expect(
mixedFilter.allowedExtensions,
containsAll(<String>['png', 'jpg', 'jpeg', 'pdf']),
);
final mediaFilter = shellCoreTestHooks.filePickerFilterForAcceptTypes(
<String>['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: <String>['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>[
XFile('/tmp/source.mov', name: 'source.mov'),
];
final result = await shellCoreTestHooks
.pickAcceptedMediaFilesForFileSelection(
ImagePicker(),
acceptTypes: <String>['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: <String>['image/png'],
capture: false,
multiple: false,
);
expect(result, isNull);
expect(
shellCoreTestHooks.shouldUseMediaPickerForFileSelection(
<String>['image/png'],
),
isFalse,
);
expect(fakeImagePickerPlatform.lastMethod, isNull);
});
test('pickFile 遇到严格图片 accept 会走 FilePicker 自定义过滤', () async {
fakeFilePicker.nextResult = FilePickerResult(<PlatformFile>[
PlatformFile(
name: 'source.png',
path: '/tmp/source.png',
size: 10,
),
]);
final result = await shellCoreTestHooks.pickFilesFromBridgePayload(
<String, dynamic>{
'accept': 'image/png',
'responseType': 'uri',
},
imagePicker: ImagePicker(),
);
expect(result, isA<Map<String, dynamic>>());
expect(fakeFilePicker.lastType, FileType.custom);
expect(fakeFilePicker.lastAllowedExtensions, <String>['png']);
expect(fakeImagePickerPlatform.lastMethod, isNull);
});
test('WebView 文件选择遇到严格图片 accept 会走 FilePicker 自定义过滤', () async {
fakeFilePicker.nextResult = FilePickerResult(<PlatformFile>[
PlatformFile(
name: 'source.png',
path: '/tmp/source.png',
size: 10,
),
]);
final result = await shellCoreTestHooks.selectFilesForWebView(
const FileSelectorParams(
isCaptureEnabled: false,
mode: FileSelectorMode.open,
acceptTypes: <String>['image/png'],
),
imagePicker: ImagePicker(),
);
expect(result, <String>['file:///tmp/source.png']);
expect(fakeFilePicker.lastType, FileType.custom);
expect(fakeFilePicker.lastAllowedExtensions, <String>['png']);
expect(fakeImagePickerPlatform.lastMethod, isNull);
});
test('权限类型映射正确', () { test('权限类型映射正确', () {
expect(shellCoreTestHooks.permissionForType('camera'), Permission.camera); expect(shellCoreTestHooks.permissionForType('camera'), Permission.camera);
expect( expect(
@ -1369,7 +1584,9 @@ void main() {
final invalidUri = await shellCoreTestHooks.fetchBootstrapConfig('://'); final invalidUri = await shellCoreTestHooks.fetchBootstrapConfig('://');
expect(invalidUri?.initialUrl, 'https://remote.example.com'); 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( final notFound = await _runWithRealHttpClient(
() => shellCoreTestHooks.fetchBootstrapConfig(notFoundUri.toString()), () => shellCoreTestHooks.fetchBootstrapConfig(notFoundUri.toString()),
); );
@ -1412,12 +1629,14 @@ void main() {
expect(script, contains('window.AppShell')); expect(script, contains('window.AppShell'));
}); });
test('脚本暴露全部 14 个 Action', () { test('脚本暴露全部 16 个 Action', () {
final script = shellCoreTestHooks.buildAppShellBridgeScript(); final script = shellCoreTestHooks.buildAppShellBridgeScript();
for (final action in <String>[ for (final action in <String>[
'pickImage', 'pickImage',
'captureImage', 'captureImage',
'pickVideo',
'captureVideo',
'pickFile', 'pickFile',
'openExternal', 'openExternal',
'requestPermissions', 'requestPermissions',
@ -1638,7 +1857,6 @@ void main() {
isNull, isNull,
); );
}); });
}); });
group('Phase 2: 新增 Bridge Action', () { group('Phase 2: 新增 Bridge Action', () {
@ -1724,9 +1942,9 @@ void main() {
try { try {
await shellCoreTestHooks.getDeviceInfoFromBridge(); await shellCoreTestHooks.getDeviceInfoFromBridge();
// mock // mock
} catch (e) { } on Object catch (error) {
// MissingPluginException // MissingPluginException
expect(e, isNotNull); expect(error, isNotNull);
} }
}); });
@ -2064,8 +2282,9 @@ void main() {
), ),
); );
addTearDown(() { addTearDown(() {
shellCoreTestHooks.initializeEnvironment(_testEnvironment); shellCoreTestHooks
shellCoreTestHooks.setupUpgradeConfigUrl(null); ..initializeEnvironment(_testEnvironment)
..setupUpgradeConfigUrl(null);
}); });
late BuildContext pageContext; late BuildContext pageContext;
@ -2359,7 +2578,9 @@ void main() {
expect(await shellCoreTestHooks.fetchUpgradeConfig('%%%'), isNull); expect(await shellCoreTestHooks.fetchUpgradeConfig('%%%'), isNull);
final notFoundUri = await _startJsonServer((_) async => throw 404); final notFoundUri = await _startJsonServer(
(_) async => throw const _HttpStatusException(404),
);
expect( expect(
await _runWithRealHttpClient( await _runWithRealHttpClient(
() => shellCoreTestHooks.fetchUpgradeConfig(notFoundUri.toString()), () => shellCoreTestHooks.fetchUpgradeConfig(notFoundUri.toString()),
@ -2487,8 +2708,9 @@ void main() {
), ),
); );
addTearDown(() { addTearDown(() {
shellCoreTestHooks.initializeEnvironment(_testEnvironment); shellCoreTestHooks
shellCoreTestHooks.setupUpgradeConfigUrl(null); ..initializeEnvironment(_testEnvironment)
..setupUpgradeConfigUrl(null);
}); });
final successUri = await _startJsonServer( final successUri = await _startJsonServer(
@ -2632,9 +2854,9 @@ void main() {
}); });
test('升级配置异步解析闭包按本地版本决定是否返回升级信息', () async { test('升级配置异步解析闭包按本地版本决定是否返回升级信息', () async {
addTearDown(() => shellCoreTestHooks.setLocalBuildNumberResolver(null)); addTearDown(() => shellCoreTestHooks.localBuildNumberResolver = null);
shellCoreTestHooks.setLocalBuildNumberResolver(() async => 200); shellCoreTestHooks.localBuildNumberResolver = () async => 200;
final version = await shellCoreTestHooks.resolveUpgradeConfig( final version = await shellCoreTestHooks.resolveUpgradeConfig(
const ShellUpgradeConfig( const ShellUpgradeConfig(
upgrade: ShellUpgradeReleaseConfig( upgrade: ShellUpgradeReleaseConfig(
@ -2649,7 +2871,7 @@ void main() {
expect(version?.versionBuildNumber, 300); expect(version?.versionBuildNumber, 300);
expect(version?.downloadUrl, 'https://example.com/app.apk'); expect(version?.downloadUrl, 'https://example.com/app.apk');
shellCoreTestHooks.setLocalBuildNumberResolver(() async => 300); shellCoreTestHooks.localBuildNumberResolver = () async => 300;
final sameVersion = await shellCoreTestHooks.resolveUpgradeConfig( final sameVersion = await shellCoreTestHooks.resolveUpgradeConfig(
const ShellUpgradeConfig( const ShellUpgradeConfig(
upgrade: ShellUpgradeReleaseConfig( upgrade: ShellUpgradeReleaseConfig(
@ -2661,7 +2883,7 @@ void main() {
); );
expect(sameVersion, isNull); expect(sameVersion, isNull);
shellCoreTestHooks.setLocalBuildNumberResolver(() async => null); shellCoreTestHooks.localBuildNumberResolver = () async => null;
final fallbackVersion = await shellCoreTestHooks.resolveUpgradeConfig( final fallbackVersion = await shellCoreTestHooks.resolveUpgradeConfig(
const ShellUpgradeConfig( const ShellUpgradeConfig(
upgrade: ShellUpgradeReleaseConfig( upgrade: ShellUpgradeReleaseConfig(
@ -2752,8 +2974,17 @@ const List<int> kTransparentImage = <int>[
0x82, 0x82,
]; ];
class _HttpStatusException implements Exception {
const _HttpStatusException(this.statusCode);
final int statusCode;
}
class _RealHttpOverrides extends HttpOverrides { class _RealHttpOverrides extends HttpOverrides {
@override @override
// Flutter test HttpOverrides
// 访 HTTP
// ignore: unnecessary_overrides
HttpClient createHttpClient(SecurityContext? context) { HttpClient createHttpClient(SecurityContext? context) {
return super.createHttpClient(context); return super.createHttpClient(context);
} }
@ -2805,20 +3036,107 @@ class _FakeUrlLauncherPlatform extends UrlLauncherPlatform {
class _FakeImagePickerPlatform extends ImagePickerPlatform { class _FakeImagePickerPlatform extends ImagePickerPlatform {
XFile? nextImage; XFile? nextImage;
XFile? nextVideo;
List<XFile> nextImages = <XFile>[];
List<XFile> nextVideos = <XFile>[];
List<XFile> nextMedia = <XFile>[];
bool shouldThrow = false; bool shouldThrow = false;
ImageSource? lastSource; ImageSource? lastSource;
String? lastMethod;
ImagePickerOptions? lastImageOptions;
MultiImagePickerOptions? lastMultiImageOptions;
MediaOptions? lastMediaOptions;
@override @override
Future<XFile?> getImageFromSource({ Future<XFile?> getImageFromSource({
required ImageSource source, required ImageSource source,
ImagePickerOptions options = const ImagePickerOptions(), ImagePickerOptions options = const ImagePickerOptions(),
}) async { }) async {
lastMethod = 'getImageFromSource';
lastSource = source; lastSource = source;
lastImageOptions = options;
if (shouldThrow) { if (shouldThrow) {
throw PlatformException(code: 'pick-failed'); throw PlatformException(code: 'pick-failed');
} }
return nextImage; return nextImage;
} }
@override
Future<List<XFile>> getMultiImageWithOptions({
MultiImagePickerOptions options = const MultiImagePickerOptions(),
}) async {
lastMethod = 'getMultiImageWithOptions';
lastMultiImageOptions = options;
if (shouldThrow) {
throw PlatformException(code: 'pick-failed');
}
return nextImages;
}
@override
Future<List<XFile>> getMedia({required MediaOptions options}) async {
lastMethod = 'getMedia';
lastMediaOptions = options;
if (shouldThrow) {
throw PlatformException(code: 'pick-failed');
}
return nextMedia;
}
@override
Future<XFile?> 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<List<XFile>> 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<String>? lastAllowedExtensions;
bool? lastAllowMultiple;
bool? lastWithData;
@override
Future<FilePickerResult?> pickFiles({
String? dialogTitle,
String? initialDirectory,
FileType type = FileType.any,
List<String>? 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 { class _FakeWebViewPlatform extends WebViewPlatform {

View File

@ -9,6 +9,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.19.1" 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: path:
dependency: transitive dependency: transitive
description: description:

View File

@ -8,3 +8,6 @@ environment:
dependencies: dependencies:
yaml: ^3.1.2 yaml: ^3.1.2
dev_dependencies:
lints: ^5.1.0