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 分析器配置,用于在开发阶段发现错误、警告和代码规范问题。
|
# Dart 分析器配置,用于在开发阶段发现错误、警告和代码规范问题。
|
||||||
# 可通过 `flutter analyze` 执行静态检查。
|
# 可通过 `flutter analyze` 执行静态检查。
|
||||||
include: package:flutter_lints/flutter.yaml
|
include: package:lints/recommended.yaml
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
# 如需自定义规则,可在此处开启或关闭指定 lint。
|
# 如需自定义规则,可在此处开启或关闭指定 lint。
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 }),
|
||||||
|
|
|
||||||
|
|
@ -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_')) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
)
|
||||||
if (file != null) {
|
case final XFile file)
|
||||||
files = <XFile>[file];
|
file,
|
||||||
}
|
]
|
||||||
} else if (multiple) {
|
: await _pickGalleryImages(
|
||||||
files = await _imagePicker.pickMultiImage(
|
_imagePicker,
|
||||||
imageQuality: _pickedImageQuality,
|
multiple: multiple,
|
||||||
maxWidth: _pickedImageMaxWidth,
|
);
|
||||||
maxHeight: _pickedImageMaxHeight,
|
|
||||||
);
|
final serialized = await _serializeXFiles(
|
||||||
} else {
|
files,
|
||||||
final file = await _imagePicker.pickImage(
|
responseType: responseType,
|
||||||
source: ImageSource.gallery,
|
);
|
||||||
imageQuality: _pickedImageQuality,
|
return multiple ? serialized : serialized.firstOrNull;
|
||||||
maxWidth: _pickedImageMaxWidth,
|
}
|
||||||
maxHeight: _pickedImageMaxHeight,
|
|
||||||
);
|
Future<Object?> _pickVideosFromBridge({
|
||||||
if (file != null) {
|
required ImageSource source,
|
||||||
files = <XFile>[file];
|
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(
|
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>[];
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -8,3 +8,6 @@ environment:
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
yaml: ^3.1.2
|
yaml: ^3.1.2
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
lints: ^5.1.0
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue