feat: extend media bridge and document changes
This commit is contained in:
parent
40d1b179d0
commit
c1d2db1d60
|
|
@ -0,0 +1,17 @@
|
|||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### 媒体能力
|
||||
- `web_shell_core` 新增 `pickVideo`、`captureVideo` JS Bridge 能力
|
||||
- 文件选择增加图片、视频与混合媒体 `accept` 识别,并按场景切换到更合适的系统选择器
|
||||
- 广义媒体选择默认返回原始文件参数,避免在文件上传场景里被图片压缩逻辑干扰
|
||||
|
||||
### 平台与配置
|
||||
- Android 权限声明补充音频、用户可选媒体、震动与常亮相关能力
|
||||
- 启动配置与升级配置解析增强,补充上下文安全判断和更完整的错误日志
|
||||
|
||||
### 文档与质量
|
||||
- 更新 `web_shell_core` README 的桥接能力说明
|
||||
- 调整 lint 依赖与分析配置
|
||||
- 扩充媒体相关测试覆盖,包括录像、媒体过滤和 WebView 文件选择分支
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Dart 分析器配置,用于在开发阶段发现错误、警告和代码规范问题。
|
||||
# 可通过 `flutter analyze` 执行静态检查。
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
include: package:lints/recommended.yaml
|
||||
|
||||
linter:
|
||||
# 如需自定义规则,可在此处开启或关闭指定 lint。
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -24,8 +24,14 @@
|
|||
<!-- 媒体与硬件交互 (permission_handler 所需) -->
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<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_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
|
||||
|
|
|
|||
|
|
@ -112,6 +112,9 @@ Future<ShellUpgradeConfig?> reloadUpgradeRuntimeConfigAndCheckVersion(
|
|||
if (remoteConfig == null) {
|
||||
return null;
|
||||
}
|
||||
if (!context.mounted) {
|
||||
return remoteConfig;
|
||||
}
|
||||
|
||||
await ShellUpgradeService.instance.checkVersion(
|
||||
context,
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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_')) {
|
||||
|
|
|
|||
|
|
@ -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<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;
|
||||
|
||||
|
|
@ -14,24 +34,6 @@ class ShellBootstrapConfig {
|
|||
/// 可选的远程升级配置地址。
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,31 @@ part of '../../core_app.dart';
|
|||
const double _pickedImageMaxWidth = 1600;
|
||||
const double _pickedImageMaxHeight = 1600;
|
||||
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(
|
||||
ImagePicker imagePicker, {
|
||||
bool showPermissionAlert = false,
|
||||
bool preserveOriginal = false,
|
||||
WebViewController? controller,
|
||||
}) async {
|
||||
final cameraStatus = await Permission.camera.request();
|
||||
|
|
@ -18,6 +39,9 @@ Future<XFile?> _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<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(
|
||||
List<XFile> files, {
|
||||
required String responseType,
|
||||
|
|
@ -100,6 +229,24 @@ List<String> _xFilesToUriStrings(List<XFile> files) {
|
|||
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) {
|
||||
return acceptTypes
|
||||
.map((type) => type.trim())
|
||||
|
|
@ -121,16 +268,123 @@ bool _acceptsOnlyImages(List<String> acceptTypes) {
|
|||
bool _isImageAcceptType(String acceptType) {
|
||||
final value = acceptType.toLowerCase();
|
||||
return value.startsWith('image/') ||
|
||||
const <String>{
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.webp',
|
||||
'.gif',
|
||||
'.bmp',
|
||||
'.heic',
|
||||
'.heif',
|
||||
}.contains(value);
|
||||
_imageFileExtensions.map((extension) => '.$extension').contains(value);
|
||||
}
|
||||
|
||||
bool _acceptsVideos(List<String> acceptTypes) {
|
||||
return acceptTypes
|
||||
.map((type) => type.trim())
|
||||
.where((type) => type.isNotEmpty)
|
||||
.any(_isVideoAcceptType);
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
@ -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<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}) {
|
||||
return switch (value) {
|
||||
final bool boolValue => boolValue,
|
||||
|
|
|
|||
|
|
@ -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<String, dynamic> 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<String, dynamic>.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<String, dynamic> json) {
|
||||
final data = json['data'];
|
||||
final dataMap = data is Map<String, dynamic>
|
||||
|
|
@ -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<String, dynamic>? _readShellUpgradeEmbeddedConfig(String? value) {
|
|||
if (decoded is Map) {
|
||||
return Map<String, dynamic>.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<int?> 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<int?> Function()? resolver) {
|
||||
Future<int?> Function()? get debugLocalBuildNumberResolver =>
|
||||
_localBuildNumberResolver;
|
||||
|
||||
/// 仅供测试注入本地版本号解析逻辑。
|
||||
@visibleForTesting
|
||||
set debugLocalBuildNumberResolver(Future<int?> 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,8 +93,12 @@ class ShellCoreTestHooks {
|
|||
}
|
||||
|
||||
/// 为测试注入本地构建号解析逻辑。
|
||||
void setLocalBuildNumberResolver(Future<int?> Function()? resolver) {
|
||||
ShellUpgradeService.instance.debugSetLocalBuildNumberResolver(resolver);
|
||||
Future<int?> Function()? get localBuildNumberResolver =>
|
||||
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 字符串。
|
||||
List<String> xFilesToUriStrings(List<XFile> files) {
|
||||
return _xFilesToUriStrings(files);
|
||||
|
|
@ -225,6 +242,83 @@ class ShellCoreTestHooks {
|
|||
/// 判断单个 accept 标记是否应视为图片类型。
|
||||
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 类型。
|
||||
String guessMimeType(String fileName) => _guessMimeType(fileName);
|
||||
|
||||
|
|
|
|||
|
|
@ -730,6 +730,16 @@ class _WebShellPageState extends State<WebShellPage>
|
|||
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<WebShellPage>
|
|||
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<WebShellPage>
|
|||
source == ImageSource.gallery && _boolValue(payload['multiple']);
|
||||
final responseType = (payload['responseType'] ?? 'dataUrl').toString();
|
||||
|
||||
var files = <XFile>[];
|
||||
if (source == ImageSource.camera) {
|
||||
final file = await _pickCameraImage(
|
||||
_imagePicker,
|
||||
showPermissionAlert: true,
|
||||
controller: _controller,
|
||||
);
|
||||
if (file != null) {
|
||||
files = <XFile>[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 = <XFile>[file];
|
||||
}
|
||||
}
|
||||
final files = source == ImageSource.camera
|
||||
? <XFile>[
|
||||
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<Object?> _pickVideosFromBridge({
|
||||
required ImageSource source,
|
||||
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,
|
||||
);
|
||||
|
||||
final serialized = await _serializeXFiles(
|
||||
files,
|
||||
|
|
@ -840,25 +864,11 @@ class _WebShellPageState extends State<WebShellPage>
|
|||
}
|
||||
|
||||
Future<Object?> _pickFilesFromBridge(Map<String, dynamic> 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']) ? <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 {
|
||||
|
|
@ -921,50 +931,11 @@ class _WebShellPageState extends State<WebShellPage>
|
|||
}
|
||||
|
||||
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 <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,
|
||||
return _selectFilesForWebView(
|
||||
params,
|
||||
imagePicker: _imagePicker,
|
||||
controller: _controller,
|
||||
);
|
||||
|
||||
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) {
|
||||
debugPrint('处理文件选择失败:$error\n$stackTrace');
|
||||
return <String>[];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<Uri> _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<String> platformCalls;
|
||||
late List<MethodCall> 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(<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('权限类型映射正确', () {
|
||||
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 <String>[
|
||||
'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<int> kTransparentImage = <int>[
|
|||
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<XFile> nextImages = <XFile>[];
|
||||
List<XFile> nextVideos = <XFile>[];
|
||||
List<XFile> nextMedia = <XFile>[];
|
||||
bool shouldThrow = false;
|
||||
ImageSource? lastSource;
|
||||
String? lastMethod;
|
||||
ImagePickerOptions? lastImageOptions;
|
||||
MultiImagePickerOptions? lastMultiImageOptions;
|
||||
MediaOptions? lastMediaOptions;
|
||||
|
||||
@override
|
||||
Future<XFile?> 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<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 {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -8,3 +8,6 @@ environment:
|
|||
|
||||
dependencies:
|
||||
yaml: ^3.1.2
|
||||
|
||||
dev_dependencies:
|
||||
lints: ^5.1.0
|
||||
|
|
|
|||
Loading…
Reference in New Issue