feat: 拆分启动与升级配置并补齐核心测试
This commit is contained in:
parent
0d259334ee
commit
435d99772c
210
README.md
210
README.md
|
|
@ -1,114 +1,198 @@
|
|||
# web_android_shell
|
||||
|
||||
Android 平板专用 H5 壳应用 — Monorepo 多品牌架构。
|
||||
Android 平板专用 H5 壳应用 Monorepo,面向多品牌白标场景。
|
||||
|
||||
## 项目结构
|
||||
## 当前品牌
|
||||
|
||||
```
|
||||
- `apps/aixue`:爱学蝶变
|
||||
- `apps/test`:测试壳工程
|
||||
- `apps/yunxiao`:云校嗨学
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
web_android_shell/
|
||||
├── apps/ # 品牌应用(每个品牌一个 Flutter App)
|
||||
│ └── quanxue/ # 全学通
|
||||
│ ├── aixue/
|
||||
│ ├── test/
|
||||
│ └── yunxiao/
|
||||
├── flavors/ # 品牌配置 + 品牌资源源文件
|
||||
│ ├── aixue.yaml
|
||||
│ ├── test.yaml
|
||||
│ ├── yunxiao.yaml
|
||||
│ ├── aixue/
|
||||
│ ├── test/
|
||||
│ └── yunxiao/
|
||||
├── packages/
|
||||
│ ├── web_shell_core/ # 核心库(WebView 引擎 + Bridge + 服务)
|
||||
│ └── web_android_shell/ # 旧版入口(已迁移至 apps/quanxue)
|
||||
├── flavors/ # 品牌配置 + 品牌资源
|
||||
│ ├── quanxue.yaml # 品牌配置
|
||||
│ └── quanxue/ # 品牌资源(图标、启动页)
|
||||
│ ├── icon.png
|
||||
│ ├── icon_foreground.png
|
||||
│ └── splash.png
|
||||
│ ├── web_shell_core/ # 核心壳能力库
|
||||
│ └── web_android_shell/ # 历史包,当前不作为主入口维护
|
||||
├── tool/
|
||||
│ ├── generate_app.dart # 一键生成新品牌应用
|
||||
│ └── flutter_run_fresh.ps1 # Windows 调试脚本(自动杀旧进程)
|
||||
└── doc/ # 项目文档
|
||||
│ ├── generate_app.dart # 按 flavor 一键生成品牌 App
|
||||
│ └── flutter_run_fresh.ps1 # Windows 调试脚本
|
||||
└── doc/
|
||||
└── architecture.md # 架构说明
|
||||
```
|
||||
|
||||
## 核心思路
|
||||
|
||||
- 应用启动优先读取本地默认启动配置 `assets/config/bootstrap.json`
|
||||
- 如配置了 `bootstrap_config_url`,会继续拉取远程启动配置,并使用缓存兜底
|
||||
- 如配置了 `upgrade_config_url`,首帧后异步检查版本更新
|
||||
- 启动配置与升级配置已拆分,不再共用同一个 JSON
|
||||
- 屏幕方向支持通过启动配置中的 `preferredOrientations` 控制
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 运行已有品牌
|
||||
### 运行已有品牌
|
||||
|
||||
```bash
|
||||
cd apps/quanxue
|
||||
cd apps/aixue
|
||||
flutter run
|
||||
```
|
||||
|
||||
### 2. 生成新品牌
|
||||
也可以替换为:
|
||||
|
||||
```bash
|
||||
# 1) 在 flavors/ 下创建品牌配置
|
||||
cp flavors/quanxue.yaml flavors/新品牌.yaml
|
||||
# 2) 修改配置中的 app_name, application_id, app_key, theme, branding
|
||||
# 3) 准备品牌资源(参见下方规格要求)
|
||||
mkdir flavors/新品牌
|
||||
cp 你的图标.png flavors/新品牌/icon.png
|
||||
cp 你的前景图.png flavors/新品牌/icon_foreground.png
|
||||
cp 你的启动图.png flavors/新品牌/splash.png
|
||||
# 4) 运行生成脚本
|
||||
dart run tool/generate_app.dart 新品牌
|
||||
cd apps/test
|
||||
flutter run
|
||||
```
|
||||
|
||||
```bash
|
||||
cd apps/yunxiao
|
||||
flutter run
|
||||
```
|
||||
|
||||
### 生成或重建品牌应用
|
||||
|
||||
```bash
|
||||
dart run tool/generate_app.dart aixue
|
||||
dart run tool/generate_app.dart test
|
||||
dart run tool/generate_app.dart yunxiao
|
||||
```
|
||||
|
||||
生成脚本会自动完成:
|
||||
- 创建 Flutter 应用 → `apps/新品牌/`
|
||||
- 添加 `web_shell_core` 依赖
|
||||
- 覆写 `MainActivity` 继承 `CoreShellActivity`
|
||||
- 更新 `AndroidManifest.xml` 应用名
|
||||
- 生成品牌入口 `main.dart`
|
||||
- 复制品牌资源到生成目录
|
||||
- 添加 `flutter_launcher_icons` / `flutter_native_splash` 依赖
|
||||
- 自动生成应用图标和启动页
|
||||
|
||||
### 3. 品牌配置格式
|
||||
- 创建 Flutter Android 应用到 `apps/<品牌名>/`
|
||||
- 添加 `web_shell_core` 依赖
|
||||
- 修正 Android `namespace` 与 `applicationId`
|
||||
- 生成 `MainActivity` 并继承 `CoreShellActivity`
|
||||
- 生成品牌入口 `lib/main.dart`
|
||||
- 生成本地默认启动配置 `assets/config/bootstrap.json`
|
||||
- 复制品牌资源到 `assets/branding/`
|
||||
- 注册 `assets/branding/` 与 `assets/config/`
|
||||
- 生成 launcher icon 与 splash
|
||||
- 注入 Android 签名配置
|
||||
|
||||
## Flavor 配置格式
|
||||
|
||||
每个品牌在 `flavors/<品牌名>.yaml` 中维护配置。
|
||||
|
||||
```yaml
|
||||
app_name: "全学通" # 应用名
|
||||
application_id: "com.yuanxuan.quanxue" # 包名
|
||||
app_key: "quanxue_prod" # 业务标识
|
||||
app_name: "全学通"
|
||||
application_id: "com.yuanxuan.quanxue"
|
||||
app_key: "quanxue_prod"
|
||||
default_url: "https://example.com/login"
|
||||
bootstrap_config_url: "https://example.com/bootstrap.json"
|
||||
upgrade_config_url: "https://example.com/upgrade.json"
|
||||
preferred_orientations:
|
||||
- "portraitUp"
|
||||
- "portraitDown"
|
||||
theme:
|
||||
accent_color: "0xFF3ED37B" # 主题色
|
||||
bg_color: "0xFFFFFFFF" # 背景色
|
||||
text_color: "0xFF1F2937" # 主文字色
|
||||
muted_text_color: "0xFF6B7280" # 次要文字色
|
||||
accent_color: "0xFF3ED37B"
|
||||
bg_color: "0xFFFFFFFF"
|
||||
text_color: "0xFF1F2937"
|
||||
muted_text_color: "0xFF6B7280"
|
||||
branding:
|
||||
icon: "icon.png" # 应用图标(相对于 flavors/<品牌>/)
|
||||
icon_background: "#FFFFFF" # 自适应图标背景色
|
||||
icon_foreground: "icon_foreground.png" # 自适应图标前景
|
||||
splash: "splash.png" # 启动页图片
|
||||
splash_color: "#FFFFFF" # 启动页背景色
|
||||
icon: "icon.png"
|
||||
icon_background: "#FFFFFF"
|
||||
icon_foreground: "icon_foreground.png"
|
||||
splash: "splash.png"
|
||||
splash_color: "#FFFFFF"
|
||||
```
|
||||
|
||||
### 4. 品牌资源规格
|
||||
### 字段说明
|
||||
|
||||
- `default_url`:生成到本地默认启动配置 `assets/config/bootstrap.json`
|
||||
- `bootstrap_config_url`:可选,远程启动配置地址;为空时不会写入 `main.dart`
|
||||
- `upgrade_config_url`:可选,远程升级配置地址;为空时不会写入 `main.dart`
|
||||
- `preferred_orientations`:首屏方向配置,支持:
|
||||
- `portraitUp`
|
||||
- `portraitDown`
|
||||
- `landscapeLeft`
|
||||
- `landscapeRight`
|
||||
|
||||
## 生成后的入口形态
|
||||
|
||||
生成器会把品牌入口写成统一模板:
|
||||
|
||||
```dart
|
||||
runShellApp(
|
||||
ShellEnvironment(
|
||||
appName: '品牌名',
|
||||
appKey: 'brand_key',
|
||||
accentColor: const Color(0xFF3ED37B),
|
||||
backgroundColor: const Color(0xFFFFFFFF),
|
||||
textColor: const Color(0xFF1F2937),
|
||||
mutedTextColor: const Color(0xFF6B7280),
|
||||
splashImage: const AssetImage('assets/branding/splash.png'),
|
||||
bootstrapConfigAsset: 'assets/config/bootstrap.json',
|
||||
bootstrapConfigUrl: 'https://example.com/bootstrap.json',
|
||||
upgradeConfigUrl: 'https://example.com/upgrade.json',
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
其中远程地址为空时,对应字段不会写入生成结果。
|
||||
|
||||
## 本地默认启动配置格式
|
||||
|
||||
生成器会自动生成 `assets/config/bootstrap.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"initialUrl": "https://example.com/login",
|
||||
"preferredOrientations": [
|
||||
"portraitUp",
|
||||
"portraitDown"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 品牌资源规格
|
||||
|
||||
| 资源 | 文件名 | 最小尺寸 | 格式 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| 应用图标 | `icon.png` | 1024×1024 | PNG | 正方形,用于生成各尺寸 launcher icon |
|
||||
| 自适应图标前景 | `icon_foreground.png` | 1024×1024 | PNG (透明背景) | 主体内容需居中,四周留 **25% 安全区** |
|
||||
| 启动页图片 | `splash.png` | 1152×1152 | PNG | 用于生成 Android 12+ 和旧版启动页 |
|
||||
| 应用图标 | `icon.png` | 1024×1024 | PNG | 用于生成各尺寸 launcher icon |
|
||||
| 自适应图标前景 | `icon_foreground.png` | 1024×1024 | PNG | 建议透明背景,主体留安全区 |
|
||||
| 启动页图片 | `splash.png` | 1152×1152 | PNG | 用于生成 Android 12+ 与旧版启动页 |
|
||||
|
||||
> **提示:** 资源文件放置在 `flavors/<品牌名>/` 目录下,`branding` 中的路径相对于此目录。如果省略 `branding` 段,脚本会跳过图标和启动页生成。
|
||||
品牌资源源文件放在 `flavors/<品牌名>/` 下,生成后会复制到 `apps/<品牌名>/assets/branding/`。
|
||||
|
||||
## 调试说明
|
||||
|
||||
部分教育平板设备使用 `Ctrl+C` 结束 `flutter run` 后,旧进程可能留在后台影响 WebView 启动。
|
||||
部分教育平板设备在 `flutter run` 中断后,旧进程可能残留并影响 WebView 启动。
|
||||
|
||||
推荐做法:
|
||||
|
||||
**推荐做法:**
|
||||
- 调试结束时在 `flutter run` 控制台按 `q` 退出
|
||||
- 或使用调试脚本自动杀旧进程再启动:
|
||||
- 或使用:
|
||||
|
||||
```powershell
|
||||
.\tool\flutter_run_fresh.ps1 # 自动选设备
|
||||
.\tool\flutter_run_fresh.ps1 -d F136A # 指定设备
|
||||
.\tool\flutter_run_fresh.ps1
|
||||
.\tool\flutter_run_fresh.ps1 -d F136A
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 组件 | 技术 |
|
||||
|---|---|
|
||||
| 框架 | Flutter 3.x (Dart 3.11+) |
|
||||
| 框架 | Flutter 3.x / Dart 3.11+ |
|
||||
| WebView | `webview_flutter` + `webview_flutter_android` |
|
||||
| 宿主能力 | `image_picker` · `file_picker` · `permission_handler` · `url_launcher` |
|
||||
| 原生层 | Kotlin Plugin + Java `CoreShellActivity` |
|
||||
| 代码规范 | `very_good_analysis` |
|
||||
| 配置缓存 | `shared_preferences` |
|
||||
| 网络与状态 | `http` · `connectivity_plus` |
|
||||
| 原生层 | Java `CoreShellActivity` + Kotlin plugin |
|
||||
|
||||
## 平台约束
|
||||
|
||||
**仅支持 Android 平板。** iOS / Web / Desktop 平台已移除。
|
||||
仅支持 Android 平板。
|
||||
|
|
|
|||
|
|
@ -2,92 +2,140 @@
|
|||
|
||||
## 整体架构
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ apps/quanxue apps/品牌B apps/品牌C │ 品牌应用层
|
||||
│ (16 行 main.dart) │ 只传 ShellEnvironment
|
||||
├──────────────────────────────────────────────────────┤
|
||||
```text
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ apps/aixue apps/test apps/yunxiao apps/... │ 品牌应用层
|
||||
│ main.dart 只负责传入 ShellEnvironment │
|
||||
├────────────────────────────────────────────────────────────┤
|
||||
│ web_shell_core │ 核心库
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ config │ │ engine │ │ bridge │ │
|
||||
│ │ 环境配置 │ │ 兼容引擎 │ │ JS 桥接 │ │
|
||||
│ │ 启动配置 │ │ 兼容引擎 │ │ 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)
|
||||
```text
|
||||
main()
|
||||
→ runShellApp(env)
|
||||
→ WidgetsFlutterBinding.ensureInitialized()
|
||||
→ 设置屏幕方向(竖屏锁定)
|
||||
→ 读取本地默认启动配置 bootstrapConfigAsset
|
||||
→ 拉取远程启动配置 bootstrapConfigUrl(可选,失败走缓存)
|
||||
→ 合并 initialUrl / preferredOrientations
|
||||
→ setupConfigUrl(upgradeConfigUrl)
|
||||
→ _initializeUrls()
|
||||
→ SystemChrome.setPreferredOrientations(...)
|
||||
→ 进入沉浸式模式
|
||||
→ runApp(ShellApp)
|
||||
→ Android? → WebShellPage(WebView 容器)
|
||||
→ 其他? → UnsupportedPlatformPage(兜底页)
|
||||
→ 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 协议
|
||||
|
||||
```
|
||||
```text
|
||||
H5 页面 Flutter 壳
|
||||
│ │
|
||||
│ AppShellChannel. │
|
||||
│ postMessage(JSON) │
|
||||
│ ──────────────────────→ │ 解析 action + payload
|
||||
│ │ 执行对应 handler
|
||||
│ window. │
|
||||
│ __appShellReceiveResponse│
|
||||
│ ←────────────────────── │ 返回 { requestId, success, data/error }
|
||||
│ 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`
|
||||
|
|
|
|||
|
|
@ -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` 日志
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# web_shell_core
|
||||
|
||||
Android 平板专用的 H5 壳核心库。所有品牌应用共享此库,只需传入 `ShellEnvironment` 即可启动。
|
||||
Android 平板专用 H5 壳核心库。所有品牌应用共享此库,只需传入 `ShellEnvironment` 即可启动。
|
||||
|
||||
## 功能
|
||||
|
||||
|
|
@ -8,14 +8,15 @@ Android 平板专用的 H5 壳核心库。所有品牌应用共享此库,只
|
|||
|---|---|
|
||||
| **WebView 引擎** | 自动兼容低版本 Android WebView,支持 texture / hybrid 双渲染模式自动切换 |
|
||||
| **启动恢复** | 看门狗超时检测 → 渲染模式降级 → 深度清理 → 自动重试 |
|
||||
| **JS Bridge** | `window.AppShell` 协议,支持 8 种 Action(pickImage · captureImage · pickFile · openExternal · requestPermissions · reloadPage · goBack · closeApp) |
|
||||
| **JS Bridge** | `window.AppShell` 协议,当前支持 12 个 Action |
|
||||
| **旧相机兼容** | Monkey-patch `openCamera` / `captureImage` 兼容老 H5 页面 |
|
||||
| **媒体服务** | 相机拍照 · 图库选图 · 文件选择 · base64 / dataUrl / uri 三种序列化格式 |
|
||||
| **权限服务** | camera · microphone · location · photos · videos · storage 统一映射 |
|
||||
| **导航服务** | URL scheme 白名单路由,非 WebView 协议自动跳转外部应用 |
|
||||
| **壳层 UI** | 启动加载动画 · 错误恢复页 · 进度条 · 不支持平台兜底页 |
|
||||
| **启动配置** | 支持本地默认启动配置文件 + 远程启动配置缓存 |
|
||||
| **启动配置** | 支持本地默认启动配置文件、远程启动配置和缓存兜底 |
|
||||
| **版本检查** | 升级配置独立请求,不再与启动配置共用一个 JSON |
|
||||
| **方向控制** | 支持通过启动配置动态设置 `SystemChrome.setPreferredOrientations` |
|
||||
|
||||
## 使用方式
|
||||
|
||||
|
|
@ -41,10 +42,11 @@ void main() {
|
|||
ShellEnvironment(
|
||||
appName: '全学通',
|
||||
appKey: 'quanxue_prod',
|
||||
accentColor: Color(0xFF3ED37B),
|
||||
backgroundColor: Color(0xFFFFFFFF),
|
||||
textColor: Color(0xFF1F2937),
|
||||
mutedTextColor: Color(0xFF6B7280),
|
||||
accentColor: const Color(0xFF3ED37B),
|
||||
backgroundColor: const Color(0xFFFFFFFF),
|
||||
textColor: const Color(0xFF1F2937),
|
||||
mutedTextColor: const Color(0xFF6B7280),
|
||||
splashImage: const AssetImage('assets/branding/splash.png'),
|
||||
bootstrapConfigAsset: 'assets/config/bootstrap.json',
|
||||
bootstrapConfigUrl: 'https://example.com/bootstrap.json',
|
||||
upgradeConfigUrl: 'https://example.com/upgrade.json',
|
||||
|
|
@ -53,6 +55,11 @@ void main() {
|
|||
}
|
||||
```
|
||||
|
||||
- `bootstrapConfigAsset`:本地默认启动配置文件
|
||||
- `bootstrapConfigUrl`:可选,远程启动配置地址
|
||||
- `upgradeConfigUrl`:可选,远程升级配置地址
|
||||
- `preferredOrientations`:也可以直接在 `ShellEnvironment` 中传入,作为默认值
|
||||
|
||||
### 3. 远程启动配置格式
|
||||
|
||||
```json
|
||||
|
|
@ -79,38 +86,69 @@ void main() {
|
|||
}
|
||||
```
|
||||
|
||||
## 启动时序
|
||||
|
||||
```text
|
||||
runShellApp(env)
|
||||
→ 读取 bootstrapConfigAsset
|
||||
→ 拉取 bootstrapConfigUrl(可选)
|
||||
→ 合并 initialUrl / preferredOrientations
|
||||
→ setupConfigUrl(upgradeConfigUrl)
|
||||
→ SystemChrome.setPreferredOrientations(...)
|
||||
→ runApp(ShellApp)
|
||||
|
||||
WebShellPage.initState()
|
||||
→ 首帧后加载首页
|
||||
→ 异步检查 upgradeConfigUrl
|
||||
```
|
||||
|
||||
## 支持的 Bridge Action
|
||||
|
||||
- `pickImage`
|
||||
- `captureImage`
|
||||
- `pickFile`
|
||||
- `openExternal`
|
||||
- `requestPermissions`
|
||||
- `reloadPage`
|
||||
- `goBack`
|
||||
- `closeApp`
|
||||
- `getDeviceInfo`
|
||||
- `getNetworkStatus`
|
||||
- `showToast`
|
||||
- `setStatusBar`
|
||||
|
||||
## 代码结构
|
||||
|
||||
```
|
||||
```text
|
||||
lib/
|
||||
├── core_app.dart # 库入口 + part 指令 + runShellApp()
|
||||
├── web_shell_core.dart # 公开 API 导出
|
||||
├── core_app.dart
|
||||
├── web_shell_core.dart
|
||||
└── src/
|
||||
├── config/
|
||||
│ ├── shell_environment.dart # 品牌配置数据类
|
||||
│ └── url_resolver.dart # URL 解析与归一化
|
||||
│ ├── shell_environment.dart
|
||||
│ └── url_resolver.dart
|
||||
├── engine/
|
||||
│ ├── compatibility.dart # Android WebView 兼容检测
|
||||
│ └── recovery.dart # 启动看门狗 + 错误映射
|
||||
│ ├── compatibility.dart
|
||||
│ └── recovery.dart
|
||||
├── bridge/
|
||||
│ ├── bridge_protocol.dart # JS Bridge 注入与响应
|
||||
│ ├── bridge_actions.dart # Action handler(占位)
|
||||
│ └── legacy_camera_compat.dart # 旧相机 JS 兼容层
|
||||
│ ├── bridge_protocol.dart
|
||||
│ ├── bridge_actions.dart
|
||||
│ └── legacy_camera_compat.dart
|
||||
├── services/
|
||||
│ ├── config_service.dart # 启动配置读取与缓存
|
||||
│ ├── media_service.dart # 相机/图库/文件 + 序列化
|
||||
│ ├── permission_service.dart # 权限类型映射
|
||||
│ ├── navigation_service.dart # URL 路由 + 外链跳转
|
||||
│ └── upgrade_service.dart # 升级配置请求与弹窗转换
|
||||
│ ├── config_service.dart
|
||||
│ ├── media_service.dart
|
||||
│ ├── permission_service.dart
|
||||
│ ├── navigation_service.dart
|
||||
│ └── upgrade_service.dart
|
||||
├── ui/
|
||||
│ ├── shell_app.dart # MaterialApp 入口
|
||||
│ ├── shell_page.dart # WebView 主页面
|
||||
│ ├── launch_overlay.dart # 启动加载动画
|
||||
│ ├── error_overlay.dart # 错误恢复页
|
||||
│ ├── progress_bar.dart # 顶部进度条
|
||||
│ └── unsupported_platform_page.dart # 平台兜底页
|
||||
│ ├── shell_app.dart
|
||||
│ ├── shell_page.dart
|
||||
│ ├── launch_overlay.dart
|
||||
│ ├── error_overlay.dart
|
||||
│ ├── progress_bar.dart
|
||||
│ └── unsupported_platform_page.dart
|
||||
└── testing/
|
||||
└── test_hooks.dart # 测试钩子(@visibleForTesting)
|
||||
└── test_hooks.dart
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
|
@ -121,17 +159,19 @@ flutter test
|
|||
```
|
||||
|
||||
当前测试覆盖:
|
||||
- 平台检测 · URL 解析 · 兼容性策略 · 错误映射
|
||||
|
||||
- 平台检测 · URL 解析 · 启动配置解析 · 方向配置
|
||||
- Bridge 注入/响应/异常处理 · 媒体序列化 · 权限映射
|
||||
- 导航路由 · 启动配置解析 · 方向配置 · 组件渲染
|
||||
- 导航路由 · 兼容性策略 · 错误恢复 · 组件渲染
|
||||
|
||||
## 平台约束
|
||||
|
||||
仅支持 **Android**。其他平台会展示兜底提示页。
|
||||
仅支持 Android。其他平台会展示兜底提示页。
|
||||
|
||||
## Android 原生层
|
||||
|
||||
`CoreShellActivity`(Java)提供:
|
||||
|
||||
- WebView 数据目录隔离(避免多进程冲突)
|
||||
- 旧进程自动终止
|
||||
- WebView 信息查询(SDK 版本、WebView 包名/版本号)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,7 +1877,450 @@ 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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue