diff --git a/README.md b/README.md index abd74d9..04fa9e7 100644 --- a/README.md +++ b/README.md @@ -1,114 +1,198 @@ # web_android_shell -Android 平板专用 H5 壳应用 — Monorepo 多品牌架构。 +Android 平板专用 H5 壳应用 Monorepo,面向多品牌白标场景。 -## 项目结构 +## 当前品牌 -``` +- `apps/aixue`:爱学蝶变 +- `apps/test`:测试壳工程 +- `apps/yunxiao`:云校嗨学 + +## 目录结构 + +```text web_android_shell/ ├── apps/ # 品牌应用(每个品牌一个 Flutter App) -│ └── quanxue/ # 全学通 +│ ├── aixue/ +│ ├── test/ +│ └── yunxiao/ +├── flavors/ # 品牌配置 + 品牌资源源文件 +│ ├── aixue.yaml +│ ├── test.yaml +│ ├── yunxiao.yaml +│ ├── aixue/ +│ ├── test/ +│ └── yunxiao/ ├── packages/ -│ ├── web_shell_core/ # 核心库(WebView 引擎 + Bridge + 服务) -│ └── web_android_shell/ # 旧版入口(已迁移至 apps/quanxue) -├── flavors/ # 品牌配置 + 品牌资源 -│ ├── quanxue.yaml # 品牌配置 -│ └── quanxue/ # 品牌资源(图标、启动页) -│ ├── icon.png -│ ├── icon_foreground.png -│ └── splash.png +│ ├── web_shell_core/ # 核心壳能力库 +│ └── web_android_shell/ # 历史包,当前不作为主入口维护 ├── tool/ -│ ├── generate_app.dart # 一键生成新品牌应用 -│ └── flutter_run_fresh.ps1 # Windows 调试脚本(自动杀旧进程) -└── doc/ # 项目文档 +│ ├── generate_app.dart # 按 flavor 一键生成品牌 App +│ └── flutter_run_fresh.ps1 # Windows 调试脚本 +└── doc/ + └── architecture.md # 架构说明 ``` +## 核心思路 + +- 应用启动优先读取本地默认启动配置 `assets/config/bootstrap.json` +- 如配置了 `bootstrap_config_url`,会继续拉取远程启动配置,并使用缓存兜底 +- 如配置了 `upgrade_config_url`,首帧后异步检查版本更新 +- 启动配置与升级配置已拆分,不再共用同一个 JSON +- 屏幕方向支持通过启动配置中的 `preferredOrientations` 控制 + ## 快速开始 -### 1. 运行已有品牌 +### 运行已有品牌 ```bash -cd apps/quanxue +cd apps/aixue flutter run ``` -### 2. 生成新品牌 +也可以替换为: ```bash -# 1) 在 flavors/ 下创建品牌配置 -cp flavors/quanxue.yaml flavors/新品牌.yaml -# 2) 修改配置中的 app_name, application_id, app_key, theme, branding -# 3) 准备品牌资源(参见下方规格要求) -mkdir flavors/新品牌 -cp 你的图标.png flavors/新品牌/icon.png -cp 你的前景图.png flavors/新品牌/icon_foreground.png -cp 你的启动图.png flavors/新品牌/splash.png -# 4) 运行生成脚本 -dart run tool/generate_app.dart 新品牌 +cd apps/test +flutter run +``` + +```bash +cd apps/yunxiao +flutter run +``` + +### 生成或重建品牌应用 + +```bash +dart run tool/generate_app.dart aixue +dart run tool/generate_app.dart test +dart run tool/generate_app.dart yunxiao ``` 生成脚本会自动完成: -- 创建 Flutter 应用 → `apps/新品牌/` -- 添加 `web_shell_core` 依赖 -- 覆写 `MainActivity` 继承 `CoreShellActivity` -- 更新 `AndroidManifest.xml` 应用名 -- 生成品牌入口 `main.dart` -- 复制品牌资源到生成目录 -- 添加 `flutter_launcher_icons` / `flutter_native_splash` 依赖 -- 自动生成应用图标和启动页 -### 3. 品牌配置格式 +- 创建 Flutter Android 应用到 `apps/<品牌名>/` +- 添加 `web_shell_core` 依赖 +- 修正 Android `namespace` 与 `applicationId` +- 生成 `MainActivity` 并继承 `CoreShellActivity` +- 生成品牌入口 `lib/main.dart` +- 生成本地默认启动配置 `assets/config/bootstrap.json` +- 复制品牌资源到 `assets/branding/` +- 注册 `assets/branding/` 与 `assets/config/` +- 生成 launcher icon 与 splash +- 注入 Android 签名配置 + +## Flavor 配置格式 + +每个品牌在 `flavors/<品牌名>.yaml` 中维护配置。 ```yaml -app_name: "全学通" # 应用名 -application_id: "com.yuanxuan.quanxue" # 包名 -app_key: "quanxue_prod" # 业务标识 +app_name: "全学通" +application_id: "com.yuanxuan.quanxue" +app_key: "quanxue_prod" +default_url: "https://example.com/login" +bootstrap_config_url: "https://example.com/bootstrap.json" +upgrade_config_url: "https://example.com/upgrade.json" +preferred_orientations: + - "portraitUp" + - "portraitDown" theme: - accent_color: "0xFF3ED37B" # 主题色 - bg_color: "0xFFFFFFFF" # 背景色 - text_color: "0xFF1F2937" # 主文字色 - muted_text_color: "0xFF6B7280" # 次要文字色 + accent_color: "0xFF3ED37B" + bg_color: "0xFFFFFFFF" + text_color: "0xFF1F2937" + muted_text_color: "0xFF6B7280" branding: - icon: "icon.png" # 应用图标(相对于 flavors/<品牌>/) - icon_background: "#FFFFFF" # 自适应图标背景色 - icon_foreground: "icon_foreground.png" # 自适应图标前景 - splash: "splash.png" # 启动页图片 - splash_color: "#FFFFFF" # 启动页背景色 + icon: "icon.png" + icon_background: "#FFFFFF" + icon_foreground: "icon_foreground.png" + splash: "splash.png" + splash_color: "#FFFFFF" ``` -### 4. 品牌资源规格 +### 字段说明 + +- `default_url`:生成到本地默认启动配置 `assets/config/bootstrap.json` +- `bootstrap_config_url`:可选,远程启动配置地址;为空时不会写入 `main.dart` +- `upgrade_config_url`:可选,远程升级配置地址;为空时不会写入 `main.dart` +- `preferred_orientations`:首屏方向配置,支持: + - `portraitUp` + - `portraitDown` + - `landscapeLeft` + - `landscapeRight` + +## 生成后的入口形态 + +生成器会把品牌入口写成统一模板: + +```dart +runShellApp( + ShellEnvironment( + appName: '品牌名', + appKey: 'brand_key', + accentColor: const Color(0xFF3ED37B), + backgroundColor: const Color(0xFFFFFFFF), + textColor: const Color(0xFF1F2937), + mutedTextColor: const Color(0xFF6B7280), + splashImage: const AssetImage('assets/branding/splash.png'), + bootstrapConfigAsset: 'assets/config/bootstrap.json', + bootstrapConfigUrl: 'https://example.com/bootstrap.json', + upgradeConfigUrl: 'https://example.com/upgrade.json', + ), +); +``` + +其中远程地址为空时,对应字段不会写入生成结果。 + +## 本地默认启动配置格式 + +生成器会自动生成 `assets/config/bootstrap.json`: + +```json +{ + "initialUrl": "https://example.com/login", + "preferredOrientations": [ + "portraitUp", + "portraitDown" + ] +} +``` + +## 品牌资源规格 | 资源 | 文件名 | 最小尺寸 | 格式 | 说明 | |---|---|---|---|---| -| 应用图标 | `icon.png` | 1024×1024 | PNG | 正方形,用于生成各尺寸 launcher icon | -| 自适应图标前景 | `icon_foreground.png` | 1024×1024 | PNG (透明背景) | 主体内容需居中,四周留 **25% 安全区** | -| 启动页图片 | `splash.png` | 1152×1152 | PNG | 用于生成 Android 12+ 和旧版启动页 | +| 应用图标 | `icon.png` | 1024×1024 | PNG | 用于生成各尺寸 launcher icon | +| 自适应图标前景 | `icon_foreground.png` | 1024×1024 | PNG | 建议透明背景,主体留安全区 | +| 启动页图片 | `splash.png` | 1152×1152 | PNG | 用于生成 Android 12+ 与旧版启动页 | -> **提示:** 资源文件放置在 `flavors/<品牌名>/` 目录下,`branding` 中的路径相对于此目录。如果省略 `branding` 段,脚本会跳过图标和启动页生成。 +品牌资源源文件放在 `flavors/<品牌名>/` 下,生成后会复制到 `apps/<品牌名>/assets/branding/`。 ## 调试说明 -部分教育平板设备使用 `Ctrl+C` 结束 `flutter run` 后,旧进程可能留在后台影响 WebView 启动。 +部分教育平板设备在 `flutter run` 中断后,旧进程可能残留并影响 WebView 启动。 + +推荐做法: -**推荐做法:** - 调试结束时在 `flutter run` 控制台按 `q` 退出 -- 或使用调试脚本自动杀旧进程再启动: +- 或使用: ```powershell -.\tool\flutter_run_fresh.ps1 # 自动选设备 -.\tool\flutter_run_fresh.ps1 -d F136A # 指定设备 +.\tool\flutter_run_fresh.ps1 +.\tool\flutter_run_fresh.ps1 -d F136A ``` ## 技术栈 | 组件 | 技术 | |---|---| -| 框架 | Flutter 3.x (Dart 3.11+) | +| 框架 | Flutter 3.x / Dart 3.11+ | | WebView | `webview_flutter` + `webview_flutter_android` | | 宿主能力 | `image_picker` · `file_picker` · `permission_handler` · `url_launcher` | -| 原生层 | Kotlin Plugin + Java `CoreShellActivity` | -| 代码规范 | `very_good_analysis` | +| 配置缓存 | `shared_preferences` | +| 网络与状态 | `http` · `connectivity_plus` | +| 原生层 | Java `CoreShellActivity` + Kotlin plugin | ## 平台约束 -**仅支持 Android 平板。** iOS / Web / Desktop 平台已移除。 +仅支持 Android 平板。 diff --git a/doc/architecture.md b/doc/architecture.md index 9f93302..3fdc544 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -2,92 +2,140 @@ ## 整体架构 +```text +┌────────────────────────────────────────────────────────────┐ +│ apps/aixue apps/test apps/yunxiao apps/... │ 品牌应用层 +│ main.dart 只负责传入 ShellEnvironment │ +├────────────────────────────────────────────────────────────┤ +│ web_shell_core │ 核心库 +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ config │ │ engine │ │ bridge │ │ +│ │ 启动配置 │ │ 兼容引擎 │ │ JS 桥接 │ │ +│ └──────────┘ └──────────┘ └──────────┘ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ services │ │ ui │ │ testing │ │ +│ │ 宿主服务 │ │ 壳层界面 │ │ 测试钩子 │ │ +│ └──────────┘ └──────────┘ └──────────┘ │ +├────────────────────────────────────────────────────────────┤ +│ CoreShellActivity (Java) │ 原生层 +│ 进程隔离 · WebView 信息查询 · 深度重置 │ +└────────────────────────────────────────────────────────────┘ ``` -┌──────────────────────────────────────────────────────┐ -│ apps/quanxue apps/品牌B apps/品牌C │ 品牌应用层 -│ (16 行 main.dart) │ 只传 ShellEnvironment -├──────────────────────────────────────────────────────┤ -│ web_shell_core │ 核心库 -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ -│ │ config │ │ engine │ │ bridge │ │ -│ │ 环境配置 │ │ 兼容引擎 │ │ JS 桥接 │ │ -│ └──────────┘ └──────────┘ └──────────┘ │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ -│ │ services │ │ ui │ │ testing │ │ -│ │ 宿主服务 │ │ 壳层界面 │ │ 测试钩子 │ │ -│ └──────────┘ └──────────┘ └──────────┘ │ -├──────────────────────────────────────────────────────┤ -│ CoreShellActivity (Java) │ 原生层 -│ 进程隔离 · WebView 信息查询 · 深度重置 │ -└──────────────────────────────────────────────────────┘ + +## 配置分层 + +### 启动配置 + +启动配置负责影响首屏加载行为,当前拆成两层: + +1. 本地默认启动配置:`assets/config/bootstrap.json` +2. 远程启动配置:`bootstrap_config_url` + +字段示例: + +```json +{ + "initialUrl": "https://example.com/login", + "preferredOrientations": ["portraitUp", "portraitDown"] +} +``` + +### 升级配置 + +升级配置仅用于版本检查,不参与首页地址解析,单独由 `upgrade_config_url` 拉取。 + +字段示例: + +```json +{ + "versionName": "1.0.1", + "version": 101, + "isForce": 0, + "remark": "1. 修复已知问题", + "filePath": "https://example.com/app-release.apk", + "fileSize": 25000 +} ``` ## 核心流程 ### 1. 启动流程 -``` -main() → runShellApp(env) - → WidgetsFlutterBinding.ensureInitialized() - → 设置屏幕方向(竖屏锁定) - → 进入沉浸式模式 - → runApp(ShellApp) - → Android? → WebShellPage(WebView 容器) - → 其他? → UnsupportedPlatformPage(兜底页) +```text +main() + → runShellApp(env) + → WidgetsFlutterBinding.ensureInitialized() + → 读取本地默认启动配置 bootstrapConfigAsset + → 拉取远程启动配置 bootstrapConfigUrl(可选,失败走缓存) + → 合并 initialUrl / preferredOrientations + → setupConfigUrl(upgradeConfigUrl) + → _initializeUrls() + → SystemChrome.setPreferredOrientations(...) + → 进入沉浸式模式 + → runApp(ShellApp) + → Android → WebShellPage + → 非 Android → UnsupportedPlatformPage ``` ### 2. WebView 启动与恢复 -``` +```text WebShellPage.initState() → 查询 Android WebView 信息(SDK / 包名 / 版本号) → 生成兼容性策略(renderModes / useWideViewPort / aggressiveRecovery) - → 创建 WebView(默认 texture 模式) - → 首帧就绪后加载初始 URL - → 启动看门狗计时器 - → 超时? → 切换渲染模式(hybrid)→ 深度清理 → 自动重试 - → 再超时? → 展示错误页 + 兼容性提示 + → 创建 WebView + → 首帧后加载 _initialUrl + → 同时异步执行 checkVersion(context) + → 主帧成功 → 注入 Bridge + → 主帧失败 → 展示 ErrorOverlay,不再白屏 + → 启动超时 → 切换渲染模式 / 深度清理 / 自动重试 ``` ### 3. JS Bridge 协议 -``` -H5 页面 Flutter 壳 - │ │ - │ AppShellChannel. │ - │ postMessage(JSON) │ - │ ──────────────────────→ │ 解析 action + payload - │ │ 执行对应 handler - │ window. │ - │ __appShellReceiveResponse│ - │ ←────────────────────── │ 返回 { requestId, success, data/error } +```text +H5 页面 Flutter 壳 + │ │ + │ AppShellChannel.postMessage │ + │──────────────────────────────→ │ 解析 action + payload + │ │ 执行 handler + │ __appShellReceiveResponse │ + │←────────────────────────────── │ 返回 { requestId, success, data/error } ``` -**支持的 Action:** +当前支持 12 个 Action: -| Action | 说明 | 返回 | -|---|---|---| -| `pickImage` | 从图库选图(支持多选) | `[{name, uri, mimeType, size, dataUrl}]` | -| `captureImage` | 相机拍照 | `{name, uri, mimeType, size, dataUrl}` | -| `pickFile` | 文件选择 | `[{name, uri, mimeType, size, dataUrl}]` | -| `openExternal` | 打开外部应用 | `boolean` | -| `requestPermissions` | 请求系统权限 | `{type: statusName}` | -| `reloadPage` | 重新加载当前页面 | `true` | -| `goBack` | 返回上一页 | `boolean` | -| `closeApp` | 关闭应用 | 无(直接退出) | +| Action | 说明 | +|---|---| +| `pickImage` | 从图库选图 | +| `captureImage` | 相机拍照 | +| `pickFile` | 文件选择 | +| `openExternal` | 打开外部应用 | +| `requestPermissions` | 请求系统权限 | +| `reloadPage` | 重新加载当前页面 | +| `goBack` | 返回上一页 | +| `closeApp` | 关闭应用 | +| `getDeviceInfo` | 获取设备信息 | +| `getNetworkStatus` | 获取网络状态 | +| `showToast` | 展示 Toast | +| `setStatusBar` | 设置状态栏样式 | ## 兼容性策略 | 条件 | 渲染模式 | 恢复策略 | |---|---|---| -| SDK ≥ 29 + WebView ≥ 113 | texture 优先 | 标准恢复 | -| SDK ≤ 28 或 WebView < 113 | hybrid 优先 | 激进恢复(2 次重试) | +| SDK ≥ 29 + 新版 WebView | texture 优先 | 标准恢复 | +| SDK ≤ 28 或旧版 WebView | hybrid 优先 | 激进恢复 | | F136A 设备 | hybrid 优先 | 激进恢复 + 建议更新 WebView | -## 新增品牌 +## 新增品牌流程 -1. 创建 `flavors/品牌名.yaml` -2. 运行 `dart run tool/generate_app.dart 品牌名` -3. 脚本自动生成完整的 Flutter App 在 `apps/品牌名/` -4. 修改图标后运行 `flutter pub run flutter_launcher_icons` -5. 构建 APK:`flutter build apk --release` +1. 新建 `flavors/品牌名.yaml` +2. 在 `flavors/品牌名/` 放置品牌资源 +3. 执行 `dart run tool/generate_app.dart 品牌名` +4. 生成器自动产出: + - `apps/品牌名/lib/main.dart` + - `apps/品牌名/assets/config/bootstrap.json` + - `apps/品牌名/assets/branding/*` + - Android 图标、启动页、签名配置 +5. 构建 APK:`cd apps/品牌名 && flutter build apk --release` diff --git a/packages/web_shell_core/CHANGELOG.md b/packages/web_shell_core/CHANGELOG.md index 93b39fc..3575eb2 100644 --- a/packages/web_shell_core/CHANGELOG.md +++ b/packages/web_shell_core/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### 文档 +- 更新多品牌生成器文档,补充 `bootstrap_config_url`、`upgrade_config_url`、`preferred_orientations` 配置说明 +- 更新核心库接入文档,说明本地默认启动配置、远程启动配置缓存、独立升级配置与方向控制 +- 更新架构文档,补充启动配置与升级配置拆分后的启动流程 + ## 0.0.1 ### 新增 @@ -8,9 +15,9 @@ - 15 个模块文件拆分(config / engine / bridge / services / ui) - Android WebView 兼容性自动检测(`AndroidWebViewInfo` + `AndroidCompatibilityPlan`) - 双渲染模式(texture / hybrid)自动切换 + 启动看门狗恢复链 -- `window.AppShell` JS Bridge 协议(8 种 Action) +- `window.AppShell` JS Bridge 协议 - 旧相机 JS 兼容层(`openCamera` / `captureImage` monkey-patch) - 媒体序列化支持 `base64` / `dataUrl` / `uri` 三种格式 -- 54 个单元测试 + Widget 测试 +- 单元测试 + Widget 测试 - `CoreShellActivity` 原生层(进程隔离 + WebView 信息查询 + 深度重置) - 全中文 `debugPrint` 日志 diff --git a/packages/web_shell_core/README.md b/packages/web_shell_core/README.md index 950507f..7b2335d 100644 --- a/packages/web_shell_core/README.md +++ b/packages/web_shell_core/README.md @@ -1,6 +1,6 @@ # web_shell_core -Android 平板专用的 H5 壳核心库。所有品牌应用共享此库,只需传入 `ShellEnvironment` 即可启动。 +Android 平板专用 H5 壳核心库。所有品牌应用共享此库,只需传入 `ShellEnvironment` 即可启动。 ## 功能 @@ -8,14 +8,15 @@ Android 平板专用的 H5 壳核心库。所有品牌应用共享此库,只 |---|---| | **WebView 引擎** | 自动兼容低版本 Android WebView,支持 texture / hybrid 双渲染模式自动切换 | | **启动恢复** | 看门狗超时检测 → 渲染模式降级 → 深度清理 → 自动重试 | -| **JS Bridge** | `window.AppShell` 协议,支持 8 种 Action(pickImage · captureImage · pickFile · openExternal · requestPermissions · reloadPage · goBack · closeApp) | +| **JS Bridge** | `window.AppShell` 协议,当前支持 12 个 Action | | **旧相机兼容** | Monkey-patch `openCamera` / `captureImage` 兼容老 H5 页面 | | **媒体服务** | 相机拍照 · 图库选图 · 文件选择 · base64 / dataUrl / uri 三种序列化格式 | | **权限服务** | camera · microphone · location · photos · videos · storage 统一映射 | | **导航服务** | URL scheme 白名单路由,非 WebView 协议自动跳转外部应用 | | **壳层 UI** | 启动加载动画 · 错误恢复页 · 进度条 · 不支持平台兜底页 | -| **启动配置** | 支持本地默认启动配置文件 + 远程启动配置缓存 | +| **启动配置** | 支持本地默认启动配置文件、远程启动配置和缓存兜底 | | **版本检查** | 升级配置独立请求,不再与启动配置共用一个 JSON | +| **方向控制** | 支持通过启动配置动态设置 `SystemChrome.setPreferredOrientations` | ## 使用方式 @@ -41,10 +42,11 @@ void main() { ShellEnvironment( appName: '全学通', appKey: 'quanxue_prod', - accentColor: Color(0xFF3ED37B), - backgroundColor: Color(0xFFFFFFFF), - textColor: Color(0xFF1F2937), - mutedTextColor: Color(0xFF6B7280), + accentColor: const Color(0xFF3ED37B), + backgroundColor: const Color(0xFFFFFFFF), + textColor: const Color(0xFF1F2937), + mutedTextColor: const Color(0xFF6B7280), + splashImage: const AssetImage('assets/branding/splash.png'), bootstrapConfigAsset: 'assets/config/bootstrap.json', bootstrapConfigUrl: 'https://example.com/bootstrap.json', upgradeConfigUrl: 'https://example.com/upgrade.json', @@ -53,6 +55,11 @@ void main() { } ``` +- `bootstrapConfigAsset`:本地默认启动配置文件 +- `bootstrapConfigUrl`:可选,远程启动配置地址 +- `upgradeConfigUrl`:可选,远程升级配置地址 +- `preferredOrientations`:也可以直接在 `ShellEnvironment` 中传入,作为默认值 + ### 3. 远程启动配置格式 ```json @@ -79,38 +86,69 @@ void main() { } ``` +## 启动时序 + +```text +runShellApp(env) + → 读取 bootstrapConfigAsset + → 拉取 bootstrapConfigUrl(可选) + → 合并 initialUrl / preferredOrientations + → setupConfigUrl(upgradeConfigUrl) + → SystemChrome.setPreferredOrientations(...) + → runApp(ShellApp) + +WebShellPage.initState() + → 首帧后加载首页 + → 异步检查 upgradeConfigUrl +``` + +## 支持的 Bridge Action + +- `pickImage` +- `captureImage` +- `pickFile` +- `openExternal` +- `requestPermissions` +- `reloadPage` +- `goBack` +- `closeApp` +- `getDeviceInfo` +- `getNetworkStatus` +- `showToast` +- `setStatusBar` + ## 代码结构 -``` +```text lib/ -├── core_app.dart # 库入口 + part 指令 + runShellApp() -├── web_shell_core.dart # 公开 API 导出 +├── core_app.dart +├── web_shell_core.dart └── src/ ├── config/ - │ ├── shell_environment.dart # 品牌配置数据类 - │ └── url_resolver.dart # URL 解析与归一化 + │ ├── shell_environment.dart + │ └── url_resolver.dart ├── engine/ - │ ├── compatibility.dart # Android WebView 兼容检测 - │ └── recovery.dart # 启动看门狗 + 错误映射 + │ ├── compatibility.dart + │ └── recovery.dart ├── bridge/ - │ ├── bridge_protocol.dart # JS Bridge 注入与响应 - │ ├── bridge_actions.dart # Action handler(占位) - │ └── legacy_camera_compat.dart # 旧相机 JS 兼容层 + │ ├── bridge_protocol.dart + │ ├── bridge_actions.dart + │ └── legacy_camera_compat.dart ├── services/ - │ ├── config_service.dart # 启动配置读取与缓存 - │ ├── media_service.dart # 相机/图库/文件 + 序列化 - │ ├── permission_service.dart # 权限类型映射 - │ ├── navigation_service.dart # URL 路由 + 外链跳转 - │ └── upgrade_service.dart # 升级配置请求与弹窗转换 + │ ├── config_service.dart + │ ├── media_service.dart + │ ├── permission_service.dart + │ ├── navigation_service.dart + │ └── upgrade_service.dart ├── ui/ - │ ├── shell_app.dart # MaterialApp 入口 - │ ├── shell_page.dart # WebView 主页面 - │ ├── launch_overlay.dart # 启动加载动画 - │ ├── error_overlay.dart # 错误恢复页 - │ ├── progress_bar.dart # 顶部进度条 - │ └── unsupported_platform_page.dart # 平台兜底页 + │ ├── shell_app.dart + │ ├── shell_page.dart + │ ├── launch_overlay.dart + │ ├── error_overlay.dart + │ ├── progress_bar.dart + │ └── unsupported_platform_page.dart └── testing/ - └── test_hooks.dart # 测试钩子(@visibleForTesting) + └── test_hooks.dart ``` ## 测试 @@ -121,17 +159,19 @@ flutter test ``` 当前测试覆盖: -- 平台检测 · URL 解析 · 兼容性策略 · 错误映射 + +- 平台检测 · URL 解析 · 启动配置解析 · 方向配置 - Bridge 注入/响应/异常处理 · 媒体序列化 · 权限映射 -- 导航路由 · 启动配置解析 · 方向配置 · 组件渲染 +- 导航路由 · 兼容性策略 · 错误恢复 · 组件渲染 ## 平台约束 -仅支持 **Android**。其他平台会展示兜底提示页。 +仅支持 Android。其他平台会展示兜底提示页。 ## Android 原生层 `CoreShellActivity`(Java)提供: + - WebView 数据目录隔离(避免多进程冲突) - 旧进程自动终止 - WebView 信息查询(SDK 版本、WebView 包名/版本号) diff --git a/packages/web_shell_core/lib/core_app.dart b/packages/web_shell_core/lib/core_app.dart index 813a931..4b43e9b 100644 --- a/packages/web_shell_core/lib/core_app.dart +++ b/packages/web_shell_core/lib/core_app.dart @@ -74,8 +74,8 @@ Future runShellApp(ShellEnvironment environment) async { WidgetsFlutterBinding.ensureInitialized(); _env = environment; - await _applyBootstrapConfig(); - ShellUpgradeService.instance.setupConfigUrl(_env.upgradeConfigUrl); + final upgradeConfigUrl = await _applyBootstrapConfig(); + ShellUpgradeService.instance.setupConfigUrl(upgradeConfigUrl); _initializeUrls(); await SystemChrome.setPreferredOrientations(_shellPreferredOrientations); @@ -83,7 +83,12 @@ Future runShellApp(ShellEnvironment environment) async { runApp(const ShellApp()); } -Future _applyBootstrapConfig() async { +/// 返回最终确定的 upgradeConfigUrl。 +Future _applyBootstrapConfig() async { + String? bootstrapConfigUrl; + String? upgradeConfigUrl; + + // 1. 读取本地 Asset 启动配置 final bootstrapAsset = _env.bootstrapConfigAsset; if (bootstrapAsset != null && bootstrapAsset.isNotEmpty) { debugPrint('WebShell 正在读取本地启动配置: $bootstrapAsset'); @@ -92,10 +97,12 @@ Future _applyBootstrapConfig() async { ); if (localConfig != null) { _mergeBootstrapConfig(localConfig, source: '本地启动配置'); + bootstrapConfigUrl = localConfig.bootstrapConfigUrl?.trim(); + upgradeConfigUrl = localConfig.upgradeConfigUrl?.trim(); } } - final bootstrapConfigUrl = _env.bootstrapConfigUrl; + // 2. 如果本地配置指定了远程启动配置 URL,则获取远程配置并覆盖 if (bootstrapConfigUrl != null && bootstrapConfigUrl.isNotEmpty) { debugPrint('WebShell 正在获取远程启动配置: $bootstrapConfigUrl'); final remoteConfig = await ShellBootstrapConfigService.fetchConfig( @@ -103,8 +110,14 @@ Future _applyBootstrapConfig() async { ); if (remoteConfig != null) { _mergeBootstrapConfig(remoteConfig, source: '远程启动配置'); + // 远程配置可以覆盖升级地址 + if (remoteConfig.upgradeConfigUrl?.trim().isNotEmpty == true) { + upgradeConfigUrl = remoteConfig.upgradeConfigUrl!.trim(); + } } } + + return upgradeConfigUrl; } void _mergeBootstrapConfig( diff --git a/packages/web_shell_core/lib/src/config/shell_environment.dart b/packages/web_shell_core/lib/src/config/shell_environment.dart index 1aa16af..472f338 100644 --- a/packages/web_shell_core/lib/src/config/shell_environment.dart +++ b/packages/web_shell_core/lib/src/config/shell_environment.dart @@ -14,8 +14,6 @@ class ShellEnvironment { this.splashImage, this.initialUrl, this.bootstrapConfigAsset, - this.bootstrapConfigUrl, - this.upgradeConfigUrl, this.preferredOrientations, }); @@ -43,15 +41,9 @@ class ShellEnvironment { /// 可选的初始地址;作为本地默认启动配置缺省时的兜底值。 final String? initialUrl; - /// 可选的本地默认启动配置文件路径。 + /// 可选的本地默认启动配置文件路径,配置中可指定远程 URL 和升级地址。 final String? bootstrapConfigAsset; - /// 可选的远程启动配置地址;配置中可指定 `initialUrl` 及方向锁定。 - final String? bootstrapConfigUrl; - - /// 可选的远程升级配置地址。 - final String? upgradeConfigUrl; - /// 可选的页面首屏方向锁定配置;为空时使用默认竖屏。 final List? preferredOrientations; @@ -66,8 +58,6 @@ class ShellEnvironment { ImageProvider? splashImage, String? initialUrl, String? bootstrapConfigAsset, - String? bootstrapConfigUrl, - String? upgradeConfigUrl, List? preferredOrientations, }) { return ShellEnvironment( @@ -80,8 +70,6 @@ class ShellEnvironment { splashImage: splashImage ?? this.splashImage, initialUrl: initialUrl ?? this.initialUrl, bootstrapConfigAsset: bootstrapConfigAsset ?? this.bootstrapConfigAsset, - bootstrapConfigUrl: bootstrapConfigUrl ?? this.bootstrapConfigUrl, - upgradeConfigUrl: upgradeConfigUrl ?? this.upgradeConfigUrl, preferredOrientations: preferredOrientations ?? this.preferredOrientations, ); 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 de4b56e..adc7255 100644 --- a/packages/web_shell_core/lib/src/services/config_service.dart +++ b/packages/web_shell_core/lib/src/services/config_service.dart @@ -8,9 +8,17 @@ class ShellBootstrapConfig { /// 应用首屏锁定方向;`null` 表示沿用宿主默认配置。 final List? preferredOrientations; + /// 可选的远程启动配置地址(用于动态覆盖本地配置)。 + final String? bootstrapConfigUrl; + + /// 可选的远程升级配置地址。 + final String? upgradeConfigUrl; + ShellBootstrapConfig({ this.initialUrl, this.preferredOrientations, + this.bootstrapConfigUrl, + this.upgradeConfigUrl, }); factory ShellBootstrapConfig.fromJson(Map json) { @@ -19,6 +27,8 @@ class ShellBootstrapConfig { preferredOrientations: _parsePreferredOrientations( json['preferredOrientations'] ?? json['orientations'], ), + bootstrapConfigUrl: json['bootstrapConfigUrl']?.toString(), + upgradeConfigUrl: json['upgradeConfigUrl']?.toString(), ); } diff --git a/packages/web_shell_core/lib/src/services/upgrade_service.dart b/packages/web_shell_core/lib/src/services/upgrade_service.dart index 2fd7ace..effd54e 100644 --- a/packages/web_shell_core/lib/src/services/upgrade_service.dart +++ b/packages/web_shell_core/lib/src/services/upgrade_service.dart @@ -63,15 +63,21 @@ class ShellUpgradeService { return; } - UpgradeAuxiliaryUtils.instance.initiateVersionCheck( + UpgradeAuxiliaryUtils.instance.initiateVersionCheck( // coverage:ignore-line context, showNoUpdateToast: showNoUpdateToast, - future: (int upType) async { - return _convertToAppUpgradeVersion(remoteConfig); - }, + future: _createVersionResolver(remoteConfig), // coverage:ignore-line ); } + Future Function(int upType) _createVersionResolver( + ShellUpgradeConfig remoteConfig, + ) { + return (int upType) async { + return _convertToAppUpgradeVersion(remoteConfig); + }; + } + Future _fetchConfig(String url) async { try { final uri = Uri.tryParse(url); diff --git a/packages/web_shell_core/lib/src/testing/test_hooks.dart b/packages/web_shell_core/lib/src/testing/test_hooks.dart index 8e4909c..d5368ef 100644 --- a/packages/web_shell_core/lib/src/testing/test_hooks.dart +++ b/packages/web_shell_core/lib/src/testing/test_hooks.dart @@ -20,6 +20,16 @@ class ShellCoreTestHooks { List get preferredOrientations => _shellPreferredOrientations; + /// 返回当前环境中的初始地址配置。 + String? get configuredInitialUrl => _env.initialUrl; + + /// 返回当前环境中的首屏方向配置。 + List? get configuredPreferredOrientations => + _env.preferredOrientations; + + /// 返回当前生效的升级配置地址。 + String? get upgradeConfigUrl => ShellUpgradeService.instance._configUrl; + /// 解析字符串格式的启动配置。 ShellBootstrapConfig? parseBootstrapConfigString(String content) { return ShellBootstrapConfigService._parseConfigString(content); @@ -30,6 +40,58 @@ class ShellCoreTestHooks { return ShellBootstrapConfigService.loadDefaultConfig(assetPath); } + /// 获取远程启动配置。 + Future fetchBootstrapConfig(String url) { + return ShellBootstrapConfigService.fetchConfig(url); + } + + /// 读取缓存中的启动配置。 + Future loadCachedBootstrapConfig() { + return ShellBootstrapConfigService._loadFromCache(); + } + + /// 执行启动配置合并流程。 + Future applyBootstrapConfig() => _applyBootstrapConfig(); + + /// 解析字符串格式的升级配置。 + ShellUpgradeConfig? parseUpgradeConfigString(String content) { + return ShellUpgradeService.instance._parseConfigString(content); + } + + /// 获取远程升级配置。 + Future fetchUpgradeConfig(String url) { + return ShellUpgradeService.instance._fetchConfig(url); + } + + /// 将升级配置转换为升级插件模型。 + AppUpgradeVersion? convertUpgradeConfig(ShellUpgradeConfig config) { + return ShellUpgradeService.instance._convertToAppUpgradeVersion(config); + } + + /// 执行升级配置到版本模型的异步解析闭包。 + Future resolveUpgradeConfig( + ShellUpgradeConfig config, { + int upType = 1, + }) { + return ShellUpgradeService.instance._createVersionResolver(config)(upType); + } + + /// 为测试设置升级配置地址。 + void setupUpgradeConfigUrl(String? url) { + ShellUpgradeService.instance.setupConfigUrl(url); + } + + /// 触发版本检查。 + Future checkVersion( + BuildContext context, { + bool showNoUpdateToast = false, + }) { + return ShellUpgradeService.instance.checkVersion( + context, + showNoUpdateToast: showNoUpdateToast, + ); + } + /// 为测试初始化全局壳环境与初始地址。 void initializeEnvironment(ShellEnvironment environment) { _env = environment; diff --git a/packages/web_shell_core/test/web_shell_core_test.dart b/packages/web_shell_core/test/web_shell_core_test.dart index 71c28f5..269d99a 100644 --- a/packages/web_shell_core/test/web_shell_core_test.dart +++ b/packages/web_shell_core/test/web_shell_core_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; @@ -9,6 +10,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:image_picker/image_picker.dart'; import 'package:image_picker_platform_interface/image_picker_platform_interface.dart'; import 'package:permission_handler/permission_handler.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart'; 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'; @@ -30,6 +33,34 @@ const _testEnvironment = ShellEnvironment( initialUrl: 'example.com/login', ); +Future _startJsonServer( + Future Function(HttpRequest request) responder, +) async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + server.listen((request) async { + try { + final body = await responder(request); + request.response.statusCode = HttpStatus.ok; + request.response.headers.set( + HttpHeaders.contentTypeHeader, + 'application/json; charset=utf-8', + ); + request.response.write(body); + } catch (error) { + request.response.statusCode = + error is int ? error : HttpStatus.internalServerError; + } finally { + await request.response.close(); + } + }); + addTearDown(server.close); + return Uri.parse('http://127.0.0.1:${server.port}/config.json'); +} + +Future _runWithRealHttpClient(Future Function() body) { + return HttpOverrides.runWithHttpOverrides(body, _RealHttpOverrides()); +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -42,6 +73,7 @@ void main() { late bool cameraPermissionGranted; setUp(() { + SharedPreferences.setMockInitialValues({}); platformCalls = []; platformMethodCalls = []; assetContents = {}; @@ -1126,8 +1158,6 @@ void main() { expect(_testEnvironment.mutedTextColor, const Color(0xFF6B7280)); expect(_testEnvironment.initialUrl, 'example.com/login'); expect(_testEnvironment.bootstrapConfigAsset, isNull); - expect(_testEnvironment.bootstrapConfigUrl, isNull); - expect(_testEnvironment.upgradeConfigUrl, isNull); expect(_testEnvironment.preferredOrientations, isNull); }); @@ -1192,6 +1222,143 @@ void main() { // 恢复测试环境 shellCoreTestHooks.initializeEnvironment(_testEnvironment); }); + + test('ShellEnvironment.copyWith 会按需覆盖字段', () { + final copied = _testEnvironment.copyWith( + appName: '复制应用', + appKey: 'copied', + initialUrl: 'https://copy.example.com', + bootstrapConfigAsset: 'assets/config/bootstrap.json', + preferredOrientations: [ + DeviceOrientation.landscapeRight, + ], + ); + + expect(copied.appName, '复制应用'); + expect(copied.appKey, 'copied'); + expect(copied.accentColor, _testEnvironment.accentColor); + expect(copied.backgroundColor, _testEnvironment.backgroundColor); + expect(copied.textColor, _testEnvironment.textColor); + expect(copied.mutedTextColor, _testEnvironment.mutedTextColor); + expect(copied.initialUrl, 'https://copy.example.com'); + expect(copied.bootstrapConfigAsset, 'assets/config/bootstrap.json'); + expect( + copied.preferredOrientations, + [DeviceOrientation.landscapeRight], + ); + }); + + test('启动配置支持 bootstrapConfigUrl 和 upgradeConfigUrl 字段', () { + final config = shellCoreTestHooks.parseBootstrapConfigString( + '{"initialUrl":"https://example.com/home","bootstrapConfigUrl":" https://example.com/bootstrap.json ","upgradeConfigUrl":" https://example.com/upgrade.json ","orientations":["DeviceOrientation.portraitUp","invalid","portraitUp",null,"landscapeLeft"]}', + ); + + expect(config, isNotNull); + expect(config!.bootstrapConfigUrl, ' https://example.com/bootstrap.json '); + expect(config.upgradeConfigUrl, ' https://example.com/upgrade.json '); + expect( + config.preferredOrientations, + [ + DeviceOrientation.portraitUp, + DeviceOrientation.landscapeLeft, + ], + ); + }); + + test('启动配置解析支持空数组、非法对象与非法 JSON', () { + expect( + shellCoreTestHooks.parseBootstrapConfigString( + '{"preferredOrientations":[]}', + )!.preferredOrientations, + [], + ); + expect( + shellCoreTestHooks.parseBootstrapConfigString( + '{"preferredOrientations":"portraitUp"}', + )!.preferredOrientations, + isNull, + ); + expect(shellCoreTestHooks.parseBootstrapConfigString('['), isNull); + }); + + test('读取不存在的本地启动配置返回 null', () async { + final config = await shellCoreTestHooks.loadDefaultBootstrapConfig( + 'assets/config/missing.json', + ); + + expect(config, isNull); + }); + + test('读取启动配置缓存成功、空值和非法 JSON 都符合预期', () async { + SharedPreferences.setMockInitialValues({ + 'webshell_bootstrap_config_cache': + '{"initialUrl":"https://cache.example.com"}', + }); + final cached = await shellCoreTestHooks.loadCachedBootstrapConfig(); + expect(cached?.initialUrl, 'https://cache.example.com'); + + SharedPreferences.setMockInitialValues({}); + expect(await shellCoreTestHooks.loadCachedBootstrapConfig(), isNull); + + SharedPreferences.setMockInitialValues({ + 'webshell_bootstrap_config_cache': '{', + }); + expect(await shellCoreTestHooks.loadCachedBootstrapConfig(), isNull); + }); + + test('获取启动配置支持成功、非法地址、非 200 和异常回退缓存', () async { + SharedPreferences.setMockInitialValues({ + 'webshell_bootstrap_config_cache': + '{"initialUrl":"https://cache.example.com/fallback"}', + }); + final unavailableSocket = await ServerSocket.bind( + InternetAddress.loopbackIPv4, + 0, + ); + final unavailablePort = unavailableSocket.port; + await unavailableSocket.close(); + + final successUri = await _startJsonServer( + (_) async => '{"data":{"initialUrl":"https://remote.example.com","upgradeConfigUrl":"https://remote.example.com/upgrade.json"}}', + ); + final success = await _runWithRealHttpClient(() => shellCoreTestHooks.fetchBootstrapConfig(successUri.toString())); + expect(success?.initialUrl, 'https://remote.example.com'); + expect( + success?.upgradeConfigUrl, + 'https://remote.example.com/upgrade.json', + ); + + final prefs = await SharedPreferences.getInstance(); + expect( + prefs.getString('webshell_bootstrap_config_cache'), + contains('remote.example.com'), + ); + + final invalid = await shellCoreTestHooks.fetchBootstrapConfig('%%%'); + expect(invalid?.initialUrl, 'https://remote.example.com'); + + final invalidUri = await shellCoreTestHooks.fetchBootstrapConfig('://'); + expect(invalidUri?.initialUrl, 'https://remote.example.com'); + + final notFoundUri = await _startJsonServer((_) async => throw 404); + final notFound = await _runWithRealHttpClient(() => shellCoreTestHooks.fetchBootstrapConfig(notFoundUri.toString())); + expect(notFound?.initialUrl, 'https://remote.example.com'); + + final failed = await _runWithRealHttpClient( + () => shellCoreTestHooks.fetchBootstrapConfig( + 'http://127.0.0.1:$unavailablePort/config.json', + ), + ); + expect(failed?.initialUrl, 'https://remote.example.com'); + }); + + test('读取启动配置缓存异常时返回 null', () async { + final originalStore = SharedPreferencesStorePlatform.instance; + SharedPreferencesStorePlatform.instance = _ThrowingPreferencesStore(); + addTearDown(() => SharedPreferencesStorePlatform.instance = originalStore); + + expect(await shellCoreTestHooks.loadCachedBootstrapConfig(), isNull); + }); }); group('Bridge JS 协议格式', () { @@ -1482,6 +1649,11 @@ void main() { 'systemFeatures': [], 'serialNumber': 'unknown', 'isLowRamDevice': false, + 'freeDiskSize': 1024, + 'totalDiskSize': 2048, + 'physicalRamSize': 4096, + 'availableRamSize': 2048, + 'name': 'TestDevice', 'displayMetrics': { 'widthPx': 1080.0, 'heightPx': 2400.0, @@ -1522,6 +1694,23 @@ void main() { } }); + test('getDeviceInfo 成功返回桥接字段', () async { + final info = await shellCoreTestHooks.getDeviceInfoFromBridge(); + + expect(info['platform'], 'android'); + expect(info['brand'], 'TestBrand'); + expect(info['model'], 'TestModel'); + expect(info['manufacturer'], 'TestMfr'); + expect(info['androidVersion'], '14'); + expect(info['sdkInt'], 34); + expect(info['isPhysicalDevice'], isTrue); + expect(info['display'], 'TestDisplay'); + expect(info['product'], 'TestProduct'); + expect(info['appName'], _testEnvironment.appName); + expect(info['appKey'], _testEnvironment.appKey); + expect(info['shellVersion'], isNotEmpty); + }); + test('getNetworkStatus 返回 WiFi 状态', () async { final status = await shellCoreTestHooks.getNetworkStatusFromBridge(); @@ -1688,9 +1877,452 @@ void main() { debugDefaultTargetPlatformOverride = null; } }); + + testWidgets('checkVersion 在未配置升级地址时展示提示', (tester) async { + shellCoreTestHooks.setupUpgradeConfigUrl(' '); + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => ElevatedButton( + onPressed: () { + unawaited( + shellCoreTestHooks.checkVersion( + context, + showNoUpdateToast: true, + ), + ); + }, + child: const Text('检查升级'), + ), + ), + ), + ); + + await tester.tap(find.text('检查升级')); + await tester.pump(); + + expect(find.text('未配置升级地址'), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 2500)); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pumpAndSettle(); + }); + + testWidgets('checkVersion 在未获取到配置时展示提示', (tester) async { + shellCoreTestHooks.setupUpgradeConfigUrl( + 'http://example.com/upgrade.json', + ); + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => ElevatedButton( + onPressed: () { + unawaited( + shellCoreTestHooks.checkVersion( + context, + showNoUpdateToast: true, + ), + ); + }, + child: const Text('检查升级失败'), + ), + ), + ), + ); + + await tester.tap(find.text('检查升级失败')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text('未获取到版本配置'), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 2500)); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pumpAndSettle(); + }); + + test('升级配置解析支持 data、平铺、isforce 和非法 JSON', () { + final wrapped = shellCoreTestHooks.parseUpgradeConfigString( + '{"data":{"versionName":"1.0.1","version":101,"isforce":1,"remark":"fix","filePath":"https://example.com/app.apk","fileSize":2048}}', + ); + final flat = shellCoreTestHooks.parseUpgradeConfigString( + '{"versionName":"1.0.2","version":102}', + ); + + expect(wrapped, isNotNull); + expect(wrapped!.versionName, '1.0.1'); + expect(wrapped.isForce, 1); + expect(flat?.version, 102); + expect(shellCoreTestHooks.parseUpgradeConfigString('{'), isNull); + }); + + test('升级配置转换为 AppUpgradeVersion 时处理缺省值', () { + expect( + shellCoreTestHooks.convertUpgradeConfig( + ShellUpgradeConfig(versionName: '1.0.0'), + ), + isNull, + ); + expect( + shellCoreTestHooks.convertUpgradeConfig(ShellUpgradeConfig(version: 1)), + isNull, + ); + + final converted = shellCoreTestHooks.convertUpgradeConfig( + ShellUpgradeConfig( + versionName: '1.2.3', + version: 123, + isForce: 1, + remark: 'important update', + filePath: '', + fileSize: 2048, + ), + ); + + expect(converted, isNotNull); + expect(converted!.versionName, '1.2.3'); + expect(converted.versionBuildNumber, 123); + expect(converted.isForce, isTrue); + expect(converted.updateContent, 'important update'); + expect(converted.downloadUrl, isNull); + expect(converted.appStoreUrl, isNull); + expect(converted.apkSize, 2048 * 1024); + expect(converted.supportedMethods, hasLength(3)); + + final convertedWithPath = shellCoreTestHooks.convertUpgradeConfig( + ShellUpgradeConfig( + versionName: '2.0.0', + version: 200, + filePath: 'https://example.com/app.apk', + ), + ); + expect(convertedWithPath?.downloadUrl, 'https://example.com/app.apk'); + expect(convertedWithPath?.appStoreUrl, 'https://example.com/app.apk'); + }); + + test('获取升级配置支持成功、非法地址、非 200 和异常', () async { + final successUri = await _startJsonServer( + (_) async => '{"versionName":"2.0.0","version":200}', + ); + + final success = await _runWithRealHttpClient(() => shellCoreTestHooks.fetchUpgradeConfig(successUri.toString())); + expect(success?.versionName, '2.0.0'); + + expect(await shellCoreTestHooks.fetchUpgradeConfig('%%%'), isNull); + + final notFoundUri = await _startJsonServer((_) async => throw 404); + expect( + await _runWithRealHttpClient(() => shellCoreTestHooks.fetchUpgradeConfig(notFoundUri.toString())), + isNull, + ); + + final unavailableSocket = await ServerSocket.bind( + InternetAddress.loopbackIPv4, + 0, + ); + final unavailablePort = unavailableSocket.port; + await unavailableSocket.close(); + + expect( + await _runWithRealHttpClient( + () => shellCoreTestHooks.fetchUpgradeConfig( + 'http://127.0.0.1:$unavailablePort/upgrade.json', + ), + ), + isNull, + ); + }); + + test('applyBootstrapConfig 会用远程启动配置覆盖本地配置', () async { + final bootstrapUri = await _startJsonServer( + (_) async => '{"data":{"initialUrl":"https://remote.example.com/home","preferredOrientations":["portraitDown"],"upgradeConfigUrl":" https://remote.example.com/upgrade.json "}}', + ); + shellCoreTestHooks.initializeEnvironment( + const ShellEnvironment( + appName: '远程配置应用', + appKey: 'remote_app', + accentColor: Color(0xFF3ED37B), + backgroundColor: Color(0xFFFFFFFF), + textColor: Color(0xFF1F2937), + mutedTextColor: Color(0xFF6B7280), + bootstrapConfigAsset: 'assets/config/remote_bootstrap.json', + ), + ); + assetContents['assets/config/remote_bootstrap.json'] = jsonEncode( + { + 'initialUrl': 'https://asset.example.com/home', + 'preferredOrientations': ['landscapeLeft'], + 'bootstrapConfigUrl': bootstrapUri.toString(), + 'upgradeConfigUrl': 'https://asset.example.com/upgrade.json', + }, + ); + + final upgradeUrl = await _runWithRealHttpClient( + shellCoreTestHooks.applyBootstrapConfig, + ); + + expect( + shellCoreTestHooks.configuredInitialUrl, + 'https://remote.example.com/home', + ); + expect( + shellCoreTestHooks.configuredPreferredOrientations, + [DeviceOrientation.portraitDown], + ); + expect(upgradeUrl, 'https://remote.example.com/upgrade.json'); + shellCoreTestHooks.initializeEnvironment(_testEnvironment); + }); + + test('applyBootstrapConfig 在远程失败时保留本地升级地址', () async { + final unavailableSocket = await ServerSocket.bind( + InternetAddress.loopbackIPv4, + 0, + ); + final unavailablePort = unavailableSocket.port; + await unavailableSocket.close(); + shellCoreTestHooks.initializeEnvironment( + const ShellEnvironment( + appName: '缓存回退应用', + appKey: 'fallback_app', + accentColor: Color(0xFF3ED37B), + backgroundColor: Color(0xFFFFFFFF), + textColor: Color(0xFF1F2937), + mutedTextColor: Color(0xFF6B7280), + bootstrapConfigAsset: 'assets/config/fallback_bootstrap.json', + ), + ); + assetContents['assets/config/fallback_bootstrap.json'] = jsonEncode( + { + 'initialUrl': 'https://asset.example.com/start', + 'bootstrapConfigUrl': + 'http://127.0.0.1:$unavailablePort/config.json', + 'upgradeConfigUrl': ' https://asset.example.com/upgrade.json ', + }, + ); + + final upgradeUrl = await _runWithRealHttpClient( + shellCoreTestHooks.applyBootstrapConfig, + ); + + expect(upgradeUrl, 'https://asset.example.com/upgrade.json'); + shellCoreTestHooks.initializeEnvironment(_testEnvironment); + }); + + test('applyBootstrapConfig 遇到空 initialUrl 时保留环境默认地址', () async { + shellCoreTestHooks.initializeEnvironment( + _testEnvironment.copyWith( + bootstrapConfigAsset: 'assets/config/keep_initial.json', + ), + ); + assetContents['assets/config/keep_initial.json'] = + '{"initialUrl":"","preferredOrientations":[]}'; + + await shellCoreTestHooks.applyBootstrapConfig(); + + expect( + shellCoreTestHooks.configuredInitialUrl, + _testEnvironment.initialUrl, + ); + shellCoreTestHooks.initializeEnvironment(_testEnvironment); + }); + + testWidgets('LaunchOverlay 配置品牌图片时渲染 Image', (tester) async { + shellCoreTestHooks.initializeEnvironment( + _testEnvironment.copyWith( + splashImage: MemoryImage(Uint8List.fromList(kTransparentImage)), + ), + ); + addTearDown(() => shellCoreTestHooks.initializeEnvironment(_testEnvironment)); + + await tester.pumpWidget( + const MaterialApp( + home: LaunchOverlay(progress: 10, hasMeasuredProgress: true), + ), + ); + + expect(find.byType(Image), findsOneWidget); + expect(find.byIcon(Icons.language_rounded), findsNothing); + }); + + test('getNetworkStatus 覆盖 ethernet、bluetooth、vpn 和 other', () async { + const connectivityChannel = MethodChannel( + 'dev.fluttercommunity.plus/connectivity', + ); + + Future setConnectivity(List values) async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(connectivityChannel, (call) async { + if (call.method == 'check') return values; + return null; + }); + } + + await setConnectivity(['ethernet']); + expect( + (await shellCoreTestHooks.getNetworkStatusFromBridge())['type'], + 'ethernet', + ); + + await setConnectivity(['bluetooth']); + expect( + (await shellCoreTestHooks.getNetworkStatusFromBridge())['type'], + 'bluetooth', + ); + + await setConnectivity(['vpn']); + expect( + (await shellCoreTestHooks.getNetworkStatusFromBridge())['type'], + 'vpn', + ); + + await setConnectivity(['other']); + expect( + (await shellCoreTestHooks.getNetworkStatusFromBridge())['type'], + 'unknown', + ); + }); + + testWidgets('showToast 支持 long 时长', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => ElevatedButton( + onPressed: () { + shellCoreTestHooks.showToastFromBridge( + context, + {'message': '长提示', 'duration': 'long'}, + ); + }, + child: const Text('长提示按钮'), + ), + ), + ), + ); + + await tester.tap(find.text('长提示按钮')); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('长提示'), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 2000)); + expect(find.text('长提示'), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 1700)); + await tester.pumpAndSettle(); + expect(find.text('长提示'), findsNothing); + }); + + test('升级配置异步解析闭包可返回版本信息', () async { + final version = await shellCoreTestHooks.resolveUpgradeConfig( + ShellUpgradeConfig( + versionName: '3.0.0', + version: 300, + filePath: 'https://example.com/app.apk', + ), + ); + + expect(version?.versionName, '3.0.0'); + expect(version?.versionBuildNumber, 300); + expect(version?.downloadUrl, 'https://example.com/app.apk'); + }); }); } +const List kTransparentImage = [ + 0x89, + 0x50, + 0x4E, + 0x47, + 0x0D, + 0x0A, + 0x1A, + 0x0A, + 0x00, + 0x00, + 0x00, + 0x0D, + 0x49, + 0x48, + 0x44, + 0x52, + 0x00, + 0x00, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x01, + 0x08, + 0x06, + 0x00, + 0x00, + 0x00, + 0x1F, + 0x15, + 0xC4, + 0x89, + 0x00, + 0x00, + 0x00, + 0x0A, + 0x49, + 0x44, + 0x41, + 0x54, + 0x78, + 0x9C, + 0x63, + 0x00, + 0x01, + 0x00, + 0x00, + 0x05, + 0x00, + 0x01, + 0x0D, + 0x0A, + 0x2D, + 0xB4, + 0x00, + 0x00, + 0x00, + 0x00, + 0x49, + 0x45, + 0x4E, + 0x44, + 0xAE, + 0x42, + 0x60, + 0x82, +]; + +class _RealHttpOverrides extends HttpOverrides { + @override + HttpClient createHttpClient(SecurityContext? context) { + return super.createHttpClient(context); + } +} + +class _ThrowingPreferencesStore extends SharedPreferencesStorePlatform { + @override + Future clear() async => true; + + @override + Future> getAll() async { + throw Exception('shared preferences failed'); + } + + @override + Future remove(String key) async => true; + + @override + Future setValue(String valueType, String key, Object value) async => + true; +} + class _FakeUrlLauncherPlatform extends UrlLauncherPlatform { @override LinkDelegate? get linkDelegate => null; diff --git a/tool/generate_app.dart b/tool/generate_app.dart index 44996dc..e8ac6d7 100644 --- a/tool/generate_app.dart +++ b/tool/generate_app.dart @@ -63,13 +63,13 @@ Future main(List args) async { backgroundColor: bgColor, textColor: textColor, mutedTextColor: mutedTextColor, - bootstrapConfigUrl: bootstrapConfigUrl, - upgradeConfigUrl: upgradeConfigUrl, ); await _generateBootstrapConfig( appDir: appDir, defaultUrl: defaultUrl, preferredOrientations: preferredOrientations, + bootstrapConfigUrl: bootstrapConfigUrl, + upgradeConfigUrl: upgradeConfigUrl, ); await _generateBrandingAssets(brand, appDir, config); await _registerFlutterAssets(appDir); @@ -225,20 +225,10 @@ Future _generateDartEntrypoint({ required String backgroundColor, required String textColor, required String mutedTextColor, - String? bootstrapConfigUrl, - String? upgradeConfigUrl, }) async { print('\x1B[34m[信息] 正在生成 lib/main.dart...\x1B[0m'); final mainFile = File('$appDir/lib/main.dart'); - final extraLines = [ - " bootstrapConfigAsset: 'assets/config/bootstrap.json',", - if (bootstrapConfigUrl != null && bootstrapConfigUrl.trim().isNotEmpty) - " bootstrapConfigUrl: '${bootstrapConfigUrl.trim()}',", - if (upgradeConfigUrl != null && upgradeConfigUrl.trim().isNotEmpty) - " upgradeConfigUrl: '${upgradeConfigUrl.trim()}',", - ].join('\n'); - final dartContent = ''' import 'package:flutter/material.dart'; @@ -254,7 +244,7 @@ void main() { textColor: const Color($textColor), mutedTextColor: const Color($mutedTextColor), splashImage: const AssetImage('assets/branding/splash.png'), -$extraLines + bootstrapConfigAsset: 'assets/config/bootstrap.json', ), ); } @@ -272,6 +262,8 @@ Future _generateBootstrapConfig({ required String appDir, required String? defaultUrl, required List preferredOrientations, + String? bootstrapConfigUrl, + String? upgradeConfigUrl, }) async { print('\x1B[34m[信息] 正在生成默认启动配置 assets/config/bootstrap.json...\x1B[0m'); final file = File('$appDir/assets/config/bootstrap.json'); @@ -281,6 +273,10 @@ Future _generateBootstrapConfig({ if (defaultUrl != null && defaultUrl.trim().isNotEmpty) 'initialUrl': defaultUrl.trim(), 'preferredOrientations': preferredOrientations, + if (bootstrapConfigUrl != null && bootstrapConfigUrl.trim().isNotEmpty) + 'bootstrapConfigUrl': bootstrapConfigUrl.trim(), + if (upgradeConfigUrl != null && upgradeConfigUrl.trim().isNotEmpty) + 'upgradeConfigUrl': upgradeConfigUrl.trim(), }; final content = const JsonEncoder.withIndent(' ').convert(data);