diff --git a/README.md b/README.md index 191f589..c406aa8 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,114 @@ # web_android_shell -H5 壳子项目。 +Android 平板专用 H5 壳应用 — Monorepo 多品牌架构。 + +## 项目结构 + +``` +web_android_shell/ +├── apps/ # 品牌应用(每个品牌一个 Flutter App) +│ └── quanxue/ # 全学通 +├── packages/ +│ ├── web_shell_core/ # 核心库(WebView 引擎 + Bridge + 服务) +│ └── web_android_shell/ # 旧版入口(已迁移至 apps/quanxue) +├── flavors/ # 品牌配置 + 品牌资源 +│ ├── quanxue.yaml # 品牌配置 +│ └── quanxue/ # 品牌资源(图标、启动页) +│ ├── icon.png +│ ├── icon_foreground.png +│ └── splash.png +├── tool/ +│ ├── generate_app.dart # 一键生成新品牌应用 +│ └── flutter_run_fresh.ps1 # Windows 调试脚本(自动杀旧进程) +└── doc/ # 项目文档 +``` + +## 快速开始 + +### 1. 运行已有品牌 + +```bash +cd apps/quanxue +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 新品牌 +``` + +生成脚本会自动完成: +- 创建 Flutter 应用 → `apps/新品牌/` +- 添加 `web_shell_core` 依赖 +- 覆写 `MainActivity` 继承 `CoreShellActivity` +- 更新 `AndroidManifest.xml` 应用名 +- 生成品牌入口 `main.dart` +- 复制品牌资源到生成目录 +- 添加 `flutter_launcher_icons` / `flutter_native_splash` 依赖 +- 自动生成应用图标和启动页 + +### 3. 品牌配置格式 + +```yaml +app_name: "全学通" # 应用名 +application_id: "com.wanmake.quanxue" # 包名 +app_key: "quanxue_prod" # 业务标识 +theme: + 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" # 启动页背景色 +``` + +### 4. 品牌资源规格 + +| 资源 | 文件名 | 最小尺寸 | 格式 | 说明 | +|---|---|---|---|---| +| 应用图标 | `icon.png` | 1024×1024 | PNG | 正方形,用于生成各尺寸 launcher icon | +| 自适应图标前景 | `icon_foreground.png` | 1024×1024 | PNG (透明背景) | 主体内容需居中,四周留 **25% 安全区** | +| 启动页图片 | `splash.png` | 1152×1152 | PNG | 用于生成 Android 12+ 和旧版启动页 | + +> **提示:** 资源文件放置在 `flavors/<品牌名>/` 目录下,`branding` 中的路径相对于此目录。如果省略 `branding` 段,脚本会跳过图标和启动页生成。 ## 调试说明 -这个项目在部分设备上,如果直接用 `Ctrl+C` 结束 `flutter run`,设备里的上一次 App 进程可能还留在后台,下一次运行时会影响内嵌 WebView 启动。 +部分教育平板设备使用 `Ctrl+C` 结束 `flutter run` 后,旧进程可能留在后台影响 WebView 启动。 -更稳的做法: - -- 调试结束时优先在 `flutter run` 里按 `q` -- 或者使用项目自带脚本,先自动杀掉旧进程再启动 +**推荐做法:** +- 调试结束时在 `flutter run` 控制台按 `q` 退出 +- 或使用调试脚本自动杀旧进程再启动: ```powershell -.\tool\flutter_run_fresh.ps1 +.\tool\flutter_run_fresh.ps1 # 自动选设备 +.\tool\flutter_run_fresh.ps1 -d F136A # 指定设备 ``` -如果要指定设备,也可以继续透传给 `flutter run`: +## 技术栈 -```powershell -.\tool\flutter_run_fresh.ps1 -d F136A -``` +| 组件 | 技术 | +|---|---| +| 框架 | 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` | -脚本会自动: +## 平台约束 -- 读取 `android/local.properties` 中的 `sdk.dir` -- 调用 `adb shell am force-stop com.yuanxuan.webshell.web_android_shell` -- 然后执行 `flutter run` +**仅支持 Android 平板。** iOS / Web / Desktop 平台已移除。 diff --git a/analysis_options.yaml b/analysis_options.yaml index d4e0f0c..f13d6ae 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,28 +1,9 @@ -# This file configures the analyzer, which statically analyzes Dart code to -# check for errors, warnings, and lints. -# -# The issues identified by the analyzer are surfaced in the UI of Dart-enabled -# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be -# invoked from the command line by running `flutter analyze`. - -# The following line activates a set of recommended lints for Flutter apps, -# packages, and plugins designed to encourage good coding practices. -include: package:flutter_lints/flutter.yaml - -linter: - # The lint rules applied to this project can be customized in the - # section below to disable rules from the `package:flutter_lints/flutter.yaml` - # included above or to enable additional rules. A list of all available lints - # and their documentation is published at https://dart.dev/lints. - # - # Instead of disabling a lint rule for the entire project in the - # section below, it can also be suppressed for a single line of code - # or a specific dart file by using the `// ignore: name_of_lint` and - # `// ignore_for_file: name_of_lint` syntax on the line or in the file - # producing the lint. - rules: - # avoid_print: false # Uncomment to disable the `avoid_print` rule - # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options +# Dart 分析器配置,用于在开发阶段发现错误、警告和代码规范问题。 +# 可通过 `flutter analyze` 执行静态检查。 +include: package:flutter_lints/flutter.yaml + +linter: + # 如需自定义规则,可在此处开启或关闭指定 lint。 + rules: + # avoid_print: false # 取消注释后可关闭 `avoid_print` 规则。 + # prefer_single_quotes: true # 取消注释后可开启 `prefer_single_quotes` 规则。 diff --git a/apps/quanxue/.gitignore b/apps/quanxue/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/apps/quanxue/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/apps/quanxue/.metadata b/apps/quanxue/.metadata new file mode 100644 index 0000000..05a325e --- /dev/null +++ b/apps/quanxue/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "90673a4eef275d1a6692c26ac80d6d746d41a73a" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a + base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a + - platform: android + create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a + base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/apps/quanxue/README.md b/apps/quanxue/README.md new file mode 100644 index 0000000..213693b --- /dev/null +++ b/apps/quanxue/README.md @@ -0,0 +1,17 @@ +# quanxue + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) +- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/apps/quanxue/analysis_options.yaml b/apps/quanxue/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/apps/quanxue/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/apps/quanxue/android/.gitignore b/apps/quanxue/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/apps/quanxue/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/apps/quanxue/android/app/build.gradle.kts similarity index 82% rename from android/app/build.gradle.kts rename to apps/quanxue/android/app/build.gradle.kts index 7868f7c..5badcf2 100644 --- a/android/app/build.gradle.kts +++ b/apps/quanxue/android/app/build.gradle.kts @@ -1,45 +1,44 @@ -plugins { - id("com.android.application") - id("kotlin-android") - // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. - id("dev.flutter.flutter-gradle-plugin") -} - -android { - namespace = "com.yuanxuan.webshell.web_android_shell" - compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() - } - +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.wanmake.quanxue" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.yuanxuan.webshell.web_android_shell" + applicationId = "com.wanmake.quanxue" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion - // Keep a stable targetSdk for better compatibility with older system WebView builds. - targetSdk = 34 + targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName } - - buildTypes { - release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") - } - } -} - -flutter { - source = "../.." -} + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/apps/quanxue/android/app/src/debug/AndroidManifest.xml b/apps/quanxue/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/apps/quanxue/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/apps/quanxue/android/app/src/main/AndroidManifest.xml b/apps/quanxue/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a0b8770 --- /dev/null +++ b/apps/quanxue/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/quanxue/android/app/src/main/java/com/wanmake/quanxue/MainActivity.java b/apps/quanxue/android/app/src/main/java/com/wanmake/quanxue/MainActivity.java new file mode 100644 index 0000000..b46b12b --- /dev/null +++ b/apps/quanxue/android/app/src/main/java/com/wanmake/quanxue/MainActivity.java @@ -0,0 +1,6 @@ +package com.wanmake.quanxue; + +import com.yuanxuan.webshell.core.web_shell_core.CoreShellActivity; + +public class MainActivity extends CoreShellActivity { +} diff --git a/apps/quanxue/android/app/src/main/res/drawable-hdpi/android12splash.png b/apps/quanxue/android/app/src/main/res/drawable-hdpi/android12splash.png new file mode 100644 index 0000000..093727a Binary files /dev/null and b/apps/quanxue/android/app/src/main/res/drawable-hdpi/android12splash.png differ diff --git a/apps/quanxue/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/apps/quanxue/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..9054ac5 Binary files /dev/null and b/apps/quanxue/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/apps/quanxue/android/app/src/main/res/drawable-hdpi/splash.png b/apps/quanxue/android/app/src/main/res/drawable-hdpi/splash.png new file mode 100644 index 0000000..093727a Binary files /dev/null and b/apps/quanxue/android/app/src/main/res/drawable-hdpi/splash.png differ diff --git a/apps/quanxue/android/app/src/main/res/drawable-mdpi/android12splash.png b/apps/quanxue/android/app/src/main/res/drawable-mdpi/android12splash.png new file mode 100644 index 0000000..21677fe Binary files /dev/null and b/apps/quanxue/android/app/src/main/res/drawable-mdpi/android12splash.png differ diff --git a/apps/quanxue/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/apps/quanxue/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..78c4b1a Binary files /dev/null and b/apps/quanxue/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/apps/quanxue/android/app/src/main/res/drawable-mdpi/splash.png b/apps/quanxue/android/app/src/main/res/drawable-mdpi/splash.png new file mode 100644 index 0000000..21677fe Binary files /dev/null and b/apps/quanxue/android/app/src/main/res/drawable-mdpi/splash.png differ diff --git a/apps/quanxue/android/app/src/main/res/drawable-night-hdpi/android12splash.png b/apps/quanxue/android/app/src/main/res/drawable-night-hdpi/android12splash.png new file mode 100644 index 0000000..093727a Binary files /dev/null and b/apps/quanxue/android/app/src/main/res/drawable-night-hdpi/android12splash.png differ diff --git a/apps/quanxue/android/app/src/main/res/drawable-night-mdpi/android12splash.png b/apps/quanxue/android/app/src/main/res/drawable-night-mdpi/android12splash.png new file mode 100644 index 0000000..21677fe Binary files /dev/null and b/apps/quanxue/android/app/src/main/res/drawable-night-mdpi/android12splash.png differ diff --git a/apps/quanxue/android/app/src/main/res/drawable-night-xhdpi/android12splash.png b/apps/quanxue/android/app/src/main/res/drawable-night-xhdpi/android12splash.png new file mode 100644 index 0000000..f86ccfb Binary files /dev/null and b/apps/quanxue/android/app/src/main/res/drawable-night-xhdpi/android12splash.png differ diff --git a/apps/quanxue/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png b/apps/quanxue/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png new file mode 100644 index 0000000..ec5e150 Binary files /dev/null and b/apps/quanxue/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png differ diff --git a/apps/quanxue/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png b/apps/quanxue/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png new file mode 100644 index 0000000..fb0ffb9 Binary files /dev/null and b/apps/quanxue/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png differ diff --git a/apps/quanxue/android/app/src/main/res/drawable-v21/background.png b/apps/quanxue/android/app/src/main/res/drawable-v21/background.png new file mode 100644 index 0000000..8e21404 Binary files /dev/null and b/apps/quanxue/android/app/src/main/res/drawable-v21/background.png differ diff --git a/apps/quanxue/android/app/src/main/res/drawable-v21/launch_background.xml b/apps/quanxue/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..3cc4948 --- /dev/null +++ b/apps/quanxue/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/quanxue/android/app/src/main/res/drawable-xhdpi/android12splash.png b/apps/quanxue/android/app/src/main/res/drawable-xhdpi/android12splash.png new file mode 100644 index 0000000..f86ccfb Binary files /dev/null and b/apps/quanxue/android/app/src/main/res/drawable-xhdpi/android12splash.png differ diff --git a/apps/quanxue/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/apps/quanxue/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..0aafa46 Binary files /dev/null and b/apps/quanxue/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/apps/quanxue/android/app/src/main/res/drawable-xhdpi/splash.png b/apps/quanxue/android/app/src/main/res/drawable-xhdpi/splash.png new file mode 100644 index 0000000..f86ccfb Binary files /dev/null and b/apps/quanxue/android/app/src/main/res/drawable-xhdpi/splash.png differ diff --git a/apps/quanxue/android/app/src/main/res/drawable-xxhdpi/android12splash.png b/apps/quanxue/android/app/src/main/res/drawable-xxhdpi/android12splash.png new file mode 100644 index 0000000..ec5e150 Binary files /dev/null and b/apps/quanxue/android/app/src/main/res/drawable-xxhdpi/android12splash.png differ diff --git a/apps/quanxue/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/apps/quanxue/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..fab1938 Binary files /dev/null and b/apps/quanxue/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/apps/quanxue/android/app/src/main/res/drawable-xxhdpi/splash.png b/apps/quanxue/android/app/src/main/res/drawable-xxhdpi/splash.png new file mode 100644 index 0000000..ec5e150 Binary files /dev/null and b/apps/quanxue/android/app/src/main/res/drawable-xxhdpi/splash.png differ diff --git a/apps/quanxue/android/app/src/main/res/drawable-xxxhdpi/android12splash.png b/apps/quanxue/android/app/src/main/res/drawable-xxxhdpi/android12splash.png new file mode 100644 index 0000000..fb0ffb9 Binary files /dev/null and b/apps/quanxue/android/app/src/main/res/drawable-xxxhdpi/android12splash.png differ diff --git a/apps/quanxue/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/apps/quanxue/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..9f56d26 Binary files /dev/null and b/apps/quanxue/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/apps/quanxue/android/app/src/main/res/drawable-xxxhdpi/splash.png b/apps/quanxue/android/app/src/main/res/drawable-xxxhdpi/splash.png new file mode 100644 index 0000000..fb0ffb9 Binary files /dev/null and b/apps/quanxue/android/app/src/main/res/drawable-xxxhdpi/splash.png differ diff --git a/apps/quanxue/android/app/src/main/res/drawable/background.png b/apps/quanxue/android/app/src/main/res/drawable/background.png new file mode 100644 index 0000000..8e21404 Binary files /dev/null and b/apps/quanxue/android/app/src/main/res/drawable/background.png differ diff --git a/apps/quanxue/android/app/src/main/res/drawable/launch_background.xml b/apps/quanxue/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..3cc4948 --- /dev/null +++ b/apps/quanxue/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/quanxue/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/apps/quanxue/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..c79c58a --- /dev/null +++ b/apps/quanxue/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/apps/quanxue/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/quanxue/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..9960f24 Binary files /dev/null and b/apps/quanxue/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/apps/quanxue/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/apps/quanxue/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..398bcb9 Binary files /dev/null and b/apps/quanxue/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/apps/quanxue/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/apps/quanxue/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..3818b9a Binary files /dev/null and b/apps/quanxue/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/apps/quanxue/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/apps/quanxue/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..e7e712e Binary files /dev/null and b/apps/quanxue/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/apps/quanxue/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/apps/quanxue/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..eb17a16 Binary files /dev/null and b/apps/quanxue/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/apps/quanxue/android/app/src/main/res/values-night-v31/styles.xml b/apps/quanxue/android/app/src/main/res/values-night-v31/styles.xml new file mode 100644 index 0000000..1b8e45f --- /dev/null +++ b/apps/quanxue/android/app/src/main/res/values-night-v31/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/apps/quanxue/android/app/src/main/res/values-night/styles.xml b/apps/quanxue/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..dbc9ea9 --- /dev/null +++ b/apps/quanxue/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/apps/quanxue/android/app/src/main/res/values-v31/styles.xml b/apps/quanxue/android/app/src/main/res/values-v31/styles.xml new file mode 100644 index 0000000..8012d02 --- /dev/null +++ b/apps/quanxue/android/app/src/main/res/values-v31/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/apps/quanxue/android/app/src/main/res/values/colors.xml b/apps/quanxue/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..c5d5899 --- /dev/null +++ b/apps/quanxue/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/apps/quanxue/android/app/src/main/res/values/styles.xml b/apps/quanxue/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..0d1fa8f --- /dev/null +++ b/apps/quanxue/android/app/src/main/res/values/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/apps/quanxue/android/app/src/profile/AndroidManifest.xml b/apps/quanxue/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/apps/quanxue/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/apps/quanxue/android/build.gradle.kts b/apps/quanxue/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/apps/quanxue/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/apps/quanxue/android/gradle.properties b/apps/quanxue/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/apps/quanxue/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/apps/quanxue/android/gradle/wrapper/gradle-wrapper.properties b/apps/quanxue/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/apps/quanxue/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/apps/quanxue/android/settings.gradle.kts b/apps/quanxue/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/apps/quanxue/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/apps/quanxue/assets/branding/icon.png b/apps/quanxue/assets/branding/icon.png new file mode 100644 index 0000000..f19711a Binary files /dev/null and b/apps/quanxue/assets/branding/icon.png differ diff --git a/apps/quanxue/assets/branding/icon_foreground.png b/apps/quanxue/assets/branding/icon_foreground.png new file mode 100644 index 0000000..977ad82 Binary files /dev/null and b/apps/quanxue/assets/branding/icon_foreground.png differ diff --git a/apps/quanxue/assets/branding/splash.png b/apps/quanxue/assets/branding/splash.png new file mode 100644 index 0000000..1a95930 Binary files /dev/null and b/apps/quanxue/assets/branding/splash.png differ diff --git a/apps/quanxue/flutter_launcher_icons.yaml b/apps/quanxue/flutter_launcher_icons.yaml new file mode 100644 index 0000000..61717f8 --- /dev/null +++ b/apps/quanxue/flutter_launcher_icons.yaml @@ -0,0 +1,5 @@ +flutter_launcher_icons: + android: true + image_path: "assets/branding/icon.png" + adaptive_icon_background: "#FFFFFF" + adaptive_icon_foreground: "assets/branding/icon_foreground.png" diff --git a/apps/quanxue/flutter_native_splash.yaml b/apps/quanxue/flutter_native_splash.yaml new file mode 100644 index 0000000..bd05f40 --- /dev/null +++ b/apps/quanxue/flutter_native_splash.yaml @@ -0,0 +1,6 @@ +flutter_native_splash: + color: "#FFFFFF" + image: "assets/branding/splash.png" + android_12: + image: "assets/branding/splash.png" + icon_background_color: "#FFFFFF" diff --git a/apps/quanxue/lib/main.dart b/apps/quanxue/lib/main.dart new file mode 100644 index 0000000..7746930 --- /dev/null +++ b/apps/quanxue/lib/main.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; +import 'package:web_shell_core/web_shell_core.dart'; + +void main() { + runShellApp( + ShellEnvironment( + appName: '全学通', + appKey: 'quanxue_prod', + accentColor: const Color(0xFF3ED37B), + backgroundColor: const Color(0xFFFFFFFF), + textColor: const Color(0xFF1F2937), + mutedTextColor: const Color(0xFF6B7280), + ), + ); +} diff --git a/apps/quanxue/pubspec.lock b/apps/quanxue/pubspec.lock new file mode 100644 index 0000000..b771d72 --- /dev/null +++ b/apps/quanxue/pubspec.lock @@ -0,0 +1,689 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + ansicolor: + dependency: transitive + description: + name: ansicolor + sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.3" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.0.9" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.3.5+2" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.2" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.8" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.12" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + file_picker: + dependency: transitive + description: + name: file_picker + sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343" + url: "https://pub.flutter-io.cn" + source: hosted + version: "10.3.10" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.9.3+5" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.14.4" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.0.0" + flutter_native_splash: + dependency: "direct dev" + description: + name: flutter_native_splash + sha256: "4fb9f4113350d3a80841ce05ebf1976a36de622af7d19aca0ca9a9911c7ff002" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.7" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.33" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.15.6" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.8.0" + image_picker: + dependency: transitive + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: eda9b91b7e266d9041084a42d605a74937d996b87083395c5e47835916a86156 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.8.13+14" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.2.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.11.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.flutter-io.cn" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.1.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.12.18" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.9.1" + permission_handler: + dependency: transitive + description: + name: permission_handler + sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 + url: "https://pub.flutter-io.cn" + source: hosted + version: "12.0.1" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + url: "https://pub.flutter-io.cn" + source: hosted + version: "13.0.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.2.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.0.2" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.5.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.9" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.0" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.1" + url_launcher: + dependency: transitive + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.3.28" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.2" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.5" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.flutter-io.cn" + source: hosted + version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" + web_shell_core: + dependency: "direct main" + description: + path: "../../packages/web_shell_core" + relative: true + source: path + version: "0.0.1" + webview_flutter: + dependency: transitive + description: + name: webview_flutter + sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.13.1" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: "2a03df01df2fd30b075d1e7f24c28aee593f2e5d5ac4c3c4283c5eda63717b24" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.10.13" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.14.0" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: "0d85e8bc5db9a7c49f6ff57cbeafc6cd8216ad9c9ebc70b2c4579d955698933a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.24.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.15.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.11.0 <4.0.0" + flutter: ">=3.38.0" diff --git a/apps/quanxue/pubspec.yaml b/apps/quanxue/pubspec.yaml new file mode 100644 index 0000000..c5c5545 --- /dev/null +++ b/apps/quanxue/pubspec.yaml @@ -0,0 +1,93 @@ +name: quanxue +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.11.0 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + web_shell_core: + path: ../../packages/web_shell_core + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + flutter_launcher_icons: ^0.14.4 + flutter_native_splash: ^2.4.7 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/doc/architecture.md b/doc/architecture.md new file mode 100644 index 0000000..9f93302 --- /dev/null +++ b/doc/architecture.md @@ -0,0 +1,93 @@ +# 架构设计 + +## 整体架构 + +``` +┌──────────────────────────────────────────────────────┐ +│ apps/quanxue apps/品牌B apps/品牌C │ 品牌应用层 +│ (16 行 main.dart) │ 只传 ShellEnvironment +├──────────────────────────────────────────────────────┤ +│ web_shell_core │ 核心库 +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ config │ │ engine │ │ bridge │ │ +│ │ 环境配置 │ │ 兼容引擎 │ │ JS 桥接 │ │ +│ └──────────┘ └──────────┘ └──────────┘ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ services │ │ ui │ │ testing │ │ +│ │ 宿主服务 │ │ 壳层界面 │ │ 测试钩子 │ │ +│ └──────────┘ └──────────┘ └──────────┘ │ +├──────────────────────────────────────────────────────┤ +│ CoreShellActivity (Java) │ 原生层 +│ 进程隔离 · WebView 信息查询 · 深度重置 │ +└──────────────────────────────────────────────────────┘ +``` + +## 核心流程 + +### 1. 启动流程 + +``` +main() → runShellApp(env) + → WidgetsFlutterBinding.ensureInitialized() + → 设置屏幕方向(竖屏锁定) + → 进入沉浸式模式 + → runApp(ShellApp) + → Android? → WebShellPage(WebView 容器) + → 其他? → UnsupportedPlatformPage(兜底页) +``` + +### 2. WebView 启动与恢复 + +``` +WebShellPage.initState() + → 查询 Android WebView 信息(SDK / 包名 / 版本号) + → 生成兼容性策略(renderModes / useWideViewPort / aggressiveRecovery) + → 创建 WebView(默认 texture 模式) + → 首帧就绪后加载初始 URL + → 启动看门狗计时器 + → 超时? → 切换渲染模式(hybrid)→ 深度清理 → 自动重试 + → 再超时? → 展示错误页 + 兼容性提示 +``` + +### 3. JS Bridge 协议 + +``` +H5 页面 Flutter 壳 + │ │ + │ AppShellChannel. │ + │ postMessage(JSON) │ + │ ──────────────────────→ │ 解析 action + payload + │ │ 执行对应 handler + │ window. │ + │ __appShellReceiveResponse│ + │ ←────────────────────── │ 返回 { requestId, success, data/error } +``` + +**支持的 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` | 关闭应用 | 无(直接退出) | + +## 兼容性策略 + +| 条件 | 渲染模式 | 恢复策略 | +|---|---|---| +| SDK ≥ 29 + WebView ≥ 113 | texture 优先 | 标准恢复 | +| SDK ≤ 28 或 WebView < 113 | hybrid 优先 | 激进恢复(2 次重试) | +| 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` diff --git a/doc/images/foreground_spec.png b/doc/images/foreground_spec.png new file mode 100644 index 0000000..e24d23b Binary files /dev/null and b/doc/images/foreground_spec.png differ diff --git a/doc/images/icon_spec.png b/doc/images/icon_spec.png new file mode 100644 index 0000000..96a3655 Binary files /dev/null and b/doc/images/icon_spec.png differ diff --git a/doc/images/splash_spec.png b/doc/images/splash_spec.png new file mode 100644 index 0000000..b1be568 Binary files /dev/null and b/doc/images/splash_spec.png differ diff --git a/doc/plan.md b/doc/plan.md deleted file mode 100644 index f3fe258..0000000 --- a/doc/plan.md +++ /dev/null @@ -1,38 +0,0 @@ -# Role -你是一个拥有 8 年经验的资深 Flutter & Android 系统架构师。你擅长对已有的 Flutter 遗留项目进行工程化改造,特别是接入多风味(Flavors)和白标(White-label)架构。 - -# Context & Goal -我目前已经有了一个开发好的 Flutter 壳项目。现在需要对这个**已有项目**进行 Flavors 改造,使其能够通过不同的打包命令,输出两个完全独立的 App。 -**注意:本项目是纯 Android 定制平板项目,绝对不需要任何 iOS 相关的配置和代码!** - -这两个 App 是: -1. 劝学 (Flavor: `quanxue`) - 包名: `com.wanmake.quanxue`,应用名: "劝学" -2. 点智学 (Flavor: `dianzhi`) - 包名: `com.wanmake.dianzhi`,应用名: "点智学" - -# Execution Steps (请提供修改现有文件的差异代码与执行步骤) - -## Step 1: Dart 侧运行环境变量改造 -由于是已有项目,请帮我生成一个优雅的单例配置类 `AppEnvironment`。 -1. 使用 `const String.fromEnvironment('APP_FLAVOR')` 来捕获打包时传入的参数。 -2. 根据捕获到的 Flavor,向外暴露当前 App 的主题色、`appName` 和对应的接口 `baseUrl`。 -3. 请给出 `main.dart` 中如何初始化并读取这个配置类的极简示例。 - -## Step 2: Android 端 Gradle 深度改造 -请直接输出 `android/app/build.gradle` 需要修改的补丁代码(Patch): -1. 移除 `defaultConfig` 中的硬编码 `applicationId`。 -2. 增加 `flavorDimensions "app_type"`。 -3. 编写 `productFlavors` 代码块,配置 `quanxue` 和 `dianzhi`,分别注入其 `applicationId`。 -4. 使用 `resValue` 将 "劝学" 和 "点智学" 注入为 `app_name`。 - -## Step 3: AndroidManifest.xml 动态化 -请给出 `android/app/src/main/AndroidManifest.xml` 的修改代码: -1. 将 `` 标签的 `android:label` 属性修改为读取注入的 `@string/app_name`。 -2. 确保没有任何硬编码的应用名称残留。 - -## Step 4: 资源配置文件 (YAML) 生成 -1. 请帮我生成 4 个 YAML 配置文件,用于配合 `flutter_launcher_icons` 和 `flutter_native_splash` 插件。 -2. 为 `quanxue` 和 `dianzhi` 分别配置独立的图标和启动页路径,路径统一指向 `assets/branding/{flavor}/...`。 -3. **关键:** 在这 4 个 YAML 文件中,必须显式设置 `android: true` 并且 **`ios: false`**。 - -## Step 5: 打包与测试脚本 -提供完整的终端测试与打包命令示例,仅限 Android 平台(包含如何传入 `--flavor` 和 `--dart-define` 打包 APK )。 diff --git a/doc/品牌资源规范.md b/doc/品牌资源规范.md new file mode 100644 index 0000000..920ab3f --- /dev/null +++ b/doc/品牌资源规范.md @@ -0,0 +1,57 @@ +# 品牌 UI 资源交付规范 + +本文档面向 UI 设计师,说明了在使用 `web_android_shell` 框架生成各品牌 Android 壳应用时,需要提供的图片素材规格。 + +**文件存放路径**:`flavors/<品牌名>/` + +--- + +## 1. 交付总览 + +| 资源名称 | 文件名 | 格式要求 | 尺寸 (px) | 核心要求 | +|---|---|---|---|---| +| **应用图标** | `icon.png` | PNG *(不透明)* | 1024 × 1024 | 正方形铺满(用于老版本系统图标)。 | +| **自适应前景** | `icon_foreground.png` | **PNG (背景透明)** | 1024 × 1024 | 主体图形居中。四周必须保留 **33% 的透明安全区**。 | +| **启动页图像** | `splash.png` | PNG *(含透明或实色)* | 1152 × 1152 | 核心 Logo 必须完全置于正中心的 **768×768 直径圆形**范围内。 | +| **单色前景** *(可选)* | `icon_monochrome.png` | **PNG (背景透明)** | 1024 × 1024 | 专为 Android 13+ 提供。必须是纯黑色或纯白色(无渐变),靠透明度展现轮廓。 | + +--- + +## 2. 详细规格说明与示意图 + +### 2.1 应用图标 (`icon.png`) + +这是最基础的图标,用于向后兼容较老版本的 Android 系统。 + +* **尺寸**:1024 × 1024 像素(也可提供最低 512 × 512 的设计底线)。 +* **要求**:不需要圆角或圆形裁切(系统会自动裁切),直接提交带有背景颜色的**纯正方向**图形。 + +![应用图标规范](images/icon_spec.png) + +--- + +### 2.2 自适应图标前景 (`icon_foreground.png`) + +自 Android 8.0 起,系统采用了由「背景层」+「前景层」自由组合实现动态效果的自适应图标(Adaptive Icon)。该图即为此时使用的「前景层」。 + +* **格式**:**必须是带透明背景的 PNG (PNG-24/32)**,不能使用 JPEG,否则其白色底会遮盖住背景颜色。 +* **安全区设计**: + * 画布总尺寸保持 1024 × 1024。 + * **主体内容必须集中在中央的 682 × 682 的圆形安全区内**。 + * 外围的透明留白(约占据画布边长的 33%)将被系统底层用于视差动画和异形遮罩裁切。超出中央安全圈的内容**将被无情裁掉**。 + +![自适应前景图规范](images/foreground_spec.png) + +--- + +### 2.3 启动页图片 (`splash.png`) + +自 Android 12 开始,系统强制接管开屏动画(SplashScreen API),对核心图像的位置和大小有极其严苛的要求,否则在 2K/4K 等高清分辨率平板上会造成拉伸模糊或主体被截断。 + +* **画布尺寸**:**绝对正中心对其的 1152 × 1152 像素**。 +* **排版留白**: + * 核心 Logo 及其相关文字,必须完全置于中心一个 **直径为 768 px 的虚拟圆圈** 内。 + * 中心圆之外的区域,将根据不同机型屏幕分辨率被裁掉。 +* **背景机制**:通常启动页背景色是实色(在 `flavors/xxx.yaml` 配置的 `splash_color`),因此该图建议为透明背景(Logo 独占);如果该图自带实色背景也没有关系,只要确保主体内容满足 768 圆圈即可。 + +![启动页规范](images/splash_spec.png) diff --git a/flavors/quanxue.yaml b/flavors/quanxue.yaml new file mode 100644 index 0000000..94c24e5 --- /dev/null +++ b/flavors/quanxue.yaml @@ -0,0 +1,14 @@ +app_name: "全学通" +application_id: "com.wanmake.quanxue" +app_key: "quanxue_prod" +theme: + accent_color: "0xFF3ED37B" + bg_color: "0xFFFFFFFF" + text_color: "0xFF1F2937" + muted_text_color: "0xFF6B7280" +branding: + icon: "icon.png" + icon_background: "#FFFFFF" + icon_foreground: "icon_foreground.png" + splash: "splash.png" + splash_color: "#FFFFFF" diff --git a/flavors/quanxue/icon.png b/flavors/quanxue/icon.png new file mode 100644 index 0000000..f19711a Binary files /dev/null and b/flavors/quanxue/icon.png differ diff --git a/flavors/quanxue/icon_foreground.png b/flavors/quanxue/icon_foreground.png new file mode 100644 index 0000000..977ad82 Binary files /dev/null and b/flavors/quanxue/icon_foreground.png differ diff --git a/flavors/quanxue/splash.png b/flavors/quanxue/splash.png new file mode 100644 index 0000000..1a95930 Binary files /dev/null and b/flavors/quanxue/splash.png differ diff --git a/ios/.gitignore b/ios/.gitignore deleted file mode 100644 index ad322bc..0000000 --- a/ios/.gitignore +++ /dev/null @@ -1,34 +0,0 @@ -**/dgph -*.mode1v3 -*.mode2v3 -*.moved-aside -*.pbxuser -*.perspectivev3 -**/*sync/ -.sconsign.dblite -.tags* -**/.vagrant/ -**/DerivedData/ -Icon? -**/Pods/ -**/.symlinks/ -profile -xcuserdata -**/.generated/ -Flutter/App.framework -Flutter/Flutter.framework -Flutter/Flutter.podspec -Flutter/Generated.xcconfig -Flutter/ephemeral/ -Flutter/app.flx -Flutter/app.zip -Flutter/flutter_assets/ -Flutter/flutter_export_environment.sh -ServiceDefinitions.json -Runner/GeneratedPluginRegistrant.* - -# Exceptions to above rules. -!default.mode1v3 -!default.mode2v3 -!default.pbxuser -!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist deleted file mode 100644 index 256cf28..0000000 --- a/ios/Flutter/AppFrameworkInfo.plist +++ /dev/null @@ -1,24 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - App - CFBundleIdentifier - io.flutter.flutter.app - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - App - CFBundlePackageType - FMWK - CFBundleShortVersionString - 1.0 - CFBundleSignature - ???? - CFBundleVersion - 1.0 - - diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig deleted file mode 100644 index dfd2626..0000000 --- a/ios/Flutter/Debug.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" -#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig deleted file mode 100644 index a97381a..0000000 --- a/ios/Flutter/Release.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" -#include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile deleted file mode 100644 index 620e46e..0000000 --- a/ios/Podfile +++ /dev/null @@ -1,43 +0,0 @@ -# Uncomment this line to define a global platform for your project -# platform :ios, '13.0' - -# CocoaPods analytics sends network stats synchronously affecting flutter build latency. -ENV['COCOAPODS_DISABLE_STATS'] = 'true' - -project 'Runner', { - 'Debug' => :debug, - 'Profile' => :release, - 'Release' => :release, -} - -def flutter_root - generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) - unless File.exist?(generated_xcode_build_settings_path) - raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" - end - - File.foreach(generated_xcode_build_settings_path) do |line| - matches = line.match(/FLUTTER_ROOT\=(.*)/) - return matches[1].strip if matches - end - raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" -end - -require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) - -flutter_ios_podfile_setup - -target 'Runner' do - use_frameworks! - - flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) - target 'RunnerTests' do - inherit! :search_paths - end -end - -post_install do |installer| - installer.pods_project.targets.each do |target| - flutter_additional_ios_build_settings(target) - end -end diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj deleted file mode 100644 index 338afe6..0000000 --- a/ios/Runner.xcodeproj/project.pbxproj +++ /dev/null @@ -1,620 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 54; - objects = { - -/* Begin PBXBuildFile section */ - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; - 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; - 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 97C146E61CF9000F007C117D /* Project object */; - proxyType = 1; - remoteGlobalIDString = 97C146ED1CF9000F007C117D; - remoteInfo = Runner; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 9705A1C41CF9048500538489 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; - 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; - 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; - 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; - 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; - 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; - 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 97C146EB1CF9000F007C117D /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 331C8082294A63A400263BE5 /* RunnerTests */ = { - isa = PBXGroup; - children = ( - 331C807B294A618700263BE5 /* RunnerTests.swift */, - ); - path = RunnerTests; - sourceTree = ""; - }; - 9740EEB11CF90186004384FC /* Flutter */ = { - isa = PBXGroup; - children = ( - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 9740EEB31CF90195004384FC /* Generated.xcconfig */, - ); - name = Flutter; - sourceTree = ""; - }; - 97C146E51CF9000F007C117D = { - isa = PBXGroup; - children = ( - 9740EEB11CF90186004384FC /* Flutter */, - 97C146F01CF9000F007C117D /* Runner */, - 97C146EF1CF9000F007C117D /* Products */, - 331C8082294A63A400263BE5 /* RunnerTests */, - ); - sourceTree = ""; - }; - 97C146EF1CF9000F007C117D /* Products */ = { - isa = PBXGroup; - children = ( - 97C146EE1CF9000F007C117D /* Runner.app */, - 331C8081294A63A400263BE5 /* RunnerTests.xctest */, - ); - name = Products; - sourceTree = ""; - }; - 97C146F01CF9000F007C117D /* Runner */ = { - isa = PBXGroup; - children = ( - 97C146FA1CF9000F007C117D /* Main.storyboard */, - 97C146FD1CF9000F007C117D /* Assets.xcassets */, - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, - 97C147021CF9000F007C117D /* Info.plist */, - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, - 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, - ); - path = Runner; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 331C8080294A63A400263BE5 /* RunnerTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; - buildPhases = ( - 331C807D294A63A400263BE5 /* Sources */, - 331C807F294A63A400263BE5 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 331C8086294A63A400263BE5 /* PBXTargetDependency */, - ); - name = RunnerTests; - productName = RunnerTests; - productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; - 97C146ED1CF9000F007C117D /* Runner */ = { - isa = PBXNativeTarget; - buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; - buildPhases = ( - 9740EEB61CF901F6004384FC /* Run Script */, - 97C146EA1CF9000F007C117D /* Sources */, - 97C146EB1CF9000F007C117D /* Frameworks */, - 97C146EC1CF9000F007C117D /* Resources */, - 9705A1C41CF9048500538489 /* Embed Frameworks */, - 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = Runner; - productName = Runner; - productReference = 97C146EE1CF9000F007C117D /* Runner.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 97C146E61CF9000F007C117D /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = YES; - LastUpgradeCheck = 1510; - ORGANIZATIONNAME = ""; - TargetAttributes = { - 331C8080294A63A400263BE5 = { - CreatedOnToolsVersion = 14.0; - TestTargetID = 97C146ED1CF9000F007C117D; - }; - 97C146ED1CF9000F007C117D = { - CreatedOnToolsVersion = 7.3.1; - LastSwiftMigration = 1100; - }; - }; - }; - buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 97C146E51CF9000F007C117D; - productRefGroup = 97C146EF1CF9000F007C117D /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 97C146ED1CF9000F007C117D /* Runner */, - 331C8080294A63A400263BE5 /* RunnerTests */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 331C807F294A63A400263BE5 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 97C146EC1CF9000F007C117D /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", - ); - name = "Thin Binary"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; - }; - 9740EEB61CF901F6004384FC /* Run Script */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Run Script"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 331C807D294A63A400263BE5 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 97C146EA1CF9000F007C117D /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, - 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 97C146ED1CF9000F007C117D /* Runner */; - targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin PBXVariantGroup section */ - 97C146FA1CF9000F007C117D /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C146FB1CF9000F007C117D /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C147001CF9000F007C117D /* Base */, - ); - name = LaunchScreen.storyboard; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 249021D3217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Profile; - }; - 249021D4217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.yuanxuan.webshell.webAndroidShell; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Profile; - }; - 331C8088294A63A400263BE5 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.yuanxuan.webshell.webAndroidShell.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; - }; - name = Debug; - }; - 331C8089294A63A400263BE5 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.yuanxuan.webshell.webAndroidShell.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; - }; - name = Release; - }; - 331C808A294A63A400263BE5 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.yuanxuan.webshell.webAndroidShell.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; - }; - name = Profile; - }; - 97C147031CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 97C147041CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - 97C147061CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.yuanxuan.webshell.webAndroidShell; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Debug; - }; - 97C147071CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.yuanxuan.webshell.webAndroidShell; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 331C8088294A63A400263BE5 /* Debug */, - 331C8089294A63A400263BE5 /* Release */, - 331C808A294A63A400263BE5 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147031CF9000F007C117D /* Debug */, - 97C147041CF9000F007C117D /* Release */, - 249021D3217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147061CF9000F007C117D /* Debug */, - 97C147071CF9000F007C117D /* Release */, - 249021D4217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 97C146E61CF9000F007C117D /* Project object */; -} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index c4b79bd..0000000 --- a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index fc6bf80..0000000 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index af0309c..0000000 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - PreviewsEnabled - - - diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme deleted file mode 100644 index bbabc4e..0000000 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ /dev/null @@ -1,101 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 59c6d39..0000000 --- a/ios/Runner.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index fc6bf80..0000000 --- a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index af0309c..0000000 --- a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - PreviewsEnabled - - - diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift deleted file mode 100644 index ed1c097..0000000 --- a/ios/Runner/AppDelegate.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Flutter -import UIKit - -@main -@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { - override func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - return super.application(application, didFinishLaunchingWithOptions: launchOptions) - } - - func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { - GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) - } -} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 1950fd8..0000000 --- a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,122 +0,0 @@ -{ - "images" : [ - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@3x.png", - "scale" : "3x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@3x.png", - "scale" : "3x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@3x.png", - "scale" : "3x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@2x.png", - "scale" : "2x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@3x.png", - "scale" : "3x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@1x.png", - "scale" : "1x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@1x.png", - "scale" : "1x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@1x.png", - "scale" : "1x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@2x.png", - "scale" : "2x" - }, - { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "Icon-App-83.5x83.5@2x.png", - "scale" : "2x" - }, - { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "Icon-App-1024x1024@1x.png", - "scale" : "1x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png deleted file mode 100644 index dc9ada4..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png deleted file mode 100644 index 7353c41..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png deleted file mode 100644 index 797d452..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png deleted file mode 100644 index 6ed2d93..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png deleted file mode 100644 index 4cd7b00..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png deleted file mode 100644 index fe73094..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png deleted file mode 100644 index 321773c..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png deleted file mode 100644 index 797d452..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png deleted file mode 100644 index 502f463..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png deleted file mode 100644 index 0ec3034..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png deleted file mode 100644 index 0ec3034..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png deleted file mode 100644 index e9f5fea..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png deleted file mode 100644 index 84ac32a..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png deleted file mode 100644 index 8953cba..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png deleted file mode 100644 index 0467bf1..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json deleted file mode 100644 index d08a4de..0000000 --- a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "LaunchImage.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@3x.png", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png deleted file mode 100644 index 9da19ea..0000000 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png deleted file mode 100644 index 9da19ea..0000000 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png deleted file mode 100644 index 9da19ea..0000000 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md deleted file mode 100644 index 65a94b5..0000000 --- a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Launch Screen Assets - -You can customize the launch screen with your own desired assets by replacing the image files in this directory. - -You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard deleted file mode 100644 index 497371e..0000000 --- a/ios/Runner/Base.lproj/LaunchScreen.storyboard +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard deleted file mode 100644 index bbb83ca..0000000 --- a/ios/Runner/Base.lproj/Main.storyboard +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist deleted file mode 100644 index 77f90b9..0000000 --- a/ios/Runner/Info.plist +++ /dev/null @@ -1,70 +0,0 @@ - - - - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - web_android_shell - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - web_android_shell - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneClassName - UIWindowScene - UISceneConfigurationName - flutter - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate - UISceneStoryboardFile - Main - - - - - UIApplicationSupportsIndirectInputEvents - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - - diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h deleted file mode 100644 index fae207f..0000000 --- a/ios/Runner/Runner-Bridging-Header.h +++ /dev/null @@ -1 +0,0 @@ -#import "GeneratedPluginRegistrant.h" diff --git a/ios/Runner/SceneDelegate.swift b/ios/Runner/SceneDelegate.swift deleted file mode 100644 index b79be9b..0000000 --- a/ios/Runner/SceneDelegate.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Flutter -import UIKit - -class SceneDelegate: FlutterSceneDelegate { - -} diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift deleted file mode 100644 index 4d206de..0000000 --- a/ios/RunnerTests/RunnerTests.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Flutter -import UIKit -import XCTest - -class RunnerTests: XCTestCase { - - func testExample() { - // If you add code to the Runner application, consider adding tests here. - // See https://developer.apple.com/documentation/xctest for more information about using XCTest. - } - -} diff --git a/packages/web_android_shell/.gitignore b/packages/web_android_shell/.gitignore new file mode 100644 index 0000000..6f0d006 --- /dev/null +++ b/packages/web_android_shell/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/.metadata b/packages/web_android_shell/.metadata similarity index 100% rename from .metadata rename to packages/web_android_shell/.metadata diff --git a/packages/web_android_shell/README.md b/packages/web_android_shell/README.md new file mode 100644 index 0000000..f5015e2 --- /dev/null +++ b/packages/web_android_shell/README.md @@ -0,0 +1,7 @@ +# web_android_shell(已迁移) + +> ⚠️ 本包为旧版入口,已迁移至 Monorepo 架构。 + +当前用途:为旧版 `MainActivity` 提供壳层入口,调用 `web_shell_core` 启动应用。 + +新品牌应用请使用 `apps/` 目录下的独立应用,参考 [`apps/quanxue/`](../../apps/quanxue/)。 diff --git a/packages/web_android_shell/analysis_options.yaml b/packages/web_android_shell/analysis_options.yaml new file mode 100644 index 0000000..f13d6ae --- /dev/null +++ b/packages/web_android_shell/analysis_options.yaml @@ -0,0 +1,9 @@ +# Dart 分析器配置,用于在开发阶段发现错误、警告和代码规范问题。 +# 可通过 `flutter analyze` 执行静态检查。 +include: package:flutter_lints/flutter.yaml + +linter: + # 如需自定义规则,可在此处开启或关闭指定 lint。 + rules: + # avoid_print: false # 取消注释后可关闭 `avoid_print` 规则。 + # prefer_single_quotes: true # 取消注释后可开启 `prefer_single_quotes` 规则。 diff --git a/android/.gitignore b/packages/web_android_shell/android/.gitignore similarity index 100% rename from android/.gitignore rename to packages/web_android_shell/android/.gitignore diff --git a/packages/web_android_shell/android/app/build.gradle.kts b/packages/web_android_shell/android/app/build.gradle.kts new file mode 100644 index 0000000..5dfc1c2 --- /dev/null +++ b/packages/web_android_shell/android/app/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // Flutter Gradle 插件必须放在 Android 与 Kotlin Gradle 插件之后应用。 + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.yuanxuan.webshell.web_android_shell" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // 待补充:请替换成你自己的唯一应用标识。 + applicationId = "com.yuanxuan.webshell.web_android_shell" + // 下面这些值可以按应用实际需求调整。 + // 更多说明可参考:https://flutter.dev/to/review-gradle-config。 + minSdk = flutter.minSdkVersion + // 为兼容旧版系统 WebView,这里保持稳定的 targetSdk。 + targetSdk = 34 + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // 待补充:为 release 构建配置正式签名。 + // 当前先使用 debug 签名,确保 `flutter run --release` 可直接运行。 + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/packages/web_android_shell/android/app/src/debug/AndroidManifest.xml similarity index 100% rename from android/app/src/debug/AndroidManifest.xml rename to packages/web_android_shell/android/app/src/debug/AndroidManifest.xml diff --git a/android/app/src/main/AndroidManifest.xml b/packages/web_android_shell/android/app/src/main/AndroidManifest.xml similarity index 100% rename from android/app/src/main/AndroidManifest.xml rename to packages/web_android_shell/android/app/src/main/AndroidManifest.xml diff --git a/android/app/src/main/java/com/yuanxuan/webshell/web_android_shell/MainActivity.java b/packages/web_android_shell/android/app/src/main/java/com/yuanxuan/webshell/web_android_shell/MainActivity.java similarity index 97% rename from android/app/src/main/java/com/yuanxuan/webshell/web_android_shell/MainActivity.java rename to packages/web_android_shell/android/app/src/main/java/com/yuanxuan/webshell/web_android_shell/MainActivity.java index 53cf90a..ee38a3e 100644 --- a/android/app/src/main/java/com/yuanxuan/webshell/web_android_shell/MainActivity.java +++ b/packages/web_android_shell/android/app/src/main/java/com/yuanxuan/webshell/web_android_shell/MainActivity.java @@ -126,7 +126,7 @@ public class MainActivity extends FlutterActivity { Process.killProcess(previousPid); SystemClock.sleep(180); } catch (RuntimeException ignored) { - // Ignore kill failures and continue startup with the current process. + // 忽略终止旧进程失败的情况,并继续使用当前进程启动。 } } @@ -179,7 +179,7 @@ public class MainActivity extends FlutterActivity { deleteDatabase("webview.db"); deleteDatabase("webviewCache.db"); } catch (RuntimeException ignored) { - // Ignore cleanup failures on legacy devices and continue with startup. + // 忽略旧设备上的清理失败,继续执行启动流程。 } } @@ -201,7 +201,7 @@ public class MainActivity extends FlutterActivity { } } - //noinspection ResultOfMethodCallIgnored + target.delete(); } diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/packages/web_android_shell/android/app/src/main/res/drawable-v21/launch_background.xml similarity index 100% rename from android/app/src/main/res/drawable-v21/launch_background.xml rename to packages/web_android_shell/android/app/src/main/res/drawable-v21/launch_background.xml diff --git a/android/app/src/main/res/drawable/launch_background.xml b/packages/web_android_shell/android/app/src/main/res/drawable/launch_background.xml similarity index 100% rename from android/app/src/main/res/drawable/launch_background.xml rename to packages/web_android_shell/android/app/src/main/res/drawable/launch_background.xml diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/packages/web_android_shell/android/app/src/main/res/mipmap-hdpi/ic_launcher.png similarity index 100% rename from android/app/src/main/res/mipmap-hdpi/ic_launcher.png rename to packages/web_android_shell/android/app/src/main/res/mipmap-hdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/packages/web_android_shell/android/app/src/main/res/mipmap-mdpi/ic_launcher.png similarity index 100% rename from android/app/src/main/res/mipmap-mdpi/ic_launcher.png rename to packages/web_android_shell/android/app/src/main/res/mipmap-mdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/packages/web_android_shell/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from android/app/src/main/res/mipmap-xhdpi/ic_launcher.png rename to packages/web_android_shell/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/packages/web_android_shell/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png rename to packages/web_android_shell/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/packages/web_android_shell/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png rename to packages/web_android_shell/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png diff --git a/android/app/src/main/res/values-night/styles.xml b/packages/web_android_shell/android/app/src/main/res/values-night/styles.xml similarity index 100% rename from android/app/src/main/res/values-night/styles.xml rename to packages/web_android_shell/android/app/src/main/res/values-night/styles.xml diff --git a/android/app/src/main/res/values/styles.xml b/packages/web_android_shell/android/app/src/main/res/values/styles.xml similarity index 100% rename from android/app/src/main/res/values/styles.xml rename to packages/web_android_shell/android/app/src/main/res/values/styles.xml diff --git a/android/app/src/profile/AndroidManifest.xml b/packages/web_android_shell/android/app/src/profile/AndroidManifest.xml similarity index 100% rename from android/app/src/profile/AndroidManifest.xml rename to packages/web_android_shell/android/app/src/profile/AndroidManifest.xml diff --git a/android/build.gradle.kts b/packages/web_android_shell/android/build.gradle.kts similarity index 100% rename from android/build.gradle.kts rename to packages/web_android_shell/android/build.gradle.kts diff --git a/android/gradle.properties b/packages/web_android_shell/android/gradle.properties similarity index 100% rename from android/gradle.properties rename to packages/web_android_shell/android/gradle.properties diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/packages/web_android_shell/android/gradle/wrapper/gradle-wrapper.properties similarity index 100% rename from android/gradle/wrapper/gradle-wrapper.properties rename to packages/web_android_shell/android/gradle/wrapper/gradle-wrapper.properties diff --git a/android/settings.gradle.kts b/packages/web_android_shell/android/settings.gradle.kts similarity index 100% rename from android/settings.gradle.kts rename to packages/web_android_shell/android/settings.gradle.kts diff --git a/lib/main.dart b/packages/web_android_shell/lib/main.dart similarity index 95% rename from lib/main.dart rename to packages/web_android_shell/lib/main.dart index f12a3fc..f10f27b 100644 --- a/lib/main.dart +++ b/packages/web_android_shell/lib/main.dart @@ -65,9 +65,7 @@ bool _supportsEmbeddedWebView({bool isWeb = kIsWeb, TargetPlatform? platform}) { } final TargetPlatform target = platform ?? defaultTargetPlatform; - return target == TargetPlatform.android || - target == TargetPlatform.iOS || - target == TargetPlatform.macOS; + return target == TargetPlatform.android; } Future main() async { @@ -327,7 +325,7 @@ class _WebShellPageState extends State @override void initState() { super.initState(); - debugPrint('WebShell initState, initialUrl=$_initialUrl'); + debugPrint('WebShell 初始化,初始地址=$_initialUrl'); WidgetsBinding.instance.addObserver(this); _androidCompatibilityFuture = _prepareAndroidCompatibility(); _recreateWebView(); @@ -377,7 +375,7 @@ class _WebShellPageState extends State _androidWebViewInfo = _AndroidWebViewInfo.fromMap(rawInfo); } } catch (error, stackTrace) { - debugPrint('Load Android WebView info failed: $error\n$stackTrace'); + debugPrint('读取 Android WebView 信息失败:$error\n$stackTrace'); } _androidCompatibilityPlan = _AndroidCompatibilityPlan.fromInfo( @@ -385,11 +383,11 @@ class _WebShellPageState extends State ); _renderModeIndex = 0; debugPrint( - 'WebShell Android WebView info: ' - '${_androidWebViewInfo?.summary ?? 'unavailable'}', + 'WebShell Android WebView 信息:' + '${_androidWebViewInfo?.summary ?? '不可用'}', ); debugPrint( - 'WebShell compatibility plan: ${_androidCompatibilityPlan.describe()}', + 'WebShell 兼容策略:${_androidCompatibilityPlan.describe()}', ); } @@ -404,7 +402,7 @@ class _WebShellPageState extends State _hasAppliedCompatibilityPlan = true; if (_configuredRenderMode == _activeRenderMode) { debugPrint( - 'WebShell compatibility plan keeps current WebView ' + 'WebShell 兼容策略保持当前 WebView ' '(${_activeRenderMode.logName})', ); return; @@ -419,7 +417,7 @@ class _WebShellPageState extends State } _renderModeIndex = nextIndex; - debugPrint('WebShell switched render mode to ${_activeRenderMode.logName}'); + debugPrint('WebShell 已切换渲染模式为 ${_activeRenderMode.logName}'); return true; } @@ -454,7 +452,7 @@ class _WebShellPageState extends State _controllerSetupFuture = _configureController(controller, generation); _configuredRenderMode = renderMode; debugPrint( - 'WebShell recreate WebView #$generation with ${renderMode.logName}', + 'WebShell 正在以 ${renderMode.logName} 重建 WebView #$generation', ); PlatformWebViewWidgetCreationParams widgetParams = PlatformWebViewWidgetCreationParams( @@ -525,7 +523,7 @@ class _WebShellPageState extends State ); await androidController.setOnConsoleMessage((message) { debugPrint( - 'WebView console [${message.level.name}] ${message.message}', + 'WebView 控制台 [${message.level.name}] ${message.message}', ); }); } @@ -580,7 +578,7 @@ class _WebShellPageState extends State return; } if (!_isNetworkUrl(url)) { - debugPrint('WebView ignore non-network page start: $url'); + debugPrint('WebView 忽略非网络页面开始事件:$url'); return; } _hasStartedRemoteMainFrame = true; @@ -606,7 +604,7 @@ class _WebShellPageState extends State return; } if (!_isNetworkUrl(url)) { - debugPrint('WebView ignore non-network page finish: $url'); + debugPrint('WebView 忽略非网络页面完成事件:$url'); return; } _recordWebViewEvent('页面加载完成:$url'); @@ -633,13 +631,13 @@ class _WebShellPageState extends State final Uri? requestUri = error.request?.uri; if (!_shouldTreatHttpErrorAsMainFrame(error)) { debugPrint( - 'Ignore subresource HTTP error: ' - '${statusCode ?? 'unknown'} ${requestUri ?? 'unknown'}', + '忽略子资源 HTTP 错误:' + '${statusCode ?? '未知'} ${requestUri ?? '未知'}', ); return; } _recordWebViewEvent( - 'HTTP 错误:${statusCode ?? 'unknown'} ${requestUri ?? _currentUrl}', + 'HTTP 错误:${statusCode ?? '未知'} ${requestUri ?? _currentUrl}', ); _setMainFrameError( title: statusCode == null ? '服务器响应异常' : '服务器异常 $statusCode', @@ -692,7 +690,7 @@ class _WebShellPageState extends State if (!mounted) { return; } - debugPrint('WebShell first frame ready, start initial load'); + debugPrint('WebShell 首帧已就绪,开始初始加载'); await _startLoadSequence(rebuildWebView: false, resetRetryCount: true); } @@ -741,7 +739,7 @@ class _WebShellPageState extends State void _recordWebViewEvent(String event) { _lastWebViewEvent = event; - debugPrint('WebView $event'); + debugPrint('WebView 事件:$event'); } void _armStartupWatchdog() { @@ -815,25 +813,25 @@ class _WebShellPageState extends State try { await controllerSetupFuture; } catch (error, stackTrace) { - debugPrint('Await WebView controller setup failed: $error\n$stackTrace'); + debugPrint('等待 WebView 控制器初始化失败:$error\n$stackTrace'); } try { await _controller.clearCache(); } catch (error, stackTrace) { - debugPrint('Clear WebView cache failed: $error\n$stackTrace'); + debugPrint('清理 WebView 缓存失败:$error\n$stackTrace'); } try { await _controller.clearLocalStorage(); } catch (error, stackTrace) { - debugPrint('Clear WebView local storage failed: $error\n$stackTrace'); + debugPrint('清理 WebView 本地存储失败:$error\n$stackTrace'); } try { await _cookieManager.clearCookies(); } catch (error, stackTrace) { - debugPrint('Clear WebView cookies failed: $error\n$stackTrace'); + debugPrint('清理 WebView Cookie 失败:$error\n$stackTrace'); } if (!deepReset || defaultTargetPlatform != TargetPlatform.android) { @@ -845,7 +843,7 @@ class _WebShellPageState extends State 'resetAndroidWebViewState', ); } catch (error, stackTrace) { - debugPrint('Reset Android WebView state failed: $error\n$stackTrace'); + debugPrint('重置 Android WebView 状态失败:$error\n$stackTrace'); } } @@ -940,9 +938,9 @@ class _WebShellPageState extends State Future _injectAppShellBridge(String url) async { try { await _controller.runJavaScript(_buildAppShellBridgeScript()); - debugPrint('Injected AppShell bridge for $url'); + debugPrint('已为 $url 注入 AppShell 桥接'); } catch (error, stackTrace) { - debugPrint('Inject AppShell bridge failed: $error\n$stackTrace'); + debugPrint('注入 AppShell 桥接失败:$error\n$stackTrace'); } } @@ -966,7 +964,7 @@ class _WebShellPageState extends State return; } - debugPrint('AppShell bridge request: action=$action payload=$payload'); + debugPrint('AppShell 桥接请求:action=$action payload=$payload'); late final Object? data; switch (action) { @@ -1005,7 +1003,7 @@ class _WebShellPageState extends State data: data, ); } catch (error, stackTrace) { - debugPrint('Handle AppShell bridge failed: $error\n$stackTrace'); + debugPrint('处理 AppShell 桥接请求失败:$error\n$stackTrace'); if (requestId.isNotEmpty) { await _sendBridgeResponse( requestId: requestId, @@ -1148,14 +1146,14 @@ class _WebShellPageState extends State ); } catch (bridgeError, stackTrace) { debugPrint( - 'Send AppShell bridge response failed: $bridgeError\n$stackTrace', + '发送 AppShell 桥接响应失败:$bridgeError\n$stackTrace', ); } } Future> _handleFileSelector(FileSelectorParams params) async { debugPrint( - 'WebView file selector: ' + 'WebView 文件选择: ' 'accept=${params.acceptTypes}, ' 'capture=${params.isCaptureEnabled}, ' 'mode=${params.mode.name}', @@ -1212,7 +1210,7 @@ class _WebShellPageState extends State .map((path) => Uri.file(path).toString()) .toList(); } catch (error, stackTrace) { - debugPrint('Handle file selector failed: $error\n$stackTrace'); + debugPrint('处理文件选择失败:$error\n$stackTrace'); return []; } } @@ -1221,7 +1219,7 @@ class _WebShellPageState extends State PlatformWebViewPermissionRequest request, ) async { debugPrint( - 'WebView permission request: ' + 'WebView 权限请求:' '${request.types.map((type) => type.name).join(', ')}', ); @@ -1252,7 +1250,7 @@ class _WebShellPageState extends State Future _handleGeolocationPermissionRequest( GeolocationPermissionsRequestParams request, ) async { - debugPrint('WebView geolocation permission request: ${request.origin}'); + debugPrint('WebView 地理位置权限请求:${request.origin}'); final PermissionStatus status = await Permission.location.request(); return GeolocationPermissionsResponse( allow: status.isGranted, @@ -1278,7 +1276,7 @@ class _WebShellPageState extends State maxHeight: _pickedImageMaxHeight, ); } catch (error, stackTrace) { - debugPrint('Pick camera image failed: $error\n$stackTrace'); + debugPrint('调用相机拍照失败:$error\n$stackTrace'); if (showPermissionAlert) { await _showWebAlert('无法打开系统相机,请稍后重试'); } @@ -1355,7 +1353,7 @@ class _WebShellPageState extends State try { return await launchUrl(uri, mode: LaunchMode.externalApplication); } catch (error, stackTrace) { - debugPrint('Open external uri failed: $error\n$stackTrace'); + debugPrint('外部打开 URI 失败:$error\n$stackTrace'); return false; } } @@ -1385,7 +1383,7 @@ class _WebShellPageState extends State try { await _controller.runJavaScript('window.alert(${jsonEncode(message)});'); } catch (error, stackTrace) { - debugPrint('Show web alert failed: $error\n$stackTrace'); + debugPrint('展示网页弹窗失败:$error\n$stackTrace'); } } @@ -1689,14 +1687,14 @@ class _WebShellPageState extends State await _waitForWebViewMount(generation); debugPrint( - 'WebShell start real URL load on WebView #$generation ' + 'WebShell 开始在 WebView #$generation 加载真实地址 ' '(${_activeRenderMode.logName}): $_initialUrl', ); try { _armStartupWatchdog(); await _controller.loadRequest(_initialUri); } catch (error, stackTrace) { - debugPrint('Start initial URL load failed: $error\n$stackTrace'); + debugPrint('初始地址加载失败:$error\n$stackTrace'); _setMainFrameError( title: '地址无法加载', message: '无法打开 $_initialUrl,请检查地址格式或网络后重试。', @@ -1760,7 +1758,7 @@ class _WebShellPageState extends State _armStartupWatchdog(); await _controller.reload(); } catch (error, stackTrace) { - debugPrint('Reload current page failed: $error\n$stackTrace'); + debugPrint('重新加载当前页面失败:$error\n$stackTrace'); await _startLoadSequence(rebuildWebView: true, resetRetryCount: true); } } @@ -1873,7 +1871,7 @@ class UnsupportedPlatformPage extends StatelessWidget { ), const SizedBox(height: 12), Text( - '请在 Android、iOS 或 macOS 上运行当前项目。\n$_initialUrl', + '当前项目仅支持 Android 平板运行。\n$_initialUrl', textAlign: TextAlign.center, style: const TextStyle( height: 1.6, diff --git a/packages/web_android_shell/pubspec.lock b/packages/web_android_shell/pubspec.lock new file mode 100644 index 0000000..1997430 --- /dev/null +++ b/packages/web_android_shell/pubspec.lock @@ -0,0 +1,578 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.3.5+2" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.8" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.12" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343" + url: "https://pub.flutter-io.cn" + source: hosted + version: "10.3.10" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.9.3+5" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.33" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: eda9b91b7e266d9041084a42d605a74937d996b87083395c5e47835916a86156 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.8.13+14" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.2.2" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.flutter-io.cn" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.1.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.12.18" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.9.1" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 + url: "https://pub.flutter-io.cn" + source: hosted + version: "12.0.1" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + url: "https://pub.flutter-io.cn" + source: hosted + version: "13.0.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.2.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.0.2" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.8" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.9" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.3.28" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.2" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.5" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.flutter-io.cn" + source: hosted + version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" + webview_flutter: + dependency: "direct main" + description: + name: webview_flutter + sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.13.1" + webview_flutter_android: + dependency: "direct main" + description: + name: webview_flutter_android + sha256: "2a03df01df2fd30b075d1e7f24c28aee593f2e5d5ac4c3c4283c5eda63717b24" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.10.13" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.14.0" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: "2df8fd9ada04d699b9db8e79aa783a16e5d89b69e5b74009b87e16b59912cf98" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.24.0" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.15.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.6.1" +sdks: + dart: ">=3.11.0 <4.0.0" + flutter: ">=3.38.0" diff --git a/packages/web_android_shell/pubspec.yaml b/packages/web_android_shell/pubspec.yaml new file mode 100644 index 0000000..2780208 --- /dev/null +++ b/packages/web_android_shell/pubspec.yaml @@ -0,0 +1,35 @@ +name: web_android_shell +description: "H5壳子项目." +# 阻止误发布到 pub.dev。 +publish_to: 'none' + +# Android 应用版本号:`build-name` 对应 `versionName`,`build-number` 对应 `versionCode`。 +version: 1.0.0+1 + +environment: + sdk: ^3.11.0 + +# 应用运行依赖。 +dependencies: + flutter: + sdk: flutter + + # Material 图标已覆盖主要场景;此依赖保留以兼容历史代码。 + cupertino_icons: ^1.0.8 + webview_flutter: ^4.13.1 + webview_flutter_android: ^4.10.13 + image_picker: ^1.2.1 + file_picker: ^10.3.10 + permission_handler: ^12.0.1 + url_launcher: ^6.3.2 + +dev_dependencies: + flutter_test: + sdk: flutter + + # 推荐的 Flutter lint 规则集合。 + flutter_lints: ^6.0.0 + +flutter: + # 启用 Material 图标字体。 + uses-material-design: true diff --git a/test/widget_test.dart b/packages/web_android_shell/test/widget_test.dart similarity index 100% rename from test/widget_test.dart rename to packages/web_android_shell/test/widget_test.dart diff --git a/packages/web_android_shell/tool/flutter_run_fresh.ps1 b/packages/web_android_shell/tool/flutter_run_fresh.ps1 new file mode 100644 index 0000000..e4591e7 --- /dev/null +++ b/packages/web_android_shell/tool/flutter_run_fresh.ps1 @@ -0,0 +1,187 @@ +[CmdletBinding()] +param( + [ValidateSet('Auto', 'Disable', 'Enable', 'Keep')] + [string]$WebViewMultiprocess = 'Auto', + + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$FlutterArgs +) + +$ErrorActionPreference = 'Stop' + +$projectRoot = Split-Path -Parent $PSScriptRoot +$localPropertiesPath = Join-Path $projectRoot 'android\local.properties' +$packageName = 'com.yuanxuan.webshell.web_android_shell' + +function Get-LocalPropertyValue { + param( + [string]$Path, + [string]$Name + ) + + if (-not (Test-Path $Path)) { + return $null + } + + $escapedName = [regex]::Escape($Name) + foreach ($line in Get-Content $Path) { + if ($line -match "^$escapedName=(.+)$") { + return $matches[1].Trim() -replace '\\\\', '\' + } + } + + return $null +} + +function Get-DeviceIdFromArgs { + param([string[]]$ArgsToScan) + + for ($index = 0; $index -lt $ArgsToScan.Length; $index += 1) { + $arg = $ArgsToScan[$index] + if ($arg -eq '-d' -or $arg -eq '--device-id') { + if ($index + 1 -lt $ArgsToScan.Length) { + return $ArgsToScan[$index + 1] + } + } + + if ($arg.StartsWith('--device-id=')) { + return $arg.Substring('--device-id='.Length) + } + } + + return $null +} + +function Get-DeviceModel { + param( + [string]$AdbPath, + [string[]]$AdbArgsPrefix + ) + + return ((& $AdbPath @AdbArgsPrefix shell getprop ro.product.model) | Out-String).Trim() +} + +function Get-WebViewUpdateState { + param( + [string]$AdbPath, + [string[]]$AdbArgsPrefix + ) + + return (& $AdbPath @AdbArgsPrefix shell dumpsys webviewupdate | Out-String) +} + +function Get-WebViewInfo { + param( + [string]$AdbPath, + [string[]]$AdbArgsPrefix + ) + + $state = Get-WebViewUpdateState -AdbPath $AdbPath -AdbArgsPrefix $AdbArgsPrefix + $multiprocessEnabled = $state -match 'Multiprocess enabled:\s+true' + $versionName = $null + + if ($state -match 'Current WebView package \(name, version\): \([^\),]+,\s*([^\)]+)\)') { + $versionName = $matches[1].Trim() + } + + $majorVersion = $null + if ($versionName) { + $majorToken = $versionName.Split('.')[0] + $parsedMajor = 0 + if ([int]::TryParse($majorToken, [ref]$parsedMajor)) { + $majorVersion = $parsedMajor + } + } + + return @{ + MultiprocessEnabled = $multiprocessEnabled + VersionName = $versionName + MajorVersion = $majorVersion + RawState = $state + } +} + +function Set-WebViewMultiprocessState { + param( + [string]$AdbPath, + [string[]]$AdbArgsPrefix, + [bool]$Enabled + ) + + $command = if ($Enabled) { 'enable-multiprocess' } else { 'disable-multiprocess' } + & $AdbPath @AdbArgsPrefix shell cmd webviewupdate $command | Out-Host + Start-Sleep -Seconds 1 +} + +$sdkDir = Get-LocalPropertyValue -Path $localPropertiesPath -Name 'sdk.dir' +$adbPath = $null + +if ($sdkDir) { + $candidate = Join-Path $sdkDir 'platform-tools\adb.exe' + if (Test-Path $candidate) { + $adbPath = $candidate + } +} + +if (-not $adbPath) { + $adbCommand = Get-Command adb.exe -ErrorAction SilentlyContinue + if ($adbCommand) { + $adbPath = $adbCommand.Source + } +} + +if (-not $adbPath) { + throw "未找到 adb.exe,请检查 android/local.properties 中的 sdk.dir。" +} + +$deviceId = Get-DeviceIdFromArgs -ArgsToScan $FlutterArgs +$adbArgsPrefix = @() + +if ($deviceId) { + $adbArgsPrefix = @('-s', $deviceId) +} + +Write-Host "Using adb: $adbPath" +if ($deviceId) { + Write-Host "Target device: $deviceId" +} + +& $adbPath @adbArgsPrefix start-server | Out-Null + +$deviceModel = Get-DeviceModel -AdbPath $adbPath -AdbArgsPrefix $adbArgsPrefix +$webViewInfo = Get-WebViewInfo -AdbPath $adbPath -AdbArgsPrefix $adbArgsPrefix + +Write-Host "Device model: $deviceModel" +if ($webViewInfo.VersionName) { + Write-Host "System WebView: $($webViewInfo.VersionName)" +} +Write-Host "WebView multiprocess enabled: $($webViewInfo.MultiprocessEnabled)" + +$shouldDisableMultiprocess = $false +switch ($WebViewMultiprocess) { + 'Disable' { + $shouldDisableMultiprocess = $true + } + 'Auto' { + $shouldDisableMultiprocess = + $deviceModel -eq 'F136A' -or + ($webViewInfo.MajorVersion -ne $null -and $webViewInfo.MajorVersion -le 101) + } +} + +if ($shouldDisableMultiprocess -and $webViewInfo.MultiprocessEnabled) { + Write-Host "Disabling Android WebView multiprocess for compatibility..." + Set-WebViewMultiprocessState -AdbPath $adbPath -AdbArgsPrefix $adbArgsPrefix -Enabled:$false +} elseif ($WebViewMultiprocess -eq 'Enable' -and -not $webViewInfo.MultiprocessEnabled) { + Write-Host "Re-enabling Android WebView multiprocess..." + Set-WebViewMultiprocessState -AdbPath $adbPath -AdbArgsPrefix $adbArgsPrefix -Enabled:$true +} + +Write-Host "Force-stopping previous app process: $packageName" +& $adbPath @adbArgsPrefix shell am force-stop $packageName | Out-Null + +Start-Sleep -Milliseconds 800 + +Write-Host "Starting flutter run..." +& flutter run @FlutterArgs +exit $LASTEXITCODE diff --git a/packages/web_shell_core/.gitignore b/packages/web_shell_core/.gitignore new file mode 100644 index 0000000..b9d7f25 --- /dev/null +++ b/packages/web_shell_core/.gitignore @@ -0,0 +1,33 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins-dependencies +/build/ +/coverage/ diff --git a/packages/web_shell_core/.metadata b/packages/web_shell_core/.metadata new file mode 100644 index 0000000..d767a0e --- /dev/null +++ b/packages/web_shell_core/.metadata @@ -0,0 +1,33 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "90673a4eef275d1a6692c26ac80d6d746d41a73a" + channel: "stable" + +project_type: plugin + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a + base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a + - platform: android + create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a + base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a + - platform: ios + create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a + base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/packages/web_shell_core/CHANGELOG.md b/packages/web_shell_core/CHANGELOG.md new file mode 100644 index 0000000..93b39fc --- /dev/null +++ b/packages/web_shell_core/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +## 0.0.1 + +### 新增 +- 从 `web_android_shell` 提取核心库,支持多品牌白标架构 +- `runShellApp()` 唯一入口 + `ShellEnvironment` 品牌配置 +- 15 个模块文件拆分(config / engine / bridge / services / ui) +- Android WebView 兼容性自动检测(`AndroidWebViewInfo` + `AndroidCompatibilityPlan`) +- 双渲染模式(texture / hybrid)自动切换 + 启动看门狗恢复链 +- `window.AppShell` JS Bridge 协议(8 种 Action) +- 旧相机 JS 兼容层(`openCamera` / `captureImage` monkey-patch) +- 媒体序列化支持 `base64` / `dataUrl` / `uri` 三种格式 +- 54 个单元测试 + Widget 测试 +- `CoreShellActivity` 原生层(进程隔离 + WebView 信息查询 + 深度重置) +- 全中文 `debugPrint` 日志 diff --git a/packages/web_shell_core/LICENSE b/packages/web_shell_core/LICENSE new file mode 100644 index 0000000..ba75c69 --- /dev/null +++ b/packages/web_shell_core/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/packages/web_shell_core/README.md b/packages/web_shell_core/README.md new file mode 100644 index 0000000..9910ce6 --- /dev/null +++ b/packages/web_shell_core/README.md @@ -0,0 +1,92 @@ +# web_shell_core + +Android 平板专用的 H5 壳核心库。所有品牌应用共享此库,只需传入 `ShellEnvironment` 即可启动。 + +## 功能 + +| 模块 | 说明 | +|---|---| +| **WebView 引擎** | 自动兼容低版本 Android WebView,支持 texture / hybrid 双渲染模式自动切换 | +| **启动恢复** | 看门狗超时检测 → 渲染模式降级 → 深度清理 → 自动重试 | +| **JS Bridge** | `window.AppShell` 协议,支持 8 种 Action(pickImage · captureImage · pickFile · openExternal · requestPermissions · reloadPage · goBack · closeApp) | +| **旧相机兼容** | Monkey-patch `openCamera` / `captureImage` 兼容老 H5 页面 | +| **媒体服务** | 相机拍照 · 图库选图 · 文件选择 · base64 / dataUrl / uri 三种序列化格式 | +| **权限服务** | camera · microphone · location · photos · videos · storage 统一映射 | +| **导航服务** | URL scheme 白名单路由,非 WebView 协议自动跳转外部应用 | +| **壳层 UI** | 启动加载动画 · 错误恢复页 · 进度条 · 不支持平台兜底页 | + +## 使用方式 + +```dart +import 'package:web_shell_core/web_shell_core.dart'; + +void main() { + runShellApp( + ShellEnvironment( + appName: '全学通', + appKey: 'quanxue_prod', + accentColor: Color(0xFF3ED37B), + backgroundColor: Color(0xFFFFFFFF), + textColor: Color(0xFF1F2937), + mutedTextColor: Color(0xFF6B7280), + initialUrl: 'example.com/login', // 可选,不传使用默认地址 + ), + ); +} +``` + +## 代码结构 + +``` +lib/ +├── core_app.dart # 库入口 + part 指令 + runShellApp() +├── web_shell_core.dart # 公开 API 导出 +└── src/ + ├── config/ + │ ├── shell_environment.dart # 品牌配置数据类 + │ └── url_resolver.dart # URL 解析与归一化 + ├── engine/ + │ ├── compatibility.dart # Android WebView 兼容检测 + │ └── recovery.dart # 启动看门狗 + 错误映射 + ├── bridge/ + │ ├── bridge_protocol.dart # JS Bridge 注入与响应 + │ ├── bridge_actions.dart # Action handler(占位) + │ └── legacy_camera_compat.dart # 旧相机 JS 兼容层 + ├── services/ + │ ├── media_service.dart # 相机/图库/文件 + 序列化 + │ ├── permission_service.dart # 权限类型映射 + │ └── navigation_service.dart # URL 路由 + 外链跳转 + ├── ui/ + │ ├── shell_app.dart # MaterialApp 入口 + │ ├── shell_page.dart # WebView 主页面 + │ ├── launch_overlay.dart # 启动加载动画 + │ ├── error_overlay.dart # 错误恢复页 + │ ├── progress_bar.dart # 顶部进度条 + │ └── unsupported_platform_page.dart # 平台兜底页 + └── testing/ + └── test_hooks.dart # 测试钩子(@visibleForTesting) +``` + +## 测试 + +```bash +cd packages/web_shell_core +flutter test +``` + +当前 **67 个测试用例**,覆盖: +- 平台检测 · URL 解析 · 兼容性策略 · 错误映射 +- Bridge 注入/响应/异常处理 · 媒体序列化 · 权限映射 +- 导航路由 · 所有独立 UI 组件 + +## 平台约束 + +仅支持 **Android**。其他平台会展示兜底提示页。 + +## Android 原生层 + +`CoreShellActivity`(Java)提供: +- WebView 数据目录隔离(避免多进程冲突) +- 旧进程自动终止 +- WebView 信息查询(SDK 版本、WebView 包名/版本号) +- WebView 状态深度重置 diff --git a/packages/web_shell_core/analysis_options.yaml b/packages/web_shell_core/analysis_options.yaml new file mode 100644 index 0000000..71b1554 --- /dev/null +++ b/packages/web_shell_core/analysis_options.yaml @@ -0,0 +1,5 @@ +include: package:very_good_analysis/analysis_options.yaml + +analyzer: + exclude: + - coverage/** diff --git a/packages/web_shell_core/android/.gitignore b/packages/web_shell_core/android/.gitignore new file mode 100644 index 0000000..161bdcd --- /dev/null +++ b/packages/web_shell_core/android/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +.cxx diff --git a/packages/web_shell_core/android/build.gradle.kts b/packages/web_shell_core/android/build.gradle.kts new file mode 100644 index 0000000..e80385f --- /dev/null +++ b/packages/web_shell_core/android/build.gradle.kts @@ -0,0 +1,76 @@ +group = "com.yuanxuan.webshell.core.web_shell_core" +version = "1.0-SNAPSHOT" + +buildscript { + val kotlinVersion = "2.2.20" + repositories { + google() + mavenCentral() + } + + dependencies { + classpath("com.android.tools.build:gradle:8.11.1") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +plugins { + id("com.android.library") + id("kotlin-android") +} + +android { + namespace = "com.yuanxuan.webshell.core.web_shell_core" + + compileSdk = 36 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + sourceSets { + getByName("main") { + java.srcDirs("src/main/kotlin") + } + getByName("test") { + java.srcDirs("src/test/kotlin") + } + } + + defaultConfig { + minSdk = 24 + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + all { + it.useJUnitPlatform() + + it.outputs.upToDateWhen { false } + + it.testLogging { + events("passed", "skipped", "failed", "standardOut", "standardError") + showStandardStreams = true + } + } + } + } +} + +dependencies { + testImplementation("org.jetbrains.kotlin:kotlin-test") + testImplementation("org.mockito:mockito-core:5.0.0") +} diff --git a/packages/web_shell_core/android/settings.gradle b/packages/web_shell_core/android/settings.gradle new file mode 100644 index 0000000..33eea3d --- /dev/null +++ b/packages/web_shell_core/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'web_shell_core' diff --git a/packages/web_shell_core/android/settings.gradle.kts b/packages/web_shell_core/android/settings.gradle.kts new file mode 100644 index 0000000..33eea3d --- /dev/null +++ b/packages/web_shell_core/android/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = 'web_shell_core' diff --git a/packages/web_shell_core/android/src/main/AndroidManifest.xml b/packages/web_shell_core/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..e9c4757 --- /dev/null +++ b/packages/web_shell_core/android/src/main/AndroidManifest.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/web_shell_core/android/src/main/java/com/yuanxuan/webshell/core/web_shell_core/CoreShellActivity.java b/packages/web_shell_core/android/src/main/java/com/yuanxuan/webshell/core/web_shell_core/CoreShellActivity.java new file mode 100644 index 0000000..2d09d58 --- /dev/null +++ b/packages/web_shell_core/android/src/main/java/com/yuanxuan/webshell/core/web_shell_core/CoreShellActivity.java @@ -0,0 +1,241 @@ +package com.yuanxuan.webshell.core.web_shell_core; + +import android.app.ActivityManager; +import android.content.SharedPreferences; +import android.content.pm.PackageInfo; +import android.os.Build; +import android.os.Bundle; +import android.os.Process; +import android.os.SystemClock; +import android.webkit.CookieManager; +import android.webkit.WebStorage; +import android.webkit.WebView; + +import androidx.annotation.NonNull; + +import java.io.File; +import java.util.HashMap; +import java.util.Map; + +import io.flutter.embedding.android.FlutterActivity; +import io.flutter.embedding.engine.FlutterEngine; +import io.flutter.plugin.common.MethodChannel; + +public class CoreShellActivity extends FlutterActivity { + private static final String DEVICE_CHANNEL = "app_shell/device"; + private static final String DEBUG_PROCESS_PREFS = "app_shell_debug_process"; + private static final String DEBUG_PROCESS_PID_KEY = "pid"; + private String webViewDataDirectorySuffix; + + @Override + protected void onCreate(Bundle savedInstanceState) { + terminatePreviousDebugProcessIfNeeded(); + prepareWebViewDataDirectory(); + super.onCreate(savedInstanceState); + registerCurrentDebugProcess(); + } + + @Override + protected void onDestroy() { + clearCurrentDebugProcessRegistration(); + super.onDestroy(); + } + + @Override + public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) { + super.configureFlutterEngine(flutterEngine); + new MethodChannel( + flutterEngine.getDartExecutor().getBinaryMessenger(), + DEVICE_CHANNEL + ).setMethodCallHandler((call, result) -> { + switch (call.method) { + case "getAndroidWebViewInfo": + result.success(buildAndroidWebViewInfo()); + break; + case "resetAndroidWebViewState": + resetAndroidWebViewState(result); + break; + default: + result.notImplemented(); + break; + } + }); + } + + private Map buildAndroidWebViewInfo() { + final Map info = new HashMap<>(); + info.put("sdkInt", Build.VERSION.SDK_INT); + info.put("manufacturer", Build.MANUFACTURER); + info.put("brand", Build.BRAND); + info.put("model", Build.MODEL); + if (webViewDataDirectorySuffix != null) { + info.put("webViewDataDirectorySuffix", webViewDataDirectorySuffix); + } + + final PackageInfo currentWebViewPackage = getCurrentWebViewPackageCompat(); + if (currentWebViewPackage != null) { + info.put("webViewPackageName", currentWebViewPackage.packageName); + if (currentWebViewPackage.versionName != null) { + info.put("webViewVersionName", currentWebViewPackage.versionName); + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + info.put( + "webViewLongVersionCode", + currentWebViewPackage.getLongVersionCode() + ); + } else { + info.put( + "webViewLongVersionCode", + (long) currentWebViewPackage.versionCode + ); + } + } + return info; + } + + private void prepareWebViewDataDirectory() { + webViewDataDirectorySuffix = null; + if (!isDebuggableBuild() || Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + return; + } + + clearLegacyDebugWebViewData(); + } + + private void terminatePreviousDebugProcessIfNeeded() { + if (!isDebuggableBuild()) { + return; + } + + final SharedPreferences preferences = getSharedPreferences( + DEBUG_PROCESS_PREFS, + MODE_PRIVATE + ); + final int currentPid = Process.myPid(); + final int previousPid = preferences.getInt(DEBUG_PROCESS_PID_KEY, -1); + if (previousPid <= 0 || previousPid == currentPid) { + return; + } + + if (!isSamePackageProcessRunning(previousPid)) { + preferences.edit().remove(DEBUG_PROCESS_PID_KEY).apply(); + return; + } + + try { + Process.killProcess(previousPid); + SystemClock.sleep(180); + } catch (RuntimeException ignored) { + // 忽略终止旧进程失败的情况,并继续使用当前进程启动。 + } + } + + private void registerCurrentDebugProcess() { + if (!isDebuggableBuild()) { + return; + } + + getSharedPreferences(DEBUG_PROCESS_PREFS, MODE_PRIVATE) + .edit() + .putInt(DEBUG_PROCESS_PID_KEY, Process.myPid()) + .apply(); + } + + private void clearCurrentDebugProcessRegistration() { + if (!isDebuggableBuild()) { + return; + } + + final SharedPreferences preferences = getSharedPreferences( + DEBUG_PROCESS_PREFS, + MODE_PRIVATE + ); + if (preferences.getInt(DEBUG_PROCESS_PID_KEY, -1) != Process.myPid()) { + return; + } + preferences.edit().remove(DEBUG_PROCESS_PID_KEY).apply(); + } + + private boolean isSamePackageProcessRunning(int pid) { + final ActivityManager activityManager = + (ActivityManager) getSystemService(ACTIVITY_SERVICE); + if (activityManager == null) { + return false; + } + + for (final ActivityManager.RunningAppProcessInfo processInfo + : activityManager.getRunningAppProcesses()) { + if (processInfo.pid == pid) { + return getPackageName().equals(processInfo.processName); + } + } + return false; + } + + private void clearLegacyDebugWebViewData() { + try { + final File appWebViewDir = new File(getApplicationInfo().dataDir, "app_webview"); + deleteRecursively(appWebViewDir); + deleteDatabase("webview.db"); + deleteDatabase("webviewCache.db"); + } catch (RuntimeException ignored) { + // 忽略旧设备上的清理失败,继续执行启动流程。 + } + } + + private boolean isDebuggableBuild() { + return (getApplicationInfo().flags & android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0; + } + + private void deleteRecursively(File target) { + if (target == null || !target.exists()) { + return; + } + + if (target.isDirectory()) { + final File[] children = target.listFiles(); + if (children != null) { + for (final File child : children) { + deleteRecursively(child); + } + } + } + + + target.delete(); + } + + private PackageInfo getCurrentWebViewPackageCompat() { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + return WebView.getCurrentWebViewPackage(); + } + } catch (RuntimeException ignored) { + return null; + } + return null; + } + + private void resetAndroidWebViewState(@NonNull MethodChannel.Result result) { + try { + WebStorage.getInstance().deleteAllData(); + + final CookieManager cookieManager = CookieManager.getInstance(); + cookieManager.removeSessionCookies(null); + cookieManager.removeAllCookies(value -> { + try { + cookieManager.flush(); + WebView.clearClientCertPreferences(() -> result.success(true)); + } catch (RuntimeException error) { + result.error( + "webview_reset_failed", + error.toString(), + null + ); + } + }); + } catch (RuntimeException error) { + result.error("webview_reset_failed", error.toString(), null); + } + } +} diff --git a/packages/web_shell_core/android/src/main/kotlin/com/yuanxuan/webshell/core/web_shell_core/WebShellCorePlugin.kt b/packages/web_shell_core/android/src/main/kotlin/com/yuanxuan/webshell/core/web_shell_core/WebShellCorePlugin.kt new file mode 100644 index 0000000..6d7289e --- /dev/null +++ b/packages/web_shell_core/android/src/main/kotlin/com/yuanxuan/webshell/core/web_shell_core/WebShellCorePlugin.kt @@ -0,0 +1,38 @@ +package com.yuanxuan.webshell.core.web_shell_core + +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result + +/** WebShellCorePlugin 插件实现。 */ +class WebShellCorePlugin : + FlutterPlugin, + MethodCallHandler { + // 用于 Flutter 与原生 Android 通信的 MethodChannel。 + // + // 这里保留本地引用,用于在 Flutter Engine 绑定时注册插件, + // 并在 Flutter Engine 分离时正确注销。 + private lateinit var channel: MethodChannel + + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + channel = MethodChannel(flutterPluginBinding.binaryMessenger, "web_shell_core") + channel.setMethodCallHandler(this) + } + + override fun onMethodCall( + call: MethodCall, + result: Result + ) { + if (call.method == "getPlatformVersion") { + result.success("Android ${android.os.Build.VERSION.RELEASE}") + } else { + result.notImplemented() + } + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + } +} diff --git a/packages/web_shell_core/android/src/test/kotlin/com/yuanxuan/webshell/core/web_shell_core/WebShellCorePluginTest.kt b/packages/web_shell_core/android/src/test/kotlin/com/yuanxuan/webshell/core/web_shell_core/WebShellCorePluginTest.kt new file mode 100644 index 0000000..cede880 --- /dev/null +++ b/packages/web_shell_core/android/src/test/kotlin/com/yuanxuan/webshell/core/web_shell_core/WebShellCorePluginTest.kt @@ -0,0 +1,26 @@ +package com.yuanxuan.webshell.core.web_shell_core + +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import org.mockito.Mockito +import kotlin.test.Test + +/* + * 这是一个简单的单元测试示例,用于验证插件 Kotlin 实现中的基础逻辑。 + * + * 你可以在对应 Android 工程目录下执行 `./gradlew testDebugUnitTest` 运行, + * 也可以直接在支持 JUnit 的 IDE 中运行。 + */ + +internal class WebShellCorePluginTest { + @Test + fun onMethodCall_getPlatformVersion_returnsExpectedValue() { + val plugin = WebShellCorePlugin() + + val call = MethodCall("getPlatformVersion", null) + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + plugin.onMethodCall(call, mockResult) + + Mockito.verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE) + } +} diff --git a/packages/web_shell_core/lib/core_app.dart b/packages/web_shell_core/lib/core_app.dart new file mode 100644 index 0000000..ad081aa --- /dev/null +++ b/packages/web_shell_core/lib/core_app.dart @@ -0,0 +1,93 @@ +/// WebShell 核心库:为 H5 壳应用提供 WebView 引擎、原生桥接与平台服务。 +library; + +import 'dart:async'; +import 'dart:convert'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:webview_flutter/webview_flutter.dart'; +import 'package:webview_flutter_android/webview_flutter_android.dart'; + +// ── 配置 ── +part 'src/config/shell_environment.dart'; +part 'src/config/url_resolver.dart'; + +// ── 引擎 ── +part 'src/engine/compatibility.dart'; +part 'src/engine/recovery.dart'; + +// ── 桥接 ── +part 'src/bridge/bridge_protocol.dart'; +part 'src/bridge/bridge_actions.dart'; +part 'src/bridge/legacy_camera_compat.dart'; + +// ── 服务 ── +part 'src/services/media_service.dart'; +part 'src/services/permission_service.dart'; +part 'src/services/navigation_service.dart'; + +// ── 界面 ── +part 'src/ui/shell_app.dart'; +part 'src/ui/shell_page.dart'; +part 'src/ui/launch_overlay.dart'; +part 'src/ui/error_overlay.dart'; +part 'src/ui/progress_bar.dart'; +part 'src/ui/unsupported_platform_page.dart'; +part 'src/testing/test_hooks.dart'; + +// ── 全局环境 ── +late ShellEnvironment _env; + +Color get _shellAccentColor => _env.accentColor; +Color get _shellBackgroundColor => _env.backgroundColor; +Color get _shellTextColor => _env.textColor; +Color get _shellMutedTextColor => _env.mutedTextColor; + +// ── 入口 ── + +/// 启动壳应用的唯一入口。 +/// 各品牌应用的 `main.dart` 调用此函数,并传入自己的 [ShellEnvironment]。 +Future runShellApp(ShellEnvironment environment) async { + _env = environment; + _initializeUrls(); + WidgetsFlutterBinding.ensureInitialized(); + await SystemChrome.setPreferredOrientations(const [ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + await _enterImmersiveMode(); + runApp(const ShellApp()); +} + +Future _enterImmersiveMode() async { + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + statusBarBrightness: Brightness.dark, + systemNavigationBarColor: Colors.black, + systemNavigationBarDividerColor: Colors.black, + systemNavigationBarIconBrightness: Brightness.light, + ), + ); +} + +bool _supportsEmbeddedWebView({ + bool isWeb = kIsWeb, + TargetPlatform? platform, +}) { + if (isWeb) { + return false; + } + + final target = platform ?? defaultTargetPlatform; + return target == TargetPlatform.android; +} diff --git a/packages/web_shell_core/lib/src/bridge/bridge_actions.dart b/packages/web_shell_core/lib/src/bridge/bridge_actions.dart new file mode 100644 index 0000000..c6459df --- /dev/null +++ b/packages/web_shell_core/lib/src/bridge/bridge_actions.dart @@ -0,0 +1,5 @@ +part of '../../core_app.dart'; + +// 当前无需在 `bridge_actions.dart` 中编写独立代码 —— +// 桥接动作处理逻辑直接内嵌在 `shell_page.dart` 的 `_handleBridgeMessage` 方法中。 +// 此文件预留给后续将动作处理器拆分出去时使用。 diff --git a/packages/web_shell_core/lib/src/bridge/bridge_protocol.dart b/packages/web_shell_core/lib/src/bridge/bridge_protocol.dart new file mode 100644 index 0000000..9a098a3 --- /dev/null +++ b/packages/web_shell_core/lib/src/bridge/bridge_protocol.dart @@ -0,0 +1,108 @@ +part of '../../core_app.dart'; + +const String _appShellBridgeChannel = 'AppShellBridge'; +const String _appShellBridgeVersion = '1.0.0'; + +Future _injectAppShellBridge( + WebViewController controller, + String url, +) async { + try { + await controller.runJavaScript(_buildAppShellBridgeScript()); + debugPrint('已为 $url 注入 AppShell 桥接'); + } on Object catch (error, stackTrace) { + debugPrint('注入 AppShell 桥接失败:$error\n$stackTrace'); + } +} + +Future _sendBridgeResponse( + WebViewController controller, { + required String requestId, + required bool success, + Object? data, + String? error, +}) async { + final response = { + 'requestId': requestId, + 'success': success, + 'data': data, + 'error': error, + }; + + try { + await controller.runJavaScript( + 'window.__appShellReceiveResponse(${jsonEncode(response)});', + ); + } on Object catch (bridgeError, stackTrace) { + debugPrint( + '发送 AppShell 桥接响应失败:$bridgeError\n$stackTrace', + ); + } +} + +String _buildAppShellBridgeScript() { + return ''' +(() => { + const channel = window.$_appShellBridgeChannel; + if (!channel || typeof channel.postMessage !== 'function') { + return; + } + + if (window.AppShell && window.AppShell.__nativeShellVersion === '$_appShellBridgeVersion') { + return; + } + + const pending = new Map(); + + window.__appShellReceiveResponse = function(response) { + if (!response || !response.requestId) { + return; + } + + const task = pending.get(response.requestId); + if (!task) { + return; + } + + pending.delete(response.requestId); + + if (response.success) { + task.resolve(response.data ?? null); + return; + } + + task.reject(new Error(response.error || 'Native request failed')); + }; + + const send = (action, payload = {}) => new Promise((resolve, reject) => { + const requestId = + Date.now().toString(36) + Math.random().toString(36).slice(2); + pending.set(requestId, { resolve, reject }); + channel.postMessage(JSON.stringify({ requestId, action, payload })); + }); + + window.AppShell = { + __nativeShellVersion: '$_appShellBridgeVersion', + isNativeShell: true, + version: '$_appShellBridgeVersion', + pickImage: (options = {}) => send('pickImage', options), + captureImage: (options = {}) => send('captureImage', options), + pickFile: (options = {}) => send('pickFile', options), + openExternal: (url) => send('openExternal', { url }), + requestPermissions: (types = []) => send('requestPermissions', { types }), + reloadPage: () => send('reloadPage'), + goBack: () => send('goBack'), + closeApp: () => send('closeApp'), + }; + + window.dispatchEvent(new CustomEvent('app-shell-ready', { + detail: { + version: '$_appShellBridgeVersion', + isNativeShell: true, + }, + })); + +$_legacyCameraCompatScript +})(); +'''; +} diff --git a/packages/web_shell_core/lib/src/bridge/legacy_camera_compat.dart b/packages/web_shell_core/lib/src/bridge/legacy_camera_compat.dart new file mode 100644 index 0000000..5def953 --- /dev/null +++ b/packages/web_shell_core/lib/src/bridge/legacy_camera_compat.dart @@ -0,0 +1,132 @@ +part of '../../core_app.dart'; + +// 旧版 H5 相机兼容层 JavaScript 代码 +// 通过替换 `window.openCamera` / `capturePhoto` 的方式兼容老页面。 + +const String _legacyCameraCompatScript = ''' + if (!window.__appShellLegacyCameraCompatInstalled) { + window.__appShellLegacyCameraCompatInstalled = true; + + const installLegacyCameraCompat = () => { + if (typeof window.openCamera !== 'function') { + return false; + } + if (window.openCamera.__appShellCompatWrapped) { + return true; + } + + const originalOpenCamera = window.openCamera.bind(window); + const originalCapturePhoto = + typeof window.capturePhoto === 'function' + ? window.capturePhoto.bind(window) + : null; + + const deliverCaptureResult = (args, result) => { + const detail = { + args: Array.from(args), + result, + }; + + if (typeof window.handleNativeCapture === 'function') { + window.handleNativeCapture(detail); + return true; + } + + const targetId = + detail.args[0] ?? + window.currentUploadId ?? + null; + + if ( + targetId != null && + result && + result.dataUrl && + typeof window.addPreview === 'function' + ) { + window.currentUploadId = targetId; + window.addPreview(targetId, result.dataUrl); + return true; + } + + window.dispatchEvent( + new CustomEvent('app-shell-image-captured', { + detail, + }), + ); + return false; + }; + + const closeLegacyCameraModal = () => { + if (typeof window.closeCamera === 'function') { + try { + window.closeCamera(); + } catch (_) {} + } + }; + + const wrappedOpenCamera = async function(...args) { + try { + const result = await window.AppShell.captureImage({ + responseType: 'dataUrl', + }); + const delivered = deliverCaptureResult(args, result); + if (!delivered) { + return result; + } + closeLegacyCameraModal(); + return result; + } catch (error) { + console.warn( + '[AppShell] legacy openCamera fallback to page implementation', + error, + ); + return originalOpenCamera(...args); + } + }; + + wrappedOpenCamera.__appShellCompatWrapped = true; + window.openCamera = wrappedOpenCamera; + + if (originalCapturePhoto) { + const wrappedCapturePhoto = function(...args) { + if ( + window.currentUploadId != null && + window.__lastAppShellCaptureResult && + window.__lastAppShellCaptureResult.dataUrl && + typeof window.addPreview === 'function' + ) { + window.addPreview( + window.currentUploadId, + window.__lastAppShellCaptureResult.dataUrl, + ); + closeLegacyCameraModal(); + return; + } + return originalCapturePhoto(...args); + }; + wrappedCapturePhoto.__appShellCompatWrapped = true; + window.capturePhoto = wrappedCapturePhoto; + } + + return true; + }; + + const originalCaptureImage = window.AppShell.captureImage; + window.AppShell.captureImage = async function(options = {}) { + const result = await originalCaptureImage(options); + window.__lastAppShellCaptureResult = result; + return result; + }; + + installLegacyCameraCompat(); + + let attempts = 0; + const compatTimer = setInterval(() => { + attempts += 1; + const installed = installLegacyCameraCompat(); + if (installed || attempts >= 20) { + clearInterval(compatTimer); + } + }, 500); + } +'''; diff --git a/packages/web_shell_core/lib/src/config/shell_environment.dart b/packages/web_shell_core/lib/src/config/shell_environment.dart new file mode 100644 index 0000000..e3fbd81 --- /dev/null +++ b/packages/web_shell_core/lib/src/config/shell_environment.dart @@ -0,0 +1,37 @@ +part of '../../core_app.dart'; + +/// 壳应用的品牌环境配置。 +/// 每个白标应用都在 `main.dart` 中传入自己的 [ShellEnvironment] 实例。 +class ShellEnvironment { + /// 创建 Android 平板壳应用的环境配置。 + const ShellEnvironment({ + required this.appName, + required this.appKey, + required this.accentColor, + required this.backgroundColor, + required this.textColor, + required this.mutedTextColor, + this.initialUrl, + }); + + /// 应用显示名称(如 "全学通"、"点智学") + final String appName; + + /// 应用唯一标识符(用于后端区分品牌) + final String appKey; + + /// 品牌主题强调色 + final Color accentColor; + + /// 品牌背景色 + final Color backgroundColor; + + /// 品牌正文文字色 + final Color textColor; + + /// 品牌次要文字色 + final Color mutedTextColor; + + /// 可选的初始地址;为空时使用默认地址。 + final String? initialUrl; +} diff --git a/packages/web_shell_core/lib/src/config/url_resolver.dart b/packages/web_shell_core/lib/src/config/url_resolver.dart new file mode 100644 index 0000000..df89f8c --- /dev/null +++ b/packages/web_shell_core/lib/src/config/url_resolver.dart @@ -0,0 +1,66 @@ +part of '../../core_app.dart'; + +const String _defaultInitialUrl = 'http://xszy.lzzneng.com/login.html'; + +late Uri _initialUri; +late String _initialUrl; + +void _initializeUrls() { + final candidate = (_env.initialUrl ?? '').trim(); + if (candidate.isEmpty) { + _initialUri = Uri.parse(_defaultInitialUrl); + } else { + final directUri = Uri.tryParse(candidate); + if (directUri != null && directUri.hasScheme) { + _initialUri = directUri; + } else if (candidate.startsWith('//')) { + final protocolRelativeUri = Uri.tryParse('https:$candidate'); + if (protocolRelativeUri != null && protocolRelativeUri.hasScheme) { + _initialUri = protocolRelativeUri; + } + } else { + final httpsUri = Uri.tryParse('https://$candidate'); + if (httpsUri != null && httpsUri.hasScheme && httpsUri.host.isNotEmpty) { + _initialUri = httpsUri; + } else { + _initialUri = Uri.parse(_defaultInitialUrl); + } + } + } + _initialUrl = _initialUri.toString(); +} + +bool _isNetworkUrl(String? url) { + final uri = Uri.tryParse(url ?? ''); + if (uri == null) { + return false; + } + return uri.scheme == 'http' || uri.scheme == 'https'; +} + +String _normalizeComparableUri(Uri uri) { + final scheme = uri.scheme.toLowerCase(); + final host = uri.host.toLowerCase(); + final port = uri.hasPort ? uri.port : _defaultPortForScheme(scheme); + final path = _normalizeComparablePath(uri.path); + final query = uri.query; + return '$scheme://$host:$port$path?$query'; +} + +int _defaultPortForScheme(String scheme) { + return switch (scheme) { + 'https' => 443, + 'http' => 80, + _ => -1, + }; +} + +String _normalizeComparablePath(String path) { + if (path.isEmpty) { + return '/'; + } + if (path.length > 1 && path.endsWith('/')) { + return path.substring(0, path.length - 1); + } + return path; +} diff --git a/packages/web_shell_core/lib/src/engine/compatibility.dart b/packages/web_shell_core/lib/src/engine/compatibility.dart new file mode 100644 index 0000000..4025866 --- /dev/null +++ b/packages/web_shell_core/lib/src/engine/compatibility.dart @@ -0,0 +1,226 @@ +part of '../../core_app.dart'; + +/// Android WebView 的渲染模式。 +enum AndroidRenderMode { + /// 使用纹理层渲染模式。 + texture, + + /// 使用混合合成渲染模式。 + hybrid + ; + + /// 当前模式是否使用混合合成。 + bool get usesHybridComposition => this == AndroidRenderMode.hybrid; + + /// 返回当前渲染模式对应的稳定日志标识。 + String get logName => switch (this) { + AndroidRenderMode.texture => 'texture-layer', + AndroidRenderMode.hybrid => 'hybrid-composition', + }; + + /// 返回当前渲染模式面向用户的显示名称。 + String get displayName => switch (this) { + AndroidRenderMode.texture => '标准模式', + AndroidRenderMode.hybrid => '兼容模式', + }; +} + +/// Android 设备及系统 WebView 的运行信息。 +class AndroidWebViewInfo { + /// 创建 Android 设备与 WebView 元数据快照。 + AndroidWebViewInfo({ + required this.sdkInt, + required this.manufacturer, + required this.brand, + required this.model, + this.webViewDataDirectorySuffix, + this.webViewPackageName, + this.webViewVersionName, + this.webViewLongVersionCode, + }) : webViewMajorVersion = _parseWebViewMajorVersion(webViewVersionName); + + /// 根据平台通道返回的原始数据创建实例。 + factory AndroidWebViewInfo.fromMap(Map raw) { + return AndroidWebViewInfo( + sdkInt: _readInt(raw['sdkInt']), + manufacturer: _readString(raw['manufacturer']), + brand: _readString(raw['brand']), + model: _readString(raw['model']), + webViewDataDirectorySuffix: _readNullableString( + raw['webViewDataDirectorySuffix'], + ), + webViewPackageName: _readNullableString(raw['webViewPackageName']), + webViewVersionName: _readNullableString(raw['webViewVersionName']), + webViewLongVersionCode: _readNullableInt(raw['webViewLongVersionCode']), + ); + } + + /// 当前设备的 Android SDK 版本。 + final int sdkInt; + + /// 设备制造商。 + final String manufacturer; + + /// 设备品牌。 + final String brand; + + /// 设备型号。 + final String model; + + /// 当前 WebView 数据目录后缀;有值时返回。 + final String? webViewDataDirectorySuffix; + + /// 已安装 WebView 的包名;有值时返回。 + final String? webViewPackageName; + + /// 已安装 WebView 的版本名;有值时返回。 + final String? webViewVersionName; + + /// 已安装 WebView 的长版本号;有值时返回。 + final int? webViewLongVersionCode; + + /// 从 [webViewVersionName] 中解析出的 WebView 主版本号。 + final int? webViewMajorVersion; + + /// 当前 WebView 是否应按旧版本处理。 + bool get isLegacyWebView => + webViewMajorVersion != null && + webViewMajorVersion! <= _legacyWebViewMajorVersionThreshold; + + /// 当前设备是否命中已知问题机型 `F136A`。 + bool get isF136A => model.toUpperCase() == 'F136A'; + + /// 返回用于诊断的精简日志摘要。 + String get summary { + final deviceSummary = [ + manufacturer, + model, + ].where((part) => part.isNotEmpty).join(' '); + final parts = [ + 'sdk=$sdkInt', + if (manufacturer.isNotEmpty || model.isNotEmpty) 'device=$deviceSummary', + if (webViewPackageName case final String packageName + when packageName.isNotEmpty) + 'webViewPackage=$packageName', + if (webViewVersionName case final String versionName + when versionName.isNotEmpty) + 'webViewVersion=$versionName', + if (webViewDataDirectorySuffix case final String suffix + when suffix.isNotEmpty) + 'webViewSuffix=$suffix', + ]; + return parts.join(', '); + } + + static int _readInt(Object? value, {int fallback = 0}) { + if (value is int) { + return value; + } + return int.tryParse((value ?? '').toString()) ?? fallback; + } + + static int? _readNullableInt(Object? value) { + if (value == null) { + return null; + } + if (value is int) { + return value; + } + return int.tryParse(value.toString()); + } + + static String _readString(Object? value) { + return (value ?? '').toString().trim(); + } + + static String? _readNullableString(Object? value) { + final normalized = _readString(value); + return normalized.isEmpty ? null : normalized; + } +} + +/// Android WebView 的兼容策略。 +class AndroidCompatibilityPlan { + /// 创建 WebView 兼容策略。 + const AndroidCompatibilityPlan({ + required this.renderModes, + required this.useWideViewPort, + required this.suggestWebViewUpdate, + required this.prefersAggressiveRecovery, + }); + + /// 创建设备信息未知时使用的默认兜底策略。 + factory AndroidCompatibilityPlan.fallback() { + return const AndroidCompatibilityPlan( + renderModes: kDebugMode + ? [ + AndroidRenderMode.texture, + AndroidRenderMode.hybrid, + ] + : [ + AndroidRenderMode.hybrid, + AndroidRenderMode.texture, + ], + useWideViewPort: true, + suggestWebViewUpdate: false, + prefersAggressiveRecovery: true, + ); + } + + /// 根据当前 Android 设备与 WebView 信息构建策略。 + factory AndroidCompatibilityPlan.fromInfo(AndroidWebViewInfo? info) { + if (info == null) { + return AndroidCompatibilityPlan.fallback(); + } + + final legacyAndroid = info.sdkInt <= 28; + final legacyWebView = info.isLegacyWebView; + final preferHybridFirst = info.isF136A || legacyAndroid || legacyWebView; + + return AndroidCompatibilityPlan( + renderModes: preferHybridFirst + ? const [ + AndroidRenderMode.hybrid, + AndroidRenderMode.texture, + ] + : const [ + AndroidRenderMode.texture, + AndroidRenderMode.hybrid, + ], + useWideViewPort: true, + suggestWebViewUpdate: info.isF136A || legacyWebView || info.sdkInt <= 26, + prefersAggressiveRecovery: info.isF136A || legacyAndroid || legacyWebView, + ); + } + + /// 按优先级排序的候选渲染模式。 + final List renderModes; + + /// 是否启用宽视口支持。 + final bool useWideViewPort; + + /// 界面是否需要提示用户更新系统 WebView。 + final bool suggestWebViewUpdate; + + /// 恢复流程是否使用更激进的策略。 + final bool prefersAggressiveRecovery; + + /// 返回精简的诊断描述。 + String describe() { + return [ + 'modes=${renderModes.map((mode) => mode.logName).join(' -> ')}', + 'wideViewport=$useWideViewPort', + 'aggressiveRecovery=$prefersAggressiveRecovery', + ].join(', '); + } +} + +const int _legacyWebViewMajorVersionThreshold = 110; + +int? _parseWebViewMajorVersion(String? versionName) { + if (versionName == null || versionName.isEmpty) { + return null; + } + final candidate = versionName.split('.').first.trim(); + return int.tryParse(candidate); +} diff --git a/packages/web_shell_core/lib/src/engine/recovery.dart b/packages/web_shell_core/lib/src/engine/recovery.dart new file mode 100644 index 0000000..e320620 --- /dev/null +++ b/packages/web_shell_core/lib/src/engine/recovery.dart @@ -0,0 +1,31 @@ +part of '../../core_app.dart'; + +const Duration _releaseStartupWatchdogDuration = Duration(seconds: 15); +const Duration _debugStartupWatchdogDuration = Duration(seconds: 15); + +// ── 错误提示映射 ── + +String _friendlyErrorTitle(WebResourceError error) { + return switch (error.errorType) { + WebResourceErrorType.timeout => '请求超时', + WebResourceErrorType.hostLookup || + WebResourceErrorType.connect || + WebResourceErrorType.io => '网络连接失败', + WebResourceErrorType.failedSslHandshake => '安全连接失败', + _ => '页面加载失败', + }; +} + +String _friendlyErrorMessage(WebResourceError error) { + return switch (error.errorType) { + WebResourceErrorType.timeout => '当前网络较慢,请稍后重新加载。', + WebResourceErrorType.hostLookup || + WebResourceErrorType.connect || + WebResourceErrorType.io => '没有成功连接到服务器,请检查网络后重试。', + WebResourceErrorType.failedSslHandshake => '当前站点证书校验失败,请稍后再试。', + _ => + error.description.trim().isEmpty + ? '请稍后重新加载页面。' + : error.description.trim(), + }; +} diff --git a/packages/web_shell_core/lib/src/services/media_service.dart b/packages/web_shell_core/lib/src/services/media_service.dart new file mode 100644 index 0000000..9b27e64 --- /dev/null +++ b/packages/web_shell_core/lib/src/services/media_service.dart @@ -0,0 +1,186 @@ +part of '../../core_app.dart'; + +const double _pickedImageMaxWidth = 1600; +const double _pickedImageMaxHeight = 1600; +const int _pickedImageQuality = 85; + +Future _pickCameraImage( + ImagePicker imagePicker, { + bool showPermissionAlert = false, + WebViewController? controller, +}) async { + final cameraStatus = await Permission.camera.request(); + if (!cameraStatus.isGranted) { + if (showPermissionAlert && controller != null) { + await _showWebAlert(controller, '请先在系统设置中允许相机权限'); + } + return null; + } + + try { + return await imagePicker.pickImage( + source: ImageSource.camera, + imageQuality: _pickedImageQuality, + maxWidth: _pickedImageMaxWidth, + maxHeight: _pickedImageMaxHeight, + ); + } on Object catch (error, stackTrace) { + debugPrint('调用相机拍照失败:$error\n$stackTrace'); + if (showPermissionAlert && controller != null) { + await _showWebAlert(controller, '无法打开系统相机,请稍后重试'); + } + return null; + } +} + +Future>> _serializeXFiles( + List files, { + required String responseType, +}) async { + final includeBase64 = responseType == 'base64' || responseType == 'dataUrl'; + final includeDataUrl = responseType == 'dataUrl'; + + final serialized = >[]; + for (final file in files) { + String? base64Value; + final mimeType = _guessMimeType(file.name); + + if (includeBase64 || includeDataUrl) { + base64Value = base64Encode(await file.readAsBytes()); + } + + serialized.add({ + 'name': file.name, + 'uri': Uri.file(file.path).toString(), + 'mimeType': mimeType, + 'size': await file.length(), + if (responseType == 'base64') 'base64': base64Value, + if (includeDataUrl && base64Value != null) + 'dataUrl': 'data:$mimeType;base64,$base64Value', + }); + } + return serialized; +} + +Future>> _serializePlatformFiles( + List files, { + required String responseType, +}) async { + final includeBase64 = responseType == 'base64' || responseType == 'dataUrl'; + final includeDataUrl = responseType == 'dataUrl'; + + final serialized = >[]; + for (final file in files) { + final mimeType = _guessMimeType(file.name); + String? base64Value; + + if (includeBase64 || includeDataUrl) { + final List? bytes = + file.bytes ?? + (file.path == null ? null : await XFile(file.path!).readAsBytes()); + if (bytes != null) { + base64Value = base64Encode(bytes); + } + } + + serialized.add({ + 'name': file.name, + 'uri': file.path == null ? null : Uri.file(file.path!).toString(), + 'mimeType': mimeType, + 'size': file.size, + if (responseType == 'base64') 'base64': base64Value, + if (includeDataUrl && base64Value != null) + 'dataUrl': 'data:$mimeType;base64,$base64Value', + }); + } + return serialized; +} + +List _xFilesToUriStrings(List files) { + return files.map((file) => Uri.file(file.path).toString()).toList(); +} + +bool _acceptsImages(List acceptTypes) { + return acceptTypes + .map((type) => type.trim()) + .where((type) => type.isNotEmpty) + .any(_isImageAcceptType); +} + +bool _acceptsOnlyImages(List acceptTypes) { + final normalizedTypes = acceptTypes + .map((type) => type.trim()) + .where((type) => type.isNotEmpty) + .toList(); + if (normalizedTypes.isEmpty) { + return false; + } + return normalizedTypes.every(_isImageAcceptType); +} + +bool _isImageAcceptType(String acceptType) { + final value = acceptType.toLowerCase(); + return value.startsWith('image/') || + const { + '.png', + '.jpg', + '.jpeg', + '.webp', + '.gif', + '.bmp', + '.heic', + '.heif', + }.contains(value); +} + +String _guessMimeType(String fileName) { + final lower = fileName.toLowerCase(); + if (lower.endsWith('.png')) { + return 'image/png'; + } + if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) { + return 'image/jpeg'; + } + if (lower.endsWith('.webp')) { + return 'image/webp'; + } + if (lower.endsWith('.gif')) { + return 'image/gif'; + } + if (lower.endsWith('.bmp')) { + return 'image/bmp'; + } + if (lower.endsWith('.heic')) { + return 'image/heic'; + } + if (lower.endsWith('.heif')) { + return 'image/heif'; + } + if (lower.endsWith('.pdf')) { + return 'application/pdf'; + } + if (lower.endsWith('.txt')) { + return 'text/plain'; + } + if (lower.endsWith('.apk')) { + return 'application/vnd.android.package-archive'; + } + return 'application/octet-stream'; +} + +Future _showWebAlert(WebViewController controller, String message) async { + try { + await controller.runJavaScript('window.alert(${jsonEncode(message)});'); + } on Object catch (error, stackTrace) { + debugPrint('展示网页弹窗失败:$error\n$stackTrace'); + } +} + +bool _boolValue(Object? value, {bool defaultValue = false}) { + return switch (value) { + final bool boolValue => boolValue, + final String stringValue => stringValue.toLowerCase() == 'true', + final int intValue => intValue != 0, + _ => defaultValue, + }; +} diff --git a/packages/web_shell_core/lib/src/services/navigation_service.dart b/packages/web_shell_core/lib/src/services/navigation_service.dart new file mode 100644 index 0000000..412505b --- /dev/null +++ b/packages/web_shell_core/lib/src/services/navigation_service.dart @@ -0,0 +1,22 @@ +part of '../../core_app.dart'; + +bool _shouldStayInWebView(Uri uri) { + return { + 'http', + 'https', + 'about', + 'data', + 'javascript', + 'file', + 'blob', + }.contains(uri.scheme.toLowerCase()); +} + +Future _openExternalUri(Uri uri) async { + try { + return await launchUrl(uri, mode: LaunchMode.externalApplication); + } on Object catch (error, stackTrace) { + debugPrint('外部打开 URI 失败:$error\n$stackTrace'); + return false; + } +} diff --git a/packages/web_shell_core/lib/src/services/permission_service.dart b/packages/web_shell_core/lib/src/services/permission_service.dart new file mode 100644 index 0000000..f13b39c --- /dev/null +++ b/packages/web_shell_core/lib/src/services/permission_service.dart @@ -0,0 +1,13 @@ +part of '../../core_app.dart'; + +Permission? _permissionForType(String type) { + return switch (type.toLowerCase()) { + 'camera' => Permission.camera, + 'microphone' || 'audio' => Permission.microphone, + 'location' => Permission.location, + 'photos' || 'images' => Permission.photos, + 'videos' => Permission.videos, + 'storage' => Permission.storage, + _ => null, + }; +} diff --git a/packages/web_shell_core/lib/src/testing/test_hooks.dart b/packages/web_shell_core/lib/src/testing/test_hooks.dart new file mode 100644 index 0000000..93c20f2 --- /dev/null +++ b/packages/web_shell_core/lib/src/testing/test_hooks.dart @@ -0,0 +1,157 @@ +part of '../../core_app.dart'; + +/// `web_shell_core` 内部逻辑的测试入口。 +@visibleForTesting +const shellCoreTestHooks = ShellCoreTestHooks(); + +/// 对私有实现的测试封装,避免把内部细节暴露成正式 API。 +@visibleForTesting +class ShellCoreTestHooks { + /// 创建 `web_shell_core` 的测试钩子封装。 + const ShellCoreTestHooks(); + + /// 返回当前初始地址字符串。 + String get initialUrl => _initialUrl; + + /// 返回当前初始 URI。 + Uri get initialUri => _initialUri; + + /// 为测试初始化全局壳环境与初始地址。 + void initializeEnvironment(ShellEnvironment environment) { + _env = environment; + _initializeUrls(); + } + + /// 通过与运行时一致的代码路径进入沉浸式模式。 + Future enterImmersiveMode() => _enterImmersiveMode(); + + /// 判断哪些平台支持内嵌 WebView。 + bool supportsEmbeddedWebView({ + bool isWeb = false, + TargetPlatform? platform, + }) { + return _supportsEmbeddedWebView(isWeb: isWeb, platform: platform); + } + + /// 判断给定地址是否应视为网络地址。 + bool isNetworkUrl(String? url) => _isNetworkUrl(url); + + /// 将 URI 归一化为可比较的字符串形式。 + String normalizeComparableUri(Uri uri) => _normalizeComparableUri(uri); + + /// 返回指定 URI 协议对应的默认端口。 + int defaultPortForScheme(String scheme) => _defaultPortForScheme(scheme); + + /// 归一化路径,供 URI 比较使用。 + String normalizeComparablePath(String path) => _normalizeComparablePath(path); + + /// 返回桥接初始化脚本。 + String buildAppShellBridgeScript() => _buildAppShellBridgeScript(); + + /// 返回旧相机兼容脚本。 + String get legacyCameraCompatScript => _legacyCameraCompatScript; + + /// 向当前页面注入桥接脚本。 + Future injectAppShellBridge(WebViewController controller, String url) { + return _injectAppShellBridge(controller, url); + } + + /// 向当前页面发送桥接响应。 + Future sendBridgeResponse( + WebViewController controller, { + required String requestId, + required bool success, + Object? data, + String? error, + }) { + return _sendBridgeResponse( + controller, + requestId: requestId, + success: success, + data: data, + error: error, + ); + } + + /// 将 WebView 资源错误映射为面向用户的标题。 + String friendlyErrorTitle(WebResourceError error) { + return _friendlyErrorTitle(error); + } + + /// 将 WebView 资源错误映射为面向用户的提示文案。 + String friendlyErrorMessage(WebResourceError error) { + return _friendlyErrorMessage(error); + } + + /// 将选中的图片文件序列化为桥接负载格式。 + Future>> serializeXFiles( + List files, { + required String responseType, + }) { + return _serializeXFiles(files, responseType: responseType); + } + + /// 将平台文件序列化为桥接负载格式。 + Future>> serializePlatformFiles( + List files, { + required String responseType, + }) { + return _serializePlatformFiles(files, responseType: responseType); + } + + /// 执行桥接与文件选择器共用的相机取图流程。 + Future pickCameraImage( + ImagePicker imagePicker, { + bool showPermissionAlert = false, + WebViewController? controller, + }) { + return _pickCameraImage( + imagePicker, + showPermissionAlert: showPermissionAlert, + controller: controller, + ); + } + + /// 将选中的文件转换成 `file://` URI 字符串。 + List xFilesToUriStrings(List files) { + return _xFilesToUriStrings(files); + } + + /// 判断传入的 accept 列表是否允许图片。 + bool acceptsImages(List acceptTypes) => _acceptsImages(acceptTypes); + + /// 判断传入的 accept 列表是否只允许图片。 + bool acceptsOnlyImages(List acceptTypes) { + return _acceptsOnlyImages(acceptTypes); + } + + /// 判断单个 accept 标记是否应视为图片类型。 + bool isImageAcceptType(String acceptType) => _isImageAcceptType(acceptType); + + /// 根据文件名推断 MIME 类型。 + String guessMimeType(String fileName) => _guessMimeType(fileName); + + /// 在当前页面中显示网页弹窗。 + Future showWebAlert(WebViewController controller, String message) { + return _showWebAlert(controller, message); + } + + /// 按桥接语义将动态值转换为布尔值。 + bool boolValue(Object? value, {bool defaultValue = false}) { + return _boolValue(value, defaultValue: defaultValue); + } + + /// 判断 URI 是否应继续留在内嵌 WebView 中。 + bool shouldStayInWebView(Uri uri) => _shouldStayInWebView(uri); + + /// 使用外部应用打开指定 URI。 + Future openExternalUri(Uri uri) => _openExternalUri(uri); + + /// 将逻辑权限类型映射为 `permission_handler` 中的权限对象。 + Permission? permissionForType(String type) => _permissionForType(type); + + /// 从版本名中解析 WebView 主版本号。 + int? parseWebViewMajorVersion(String? versionName) { + return _parseWebViewMajorVersion(versionName); + } +} diff --git a/packages/web_shell_core/lib/src/ui/error_overlay.dart b/packages/web_shell_core/lib/src/ui/error_overlay.dart new file mode 100644 index 0000000..49c6a25 --- /dev/null +++ b/packages/web_shell_core/lib/src/ui/error_overlay.dart @@ -0,0 +1,92 @@ +part of '../../core_app.dart'; + +/// 页面加载失败时显示的全屏错误浮层。 +class ErrorOverlay extends StatelessWidget { + /// 创建当前页面的错误浮层。 + const ErrorOverlay({ + required this.title, + required this.message, + required this.currentUrl, + required this.onRetry, + super.key, + }); + + /// 展示给用户的错误标题。 + final String title; + + /// 展示给用户的错误说明。 + final String message; + + /// 当前失败状态对应的地址。 + final String currentUrl; + + /// 点击重试按钮后触发的回调。 + final Future Function() onRetry; + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: _shellBackgroundColor, + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 92, + height: 92, + decoration: BoxDecoration( + color: const Color(0xFFFEF2F2), + borderRadius: BorderRadius.circular(28), + ), + alignment: Alignment.center, + child: const Icon( + Icons.wifi_off_rounded, + size: 44, + color: Color(0xFFEF4444), + ), + ), + const SizedBox(height: 22), + Text( + title, + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w800, + color: _shellTextColor, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + Text( + message, + style: TextStyle( + fontSize: 14, + height: 1.6, + color: _shellMutedTextColor, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Text( + currentUrl, + style: const TextStyle(fontSize: 12, color: Color(0xFF94A3B8)), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: onRetry, + style: FilledButton.styleFrom( + backgroundColor: _shellAccentColor, + foregroundColor: Colors.black, + ), + icon: const Icon(Icons.refresh_rounded), + label: const Text('重新加载'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/packages/web_shell_core/lib/src/ui/launch_overlay.dart b/packages/web_shell_core/lib/src/ui/launch_overlay.dart new file mode 100644 index 0000000..2cd78b5 --- /dev/null +++ b/packages/web_shell_core/lib/src/ui/launch_overlay.dart @@ -0,0 +1,85 @@ +part of '../../core_app.dart'; + +/// 首屏启动阶段显示的加载浮层。 +class LaunchOverlay extends StatelessWidget { + /// 创建壳应用启动阶段的加载浮层。 + const LaunchOverlay({ + required this.progress, + required this.hasMeasuredProgress, + super.key, + }); + + /// 当前加载进度。 + final int progress; + + /// 当前进度值是否由 WebView 实际回传。 + final bool hasMeasuredProgress; + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: _shellBackgroundColor, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 88, + height: 88, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [const Color(0xFF66E59A), _shellAccentColor], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: _shellAccentColor.withValues(alpha: 0.3), + blurRadius: 20, + offset: const Offset(0, 6), + ), + ], + ), + alignment: Alignment.center, + child: const Icon( + Icons.language_rounded, + size: 42, + color: Colors.white, + ), + ), + const SizedBox(height: 24), + Text( + '页面加载中', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w800, + color: _shellTextColor, + ), + ), + const SizedBox(height: 10), + Text( + '正在为你启动 H5 页面', + style: TextStyle(color: _shellMutedTextColor, fontSize: 14), + ), + const SizedBox(height: 36), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 64), + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: LinearProgressIndicator( + minHeight: 6, + value: hasMeasuredProgress ? progress / 100 : null, + backgroundColor: const Color(0xFFE7F3EB), + valueColor: AlwaysStoppedAnimation( + _shellAccentColor, + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/web_shell_core/lib/src/ui/progress_bar.dart b/packages/web_shell_core/lib/src/ui/progress_bar.dart new file mode 100644 index 0000000..cb3cfc0 --- /dev/null +++ b/packages/web_shell_core/lib/src/ui/progress_bar.dart @@ -0,0 +1,27 @@ +part of '../../core_app.dart'; + +/// 顶部进度条组件。 +class TopProgressBar extends StatelessWidget { + /// 创建顶部进度条组件。 + const TopProgressBar({ + required this.progress, + required this.hasMeasuredProgress, + super.key, + }); + + /// 当前加载进度。 + final int progress; + + /// 当前进度值是否由 WebView 实际回传。 + final bool hasMeasuredProgress; + + @override + Widget build(BuildContext context) { + return LinearProgressIndicator( + minHeight: 3, + value: hasMeasuredProgress ? progress / 100 : null, + backgroundColor: Colors.white.withValues(alpha: 0.8), + valueColor: AlwaysStoppedAnimation(_shellAccentColor), + ); + } +} diff --git a/packages/web_shell_core/lib/src/ui/shell_app.dart b/packages/web_shell_core/lib/src/ui/shell_app.dart new file mode 100644 index 0000000..bbd5073 --- /dev/null +++ b/packages/web_shell_core/lib/src/ui/shell_app.dart @@ -0,0 +1,17 @@ +part of '../../core_app.dart'; + +/// `web_shell_core` 的根应用组件。 +class ShellApp extends StatelessWidget { + /// 创建壳应用根组件。 + const ShellApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + home: _supportsEmbeddedWebView() + ? const WebShellPage() + : const UnsupportedPlatformPage(), + ); + } +} diff --git a/packages/web_shell_core/lib/src/ui/shell_page.dart b/packages/web_shell_core/lib/src/ui/shell_page.dart new file mode 100644 index 0000000..1d3625b --- /dev/null +++ b/packages/web_shell_core/lib/src/ui/shell_page.dart @@ -0,0 +1,1061 @@ +part of '../../core_app.dart'; + +const MethodChannel _appShellDeviceChannel = MethodChannel('app_shell/device'); + +/// 承载核心 WebView 的主页面。 +class WebShellPage extends StatefulWidget { + /// 创建主壳页面。 + const WebShellPage({super.key}); + + @override + State createState() => _WebShellPageState(); +} + +// coverage:ignore-start +class _WebShellPageState extends State + with WidgetsBindingObserver { + final ImagePicker _imagePicker = ImagePicker(); + final WebViewCookieManager _cookieManager = WebViewCookieManager(); + + late WebViewController _controller; + late WebViewWidget _webViewWidget; + late Future _controllerSetupFuture; + late final Future _androidCompatibilityFuture; + AndroidWebViewInfo? _androidWebViewInfo; + AndroidCompatibilityPlan _androidCompatibilityPlan = + AndroidCompatibilityPlan.fallback(); + AndroidRenderMode? _configuredRenderMode; + int _webViewGeneration = 0; + int _renderModeIndex = 0; + + bool _hasTriggeredInitialLoad = false; + bool _hasAppliedCompatibilityPlan = false; + bool _isLoadingPage = false; + bool _hasBootstrapped = false; + bool _hasStartedRemoteMainFrame = false; + bool _hasMainFrameError = false; + bool _hasMeasuredProgress = false; + Timer? _startupWatchdogTimer; + int _progress = 0; + int _startupRetryCount = 0; + String _currentUrl = _initialUrl; + String _errorTitle = '页面加载失败'; + String _errorMessage = '请检查网络后重试。'; + String _lastWebViewEvent = '应用启动'; + + @override + void initState() { + super.initState(); + debugPrint('WebShell 初始化,初始地址=$_initialUrl'); + WidgetsBinding.instance.addObserver(this); + _androidCompatibilityFuture = _prepareAndroidCompatibility(); + _recreateWebView(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _hasTriggeredInitialLoad) { + return; + } + _hasTriggeredInitialLoad = true; + unawaited(_loadInitialPage()); + }); + } + + // ── 渲染模式索引 ── + + int get _safeRenderModeIndex { + if (_renderModeIndex < 0) { + return 0; + } + final maxIndex = _androidCompatibilityPlan.renderModes.length - 1; + if (_renderModeIndex > maxIndex) { + return maxIndex; + } + return _renderModeIndex; + } + + AndroidRenderMode get _activeRenderMode => + _androidCompatibilityPlan.renderModes[_safeRenderModeIndex]; + + Duration get _effectiveStartupWatchdogDuration { + if (defaultTargetPlatform != TargetPlatform.android) { + return _releaseStartupWatchdogDuration; + } + if (kDebugMode) { + return _debugStartupWatchdogDuration; + } + return _releaseStartupWatchdogDuration; + } + + // ── Android 兼容性 ── + + Future _prepareAndroidCompatibility() async { + if (defaultTargetPlatform != TargetPlatform.android) { + return; + } + + try { + final rawInfo = await _appShellDeviceChannel + .invokeMapMethod('getAndroidWebViewInfo'); + if (rawInfo != null) { + _androidWebViewInfo = AndroidWebViewInfo.fromMap(rawInfo); + } + } on Object catch (error, stackTrace) { + debugPrint('读取 Android WebView 信息失败:$error\n$stackTrace'); + } + + _androidCompatibilityPlan = AndroidCompatibilityPlan.fromInfo( + _androidWebViewInfo, + ); + _renderModeIndex = 0; + debugPrint( + 'WebShell Android WebView 信息:' + '${_androidWebViewInfo?.summary ?? '不可用'}', + ); + debugPrint( + 'WebShell 兼容策略:${_androidCompatibilityPlan.describe()}', + ); + } + + Future _ensureCompatibilityPlanApplied() async { + await _androidCompatibilityFuture; + if (!mounted || + _hasAppliedCompatibilityPlan || + defaultTargetPlatform != TargetPlatform.android) { + return; + } + + _hasAppliedCompatibilityPlan = true; + if (_configuredRenderMode == _activeRenderMode) { + debugPrint( + 'WebShell 兼容策略保持当前 WebView ' + '(${_activeRenderMode.logName})', + ); + return; + } + _recreateWebView(); + } + + bool _switchToNextRenderMode() { + final nextIndex = _safeRenderModeIndex + 1; + if (nextIndex >= _androidCompatibilityPlan.renderModes.length) { + return false; + } + _renderModeIndex = nextIndex; + debugPrint('WebShell 已切换渲染模式为 ${_activeRenderMode.logName}'); + return true; + } + + String _buildCompatibilityGuidance() { + if (defaultTargetPlatform != TargetPlatform.android) { + return ''; + } + final lines = [ + '当前渲染策略:${_activeRenderMode.displayName}', + if (_androidWebViewInfo?.model case final String model + when model.isNotEmpty) + '当前设备:$model', + if (_androidWebViewInfo?.webViewVersionName case final String version + when version.isNotEmpty) + '系统 WebView:$version', + if (_androidCompatibilityPlan.suggestWebViewUpdate) + '如果内嵌页面仍异常,建议更新 Android System WebView 或 Chrome。', + ]; + return lines.join('\n'); + } + + // ── WebView 创建 ── + + void _recreateWebView() { + const controllerParams = PlatformWebViewControllerCreationParams(); + final controller = WebViewController.fromPlatformCreationParams( + controllerParams, + ); + final generation = ++_webViewGeneration; + final renderMode = _activeRenderMode; + + _controller = controller; + _controllerSetupFuture = _configureController(controller, generation); + _configuredRenderMode = renderMode; + debugPrint( + 'WebShell 正在以 ${renderMode.logName} 重建 WebView #$generation', + ); + var widgetParams = PlatformWebViewWidgetCreationParams( + key: ValueKey('webview-$generation-${renderMode.logName}'), + controller: controller.platform, + ); + + if (defaultTargetPlatform == TargetPlatform.android) { + widgetParams = _buildAndroidWidgetParams(widgetParams, renderMode); + } + + _webViewWidget = WebViewWidget.fromPlatformCreationParams( + params: widgetParams, + ); + } + + bool _isActiveWebViewGeneration(int generation) { + return generation == _webViewGeneration; + } + + PlatformWebViewWidgetCreationParams _buildAndroidWidgetParams( + PlatformWebViewWidgetCreationParams widgetParams, + AndroidRenderMode renderMode, + ) { + const createParams = AndroidWebViewWidgetCreationParams + .fromPlatformWebViewWidgetCreationParams; + return createParams( + widgetParams, + displayWithHybridComposition: renderMode.usesHybridComposition, + ); + } + + Future _configureController( + WebViewController controller, + int generation, + ) async { + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.enableZoom(false); + await controller.setBackgroundColor(Colors.white); + await controller.addJavaScriptChannel( + _appShellBridgeChannel, + onMessageReceived: (message) { + if (!_isActiveWebViewGeneration(generation)) { + return; + } + unawaited(_handleBridgeMessage(message.message)); + }, + ); + await controller.setNavigationDelegate( + _buildNavigationDelegate(generation), + ); + + if (controller.platform is AndroidWebViewController) { + final androidController = controller.platform as AndroidWebViewController; + + await AndroidWebViewController.enableDebugging( + kDebugMode && !_androidCompatibilityPlan.prefersAggressiveRecovery, + ); + await androidController.setMediaPlaybackRequiresUserGesture(false); + await androidController.setMixedContentMode(MixedContentMode.alwaysAllow); + await androidController.setOverScrollMode(WebViewOverScrollMode.never); + await androidController.setUseWideViewPort( + _androidCompatibilityPlan.useWideViewPort, + ); + await androidController.setTextZoom(100); + await androidController.setVerticalScrollBarEnabled(false); + await androidController.setHorizontalScrollBarEnabled(false); + await androidController.setOnShowFileSelector(_handleFileSelector); + await androidController.setOnPlatformPermissionRequest( + _handlePlatformPermissionRequest, + ); + await androidController.setGeolocationPermissionsPromptCallbacks( + onShowPrompt: _handleGeolocationPermissionRequest, + ); + await androidController.setOnConsoleMessage((message) { + debugPrint( + 'WebView 控制台 [${message.level.name}] ${message.message}', + ); + }); + } + } + + NavigationDelegate _buildNavigationDelegate(int generation) { + return NavigationDelegate( + onProgress: (progress) { + if (!_isActiveWebViewGeneration(generation)) { + return; + } + if (progress == 10 || + progress == 30 || + progress == 60 || + progress == 90) { + _recordWebViewEvent('加载进度 $progress%'); + } + if (!mounted) { + return; + } + setState(() { + _progress = progress; + if (_hasStartedRemoteMainFrame || progress >= 100) { + _hasMeasuredProgress = true; + } + }); + }, + onNavigationRequest: (request) async { + if (!_isActiveWebViewGeneration(generation)) { + return NavigationDecision.navigate; + } + return _handleNavigationRequest(request); + }, + onUrlChange: (change) { + if (!_isActiveWebViewGeneration(generation)) { + return; + } + final url = change.url; + if (url == null || url.isEmpty) { + return; + } + _recordWebViewEvent('地址变化:$url'); + if (!mounted) { + return; + } + setState(() { + _currentUrl = url; + }); + }, + onPageStarted: (url) { + if (!_isActiveWebViewGeneration(generation)) { + return; + } + if (!_isNetworkUrl(url)) { + debugPrint('WebView 忽略非网络页面开始事件:$url'); + return; + } + _hasStartedRemoteMainFrame = true; + _cancelStartupWatchdog(); + _recordWebViewEvent('页面开始加载:$url'); + if (!mounted) { + return; + } + setState(() { + _currentUrl = url; + if (_hasMeasuredProgress) { + _progress = _progress < 30 ? 30 : _progress; + } + _isLoadingPage = true; + _hasBootstrapped = true; + _hasMainFrameError = false; + _errorTitle = '页面加载失败'; + _errorMessage = '请检查网络后重试。'; + }); + }, + onPageFinished: (url) { + if (!_isActiveWebViewGeneration(generation)) { + return; + } + if (!_isNetworkUrl(url)) { + debugPrint('WebView 忽略非网络页面完成事件:$url'); + return; + } + _recordWebViewEvent('页面加载完成:$url'); + _cancelStartupWatchdog(); + unawaited(_injectAppShellBridge(_controller, url)); + if (!mounted) { + return; + } + setState(() { + _currentUrl = url; + _hasMeasuredProgress = true; + _progress = 100; + _isLoadingPage = false; + _hasBootstrapped = true; + _hasMainFrameError = false; + _startupRetryCount = 0; + }); + }, + onHttpError: (error) { + if (!_isActiveWebViewGeneration(generation)) { + return; + } + final statusCode = error.response?.statusCode; + final requestUri = error.request?.uri; + if (!_shouldTreatHttpErrorAsMainFrame(error)) { + debugPrint( + '忽略子资源 HTTP 错误:' + '${statusCode ?? '未知'} ${requestUri ?? '未知'}', + ); + return; + } + _recordWebViewEvent( + 'HTTP 错误:${statusCode ?? '未知'} ${requestUri ?? _currentUrl}', + ); + _setMainFrameError( + title: statusCode == null ? '服务器响应异常' : '服务器异常 $statusCode', + message: statusCode == null + ? '页面返回异常,请稍后重试。' + : '页面返回了 $statusCode 状态码,请稍后重试。', + ); + }, + onWebResourceError: (error) { + if (!_isActiveWebViewGeneration(generation)) { + return; + } + _recordWebViewEvent( + '资源错误:code=${error.errorCode}, ' + 'type=${error.errorType}, ' + 'mainFrame=${error.isForMainFrame}, ' + 'url=${error.url}, ' + 'description=${error.description}', + ); + if (!mounted || error.isForMainFrame == false) { + return; + } + _setMainFrameError( + title: _friendlyErrorTitle(error), + message: _friendlyErrorMessage(error), + ); + }, + ); + } + + // ── 生命周期 ── + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + unawaited(_enterImmersiveMode()); + } + } + + @override + void dispose() { + _cancelStartupWatchdog(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + // ── 页面加载 ── + + Future _loadInitialPage() async { + if (!mounted) { + return; + } + await _ensureCompatibilityPlanApplied(); + if (!mounted) { + return; + } + debugPrint('WebShell 首帧已就绪,开始初始加载'); + await _startLoadSequence(rebuildWebView: false, resetRetryCount: true); + } + + Future _handleBackPressed() async { + if (await _controller.canGoBack()) { + await _controller.goBack(); + return; + } + await SystemNavigator.pop(); + } + + Future _handleNavigationRequest( + NavigationRequest request, + ) async { + final uri = Uri.tryParse(request.url); + if (uri == null || _shouldStayInWebView(uri)) { + return NavigationDecision.navigate; + } + + final opened = await _openExternalUri(uri); + if (!opened) { + await _showWebAlert(_controller, '无法打开外部应用:${uri.scheme}'); + } + return NavigationDecision.prevent; + } + + void _recordWebViewEvent(String event) { + _lastWebViewEvent = event; + debugPrint('WebView 事件:$event'); + } + + // ── 看门狗 ── + + void _armStartupWatchdog() { + _cancelStartupWatchdog(); + _startupWatchdogTimer = Timer(_effectiveStartupWatchdogDuration, () { + unawaited(_handleStartupTimeout()); + }); + } + + void _cancelStartupWatchdog() { + _startupWatchdogTimer?.cancel(); + _startupWatchdogTimer = null; + } + + Future _handleStartupTimeout() async { + if (!mounted || + !_isLoadingPage || + _hasMainFrameError || + _hasStartedRemoteMainFrame) { + return; + } + + final switchedRenderMode = _switchToNextRenderMode(); + final maxRetryCount = _androidCompatibilityPlan.prefersAggressiveRecovery + ? 2 + : 1; + + if (switchedRenderMode || _startupRetryCount < maxRetryCount) { + final nextRetryCount = _startupRetryCount + 1; + final recoveryAction = switchedRenderMode + ? '切换到${_activeRenderMode.displayName}' + : '深度清理 WebView 状态'; + _recordWebViewEvent('启动超时,$recoveryAction 并自动重试第 $nextRetryCount 次'); + setState(() { + _startupRetryCount = nextRetryCount; + _hasMeasuredProgress = false; + _progress = 0; + _isLoadingPage = true; + }); + await _recoverFromBrokenStartupState(deepReset: true); + await _startLoadSequence(rebuildWebView: true, resetRetryCount: false); + return; + } + + _setMainFrameError( + title: '页面启动超时', + message: [ + '${_effectiveStartupWatchdogDuration.inSeconds} 秒内没有完成页面加载。', + '最近事件:$_lastWebViewEvent', + _buildCompatibilityGuidance(), + ].where((line) => line.isNotEmpty).join('\n'), + ); + } + + void _setMainFrameError({required String title, required String message}) { + _cancelStartupWatchdog(); + if (!mounted) { + return; + } + setState(() { + _isLoadingPage = false; + _hasMainFrameError = true; + _hasMeasuredProgress = false; + _errorTitle = title; + _errorMessage = message; + _progress = 0; + }); + } + + Future _recoverFromBrokenStartupState({bool deepReset = false}) async { + final controllerSetupFuture = _controllerSetupFuture; + try { + await controllerSetupFuture; + } on Object catch (error, stackTrace) { + debugPrint('等待 WebView 控制器初始化失败:$error\n$stackTrace'); + } + + try { + await _controller.clearCache(); + } on Object catch (error, stackTrace) { + debugPrint('清理 WebView 缓存失败:$error\n$stackTrace'); + } + + try { + await _controller.clearLocalStorage(); + } on Object catch (error, stackTrace) { + debugPrint('清理 WebView 本地存储失败:$error\n$stackTrace'); + } + + try { + await _cookieManager.clearCookies(); + } on Object catch (error, stackTrace) { + debugPrint('清理 WebView Cookie 失败:$error\n$stackTrace'); + } + + if (!deepReset || defaultTargetPlatform != TargetPlatform.android) { + return; + } + + try { + await _appShellDeviceChannel.invokeMethod( + 'resetAndroidWebViewState', + ); + } on Object catch (error, stackTrace) { + debugPrint('重置 Android WebView 状态失败:$error\n$stackTrace'); + } + } + + bool _shouldTreatHttpErrorAsMainFrame(HttpResponseError error) { + final requestUri = error.request?.uri; + if (requestUri == null) { + return true; + } + if (!_isNetworkUrl(requestUri.toString())) { + return false; + } + final currentUri = Uri.tryParse(_currentUrl); + if (_isSameDocumentRequest(requestUri, currentUri)) { + return true; + } + if (!_hasBootstrapped && _isSameDocumentRequest(requestUri, _initialUri)) { + return true; + } + return false; + } + + bool _isSameDocumentRequest(Uri left, Uri? right) { + if (right == null) { + return false; + } + return _normalizeComparableUri(left) == _normalizeComparableUri(right); + } + + // ── Bridge 消息处理 ── + + Future _handleBridgeMessage(String rawMessage) async { + var requestId = ''; + + try { + final dynamic decoded = jsonDecode(rawMessage); + if (decoded is! Map) { + return; + } + + final message = Map.from(decoded); + requestId = (message['requestId'] ?? '').toString(); + final action = (message['action'] ?? '').toString(); + final payload = message['payload'] is Map + ? Map.from(message['payload'] as Map) + : {}; + + if (requestId.isEmpty || action.isEmpty) { + return; + } + + debugPrint('AppShell 桥接请求:action=$action payload=$payload'); + + late final Object? data; + switch (action) { + case 'pickImage': + data = await _pickImagesFromBridge( + source: ImageSource.gallery, + payload: payload, + ); + case 'captureImage': + data = await _pickImagesFromBridge( + source: ImageSource.camera, + payload: payload, + ); + case 'pickFile': + data = await _pickFilesFromBridge(payload); + case 'openExternal': + data = await _openExternalFromBridge(payload); + case 'requestPermissions': + data = await _requestPermissionsFromBridge(payload); + case 'reloadPage': + await _reloadPage(); + data = true; + case 'goBack': + data = await _goBackFromBridge(); + case 'closeApp': + await _sendBridgeResponse( + _controller, + requestId: requestId, + success: true, + ); + await SystemNavigator.pop(); + return; + default: + throw UnsupportedError('Unsupported AppShell action: $action'); + } + + await _sendBridgeResponse( + _controller, + requestId: requestId, + success: true, + data: data, + ); + } on Object catch (error, stackTrace) { + debugPrint('处理 AppShell 桥接请求失败:$error\n$stackTrace'); + if (requestId.isNotEmpty) { + await _sendBridgeResponse( + _controller, + requestId: requestId, + success: false, + error: error.toString(), + ); + } + } + } + + Future _pickImagesFromBridge({ + required ImageSource source, + required Map payload, + }) async { + final multiple = + source == ImageSource.gallery && _boolValue(payload['multiple']); + final responseType = (payload['responseType'] ?? 'dataUrl').toString(); + + var files = []; + if (source == ImageSource.camera) { + final file = await _pickCameraImage( + _imagePicker, + showPermissionAlert: true, + controller: _controller, + ); + if (file != null) { + files = [file]; + } + } else if (multiple) { + files = await _imagePicker.pickMultiImage( + imageQuality: _pickedImageQuality, + maxWidth: _pickedImageMaxWidth, + maxHeight: _pickedImageMaxHeight, + ); + } else { + final file = await _imagePicker.pickImage( + source: ImageSource.gallery, + imageQuality: _pickedImageQuality, + maxWidth: _pickedImageMaxWidth, + maxHeight: _pickedImageMaxHeight, + ); + if (file != null) { + files = [file]; + } + } + + final serialized = await _serializeXFiles( + files, + responseType: responseType, + ); + return multiple ? serialized : serialized.firstOrNull; + } + + Future _pickFilesFromBridge(Map payload) async { + final responseType = (payload['responseType'] ?? 'uri').toString(); + final includeBinary = responseType == 'dataUrl' || responseType == 'base64'; + + final result = await FilePicker.platform.pickFiles( + allowMultiple: _boolValue(payload['multiple']), + withData: includeBinary, + ); + + if (result == null) { + return _boolValue(payload['multiple']) ? >[] : null; + } + + final serialized = await _serializePlatformFiles( + result.files, + responseType: responseType, + ); + return _boolValue(payload['multiple']) + ? serialized + : serialized.firstOrNull; + } + + Future _openExternalFromBridge(Map payload) async { + final url = (payload['url'] ?? '').toString(); + final uri = Uri.tryParse(url); + if (uri == null) { + return false; + } + if (_isNetworkUrl(uri.toString())) { + return false; + } + return _openExternalUri(uri); + } + + Future> _requestPermissionsFromBridge( + Map payload, + ) async { + final types = (payload['types'] as List? ?? const []) + .map((type) => type.toString()) + .toList(); + + final permissions = { + for (final String type in types) + if (_permissionForType(type) case final Permission permission) + type: permission, + }; + + if (permissions.isEmpty) { + return {}; + } + + final statuses = await permissions.values.toSet().toList().request(); + + return { + for (final MapEntry entry in permissions.entries) + entry.key: (statuses[entry.value] ?? PermissionStatus.denied).name, + }; + } + + Future _goBackFromBridge() async { + if (await _controller.canGoBack()) { + await _controller.goBack(); + return true; + } + return false; + } + + // ── 文件选择器 ── + + Future> _handleFileSelector(FileSelectorParams params) async { + debugPrint( + 'WebView 文件选择: ' + 'accept=${params.acceptTypes}, ' + 'capture=${params.isCaptureEnabled}, ' + 'mode=${params.mode.name}', + ); + + if (params.mode == FileSelectorMode.save) { + return []; + } + + try { + final acceptsImgs = _acceptsImages(params.acceptTypes); + final imagesOnly = _acceptsOnlyImages(params.acceptTypes); + + if (params.isCaptureEnabled && acceptsImgs) { + final capturedImage = await _pickCameraImage(_imagePicker); + return _xFilesToUriStrings( + capturedImage == null ? const [] : [capturedImage], + ); + } + + if (imagesOnly) { + if (params.mode == FileSelectorMode.openMultiple) { + final images = await _imagePicker.pickMultiImage( + imageQuality: _pickedImageQuality, + maxWidth: _pickedImageMaxWidth, + maxHeight: _pickedImageMaxHeight, + ); + return _xFilesToUriStrings(images); + } + + final image = await _imagePicker.pickImage( + source: ImageSource.gallery, + imageQuality: _pickedImageQuality, + maxWidth: _pickedImageMaxWidth, + maxHeight: _pickedImageMaxHeight, + ); + return _xFilesToUriStrings( + image == null ? const [] : [image], + ); + } + + final result = await FilePicker.platform.pickFiles( + allowMultiple: params.mode == FileSelectorMode.openMultiple, + ); + + if (result == null) { + return []; + } + + return result.files + .map((file) => file.path) + .whereType() + .map((path) => Uri.file(path).toString()) + .toList(); + } on Object catch (error, stackTrace) { + debugPrint('处理文件选择失败:$error\n$stackTrace'); + return []; + } + } + + // ── 权限 ── + + Future _handlePlatformPermissionRequest( + PlatformWebViewPermissionRequest request, + ) async { + debugPrint( + 'WebView 权限请求:' + '${request.types.map((type) => type.name).join(', ')}', + ); + + final permissions = [ + if (request.types.contains(WebViewPermissionResourceType.camera)) + Permission.camera, + if (request.types.contains(WebViewPermissionResourceType.microphone)) + Permission.microphone, + ]; + + if (permissions.isEmpty) { + await request.deny(); + return; + } + + final statuses = await permissions.request(); + final allGranted = statuses.values.every((status) => status.isGranted); + + if (allGranted) { + await request.grant(); + return; + } + + await request.deny(); + } + + Future _handleGeolocationPermissionRequest( + GeolocationPermissionsRequestParams request, + ) async { + debugPrint('WebView 地理位置权限请求:${request.origin}'); + final status = await Permission.location.request(); + return GeolocationPermissionsResponse( + allow: status.isGranted, + retain: status.isGranted, + ); + } + + // ── 加载序列 ── + + Future _startLoadSequence({ + required bool rebuildWebView, + required bool resetRetryCount, + }) async { + _cancelStartupWatchdog(); + if (!mounted) { + return; + } + if (rebuildWebView) { + _recreateWebView(); + } + + final generation = _webViewGeneration; + final controllerSetupFuture = _controllerSetupFuture; + + _hasStartedRemoteMainFrame = false; + setState(() { + _isLoadingPage = true; + _hasMainFrameError = false; + _hasMeasuredProgress = false; + _progress = 0; + if (resetRetryCount) { + _startupRetryCount = 0; + } + _errorTitle = '页面加载失败'; + _errorMessage = '请检查网络后重试。'; + _currentUrl = _initialUrl; + }); + + await controllerSetupFuture; + if (!mounted || !_isActiveWebViewGeneration(generation)) { + return; + } + + await _waitForWebViewMount(generation); + + debugPrint( + 'WebShell 开始在 WebView #$generation 加载真实地址 ' + '(${_activeRenderMode.logName}): $_initialUrl', + ); + try { + _armStartupWatchdog(); + await _controller.loadRequest(_initialUri); + } on Object catch (error, stackTrace) { + debugPrint('初始地址加载失败:$error\n$stackTrace'); + _setMainFrameError( + title: '地址无法加载', + message: '无法打开 $_initialUrl,请检查地址格式或网络后重试。', + ); + } + } + + Future _waitForWebViewMount(int generation) async { + final isAndroidDebug = + kDebugMode && defaultTargetPlatform == TargetPlatform.android; + final usesHybridComposition = + defaultTargetPlatform == TargetPlatform.android && + _activeRenderMode.usesHybridComposition; + final framesToWait = isAndroidDebug + ? (usesHybridComposition ? 6 : 4) + : (usesHybridComposition ? 2 : 1); + final settleDelay = isAndroidDebug + ? (usesHybridComposition + ? const Duration(milliseconds: 250) + : const Duration(milliseconds: 140)) + : (usesHybridComposition + ? const Duration(milliseconds: 80) + : const Duration(milliseconds: 40)); + + for (var index = 0; index < framesToWait; index += 1) { + await SchedulerBinding.instance.endOfFrame; + if (!mounted || !_isActiveWebViewGeneration(generation)) { + return; + } + await Future.delayed(settleDelay); + if (!mounted || !_isActiveWebViewGeneration(generation)) { + return; + } + } + } + + Future _reloadPage() async { + if (!mounted) { + return; + } + await _ensureCompatibilityPlanApplied(); + if (!mounted) { + return; + } + if (!_hasBootstrapped) { + await _recoverFromBrokenStartupState(deepReset: true); + await _startLoadSequence(rebuildWebView: true, resetRetryCount: true); + return; + } + setState(() { + _hasStartedRemoteMainFrame = false; + _isLoadingPage = true; + _hasMainFrameError = false; + _hasMeasuredProgress = false; + _progress = 0; + _startupRetryCount = 0; + _errorTitle = '页面加载失败'; + _errorMessage = '请检查网络后重试。'; + }); + try { + _armStartupWatchdog(); + await _controller.reload(); + } on Object catch (error, stackTrace) { + debugPrint('重新加载当前页面失败:$error\n$stackTrace'); + await _startLoadSequence(rebuildWebView: true, resetRetryCount: true); + } + } + + // ── UI 构建 ── + + @override + Widget build(BuildContext context) { + final showProgressBar = + _isLoadingPage && (!_hasMeasuredProgress || _progress < 100); + final showLaunchOverlay = !_hasBootstrapped && !_hasMainFrameError; + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) { + if (!didPop) { + unawaited(_handleBackPressed()); + } + }, + child: Scaffold( + backgroundColor: _shellBackgroundColor, + body: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [_shellBackgroundColor, const Color(0xFFF4FBF7)], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: Stack( + children: [ + _webViewWidget, + if (showProgressBar) + Positioned( + top: 0, + left: 0, + right: 0, + child: TopProgressBar( + progress: _progress, + hasMeasuredProgress: _hasMeasuredProgress, + ), + ), + if (showLaunchOverlay) + LaunchOverlay( + progress: _progress, + hasMeasuredProgress: _hasMeasuredProgress, + ), + if (_hasMainFrameError) + ErrorOverlay( + title: _errorTitle, + message: _errorMessage, + currentUrl: _currentUrl, + onRetry: _reloadPage, + ), + ], + ), + ), + ), + ); + } +} +// coverage:ignore-end diff --git a/packages/web_shell_core/lib/src/ui/unsupported_platform_page.dart b/packages/web_shell_core/lib/src/ui/unsupported_platform_page.dart new file mode 100644 index 0000000..d7068f6 --- /dev/null +++ b/packages/web_shell_core/lib/src/ui/unsupported_platform_page.dart @@ -0,0 +1,57 @@ +part of '../../core_app.dart'; + +/// 非 Android 平台上的兜底提示页。 +class UnsupportedPlatformPage extends StatelessWidget { + /// 创建不支持平台提示页。 + const UnsupportedPlatformPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: _shellBackgroundColor, + body: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: const Color(0xFFE8F5E9), + borderRadius: BorderRadius.circular(24), + ), + alignment: Alignment.center, + child: Icon( + Icons.language_rounded, + size: 42, + color: _shellAccentColor, + ), + ), + const SizedBox(height: 20), + Text( + '当前平台不支持内嵌 WebView', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w800, + color: _shellTextColor, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 14), + Text( + '当前项目仅支持 Android 平板运行。\n$_initialUrl', + textAlign: TextAlign.center, + style: TextStyle( + height: 1.6, + color: _shellMutedTextColor, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/packages/web_shell_core/lib/web_shell_core.dart b/packages/web_shell_core/lib/web_shell_core.dart new file mode 100644 index 0000000..d236ff7 --- /dev/null +++ b/packages/web_shell_core/lib/web_shell_core.dart @@ -0,0 +1 @@ +export 'core_app.dart' hide ShellCoreTestHooks, shellCoreTestHooks; diff --git a/packages/web_shell_core/pubspec.yaml b/packages/web_shell_core/pubspec.yaml new file mode 100644 index 0000000..03417c9 --- /dev/null +++ b/packages/web_shell_core/pubspec.yaml @@ -0,0 +1,34 @@ +name: web_shell_core +description: "Android 平板专用 H5 壳核心库,提供 WebView 引擎、JS Bridge 和宿主服务。" +version: 0.0.1 +homepage: + +environment: + sdk: ^3.11.0 + flutter: '>=3.3.0' + +dependencies: + file_picker: ^10.3.10 + flutter: + sdk: flutter + image_picker: ^1.2.1 + permission_handler: ^12.0.1 + plugin_platform_interface: ^2.0.2 + url_launcher: ^6.3.2 + webview_flutter: ^4.13.1 + webview_flutter_android: ^4.10.13 + +dev_dependencies: + flutter_test: + sdk: flutter + image_picker_platform_interface: ^2.11.1 + url_launcher_platform_interface: ^2.3.2 + very_good_analysis: ^10.2.0 + webview_flutter_platform_interface: ^2.14.0 + +flutter: + plugin: + platforms: + android: + package: com.yuanxuan.webshell.core.web_shell_core + pluginClass: WebShellCorePlugin diff --git a/packages/web_shell_core/test/web_shell_core_test.dart b/packages/web_shell_core/test/web_shell_core_test.dart new file mode 100644 index 0000000..646a912 --- /dev/null +++ b/packages/web_shell_core/test/web_shell_core_test.dart @@ -0,0 +1,1513 @@ +import 'dart:io'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +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: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'; +import 'package:webview_flutter/webview_flutter.dart'; +import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; + +const MethodChannel _platformChannel = SystemChannels.platform; +const _permissionChannel = MethodChannel( + 'flutter.baseflow.com/permissions/methods', +); + +const _testEnvironment = ShellEnvironment( + appName: '测试应用', + appKey: 'test_app', + accentColor: Color(0xFF3ED37B), + backgroundColor: Color(0xFFFFFFFF), + textColor: Color(0xFF1F2937), + mutedTextColor: Color(0xFF6B7280), + initialUrl: 'example.com/login', +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late _FakeWebViewPlatform fakeWebViewPlatform; + late _FakeImagePickerPlatform fakeImagePickerPlatform; + late _FakeUrlLauncherPlatform fakeUrlLauncherPlatform; + late List platformCalls; + late bool cameraPermissionGranted; + + setUp(() { + platformCalls = []; + cameraPermissionGranted = true; + fakeWebViewPlatform = _FakeWebViewPlatform(); + fakeImagePickerPlatform = _FakeImagePickerPlatform(); + fakeUrlLauncherPlatform = _FakeUrlLauncherPlatform(); + + WebViewPlatform.instance = fakeWebViewPlatform; + ImagePickerPlatform.instance = fakeImagePickerPlatform; + UrlLauncherPlatform.instance = fakeUrlLauncherPlatform; + shellCoreTestHooks.initializeEnvironment(_testEnvironment); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_platformChannel, (call) async { + platformCalls.add(call.method); + return null; + }); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_permissionChannel, (call) async { + if (call.method != 'requestPermissions') { + return null; + } + + final permissions = (call.arguments as List).cast(); + final statusValue = cameraPermissionGranted ? 1 : 0; + return { + for (final permission in permissions) permission: statusValue, + }; + }); + }); + + tearDown(() { + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_platformChannel, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_permissionChannel, null); + }); + + group('运行时与平台', () { + test('只支持 Android 内嵌 WebView', () { + expect( + shellCoreTestHooks.supportsEmbeddedWebView( + platform: TargetPlatform.android, + ), + isTrue, + ); + expect( + shellCoreTestHooks.supportsEmbeddedWebView( + platform: TargetPlatform.iOS, + ), + isFalse, + ); + expect( + shellCoreTestHooks.supportsEmbeddedWebView( + platform: TargetPlatform.macOS, + ), + isFalse, + ); + expect(shellCoreTestHooks.supportsEmbeddedWebView(isWeb: true), isFalse); + }); + + test('enterImmersiveMode 会透传到 SystemChrome', () async { + await shellCoreTestHooks.enterImmersiveMode(); + + expect(platformCalls, contains('SystemChrome.setEnabledSystemUIMode')); + }); + + testWidgets('runShellApp 会设置系统 UI 并启动应用', (tester) async { + try { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + + await runShellApp(_testEnvironment); + await tester.pump(); + + expect( + platformCalls, + contains('SystemChrome.setPreferredOrientations'), + ); + expect(platformCalls, contains('SystemChrome.setEnabledSystemUIMode')); + expect(platformCalls, contains('SystemChrome.setSystemUIOverlayStyle')); + expect(find.byType(ShellApp), findsOneWidget); + expect(find.byType(UnsupportedPlatformPage), findsOneWidget); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets('ShellApp 在非 Android 下展示兜底页', (tester) async { + try { + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + + await tester.pumpWidget(const ShellApp()); + + expect(find.byType(UnsupportedPlatformPage), findsOneWidget); + expect(find.text('当前平台不支持内嵌 WebView'), findsOneWidget); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets('ShellApp 在 Android 下展示 WebView 容器', (tester) async { + try { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + + await tester.pumpWidget(const ShellApp()); + await tester.pump(); + + expect( + find.byKey(const ValueKey('fake-webview')), + findsOneWidget, + ); + expect(fakeWebViewPlatform.createdControllers, isNotEmpty); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + }); + + group('URL 与兼容策略', () { + test('初始化 URL 时会补全 https 协议', () { + expect(shellCoreTestHooks.initialUrl, 'https://example.com/login'); + expect( + shellCoreTestHooks.initialUri, + Uri.parse('https://example.com/login'), + ); + }); + + test('初始化 URL 会处理空值、协议相对地址和非法地址', () { + shellCoreTestHooks.initializeEnvironment( + const ShellEnvironment( + appName: '空地址', + appKey: 'empty', + accentColor: Color(0xFF000000), + backgroundColor: Color(0xFFFFFFFF), + textColor: Color(0xFF000000), + mutedTextColor: Color(0xFF888888), + ), + ); + expect( + shellCoreTestHooks.initialUrl, + 'http://xszy.lzzneng.com/login.html', + ); + + shellCoreTestHooks.initializeEnvironment( + const ShellEnvironment( + appName: '协议相对', + appKey: 'protocol_relative', + accentColor: Color(0xFF000000), + backgroundColor: Color(0xFFFFFFFF), + textColor: Color(0xFF000000), + mutedTextColor: Color(0xFF888888), + initialUrl: '//example.org/index.html', + ), + ); + expect(shellCoreTestHooks.initialUrl, 'https://example.org/index.html'); + + shellCoreTestHooks.initializeEnvironment( + const ShellEnvironment( + appName: '非法地址', + appKey: 'invalid', + accentColor: Color(0xFF000000), + backgroundColor: Color(0xFFFFFFFF), + textColor: Color(0xFF000000), + mutedTextColor: Color(0xFF888888), + initialUrl: '/relative-path', + ), + ); + expect( + shellCoreTestHooks.initialUrl, + 'http://xszy.lzzneng.com/login.html', + ); + + shellCoreTestHooks.initializeEnvironment(_testEnvironment); + }); + + test('网络 URL 与 URI 归一化逻辑正确', () { + expect(shellCoreTestHooks.isNetworkUrl('https://example.com'), isTrue); + expect(shellCoreTestHooks.isNetworkUrl('http://example.com'), isTrue); + expect(shellCoreTestHooks.isNetworkUrl('file:///tmp/demo.txt'), isFalse); + expect(shellCoreTestHooks.defaultPortForScheme('https'), 443); + expect(shellCoreTestHooks.defaultPortForScheme('custom'), -1); + expect(shellCoreTestHooks.normalizeComparablePath(''), '/'); + expect(shellCoreTestHooks.normalizeComparablePath('/demo/'), '/demo'); + expect( + shellCoreTestHooks.normalizeComparableUri( + Uri.parse('https://Example.com/demo/?a=1'), + ), + 'https://example.com:443/demo?a=1', + ); + }); + + test('AndroidWebViewInfo 能从 map 构建并输出摘要', () { + final info = AndroidWebViewInfo.fromMap({ + 'sdkInt': '28', + 'manufacturer': 'Yuanxuan', + 'brand': 'YX', + 'model': 'F136A', + 'webViewDataDirectorySuffix': 'shell', + 'webViewPackageName': 'com.google.android.webview', + 'webViewVersionName': '109.0.0', + 'webViewLongVersionCode': '123', + }); + + expect(info.sdkInt, 28); + expect(info.manufacturer, 'Yuanxuan'); + expect(info.brand, 'YX'); + expect(info.model, 'F136A'); + expect(info.webViewDataDirectorySuffix, 'shell'); + expect(info.webViewPackageName, 'com.google.android.webview'); + expect(info.webViewVersionName, '109.0.0'); + expect(info.webViewLongVersionCode, 123); + expect(info.webViewMajorVersion, 109); + expect(info.isLegacyWebView, isTrue); + expect(info.isF136A, isTrue); + expect(info.summary, contains('sdk=28')); + expect(info.summary, contains('device=Yuanxuan F136A')); + expect(info.summary, contains('webViewVersion=109.0.0')); + }); + + test('AndroidCompatibilityPlan 会根据设备信息产出恢复策略', () { + final info = AndroidWebViewInfo( + sdkInt: 28, + manufacturer: 'YX', + brand: 'YX', + model: 'F136A', + webViewVersionName: '109.0.1', + ); + + final fallbackPlan = AndroidCompatibilityPlan.fallback(); + final plan = AndroidCompatibilityPlan.fromInfo(info); + final nullInfoPlan = AndroidCompatibilityPlan.fromInfo(null); + + expect(fallbackPlan.renderModes, isNotEmpty); + expect(nullInfoPlan.renderModes, isNotEmpty); + expect(plan.renderModes.first, AndroidRenderMode.hybrid); + expect(AndroidRenderMode.texture.displayName, '标准模式'); + expect(AndroidRenderMode.hybrid.displayName, '兼容模式'); + expect(plan.useWideViewPort, isTrue); + expect(plan.suggestWebViewUpdate, isTrue); + expect(plan.prefersAggressiveRecovery, isTrue); + expect(plan.describe(), contains('modes=')); + expect(plan.describe(), contains('aggressiveRecovery=true')); + }); + + test('WebView 主版本解析兼容空值和正常值', () { + expect(shellCoreTestHooks.parseWebViewMajorVersion(null), isNull); + expect(shellCoreTestHooks.parseWebViewMajorVersion(''), isNull); + expect( + shellCoreTestHooks.parseWebViewMajorVersion('122.0.6261.86'), + 122, + ); + }); + }); + + group('Bridge 与错误映射', () { + test('bridge 脚本包含主要能力与兼容层', () { + final script = shellCoreTestHooks.buildAppShellBridgeScript(); + + expect(script, contains('window.AppShell')); + expect(script, contains('pickImage')); + expect(script, contains('captureImage')); + expect(script, contains('pickFile')); + expect(script, contains('requestPermissions')); + expect(script, contains(shellCoreTestHooks.legacyCameraCompatScript)); + }); + + test('bridge 注入、响应与 alert 都会调用 JS', () async { + final platformController = _FakePlatformWebViewController( + const PlatformWebViewControllerCreationParams(), + ); + final controller = WebViewController.fromPlatform(platformController); + + await shellCoreTestHooks.injectAppShellBridge( + controller, + 'https://a.com', + ); + await shellCoreTestHooks.sendBridgeResponse( + controller, + requestId: 'req-1', + success: true, + data: {'ok': true}, + ); + await shellCoreTestHooks.showWebAlert(controller, 'hello'); + + expect(platformController.javaScriptCalls, hasLength(3)); + expect( + platformController.javaScriptCalls.first, + contains('window.AppShell'), + ); + expect( + platformController.javaScriptCalls[1], + contains('window.__appShellReceiveResponse'), + ); + expect(platformController.javaScriptCalls.last, contains('window.alert')); + }); + + test('bridge 注入和 alert 的异常会被吞掉', () async { + final platformController = _FakePlatformWebViewController( + const PlatformWebViewControllerCreationParams(), + )..throwOnRunJavaScript = true; + final controller = WebViewController.fromPlatform(platformController); + + await shellCoreTestHooks.injectAppShellBridge( + controller, + 'https://a.com', + ); + await shellCoreTestHooks.sendBridgeResponse( + controller, + requestId: 'req-2', + success: false, + error: 'boom', + ); + await shellCoreTestHooks.showWebAlert(controller, 'hello'); + + expect(platformController.javaScriptCalls, isEmpty); + }); + + test('错误标题与文案映射正确', () { + const timeoutError = WebResourceError( + errorCode: -1, + description: 'timeout', + errorType: WebResourceErrorType.timeout, + ); + const connectError = WebResourceError( + errorCode: -2, + description: 'connect', + errorType: WebResourceErrorType.connect, + ); + const sslError = WebResourceError( + errorCode: -3, + description: 'ssl', + errorType: WebResourceErrorType.failedSslHandshake, + ); + const customError = WebResourceError( + errorCode: -4, + description: 'custom error', + errorType: WebResourceErrorType.unknown, + ); + + expect(shellCoreTestHooks.friendlyErrorTitle(timeoutError), '请求超时'); + expect(shellCoreTestHooks.friendlyErrorTitle(connectError), '网络连接失败'); + expect(shellCoreTestHooks.friendlyErrorTitle(sslError), '安全连接失败'); + expect(shellCoreTestHooks.friendlyErrorTitle(customError), '页面加载失败'); + + expect( + shellCoreTestHooks.friendlyErrorMessage(timeoutError), + '当前网络较慢,请稍后重新加载。', + ); + expect( + shellCoreTestHooks.friendlyErrorMessage(connectError), + '没有成功连接到服务器,请检查网络后重试。', + ); + expect( + shellCoreTestHooks.friendlyErrorMessage(sslError), + '当前站点证书校验失败,请稍后再试。', + ); + expect( + shellCoreTestHooks.friendlyErrorMessage(customError), + 'custom error', + ); + }); + }); + + group('媒体与权限工具', () { + test('序列化 XFile 与 PlatformFile', () async { + final tempDirectory = await Directory.systemTemp.createTemp('web-shell'); + final file = File('${tempDirectory.path}/demo.txt') + ..writeAsStringSync('hello'); + final binaryFile = File('${tempDirectory.path}/demo.bin') + ..writeAsBytesSync([4, 5, 6]); + addTearDown(() async { + if (tempDirectory.existsSync()) { + await tempDirectory.delete(recursive: true); + } + }); + + final xFile = XFile(file.path, name: 'demo.txt'); + final serializedXFiles = await shellCoreTestHooks.serializeXFiles( + [xFile], + responseType: 'dataUrl', + ); + final serializedPlatformFiles = await shellCoreTestHooks + .serializePlatformFiles( + [ + PlatformFile( + name: 'demo.apk', + size: 3, + bytes: Uint8List.fromList([1, 2, 3]), + ), + PlatformFile( + name: 'demo.bin', + path: binaryFile.path, + size: 3, + ), + ], + responseType: 'dataUrl', + ); + final uriStrings = shellCoreTestHooks.xFilesToUriStrings([xFile]); + + expect(serializedXFiles.single['name'], 'demo.txt'); + expect(serializedXFiles.single['mimeType'], 'text/plain'); + expect(serializedXFiles.single['dataUrl'], startsWith('data:text/plain')); + expect( + serializedPlatformFiles.first['mimeType'], + 'application/vnd.android.package-archive', + ); + expect( + serializedPlatformFiles.first['dataUrl'], + startsWith('data:application/vnd.android.package-archive'), + ); + expect( + serializedPlatformFiles.last['dataUrl'], + startsWith('data:application/octet-stream'), + ); + expect(uriStrings.single, Uri.file(file.path).toString()); + }); + + test('文件 accept、MIME 与布尔转换正确', () { + expect(shellCoreTestHooks.acceptsImages([' image/* ']), isTrue); + expect( + shellCoreTestHooks.acceptsOnlyImages(['.png', '.jpg']), + isTrue, + ); + expect( + shellCoreTestHooks.acceptsOnlyImages(['.png', '.pdf']), + isFalse, + ); + expect(shellCoreTestHooks.isImageAcceptType('.heic'), isTrue); + expect(shellCoreTestHooks.guessMimeType('photo.jpeg'), 'image/jpeg'); + expect(shellCoreTestHooks.guessMimeType('photo.webp'), 'image/webp'); + expect(shellCoreTestHooks.guessMimeType('photo.gif'), 'image/gif'); + expect(shellCoreTestHooks.guessMimeType('photo.bmp'), 'image/bmp'); + expect(shellCoreTestHooks.guessMimeType('photo.heic'), 'image/heic'); + expect(shellCoreTestHooks.guessMimeType('photo.heif'), 'image/heif'); + expect(shellCoreTestHooks.guessMimeType('book.pdf'), 'application/pdf'); + expect( + shellCoreTestHooks.guessMimeType('demo.apk'), + 'application/vnd.android.package-archive', + ); + expect( + shellCoreTestHooks.guessMimeType('file.bin'), + 'application/octet-stream', + ); + expect(shellCoreTestHooks.boolValue(true), isTrue); + expect(shellCoreTestHooks.boolValue('true'), isTrue); + expect(shellCoreTestHooks.boolValue(1), isTrue); + expect( + shellCoreTestHooks.boolValue('other', defaultValue: true), + isFalse, + ); + expect(shellCoreTestHooks.boolValue(0), isFalse); + }); + + test('相机权限通过时会调用 image picker', () async { + final tempDirectory = await Directory.systemTemp.createTemp('camera-ok'); + final file = File('${tempDirectory.path}/camera.jpg') + ..writeAsBytesSync([1, 2, 3]); + addTearDown(() async { + if (tempDirectory.existsSync()) { + await tempDirectory.delete(recursive: true); + } + }); + fakeImagePickerPlatform.nextImage = XFile(file.path, name: 'camera.jpg'); + + final result = await shellCoreTestHooks.pickCameraImage(ImagePicker()); + + expect(result, isNotNull); + expect(result!.path, file.path); + expect(fakeImagePickerPlatform.lastSource, ImageSource.camera); + }); + + test('相机权限拒绝时返回 null 并可提示 Web alert', () async { + cameraPermissionGranted = false; + final platformController = _FakePlatformWebViewController( + const PlatformWebViewControllerCreationParams(), + ); + final controller = WebViewController.fromPlatform(platformController); + + final result = await shellCoreTestHooks.pickCameraImage( + ImagePicker(), + showPermissionAlert: true, + controller: controller, + ); + + expect(result, isNull); + expect( + platformController.javaScriptCalls.single, + contains('请先在系统设置中允许相机权限'), + ); + }); + + test('相机打开失败时返回 null 并可提示 Web alert', () async { + fakeImagePickerPlatform.shouldThrow = true; + final platformController = _FakePlatformWebViewController( + const PlatformWebViewControllerCreationParams(), + ); + final controller = WebViewController.fromPlatform(platformController); + + final result = await shellCoreTestHooks.pickCameraImage( + ImagePicker(), + showPermissionAlert: true, + controller: controller, + ); + + expect(result, isNull); + expect(platformController.javaScriptCalls.single, contains('无法打开系统相机')); + }); + + test('权限类型映射正确', () { + expect(shellCoreTestHooks.permissionForType('camera'), Permission.camera); + expect( + shellCoreTestHooks.permissionForType('microphone'), + Permission.microphone, + ); + expect( + shellCoreTestHooks.permissionForType('audio'), + Permission.microphone, + ); + expect( + shellCoreTestHooks.permissionForType('location'), + Permission.location, + ); + expect(shellCoreTestHooks.permissionForType('photos'), Permission.photos); + expect(shellCoreTestHooks.permissionForType('videos'), Permission.videos); + expect( + shellCoreTestHooks.permissionForType('storage'), + Permission.storage, + ); + expect(shellCoreTestHooks.permissionForType('unknown'), isNull); + }); + }); + + group('导航与组件', () { + test('导航 scheme 判断和外部打开行为正确', () async { + expect( + shellCoreTestHooks.shouldStayInWebView( + Uri.parse('https://example.com'), + ), + isTrue, + ); + expect( + shellCoreTestHooks.shouldStayInWebView( + Uri.parse('javascript:alert(1)'), + ), + isTrue, + ); + expect( + shellCoreTestHooks.shouldStayInWebView(Uri.parse('tel:10086')), + isFalse, + ); + + fakeUrlLauncherPlatform.shouldSucceed = true; + expect( + await shellCoreTestHooks.openExternalUri(Uri.parse('tel:10086')), + isTrue, + ); + expect(fakeUrlLauncherPlatform.lastUrl, 'tel:10086'); + + fakeUrlLauncherPlatform.shouldThrow = true; + expect( + await shellCoreTestHooks.openExternalUri(Uri.parse('weixin://demo')), + isFalse, + ); + }); + + testWidgets('UnsupportedPlatformPage 展示 Android-only 文案', (tester) async { + await tester.pumpWidget( + const MaterialApp(home: UnsupportedPlatformPage()), + ); + + expect(find.text('当前平台不支持内嵌 WebView'), findsOneWidget); + expect(find.textContaining('当前项目仅支持 Android 平板运行。'), findsOneWidget); + expect(find.textContaining('https://example.com/login'), findsOneWidget); + }); + + testWidgets('ErrorOverlay 点击重试会触发回调', (tester) async { + var retried = false; + + await tester.pumpWidget( + MaterialApp( + home: ErrorOverlay( + title: '错误标题', + message: '错误信息', + currentUrl: 'https://example.com', + onRetry: () async { + retried = true; + }, + ), + ), + ); + + expect(find.text('错误标题'), findsOneWidget); + expect(find.text('错误信息'), findsOneWidget); + expect(find.text('https://example.com'), findsOneWidget); + await tester.tap(find.text('重新加载')); + await tester.pump(); + expect(retried, isTrue); + }); + + testWidgets('LaunchOverlay 与 TopProgressBar 能正确渲染进度', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Column( + children: [ + Expanded( + child: LaunchOverlay(progress: 80, hasMeasuredProgress: true), + ), + TopProgressBar(progress: 50, hasMeasuredProgress: true), + ], + ), + ), + ); + + expect(find.text('页面加载中'), findsOneWidget); + expect(find.byType(LinearProgressIndicator), findsNWidgets(2)); + + final indicators = tester.widgetList( + find.byType(LinearProgressIndicator), + ); + expect(indicators.first.value, 0.8); + expect(indicators.last.value, 0.5); + }); + + testWidgets('LaunchOverlay 未量测进度时显示不确定进度', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: LaunchOverlay(progress: 0, hasMeasuredProgress: false), + ), + ); + + final indicator = tester.widget( + find.byType(LinearProgressIndicator), + ); + expect(indicator.value, isNull); + }); + + testWidgets('TopProgressBar 未量测进度时显示不确定进度', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: TopProgressBar(progress: 0, hasMeasuredProgress: false), + ), + ), + ); + + final indicator = tester.widget( + find.byType(LinearProgressIndicator), + ); + expect(indicator.value, isNull); + }); + }); + + group('导航 scheme 边界场景', () { + test('data/blob/about/file 协议留在 WebView 中', () { + expect( + shellCoreTestHooks.shouldStayInWebView( + Uri.parse('data:text/html,

