diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..5c024cc
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,17 @@
+# Changelog
+
+## Unreleased
+
+### 媒体能力
+- `web_shell_core` 新增 `pickVideo`、`captureVideo` JS Bridge 能力
+- 文件选择增加图片、视频与混合媒体 `accept` 识别,并按场景切换到更合适的系统选择器
+- 广义媒体选择默认返回原始文件参数,避免在文件上传场景里被图片压缩逻辑干扰
+
+### 平台与配置
+- Android 权限声明补充音频、用户可选媒体、震动与常亮相关能力
+- 启动配置与升级配置解析增强,补充上下文安全判断和更完整的错误日志
+
+### 文档与质量
+- 更新 `web_shell_core` README 的桥接能力说明
+- 调整 lint 依赖与分析配置
+- 扩充媒体相关测试覆盖,包括录像、媒体过滤和 WebView 文件选择分支
diff --git a/analysis_options.yaml b/analysis_options.yaml
index f13d6ae..7e34066 100644
--- a/analysis_options.yaml
+++ b/analysis_options.yaml
@@ -1,6 +1,6 @@
# Dart 分析器配置,用于在开发阶段发现错误、警告和代码规范问题。
# 可通过 `flutter analyze` 执行静态检查。
-include: package:flutter_lints/flutter.yaml
+include: package:lints/recommended.yaml
linter:
# 如需自定义规则,可在此处开启或关闭指定 lint。
diff --git a/packages/web_shell_core/CHANGELOG.md b/packages/web_shell_core/CHANGELOG.md
index 3575eb2..242868a 100644
--- a/packages/web_shell_core/CHANGELOG.md
+++ b/packages/web_shell_core/CHANGELOG.md
@@ -2,10 +2,23 @@
## Unreleased
-### 文档
-- 更新多品牌生成器文档,补充 `bootstrap_config_url`、`upgrade_config_url`、`preferred_orientations` 配置说明
-- 更新核心库接入文档,说明本地默认启动配置、远程启动配置缓存、独立升级配置与方向控制
-- 更新架构文档,补充启动配置与升级配置拆分后的启动流程
+### 新增
+- JS Bridge 新增 `pickVideo` 与 `captureVideo` 能力,支持图库选视频、相机录像与多选视频
+- `pickFile`/WebView 文件选择增加图片、视频与混合媒体 `accept` 识别,广义媒体类型会优先走系统媒体选择器
+- AndroidManifest 补充音频、用户可选媒体、震动与常亮相关权限声明
+
+### 变更
+- 图片与混合媒体的文件选择在广义 `accept` 场景下默认保留原始文件,不再附带压缩参数
+- 启动配置与升级配置模型补充更完整的字段说明、异常日志与上下文安全检查
+- README 更新 JS Bridge action 数量与媒体能力说明
+
+### 测试
+- 扩充媒体选择、文件过滤、WebView 文件上传与录像权限相关测试覆盖
+- 补充测试钩子与平台桩实现,便于验证图片/视频/文件选择分支
+
+### 工程
+- 根目录分析配置切换为 `package:lints/recommended.yaml`
+- 增加 `lints` 与 `very_good_analysis` 依赖以支持新的 lint 规则与测试辅助
## 0.0.1
diff --git a/packages/web_shell_core/README.md b/packages/web_shell_core/README.md
index 7b2335d..e73a866 100644
--- a/packages/web_shell_core/README.md
+++ b/packages/web_shell_core/README.md
@@ -8,9 +8,9 @@ Android 平板专用 H5 壳核心库。所有品牌应用共享此库,只需
|---|---|
| **WebView 引擎** | 自动兼容低版本 Android WebView,支持 texture / hybrid 双渲染模式自动切换 |
| **启动恢复** | 看门狗超时检测 → 渲染模式降级 → 深度清理 → 自动重试 |
-| **JS Bridge** | `window.AppShell` 协议,当前支持 12 个 Action |
+| **JS Bridge** | `window.AppShell` 协议,当前支持 16 个 Action |
| **旧相机兼容** | Monkey-patch `openCamera` / `captureImage` 兼容老 H5 页面 |
-| **媒体服务** | 相机拍照 · 图库选图 · 文件选择 · base64 / dataUrl / uri 三种序列化格式 |
+| **媒体服务** | 相机拍照/录像 · 图库选图/选视频/混合媒体选择 · 文件选择 · base64 / dataUrl / uri 三种序列化格式 |
| **权限服务** | camera · microphone · location · photos · videos · storage 统一映射 |
| **导航服务** | URL scheme 白名单路由,非 WebView 协议自动跳转外部应用 |
| **壳层 UI** | 启动加载动画 · 错误恢复页 · 进度条 · 不支持平台兜底页 |
@@ -106,6 +106,8 @@ WebShellPage.initState()
- `pickImage`
- `captureImage`
+- `pickVideo`
+- `captureVideo`
- `pickFile`
- `openExternal`
- `requestPermissions`
diff --git a/packages/web_shell_core/android/src/main/AndroidManifest.xml b/packages/web_shell_core/android/src/main/AndroidManifest.xml
index e9c4757..3123406 100644
--- a/packages/web_shell_core/android/src/main/AndroidManifest.xml
+++ b/packages/web_shell_core/android/src/main/AndroidManifest.xml
@@ -24,8 +24,14 @@
+
+
+
+
+
+
reloadUpgradeRuntimeConfigAndCheckVersion(
if (remoteConfig == null) {
return null;
}
+ if (!context.mounted) {
+ return remoteConfig;
+ }
await ShellUpgradeService.instance.checkVersion(
context,
diff --git a/packages/web_shell_core/lib/src/bridge/bridge_protocol.dart b/packages/web_shell_core/lib/src/bridge/bridge_protocol.dart
index d68cbe5..3977d9e 100644
--- a/packages/web_shell_core/lib/src/bridge/bridge_protocol.dart
+++ b/packages/web_shell_core/lib/src/bridge/bridge_protocol.dart
@@ -87,6 +87,8 @@ String _buildAppShellBridgeScript() {
version: '$_appShellBridgeVersion',
pickImage: (options = {}) => send('pickImage', options),
captureImage: (options = {}) => send('captureImage', options),
+ pickVideo: (options = {}) => send('pickVideo', options),
+ captureVideo: (options = {}) => send('captureVideo', options),
pickFile: (options = {}) => send('pickFile', options),
openExternal: (url) => send('openExternal', { url }),
requestPermissions: (types = []) => send('requestPermissions', { types }),
diff --git a/packages/web_shell_core/lib/src/engine/recovery.dart b/packages/web_shell_core/lib/src/engine/recovery.dart
index fc4e448..d9e6b21 100644
--- a/packages/web_shell_core/lib/src/engine/recovery.dart
+++ b/packages/web_shell_core/lib/src/engine/recovery.dart
@@ -33,10 +33,13 @@ String _parseRawErrorDescription(String description) {
return '请稍后重新加载页面。';
}
final lower = cleanDesc.toLowerCase();
- if (lower.contains('err_internet_disconnected') || lower.contains('err_address_unreachable') || lower.contains('err_name_not_resolved')) {
+ if (lower.contains('err_internet_disconnected') ||
+ lower.contains('err_address_unreachable') ||
+ lower.contains('err_name_not_resolved')) {
return '没有成功连接到服务器,请检查网络后重试。';
}
- if (lower.contains('err_connection_timed_out') || lower.contains('err_timed_out')) {
+ if (lower.contains('err_connection_timed_out') ||
+ lower.contains('err_timed_out')) {
return '当前网络较慢,请稍后重新加载。';
}
if (lower.contains('err_cert_') || lower.contains('err_ssl_')) {
diff --git a/packages/web_shell_core/lib/src/services/config_service.dart b/packages/web_shell_core/lib/src/services/config_service.dart
index adc7255..aad928b 100644
--- a/packages/web_shell_core/lib/src/services/config_service.dart
+++ b/packages/web_shell_core/lib/src/services/config_service.dart
@@ -2,6 +2,26 @@ part of '../../core_app.dart';
/// 壳应用的启动配置模型。
class ShellBootstrapConfig {
+ /// 创建一份启动配置。
+ const ShellBootstrapConfig({
+ this.initialUrl,
+ this.preferredOrientations,
+ this.bootstrapConfigUrl,
+ this.upgradeConfigUrl,
+ });
+
+ /// 从 JSON 映射解析启动配置。
+ factory ShellBootstrapConfig.fromJson(Map json) {
+ return ShellBootstrapConfig(
+ initialUrl: json['initialUrl']?.toString(),
+ preferredOrientations: _parsePreferredOrientations(
+ json['preferredOrientations'] ?? json['orientations'],
+ ),
+ bootstrapConfigUrl: json['bootstrapConfigUrl']?.toString(),
+ upgradeConfigUrl: json['upgradeConfigUrl']?.toString(),
+ );
+ }
+
/// 远程或本地配置指定的首页地址。
final String? initialUrl;
@@ -14,24 +34,6 @@ class ShellBootstrapConfig {
/// 可选的远程升级配置地址。
final String? upgradeConfigUrl;
- ShellBootstrapConfig({
- this.initialUrl,
- this.preferredOrientations,
- this.bootstrapConfigUrl,
- this.upgradeConfigUrl,
- });
-
- factory ShellBootstrapConfig.fromJson(Map json) {
- return ShellBootstrapConfig(
- initialUrl: json['initialUrl']?.toString(),
- preferredOrientations: _parsePreferredOrientations(
- json['preferredOrientations'] ?? json['orientations'],
- ),
- bootstrapConfigUrl: json['bootstrapConfigUrl']?.toString(),
- upgradeConfigUrl: json['upgradeConfigUrl']?.toString(),
- );
- }
-
static List? _parsePreferredOrientations(Object? value) {
if (value is! List) {
return null;
@@ -81,8 +83,8 @@ class ShellBootstrapConfigService {
try {
final content = await rootBundle.loadString(assetPath);
return _parseConfigString(content);
- } catch (e) {
- debugPrint('读取 WebShell 本地启动配置失败: $e');
+ } on Object catch (error, stackTrace) {
+ debugPrint('读取 WebShell 本地启动配置失败: $error\n$stackTrace');
return null;
}
}
@@ -103,8 +105,11 @@ class ShellBootstrapConfigService {
return _parseConfigString(content);
}
return _loadFromCache();
- } catch (e) {
- debugPrint('获取 WebShell 启动配置失败(网络异常/超时),尝试读取缓存: $e');
+ } on Object catch (error, stackTrace) {
+ debugPrint(
+ '获取 WebShell 启动配置失败(网络异常/超时),尝试读取缓存: '
+ '$error\n$stackTrace',
+ );
return _loadFromCache();
}
}
@@ -118,8 +123,8 @@ class ShellBootstrapConfigService {
debugPrint('成功读取到本地缓存的 WebShell 启动配置');
return _parseConfigString(cachedContent);
}
- } catch (e) {
- debugPrint('读取 WebShell 启动配置缓存失败: $e');
+ } on Object catch (error, stackTrace) {
+ debugPrint('读取 WebShell 启动配置缓存失败: $error\n$stackTrace');
}
return null;
}
@@ -134,8 +139,8 @@ class ShellBootstrapConfigService {
: jsonMap;
return ShellBootstrapConfig.fromJson(data);
}
- } catch (e) {
- debugPrint('解析 WebShell 启动配置异常: $e');
+ } on Object catch (error, stackTrace) {
+ debugPrint('解析 WebShell 启动配置异常: $error\n$stackTrace');
}
return null;
}
diff --git a/packages/web_shell_core/lib/src/services/media_service.dart b/packages/web_shell_core/lib/src/services/media_service.dart
index 9b27e64..8b43fee 100644
--- a/packages/web_shell_core/lib/src/services/media_service.dart
+++ b/packages/web_shell_core/lib/src/services/media_service.dart
@@ -3,10 +3,31 @@ part of '../../core_app.dart';
const double _pickedImageMaxWidth = 1600;
const double _pickedImageMaxHeight = 1600;
const int _pickedImageQuality = 85;
+const Set _imageFileExtensions = {
+ 'png',
+ 'jpg',
+ 'jpeg',
+ 'webp',
+ 'gif',
+ 'bmp',
+ 'heic',
+ 'heif',
+};
+const Set _videoFileExtensions = {
+ 'mp4',
+ 'mov',
+ 'avi',
+ 'wmv',
+ 'mkv',
+ 'm4v',
+ 'webm',
+ '3gp',
+};
Future _pickCameraImage(
ImagePicker imagePicker, {
bool showPermissionAlert = false,
+ bool preserveOriginal = false,
WebViewController? controller,
}) async {
final cameraStatus = await Permission.camera.request();
@@ -18,6 +39,9 @@ Future _pickCameraImage(
}
try {
+ if (preserveOriginal) {
+ return await imagePicker.pickImage(source: ImageSource.camera);
+ }
return await imagePicker.pickImage(
source: ImageSource.camera,
imageQuality: _pickedImageQuality,
@@ -33,6 +57,111 @@ Future _pickCameraImage(
}
}
+Future _pickCameraVideo(
+ ImagePicker imagePicker, {
+ bool showPermissionAlert = false,
+ WebViewController? controller,
+}) async {
+ final statuses = await [
+ Permission.camera,
+ Permission.microphone,
+ ].request();
+
+ final granted = statuses.values.every((status) => status.isGranted);
+
+ if (!granted) {
+ if (showPermissionAlert && controller != null) {
+ await _showWebAlert(controller, '请先在系统设置中允许相机和麦克风权限');
+ }
+ return null;
+ }
+
+ try {
+ return await imagePicker.pickVideo(
+ source: ImageSource.camera,
+ );
+ } on Object catch (error, stackTrace) {
+ debugPrint('调用相机录像失败:$error\n$stackTrace');
+ if (showPermissionAlert && controller != null) {
+ await _showWebAlert(controller, '无法打开系统相机录像,请稍后重试');
+ }
+ return null;
+ }
+}
+
+Future> _pickGalleryImages(
+ ImagePicker imagePicker, {
+ required bool multiple,
+ bool preserveOriginal = false,
+}) async {
+ if (multiple) {
+ if (preserveOriginal) {
+ return imagePicker.pickMultiImage();
+ }
+ return imagePicker.pickMultiImage(
+ imageQuality: _pickedImageQuality,
+ maxWidth: _pickedImageMaxWidth,
+ maxHeight: _pickedImageMaxHeight,
+ );
+ }
+
+ if (preserveOriginal) {
+ final file = await imagePicker.pickImage(source: ImageSource.gallery);
+ return file == null ? const [] : [file];
+ }
+
+ final file = await imagePicker.pickImage(
+ source: ImageSource.gallery,
+ imageQuality: _pickedImageQuality,
+ maxWidth: _pickedImageMaxWidth,
+ maxHeight: _pickedImageMaxHeight,
+ );
+ return file == null ? const [] : [file];
+}
+
+Future> _pickGalleryVideos(
+ ImagePicker imagePicker, {
+ required bool multiple,
+}) async {
+ if (multiple) {
+ return imagePicker.pickMultiVideo();
+ }
+
+ final file = await imagePicker.pickVideo(
+ source: ImageSource.gallery,
+ );
+ return file == null ? const [] : [file];
+}
+
+Future> _pickGalleryMedia(
+ ImagePicker imagePicker, {
+ required bool multiple,
+ bool preserveOriginal = false,
+}) async {
+ if (multiple) {
+ if (preserveOriginal) {
+ return imagePicker.pickMultipleMedia();
+ }
+ return imagePicker.pickMultipleMedia(
+ imageQuality: _pickedImageQuality,
+ maxWidth: _pickedImageMaxWidth,
+ maxHeight: _pickedImageMaxHeight,
+ );
+ }
+
+ if (preserveOriginal) {
+ final file = await imagePicker.pickMedia();
+ return file == null ? const [] : [file];
+ }
+
+ final file = await imagePicker.pickMedia(
+ imageQuality: _pickedImageQuality,
+ maxWidth: _pickedImageMaxWidth,
+ maxHeight: _pickedImageMaxHeight,
+ );
+ return file == null ? const [] : [file];
+}
+
Future>> _serializeXFiles(
List files, {
required String responseType,
@@ -100,6 +229,24 @@ List _xFilesToUriStrings(List files) {
return files.map((file) => Uri.file(file.path).toString()).toList();
}
+List _acceptTypesFromValue(Object? value) {
+ final rawTypes = switch (value) {
+ final String stringValue => stringValue.split(','),
+ final Iterable iterableValue => iterableValue.expand((item) {
+ final stringValue = item.toString();
+ return stringValue.contains(',')
+ ? stringValue.split(',')
+ : [stringValue];
+ }),
+ _ => const [],
+ };
+
+ return rawTypes
+ .map((type) => type.trim().toLowerCase())
+ .where((type) => type.isNotEmpty)
+ .toList();
+}
+
bool _acceptsImages(List acceptTypes) {
return acceptTypes
.map((type) => type.trim())
@@ -121,16 +268,123 @@ bool _acceptsOnlyImages(List acceptTypes) {
bool _isImageAcceptType(String acceptType) {
final value = acceptType.toLowerCase();
return value.startsWith('image/') ||
- const {
- '.png',
- '.jpg',
- '.jpeg',
- '.webp',
- '.gif',
- '.bmp',
- '.heic',
- '.heif',
- }.contains(value);
+ _imageFileExtensions.map((extension) => '.$extension').contains(value);
+}
+
+bool _acceptsVideos(List acceptTypes) {
+ return acceptTypes
+ .map((type) => type.trim())
+ .where((type) => type.isNotEmpty)
+ .any(_isVideoAcceptType);
+}
+
+bool _acceptsOnlyVideos(List acceptTypes) {
+ final normalizedTypes = acceptTypes
+ .map((type) => type.trim())
+ .where((type) => type.isNotEmpty)
+ .toList();
+ if (normalizedTypes.isEmpty) {
+ return false;
+ }
+ return normalizedTypes.every(_isVideoAcceptType);
+}
+
+bool _isVideoAcceptType(String acceptType) {
+ final value = acceptType.toLowerCase();
+ return value.startsWith('video/') ||
+ _videoFileExtensions.map((extension) => '.$extension').contains(value);
+}
+
+bool _acceptsOnlyMedia(List acceptTypes) {
+ final normalizedTypes = acceptTypes
+ .map((type) => type.trim())
+ .where((type) => type.isNotEmpty)
+ .toList();
+ if (normalizedTypes.isEmpty) {
+ return false;
+ }
+ return normalizedTypes.every(
+ (type) => _isImageAcceptType(type) || _isVideoAcceptType(type),
+ );
+}
+
+List _allowedExtensionsForAcceptTypes(List acceptTypes) {
+ final extensions = {};
+ for (final acceptType in acceptTypes) {
+ extensions.addAll(_extensionsForAcceptType(acceptType));
+ }
+ return extensions.toList()..sort();
+}
+
+List _extensionsForAcceptType(String acceptType) {
+ final value = acceptType.trim().toLowerCase();
+ if (value.isEmpty) {
+ return const [];
+ }
+ if (value.startsWith('.')) {
+ return [value.substring(1)];
+ }
+ return switch (value) {
+ 'image/*' => _imageFileExtensions.toList(),
+ 'video/*' => _videoFileExtensions.toList(),
+ 'image/png' => const ['png'],
+ 'image/jpeg' => const ['jpg', 'jpeg'],
+ 'image/webp' => const ['webp'],
+ 'image/gif' => const ['gif'],
+ 'image/bmp' => const ['bmp'],
+ 'image/heic' => const ['heic'],
+ 'image/heif' => const ['heif'],
+ 'video/mp4' => const ['mp4'],
+ 'video/quicktime' => const ['mov'],
+ 'video/x-msvideo' => const ['avi'],
+ 'video/x-ms-wmv' => const ['wmv'],
+ 'video/x-matroska' => const ['mkv'],
+ 'video/x-m4v' => const ['m4v'],
+ 'video/webm' => const ['webm'],
+ 'video/3gpp' => const ['3gp'],
+ 'application/pdf' => const ['pdf'],
+ 'text/plain' => const ['txt'],
+ 'application/vnd.android.package-archive' => const ['apk'],
+ _ => const [],
+ };
+}
+
+({FileType type, List? allowedExtensions})
+_filePickerFilterForAcceptTypes(
+ List acceptTypes,
+) {
+ final normalizedTypes = _acceptTypesFromValue(acceptTypes);
+ if (normalizedTypes.isEmpty) {
+ return (type: FileType.any, allowedExtensions: null);
+ }
+ if (!_shouldUseMediaPickerForFileSelection(normalizedTypes)) {
+ final allowedExtensions = _allowedExtensionsForAcceptTypes(normalizedTypes);
+ if (allowedExtensions.isNotEmpty) {
+ return (type: FileType.custom, allowedExtensions: allowedExtensions);
+ }
+ return (type: FileType.any, allowedExtensions: null);
+ }
+ if (_acceptsOnlyImages(normalizedTypes)) {
+ return (type: FileType.image, allowedExtensions: null);
+ }
+ if (_acceptsOnlyVideos(normalizedTypes)) {
+ return (type: FileType.video, allowedExtensions: null);
+ }
+ if (_acceptsOnlyMedia(normalizedTypes)) {
+ return (type: FileType.media, allowedExtensions: null);
+ }
+
+ return (type: FileType.any, allowedExtensions: null);
+}
+
+bool _shouldUseMediaPickerForFileSelection(List acceptTypes) {
+ final normalizedTypes = _acceptTypesFromValue(acceptTypes);
+ if (normalizedTypes.isEmpty) {
+ return false;
+ }
+ return normalizedTypes.every(
+ (type) => type == 'image/*' || type == 'video/*',
+ );
}
String _guessMimeType(String fileName) {
@@ -156,6 +410,30 @@ String _guessMimeType(String fileName) {
if (lower.endsWith('.heif')) {
return 'image/heif';
}
+ if (lower.endsWith('.mp4')) {
+ return 'video/mp4';
+ }
+ if (lower.endsWith('.mov')) {
+ return 'video/quicktime';
+ }
+ if (lower.endsWith('.avi')) {
+ return 'video/x-msvideo';
+ }
+ if (lower.endsWith('.wmv')) {
+ return 'video/x-ms-wmv';
+ }
+ if (lower.endsWith('.mkv')) {
+ return 'video/x-matroska';
+ }
+ if (lower.endsWith('.m4v')) {
+ return 'video/x-m4v';
+ }
+ if (lower.endsWith('.webm')) {
+ return 'video/webm';
+ }
+ if (lower.endsWith('.3gp')) {
+ return 'video/3gpp';
+ }
if (lower.endsWith('.pdf')) {
return 'application/pdf';
}
@@ -176,6 +454,155 @@ Future _showWebAlert(WebViewController controller, String message) async {
}
}
+Future?> _pickAcceptedMediaFiles(
+ ImagePicker imagePicker, {
+ required List acceptTypes,
+ required bool capture,
+ required bool multiple,
+ WebViewController? controller,
+}) async {
+ if (!_shouldUseMediaPickerForFileSelection(acceptTypes)) {
+ return null;
+ }
+
+ if (capture) {
+ if (_acceptsOnlyImages(acceptTypes)) {
+ return [
+ if (await _pickCameraImage(
+ imagePicker,
+ showPermissionAlert: true,
+ preserveOriginal: true,
+ controller: controller,
+ )
+ case final XFile file)
+ file,
+ ];
+ }
+ if (_acceptsOnlyVideos(acceptTypes)) {
+ return [
+ if (await _pickCameraVideo(
+ imagePicker,
+ showPermissionAlert: true,
+ controller: controller,
+ )
+ case final XFile file)
+ file,
+ ];
+ }
+ return null;
+ }
+
+ if (_acceptsOnlyImages(acceptTypes)) {
+ return _pickGalleryImages(
+ imagePicker,
+ multiple: multiple,
+ preserveOriginal: true,
+ );
+ }
+ if (_acceptsOnlyVideos(acceptTypes)) {
+ return _pickGalleryVideos(
+ imagePicker,
+ multiple: multiple,
+ );
+ }
+ if (_acceptsOnlyMedia(acceptTypes)) {
+ return _pickGalleryMedia(
+ imagePicker,
+ multiple: multiple,
+ preserveOriginal: true,
+ );
+ }
+ return null;
+}
+
+Future