feat: 拆分启动与升级配置并补齐核心测试

This commit is contained in:
Max 2026-03-20 19:47:50 +08:00
parent 0d259334ee
commit 435d99772c
11 changed files with 1079 additions and 193 deletions

210
README.md
View File

@ -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 平板。

View File

@ -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? → WebShellPageWebView 容器)
→ 其他? → 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`

View File

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

View File

@ -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 种 ActionpickImage · 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 包名/版本号)

View File

@ -74,8 +74,8 @@ Future<void> 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<void> runShellApp(ShellEnvironment environment) async {
runApp(const ShellApp());
}
Future<void> _applyBootstrapConfig() async {
/// upgradeConfigUrl
Future<String?> _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<void> _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<void> _applyBootstrapConfig() async {
);
if (remoteConfig != null) {
_mergeBootstrapConfig(remoteConfig, source: '远程启动配置');
//
if (remoteConfig.upgradeConfigUrl?.trim().isNotEmpty == true) {
upgradeConfigUrl = remoteConfig.upgradeConfigUrl!.trim();
}
}
}
return upgradeConfigUrl;
}
void _mergeBootstrapConfig(

View File

@ -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<DeviceOrientation>? preferredOrientations;
@ -66,8 +58,6 @@ class ShellEnvironment {
ImageProvider? splashImage,
String? initialUrl,
String? bootstrapConfigAsset,
String? bootstrapConfigUrl,
String? upgradeConfigUrl,
List<DeviceOrientation>? 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,
);

View File

@ -8,9 +8,17 @@ class ShellBootstrapConfig {
/// `null` 沿宿
final List<DeviceOrientation>? preferredOrientations;
///
final String? bootstrapConfigUrl;
///
final String? upgradeConfigUrl;
ShellBootstrapConfig({
this.initialUrl,
this.preferredOrientations,
this.bootstrapConfigUrl,
this.upgradeConfigUrl,
});
factory ShellBootstrapConfig.fromJson(Map<String, dynamic> json) {
@ -19,6 +27,8 @@ class ShellBootstrapConfig {
preferredOrientations: _parsePreferredOrientations(
json['preferredOrientations'] ?? json['orientations'],
),
bootstrapConfigUrl: json['bootstrapConfigUrl']?.toString(),
upgradeConfigUrl: json['upgradeConfigUrl']?.toString(),
);
}

View File

@ -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<AppUpgradeVersion?> Function(int upType) _createVersionResolver(
ShellUpgradeConfig remoteConfig,
) {
return (int upType) async {
return _convertToAppUpgradeVersion(remoteConfig);
};
}
Future<ShellUpgradeConfig?> _fetchConfig(String url) async {
try {
final uri = Uri.tryParse(url);

View File

@ -20,6 +20,16 @@ class ShellCoreTestHooks {
List<DeviceOrientation> get preferredOrientations =>
_shellPreferredOrientations;
///
String? get configuredInitialUrl => _env.initialUrl;
///
List<DeviceOrientation>? 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<ShellBootstrapConfig?> fetchBootstrapConfig(String url) {
return ShellBootstrapConfigService.fetchConfig(url);
}
///
Future<ShellBootstrapConfig?> loadCachedBootstrapConfig() {
return ShellBootstrapConfigService._loadFromCache();
}
///
Future<String?> applyBootstrapConfig() => _applyBootstrapConfig();
///
ShellUpgradeConfig? parseUpgradeConfigString(String content) {
return ShellUpgradeService.instance._parseConfigString(content);
}
///
Future<ShellUpgradeConfig?> fetchUpgradeConfig(String url) {
return ShellUpgradeService.instance._fetchConfig(url);
}
///
AppUpgradeVersion? convertUpgradeConfig(ShellUpgradeConfig config) {
return ShellUpgradeService.instance._convertToAppUpgradeVersion(config);
}
///
Future<AppUpgradeVersion?> resolveUpgradeConfig(
ShellUpgradeConfig config, {
int upType = 1,
}) {
return ShellUpgradeService.instance._createVersionResolver(config)(upType);
}
///
void setupUpgradeConfigUrl(String? url) {
ShellUpgradeService.instance.setupConfigUrl(url);
}
///
Future<void> checkVersion(
BuildContext context, {
bool showNoUpdateToast = false,
}) {
return ShellUpgradeService.instance.checkVersion(
context,
showNoUpdateToast: showNoUpdateToast,
);
}
///
void initializeEnvironment(ShellEnvironment environment) {
_env = environment;

View File

@ -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<Uri> _startJsonServer(
Future<String> 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<T> _runWithRealHttpClient<T>(Future<T> Function() body) {
return HttpOverrides.runWithHttpOverrides(body, _RealHttpOverrides());
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@ -42,6 +73,7 @@ void main() {
late bool cameraPermissionGranted;
setUp(() {
SharedPreferences.setMockInitialValues(<String, Object>{});
platformCalls = <String>[];
platformMethodCalls = <MethodCall>[];
assetContents = <String, String>{};
@ -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>[
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>[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>[
DeviceOrientation.portraitUp,
DeviceOrientation.landscapeLeft,
],
);
});
test('启动配置解析支持空数组、非法对象与非法 JSON', () {
expect(
shellCoreTestHooks.parseBootstrapConfigString(
'{"preferredOrientations":[]}',
)!.preferredOrientations,
<DeviceOrientation>[],
);
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(<String, Object>{
'webshell_bootstrap_config_cache':
'{"initialUrl":"https://cache.example.com"}',
});
final cached = await shellCoreTestHooks.loadCachedBootstrapConfig();
expect(cached?.initialUrl, 'https://cache.example.com');
SharedPreferences.setMockInitialValues(<String, Object>{});
expect(await shellCoreTestHooks.loadCachedBootstrapConfig(), isNull);
SharedPreferences.setMockInitialValues(<String, Object>{
'webshell_bootstrap_config_cache': '{',
});
expect(await shellCoreTestHooks.loadCachedBootstrapConfig(), isNull);
});
test('获取启动配置支持成功、非法地址、非 200 和异常回退缓存', () async {
SharedPreferences.setMockInitialValues(<String, Object>{
'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': <String>[],
'serialNumber': 'unknown',
'isLowRamDevice': false,
'freeDiskSize': 1024,
'totalDiskSize': 2048,
'physicalRamSize': 4096,
'availableRamSize': 2048,
'name': 'TestDevice',
'displayMetrics': <Object?, Object?>{
'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(
<String, Object?>{
'initialUrl': 'https://asset.example.com/home',
'preferredOrientations': <String>['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>[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(
<String, Object?>{
'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<void> setConnectivity(List<String> values) async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(connectivityChannel, (call) async {
if (call.method == 'check') return values;
return null;
});
}
await setConnectivity(<String>['ethernet']);
expect(
(await shellCoreTestHooks.getNetworkStatusFromBridge())['type'],
'ethernet',
);
await setConnectivity(<String>['bluetooth']);
expect(
(await shellCoreTestHooks.getNetworkStatusFromBridge())['type'],
'bluetooth',
);
await setConnectivity(<String>['vpn']);
expect(
(await shellCoreTestHooks.getNetworkStatusFromBridge())['type'],
'vpn',
);
await setConnectivity(<String>['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,
<String, dynamic>{'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<int> kTransparentImage = <int>[
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<bool> clear() async => true;
@override
Future<Map<String, Object>> getAll() async {
throw Exception('shared preferences failed');
}
@override
Future<bool> remove(String key) async => true;
@override
Future<bool> setValue(String valueType, String key, Object value) async =>
true;
}
class _FakeUrlLauncherPlatform extends UrlLauncherPlatform {
@override
LinkDelegate? get linkDelegate => null;

View File

@ -63,13 +63,13 @@ Future<void> main(List<String> 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<void> _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 = <String>[
" 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<void> _generateBootstrapConfig({
required String appDir,
required String? defaultUrl,
required List<String> 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<void> _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);