hello

'), + ), + isTrue, + ); + expect( + shellCoreTestHooks.shouldStayInWebView(Uri.parse('about:blank')), + isTrue, + ); + expect( + shellCoreTestHooks.shouldStayInWebView( + Uri.parse('blob:https://example.com/uuid'), + ), + isTrue, + ); + expect( + shellCoreTestHooks.shouldStayInWebView( + Uri.parse('file:///tmp/test.html'), + ), + isTrue, + ); + }); + + test('intent/alipay/weixin/sms 等自定义 scheme 走外部应用', () { + for (final scheme in [ + 'intent://demo', + 'alipays://pay', + 'weixin://dl/business', + 'sms:10086', + 'mailto:test@example.com', + ]) { + expect( + shellCoreTestHooks.shouldStayInWebView(Uri.parse(scheme)), + isFalse, + reason: 'scheme=$scheme should leave WebView', + ); + } + }); + + test('openExternalUri 成功返回 true', () async { + fakeUrlLauncherPlatform + ..shouldSucceed = true + ..shouldThrow = false; + + final result = await shellCoreTestHooks.openExternalUri( + Uri.parse('alipays://pay?orderId=123'), + ); + + expect(result, isTrue); + expect(fakeUrlLauncherPlatform.lastUrl, 'alipays://pay?orderId=123'); + }); + }); + + group('序列化 responseType 边界', () { + test('base64 模式包含 base64 但不包含 dataUrl', () async { + final tempDirectory = await Directory.systemTemp.createTemp('ser-b64'); + final file = File('${tempDirectory.path}/data.txt') + ..writeAsStringSync('abc'); + addTearDown(() async { + if (tempDirectory.existsSync()) { + await tempDirectory.delete(recursive: true); + } + }); + + final result = await shellCoreTestHooks.serializeXFiles( + [XFile(file.path, name: 'data.txt')], + responseType: 'base64', + ); + + expect(result.single['base64'], isNotNull); + expect(result.single.containsKey('dataUrl'), isFalse); + }); + + test('uri 模式不包含 base64 也不包含 dataUrl', () async { + final tempDirectory = await Directory.systemTemp.createTemp('ser-uri'); + final file = File('${tempDirectory.path}/data.txt') + ..writeAsStringSync('abc'); + addTearDown(() async { + if (tempDirectory.existsSync()) { + await tempDirectory.delete(recursive: true); + } + }); + + final result = await shellCoreTestHooks.serializeXFiles( + [XFile(file.path, name: 'data.txt')], + responseType: 'uri', + ); + + expect(result.single.containsKey('base64'), isFalse); + expect(result.single.containsKey('dataUrl'), isFalse); + expect(result.single['uri'], contains('data.txt')); + }); + + test('空文件列表返回空数组', () async { + final xResult = await shellCoreTestHooks.serializeXFiles( + [], + responseType: 'dataUrl', + ); + final pfResult = await shellCoreTestHooks.serializePlatformFiles( + [], + responseType: 'base64', + ); + + expect(xResult, isEmpty); + expect(pfResult, isEmpty); + }); + + test('PlatformFile 无 bytes 也无 path 时 base64 为 null', () async { + final result = await shellCoreTestHooks.serializePlatformFiles( + [PlatformFile(name: 'ghost.dat', size: 0)], + responseType: 'base64', + ); + + expect(result.single['base64'], isNull); + expect(result.single['uri'], isNull); + }); + }); + + group('AndroidCompatibilityPlan 扩展场景', () { + test('现代设备(高 SDK + 新 WebView)优先使用 texture', () { + final info = AndroidWebViewInfo( + sdkInt: 34, + manufacturer: 'Samsung', + brand: 'samsung', + model: 'SM-X810', + webViewVersionName: '122.0.6261.86', + ); + + final plan = AndroidCompatibilityPlan.fromInfo(info); + + expect(info.isLegacyWebView, isFalse); + expect(info.isF136A, isFalse); + expect(plan.renderModes.first, AndroidRenderMode.texture); + expect(plan.suggestWebViewUpdate, isFalse); + expect(plan.prefersAggressiveRecovery, isFalse); + }); + + test('低 SDK(≤28)但非 F136A 也会使用 hybrid 优先', () { + final info = AndroidWebViewInfo( + sdkInt: 26, + manufacturer: 'Huawei', + brand: 'HUAWEI', + model: 'MediaPad T3', + webViewVersionName: '120.0.0', + ); + + final plan = AndroidCompatibilityPlan.fromInfo(info); + + expect(plan.renderModes.first, AndroidRenderMode.hybrid); + expect(plan.suggestWebViewUpdate, isTrue); + expect(plan.prefersAggressiveRecovery, isTrue); + }); + + test('AndroidWebViewInfo 字段缺失时使用安全默认值', () { + final info = AndroidWebViewInfo.fromMap({}); + + expect(info.sdkInt, 0); + expect(info.manufacturer, ''); + expect(info.brand, ''); + expect(info.model, ''); + expect(info.webViewDataDirectorySuffix, isNull); + expect(info.webViewPackageName, isNull); + expect(info.webViewVersionName, isNull); + expect(info.webViewLongVersionCode, isNull); + expect(info.webViewMajorVersion, isNull); + }); + + test('AndroidRenderMode 枚举属性正确', () { + expect(AndroidRenderMode.texture.usesHybridComposition, isFalse); + expect(AndroidRenderMode.hybrid.usesHybridComposition, isTrue); + expect(AndroidRenderMode.texture.logName, 'texture-layer'); + expect(AndroidRenderMode.hybrid.logName, 'hybrid-composition'); + }); + }); + + group('错误映射扩展场景', () { + test('hostLookup 和 io 错误映射为网络连接失败', () { + const hostLookupError = WebResourceError( + errorCode: -10, + description: 'dns lookup failed', + errorType: WebResourceErrorType.hostLookup, + ); + const ioError = WebResourceError( + errorCode: -11, + description: 'io interrupted', + errorType: WebResourceErrorType.io, + ); + + expect(shellCoreTestHooks.friendlyErrorTitle(hostLookupError), '网络连接失败'); + expect(shellCoreTestHooks.friendlyErrorTitle(ioError), '网络连接失败'); + expect( + shellCoreTestHooks.friendlyErrorMessage(hostLookupError), + '没有成功连接到服务器,请检查网络后重试。', + ); + expect( + shellCoreTestHooks.friendlyErrorMessage(ioError), + '没有成功连接到服务器,请检查网络后重试。', + ); + }); + + test('空 description 使用默认文案', () { + const emptyDescError = WebResourceError( + errorCode: -99, + description: ' ', + errorType: WebResourceErrorType.unknown, + ); + + expect( + shellCoreTestHooks.friendlyErrorMessage(emptyDescError), + '请稍后重新加载页面。', + ); + }); + }); + + group('Accept 类型边界场景', () { + test('空 acceptTypes 不接受图片也不仅接受图片', () { + expect(shellCoreTestHooks.acceptsImages([]), isFalse); + expect(shellCoreTestHooks.acceptsOnlyImages([]), isFalse); + }); + + test('仅空白字符的 acceptTypes 视为空', () { + expect(shellCoreTestHooks.acceptsImages([' ', '']), isFalse); + expect(shellCoreTestHooks.acceptsOnlyImages([' ', '']), isFalse); + }); + + test('image/* 通配符被识别为图片', () { + expect(shellCoreTestHooks.isImageAcceptType('image/*'), isTrue); + expect(shellCoreTestHooks.isImageAcceptType('image/png'), isTrue); + expect(shellCoreTestHooks.isImageAcceptType('image/svg+xml'), isTrue); + }); + + test('非图片文件扩展名正确返回 false', () { + expect(shellCoreTestHooks.isImageAcceptType('.pdf'), isFalse); + expect(shellCoreTestHooks.isImageAcceptType('.apk'), isFalse); + expect(shellCoreTestHooks.isImageAcceptType('application/pdf'), isFalse); + }); + + test('混合类型列表正确判断', () { + expect( + shellCoreTestHooks.acceptsImages(['.pdf', '.png']), + isTrue, + ); + expect( + shellCoreTestHooks.acceptsOnlyImages(['.pdf', '.png']), + isFalse, + ); + expect( + shellCoreTestHooks.acceptsImages(['image/*', '.jpg']), + isTrue, + ); + expect( + shellCoreTestHooks.acceptsOnlyImages(['image/*', '.jpg']), + isTrue, + ); + }); + }); + + group('boolValue 边界场景', () { + test('null 与未知类型使用 defaultValue', () { + expect(shellCoreTestHooks.boolValue(null), isFalse); + expect(shellCoreTestHooks.boolValue(null, defaultValue: true), isTrue); + expect(shellCoreTestHooks.boolValue(3.14), isFalse); + expect(shellCoreTestHooks.boolValue([]), isFalse); + }); + + test('字符串 false/TRUE 正确解析', () { + expect(shellCoreTestHooks.boolValue('false'), isFalse); + expect(shellCoreTestHooks.boolValue('TRUE'), isTrue); + expect(shellCoreTestHooks.boolValue('False'), isFalse); + }); + + test('int 非零值为 true', () { + expect(shellCoreTestHooks.boolValue(-1), isTrue); + expect(shellCoreTestHooks.boolValue(42), isTrue); + }); + }); + + group('MIME 类型扩展场景', () { + test('所有已支持扩展名都能正确猜测', () { + expect(shellCoreTestHooks.guessMimeType('FILE.PNG'), 'image/png'); + expect(shellCoreTestHooks.guessMimeType('a.JPG'), 'image/jpeg'); + expect(shellCoreTestHooks.guessMimeType('a.JPEG'), 'image/jpeg'); + expect(shellCoreTestHooks.guessMimeType('a.PDF'), 'application/pdf'); + expect(shellCoreTestHooks.guessMimeType('a.TXT'), 'text/plain'); + }); + + test('无扩展名或未知扩展名返回 octet-stream', () { + expect( + shellCoreTestHooks.guessMimeType('binary'), + 'application/octet-stream', + ); + expect( + shellCoreTestHooks.guessMimeType('data.xyz'), + 'application/octet-stream', + ); + }); + }); + + group('URL 归一化边界场景', () { + test('isNetworkUrl 对 null/空/非法值返回 false', () { + expect(shellCoreTestHooks.isNetworkUrl(null), isFalse); + expect(shellCoreTestHooks.isNetworkUrl(''), isFalse); + expect(shellCoreTestHooks.isNetworkUrl('not a url'), isFalse); + }); + + test('路径归一化处理各种格式', () { + expect(shellCoreTestHooks.normalizeComparablePath('/a/b/'), '/a/b'); + expect(shellCoreTestHooks.normalizeComparablePath('/a/b'), '/a/b'); + expect(shellCoreTestHooks.normalizeComparablePath('/'), '/'); + expect(shellCoreTestHooks.normalizeComparablePath('///'), '//'); + }); + + test('默认端口映射覆盖 http/https/未知', () { + expect(shellCoreTestHooks.defaultPortForScheme('http'), 80); + expect(shellCoreTestHooks.defaultPortForScheme('https'), 443); + expect(shellCoreTestHooks.defaultPortForScheme('ftp'), -1); + expect(shellCoreTestHooks.defaultPortForScheme(''), -1); + }); + + test('URI 归一化保留查询参数并忽略大小写', () { + expect( + shellCoreTestHooks.normalizeComparableUri( + Uri.parse('HTTP://EXAMPLE.COM:80/Path/?q=1'), + ), + 'http://example.com:80/Path?q=1', + ); + }); + }); + + group('ShellEnvironment 配置验证', () { + test('字段正确传递到全局环境', () { + expect(_testEnvironment.appName, '测试应用'); + expect(_testEnvironment.appKey, 'test_app'); + expect(_testEnvironment.accentColor, const Color(0xFF3ED37B)); + expect(_testEnvironment.backgroundColor, const Color(0xFFFFFFFF)); + expect(_testEnvironment.textColor, const Color(0xFF1F2937)); + expect(_testEnvironment.mutedTextColor, const Color(0xFF6B7280)); + expect(_testEnvironment.initialUrl, 'example.com/login'); + }); + + test('initialUrl 可选字段缺省时使用默认地址', () { + const noUrlEnv = ShellEnvironment( + appName: '无URL', + appKey: 'no_url', + accentColor: Color(0xFF000000), + backgroundColor: Color(0xFFFFFFFF), + textColor: Color(0xFF000000), + mutedTextColor: Color(0xFF888888), + ); + + expect(noUrlEnv.initialUrl, isNull); + shellCoreTestHooks.initializeEnvironment(noUrlEnv); + expect( + shellCoreTestHooks.initialUrl, + 'http://xszy.lzzneng.com/login.html', + ); + + // 恢复测试环境 + shellCoreTestHooks.initializeEnvironment(_testEnvironment); + }); + + test('https 地址直接使用不做修改', () { + shellCoreTestHooks.initializeEnvironment( + const ShellEnvironment( + appName: 'HTTPS', + appKey: 'https', + accentColor: Color(0xFF000000), + backgroundColor: Color(0xFFFFFFFF), + textColor: Color(0xFF000000), + mutedTextColor: Color(0xFF888888), + initialUrl: 'https://app.example.com/start', + ), + ); + expect( + shellCoreTestHooks.initialUrl, + 'https://app.example.com/start', + ); + expect( + shellCoreTestHooks.initialUri, + Uri.parse('https://app.example.com/start'), + ); + + // 恢复测试环境 + shellCoreTestHooks.initializeEnvironment(_testEnvironment); + }); + }); + + group('Bridge JS 协议格式', () { + test('脚本包含版本号和 app-shell-ready 事件', () { + final script = shellCoreTestHooks.buildAppShellBridgeScript(); + + expect(script, contains('__nativeShellVersion')); + expect(script, contains("'1.0.0'")); + expect(script, contains('app-shell-ready')); + expect(script, contains('isNativeShell: true')); + }); + + test('脚本包含通道守卫和去重逻辑', () { + final script = shellCoreTestHooks.buildAppShellBridgeScript(); + + expect(script, contains('AppShellBridge')); + expect(script, contains("typeof channel.postMessage !== 'function'")); + expect(script, contains('window.AppShell')); + }); + + test('脚本暴露全部 8 个 Action', () { + final script = shellCoreTestHooks.buildAppShellBridgeScript(); + + for (final action in [ + 'pickImage', + 'captureImage', + 'pickFile', + 'openExternal', + 'requestPermissions', + 'reloadPage', + 'goBack', + 'closeApp', + ]) { + expect(script, contains(action), reason: 'Missing action: $action'); + } + }); + + test('bridge 响应包含 requestId/success/data 字段', () async { + final platformController = _FakePlatformWebViewController( + const PlatformWebViewControllerCreationParams(), + ); + final controller = WebViewController.fromPlatform(platformController); + + await shellCoreTestHooks.sendBridgeResponse( + controller, + requestId: 'test-123', + success: true, + data: {'count': 5}, + ); + + expect(platformController.javaScriptCalls, hasLength(1)); + final js = platformController.javaScriptCalls.first; + expect(js, contains('__appShellReceiveResponse')); + expect(js, contains('"requestId":"test-123"')); + expect(js, contains('"success":true')); + expect(js, contains('"count":5')); + }); + + test('bridge 失败响应包含 error 字段', () async { + final platformController = _FakePlatformWebViewController( + const PlatformWebViewControllerCreationParams(), + ); + final controller = WebViewController.fromPlatform(platformController); + + await shellCoreTestHooks.sendBridgeResponse( + controller, + requestId: 'err-456', + success: false, + error: 'Something went wrong', + ); + + final js = platformController.javaScriptCalls.first; + expect(js, contains('"success":false')); + expect(js, contains('"error":"Something went wrong"')); + }); + }); + + group('AndroidCompatibilityPlan describe 格式', () { + test('fallback 策略描述包含必要字段', () { + final fallbackPlan = AndroidCompatibilityPlan.fallback(); + final description = fallbackPlan.describe(); + + expect(description, contains('modes=')); + expect(description, contains('wideViewport=true')); + expect(description, contains('aggressiveRecovery=true')); + }); + + test('现代设备策略不建议激进恢复', () { + final info = AndroidWebViewInfo( + sdkInt: 34, + manufacturer: 'Google', + brand: 'google', + model: 'Pixel Tablet', + webViewVersionName: '120.0.0', + ); + final plan = AndroidCompatibilityPlan.fromInfo(info); + final description = plan.describe(); + + expect(description, contains('texture-layer -> hybrid-composition')); + expect(description, contains('aggressiveRecovery=false')); + }); + + test('F136A 设备策略建议更新 WebView', () { + final info = AndroidWebViewInfo( + sdkInt: 30, + manufacturer: 'Yuanxuan', + brand: 'YX', + model: 'F136A', + webViewVersionName: '109.0.1', + ); + final plan = AndroidCompatibilityPlan.fromInfo(info); + + expect(plan.suggestWebViewUpdate, isTrue); + expect(plan.prefersAggressiveRecovery, isTrue); + expect(plan.renderModes.first, AndroidRenderMode.hybrid); + }); + + test('summary 对空制造商不重复空格', () { + final info = AndroidWebViewInfo( + sdkInt: 30, + manufacturer: '', + brand: '', + model: 'TestDevice', + ); + + expect(info.summary, contains('device=TestDevice')); + expect(info.summary, isNot(contains('device= '))); + }); + + test('summary 对空型号不显示 device 字段', () { + final info = AndroidWebViewInfo( + sdkInt: 30, + manufacturer: '', + brand: '', + model: '', + ); + + expect(info.summary, isNot(contains('device='))); + expect(info.summary, startsWith('sdk=30')); + }); + }); + + group('相机功能边界场景', () { + test('权限拒绝且不带 controller 时静默返回 null', () async { + cameraPermissionGranted = false; + + final result = await shellCoreTestHooks.pickCameraImage(ImagePicker()); + + expect(result, isNull); + }); + + test('权限拒绝且 showPermissionAlert=false 时不弹窗', () async { + cameraPermissionGranted = false; + final platformController = _FakePlatformWebViewController( + const PlatformWebViewControllerCreationParams(), + ); + final controller = WebViewController.fromPlatform(platformController); + + final result = await shellCoreTestHooks.pickCameraImage( + ImagePicker(), + controller: controller, + ); + + expect(result, isNull); + expect(platformController.javaScriptCalls, isEmpty); + }); + + test('相机返回 null 时结果也为 null', () async { + fakeImagePickerPlatform.nextImage = null; + + final result = await shellCoreTestHooks.pickCameraImage(ImagePicker()); + + expect(result, isNull); + }); + }); + + group('多文件序列化', () { + test('多个 XFile 正确序列化为数组', () async { + final tempDirectory = await Directory.systemTemp.createTemp('multi'); + final file1 = File('${tempDirectory.path}/a.png') + ..writeAsBytesSync([1, 2]); + final file2 = File('${tempDirectory.path}/b.jpg') + ..writeAsBytesSync([3, 4]); + addTearDown(() async { + if (tempDirectory.existsSync()) { + await tempDirectory.delete(recursive: true); + } + }); + + final result = await shellCoreTestHooks.serializeXFiles( + [ + XFile(file1.path, name: 'a.png'), + XFile(file2.path, name: 'b.jpg'), + ], + responseType: 'dataUrl', + ); + + expect(result, hasLength(2)); + expect(result[0]['name'], 'a.png'); + expect(result[0]['mimeType'], 'image/png'); + expect(result[1]['name'], 'b.jpg'); + expect(result[1]['mimeType'], 'image/jpeg'); + }); + + test('空文件路径列表转换为空 URI 列表', () { + final uris = shellCoreTestHooks.xFilesToUriStrings([]); + expect(uris, isEmpty); + }); + + test('XFile 路径正确转换为 file:// URI', () { + final uris = shellCoreTestHooks.xFilesToUriStrings([ + XFile('/tmp/a.txt'), + XFile('/tmp/b.png'), + ]); + + expect(uris, hasLength(2)); + expect(uris[0], 'file:///tmp/a.txt'); + expect(uris[1], 'file:///tmp/b.png'); + }); + }); + + group('看门狗常量验证', () { + test('旧相机兼容脚本包含 legacy 标记和重试逻辑', () { + final script = shellCoreTestHooks.legacyCameraCompatScript; + + expect(script, contains('__appShellLegacyCameraCompatInstalled')); + expect(script, contains('installLegacyCameraCompat')); + expect(script, contains('wrappedOpenCamera')); + expect(script, contains('capturePhoto')); + expect(script, contains('setInterval')); + }); + + test('WebView 主版本解析能正确处理各种版本格式', () { + expect(shellCoreTestHooks.parseWebViewMajorVersion('83'), 83); + expect(shellCoreTestHooks.parseWebViewMajorVersion('122.0.6261'), 122); + expect( + shellCoreTestHooks.parseWebViewMajorVersion('not-a-version'), + isNull, + ); + }); + }); +} + +class _FakeUrlLauncherPlatform extends UrlLauncherPlatform { + @override + LinkDelegate? get linkDelegate => null; + + String? lastUrl; + bool shouldSucceed = true; + bool shouldThrow = false; + + @override + Future launch( + String url, { + required bool useSafariVC, + required bool useWebView, + required bool enableJavaScript, + required bool enableDomStorage, + required bool universalLinksOnly, + required Map headers, + String? webOnlyWindowName, + }) async { + lastUrl = url; + if (shouldThrow) { + throw Exception('launch failed'); + } + return shouldSucceed; + } +} + +class _FakeImagePickerPlatform extends ImagePickerPlatform { + XFile? nextImage; + bool shouldThrow = false; + ImageSource? lastSource; + + @override + Future getImageFromSource({ + required ImageSource source, + ImagePickerOptions options = const ImagePickerOptions(), + }) async { + lastSource = source; + if (shouldThrow) { + throw PlatformException(code: 'pick-failed'); + } + return nextImage; + } +} + +class _FakeWebViewPlatform extends WebViewPlatform { + final createdControllers = <_FakePlatformWebViewController>[]; + + @override + PlatformNavigationDelegate createPlatformNavigationDelegate( + PlatformNavigationDelegateCreationParams params, + ) { + return _FakePlatformNavigationDelegate(params); + } + + @override + PlatformWebViewController createPlatformWebViewController( + PlatformWebViewControllerCreationParams params, + ) { + final controller = _FakePlatformWebViewController(params); + createdControllers.add(controller); + return controller; + } + + @override + PlatformWebViewWidget createPlatformWebViewWidget( + PlatformWebViewWidgetCreationParams params, + ) { + return _FakePlatformWebViewWidget(params); + } + + @override + PlatformWebViewCookieManager createPlatformCookieManager( + PlatformWebViewCookieManagerCreationParams params, + ) { + return _FakePlatformWebViewCookieManager(params); + } +} + +class _FakePlatformWebViewCookieManager extends PlatformWebViewCookieManager { + _FakePlatformWebViewCookieManager(super.params) : super.implementation(); + + @override + Future clearCookies() async => false; + + @override + Future setCookie(WebViewCookie cookie) async {} +} + +class _FakePlatformNavigationDelegate extends PlatformNavigationDelegate { + _FakePlatformNavigationDelegate(super.params) : super.implementation(); + + @override + Future setOnNavigationRequest( + NavigationRequestCallback onNavigationRequest, + ) async {} + + @override + Future setOnPageStarted(PageEventCallback onPageStarted) async {} + + @override + Future setOnPageFinished(PageEventCallback onPageFinished) async {} + + @override + Future setOnHttpError(HttpResponseErrorCallback onHttpError) async {} + + @override + Future setOnProgress(ProgressCallback onProgress) async {} + + @override + Future setOnWebResourceError( + WebResourceErrorCallback onWebResourceError, + ) async {} + + @override + Future setOnUrlChange(UrlChangeCallback onUrlChange) async {} +} + +class _FakePlatformWebViewController extends PlatformWebViewController { + _FakePlatformWebViewController(super.params) : super.implementation(); + + final javaScriptCalls = []; + PlatformNavigationDelegate? delegate; + bool throwOnRunJavaScript = false; + bool canGoBackValue = false; + bool didGoBack = false; + Uri? lastLoadedUri; + + @override + Future addJavaScriptChannel(JavaScriptChannelParams params) async {} + + @override + Future clearCache() async {} + + @override + Future clearLocalStorage() async {} + + @override + Future canGoBack() async => canGoBackValue; + + @override + Future enableZoom(bool enabled) async {} + + @override + Future goBack() async { + didGoBack = true; + } + + @override + Future loadRequest(LoadRequestParams params) async { + lastLoadedUri = params.uri; + } + + @override + Future reload() async {} + + @override + Future runJavaScript(String javaScript) async { + if (throwOnRunJavaScript) { + throw Exception('javascript failed'); + } + javaScriptCalls.add(javaScript); + } + + @override + Future runJavaScriptReturningResult(String javaScript) async => ''; + + @override + Future setBackgroundColor(Color color) async {} + + @override + Future setJavaScriptMode(JavaScriptMode javaScriptMode) async {} + + @override + Future setPlatformNavigationDelegate( + PlatformNavigationDelegate delegate, + ) async { + this.delegate = delegate; + } +} + +class _FakePlatformWebViewWidget extends PlatformWebViewWidget { + _FakePlatformWebViewWidget(super.params) : super.implementation(); + + @override + Widget build(BuildContext context) { + return const SizedBox(key: ValueKey('fake-webview')); + } +} diff --git a/pubspec.lock b/pubspec.lock index 1997430..29f5dca 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,46 +1,6 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: - args: - dependency: transitive - description: - name: args - sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.7.0" - async: - dependency: transitive - description: - name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.13.0" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.1.2" - characters: - dependency: transitive - description: - name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.4.1" - clock: - dependency: transitive - description: - name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.1.2" collection: dependency: transitive description: @@ -49,261 +9,6 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.19.1" - cross_file: - dependency: transitive - description: - name: cross_file - sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.3.5+2" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.0.8" - dbus: - dependency: transitive - description: - name: dbus - sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.7.12" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.3.3" - ffi: - dependency: transitive - description: - name: ffi - sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.2.0" - file_picker: - dependency: "direct main" - description: - name: file_picker - sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343" - url: "https://pub.flutter-io.cn" - source: hosted - version: "10.3.10" - file_selector_linux: - dependency: transitive - description: - name: file_selector_linux - sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.9.4" - file_selector_macos: - dependency: transitive - description: - name: file_selector_macos - sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.9.5" - file_selector_platform_interface: - dependency: transitive - description: - name: file_selector_platform_interface - sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.7.0" - file_selector_windows: - dependency: transitive - description: - name: file_selector_windows - sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.9.3+5" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" - url: "https://pub.flutter-io.cn" - source: hosted - version: "6.0.0" - flutter_plugin_android_lifecycle: - dependency: transitive - description: - name: flutter_plugin_android_lifecycle - sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.0.33" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - http: - dependency: transitive - description: - name: http - sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.6.0" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.flutter-io.cn" - source: hosted - version: "4.1.2" - image_picker: - dependency: "direct main" - description: - name: image_picker - sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.2.1" - image_picker_android: - dependency: transitive - description: - name: image_picker_android - sha256: eda9b91b7e266d9041084a42d605a74937d996b87083395c5e47835916a86156 - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.8.13+14" - image_picker_for_web: - dependency: transitive - description: - name: image_picker_for_web - sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" - url: "https://pub.flutter-io.cn" - source: hosted - version: "3.1.1" - image_picker_ios: - dependency: transitive - description: - name: image_picker_ios - sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.8.13+6" - image_picker_linux: - dependency: transitive - description: - name: image_picker_linux - sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.2.2" - image_picker_macos: - dependency: transitive - description: - name: image_picker_macos - sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.2.2+1" - image_picker_platform_interface: - dependency: transitive - description: - name: image_picker_platform_interface - sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.11.1" - image_picker_windows: - dependency: transitive - description: - name: image_picker_windows - sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.2.2" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" - url: "https://pub.flutter-io.cn" - source: hosted - version: "11.0.2" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" - url: "https://pub.flutter-io.cn" - source: hosted - version: "3.0.10" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" - url: "https://pub.flutter-io.cn" - source: hosted - version: "3.0.2" - lints: - dependency: transitive - description: - name: lints - sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" - url: "https://pub.flutter-io.cn" - source: hosted - version: "6.1.0" - matcher: - dependency: transitive - description: - name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.12.18" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.13.0" - meta: - dependency: transitive - description: - name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.17.0" - mime: - dependency: transitive - description: - name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.0.0" path: dependency: transitive description: @@ -312,75 +17,6 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.9.1" - permission_handler: - dependency: "direct main" - description: - name: permission_handler - sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 - url: "https://pub.flutter-io.cn" - source: hosted - version: "12.0.1" - permission_handler_android: - dependency: transitive - description: - name: permission_handler_android - sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" - url: "https://pub.flutter-io.cn" - source: hosted - version: "13.0.1" - permission_handler_apple: - dependency: transitive - description: - name: permission_handler_apple - sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 - url: "https://pub.flutter-io.cn" - source: hosted - version: "9.4.7" - permission_handler_html: - dependency: transitive - description: - name: permission_handler_html - sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.1.3+5" - permission_handler_platform_interface: - dependency: transitive - description: - name: permission_handler_platform_interface - sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 - url: "https://pub.flutter-io.cn" - source: hosted - version: "4.3.0" - permission_handler_windows: - dependency: transitive - description: - name: permission_handler_windows - sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.2.1" - petitparser: - dependency: transitive - description: - name: petitparser - sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" - url: "https://pub.flutter-io.cn" - source: hosted - version: "7.0.2" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.1.8" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" source_span: dependency: transitive description: @@ -389,22 +25,6 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.10.2" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.12.1" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.1.4" string_scanner: dependency: transitive description: @@ -421,158 +41,13 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.2.2" - test_api: - dependency: transitive - description: - name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.7.9" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.4.0" - url_launcher: + yaml: dependency: "direct main" description: - name: url_launcher - sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce url: "https://pub.flutter-io.cn" source: hosted - version: "6.3.2" - url_launcher_android: - dependency: transitive - description: - name: url_launcher_android - sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" - url: "https://pub.flutter-io.cn" - source: hosted - version: "6.3.28" - url_launcher_ios: - dependency: transitive - description: - name: url_launcher_ios - sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" - url: "https://pub.flutter-io.cn" - source: hosted - version: "6.4.1" - url_launcher_linux: - dependency: transitive - description: - name: url_launcher_linux - sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a - url: "https://pub.flutter-io.cn" - source: hosted - version: "3.2.2" - url_launcher_macos: - dependency: transitive - description: - name: url_launcher_macos - sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" - url: "https://pub.flutter-io.cn" - source: hosted - version: "3.2.5" - url_launcher_platform_interface: - dependency: transitive - description: - name: url_launcher_platform_interface - sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.3.2" - url_launcher_web: - dependency: transitive - description: - name: url_launcher_web - sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.4.2" - url_launcher_windows: - dependency: transitive - description: - name: url_launcher_windows - sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" - url: "https://pub.flutter-io.cn" - source: hosted - version: "3.1.5" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.2.0" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" - url: "https://pub.flutter-io.cn" - source: hosted - version: "15.0.2" - web: - dependency: transitive - description: - name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.1.1" - webview_flutter: - dependency: "direct main" - description: - name: webview_flutter - sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9 - url: "https://pub.flutter-io.cn" - source: hosted - version: "4.13.1" - webview_flutter_android: - dependency: "direct main" - description: - name: webview_flutter_android - sha256: "2a03df01df2fd30b075d1e7f24c28aee593f2e5d5ac4c3c4283c5eda63717b24" - url: "https://pub.flutter-io.cn" - source: hosted - version: "4.10.13" - webview_flutter_platform_interface: - dependency: transitive - description: - name: webview_flutter_platform_interface - sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.14.0" - webview_flutter_wkwebview: - dependency: transitive - description: - name: webview_flutter_wkwebview - sha256: "2df8fd9ada04d699b9db8e79aa783a16e5d89b69e5b74009b87e16b59912cf98" - url: "https://pub.flutter-io.cn" - source: hosted - version: "3.24.0" - win32: - dependency: transitive - description: - name: win32 - sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e - url: "https://pub.flutter-io.cn" - source: hosted - version: "5.15.0" - xml: - dependency: transitive - description: - name: xml - sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" - url: "https://pub.flutter-io.cn" - source: hosted - version: "6.6.1" + version: "3.1.3" sdks: dart: ">=3.11.0 <4.0.0" - flutter: ">=3.38.0" diff --git a/pubspec.yaml b/pubspec.yaml index f258bdf..0a51e4a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,95 +1,10 @@ -name: web_android_shell -description: "H5壳子项目" -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev - -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -# In Windows, build-name is used as the major, minor, and patch parts -# of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+1 - -environment: - sdk: ^3.11.0 - -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. -dependencies: - flutter: - sdk: flutter - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.8 - webview_flutter: ^4.13.1 - webview_flutter_android: ^4.10.13 - image_picker: ^1.2.1 - file_picker: ^10.3.10 - permission_handler: ^12.0.1 - url_launcher: ^6.3.2 - -dev_dependencies: - flutter_test: - sdk: flutter - - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. - flutter_lints: ^6.0.0 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. -flutter: - - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. - uses-material-design: true - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/to/asset-from-package - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/to/font-from-package +name: web_android_shell_workspace +description: "Monorepo workspace for web shell apps." +version: 1.0.0 +publish_to: 'none' + +environment: + sdk: ^3.11.0 + +dependencies: + yaml: ^3.1.2 diff --git a/tool/generate_app.dart b/tool/generate_app.dart new file mode 100644 index 0000000..46f4866 --- /dev/null +++ b/tool/generate_app.dart @@ -0,0 +1,339 @@ +import 'dart:io'; +import 'package:yaml/yaml.dart'; + +/// 品牌名允许的字符:小写字母、数字、下划线。 +final RegExp _validBrandName = RegExp(r'^[a-z][a-z0-9_]*$'); + +Future main(List args) async { + if (args.isEmpty) { + print( + '\x1B[31m用法:dart run tool/generate_app.dart <品牌名>\x1B[0m', + ); + print('\x1B[33m示例:dart run tool/generate_app.dart quanxue\x1B[0m'); + exit(1); + } + + final String brand = args.first; + + if (!_validBrandName.hasMatch(brand)) { + print( + '\x1B[31m[错误] 品牌名 "$brand" 格式无效,' + '仅允许小写字母、数字和下划线(且必须以字母开头)。\x1B[0m', + ); + exit(1); + } + + final File configFile = File('flavors/$brand.yaml'); + + if (!configFile.existsSync()) { + print( + '\x1B[31m[错误] 未找到配置文件:${configFile.path}\x1B[0m', + ); + exit(1); + } + + print('\x1B[34m[信息] 正在为品牌生成应用:$brand...\x1B[0m'); + + // 1. 解析 YAML 配置 + final String yamlString = await configFile.readAsString(); + final YamlMap config = loadYaml(yamlString) as YamlMap; + + final String appName = config['app_name'] as String; + final String applicationId = config['application_id'] as String; + final String appKey = config['app_key'] as String; + + final YamlMap theme = config['theme'] as YamlMap; + final String accentColor = theme['accent_color'] as String; + final String bgColor = theme['bg_color'] as String; + final String textColor = theme['text_color'] as String; + final String mutedTextColor = theme['muted_text_color'] as String; + + final String appDir = 'apps/$brand'; + + print('\x1B[32m✔ 配置加载完成。\x1B[0m'); + + // 2. 创建 Flutter 应用 + await _createFlutterApp(brand, appDir, applicationId); + + // 3. 添加核心依赖 + await _addCoreDependency(appDir); + + // 4. 覆盖 MainActivity.java 以继承 CoreShellActivity + await _overwriteMainActivity(appDir, applicationId); + + // 5. 覆盖 AndroidManifest.xml 中的应用名称 + await _overwriteManifestLabel(appDir, appName); + + // 6. 生成 lib/main.dart + await _generateDartEntrypoint( + appDir, + appName, + appKey, + accentColor, + bgColor, + textColor, + mutedTextColor, + ); + + // 7. 生成图标与启动页配置 + await _generateBrandingAssets(brand, appDir, config); + + print('\x1B[32m✔ 应用 $brand 已生成到 $appDir!\x1B[0m'); + print('\x1B[34m构建应用请执行:\x1B[0m'); + print(' cd $appDir && flutter build apk'); +} + +Future _createFlutterApp( + String brand, + String appDir, + String applicationId, +) async { + print('\x1B[34m[信息] 正在执行 flutter create...\x1B[0m'); + final Directory dir = Directory(appDir); + if (dir.existsSync()) { + print( + '\x1B[33m[警告] 目录 $appDir 已存在,正在清理...\x1B[0m', + ); + dir.deleteSync(recursive: true); + } + + // 提取组织名 + // 例如:com.wanmake.quanxue -> org: com.wanmake + final List segments = applicationId.split('.'); + final String org = segments.sublist(0, segments.length - 1).join('.'); + + final ProcessResult result = await Process.run('flutter', [ + 'create', + '--org', + org, + '--project-name', + brand.replaceAll('-', '_'), + '--platforms', + 'android', + '--android-language', + 'java', + appDir, + ]); + + if (result.exitCode != 0) { + print('\x1B[31m[错误] flutter create 执行失败:\n${result.stderr}\x1B[0m'); + exit(1); + } +} + +Future _addCoreDependency(String appDir) async { + print('\x1B[34m[信息] 正在添加 web_shell_core 依赖...\x1B[0m'); + final ProcessResult result = await Process.run('flutter', [ + 'pub', + 'add', + 'web_shell_core', + '--path', + '../../packages/web_shell_core', + ], workingDirectory: appDir); + + if (result.exitCode != 0) { + print( + '\x1B[31m[错误] 添加依赖失败:\n${result.stderr}\x1B[0m', + ); + exit(1); + } +} + +Future _overwriteMainActivity(String appDir, String applicationId) async { + print('\x1B[34m[信息] 正在注入 CoreShellActivity 继承关系...\x1B[0m'); + final String mainActivityPath = + "$appDir/android/app/src/main/java/${applicationId.replaceAll('.', '/')}/MainActivity.java"; + + // 准备 Java 文件内容 + final String javaContent = + ''' +package $applicationId; + +import com.yuanxuan.webshell.core.web_shell_core.CoreShellActivity; + +public class MainActivity extends CoreShellActivity { +} +'''; + + await File(mainActivityPath).create(recursive: true); + await File(mainActivityPath).writeAsString(javaContent); +} + +Future _overwriteManifestLabel(String appDir, String appName) async { + print('\x1B[34m[信息] 正在更新 AndroidManifest.xml 应用名...\x1B[0m'); + final File manifestFile = File( + '$appDir/android/app/src/main/AndroidManifest.xml', + ); + String content = await manifestFile.readAsString(); + + // 使用简单正则替换 android:label="..." + content = content.replaceAll( + RegExp(r'android:label="[^"]*"'), + 'android:label="$appName"', + ); + + await manifestFile.writeAsString(content); +} + +Future _generateDartEntrypoint( + String appDir, + String appName, + String appKey, + String accentColor, + String bgColor, + String textColor, + String mutedTextColor, +) async { + print('\x1B[34m[信息] 正在生成 lib/main.dart...\x1B[0m'); + final File mainFile = File('$appDir/lib/main.dart'); + + final String dartContent = + ''' +import 'package:flutter/material.dart'; +import 'package:web_shell_core/web_shell_core.dart'; + +void main() { + runShellApp( + ShellEnvironment( + appName: '$appName', + appKey: '$appKey', + accentColor: const Color($accentColor), + backgroundColor: const Color($bgColor), + textColor: const Color($textColor), + mutedTextColor: const Color($mutedTextColor), + ), + ); +} +'''; + + await mainFile.writeAsString(dartContent); + + // 清理 flutter create 默认生成的测试文件(因为它依赖已删除的 MyApp 类) + final File defaultTestFile = File('$appDir/test/widget_test.dart'); + if (defaultTestFile.existsSync()) { + await defaultTestFile.delete(); + } +} + +Future _generateBrandingAssets( + String brand, + String appDir, + YamlMap config, +) async { + print('\x1B[34m[信息] 正在配置图标与启动页...\x1B[0m'); + if (config['branding'] == null) { + print( + '\x1B[33m[警告] 配置中未找到 branding 段,跳过资源生成。\x1B[0m', + ); + return; + } + final YamlMap branding = config['branding'] as YamlMap; + + // ── 1. 复制品牌资源到生成的应用目录 ── + final Directory brandSourceDir = Directory('flavors/$brand'); + final Directory brandTargetDir = Directory('$appDir/assets/branding'); + if (!brandSourceDir.existsSync()) { + print( + '\x1B[31m[错误] 品牌资源目录不存在:${brandSourceDir.path}\x1B[0m', + ); + print( + '\x1B[33m请在 flavors/$brand/ 目录下放置 icon.png、' + 'icon_foreground.png、splash.png 等资源文件。\x1B[0m', + ); + exit(1); + } + + await brandTargetDir.create(recursive: true); + for (final entity in brandSourceDir.listSync(recursive: true)) { + if (entity is File) { + final relativePath = entity.path.substring( + brandSourceDir.path.length + 1, + ); + final targetFile = File('${brandTargetDir.path}/$relativePath'); + await targetFile.parent.create(recursive: true); + await entity.copy(targetFile.path); + } + } + print('\x1B[32m✔ 品牌资源已复制到 ${brandTargetDir.path}\x1B[0m'); + + // ── 2. 添加资源生成器的 dev 依赖 ── + print('\x1B[34m[信息] 正在添加资源生成器依赖...\x1B[0m'); + final ProcessResult addDevDepsResult = await Process.run('flutter', [ + 'pub', + 'add', + '--dev', + 'flutter_launcher_icons', + 'flutter_native_splash', + ], workingDirectory: appDir); + + if (addDevDepsResult.exitCode != 0) { + print( + '\x1B[31m[错误] 添加资源生成器依赖失败:' + '\n${addDevDepsResult.stderr}\x1B[0m', + ); + exit(1); + } + + // ── 3. 生成 flutter_launcher_icons 配置 ── + // 资源路径指向复制后的位置(相对于 appDir) + final String iconPath = 'assets/branding/${branding['icon']}'; + final String iconForeground = 'assets/branding/${branding['icon_foreground']}'; + final String iconBackground = branding['icon_background'] as String; + + final String iconsYaml = ''' +flutter_launcher_icons: + android: true + image_path: "$iconPath" + adaptive_icon_background: "$iconBackground" + adaptive_icon_foreground: "$iconForeground" +'''; + await File('$appDir/flutter_launcher_icons.yaml').writeAsString(iconsYaml); + + // ── 4. 生成 flutter_native_splash 配置 ── + final String splashPath = 'assets/branding/${branding['splash']}'; + final String splashColor = branding['splash_color'] as String; + + final String splashYaml = ''' +flutter_native_splash: + color: "$splashColor" + image: "$splashPath" + android_12: + image: "$splashPath" + icon_background_color: "$splashColor" +'''; + await File('$appDir/flutter_native_splash.yaml').writeAsString(splashYaml); + + // ── 5. 执行资源生成器 ── + print('\x1B[34m[信息] 正在生成应用图标...\x1B[0m'); + final ProcessResult iconsResult = await Process.run('dart', [ + 'run', + 'flutter_launcher_icons', + '-f', + 'flutter_launcher_icons.yaml', + ], workingDirectory: appDir); + + if (iconsResult.exitCode != 0) { + print( + '\x1B[31m[错误] 图标生成失败:\n${iconsResult.stderr}\x1B[0m', + ); + print('\x1B[33mstdout:\n${iconsResult.stdout}\x1B[0m'); + exit(1); + } + print('\x1B[32m✔ 应用图标已生成。\x1B[0m'); + + print('\x1B[34m[信息] 正在生成启动页...\x1B[0m'); + final ProcessResult splashResult = await Process.run('dart', [ + 'run', + 'flutter_native_splash:create', + '--path=flutter_native_splash.yaml', + ], workingDirectory: appDir); + + if (splashResult.exitCode != 0) { + print( + '\x1B[31m[错误] 启动页生成失败:\n${splashResult.stderr}\x1B[0m', + ); + print('\x1B[33mstdout:\n${splashResult.stdout}\x1B[0m'); + exit(1); + } + print('\x1B[32m✔ 启动页已生成。\x1B[0m'); +}