feat: extend media bridge and document changes

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

17
CHANGELOG.md Normal file
View File

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

View File

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

View File

@ -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

View File

@ -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`

View File

@ -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

View File

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

View File

@ -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 }),

View File

@ -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_')) {

View File

@ -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;
}

View File

@ -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,

View File

@ -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;
}

View File

@ -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);

View File

@ -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(
final files = source == ImageSource.camera
? <XFile>[
if (await _pickCameraImage(
_imagePicker,
showPermissionAlert: true,
controller: _controller,
)
case final XFile file)
file,
]
: await _pickGalleryImages(
_imagePicker,
multiple: multiple,
);
if (file != null) {
files = <XFile>[file];
}
} else if (multiple) {
files = await _imagePicker.pickMultiImage(
imageQuality: _pickedImageQuality,
maxWidth: _pickedImageMaxWidth,
maxHeight: _pickedImageMaxHeight,
final serialized = await _serializeXFiles(
files,
responseType: responseType,
);
} else {
final file = await _imagePicker.pickImage(
source: ImageSource.gallery,
imageQuality: _pickedImageQuality,
maxWidth: _pickedImageMaxWidth,
maxHeight: _pickedImageMaxHeight,
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,
);
if (file != null) {
files = <XFile>[file];
}
}
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],
return _selectFilesForWebView(
params,
imagePicker: _imagePicker,
controller: _controller,
);
}
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) {
debugPrint('处理文件选择失败:$error\n$stackTrace');
return <String>[];

View File

@ -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

View File

@ -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 {

View File

@ -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:

View File

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