feat: 拆分启动与升级配置并补齐核心测试
This commit is contained in:
parent
0d259334ee
commit
435d99772c
210
README.md
210
README.md
|
|
@ -1,114 +1,198 @@
|
||||||
# web_android_shell
|
# web_android_shell
|
||||||
|
|
||||||
Android 平板专用 H5 壳应用 — Monorepo 多品牌架构。
|
Android 平板专用 H5 壳应用 Monorepo,面向多品牌白标场景。
|
||||||
|
|
||||||
## 项目结构
|
## 当前品牌
|
||||||
|
|
||||||
```
|
- `apps/aixue`:爱学蝶变
|
||||||
|
- `apps/test`:测试壳工程
|
||||||
|
- `apps/yunxiao`:云校嗨学
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```text
|
||||||
web_android_shell/
|
web_android_shell/
|
||||||
├── apps/ # 品牌应用(每个品牌一个 Flutter App)
|
├── apps/ # 品牌应用(每个品牌一个 Flutter App)
|
||||||
│ └── quanxue/ # 全学通
|
│ ├── aixue/
|
||||||
|
│ ├── test/
|
||||||
|
│ └── yunxiao/
|
||||||
|
├── flavors/ # 品牌配置 + 品牌资源源文件
|
||||||
|
│ ├── aixue.yaml
|
||||||
|
│ ├── test.yaml
|
||||||
|
│ ├── yunxiao.yaml
|
||||||
|
│ ├── aixue/
|
||||||
|
│ ├── test/
|
||||||
|
│ └── yunxiao/
|
||||||
├── packages/
|
├── packages/
|
||||||
│ ├── web_shell_core/ # 核心库(WebView 引擎 + Bridge + 服务)
|
│ ├── web_shell_core/ # 核心壳能力库
|
||||||
│ └── web_android_shell/ # 旧版入口(已迁移至 apps/quanxue)
|
│ └── web_android_shell/ # 历史包,当前不作为主入口维护
|
||||||
├── flavors/ # 品牌配置 + 品牌资源
|
|
||||||
│ ├── quanxue.yaml # 品牌配置
|
|
||||||
│ └── quanxue/ # 品牌资源(图标、启动页)
|
|
||||||
│ ├── icon.png
|
|
||||||
│ ├── icon_foreground.png
|
|
||||||
│ └── splash.png
|
|
||||||
├── tool/
|
├── tool/
|
||||||
│ ├── generate_app.dart # 一键生成新品牌应用
|
│ ├── generate_app.dart # 按 flavor 一键生成品牌 App
|
||||||
│ └── flutter_run_fresh.ps1 # Windows 调试脚本(自动杀旧进程)
|
│ └── flutter_run_fresh.ps1 # Windows 调试脚本
|
||||||
└── doc/ # 项目文档
|
└── doc/
|
||||||
|
└── architecture.md # 架构说明
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 核心思路
|
||||||
|
|
||||||
|
- 应用启动优先读取本地默认启动配置 `assets/config/bootstrap.json`
|
||||||
|
- 如配置了 `bootstrap_config_url`,会继续拉取远程启动配置,并使用缓存兜底
|
||||||
|
- 如配置了 `upgrade_config_url`,首帧后异步检查版本更新
|
||||||
|
- 启动配置与升级配置已拆分,不再共用同一个 JSON
|
||||||
|
- 屏幕方向支持通过启动配置中的 `preferredOrientations` 控制
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
### 1. 运行已有品牌
|
### 运行已有品牌
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd apps/quanxue
|
cd apps/aixue
|
||||||
flutter run
|
flutter run
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 生成新品牌
|
也可以替换为:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1) 在 flavors/ 下创建品牌配置
|
cd apps/test
|
||||||
cp flavors/quanxue.yaml flavors/新品牌.yaml
|
flutter run
|
||||||
# 2) 修改配置中的 app_name, application_id, app_key, theme, branding
|
```
|
||||||
# 3) 准备品牌资源(参见下方规格要求)
|
|
||||||
mkdir flavors/新品牌
|
```bash
|
||||||
cp 你的图标.png flavors/新品牌/icon.png
|
cd apps/yunxiao
|
||||||
cp 你的前景图.png flavors/新品牌/icon_foreground.png
|
flutter run
|
||||||
cp 你的启动图.png flavors/新品牌/splash.png
|
```
|
||||||
# 4) 运行生成脚本
|
|
||||||
dart run tool/generate_app.dart 新品牌
|
### 生成或重建品牌应用
|
||||||
|
|
||||||
|
```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
|
```yaml
|
||||||
app_name: "全学通" # 应用名
|
app_name: "全学通"
|
||||||
application_id: "com.yuanxuan.quanxue" # 包名
|
application_id: "com.yuanxuan.quanxue"
|
||||||
app_key: "quanxue_prod" # 业务标识
|
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:
|
theme:
|
||||||
accent_color: "0xFF3ED37B" # 主题色
|
accent_color: "0xFF3ED37B"
|
||||||
bg_color: "0xFFFFFFFF" # 背景色
|
bg_color: "0xFFFFFFFF"
|
||||||
text_color: "0xFF1F2937" # 主文字色
|
text_color: "0xFF1F2937"
|
||||||
muted_text_color: "0xFF6B7280" # 次要文字色
|
muted_text_color: "0xFF6B7280"
|
||||||
branding:
|
branding:
|
||||||
icon: "icon.png" # 应用图标(相对于 flavors/<品牌>/)
|
icon: "icon.png"
|
||||||
icon_background: "#FFFFFF" # 自适应图标背景色
|
icon_background: "#FFFFFF"
|
||||||
icon_foreground: "icon_foreground.png" # 自适应图标前景
|
icon_foreground: "icon_foreground.png"
|
||||||
splash: "splash.png" # 启动页图片
|
splash: "splash.png"
|
||||||
splash_color: "#FFFFFF" # 启动页背景色
|
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.png` | 1024×1024 | PNG | 用于生成各尺寸 launcher icon |
|
||||||
| 自适应图标前景 | `icon_foreground.png` | 1024×1024 | PNG (透明背景) | 主体内容需居中,四周留 **25% 安全区** |
|
| 自适应图标前景 | `icon_foreground.png` | 1024×1024 | PNG | 建议透明背景,主体留安全区 |
|
||||||
| 启动页图片 | `splash.png` | 1152×1152 | PNG | 用于生成 Android 12+ 和旧版启动页 |
|
| 启动页图片 | `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` 退出
|
- 调试结束时在 `flutter run` 控制台按 `q` 退出
|
||||||
- 或使用调试脚本自动杀旧进程再启动:
|
- 或使用:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\tool\flutter_run_fresh.ps1 # 自动选设备
|
.\tool\flutter_run_fresh.ps1
|
||||||
.\tool\flutter_run_fresh.ps1 -d F136A # 指定设备
|
.\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` |
|
| WebView | `webview_flutter` + `webview_flutter_android` |
|
||||||
| 宿主能力 | `image_picker` · `file_picker` · `permission_handler` · `url_launcher` |
|
| 宿主能力 | `image_picker` · `file_picker` · `permission_handler` · `url_launcher` |
|
||||||
| 原生层 | Kotlin Plugin + Java `CoreShellActivity` |
|
| 配置缓存 | `shared_preferences` |
|
||||||
| 代码规范 | `very_good_analysis` |
|
| 网络与状态 | `http` · `connectivity_plus` |
|
||||||
|
| 原生层 | Java `CoreShellActivity` + Kotlin plugin |
|
||||||
|
|
||||||
## 平台约束
|
## 平台约束
|
||||||
|
|
||||||
**仅支持 Android 平板。** iOS / Web / Desktop 平台已移除。
|
仅支持 Android 平板。
|
||||||
|
|
|
||||||
|
|
@ -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 桥接 │ │
|
1. 本地默认启动配置:`assets/config/bootstrap.json`
|
||||||
│ └──────────┘ └──────────┘ └──────────┘ │
|
2. 远程启动配置:`bootstrap_config_url`
|
||||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
|
||||||
│ │ services │ │ ui │ │ testing │ │
|
字段示例:
|
||||||
│ │ 宿主服务 │ │ 壳层界面 │ │ 测试钩子 │ │
|
|
||||||
│ └──────────┘ └──────────┘ └──────────┘ │
|
```json
|
||||||
├──────────────────────────────────────────────────────┤
|
{
|
||||||
│ CoreShellActivity (Java) │ 原生层
|
"initialUrl": "https://example.com/login",
|
||||||
│ 进程隔离 · WebView 信息查询 · 深度重置 │
|
"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. 启动流程
|
### 1. 启动流程
|
||||||
|
|
||||||
```
|
```text
|
||||||
main() → runShellApp(env)
|
main()
|
||||||
→ WidgetsFlutterBinding.ensureInitialized()
|
→ runShellApp(env)
|
||||||
→ 设置屏幕方向(竖屏锁定)
|
→ WidgetsFlutterBinding.ensureInitialized()
|
||||||
→ 进入沉浸式模式
|
→ 读取本地默认启动配置 bootstrapConfigAsset
|
||||||
→ runApp(ShellApp)
|
→ 拉取远程启动配置 bootstrapConfigUrl(可选,失败走缓存)
|
||||||
→ Android? → WebShellPage(WebView 容器)
|
→ 合并 initialUrl / preferredOrientations
|
||||||
→ 其他? → UnsupportedPlatformPage(兜底页)
|
→ setupConfigUrl(upgradeConfigUrl)
|
||||||
|
→ _initializeUrls()
|
||||||
|
→ SystemChrome.setPreferredOrientations(...)
|
||||||
|
→ 进入沉浸式模式
|
||||||
|
→ runApp(ShellApp)
|
||||||
|
→ Android → WebShellPage
|
||||||
|
→ 非 Android → UnsupportedPlatformPage
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. WebView 启动与恢复
|
### 2. WebView 启动与恢复
|
||||||
|
|
||||||
```
|
```text
|
||||||
WebShellPage.initState()
|
WebShellPage.initState()
|
||||||
→ 查询 Android WebView 信息(SDK / 包名 / 版本号)
|
→ 查询 Android WebView 信息(SDK / 包名 / 版本号)
|
||||||
→ 生成兼容性策略(renderModes / useWideViewPort / aggressiveRecovery)
|
→ 生成兼容性策略(renderModes / useWideViewPort / aggressiveRecovery)
|
||||||
→ 创建 WebView(默认 texture 模式)
|
→ 创建 WebView
|
||||||
→ 首帧就绪后加载初始 URL
|
→ 首帧后加载 _initialUrl
|
||||||
→ 启动看门狗计时器
|
→ 同时异步执行 checkVersion(context)
|
||||||
→ 超时? → 切换渲染模式(hybrid)→ 深度清理 → 自动重试
|
→ 主帧成功 → 注入 Bridge
|
||||||
→ 再超时? → 展示错误页 + 兼容性提示
|
→ 主帧失败 → 展示 ErrorOverlay,不再白屏
|
||||||
|
→ 启动超时 → 切换渲染模式 / 深度清理 / 自动重试
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. JS Bridge 协议
|
### 3. JS Bridge 协议
|
||||||
|
|
||||||
```
|
```text
|
||||||
H5 页面 Flutter 壳
|
H5 页面 Flutter 壳
|
||||||
│ │
|
│ │
|
||||||
│ AppShellChannel. │
|
│ AppShellChannel.postMessage │
|
||||||
│ postMessage(JSON) │
|
│──────────────────────────────→ │ 解析 action + payload
|
||||||
│ ──────────────────────→ │ 解析 action + payload
|
│ │ 执行 handler
|
||||||
│ │ 执行对应 handler
|
│ __appShellReceiveResponse │
|
||||||
│ window. │
|
│←────────────────────────────── │ 返回 { requestId, success, data/error }
|
||||||
│ __appShellReceiveResponse│
|
|
||||||
│ ←────────────────────── │ 返回 { requestId, success, data/error }
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**支持的 Action:**
|
当前支持 12 个 Action:
|
||||||
|
|
||||||
| Action | 说明 | 返回 |
|
| Action | 说明 |
|
||||||
|---|---|---|
|
|---|---|
|
||||||
| `pickImage` | 从图库选图(支持多选) | `[{name, uri, mimeType, size, dataUrl}]` |
|
| `pickImage` | 从图库选图 |
|
||||||
| `captureImage` | 相机拍照 | `{name, uri, mimeType, size, dataUrl}` |
|
| `captureImage` | 相机拍照 |
|
||||||
| `pickFile` | 文件选择 | `[{name, uri, mimeType, size, dataUrl}]` |
|
| `pickFile` | 文件选择 |
|
||||||
| `openExternal` | 打开外部应用 | `boolean` |
|
| `openExternal` | 打开外部应用 |
|
||||||
| `requestPermissions` | 请求系统权限 | `{type: statusName}` |
|
| `requestPermissions` | 请求系统权限 |
|
||||||
| `reloadPage` | 重新加载当前页面 | `true` |
|
| `reloadPage` | 重新加载当前页面 |
|
||||||
| `goBack` | 返回上一页 | `boolean` |
|
| `goBack` | 返回上一页 |
|
||||||
| `closeApp` | 关闭应用 | 无(直接退出) |
|
| `closeApp` | 关闭应用 |
|
||||||
|
| `getDeviceInfo` | 获取设备信息 |
|
||||||
|
| `getNetworkStatus` | 获取网络状态 |
|
||||||
|
| `showToast` | 展示 Toast |
|
||||||
|
| `setStatusBar` | 设置状态栏样式 |
|
||||||
|
|
||||||
## 兼容性策略
|
## 兼容性策略
|
||||||
|
|
||||||
| 条件 | 渲染模式 | 恢复策略 |
|
| 条件 | 渲染模式 | 恢复策略 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| SDK ≥ 29 + WebView ≥ 113 | texture 优先 | 标准恢复 |
|
| SDK ≥ 29 + 新版 WebView | texture 优先 | 标准恢复 |
|
||||||
| SDK ≤ 28 或 WebView < 113 | hybrid 优先 | 激进恢复(2 次重试) |
|
| SDK ≤ 28 或旧版 WebView | hybrid 优先 | 激进恢复 |
|
||||||
| F136A 设备 | hybrid 优先 | 激进恢复 + 建议更新 WebView |
|
| F136A 设备 | hybrid 优先 | 激进恢复 + 建议更新 WebView |
|
||||||
|
|
||||||
## 新增品牌
|
## 新增品牌流程
|
||||||
|
|
||||||
1. 创建 `flavors/品牌名.yaml`
|
1. 新建 `flavors/品牌名.yaml`
|
||||||
2. 运行 `dart run tool/generate_app.dart 品牌名`
|
2. 在 `flavors/品牌名/` 放置品牌资源
|
||||||
3. 脚本自动生成完整的 Flutter App 在 `apps/品牌名/`
|
3. 执行 `dart run tool/generate_app.dart 品牌名`
|
||||||
4. 修改图标后运行 `flutter pub run flutter_launcher_icons`
|
4. 生成器自动产出:
|
||||||
5. 构建 APK:`flutter build apk --release`
|
- `apps/品牌名/lib/main.dart`
|
||||||
|
- `apps/品牌名/assets/config/bootstrap.json`
|
||||||
|
- `apps/品牌名/assets/branding/*`
|
||||||
|
- Android 图标、启动页、签名配置
|
||||||
|
5. 构建 APK:`cd apps/品牌名 && flutter build apk --release`
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
- 更新多品牌生成器文档,补充 `bootstrap_config_url`、`upgrade_config_url`、`preferred_orientations` 配置说明
|
||||||
|
- 更新核心库接入文档,说明本地默认启动配置、远程启动配置缓存、独立升级配置与方向控制
|
||||||
|
- 更新架构文档,补充启动配置与升级配置拆分后的启动流程
|
||||||
|
|
||||||
## 0.0.1
|
## 0.0.1
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|
@ -8,9 +15,9 @@
|
||||||
- 15 个模块文件拆分(config / engine / bridge / services / ui)
|
- 15 个模块文件拆分(config / engine / bridge / services / ui)
|
||||||
- Android WebView 兼容性自动检测(`AndroidWebViewInfo` + `AndroidCompatibilityPlan`)
|
- Android WebView 兼容性自动检测(`AndroidWebViewInfo` + `AndroidCompatibilityPlan`)
|
||||||
- 双渲染模式(texture / hybrid)自动切换 + 启动看门狗恢复链
|
- 双渲染模式(texture / hybrid)自动切换 + 启动看门狗恢复链
|
||||||
- `window.AppShell` JS Bridge 协议(8 种 Action)
|
- `window.AppShell` JS Bridge 协议
|
||||||
- 旧相机 JS 兼容层(`openCamera` / `captureImage` monkey-patch)
|
- 旧相机 JS 兼容层(`openCamera` / `captureImage` monkey-patch)
|
||||||
- 媒体序列化支持 `base64` / `dataUrl` / `uri` 三种格式
|
- 媒体序列化支持 `base64` / `dataUrl` / `uri` 三种格式
|
||||||
- 54 个单元测试 + Widget 测试
|
- 单元测试 + Widget 测试
|
||||||
- `CoreShellActivity` 原生层(进程隔离 + WebView 信息查询 + 深度重置)
|
- `CoreShellActivity` 原生层(进程隔离 + WebView 信息查询 + 深度重置)
|
||||||
- 全中文 `debugPrint` 日志
|
- 全中文 `debugPrint` 日志
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# web_shell_core
|
# web_shell_core
|
||||||
|
|
||||||
Android 平板专用的 H5 壳核心库。所有品牌应用共享此库,只需传入 `ShellEnvironment` 即可启动。
|
Android 平板专用 H5 壳核心库。所有品牌应用共享此库,只需传入 `ShellEnvironment` 即可启动。
|
||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
|
|
@ -8,14 +8,15 @@ Android 平板专用的 H5 壳核心库。所有品牌应用共享此库,只
|
||||||
|---|---|
|
|---|---|
|
||||||
| **WebView 引擎** | 自动兼容低版本 Android WebView,支持 texture / hybrid 双渲染模式自动切换 |
|
| **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 页面 |
|
| **旧相机兼容** | Monkey-patch `openCamera` / `captureImage` 兼容老 H5 页面 |
|
||||||
| **媒体服务** | 相机拍照 · 图库选图 · 文件选择 · base64 / dataUrl / uri 三种序列化格式 |
|
| **媒体服务** | 相机拍照 · 图库选图 · 文件选择 · base64 / dataUrl / uri 三种序列化格式 |
|
||||||
| **权限服务** | camera · microphone · location · photos · videos · storage 统一映射 |
|
| **权限服务** | camera · microphone · location · photos · videos · storage 统一映射 |
|
||||||
| **导航服务** | URL scheme 白名单路由,非 WebView 协议自动跳转外部应用 |
|
| **导航服务** | URL scheme 白名单路由,非 WebView 协议自动跳转外部应用 |
|
||||||
| **壳层 UI** | 启动加载动画 · 错误恢复页 · 进度条 · 不支持平台兜底页 |
|
| **壳层 UI** | 启动加载动画 · 错误恢复页 · 进度条 · 不支持平台兜底页 |
|
||||||
| **启动配置** | 支持本地默认启动配置文件 + 远程启动配置缓存 |
|
| **启动配置** | 支持本地默认启动配置文件、远程启动配置和缓存兜底 |
|
||||||
| **版本检查** | 升级配置独立请求,不再与启动配置共用一个 JSON |
|
| **版本检查** | 升级配置独立请求,不再与启动配置共用一个 JSON |
|
||||||
|
| **方向控制** | 支持通过启动配置动态设置 `SystemChrome.setPreferredOrientations` |
|
||||||
|
|
||||||
## 使用方式
|
## 使用方式
|
||||||
|
|
||||||
|
|
@ -41,10 +42,11 @@ void main() {
|
||||||
ShellEnvironment(
|
ShellEnvironment(
|
||||||
appName: '全学通',
|
appName: '全学通',
|
||||||
appKey: 'quanxue_prod',
|
appKey: 'quanxue_prod',
|
||||||
accentColor: Color(0xFF3ED37B),
|
accentColor: const Color(0xFF3ED37B),
|
||||||
backgroundColor: Color(0xFFFFFFFF),
|
backgroundColor: const Color(0xFFFFFFFF),
|
||||||
textColor: Color(0xFF1F2937),
|
textColor: const Color(0xFF1F2937),
|
||||||
mutedTextColor: Color(0xFF6B7280),
|
mutedTextColor: const Color(0xFF6B7280),
|
||||||
|
splashImage: const AssetImage('assets/branding/splash.png'),
|
||||||
bootstrapConfigAsset: 'assets/config/bootstrap.json',
|
bootstrapConfigAsset: 'assets/config/bootstrap.json',
|
||||||
bootstrapConfigUrl: 'https://example.com/bootstrap.json',
|
bootstrapConfigUrl: 'https://example.com/bootstrap.json',
|
||||||
upgradeConfigUrl: 'https://example.com/upgrade.json',
|
upgradeConfigUrl: 'https://example.com/upgrade.json',
|
||||||
|
|
@ -53,6 +55,11 @@ void main() {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- `bootstrapConfigAsset`:本地默认启动配置文件
|
||||||
|
- `bootstrapConfigUrl`:可选,远程启动配置地址
|
||||||
|
- `upgradeConfigUrl`:可选,远程升级配置地址
|
||||||
|
- `preferredOrientations`:也可以直接在 `ShellEnvironment` 中传入,作为默认值
|
||||||
|
|
||||||
### 3. 远程启动配置格式
|
### 3. 远程启动配置格式
|
||||||
|
|
||||||
```json
|
```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/
|
lib/
|
||||||
├── core_app.dart # 库入口 + part 指令 + runShellApp()
|
├── core_app.dart
|
||||||
├── web_shell_core.dart # 公开 API 导出
|
├── web_shell_core.dart
|
||||||
└── src/
|
└── src/
|
||||||
├── config/
|
├── config/
|
||||||
│ ├── shell_environment.dart # 品牌配置数据类
|
│ ├── shell_environment.dart
|
||||||
│ └── url_resolver.dart # URL 解析与归一化
|
│ └── url_resolver.dart
|
||||||
├── engine/
|
├── engine/
|
||||||
│ ├── compatibility.dart # Android WebView 兼容检测
|
│ ├── compatibility.dart
|
||||||
│ └── recovery.dart # 启动看门狗 + 错误映射
|
│ └── recovery.dart
|
||||||
├── bridge/
|
├── bridge/
|
||||||
│ ├── bridge_protocol.dart # JS Bridge 注入与响应
|
│ ├── bridge_protocol.dart
|
||||||
│ ├── bridge_actions.dart # Action handler(占位)
|
│ ├── bridge_actions.dart
|
||||||
│ └── legacy_camera_compat.dart # 旧相机 JS 兼容层
|
│ └── legacy_camera_compat.dart
|
||||||
├── services/
|
├── services/
|
||||||
│ ├── config_service.dart # 启动配置读取与缓存
|
│ ├── config_service.dart
|
||||||
│ ├── media_service.dart # 相机/图库/文件 + 序列化
|
│ ├── media_service.dart
|
||||||
│ ├── permission_service.dart # 权限类型映射
|
│ ├── permission_service.dart
|
||||||
│ ├── navigation_service.dart # URL 路由 + 外链跳转
|
│ ├── navigation_service.dart
|
||||||
│ └── upgrade_service.dart # 升级配置请求与弹窗转换
|
│ └── upgrade_service.dart
|
||||||
├── ui/
|
├── ui/
|
||||||
│ ├── shell_app.dart # MaterialApp 入口
|
│ ├── shell_app.dart
|
||||||
│ ├── shell_page.dart # WebView 主页面
|
│ ├── shell_page.dart
|
||||||
│ ├── launch_overlay.dart # 启动加载动画
|
│ ├── launch_overlay.dart
|
||||||
│ ├── error_overlay.dart # 错误恢复页
|
│ ├── error_overlay.dart
|
||||||
│ ├── progress_bar.dart # 顶部进度条
|
│ ├── progress_bar.dart
|
||||||
│ └── unsupported_platform_page.dart # 平台兜底页
|
│ └── unsupported_platform_page.dart
|
||||||
└── testing/
|
└── testing/
|
||||||
└── test_hooks.dart # 测试钩子(@visibleForTesting)
|
└── test_hooks.dart
|
||||||
```
|
```
|
||||||
|
|
||||||
## 测试
|
## 测试
|
||||||
|
|
@ -121,17 +159,19 @@ flutter test
|
||||||
```
|
```
|
||||||
|
|
||||||
当前测试覆盖:
|
当前测试覆盖:
|
||||||
- 平台检测 · URL 解析 · 兼容性策略 · 错误映射
|
|
||||||
|
- 平台检测 · URL 解析 · 启动配置解析 · 方向配置
|
||||||
- Bridge 注入/响应/异常处理 · 媒体序列化 · 权限映射
|
- Bridge 注入/响应/异常处理 · 媒体序列化 · 权限映射
|
||||||
- 导航路由 · 启动配置解析 · 方向配置 · 组件渲染
|
- 导航路由 · 兼容性策略 · 错误恢复 · 组件渲染
|
||||||
|
|
||||||
## 平台约束
|
## 平台约束
|
||||||
|
|
||||||
仅支持 **Android**。其他平台会展示兜底提示页。
|
仅支持 Android。其他平台会展示兜底提示页。
|
||||||
|
|
||||||
## Android 原生层
|
## Android 原生层
|
||||||
|
|
||||||
`CoreShellActivity`(Java)提供:
|
`CoreShellActivity`(Java)提供:
|
||||||
|
|
||||||
- WebView 数据目录隔离(避免多进程冲突)
|
- WebView 数据目录隔离(避免多进程冲突)
|
||||||
- 旧进程自动终止
|
- 旧进程自动终止
|
||||||
- WebView 信息查询(SDK 版本、WebView 包名/版本号)
|
- WebView 信息查询(SDK 版本、WebView 包名/版本号)
|
||||||
|
|
|
||||||
|
|
@ -74,8 +74,8 @@ Future<void> runShellApp(ShellEnvironment environment) async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
_env = environment;
|
_env = environment;
|
||||||
|
|
||||||
await _applyBootstrapConfig();
|
final upgradeConfigUrl = await _applyBootstrapConfig();
|
||||||
ShellUpgradeService.instance.setupConfigUrl(_env.upgradeConfigUrl);
|
ShellUpgradeService.instance.setupConfigUrl(upgradeConfigUrl);
|
||||||
|
|
||||||
_initializeUrls();
|
_initializeUrls();
|
||||||
await SystemChrome.setPreferredOrientations(_shellPreferredOrientations);
|
await SystemChrome.setPreferredOrientations(_shellPreferredOrientations);
|
||||||
|
|
@ -83,7 +83,12 @@ Future<void> runShellApp(ShellEnvironment environment) async {
|
||||||
runApp(const ShellApp());
|
runApp(const ShellApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _applyBootstrapConfig() async {
|
/// 返回最终确定的 upgradeConfigUrl。
|
||||||
|
Future<String?> _applyBootstrapConfig() async {
|
||||||
|
String? bootstrapConfigUrl;
|
||||||
|
String? upgradeConfigUrl;
|
||||||
|
|
||||||
|
// 1. 读取本地 Asset 启动配置
|
||||||
final bootstrapAsset = _env.bootstrapConfigAsset;
|
final bootstrapAsset = _env.bootstrapConfigAsset;
|
||||||
if (bootstrapAsset != null && bootstrapAsset.isNotEmpty) {
|
if (bootstrapAsset != null && bootstrapAsset.isNotEmpty) {
|
||||||
debugPrint('WebShell 正在读取本地启动配置: $bootstrapAsset');
|
debugPrint('WebShell 正在读取本地启动配置: $bootstrapAsset');
|
||||||
|
|
@ -92,10 +97,12 @@ Future<void> _applyBootstrapConfig() async {
|
||||||
);
|
);
|
||||||
if (localConfig != null) {
|
if (localConfig != null) {
|
||||||
_mergeBootstrapConfig(localConfig, source: '本地启动配置');
|
_mergeBootstrapConfig(localConfig, source: '本地启动配置');
|
||||||
|
bootstrapConfigUrl = localConfig.bootstrapConfigUrl?.trim();
|
||||||
|
upgradeConfigUrl = localConfig.upgradeConfigUrl?.trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final bootstrapConfigUrl = _env.bootstrapConfigUrl;
|
// 2. 如果本地配置指定了远程启动配置 URL,则获取远程配置并覆盖
|
||||||
if (bootstrapConfigUrl != null && bootstrapConfigUrl.isNotEmpty) {
|
if (bootstrapConfigUrl != null && bootstrapConfigUrl.isNotEmpty) {
|
||||||
debugPrint('WebShell 正在获取远程启动配置: $bootstrapConfigUrl');
|
debugPrint('WebShell 正在获取远程启动配置: $bootstrapConfigUrl');
|
||||||
final remoteConfig = await ShellBootstrapConfigService.fetchConfig(
|
final remoteConfig = await ShellBootstrapConfigService.fetchConfig(
|
||||||
|
|
@ -103,8 +110,14 @@ Future<void> _applyBootstrapConfig() async {
|
||||||
);
|
);
|
||||||
if (remoteConfig != null) {
|
if (remoteConfig != null) {
|
||||||
_mergeBootstrapConfig(remoteConfig, source: '远程启动配置');
|
_mergeBootstrapConfig(remoteConfig, source: '远程启动配置');
|
||||||
|
// 远程配置可以覆盖升级地址
|
||||||
|
if (remoteConfig.upgradeConfigUrl?.trim().isNotEmpty == true) {
|
||||||
|
upgradeConfigUrl = remoteConfig.upgradeConfigUrl!.trim();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return upgradeConfigUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _mergeBootstrapConfig(
|
void _mergeBootstrapConfig(
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@ class ShellEnvironment {
|
||||||
this.splashImage,
|
this.splashImage,
|
||||||
this.initialUrl,
|
this.initialUrl,
|
||||||
this.bootstrapConfigAsset,
|
this.bootstrapConfigAsset,
|
||||||
this.bootstrapConfigUrl,
|
|
||||||
this.upgradeConfigUrl,
|
|
||||||
this.preferredOrientations,
|
this.preferredOrientations,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -43,15 +41,9 @@ class ShellEnvironment {
|
||||||
/// 可选的初始地址;作为本地默认启动配置缺省时的兜底值。
|
/// 可选的初始地址;作为本地默认启动配置缺省时的兜底值。
|
||||||
final String? initialUrl;
|
final String? initialUrl;
|
||||||
|
|
||||||
/// 可选的本地默认启动配置文件路径。
|
/// 可选的本地默认启动配置文件路径,配置中可指定远程 URL 和升级地址。
|
||||||
final String? bootstrapConfigAsset;
|
final String? bootstrapConfigAsset;
|
||||||
|
|
||||||
/// 可选的远程启动配置地址;配置中可指定 `initialUrl` 及方向锁定。
|
|
||||||
final String? bootstrapConfigUrl;
|
|
||||||
|
|
||||||
/// 可选的远程升级配置地址。
|
|
||||||
final String? upgradeConfigUrl;
|
|
||||||
|
|
||||||
/// 可选的页面首屏方向锁定配置;为空时使用默认竖屏。
|
/// 可选的页面首屏方向锁定配置;为空时使用默认竖屏。
|
||||||
final List<DeviceOrientation>? preferredOrientations;
|
final List<DeviceOrientation>? preferredOrientations;
|
||||||
|
|
||||||
|
|
@ -66,8 +58,6 @@ class ShellEnvironment {
|
||||||
ImageProvider? splashImage,
|
ImageProvider? splashImage,
|
||||||
String? initialUrl,
|
String? initialUrl,
|
||||||
String? bootstrapConfigAsset,
|
String? bootstrapConfigAsset,
|
||||||
String? bootstrapConfigUrl,
|
|
||||||
String? upgradeConfigUrl,
|
|
||||||
List<DeviceOrientation>? preferredOrientations,
|
List<DeviceOrientation>? preferredOrientations,
|
||||||
}) {
|
}) {
|
||||||
return ShellEnvironment(
|
return ShellEnvironment(
|
||||||
|
|
@ -80,8 +70,6 @@ class ShellEnvironment {
|
||||||
splashImage: splashImage ?? this.splashImage,
|
splashImage: splashImage ?? this.splashImage,
|
||||||
initialUrl: initialUrl ?? this.initialUrl,
|
initialUrl: initialUrl ?? this.initialUrl,
|
||||||
bootstrapConfigAsset: bootstrapConfigAsset ?? this.bootstrapConfigAsset,
|
bootstrapConfigAsset: bootstrapConfigAsset ?? this.bootstrapConfigAsset,
|
||||||
bootstrapConfigUrl: bootstrapConfigUrl ?? this.bootstrapConfigUrl,
|
|
||||||
upgradeConfigUrl: upgradeConfigUrl ?? this.upgradeConfigUrl,
|
|
||||||
preferredOrientations:
|
preferredOrientations:
|
||||||
preferredOrientations ?? this.preferredOrientations,
|
preferredOrientations ?? this.preferredOrientations,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,17 @@ class ShellBootstrapConfig {
|
||||||
/// 应用首屏锁定方向;`null` 表示沿用宿主默认配置。
|
/// 应用首屏锁定方向;`null` 表示沿用宿主默认配置。
|
||||||
final List<DeviceOrientation>? preferredOrientations;
|
final List<DeviceOrientation>? preferredOrientations;
|
||||||
|
|
||||||
|
/// 可选的远程启动配置地址(用于动态覆盖本地配置)。
|
||||||
|
final String? bootstrapConfigUrl;
|
||||||
|
|
||||||
|
/// 可选的远程升级配置地址。
|
||||||
|
final String? upgradeConfigUrl;
|
||||||
|
|
||||||
ShellBootstrapConfig({
|
ShellBootstrapConfig({
|
||||||
this.initialUrl,
|
this.initialUrl,
|
||||||
this.preferredOrientations,
|
this.preferredOrientations,
|
||||||
|
this.bootstrapConfigUrl,
|
||||||
|
this.upgradeConfigUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory ShellBootstrapConfig.fromJson(Map<String, dynamic> json) {
|
factory ShellBootstrapConfig.fromJson(Map<String, dynamic> json) {
|
||||||
|
|
@ -19,6 +27,8 @@ class ShellBootstrapConfig {
|
||||||
preferredOrientations: _parsePreferredOrientations(
|
preferredOrientations: _parsePreferredOrientations(
|
||||||
json['preferredOrientations'] ?? json['orientations'],
|
json['preferredOrientations'] ?? json['orientations'],
|
||||||
),
|
),
|
||||||
|
bootstrapConfigUrl: json['bootstrapConfigUrl']?.toString(),
|
||||||
|
upgradeConfigUrl: json['upgradeConfigUrl']?.toString(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,15 +63,21 @@ class ShellUpgradeService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
UpgradeAuxiliaryUtils.instance.initiateVersionCheck(
|
UpgradeAuxiliaryUtils.instance.initiateVersionCheck( // coverage:ignore-line
|
||||||
context,
|
context,
|
||||||
showNoUpdateToast: showNoUpdateToast,
|
showNoUpdateToast: showNoUpdateToast,
|
||||||
future: (int upType) async {
|
future: _createVersionResolver(remoteConfig), // coverage:ignore-line
|
||||||
return _convertToAppUpgradeVersion(remoteConfig);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<AppUpgradeVersion?> Function(int upType) _createVersionResolver(
|
||||||
|
ShellUpgradeConfig remoteConfig,
|
||||||
|
) {
|
||||||
|
return (int upType) async {
|
||||||
|
return _convertToAppUpgradeVersion(remoteConfig);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
Future<ShellUpgradeConfig?> _fetchConfig(String url) async {
|
Future<ShellUpgradeConfig?> _fetchConfig(String url) async {
|
||||||
try {
|
try {
|
||||||
final uri = Uri.tryParse(url);
|
final uri = Uri.tryParse(url);
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,16 @@ class ShellCoreTestHooks {
|
||||||
List<DeviceOrientation> get preferredOrientations =>
|
List<DeviceOrientation> get preferredOrientations =>
|
||||||
_shellPreferredOrientations;
|
_shellPreferredOrientations;
|
||||||
|
|
||||||
|
/// 返回当前环境中的初始地址配置。
|
||||||
|
String? get configuredInitialUrl => _env.initialUrl;
|
||||||
|
|
||||||
|
/// 返回当前环境中的首屏方向配置。
|
||||||
|
List<DeviceOrientation>? get configuredPreferredOrientations =>
|
||||||
|
_env.preferredOrientations;
|
||||||
|
|
||||||
|
/// 返回当前生效的升级配置地址。
|
||||||
|
String? get upgradeConfigUrl => ShellUpgradeService.instance._configUrl;
|
||||||
|
|
||||||
/// 解析字符串格式的启动配置。
|
/// 解析字符串格式的启动配置。
|
||||||
ShellBootstrapConfig? parseBootstrapConfigString(String content) {
|
ShellBootstrapConfig? parseBootstrapConfigString(String content) {
|
||||||
return ShellBootstrapConfigService._parseConfigString(content);
|
return ShellBootstrapConfigService._parseConfigString(content);
|
||||||
|
|
@ -30,6 +40,58 @@ class ShellCoreTestHooks {
|
||||||
return ShellBootstrapConfigService.loadDefaultConfig(assetPath);
|
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) {
|
void initializeEnvironment(ShellEnvironment environment) {
|
||||||
_env = environment;
|
_env = environment;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:typed_data';
|
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/image_picker.dart';
|
||||||
import 'package:image_picker_platform_interface/image_picker_platform_interface.dart';
|
import 'package:image_picker_platform_interface/image_picker_platform_interface.dart';
|
||||||
import 'package:permission_handler/permission_handler.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/link.dart';
|
||||||
import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart';
|
import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart';
|
||||||
import 'package:web_shell_core/core_app.dart';
|
import 'package:web_shell_core/core_app.dart';
|
||||||
|
|
@ -30,6 +33,34 @@ const _testEnvironment = ShellEnvironment(
|
||||||
initialUrl: 'example.com/login',
|
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() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
|
@ -42,6 +73,7 @@ void main() {
|
||||||
late bool cameraPermissionGranted;
|
late bool cameraPermissionGranted;
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||||
platformCalls = <String>[];
|
platformCalls = <String>[];
|
||||||
platformMethodCalls = <MethodCall>[];
|
platformMethodCalls = <MethodCall>[];
|
||||||
assetContents = <String, String>{};
|
assetContents = <String, String>{};
|
||||||
|
|
@ -1126,8 +1158,6 @@ void main() {
|
||||||
expect(_testEnvironment.mutedTextColor, const Color(0xFF6B7280));
|
expect(_testEnvironment.mutedTextColor, const Color(0xFF6B7280));
|
||||||
expect(_testEnvironment.initialUrl, 'example.com/login');
|
expect(_testEnvironment.initialUrl, 'example.com/login');
|
||||||
expect(_testEnvironment.bootstrapConfigAsset, isNull);
|
expect(_testEnvironment.bootstrapConfigAsset, isNull);
|
||||||
expect(_testEnvironment.bootstrapConfigUrl, isNull);
|
|
||||||
expect(_testEnvironment.upgradeConfigUrl, isNull);
|
|
||||||
expect(_testEnvironment.preferredOrientations, isNull);
|
expect(_testEnvironment.preferredOrientations, isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1192,6 +1222,143 @@ void main() {
|
||||||
// 恢复测试环境
|
// 恢复测试环境
|
||||||
shellCoreTestHooks.initializeEnvironment(_testEnvironment);
|
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 协议格式', () {
|
group('Bridge JS 协议格式', () {
|
||||||
|
|
@ -1482,6 +1649,11 @@ void main() {
|
||||||
'systemFeatures': <String>[],
|
'systemFeatures': <String>[],
|
||||||
'serialNumber': 'unknown',
|
'serialNumber': 'unknown',
|
||||||
'isLowRamDevice': false,
|
'isLowRamDevice': false,
|
||||||
|
'freeDiskSize': 1024,
|
||||||
|
'totalDiskSize': 2048,
|
||||||
|
'physicalRamSize': 4096,
|
||||||
|
'availableRamSize': 2048,
|
||||||
|
'name': 'TestDevice',
|
||||||
'displayMetrics': <Object?, Object?>{
|
'displayMetrics': <Object?, Object?>{
|
||||||
'widthPx': 1080.0,
|
'widthPx': 1080.0,
|
||||||
'heightPx': 2400.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 {
|
test('getNetworkStatus 返回 WiFi 状态', () async {
|
||||||
final status = await shellCoreTestHooks.getNetworkStatusFromBridge();
|
final status = await shellCoreTestHooks.getNetworkStatusFromBridge();
|
||||||
|
|
||||||
|
|
@ -1688,9 +1877,452 @@ void main() {
|
||||||
debugDefaultTargetPlatformOverride = null;
|
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 {
|
class _FakeUrlLauncherPlatform extends UrlLauncherPlatform {
|
||||||
@override
|
@override
|
||||||
LinkDelegate? get linkDelegate => null;
|
LinkDelegate? get linkDelegate => null;
|
||||||
|
|
|
||||||
|
|
@ -63,13 +63,13 @@ Future<void> main(List<String> args) async {
|
||||||
backgroundColor: bgColor,
|
backgroundColor: bgColor,
|
||||||
textColor: textColor,
|
textColor: textColor,
|
||||||
mutedTextColor: mutedTextColor,
|
mutedTextColor: mutedTextColor,
|
||||||
bootstrapConfigUrl: bootstrapConfigUrl,
|
|
||||||
upgradeConfigUrl: upgradeConfigUrl,
|
|
||||||
);
|
);
|
||||||
await _generateBootstrapConfig(
|
await _generateBootstrapConfig(
|
||||||
appDir: appDir,
|
appDir: appDir,
|
||||||
defaultUrl: defaultUrl,
|
defaultUrl: defaultUrl,
|
||||||
preferredOrientations: preferredOrientations,
|
preferredOrientations: preferredOrientations,
|
||||||
|
bootstrapConfigUrl: bootstrapConfigUrl,
|
||||||
|
upgradeConfigUrl: upgradeConfigUrl,
|
||||||
);
|
);
|
||||||
await _generateBrandingAssets(brand, appDir, config);
|
await _generateBrandingAssets(brand, appDir, config);
|
||||||
await _registerFlutterAssets(appDir);
|
await _registerFlutterAssets(appDir);
|
||||||
|
|
@ -225,20 +225,10 @@ Future<void> _generateDartEntrypoint({
|
||||||
required String backgroundColor,
|
required String backgroundColor,
|
||||||
required String textColor,
|
required String textColor,
|
||||||
required String mutedTextColor,
|
required String mutedTextColor,
|
||||||
String? bootstrapConfigUrl,
|
|
||||||
String? upgradeConfigUrl,
|
|
||||||
}) async {
|
}) async {
|
||||||
print('\x1B[34m[信息] 正在生成 lib/main.dart...\x1B[0m');
|
print('\x1B[34m[信息] 正在生成 lib/main.dart...\x1B[0m');
|
||||||
final mainFile = File('$appDir/lib/main.dart');
|
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 =
|
final dartContent =
|
||||||
'''
|
'''
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
@ -254,7 +244,7 @@ void main() {
|
||||||
textColor: const Color($textColor),
|
textColor: const Color($textColor),
|
||||||
mutedTextColor: const Color($mutedTextColor),
|
mutedTextColor: const Color($mutedTextColor),
|
||||||
splashImage: const AssetImage('assets/branding/splash.png'),
|
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 appDir,
|
||||||
required String? defaultUrl,
|
required String? defaultUrl,
|
||||||
required List<String> preferredOrientations,
|
required List<String> preferredOrientations,
|
||||||
|
String? bootstrapConfigUrl,
|
||||||
|
String? upgradeConfigUrl,
|
||||||
}) async {
|
}) async {
|
||||||
print('\x1B[34m[信息] 正在生成默认启动配置 assets/config/bootstrap.json...\x1B[0m');
|
print('\x1B[34m[信息] 正在生成默认启动配置 assets/config/bootstrap.json...\x1B[0m');
|
||||||
final file = File('$appDir/assets/config/bootstrap.json');
|
final file = File('$appDir/assets/config/bootstrap.json');
|
||||||
|
|
@ -281,6 +273,10 @@ Future<void> _generateBootstrapConfig({
|
||||||
if (defaultUrl != null && defaultUrl.trim().isNotEmpty)
|
if (defaultUrl != null && defaultUrl.trim().isNotEmpty)
|
||||||
'initialUrl': defaultUrl.trim(),
|
'initialUrl': defaultUrl.trim(),
|
||||||
'preferredOrientations': preferredOrientations,
|
'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);
|
final content = const JsonEncoder.withIndent(' ').convert(data);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue