first commit

This commit is contained in:
DESKTOP-I3JPKHK\wy 2025-09-16 17:59:27 +08:00
commit a666ebf74d
111 changed files with 10624 additions and 0 deletions

33
.gitignore vendored Normal file
View File

@ -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
.flutter-plugins-dependencies
build/

33
.metadata Normal file
View File

@ -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: "be698c48a6750c8cb8e61c740ca9991bb947aba2"
channel: "stable"
project_type: plugin
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: be698c48a6750c8cb8e61c740ca9991bb947aba2
base_revision: be698c48a6750c8cb8e61c740ca9991bb947aba2
- platform: android
create_revision: be698c48a6750c8cb8e61c740ca9991bb947aba2
base_revision: be698c48a6750c8cb8e61c740ca9991bb947aba2
- platform: ios
create_revision: be698c48a6750c8cb8e61c740ca9991bb947aba2
base_revision: be698c48a6750c8cb8e61c740ca9991bb947aba2
# 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'

65
CHANGELOG.md Normal file
View File

@ -0,0 +1,65 @@
# Changelog
## 1.1.0 - 易用性大幅提升
### 新增功能
* 🎯 **AppUpgradeSimple** - 极简API一行代码实现升级功能
* 🛡️ **PermissionHelper** - 智能权限管理自动处理Android各版本权限差异
* 📱 **Android兼容性优化** - 完美支持Android 6.0到Android 14的所有版本
* 🚀 **自动权限请求** - 根据Android版本自动请求相应权限
* 💡 **更友好的权限提示** - 提供权限说明对话框,引导用户授权
### 改进优化
* ✨ 简化使用流程,新手也能快速上手
* ✨ 自动处理Android权限无需手动申请
* ✨ 智能判断Android版本自动适配不同权限策略
* ✨ 提供完整的权限状态查询接口
* ✨ 优化权限被拒绝时的处理流程
### 文档更新
* 📝 添加极简示例代码
* 📝 完善Android权限说明
* 📝 添加快速集成指南
* 📝 更新API文档
## 1.0.0 - 增强版发布
### 新增功能
* 🚀 **智能版本管理** - 支持语义化版本、数字版本、时间戳、构建号等多种比较策略
* 🚀 **高性能下载管理器** - 实现断点续传、多线程下载、自动重试机制
* 🚀 **网络状态监测** - 实时监测网络类型、质量,提供智能下载策略
* 🚀 **智能缓存系统** - 多级缓存、过期管理、自动清理、多种缓存策略
* 🚀 **文件完整性校验** - 支持MD5/SHA256校验在Isolate中执行避免阻塞
* 🚀 **内存优化** - 使用弱引用、自动资源释放、防止内存泄漏
* 🚀 **静默更新** - 支持后台下载、WiFi自动下载
* 🚀 **灵活配置** - 丰富的配置选项,支持运行时动态调整
### 优化改进
* ⚡ 优化下载性能,支持智能分块和并发控制
* ⚡ 改进错误处理,添加自动重试机制
* ⚡ 优化内存使用防止大文件下载时OOM
* ⚡ 改进网络请求,添加超时控制和代理支持
* ⚡ 优化UI组件提供更好的用户体验
### 架构改进
* 📦 模块化设计,核心功能独立封装
* 📦 单例模式管理,避免重复实例化
* 📦 观察者模式处理状态变化
* 📦 策略模式处理不同平台差异
### 开发体验
* 📝 完善的API文档和使用示例
* 📝 详细的配置说明
* 📝 丰富的回调接口
* 📝 完整的错误信息
## 0.0.1 - 初始版本
* 初始版本发布
* 支持版本检查功能
* 支持Android APK下载和安装
* 支持iOS跳转App Store
* 提供升级对话框UI组件
* 提供下载进度显示组件
* 支持强制和非强制更新
* 完整的错误处理机制

1
LICENSE Normal file
View File

@ -0,0 +1 @@
TODO: Add your license here.

343
README.md Normal file
View File

@ -0,0 +1,343 @@
# App Upgrade Plugin
一款功能强大且灵活的 Flutter 应用内更新插件,专为提供符合主流平台用户习惯的无缝升级体验而设计。插件提供了简化版和增强版两套 API满足不同复杂度的使用需求。
## ✨ 核心特性
- **🎯 智能平台适配**Android 支持完整的下载-安装流程iOS 自动跳转 App Store
- **🔄 双模式更新**:非强制更新(后台下载)+ 强制更新(阻塞式对话框)
- **🛡️ 全面权限管理**:自动适配不同 Android 版本的存储、安装、通知权限
- **📱 现代化 UI**Material Design 风格对话框,支持暗色主题和自定义样式
- **🌐 健壮网络层**:基于 Dio支持断点续传、重试机制、证书配置
- **🔒 安全可靠**MD5/SHA256 文件校验,防止下载文件损坏
- **🎨 高度可定制**:丰富的配置选项和回调接口
## 📦 安装
`pubspec.yaml` 中添加依赖:
```yaml
dependencies:
app_upgrade_plugin: ^1.0.0
```
## 🚀 快速开始
### 方式一:简化版 API推荐新手
```dart
import 'package:app_upgrade_plugin/app_upgrade_plugin.dart';
// 一行代码实现完整升级流程
void checkUpdate(BuildContext context) {
AppUpgradeSimple.instance.checkUpdate(
context: context,
url: 'https://your-api.com/check-update',
params: {'channel': 'release'}, // 可选参数
showNoUpdateToast: true,
autoDownload: true,
autoInstall: false,
);
}
```
### 方式二:分离式 API更灵活
```dart
// 1. 检查更新纯逻辑不涉及UI
final upgradeInfo = await AppUpgradeSimple.instance.checkUpdateSilent(
url: 'https://your-api.com/check-update',
params: {'platform': 'android'},
);
// 2. 根据需要显示UI
if (upgradeInfo != null && context.mounted) {
AppUpgradeSimple.instance.showUpgradeDialog(
context: context,
info: upgradeInfo,
autoDownload: true,
autoInstall: false,
);
}
```
### 方式三:增强版 API高级用户
```dart
final plugin = AppUpgradePluginEnhanced.instance;
// 配置插件
plugin.configure(
debugMode: true,
wifiOnly: false,
autoCheck: true,
);
// 添加下载监听
plugin.addDownloadCallback((task) {
print('下载进度: ${task.progress}%');
});
// 智能检查更新
final info = await plugin.checkUpdateSmart(
'https://your-api.com/check-update'
);
```
## 🎨 UI 展示
插件提供多种精美的 UI 组件:
- **版本更新对话框**:显示版本信息、更新内容、文件大小
- **下载进度对话框**:实时显示下载进度和状态
- **应用市场选择**支持多应用商店选择华为、小米、OPPO等
- **权限申请对话框**:友好的权限说明和引导
## ⚙️ Android 配置
### 1. 权限配置
`android/app/src/main/AndroidManifest.xml` 中添加:
```xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 网络权限 -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- 安装权限(必需) -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- 通知权限Android 13+,可选) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application>
<!-- 你的其他配置 -->
<!-- FileProvider 配置(必需,用于 Android 7.0+ APK 安装) -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
```
### 2. FileProvider 路径配置
创建 `android/app/src/main/res/xml/file_paths.xml`
```xml
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 外部存储 -->
<external-path name="external_files" path="." />
<!-- 应用内部存储 -->
<files-path name="files" path="." />
<!-- 缓存目录 -->
<cache-path name="cache" path="." />
<!-- 外部缓存 -->
<external-cache-path name="external_cache" path="." />
<!-- 下载目录 -->
<external-path name="downloads" path="Download/" />
</paths>
```
### 3. Gradle 配置(可选)
`android/app/build.gradle` 中:
```kotlin
android {
compileSdk 34
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
isCoreLibraryDesugaringEnabled = true
}
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
}
```
## 📡 服务端 API 协议
你的版本检查接口需要返回以下格式的 JSON
```json
{
"hasUpdate": true,
"isForceUpdate": false,
"versionCode": "101",
"versionName": "1.0.1",
"updateContent": "1. 修复了登录问题\n2. 优化了界面显示\n3. 提升了性能",
"downloadUrl": "https://your-cdn.com/app-release.apk",
"appStoreUrl": "https://apps.apple.com/app/id123456789",
"apkSize": 25165824,
"apkMd5": "d41d8cd98f00b204e9800998ecf8427e",
"appMarkets": [
{
"name": "华为应用市场",
"packageName": "com.huawei.appmarket",
"url": "appmarket://details?id=com.yourapp.package"
},
{
"name": "小米应用商店",
"packageName": "com.xiaomi.market",
"url": "mimarket://details?id=com.yourapp.package"
}
]
}
```
### 字段说明
| 字段 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `hasUpdate` | boolean | ✅ | 是否有更新 |
| `isForceUpdate` | boolean | ✅ | 是否强制更新 |
| `versionCode` | string | ✅ | 版本号(数字字符串,用于版本比较) |
| `versionName` | string | ✅ | 版本名称(显示给用户) |
| `updateContent` | string | ✅ | 更新内容描述 |
| `downloadUrl` | string | Android必需 | APK下载地址 |
| `appStoreUrl` | string | iOS必需 | App Store链接 |
| `apkSize` | number | ❌ | APK文件大小字节 |
| `apkMd5` | string | ❌ | APK文件MD5校验值 |
| `appMarkets` | array | ❌ | 应用市场列表 |
## 🔧 高级配置
### 网络配置
```dart
// 自动模式(推荐)
// Debug: 自动绕过证书验证
// Release: 严格证书验证
// 手动配置
AppUpgradePlugin().configureHttp(HttpConfig(
ignoreCertificate: false, // 是否忽略证书
enableLog: true, // 启用网络日志
connectTimeout: 30, // 连接超时(秒)
receiveTimeout: 60, // 接收超时(秒)
defaultMethod: 'GET', // 默认请求方法
headers: { // 自定义请求头
'User-Agent': 'MyApp/1.0',
},
));
```
### 权限管理
插件提供了独立的权限管理工具:
```dart
import 'package:app_upgrade_plugin/core/permission_helper.dart';
// 检查并请求存储权限
final hasStorage = await PermissionHelper.checkAndRequestStoragePermission(
context: context,
);
// 检查并请求安装权限
final hasInstall = await PermissionHelper.checkAndRequestInstallPermission(
context: context,
);
// 检查并请求通知权限
final hasNotification = await PermissionHelper.checkAndRequestNotificationPermission(
context: context,
);
```
## 🐛 故障排除
### 常见问题
**Q: 安装失败,提示 "解析包时出现问题"**
A: 检查以下几点:
- APK 文件是否完整下载(检查文件大小)
- APK 签名是否正确
- 设备架构是否匹配armeabi-v7a, arm64-v8a
**Q: 权限申请失败**
A: 确保:
- AndroidManifest.xml 中已声明相应权限
- FileProvider 配置正确
- 在 MaterialApp 环境中调用权限申请
**Q: 下载失败或进度不更新**
A: 检查:
- 网络连接是否正常
- 下载URL是否可访问
- 服务器是否支持断点续传
**Q: iOS 不跳转 App Store**
A: 确认:
- `appStoreUrl` 字段格式正确
- URL 为有效的 App Store 链接
### 调试技巧
开启调试模式以获取详细日志:
```dart
// 简化版
AppUpgradeSimple.instance.init(debugMode: true);
// 增强版
AppUpgradePluginEnhanced.instance.configure(debugMode: true);
```
## 📚 API 文档
### AppUpgradeSimple
| 方法 | 描述 |
|------|------|
| `checkUpdate()` | 检查更新并显示UI |
| `checkUpdateSilent()` | 静默检查更新 |
| `showUpgradeDialog()` | 显示升级对话框 |
### AppUpgradePluginEnhanced
| 方法 | 描述 |
|------|------|
| `configure()` | 配置插件 |
| `checkUpdateSmart()` | 智能检查更新 |
| `downloadApkSmart()` | 智能下载APK |
| `installApkSmart()` | 智能安装APK |
| `addDownloadCallback()` | 添加下载监听 |
### PermissionHelper
| 方法 | 描述 |
|------|------|
| `checkAndRequestStoragePermission()` | 存储权限 |
| `checkAndRequestInstallPermission()` | 安装权限 |
| `checkAndRequestNotificationPermission()` | 通知权限 |
## 🤝 贡献
欢迎提交 Issue 和 Pull Request
## 📄 许可证
MIT License
## 🔗 相关链接
- [Flutter 官网](https://flutter.dev)
- [Android 应用安装权限文档](https://developer.android.com/reference/android/Manifest.permission#REQUEST_INSTALL_PACKAGES)
- [FileProvider 使用指南](https://developer.android.com/reference/androidx/core/content/FileProvider)

4
analysis_options.yaml Normal file
View File

@ -0,0 +1,4 @@
include: package:flutter_lints/flutter.yaml
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

9
android/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
*.iml
.gradle
/local.properties
/.idea/workspace.xml
/.idea/libraries
.DS_Store
/build
/captures
.cxx

66
android/build.gradle Normal file
View File

@ -0,0 +1,66 @@
group = "com.example.app_upgrade_plugin"
version = "1.0-SNAPSHOT"
buildscript {
ext.kotlin_version = "2.1.0"
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:8.7.3")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
apply plugin: "com.android.library"
apply plugin: "kotlin-android"
android {
namespace = "com.example.app_upgrade_plugin"
compileSdk = 35
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11
}
sourceSets {
main.java.srcDirs += "src/main/kotlin"
test.java.srcDirs += "src/test/kotlin"
}
defaultConfig {
minSdk = 21
}
dependencies {
testImplementation("org.jetbrains.kotlin:kotlin-test")
testImplementation("org.mockito:mockito-core:5.0.0")
}
testOptions {
unitTests.all {
useJUnitPlatform()
testLogging {
events "passed", "skipped", "failed", "standardOut", "standardError"
outputs.upToDateWhen {false}
showStandardStreams = true
}
}
}
}

1
android/settings.gradle Normal file
View File

@ -0,0 +1 @@
rootProject.name = 'app_upgrade_plugin'

View File

@ -0,0 +1,3 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.app_upgrade_plugin">
</manifest>

View File

@ -0,0 +1,230 @@
package com.example.app_upgrade_plugin
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.core.content.FileProvider
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
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
import java.io.File
import java.io.FileInputStream
import java.math.BigInteger
import java.security.MessageDigest
import android.os.Environment
/** AppUpgradePlugin */
class AppUpgradePlugin: FlutterPlugin, MethodCallHandler, ActivityAware {
/// The MethodChannel that will the communication between Flutter and native Android
///
/// This local reference serves to register the plugin with the Flutter Engine and unregister it
/// when the Flutter Engine is detached from the Activity
private lateinit var channel : MethodChannel
private lateinit var context: Context
private var activity: Activity? = null
private val REQUEST_INSTALL_PACKAGES = 1001
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "app_upgrade_plugin")
channel.setMethodCallHandler(this)
context = flutterPluginBinding.applicationContext
}
override fun onMethodCall(call: MethodCall, result: Result) {
when (call.method) {
"getPlatformVersion" -> {
result.success("Android ${android.os.Build.VERSION.RELEASE}")
}
"installApk" -> {
val filePath = call.argument<String>("filePath")
if (filePath != null) {
installApk(filePath, result)
} else {
result.error("INVALID_ARGUMENT", "File path is required", null)
}
}
"getDownloadPath" -> {
val downloadDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
result.success(downloadDir.path)
}
"checkApkExists" -> {
val version = call.argument<String>("version")
val md5 = call.argument<String>("md5")
if (version == null) {
result.success(false)
return
}
val downloadDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
val fileName = "app-upgrade-$version.apk"
val file = File(downloadDir, fileName)
if (!file.exists()) {
result.success(false)
return
}
if (md5 == null) {
// File exists and no MD5 check required
result.success(true)
return
}
// MD5 check
try {
val calculatedMd5 = calculateMD5(file)
result.success(calculatedMd5.equals(md5, ignoreCase = true))
} catch (e: Exception) {
result.error("MD5_ERROR", "Failed to calculate MD5", e.message)
}
}
"goToMarket" -> {
val packageName = call.argument<String>("packageName")
val marketPackage = call.argument<String>("marketPackage")
val url = call.argument<String>("url")
goToMarket(packageName, marketPackage, url, result)
}
"goToAppStore" -> {
val url = call.argument<String>("url")
if (url != null) {
goToAppStore(url)
result.success(true)
} else {
result.error("INVALID_ARGUMENT", "URL is null", null)
}
}
"getAndroidSdkVersion" -> {
result.success(Build.VERSION.SDK_INT)
}
else -> {
result.notImplemented()
}
}
}
private fun installApk(filePath: String, result: Result) {
try {
val file = File(filePath)
if (!file.exists()) {
result.error("FILE_NOT_FOUND", "APK file not found at: $filePath", null)
return
}
// Check if we have permission to install unknown apps (Android 8.0+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (!context.packageManager.canRequestPackageInstalls()) {
result.error("PERMISSION_DENIED", "No permission to install unknown apps. Please grant permission in settings.", null)
return
}
}
val intent = Intent(Intent.ACTION_VIEW)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val uri: Uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
// Android 7.0及以上使用FileProvider
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
try {
val authority = "${context.packageName}.fileprovider"
FileProvider.getUriForFile(context, authority, file)
} catch (e: IllegalArgumentException) {
// If FileProvider fails, try with a more generic authority
result.error("FILEPROVIDER_ERROR", "FileProvider configuration error. Please check AndroidManifest.xml", e.message)
return
}
} else {
// Android 6.0及以下直接使用file://
Uri.fromFile(file)
}
intent.setDataAndType(uri, "application/vnd.android.package-archive")
// Try to start the install intent
try {
context.startActivity(intent)
result.success(true)
} catch (e: Exception) {
result.error("INTENT_ERROR", "Failed to start install intent", e.message)
}
} catch (e: Exception) {
result.error("INSTALL_FAILED", "Install failed: ${e.message}", null)
}
}
private fun goToMarket(packageName: String?, marketPackage: String?, url: String?, result: MethodChannel.Result) {
if (activity == null) {
result.success(false)
return
}
try {
val finalPackageName = packageName ?: activity!!.packageName
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$finalPackageName"))
if (marketPackage != null) {
intent.setPackage(marketPackage)
}
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
activity!!.startActivity(intent)
result.success(true)
} catch (e: Exception) {
// 如果没有安装对应的应用市场则通过URL跳转
if (url != null) {
goToAppStore(url)
result.success(true)
} else {
result.error("MARKET_ERROR", "Failed to open app market", e.message)
}
}
}
private fun goToAppStore(url: String) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
activity?.startActivity(intent)
}
private fun calculateMD5(file: File): String {
val digest = MessageDigest.getInstance("MD5")
val inputStream = FileInputStream(file)
val buffer = ByteArray(8192)
var read: Int
while (inputStream.read(buffer).also { read = it } > 0) {
digest.update(buffer, 0, read)
}
inputStream.close()
val md5sum: ByteArray = digest.digest()
val bigInt = BigInteger(1, md5sum)
var output = bigInt.toString(16)
// Fill to 32 chars
output = String.format("%32s", output).replace(' ', '0')
return output
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
}
override fun onDetachedFromActivityForConfigChanges() {
activity = null
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activity = binding.activity
}
override fun onDetachedFromActivity() {
activity = null
}
}

View File

@ -0,0 +1,27 @@
package com.example.app_upgrade_plugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import kotlin.test.Test
import org.mockito.Mockito
/*
* This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation.
*
* Once you have built the plugin's example app, you can run these tests from the command
* line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or
* you can run them directly from IDEs that support JUnit such as Android Studio.
*/
internal class AppUpgradePluginTest {
@Test
fun onMethodCall_getPlatformVersion_returnsExpectedValue() {
val plugin = AppUpgradePlugin()
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)
}
}

3
devtools_options.yaml Normal file
View File

@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

45
example/.gitignore vendored Normal file
View File

@ -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
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
# 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

16
example/README.md Normal file
View File

@ -0,0 +1,16 @@
# app_upgrade_plugin_example
Demonstrates how to use the app_upgrade_plugin plugin.
## 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:
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
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.

View File

@ -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

14
example/android/.gitignore vendored Normal file
View File

@ -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

View File

@ -0,0 +1,49 @@
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.example.app_upgrade_plugin_example"
compileSdk = flutter.compileSdkVersion
ndkVersion = "27.0.12077973"
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
isCoreLibraryDesugaringEnabled = true
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.app_upgrade_plugin_example"
// 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
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 = "../.."
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
}

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@ -0,0 +1,61 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="app_upgrade_plugin_example"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<!-- FileProvider for APK installation on Android 7.0+ -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility?hl=en and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@ -0,0 +1,31 @@
package com.example.app_upgrade_plugin_example
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity: FlutterActivity() {
private val CHANNEL = "app_upgrade_plugin_channel" // 与Dart端一致
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
// 可在此处理来自Dart的调用
}
}
override fun onResume() {
super.onResume()
// 处理通知点击启动
intent?.getStringExtra("onNotificationClick")?.let { payload ->
if (payload.startsWith("download_complete:")) {
val filePath = payload.substringAfter("download_complete:")
// 通过channel通知Dart层执行安装
MethodChannel(flutterEngine!!.dartExecutor.binaryMessenger, CHANNEL)
.invokeMethod("installApkFromNotification", filePath)
}
// 清除,防止重复触发
intent.removeExtra("onNotificationClick")
}
}
}

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<!-- External storage -->
<external-path name="external_files" path="." />
<!-- Internal app storage -->
<files-path name="files" path="." />
<!-- Cache directory -->
<cache-path name="cache" path="." />
<!-- External cache -->
<external-cache-path name="external_cache" path="." />
<!-- Downloads directory -->
<external-path name="downloads" path="Download/" />
</paths>

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@ -0,0 +1,21 @@
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<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true

View File

@ -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.12-all.zip

View File

@ -0,0 +1,25 @@
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.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
}
include(":app")

View File

@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

View File

@ -0,0 +1,25 @@
// This is a basic Flutter integration test.
//
// Since integration tests run in a full Flutter application, they can interact
// with the host side of a plugin implementation, unlike Dart unit tests.
//
// For more information about Flutter integration tests, please see
// https://flutter.dev/to/integration-testing
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:app_upgrade_plugin/app_upgrade_plugin.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('getPlatformVersion test', (WidgetTester tester) async {
final AppUpgradePlugin plugin = AppUpgradePlugin();
final String? version = await plugin.getPlatformVersion();
// The version string depends on the host platform running the test, so
// just assert that some non-empty string is returned.
expect(version?.isNotEmpty, true);
});
}

34
example/ios/.gitignore vendored Normal file
View File

@ -0,0 +1,34 @@
**/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

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>12.0</string>
</dict>
</plist>

View File

@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@ -0,0 +1,616 @@
// !$*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 */; };
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 = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
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 = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
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 = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* 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 = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
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 */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* 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 */,
);
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 = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* 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 = 12.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.example.appUpgradePluginExample;
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.example.appUpgradePluginExample.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.example.appUpgradePluginExample.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.example.appUpgradePluginExample.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 = 12.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 = 12.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.example.appUpgradePluginExample;
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.example.appUpgradePluginExample;
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 */;
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@ -0,0 +1,13 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

View File

@ -0,0 +1,122 @@
{
"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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,23 @@
{
"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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View File

@ -0,0 +1,5 @@
# 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.

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

View File

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>App Upgrade Plugin</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>app_upgrade_plugin_example</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"

View File

@ -0,0 +1,27 @@
import Flutter
import UIKit
import XCTest
@testable import app_upgrade_plugin
// This demonstrates a simple unit test of the Swift portion of this plugin's implementation.
//
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
class RunnerTests: XCTestCase {
func testGetPlatformVersion() {
let plugin = AppUpgradePlugin()
let call = FlutterMethodCall(methodName: "getPlatformVersion", arguments: [])
let resultExpectation = expectation(description: "result block must be called.")
plugin.handle(call) { result in
XCTAssertEqual(result as! String, "iOS " + UIDevice.current.systemVersion)
resultExpectation.fulfill()
}
waitForExpectations(timeout: 1)
}
}

147
example/lib/main.dart Normal file
View File

@ -0,0 +1,147 @@
import 'dart:async';
import 'package:app_upgrade_plugin/app_upgrade_plugin.dart';
import 'package:app_upgrade_plugin/app_upgrade_plugin_enhanced.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
void main() {
// Flutter绑定已初始化
WidgetsFlutterBinding.ensureInitialized();
//
// - Debug模式
// - Release模式
// 使
// AppUpgradePlugin.ignoreCertificate = true;
// HTTP设置
// AppUpgradePlugin().configureHttp(HttpConfig.unsafe);
// Flutter完全初始化后
Future.delayed(Duration.zero, () {
AppUpgradePluginEnhanced.instance.configure(
debugMode: true,
wifiOnly: false,
autoCheck: true,
);
});
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
// flutter_localizations依赖
supportedLocales: const [Locale('zh', 'CN')],
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
home: const HomePage(),
);
}
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
String _platformVersion = 'Unknown';
final _appUpgradePlugin = AppUpgradePlugin();
@override
void initState() {
super.initState();
initPlatformState();
// Use addPostFrameCallback to ensure the context is valid and mounted
// after the first frame has been rendered.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_testNetworkFunctionality();
}
});
}
Future<void> _testNetworkFunctionality() async {
await AppUpgradeSimple.instance.checkUpdate(
context: context,
url: 'https://dpc-teacher-api.23544.com/api/infra/AppVersion/Get',
params: {
'appName': 'making_school_asignment_app',
'ftuType': 1,
},
showNoUpdateToast: true,
autoDownload: false,
autoInstall: true,
);
debugPrint('=== 网络功能测试完成 ===');
}
// Platform messages are asynchronous, so we initialize in an async method.
Future<void> initPlatformState() async {
String platformVersion;
// Platform messages may fail, so we use a try/catch PlatformException.
// We also handle the message potentially returning null.
try {
platformVersion = await _appUpgradePlugin.getPlatformVersion() ?? 'Unknown platform version';
} on PlatformException {
platformVersion = 'Failed to get platform version.';
}
// If the widget was removed from the tree while the asynchronous platform
// message was in flight, we want to discard the reply rather than calling
// setState to update our non-existent appearance.
if (!mounted) return;
setState(() {
_platformVersion = platformVersion;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('App Upgrade Plugin 示例'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Running on: $_platformVersion\n'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
AppUpgradeSimple.instance.checkUpdate(
context: context,
url: 'https://dpc-teacher-api.23544.com/api/infra/AppVersion/Get',
params: {
'appName': 'making_school_asignment_app',
'ftuType': 1,
},
showNoUpdateToast: true,
autoDownload: false,
autoInstall: false,
);
},
child: const Text('检查更新'),
),
],
),
),
);
}
}

View File

@ -0,0 +1,738 @@
import 'dart:async';
import 'package:app_upgrade_plugin/app_upgrade_plugin_enhanced.dart';
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
void main() {
runApp(const MyEnhancedApp());
}
class MyEnhancedApp extends StatelessWidget {
const MyEnhancedApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '增强版App升级插件示例',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const EnhancedUpgradePage(),
);
}
}
class EnhancedUpgradePage extends StatefulWidget {
const EnhancedUpgradePage({super.key});
@override
State<EnhancedUpgradePage> createState() => _EnhancedUpgradePageState();
}
class _EnhancedUpgradePageState extends State<EnhancedUpgradePage> with SingleTickerProviderStateMixin {
final _plugin = AppUpgradePluginEnhanced.instance;
late TabController _tabController;
//
Map<String, String>? _appInfo;
NetworkStatus? _networkStatus;
DownloadTask? _currentDownload;
UpgradeInfo? _upgradeInfo;
Map<String, dynamic>? _cacheStats;
//
bool _wifiOnly = true;
bool _autoCheck = true;
bool _supportBreakpoint = true;
bool _verifyIntegrity = true;
VersionCompareStrategy _versionStrategy = VersionCompareStrategy.semantic;
//
final _testVersions = ['1.0.0', '1.1.0', '1.2.0', '2.0.0-beta.1', '2.0.0'];
String _currentTestVersion = '1.0.0';
String _remoteTestVersion = '2.0.0';
@override
void initState() {
super.initState();
_tabController = TabController(length: 4, vsync: this);
_initPlugin();
_loadData();
}
@override
void dispose() {
_tabController.dispose();
_plugin.removeUpgradeCallback(_onUpgradeInfo);
_plugin.removeDownloadCallback(_onDownloadProgress);
_plugin.removeErrorCallback(_onError);
super.dispose();
}
void _initPlugin() {
//
_plugin.configure(
debugMode: true,
autoCheck: _autoCheck,
wifiOnly: _wifiOnly,
supportBreakpoint: _supportBreakpoint,
verifyIntegrity: _verifyIntegrity,
versionStrategy: _versionStrategy,
);
//
_plugin.addUpgradeCallback(_onUpgradeInfo);
_plugin.addDownloadCallback(_onDownloadProgress);
_plugin.addErrorCallback(_onError);
//
NetworkMonitor.instance.statusStream.listen((status) {
setState(() {
_networkStatus = status;
});
});
}
Future<void> _loadData() async {
//
await _requestPermissions();
// App信息
_appInfo = await _plugin.getAppInfo();
//
_networkStatus = _plugin.networkStatus;
//
_cacheStats = await _plugin.getCacheStats();
setState(() {});
}
Future<void> _requestPermissions() async {
if (Theme.of(context).platform == TargetPlatform.android) {
await [
Permission.storage,
Permission.requestInstallPackages,
Permission.notification,
].request();
}
}
void _onUpgradeInfo(UpgradeInfo info) {
setState(() {
_upgradeInfo = info;
});
//
UpgradeDialog.show(
context,
upgradeInfo: info,
primaryColor: Theme.of(context).colorScheme.primary,
);
}
void _onDownloadProgress(DownloadTask task) {
setState(() {
_currentDownload = task;
});
}
void _onError(String error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(error),
backgroundColor: Colors.red,
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('增强版App升级插件'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(text: '基础功能'),
Tab(text: '高级配置'),
Tab(text: '网络监测'),
Tab(text: '版本管理'),
],
),
),
body: TabBarView(
controller: _tabController,
children: [
_buildBasicTab(),
_buildConfigTab(),
_buildNetworkTab(),
_buildVersionTab(),
],
),
);
}
//
Widget _buildBasicTab() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// App信息卡片
if (_appInfo != null)
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'App信息',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
Text('应用名称: ${_appInfo!['appName']}'),
Text('包名: ${_appInfo!['packageName']}'),
Text('版本: ${_appInfo!['version']}'),
Text('构建号: ${_appInfo!['buildNumber']}'),
],
),
),
),
const SizedBox(height: 16),
//
if (_upgradeInfo != null)
Card(
color: Colors.amber.shade50,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.system_update, color: Colors.amber),
const SizedBox(width: 8),
const Text(
'发现新版本',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 12),
Text('版本: ${_upgradeInfo!.versionName}'),
Text('强制更新: ${_upgradeInfo!.isForceUpdate ? "" : ""}'),
if (_upgradeInfo!.apkSize != null) Text('大小: ${_formatBytes(_upgradeInfo!.apkSize!)}'),
],
),
),
),
const SizedBox(height: 16),
//
if (_currentDownload != null)
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'下载进度',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
LinearProgressIndicator(value: _currentDownload!.progress),
const SizedBox(height: 8),
Text('状态: ${_getDownloadStatusText(_currentDownload!.status)}'),
Text('进度: ${(_currentDownload!.progress * 100).toStringAsFixed(1)}%'),
if (_currentDownload!.totalSize != null)
Text(
'${_formatBytes(_currentDownload!.downloadedSize)} / ${_formatBytes(_currentDownload!.totalSize!)}'),
if (_currentDownload!.errorMessage != null)
Text('错误: ${_currentDownload!.errorMessage}', style: const TextStyle(color: Colors.red)),
],
),
),
),
const SizedBox(height: 16),
//
Wrap(
spacing: 8,
runSpacing: 8,
children: [
ElevatedButton.icon(
onPressed: () => _checkUpdate(),
icon: const Icon(Icons.refresh),
label: const Text('检查更新'),
),
if (_currentDownload != null) ...[
if (_currentDownload!.status == DownloadStatus.downloading)
ElevatedButton.icon(
onPressed: () => _plugin.pauseDownload(),
icon: const Icon(Icons.pause),
label: const Text('暂停'),
),
if (_currentDownload!.status == DownloadStatus.paused)
ElevatedButton.icon(
onPressed: () => _plugin.resumeDownload(),
icon: const Icon(Icons.play_arrow),
label: const Text('继续'),
),
if (_currentDownload!.status == DownloadStatus.failed)
ElevatedButton.icon(
onPressed: () => _plugin.retryDownload(),
icon: const Icon(Icons.replay),
label: const Text('重试'),
),
OutlinedButton.icon(
onPressed: () => _plugin.cancelDownload(),
icon: const Icon(Icons.cancel),
label: const Text('取消'),
),
],
],
),
],
),
);
}
//
Widget _buildConfigTab() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'升级配置',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('仅WiFi下载'),
subtitle: const Text('只在WiFi环境下自动下载更新'),
value: _wifiOnly,
onChanged: (value) {
setState(() {
_wifiOnly = value;
});
_plugin.configure(wifiOnly: value);
},
),
SwitchListTile(
title: const Text('自动检查更新'),
subtitle: const Text('定期自动检查是否有新版本'),
value: _autoCheck,
onChanged: (value) {
setState(() {
_autoCheck = value;
});
_plugin.configure(autoCheck: value);
},
),
SwitchListTile(
title: const Text('断点续传'),
subtitle: const Text('支持暂停后继续下载'),
value: _supportBreakpoint,
onChanged: (value) {
setState(() {
_supportBreakpoint = value;
});
_plugin.configure(supportBreakpoint: value);
},
),
SwitchListTile(
title: const Text('文件校验'),
subtitle: const Text('下载完成后校验文件完整性'),
value: _verifyIntegrity,
onChanged: (value) {
setState(() {
_verifyIntegrity = value;
});
_plugin.configure(verifyIntegrity: value);
},
),
const Divider(),
ListTile(
title: const Text('版本比较策略'),
subtitle: Text(_getVersionStrategyText(_versionStrategy)),
trailing: DropdownButton<VersionCompareStrategy>(
value: _versionStrategy,
onChanged: (value) {
if (value != null) {
setState(() {
_versionStrategy = value;
});
_plugin.configure(versionStrategy: value);
}
},
items: VersionCompareStrategy.values.map((strategy) {
return DropdownMenuItem(
value: strategy,
child: Text(_getVersionStrategyText(strategy)),
);
}).toList(),
),
),
const Divider(),
//
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'缓存管理',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
if (_cacheStats != null) ...[
Text(
'内存缓存: ${_cacheStats!['memoryCache']['sizeFormatted']} (${_cacheStats!['memoryCache']['count']}项)'),
Text(
'磁盘缓存: ${_cacheStats!['diskCache']['sizeFormatted']} (${_cacheStats!['diskCache']['count']}项)'),
Text('总计: ${_cacheStats!['total']['sizeFormatted']} (${_cacheStats!['total']['count']}项)'),
],
const SizedBox(height: 12),
Row(
children: [
ElevatedButton(
onPressed: () async {
_cacheStats = await _plugin.getCacheStats();
setState(() {});
},
child: const Text('刷新'),
),
const SizedBox(width: 8),
OutlinedButton(
onPressed: () async {
await _plugin.clearCache();
_cacheStats = await _plugin.getCacheStats();
if (mounted) {
setState(() {});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('缓存已清空')),
);
}
},
child: const Text('清空缓存'),
),
],
),
],
),
),
),
],
),
);
}
//
Widget _buildNetworkTab() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'网络状态',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
if (_networkStatus != null) ...[
Card(
color: _networkStatus!.isConnected ? Colors.green.shade50 : Colors.red.shade50,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
_networkStatus!.isConnected ? Icons.wifi : Icons.wifi_off,
color: _networkStatus!.isConnected ? Colors.green : Colors.red,
),
const SizedBox(width: 8),
Text(
_networkStatus!.isConnected ? '已连接' : '未连接',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 12),
Text('网络类型: ${_getNetworkTypeText(_networkStatus!.type)}'),
Text('网络质量: ${_getNetworkQualityText(_networkStatus!.quality)}'),
Text('计费网络: ${_networkStatus!.isMetered ? "" : ""}'),
if (_networkStatus!.downloadSpeed != null)
Text('下载速度: ${_formatBytes(_networkStatus!.downloadSpeed!.toInt())}/s'),
if (_networkStatus!.ping != null) Text('延迟: ${_networkStatus!.ping} ms'),
const SizedBox(height: 12),
Text(
'适合大文件下载: ${_networkStatus!.isSuitableForLargeDownload ? "" : ""}',
style: TextStyle(
color: _networkStatus!.isSuitableForLargeDownload ? Colors.green : Colors.orange,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
const SizedBox(height: 16),
//
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'下载策略建议',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
...NetworkMonitor.instance.getSuggestedDownloadStrategy().entries.map((e) {
return Text('${e.key}: ${e.value}');
}),
],
),
),
),
] else
const Center(child: CircularProgressIndicator()),
],
),
);
}
//
Widget _buildVersionTab() {
final comparator = _plugin.versionComparator;
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'版本比较测试',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
//
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('当前版本:'),
DropdownButton<String>(
value: _currentTestVersion,
isExpanded: true,
onChanged: (value) {
setState(() {
_currentTestVersion = value!;
});
},
items: _testVersions.map((v) {
return DropdownMenuItem(value: v, child: Text(v));
}).toList(),
),
],
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('远程版本:'),
DropdownButton<String>(
value: _remoteTestVersion,
isExpanded: true,
onChanged: (value) {
setState(() {
_remoteTestVersion = value!;
});
},
items: _testVersions.map((v) {
return DropdownMenuItem(value: v, child: Text(v));
}).toList(),
),
],
),
),
],
),
const SizedBox(height: 16),
//
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'比较结果',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
Text('需要更新: ${comparator.isUpdateAvailable(_currentTestVersion, _remoteTestVersion) ? "" : ""}'),
Text('更新类型: ${comparator.getVersionDifference(_currentTestVersion, _remoteTestVersion)}'),
Text('主要版本更新: ${comparator.isMajorUpdate(_currentTestVersion, _remoteTestVersion) ? "" : ""}'),
Text('次要版本更新: ${comparator.isMinorUpdate(_currentTestVersion, _remoteTestVersion) ? "" : ""}'),
Text('修订版本更新: ${comparator.isPatchUpdate(_currentTestVersion, _remoteTestVersion) ? "" : ""}'),
],
),
),
),
const SizedBox(height: 16),
//
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'版本排序',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
Text('升序: ${comparator.sortVersions(_testVersions).join(', ')}'),
Text('降序: ${comparator.sortVersions(_testVersions, descending: true).join(', ')}'),
Text('最新版本: ${comparator.getLatestVersion(_testVersions)}'),
],
),
),
),
],
),
);
}
//
Future<void> _checkUpdate() async {
//
const url = 'https://api.example.com/check-update';
final info = await _plugin.checkUpdateSmart(
url,
forceRefresh: true,
cacheDuration: const Duration(hours: 1),
);
if (info == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已是最新版本')),
);
}
}
//
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(2)} KB';
if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
}
String _getDownloadStatusText(DownloadStatus status) {
switch (status) {
case DownloadStatus.pending:
return '等待中';
case DownloadStatus.downloading:
return '下载中';
case DownloadStatus.paused:
return '已暂停';
case DownloadStatus.completed:
return '已完成';
case DownloadStatus.failed:
return '失败';
case DownloadStatus.cancelled:
return '已取消';
}
}
String _getNetworkTypeText(NetworkType type) {
switch (type) {
case NetworkType.none:
return '无网络';
case NetworkType.mobile:
return '移动网络';
case NetworkType.wifi:
return 'WiFi';
case NetworkType.ethernet:
return '以太网';
case NetworkType.bluetooth:
return '蓝牙';
case NetworkType.vpn:
return 'VPN';
case NetworkType.other:
return '其他';
}
}
String _getNetworkQualityText(NetworkQuality quality) {
switch (quality) {
case NetworkQuality.unknown:
return '未知';
case NetworkQuality.poor:
return '';
case NetworkQuality.moderate:
return '中等';
case NetworkQuality.good:
return '良好';
case NetworkQuality.excellent:
return '优秀';
}
}
String _getVersionStrategyText(VersionCompareStrategy strategy) {
switch (strategy) {
case VersionCompareStrategy.numeric:
return '数字比较';
case VersionCompareStrategy.semantic:
return '语义化版本';
case VersionCompareStrategy.timestamp:
return '时间戳';
case VersionCompareStrategy.buildNumber:
return '构建号';
case VersionCompareStrategy.custom:
return '自定义';
}
}
}

View File

@ -0,0 +1,165 @@
import 'package:app_upgrade_plugin/app_upgrade_simple.dart';
import 'package:flutter/material.dart';
/// 使
/// App升级功能
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'App Upgrade Simple Example',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key});
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
// URL使API地址
static const String checkUpdateUrl = 'https://api.example.com/check-update';
@override
void initState() {
super.initState();
//
_checkUpdateOnStart();
}
///
void _checkUpdateOnStart() async {
// 2
await Future.delayed(const Duration(seconds: 2));
if (mounted) {
//
final info = await AppUpgradeSimple.instance.checkUpdateSilent(
url: checkUpdateUrl,
);
if (info != null && mounted) {
//
AppUpgradeSimple.instance.checkUpdate(
context: context,
url: checkUpdateUrl,
showNoUpdateToast: false, // "已是最新版本"
autoDownload: false, //
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('App升级 - 极简示例'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.rocket_launch,
size: 80,
color: Colors.blue,
),
const SizedBox(height: 24),
const Text(
'最简单的App升级实现',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
'一行代码搞定升级功能',
style: TextStyle(fontSize: 16, color: Colors.grey),
),
const SizedBox(height: 48),
// 1使
ElevatedButton.icon(
onPressed: () {
// 🚀
AppUpgradeSimple.instance.checkUpdate(
context: context,
url: checkUpdateUrl,
);
},
icon: const Icon(Icons.flash_on),
label: const Text('一键检查更新(最简单)'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
),
const SizedBox(height: 16),
const SizedBox(height: 16),
const SizedBox(height: 48),
// 使
Container(
margin: const EdgeInsets.symmetric(horizontal: 32),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.blue.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text(
'💡 使用提示',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
SizedBox(height: 8),
Text('1. 替换 checkUpdateUrl 为您的API地址'),
Text('2. API返回格式请参考文档'),
Text('3. Android需要配置权限和FileProvider'),
Text('4. iOS需要配置App Store地址'),
],
),
),
],
),
),
);
}
}
/// API返回格式示例
/// ```json
/// {
/// "hasUpdate": true,
/// "isForceUpdate": false,
/// "versionCode": "2",
/// "versionName": "1.1.0",
/// "updateContent": "1. 修复已知问题\n2. 优化用户体验",
/// "downloadUrl": "https://example.com/app-v1.1.0.apk", // Android
/// "appStoreUrl": "https://apps.apple.com/app/id123456", // iOS
/// "apkSize": 26214400,
/// "apkMd5": "abc123def456"
/// }
/// ```

741
example/pubspec.lock Normal file
View File

@ -0,0 +1,741 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
app_upgrade_plugin:
dependency: "direct main"
description:
path: ".."
relative: true
source: path
version: "1.0.0"
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: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.12.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: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.0"
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"
connectivity_plus:
dependency: transitive
description:
name: connectivity_plus
sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.1.5"
connectivity_plus_platform_interface:
dependency: transitive
description:
name: connectivity_plus_platform_interface
sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.1"
crypto:
dependency: transitive
description:
name: crypto
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.6"
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: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.7.11"
device_info_plus:
dependency: transitive
description:
name: device_info_plus
sha256: "98f28b42168cc509abc92f88518882fd58061ea372d7999aecc424345c7bff6a"
url: "https://pub.flutter-io.cn"
source: hosted
version: "11.5.0"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.0.3"
dio:
dependency: transitive
description:
name: dio
sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.9.0"
dio_web_adapter:
dependency: transitive
description:
name: dio_web_adapter
sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.1"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.3.2"
ffi:
dependency: transitive
description:
name: ffi
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.4"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.0.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_driver:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1"
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.0.0"
flutter_local_notifications:
dependency: transitive
description:
name: flutter_local_notifications
sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610
url: "https://pub.flutter-io.cn"
source: hosted
version: "18.0.1"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01"
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.0.0"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52"
url: "https://pub.flutter-io.cn"
source: hosted
version: "8.0.0"
flutter_localizations:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
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"
fluttertoast:
dependency: transitive
description:
name: fluttertoast
sha256: "25e51620424d92d3db3832464774a6143b5053f15e382d8ffbfd40b6e795dcf1"
url: "https://pub.flutter-io.cn"
source: hosted
version: "8.2.12"
fuchsia_remote_debug_protocol:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
http:
dependency: transitive
description:
name: http
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.5.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.1.2"
integration_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
intl:
dependency: "direct main"
description:
name: intl
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.19.0"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec
url: "https://pub.flutter-io.cn"
source: hosted
version: "10.0.8"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.9"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.1"
lints:
dependency: transitive
description:
name: lints
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.1.1"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.11.1"
meta:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.16.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.0"
nm:
dependency: transitive
description:
name: nm
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.5.0"
package_info_plus:
dependency: transitive
description:
name: package_info_plus
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
url: "https://pub.flutter-io.cn"
source: hosted
version: "8.3.1"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.2.1"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "993381400e94d18469750e5b9dcb8206f15bc09f9da86b9e44a9b0092a0066db"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.18"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.2"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.3.0"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
url: "https://pub.flutter-io.cn"
source: hosted
version: "11.4.0"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
url: "https://pub.flutter-io.cn"
source: hosted
version: "12.1.0"
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: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.1.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.8"
process:
dependency: transitive
description:
name: process
sha256: "107d8be718f120bbba9dcd1e95e3bd325b1b4a4f07db64154635ba03f2567a0d"
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.0.3"
shared_preferences:
dependency: transitive
description:
name: shared_preferences
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.5.3"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: a2608114b1ffdcbc9c120eb71a0e207c71da56202852d4aab8a5e30a82269e74
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.12"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.5.4"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.1"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.1"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.10.1"
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"
sync_http:
dependency: transitive
description:
name: sync_http
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.3.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: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.7.4"
timezone:
dependency: transitive
description:
name: timezone
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.10.1"
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: 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: "81777b08c498a292d93ff2feead633174c386291e35612f8da438d6e92c4447e"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.3.20"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.3.4"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.2.1"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.2.3"
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: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.1"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.4"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.4"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14"
url: "https://pub.flutter-io.cn"
source: hosted
version: "14.3.1"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.1"
webdriver:
dependency: transitive
description:
name: webdriver
sha256: "3d773670966f02a646319410766d3b5e1037efb7f07cc68f844d5e06cd4d61c8"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.4"
win32:
dependency: transitive
description:
name: win32
sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba"
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.13.0"
win32_registry:
dependency: transitive
description:
name: win32_registry
sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.0"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.5.0"
sdks:
dart: ">=3.7.0 <4.0.0"
flutter: ">=3.29.0"

91
example/pubspec.yaml Normal file
View File

@ -0,0 +1,91 @@
name: app_upgrade_plugin_example
description: "Demonstrates how to use the app_upgrade_plugin plugin."
# 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
environment:
sdk: '>=3.0.0 <4.0.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
flutter_localizations:
sdk: flutter
intl: ^0.19.0
app_upgrade_plugin:
# When depending on this package from a real application you should use:
# app_upgrade_plugin: ^x.y.z
# See https://dart.dev/tools/pub/dependencies#version-constraints
# The example app is bundled with the plugin so we use a path dependency on
# the parent directory to use the current plugin's version.
path: ../
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
permission_handler: ^11.3.1
dev_dependencies:
integration_test:
sdk: flutter
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: ^5.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

View File

@ -0,0 +1,160 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:app_upgrade_plugin/app_upgrade_plugin.dart';
import 'package:app_upgrade_plugin/app_upgrade_plugin_enhanced.dart';
import 'package:app_upgrade_plugin_example/main.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
class MockAppUpgradePluginEnhanced implements AppUpgradePluginEnhanced {
// mock AppUpgradePluginEnhanced
// ...
//
@override
void addDownloadCallback(DownloadCallback callback) {}
@override
void addErrorCallback(ErrorCallback callback) {}
@override
void addUpgradeCallback(UpgradeCallback callback) {}
@override
Future<bool> checkApkExists(String version, String? md5) async => false;
@override
Future<UpgradeInfo?> checkUpdateSmart(String url,
{Map<String, dynamic>? params, bool forceRefresh = false, Duration? cacheDuration}) async =>
null;
@override
Future<void> clearCache() async {}
@override
UpgradeConfig get config => UpgradeConfig.instance;
@override
void configure(
{bool? debugMode,
int? checkIntervalHours,
bool? autoCheck,
bool? wifiOnly,
int? downloadTimeout,
int? connectTimeout,
int? maxRetryCount,
bool? supportBreakpoint,
bool? verifyIntegrity,
VersionCompareStrategy? versionStrategy,
Map<String, String>? customHeaders}) {}
@override
void dispose() {}
@override
Future<String?> downloadApkSmart(String url,
{String? versionName,
Function(DownloadProgress p1)? onProgress,
String? savePath,
String? md5,
String? sha256,
bool resumeIfExists = true}) async =>
null;
@override
Future<Map<String, String>> getAppInfo() async => {};
@override
Future<Map<String, dynamic>> getCacheStats() async => {};
@override
DownloadTask? getCurrentDownloadTask() => null;
@override
Future<String?> getPlatformVersion() async => 'mock';
@override
Future<bool> goToAppStore(String url) async => false;
@override
Future<bool> installApkSmart(String filePath) async => false;
@override
NetworkStatus? get networkStatus => NetworkStatus(
type: NetworkType.wifi,
quality: NetworkQuality.good,
isConnected: true,
isMetered: false,
);
@override
void pauseDownload() {}
@override
void removeDownloadCallback(DownloadCallback callback) {}
@override
void removeErrorCallback(ErrorCallback callback) {}
@override
void removeUpgradeCallback(UpgradeCallback callback) {}
@override
Future<bool> resumeDownload() async => false;
@override
Future<bool> retryDownload() async => false;
@override
Future<void> refreshNetworkStatus() async {}
@override
VersionComparator get versionComparator => VersionComparator();
@override
void cancelDownload() {}
}
void main() {
//
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
// mock mock
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/package_info'),
(MethodCall methodCall) async {
if (methodCall.method == 'getAll') {
return <String, dynamic>{
'appName': 'app_upgrade_plugin_example',
'packageName': 'com.example.app_upgrade_plugin_example',
'version': '1.0.0',
'buildNumber': '1',
};
}
return null;
},
);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('dexterous.com/flutter/local_notifications'),
(MethodCall methodCall) async => null, // null
);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('dev.flutter.plugins/connectivity'),
(MethodCall methodCall) async {
if (methodCall.method == 'check') {
return 'wifi'; // WiFi
}
return null;
},
);
});
testWidgets('Verify Platform version', (WidgetTester tester) async {
// 使 Mock enhanced plugin
AppUpgradeSimple.instance = AppUpgradeSimple.private(plugin: MockAppUpgradePluginEnhanced());
await tester.pumpWidget(const MyApp());
// UI内容
expect(find.byType(MyApp), findsOneWidget);
});
testWidgets('Verify Network Status Detection', (WidgetTester tester) async {
// 使 Mock enhanced plugin
final mockPlugin = MockAppUpgradePluginEnhanced();
AppUpgradeSimple.instance = AppUpgradeSimple.private(plugin: mockPlugin);
await tester.pumpWidget(const MyApp());
//
final networkStatus = mockPlugin.networkStatus;
expect(networkStatus, isNotNull);
expect(networkStatus!.isConnected, isTrue);
expect(networkStatus.type, equals(NetworkType.wifi));
expect(networkStatus.isMetered, isFalse);
});
}

38
ios/.gitignore vendored Normal file
View File

@ -0,0 +1,38 @@
.idea/
.vagrant/
.sconsign.dblite
.svn/
.DS_Store
*.swp
profile
DerivedData/
build/
GeneratedPluginRegistrant.h
GeneratedPluginRegistrant.m
.generated/
*.pbxuser
*.mode1v3
*.mode2v3
*.perspectivev3
!default.pbxuser
!default.mode1v3
!default.mode2v3
!default.perspectivev3
xcuserdata
*.moved-aside
*.pyc
*sync/
Icon?
.tags*
/Flutter/Generated.xcconfig
/Flutter/ephemeral/
/Flutter/flutter_export_environment.sh

0
ios/Assets/.gitkeep Normal file
View File

View File

@ -0,0 +1,50 @@
import Flutter
import UIKit
public class AppUpgradePlugin: NSObject, FlutterPlugin {
public static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(name: "app_upgrade_plugin", binaryMessenger: registrar.messenger())
let instance = AppUpgradePlugin()
registrar.addMethodCallDelegate(instance, channel: channel)
}
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "getPlatformVersion":
result(
"iOS " + UIDevice.current.systemVersion
)
case "goToAppStore":
let args = call.arguments as? [String: Any]
let url = args?["url"] as? String
if let url = url {
if let appUrl = URL(string: url) {
UIApplication.shared.open(appUrl, options: [:], completionHandler: nil)
result(true)
} else {
result(false)
}
} else {
result(false)
}
case "getDownloadPath":
let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
result(paths.first?.path)
case "checkApkExists":
// Not applicable for iOS
result(false)
case "goToMarket":
// For iOS, goToMarket is the same as goToAppStore
let args = call.arguments as? [String: Any]
let url = args?["url"] as? String
if let url = url, let appUrl = URL(string: url) {
UIApplication.shared.open(appUrl, options: [:], completionHandler: nil)
result(true)
} else {
result(false)
}
default:
result(FlutterMethodNotImplemented)
}
}
}

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyTracking</key>
<false/>
</dict>
</plist>

View File

@ -0,0 +1,29 @@
#
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html.
# Run `pod lib lint app_upgrade_plugin.podspec` to validate before publishing.
#
Pod::Spec.new do |s|
s.name = 'app_upgrade_plugin'
s.version = '0.0.1'
s.summary = 'A new Flutter plugin project.'
s.description = <<-DESC
A new Flutter plugin project.
DESC
s.homepage = 'http://example.com'
s.license = { :file => '../LICENSE' }
s.author = { 'Your Company' => 'email@example.com' }
s.source = { :path => '.' }
s.source_files = 'Classes/**/*'
s.dependency 'Flutter'
s.platform = :ios, '12.0'
# Flutter.framework does not contain a i386 slice.
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' }
s.swift_version = '5.0'
# If your plugin requires a privacy manifest, for example if it uses any
# required reason APIs, update the PrivacyInfo.xcprivacy file to describe your
# plugin's privacy impact, and then uncomment this line. For more information,
# see https://developer.apple.com/documentation/bundleresources/privacy_manifest_files
# s.resource_bundles = {'app_upgrade_plugin_privacy' => ['Resources/PrivacyInfo.xcprivacy']}
end

125
lib/app_upgrade_plugin.dart Normal file
View File

@ -0,0 +1,125 @@
import 'dart:io';
// 便
// ignore: unused_import
import 'app_upgrade_plugin_method_channel.dart';
import 'app_upgrade_plugin_platform_interface.dart';
import 'core/http_config.dart';
import 'models/upgrade_info.dart';
// API使
export 'app_upgrade_simple.dart';
// HTTP配置
export 'core/http_config.dart';
//
export 'core/permission_helper.dart';
export 'models/upgrade_info.dart';
export 'widgets/widgets.dart';
class AppUpgradePlugin {
//
static final AppUpgradePlugin _instance = AppUpgradePlugin._internal();
factory AppUpgradePlugin() => _instance;
///
/// Debug模式忽略Release模式验证
/// true将强制在所有环境下忽略证书验证
static bool ignoreCertificate = true;
AppUpgradePlugin._internal() {
//
_ensurePlatformInitialized();
// HTTP
if (ignoreCertificate) {
configureHttp(HttpConfig.unsafe);
}
}
//
void _ensurePlatformInitialized() {
//
final _ = AppUpgradePluginPlatform.instance;
}
/// HTTP设置
///
///
/// ```dart
/// //
/// AppUpgradePlugin().configureHttp(HttpConfig.development);
///
/// //
/// AppUpgradePlugin().configureHttp(HttpConfig(
/// ignoreCertificate: true,
/// connectTimeout: 60,
/// defaultMethod: 'POST',
/// ));
/// ```
void configureHttp(HttpConfig config) {
AppUpgradePluginPlatform.instance.configureHttp(config);
}
///
Future<String?> getPlatformVersion() {
return AppUpgradePluginPlatform.instance.getPlatformVersion();
}
Future<int?> getAndroidSdkVersion() {
return AppUpgradePluginPlatform.instance.getAndroidSdkVersion();
}
/// App信息
Future<Map<String, String>> getAppInfo() {
return AppUpgradePluginPlatform.instance.getAppInfo();
}
///
/// [url]
/// [params]
Future<UpgradeInfo?> checkUpdate(String url, {Map<String, dynamic>? params}) {
return AppUpgradePluginPlatform.instance.checkUpdate(url, params: params);
}
/// APK文件Android
/// [url] APK下载地址
/// [onProgress]
/// [savePath] 使
Future<String?> downloadApk(String url, {Function(DownloadProgress)? onProgress, String? savePath}) {
if (!Platform.isAndroid) {
throw UnsupportedError('downloadApk only supports Android platform');
}
return AppUpgradePluginPlatform.instance.downloadApk(url, onProgress: onProgress, savePath: savePath);
}
/// APKAndroid
/// [filePath] APK文件路径
Future<bool> installApk(String filePath) {
if (!Platform.isAndroid) {
return Future.value(false);
}
return AppUpgradePluginPlatform.instance.installApk(filePath);
}
///
/// [url]
Future<bool> goToAppStore(String url) {
return AppUpgradePluginPlatform.instance.goToAppStore(url);
}
///
Future<String?> getDownloadPath() {
return AppUpgradePluginPlatform.instance.getDownloadPath();
}
/// APK
/// [version]
/// [md5] MD5值
Future<bool> checkApkExists(String version, String? md5) {
if (!Platform.isAndroid) {
return Future.value(false);
}
return AppUpgradePluginPlatform.instance.checkApkExists(version, md5);
}
}

View File

@ -0,0 +1,638 @@
import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; // Added for MethodChannel
import 'package:flutter/widgets.dart'; // Added for WidgetsBinding
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import 'app_upgrade_plugin_platform_interface.dart';
import 'core/cache_manager.dart';
import 'core/download_manager.dart';
import 'core/network_monitor.dart';
import 'core/notification_helper.dart';
import 'core/upgrade_config.dart';
import 'core/version_comparator.dart';
import 'models/upgrade_info.dart';
export 'core/cache_manager.dart';
export 'core/download_manager.dart';
export 'core/network_monitor.dart';
export 'core/upgrade_config.dart';
export 'core/version_comparator.dart';
export 'models/upgrade_info.dart';
export 'widgets/widgets.dart';
///
typedef UpgradeCallback = void Function(UpgradeInfo info);
typedef DownloadCallback = void Function(DownloadTask task);
typedef ErrorCallback = void Function(String error);
/// App升级插件
class AppUpgradePluginEnhanced {
static AppUpgradePluginEnhanced? _instance;
///
static AppUpgradePluginEnhanced get instance {
_instance ??= AppUpgradePluginEnhanced._();
return _instance!;
}
AppUpgradePluginEnhanced._() {
_init();
}
//
final UpgradeConfig _config = UpgradeConfig.instance;
final DownloadManager _downloadManager = DownloadManager.instance;
final NetworkMonitor _networkMonitor = NetworkMonitor.instance;
final CacheManager _cacheManager = CacheManager.instance;
final NotificationHelper _notificationHelper = NotificationHelper.instance;
late VersionComparator _versionComparator;
final Dio _dio = Dio();
//
final List<UpgradeCallback> _upgradeCallbacks = [];
final List<DownloadCallback> _downloadCallbacks = [];
final List<ErrorCallback> _errorCallbacks = [];
//
UpgradeInfo? _currentUpgradeInfo;
String? _currentDownloadTaskId;
Timer? _autoCheckTimer;
StreamSubscription<NetworkStatus>? _networkSubscription;
//
final List<StreamSubscription> _subscriptions = [];
final Map<String, WeakReference<Object>> _weakRefs = {};
///
void _init() {
_versionComparator = VersionComparator(
strategy: _config.debugMode ? VersionCompareStrategy.buildNumber : VersionCompareStrategy.semantic,
);
// Flutter相关服务
_scheduleFlutterServicesInit();
// - Flutter绑定
_networkSubscription = _networkMonitor.statusStream.listen(_onNetworkStatusChanged);
_subscriptions.add(_networkSubscription!);
//
if (_config.autoCheck) {
_startAutoCheck();
}
}
/// Flutter相关服务
void _scheduleFlutterServicesInit() {
//
_initFlutterServices();
}
/// Flutter相关服务
void _initFlutterServices() {
try {
//
_notificationHelper.initialize(_onNotificationClick);
//
const MethodChannel('app_upgrade_plugin_channel').setMethodCallHandler((call) async {
if (call.method == 'installApkFromNotification') {
final filePath = call.arguments as String?;
if (filePath != null) {
await installApkSmart(filePath);
}
}
});
} catch (e) {
debugPrint('Failed to initialize Flutter services: $e');
}
}
///
void configure({
bool? debugMode,
int? checkIntervalHours,
bool? autoCheck,
bool? wifiOnly,
int? downloadTimeout,
int? connectTimeout,
int? maxRetryCount,
bool? supportBreakpoint,
bool? verifyIntegrity,
VersionCompareStrategy? versionStrategy,
Map<String, String>? customHeaders,
}) {
_config.updateConfig(
debugMode: debugMode,
checkIntervalHours: checkIntervalHours,
autoCheck: autoCheck,
wifiOnly: wifiOnly,
downloadTimeout: downloadTimeout,
connectTimeout: connectTimeout,
maxRetryCount: maxRetryCount,
supportBreakpoint: supportBreakpoint,
verifyIntegrity: verifyIntegrity,
customHeaders: customHeaders,
);
if (versionStrategy != null) {
_versionComparator = VersionComparator(strategy: versionStrategy);
}
//
if (autoCheck != null) {
if (autoCheck) {
_startAutoCheck();
} else {
_stopAutoCheck();
}
}
}
///
Future<String?> getPlatformVersion() async {
try {
final info = await PackageInfo.fromPlatform();
return info.version;
} catch (e) {
debugPrint('getPlatformVersion failed: $e');
return null;
}
}
/// App信息
Future<Map<String, String>> getAppInfo() async {
return await _cacheManager.get<Map<String, String>>(
'app_info',
strategy: CacheStrategy.cacheFirst,
networkFetcher: _getPackageInfoMap,
) ??
{};
}
///
Future<UpgradeInfo?> checkUpdateSmart(
String url, {
Map<String, dynamic>? params,
bool forceRefresh = false,
Duration? cacheDuration,
}) async {
try {
//
if (!_networkMonitor.isConnected) {
_notifyError('No network connection');
return null;
}
//
if (_config.wifiOnly && !_networkMonitor.isWifi) {
_notifyError('WiFi required for update check');
return null;
}
// 使
final strategy = forceRefresh ? CacheStrategy.networkFirst : CacheStrategy.cacheFirst;
final upgradeInfo = await _cacheManager.get<UpgradeInfo>(
'upgrade_info_$url',
strategy: strategy,
networkFetcher: () async {
final info = await _fetchUpgradeInfo(url, params: params);
if (info != null) {
//
final appInfo = await getAppInfo();
final currentVersion = appInfo['version'] ?? '0.0.0';
if (_versionComparator.isUpdateAvailable(currentVersion, info.versionName)) {
//
final isMajor = _versionComparator.isMajorUpdate(currentVersion, info.versionName);
//
if (isMajor && !info.isForceUpdate) {
//
debugPrint('Major update detected, suggesting force update');
}
return info;
}
}
return null;
},
);
if (upgradeInfo != null) {
_currentUpgradeInfo = upgradeInfo;
_notifyUpgrade(upgradeInfo);
//
if (_config.silentDownload && upgradeInfo.downloadUrl != null && _networkMonitor.isWifi) {
_startSilentDownload(upgradeInfo.downloadUrl!);
}
}
return upgradeInfo;
} catch (e) {
_notifyError('Check update failed: $e');
return null;
}
}
Future<Map<String, String>> _getPackageInfoMap() async {
final packageInfo = await PackageInfo.fromPlatform();
return {
'appName': packageInfo.appName,
'packageName': packageInfo.packageName,
'version': packageInfo.version,
'buildNumber': packageInfo.buildNumber,
};
}
///
Future<UpgradeInfo?> _fetchUpgradeInfo(
String url, {
Map<String, dynamic>? params,
}) async {
try {
final appInfo = await getAppInfo();
final requestParams = <String, dynamic>{
'version': appInfo['version'],
'buildNumber': appInfo['buildNumber'],
'platform': Platform.isAndroid ? 'android' : 'ios',
...?params,
};
final response = await _dio.get(url, queryParameters: requestParams);
if (response.statusCode == 200 && response.data != null) {
return UpgradeInfo.fromJson(response.data);
}
return null;
} catch (e) {
debugPrint('fetch upgrade info failed: $e');
return null;
}
}
/// APK
Future<String?> downloadApkSmart(
String url, {
String? versionName, //
Function(DownloadProgress)? onProgress,
String? savePath,
String? md5,
String? sha256,
bool resumeIfExists = true,
}) async {
if (!Platform.isAndroid) {
throw UnsupportedError('downloadApk only supports Android platform');
}
try {
//
final networkStatus = _networkMonitor.currentStatus;
if (networkStatus == null || !networkStatus.isConnected) {
_notifyError('No network connection for download');
return null;
}
//
_currentDownloadTaskId = await _downloadManager.createTask(
url: url,
savePath: savePath,
md5: md5,
sha256: sha256,
versionName: versionName ?? _currentUpgradeInfo?.versionName,
);
//
final progressSubscription = _downloadManager.getProgressStream(_currentDownloadTaskId!)?.listen((task) {
_notifyDownload(task);
//
_updateNotificationForTask(task);
if (onProgress != null && task.totalSize != null) {
onProgress(DownloadProgress(received: task.downloadedSize, total: task.totalSize!));
}
});
if (progressSubscription != null) {
_subscriptions.add(progressSubscription);
}
//
final success = await _downloadManager.startDownload(_currentDownloadTaskId!);
if (success) {
final task = _downloadManager.getTask(_currentDownloadTaskId!);
return task?.savePath;
}
return null;
} catch (e) {
_notifyError('Download failed: $e');
return null;
}
}
/// APK
Future<bool> installApkSmart(String filePath) async {
if (!Platform.isAndroid) {
return false;
}
try {
//
final file = File(filePath);
if (!await file.exists()) {
_notifyError('APK file not found');
return false;
}
// MD5
if (_currentUpgradeInfo?.apkMd5 != null) {
final isValid = await DownloadManager.verifyFileInIsolate({
'filePath': filePath,
'md5': _currentUpgradeInfo!.apkMd5,
});
if (!isValid) {
_notifyError('APK file integrity check failed');
return false;
}
}
return await AppUpgradePluginPlatform.instance.installApk(filePath);
} catch (e) {
_notifyError('Install failed: $e');
return false;
}
}
/// 使 url_launcher
Future<bool> goToAppStore(String url) async {
try {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
return await launchUrl(uri, mode: LaunchMode.externalApplication);
}
return false;
} catch (e) {
_notifyError('Failed to open app store: $e');
return false;
}
}
///
void pauseDownload() {
if (_currentDownloadTaskId != null) {
_downloadManager.pauseDownload(_currentDownloadTaskId!);
}
}
///
Future<bool> resumeDownload() async {
if (_currentDownloadTaskId != null) {
return await _downloadManager.resumeDownload(_currentDownloadTaskId!);
}
return false;
}
///
void cancelDownload() {
if (_currentDownloadTaskId != null) {
_downloadManager.cancelDownload(_currentDownloadTaskId!);
_notificationHelper.cancelNotification(); //
_currentDownloadTaskId = null;
}
}
///
Future<bool> retryDownload() async {
if (_currentDownloadTaskId != null) {
return await _downloadManager.retryDownload(_currentDownloadTaskId!);
}
return false;
}
///
DownloadTask? getCurrentDownloadTask() {
if (_currentDownloadTaskId != null) {
return _downloadManager.getTask(_currentDownloadTaskId!);
}
return null;
}
///
void addUpgradeCallback(UpgradeCallback callback) {
_upgradeCallbacks.add(callback);
}
///
void removeUpgradeCallback(UpgradeCallback callback) {
_upgradeCallbacks.remove(callback);
}
///
void addDownloadCallback(DownloadCallback callback) {
_downloadCallbacks.add(callback);
}
///
void removeDownloadCallback(DownloadCallback callback) {
_downloadCallbacks.remove(callback);
}
///
void addErrorCallback(ErrorCallback callback) {
_errorCallbacks.add(callback);
}
///
void removeErrorCallback(ErrorCallback callback) {
_errorCallbacks.remove(callback);
}
///
void _startAutoCheck() {
_stopAutoCheck();
_autoCheckTimer = Timer.periodic(Duration(hours: _config.checkIntervalHours), (_) async {
// WiFi环境下
if (_config.wifiOnly && !_networkMonitor.isWifi) {
return;
}
// 使URL
final lastCheckUrl = await _cacheManager.get<String>('last_check_url');
if (lastCheckUrl != null) {
await checkUpdateSmart(lastCheckUrl);
}
});
}
///
void _stopAutoCheck() {
_autoCheckTimer?.cancel();
_autoCheckTimer = null;
}
///
void _startSilentDownload(String url) async {
if (!_config.silentDownload) return;
if (!_networkMonitor.isWifi) return;
try {
final taskId = await _downloadManager.createTask(url: url);
await _downloadManager.startDownload(taskId);
} catch (e) {
debugPrint('Silent download failed: $e');
}
}
///
void _onNetworkStatusChanged(NetworkStatus status) {
if (_config.debugMode) {
debugPrint('Network status changed: ${status.toJson()}');
}
//
if (status.isConnected && _currentDownloadTaskId != null) {
final task = _downloadManager.getTask(_currentDownloadTaskId!);
if (task != null && task.status == DownloadStatus.paused) {
resumeDownload();
}
}
// WiFi环境下启动静默下载
if (status.type == NetworkType.wifi && _config.silentDownload && _currentUpgradeInfo?.downloadUrl != null) {
_startSilentDownload(_currentUpgradeInfo!.downloadUrl!);
}
}
///
void _notifyUpgrade(UpgradeInfo info) {
for (final callback in _upgradeCallbacks) {
callback(info);
}
}
///
void _notifyDownload(DownloadTask task) {
for (final callback in _downloadCallbacks) {
callback(task);
}
}
///
void _notifyError(String error) {
if (_config.debugMode) {
debugPrint('AppUpgrade Error: $error');
}
for (final callback in _errorCallbacks) {
callback(error);
}
}
///
Future<void> _onNotificationClick(NotificationResponse response) async {
if (response.payload != null && response.payload!.startsWith('download_complete:')) {
final filePath = response.payload!.split(':').last;
await installApkSmart(filePath);
}
}
///
void _updateNotificationForTask(DownloadTask task) async {
final progress = (task.progress * 100).toInt();
final appInfo = await _cacheManager.get<Map<String, String>>('app_info', strategy: CacheStrategy.cacheFirst);
final appName = appInfo?['appName'] ?? '应用';
switch (task.status) {
case DownloadStatus.downloading:
_notificationHelper.showDownloadProgressNotification(
progress: progress,
title: '$appName 下载中...',
body: '${(task.progress * 100).toStringAsFixed(1)}%',
);
break;
case DownloadStatus.completed:
_notificationHelper.showDownloadCompleteNotification(
title: '$appName 下载完成',
body: '点击安装新版本',
filePath: task.savePath,
);
break;
case DownloadStatus.failed:
_notificationHelper.showDownloadFailedNotification(
title: '$appName 下载失败',
body: task.errorMessage ?? '请检查网络后重试',
);
break;
case DownloadStatus.cancelled:
_notificationHelper.cancelNotification();
break;
default:
break;
}
}
///
Future<Map<String, dynamic>> getCacheStats() {
return _cacheManager.getCacheStats();
}
///
Future<void> clearCache() {
return _cacheManager.clear();
}
///
NetworkStatus? get networkStatus => _networkMonitor.currentStatus;
///
Future<void> refreshNetworkStatus() async {
await _networkMonitor.refreshNetworkStatus();
}
///
UpgradeConfig get config => _config;
///
VersionComparator get versionComparator => _versionComparator;
///
void dispose() {
//
_stopAutoCheck();
//
for (final subscription in _subscriptions) {
subscription.cancel();
}
_subscriptions.clear();
//
_upgradeCallbacks.clear();
_downloadCallbacks.clear();
_errorCallbacks.clear();
//
_downloadManager.dispose();
_networkMonitor.dispose();
_cacheManager.dispose();
//
_weakRefs.clear();
//
_currentUpgradeInfo = null;
_currentDownloadTaskId = null;
}
}

View File

@ -0,0 +1,561 @@
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'app_upgrade_plugin_platform_interface.dart';
import 'core/http_config.dart';
import 'models/upgrade_info.dart';
/// An implementation of [AppUpgradePluginPlatform] that uses method channels.
class MethodChannelAppUpgradePlugin extends AppUpgradePluginPlatform {
/// The method channel used to interact with the native platform.
@visibleForTesting
final methodChannel = const MethodChannel('app_upgrade_plugin');
late Dio _dio;
HttpConfig _httpConfig = const HttpConfig();
MethodChannelAppUpgradePlugin() {
//
if (!const bool.fromEnvironment('dart.vm.product')) {
// 使
_httpConfig = HttpConfig.development;
} else {
// 使
_httpConfig = HttpConfig.production;
}
// Dio
_initializeDio(_httpConfig);
}
/// Dio实例
void _initializeDio(HttpConfig config) {
_httpConfig = config;
// Dio
_dio = Dio(BaseOptions(
connectTimeout: Duration(seconds: config.connectTimeout),
receiveTimeout: Duration(seconds: config.receiveTimeout),
sendTimeout: Duration(seconds: config.connectTimeout),
headers: {
'User-Agent': 'AppUpgradePlugin/1.0',
'Accept': 'application/json',
'Content-Type': 'application/json',
...?config.headers,
},
//
validateStatus: (status) => true,
));
// HTTPS支持
(_dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () {
final client = HttpClient();
if (config.ignoreCertificate) {
client.badCertificateCallback = (cert, host, port) => true;
}
return client;
};
//
_dio.interceptors.clear();
//
if (config.enableLog) {
_dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) {
debugPrint('==== HTTP请求 ====');
debugPrint('${options.method} ${options.uri}');
debugPrint('请求头: ${options.headers}');
if (options.data != null) {
debugPrint('请求体: ${options.data}');
}
handler.next(options);
},
onResponse: (response, handler) {
debugPrint('==== HTTP响应 ====');
debugPrint('状态码: ${response.statusCode}');
debugPrint('URL: ${response.requestOptions.uri}');
handler.next(response);
},
onError: (DioException e, handler) {
_logDioError(e);
handler.next(e);
},
));
}
}
@override
void configureHttp(HttpConfig config) {
_initializeDio(config);
}
@override
Future<String?> getPlatformVersion() async {
final version = await methodChannel.invokeMethod<String>('getPlatformVersion');
return version;
}
@override
Future<UpgradeInfo?> checkUpdate(String url, {Map<String, dynamic>? params}) async {
try {
final packageInfo = await PackageInfo.fromPlatform();
final currentVersion = packageInfo.version;
final currentBuildNumber = packageInfo.buildNumber;
//
final requestParams = {
'version': currentVersion,
'buildNumber': currentBuildNumber,
'platform': Platform.isAndroid ? 'android' : 'ios',
...?params,
};
debugPrint('==== 检查更新 ====');
debugPrint('当前版本: $currentVersion (Build: $currentBuildNumber)');
debugPrint('请求URL: $url');
Response? response;
//
final usePost = _httpConfig.defaultMethod.toUpperCase() == 'POST' ||
(params != null && params.containsKey('_method') && params['_method'] == 'POST');
if (usePost) {
// POST请求
debugPrint('使用POST请求');
response = await _dio.post(
url,
data: requestParams,
);
} else {
// GET请求
debugPrint('使用GET请求');
response = await _dio.get(
url,
queryParameters: requestParams,
);
}
debugPrint('响应状态: ${response.statusCode}');
if (response.statusCode == 200) {
//
dynamic responseData = response.data;
// JSON
if (responseData is String) {
if (responseData.isEmpty) {
throw Exception('服务器返回空内容');
}
try {
responseData = json.decode(responseData);
} catch (_) {
throw Exception('响应数据格式错误');
}
}
debugPrint('响应数据: $responseData');
// TODO
// final upgradeInfo = UpgradeInfo.fromJson(responseData);
final upgradeInfo = UpgradeInfo.fromJson({
"hasUpdate": true,
"isForceUpdate": true,
"versionCode": "101", // Android buildNumber,
"versionName": "1.0.1", //
"updateContent": "1. 修复了xxx Bug。\n2. 优化了用户体验。",
"downloadUrl":
"https://dpc-job-oss.23544.com/infra-app/making_school_asignment_app/1.0.5/1/app-release.apk", // APK
"appStoreUrl": "https://itunes.apple.com/app/id123456", // iOS App Store
"apkSize": 20971520, // APK (: byte)
"apkMd5": "b10a8db164e0754105b7a99be72e3fe5" // APK MD5 ()
});
//
if (_compareVersion(upgradeInfo.versionCode, currentBuildNumber) > 0) {
debugPrint('✅ 发现新版本: ${upgradeInfo.versionName}');
return upgradeInfo;
} else {
debugPrint('✅ 当前已是最新版本');
return null;
}
} else {
throw Exception('服务器响应错误: ${response.statusCode}');
}
} on DioException catch (e) {
if (e.type == DioExceptionType.connectionError && e.error is SocketException) {
final socketError = e.error as SocketException;
if (socketError.message.contains('Failed host lookup')) {
debugPrint('❌ DNS解析失败请检查网络连接或域名配置');
}
}
_handleNetworkError(e);
return null;
} catch (e) {
debugPrint('❌ 检查更新失败: $e');
return null;
}
}
@override
Future<int?> getAndroidSdkVersion() async {
if (!Platform.isAndroid) return null;
return await methodChannel.invokeMethod<int>('getAndroidSdkVersion');
}
@override
Future<Map<String, String>> getAppInfo() async {
final packageInfo = await PackageInfo.fromPlatform();
return {
'appName': packageInfo.appName,
'packageName': packageInfo.packageName,
'version': packageInfo.version,
'buildNumber': packageInfo.buildNumber,
};
}
@override
Future<String?> downloadApk(String url, {Function(DownloadProgress)? onProgress, String? savePath}) async {
if (!Platform.isAndroid) {
throw PlatformException(code: 'PLATFORM_NOT_SUPPORTED', message: 'downloadApk only supports Android platform');
}
debugPrint('开始下载APK: $url');
// URL连接性
final canConnect = await testDownloadUrl(url);
if (!canConnect) {
debugPrint('错误: 无法连接到下载URL');
return null;
}
try {
//
savePath ??= await getDownloadPath();
if (savePath == null) {
debugPrint('错误: 无法获取下载路径');
throw Exception('无法获取下载路径');
}
debugPrint('下载路径: $savePath');
//
final fileName = 'app_${DateTime.now().millisecondsSinceEpoch}.apk';
final filePath = '$savePath/$fileName';
debugPrint('文件将保存到: $filePath');
//
final options = Options(
responseType: ResponseType.bytes,
followRedirects: true,
validateStatus: (status) {
return status != null && status < 500;
},
headers: {
'Accept': '*/*',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
},
);
//
final response = await _dio.download(
url,
filePath,
onReceiveProgress: (received, total) {
if (total != -1) {
final progress = (received / total * 100).toStringAsFixed(0);
debugPrint('下载进度: $progress% ($received/$total)');
}
if (onProgress != null) {
onProgress(DownloadProgress(received: received, total: total));
}
},
options: options,
deleteOnError: true,
);
debugPrint('下载完成,响应状态码: ${response.statusCode}');
//
final file = File(filePath);
if (await file.exists()) {
final fileSize = await file.length();
debugPrint('文件保存成功,大小: $fileSize 字节');
return filePath;
} else {
debugPrint('错误: 文件保存失败');
return null;
}
} on DioException catch (e) {
debugPrint('Dio下载异常:');
debugPrint(' 类型: ${e.type}');
debugPrint(' 消息: ${e.message}');
debugPrint(' 错误: ${e.error}');
if (e.response != null) {
debugPrint(' 响应状态码: ${e.response?.statusCode}');
debugPrint(' 响应数据: ${e.response?.data}');
}
return null;
} catch (e, stackTrace) {
debugPrint('下载APK失败: $e');
debugPrint('堆栈跟踪: $stackTrace');
return null;
}
}
@override
Future<bool> installApk(String filePath) async {
if (!Platform.isAndroid) {
debugPrint('installApk: 非Android平台');
return false;
}
try {
debugPrint('开始安装APK: $filePath');
// Check if file exists
final file = File(filePath);
if (!await file.exists()) {
debugPrint('安装失败: APK文件不存在 - $filePath');
return false;
}
final result = await methodChannel.invokeMethod<bool>('installApk', {'filePath': filePath});
debugPrint('安装APK结果: $result');
return result ?? false;
} catch (e) {
debugPrint('安装APK异常: $e');
// Provide more specific error messages
if (e.toString().contains('PERMISSION_DENIED')) {
debugPrint('安装失败: 缺少安装未知应用权限');
} else if (e.toString().contains('FILE_NOT_FOUND')) {
debugPrint('安装失败: APK文件未找到');
} else if (e.toString().contains('FILEPROVIDER_ERROR')) {
debugPrint('安装失败: FileProvider配置错误');
} else if (e.toString().contains('INTENT_ERROR')) {
debugPrint('安装失败: 无法启动安装程序');
}
return false;
}
}
@override
Future<bool> goToAppStore(String url) async {
try {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
return true;
}
return false;
} catch (e) {
debugPrint('跳转应用商店失败: $e');
return false;
}
}
@override
Future<String?> getDownloadPath() async {
try {
// 使
final nativePath = await methodChannel.invokeMethod<String>('getDownloadPath');
if (nativePath != null && nativePath.isNotEmpty) {
debugPrint('使用原生下载路径: $nativePath');
return nativePath;
}
} catch (e) {
debugPrint('获取原生下载路径失败: $e');
}
// 使 path_provider
try {
Directory? directory;
if (Platform.isAndroid) {
// Android: 使
directory = await getExternalStorageDirectory();
if (directory != null) {
// Download
final downloadDir = Directory('${directory.path}/Download');
if (!await downloadDir.exists()) {
await downloadDir.create(recursive: true);
}
debugPrint('使用外部存储下载路径: ${downloadDir.path}');
return downloadDir.path;
}
}
// 使
directory = await getApplicationDocumentsDirectory();
final downloadDir = Directory('${directory.path}/downloads');
if (!await downloadDir.exists()) {
await downloadDir.create(recursive: true);
}
debugPrint('使用应用文档下载路径: ${downloadDir.path}');
return downloadDir.path;
} catch (e) {
debugPrint('获取备用下载路径失败: $e');
return null;
}
}
@override
Future<bool> checkApkExists(String version, String? md5) async {
if (!Platform.isAndroid) return false;
final result = await methodChannel.invokeMethod<bool>('checkApkExists', {
'version': version,
'md5': md5,
});
return result ?? false;
}
///
/// 1v1大于v20-1v1小于v2
int _compareVersion(String v1, String v2) {
try {
final version1 = int.tryParse(v1) ?? 0;
final version2 = int.tryParse(v2) ?? 0;
if (version1 > version2) {
return 1;
} else if (version1 < version2) {
return -1;
} else {
return 0;
}
} catch (e) {
return 0;
}
}
/// URL的连接性
Future<bool> testDownloadUrl(String url) async {
try {
debugPrint('测试下载URL连接: $url');
final response = await _dio.head(
url,
options: Options(
receiveTimeout: const Duration(seconds: 10),
sendTimeout: const Duration(seconds: 10),
validateStatus: (status) => true,
headers: {
'User-Agent': 'AppUpgradePlugin/1.0',
},
),
);
debugPrint('URL测试响应状态码: ${response.statusCode}');
debugPrint('响应头: ${response.headers.map}');
if (response.statusCode == 200 || response.statusCode == 206) {
final contentLength = response.headers.value('content-length');
if (contentLength != null) {
final size = int.tryParse(contentLength) ?? 0;
debugPrint('文件大小: ${(size / 1024 / 1024).toStringAsFixed(2)} MB');
}
return true;
} else if (response.statusCode == 404) {
debugPrint('错误: 文件不存在 (404)');
} else if (response.statusCode == 403) {
debugPrint('错误: 访问被拒绝 (403)');
} else if (response.statusCode == 401) {
debugPrint('错误: 需要认证 (401)');
} else {
debugPrint('错误: 未知状态 (${response.statusCode})');
}
return false;
} catch (e) {
debugPrint('测试URL连接失败: $e');
return false;
}
}
/// Dio错误详情
void _logDioError(DioException e) {
debugPrint('========== Dio错误详情 ==========');
debugPrint('错误类型: ${e.type}');
debugPrint('错误消息: ${e.message}');
if (e.error != null) {
debugPrint('原始错误: ${e.error}');
if (e.error is SocketException) {
final socketError = e.error as SocketException;
debugPrint('Socket错误代码: ${socketError.osError?.errorCode}');
debugPrint('Socket错误消息: ${socketError.osError?.message}');
}
}
if (e.response != null) {
debugPrint('响应状态码: ${e.response?.statusCode}');
debugPrint('响应消息: ${e.response?.statusMessage}');
}
debugPrint('请求URL: ${e.requestOptions.uri}');
debugPrint('请求方法: ${e.requestOptions.method}');
debugPrint('================================');
}
///
void _handleNetworkError(DioException e) {
String errorMessage = '网络请求失败';
switch (e.type) {
case DioExceptionType.connectionTimeout:
errorMessage = '连接超时,请检查网络';
break;
case DioExceptionType.sendTimeout:
errorMessage = '发送超时,请检查网络';
break;
case DioExceptionType.receiveTimeout:
errorMessage = '接收超时,请检查网络';
break;
case DioExceptionType.badResponse:
errorMessage = '服务器响应错误 (${e.response?.statusCode})';
break;
case DioExceptionType.cancel:
errorMessage = '请求已取消';
break;
case DioExceptionType.connectionError:
if (e.error is SocketException) {
final socketError = e.error as SocketException;
if (socketError.message.contains('Failed host lookup')) {
errorMessage = 'DNS解析失败请检查域名或网络设置';
} else if (socketError.message.contains('Connection refused')) {
errorMessage = '连接被拒绝,请检查服务器状态';
} else if (socketError.message.contains('Network is unreachable')) {
errorMessage = '网络不可达,请检查网络连接';
} else {
errorMessage = '网络连接错误: ${socketError.message}';
}
} else {
errorMessage = '连接错误: ${e.message}';
}
break;
case DioExceptionType.badCertificate:
errorMessage = '证书验证失败';
break;
case DioExceptionType.unknown:
errorMessage = '未知错误: ${e.message}';
break;
}
debugPrint('网络错误: $errorMessage');
// Toast
// throw NetworkException(errorMessage);
}
}

View File

@ -0,0 +1,75 @@
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import 'app_upgrade_plugin_method_channel.dart';
import 'core/http_config.dart';
import 'models/upgrade_info.dart';
abstract class AppUpgradePluginPlatform extends PlatformInterface {
/// Constructs a AppUpgradePluginPlatform.
AppUpgradePluginPlatform() : super(token: _token);
static final Object _token = Object();
static AppUpgradePluginPlatform _instance = MethodChannelAppUpgradePlugin();
/// The default instance of [AppUpgradePluginPlatform] to use.
///
/// Defaults to [MethodChannelAppUpgradePlugin].
static AppUpgradePluginPlatform get instance => _instance;
/// Platform-specific implementations should set this with their own
/// platform-specific class that extends [AppUpgradePluginPlatform] when
/// they register themselves.
static set instance(AppUpgradePluginPlatform instance) {
PlatformInterface.verifyToken(instance, _token);
_instance = instance;
}
Future<String?> getPlatformVersion() {
throw UnimplementedError('platformVersion() has not been implemented.');
}
Future<int?> getAndroidSdkVersion() {
throw UnimplementedError('getAndroidSdkVersion() has not been implemented.');
}
/// HTTP设置
void configureHttp(HttpConfig config) {
throw UnimplementedError('configureHttp() has not been implemented.');
}
/// App版本信息
Future<Map<String, String>> getAppInfo() {
throw UnimplementedError('getAppInfo() has not been implemented.');
}
///
Future<UpgradeInfo?> checkUpdate(String url, {Map<String, dynamic>? params}) {
throw UnimplementedError('checkUpdate() has not been implemented.');
}
/// APK文件Android
Future<String?> downloadApk(String url, {Function(DownloadProgress)? onProgress, String? savePath}) {
throw UnimplementedError('downloadApk() has not been implemented.');
}
/// APKAndroid
Future<bool> installApk(String filePath) {
throw UnimplementedError('installApk() has not been implemented.');
}
///
Future<bool> goToAppStore(String url) {
throw UnimplementedError('goToAppStore() has not been implemented.');
}
///
Future<String?> getDownloadPath() {
throw UnimplementedError('getDownloadPath() has not been implemented.');
}
/// APK
Future<bool> checkApkExists(String version, String? md5) {
throw UnimplementedError('checkApkExists() has not been implemented.');
}
}

1152
lib/app_upgrade_simple.dart Normal file

File diff suppressed because it is too large Load Diff

495
lib/core/cache_manager.dart Normal file
View File

@ -0,0 +1,495 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; // Added for WidgetsBinding
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
///
class CacheEntry<T> {
final String key;
final T data;
final DateTime createdAt;
final DateTime? expiresAt;
final Map<String, dynamic>? metadata;
CacheEntry({required this.key, required this.data, required this.createdAt, this.expiresAt, this.metadata});
bool get isExpired {
if (expiresAt == null) return false;
return DateTime.now().isAfter(expiresAt!);
}
Map<String, dynamic> toJson() => {
'key': key,
'data': data is Map || data is List ? data : data.toString(),
'createdAt': createdAt.toIso8601String(),
'expiresAt': expiresAt?.toIso8601String(),
'metadata': metadata,
};
factory CacheEntry.fromJson(Map<String, dynamic> json) {
return CacheEntry<T>(
key: json['key'],
data: json['data'] as T,
createdAt: DateTime.parse(json['createdAt']),
expiresAt: json['expiresAt'] != null ? DateTime.parse(json['expiresAt']) : null,
metadata: json['metadata'],
);
}
}
///
enum CacheStrategy {
/// 使
cacheFirst,
/// 使使
networkFirst,
/// 使
cacheOnly,
/// 使
networkOnly,
///
fastest,
}
///
class CacheManager {
static CacheManager? _instance;
static CacheManager get instance {
_instance ??= CacheManager._();
return _instance!;
}
CacheManager._() {
_scheduleInit();
}
late SharedPreferences _prefs;
late Directory _cacheDir;
final Map<String, CacheEntry> _memoryCache = {};
final Map<String, Timer> _autoCleanTimers = {};
static const String _cachePrefix = 'app_upgrade_cache_';
static const String _cacheMetaKey = 'cache_metadata';
static const int _maxMemoryCacheSize = 50; //
static const int _maxDiskCacheSize = 100 * 1024 * 1024; // 100MB
///
void _scheduleInit() {
//
_init().catchError((e) {
debugPrint('Failed to initialize CacheManager: $e');
});
}
///
Future<void> _init() async {
_prefs = await SharedPreferences.getInstance();
_cacheDir = await _getCacheDirectory();
_startAutoClean();
await _cleanExpiredCache();
}
///
Future<Directory> _getCacheDirectory() async {
final tempDir = await getTemporaryDirectory();
final cacheDir = Directory('${tempDir.path}/app_upgrade_cache');
if (!await cacheDir.exists()) {
await cacheDir.create(recursive: true);
}
return cacheDir;
}
///
Future<void> save<T>({
required String key,
required T data,
Duration? expiration,
CacheStrategy strategy = CacheStrategy.cacheFirst,
Map<String, dynamic>? metadata,
}) async {
final fullKey = '$_cachePrefix$key';
final expiresAt = expiration != null ? DateTime.now().add(expiration) : null;
final entry = CacheEntry<T>(
key: fullKey,
data: data,
createdAt: DateTime.now(),
expiresAt: expiresAt,
metadata: metadata,
);
//
_saveToMemory(fullKey, entry);
//
await _saveToDisk(fullKey, entry);
//
await _updateCacheMetadata(fullKey, entry);
}
///
Future<T?> get<T>(
String key, {
CacheStrategy strategy = CacheStrategy.cacheFirst,
Future<T?> Function()? networkFetcher,
}) async {
final fullKey = '$_cachePrefix$key';
switch (strategy) {
case CacheStrategy.cacheFirst:
return await _getCacheFirst<T>(fullKey, networkFetcher);
case CacheStrategy.networkFirst:
return await _getNetworkFirst<T>(fullKey, networkFetcher);
case CacheStrategy.cacheOnly:
return await _getCacheOnly<T>(fullKey);
case CacheStrategy.networkOnly:
return await _getNetworkOnly<T>(networkFetcher);
case CacheStrategy.fastest:
return await _getFastest<T>(fullKey, networkFetcher);
}
}
///
Future<T?> _getCacheFirst<T>(String key, Future<T?> Function()? fetcher) async {
//
final memoryEntry = _memoryCache[key];
if (memoryEntry != null && !memoryEntry.isExpired) {
return memoryEntry.data as T?;
}
//
final diskEntry = await _getFromDisk<T>(key);
if (diskEntry != null && !diskEntry.isExpired) {
_saveToMemory(key, diskEntry);
return diskEntry.data;
}
//
if (fetcher != null) {
try {
final data = await fetcher();
if (data != null) {
await save(key: key.replaceFirst(_cachePrefix, ''), data: data);
}
return data;
} catch (e) {
//
if (diskEntry != null) {
return diskEntry.data;
}
rethrow;
}
}
return null;
}
///
Future<T?> _getNetworkFirst<T>(String key, Future<T?> Function()? fetcher) async {
if (fetcher != null) {
try {
final data = await fetcher();
if (data != null) {
await save(key: key.replaceFirst(_cachePrefix, ''), data: data);
}
return data;
} catch (e) {
// 使
final entry = await _getFromDisk<T>(key) ?? _memoryCache[key] as CacheEntry<T>?;
if (entry != null) {
return entry.data;
}
rethrow;
}
}
return null;
}
///
Future<T?> _getCacheOnly<T>(String key) async {
final memoryEntry = _memoryCache[key];
if (memoryEntry != null) {
return memoryEntry.data as T?;
}
final diskEntry = await _getFromDisk<T>(key);
if (diskEntry != null) {
_saveToMemory(key, diskEntry);
return diskEntry.data;
}
return null;
}
///
Future<T?> _getNetworkOnly<T>(Future<T?> Function()? fetcher) async {
if (fetcher != null) {
return await fetcher();
}
return null;
}
///
Future<T?> _getFastest<T>(String key, Future<T?> Function()? fetcher) async {
final futures = <Future<T?>>[];
//
futures.add(_getCacheOnly<T>(key));
//
if (fetcher != null) {
futures.add(fetcher());
}
//
try {
final result = await Future.any(futures);
//
if (result != null && fetcher != null) {
final cacheResult = await _getCacheOnly<T>(key);
if (cacheResult == null || cacheResult != result) {
await save(key: key.replaceFirst(_cachePrefix, ''), data: result);
}
}
return result;
} catch (e) {
return null;
}
}
///
void _saveToMemory<T>(String key, CacheEntry<T> entry) {
_memoryCache[key] = entry;
//
if (_memoryCache.length > _maxMemoryCacheSize) {
//
final oldestKey =
_memoryCache.entries.reduce((a, b) => a.value.createdAt.isBefore(b.value.createdAt) ? a : b).key;
_memoryCache.remove(oldestKey);
}
}
///
Future<void> _saveToDisk<T>(String key, CacheEntry<T> entry) async {
try {
final file = File('${_cacheDir.path}/${_encodeKey(key)}.json');
final json = jsonEncode(entry.toJson());
await file.writeAsString(json);
//
await _checkDiskCacheSize();
} catch (e) {
debugPrint('Failed to save to disk cache: $e');
}
}
///
Future<CacheEntry<T>?> _getFromDisk<T>(String key) async {
try {
final file = File('${_cacheDir.path}/${_encodeKey(key)}.json');
if (!await file.exists()) return null;
final json = await file.readAsString();
final data = jsonDecode(json);
return CacheEntry<T>.fromJson(data);
} catch (e) {
debugPrint('Failed to get from disk cache: $e');
return null;
}
}
///
Future<void> _updateCacheMetadata(String key, CacheEntry entry) async {
final metadata = _prefs.getString(_cacheMetaKey);
final metaMap = metadata != null ? jsonDecode(metadata) : {};
metaMap[key] = {
'createdAt': entry.createdAt.toIso8601String(),
'expiresAt': entry.expiresAt?.toIso8601String(),
'size': entry.toJson().toString().length,
};
await _prefs.setString(_cacheMetaKey, jsonEncode(metaMap));
}
///
Future<void> delete(String key) async {
final fullKey = '$_cachePrefix$key';
//
_memoryCache.remove(fullKey);
//
final file = File('${_cacheDir.path}/${_encodeKey(fullKey)}.json');
if (await file.exists()) {
await file.delete();
}
//
final metadata = _prefs.getString(_cacheMetaKey);
if (metadata != null) {
final metaMap = jsonDecode(metadata);
metaMap.remove(fullKey);
await _prefs.setString(_cacheMetaKey, jsonEncode(metaMap));
}
}
///
Future<void> clear() async {
//
_memoryCache.clear();
//
if (await _cacheDir.exists()) {
await _cacheDir.delete(recursive: true);
await _cacheDir.create();
}
//
await _prefs.remove(_cacheMetaKey);
}
///
Future<void> _cleanExpiredCache() async {
//
_memoryCache.removeWhere((key, entry) => entry.isExpired);
//
final metadata = _prefs.getString(_cacheMetaKey);
if (metadata != null) {
final metaMap = jsonDecode(metadata) as Map<String, dynamic>;
final keysToRemove = <String>[];
for (final entry in metaMap.entries) {
final expiresAt = entry.value['expiresAt'];
if (expiresAt != null) {
final expireTime = DateTime.parse(expiresAt);
if (DateTime.now().isAfter(expireTime)) {
keysToRemove.add(entry.key);
}
}
}
for (final key in keysToRemove) {
await delete(key.replaceFirst(_cachePrefix, ''));
}
}
}
///
Future<void> _checkDiskCacheSize() async {
int totalSize = 0;
final files = await _cacheDir.list().toList();
for (final file in files) {
if (file is File) {
totalSize += await file.length();
}
}
if (totalSize > _maxDiskCacheSize) {
//
final fileStats = <MapEntry<File, FileStat>>[];
for (final file in files) {
if (file is File) {
fileStats.add(MapEntry(file, await file.stat()));
}
}
fileStats.sort((a, b) => a.value.accessed.compareTo(b.value.accessed));
//
for (final entry in fileStats) {
final fileSize = await entry.key.length();
await entry.key.delete();
totalSize -= fileSize;
if (totalSize <= (_maxDiskCacheSize * 0.8).toInt()) break;
}
}
}
///
String _encodeKey(String key) {
return base64Encode(utf8.encode(key)).replaceAll('/', '_');
}
///
void _startAutoClean() {
//
_autoCleanTimers['expired'] = Timer.periodic(const Duration(hours: 1), (_) => _cleanExpiredCache());
}
///
Future<Map<String, dynamic>> getCacheStats() async {
int memoryCount = _memoryCache.length;
int memorySize = 0;
for (final entry in _memoryCache.values) {
memorySize += entry.toJson().toString().length;
}
int diskCount = 0;
int diskSize = 0;
final files = await _cacheDir.list().toList();
for (final file in files) {
if (file is File) {
diskCount++;
diskSize += await file.length();
}
}
return {
'memoryCache': {'count': memoryCount, 'size': memorySize, 'sizeFormatted': _formatBytes(memorySize)},
'diskCache': {'count': diskCount, 'size': diskSize, 'sizeFormatted': _formatBytes(diskSize)},
'total': {
'count': memoryCount + diskCount,
'size': memorySize + diskSize,
'sizeFormatted': _formatBytes(memorySize + diskSize),
},
};
}
///
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(2)} KB';
if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
}
///
Future<void> preload(List<String> keys) async {
for (final key in keys) {
final fullKey = '$_cachePrefix$key';
if (!_memoryCache.containsKey(fullKey)) {
final entry = await _getFromDisk(fullKey);
if (entry != null && !entry.isExpired) {
_saveToMemory(fullKey, entry);
}
}
}
}
///
void dispose() {
for (final timer in _autoCleanTimers.values) {
timer.cancel();
}
_autoCleanTimers.clear();
_memoryCache.clear();
}
}

View File

@ -0,0 +1,474 @@
import 'dart:async';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'upgrade_config.dart';
///
enum DownloadStatus { pending, downloading, paused, completed, failed, cancelled }
///
class DownloadTask {
final String id;
final String url;
final String savePath;
final Map<String, String>? headers;
final String? md5;
final String? sha256;
int? totalSize;
DownloadStatus status;
int downloadedSize;
double progress;
String? errorMessage;
int retryCount;
DateTime? startTime;
DateTime? endTime;
DownloadTask({
required this.id,
required this.url,
required this.savePath,
this.headers,
this.md5,
this.sha256,
this.totalSize,
this.status = DownloadStatus.pending,
this.downloadedSize = 0,
this.progress = 0.0,
this.errorMessage,
this.retryCount = 0,
this.startTime,
this.endTime,
});
Map<String, dynamic> toJson() => {
'id': id,
'url': url,
'savePath': savePath,
'headers': headers,
'md5': md5,
'sha256': sha256,
'totalSize': totalSize,
'status': status.toString(),
'downloadedSize': downloadedSize,
'progress': progress,
'errorMessage': errorMessage,
'retryCount': retryCount,
'startTime': startTime?.toIso8601String(),
'endTime': endTime?.toIso8601String(),
};
}
///
class DownloadManager {
static DownloadManager? _instance;
static DownloadManager get instance {
_instance ??= DownloadManager._();
return _instance!;
}
DownloadManager._() {
_initializeDio();
}
final Dio _dio = Dio();
final Map<String, DownloadTask> _tasks = {};
final Map<String, CancelToken> _cancelTokens = {};
final Map<String, StreamController<DownloadTask>> _progressControllers = {};
final UpgradeConfig _config = UpgradeConfig.instance;
Timer? _speedCalculatorTimer;
final Map<String, List<int>> _speedSamples = {};
/// Dio配置
void _initializeDio() {
_dio.options.connectTimeout = Duration(seconds: _config.connectTimeout);
_dio.options.receiveTimeout = Duration(seconds: _config.downloadTimeout);
if (_config.proxyUrl != null) {
(_dio.httpClientAdapter as dynamic).onHttpClientCreate = (client) {
client.findProxy = (uri) {
return 'PROXY ${_config.proxyUrl}';
};
return client;
};
}
//
_dio.interceptors.add(LogInterceptor(request: _config.debugMode, responseBody: false, error: _config.debugMode));
_dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
//
options.headers.addAll(_config.customHeaders);
handler.next(options);
},
onError: (error, handler) {
if (_config.debugMode) {
debugPrint('Download error: ${error.message}');
}
handler.next(error);
},
),
);
}
///
Future<String> createTask({
required String url,
String? savePath,
Map<String, String>? headers,
String? md5,
String? sha256,
int? expectedSize,
String? versionName, //
}) async {
final taskId = DateTime.now().millisecondsSinceEpoch.toString();
if (savePath == null) {
final dir = await _getDownloadDirectory();
// 使
final fileName = versionName != null ? 'app-upgrade-$versionName.apk' : url.split('/').last.split('?').first;
savePath = '${dir.path}/$fileName';
}
final task = DownloadTask(
id: taskId,
url: url,
savePath: savePath,
headers: headers,
md5: md5,
sha256: sha256,
totalSize: expectedSize,
);
_tasks[taskId] = task;
_progressControllers[taskId] = StreamController<DownloadTask>.broadcast();
return taskId;
}
///
Future<bool> startDownload(String taskId) async {
final task = _tasks[taskId];
if (task == null) return false;
task.status = DownloadStatus.downloading;
task.startTime = DateTime.now();
final cancelToken = CancelToken();
_cancelTokens[taskId] = cancelToken;
//
_startSpeedCalculator(taskId);
try {
//
if (_config.supportBreakpoint && await _checkBreakpointSupport(task.url)) {
await _downloadWithBreakpoint(task, cancelToken);
} else {
await _downloadNormal(task, cancelToken);
}
//
if (_config.verifyIntegrity && (task.md5 != null || task.sha256 != null)) {
final isValid = await _verifyFileIntegrity(task);
if (!isValid) {
throw Exception('File integrity check failed');
}
}
task.status = DownloadStatus.completed;
task.endTime = DateTime.now();
task.progress = 1.0;
_notifyProgress(task);
return true;
} catch (e) {
if (e is DioException && e.type == DioExceptionType.cancel) {
task.status = DownloadStatus.cancelled;
} else {
task.status = DownloadStatus.failed;
task.errorMessage = e.toString();
//
if (task.retryCount < _config.maxRetryCount) {
task.retryCount++;
await Future.delayed(Duration(seconds: _config.retryDelay));
return startDownload(taskId);
}
}
_notifyProgress(task);
return false;
} finally {
_stopSpeedCalculator(taskId);
_cancelTokens.remove(taskId);
}
}
///
Future<void> _downloadNormal(DownloadTask task, CancelToken cancelToken) async {
final response = await _dio.download(
task.url,
task.savePath,
cancelToken: cancelToken,
options: Options(headers: task.headers),
onReceiveProgress: (received, total) {
task.downloadedSize = received;
task.totalSize ??= total;
task.progress = total > 0 ? received / total : 0.0;
_notifyProgress(task);
},
);
if (response.statusCode != 200 && response.statusCode != 206) {
throw Exception('Download failed with status: ${response.statusCode}');
}
}
///
Future<void> _downloadWithBreakpoint(DownloadTask task, CancelToken cancelToken) async {
final file = File(task.savePath);
int downloadedBytes = 0;
//
if (await file.exists()) {
downloadedBytes = await file.length();
task.downloadedSize = downloadedBytes;
}
//
if (task.totalSize != null && downloadedBytes >= task.totalSize!) {
task.progress = 1.0;
_notifyProgress(task);
return;
}
// Range头
final headers = Map<String, dynamic>.from(task.headers ?? {});
headers['Range'] = 'bytes=$downloadedBytes-';
//
final raf = await file.open(mode: FileMode.append);
try {
final response = await _dio.get<ResponseBody>(
task.url,
cancelToken: cancelToken,
options: Options(headers: headers, responseType: ResponseType.stream),
);
final total = int.tryParse(response.headers.value('content-length') ?? '0') ?? 0;
task.totalSize ??= total + downloadedBytes;
//
await for (final chunk in response.data!.stream) {
await raf.writeFrom(chunk);
task.downloadedSize += chunk.length;
task.progress = task.totalSize! > 0 ? task.downloadedSize / task.totalSize! : 0.0;
_notifyProgress(task);
}
} finally {
await raf.close();
}
}
///
Future<bool> _checkBreakpointSupport(String url) async {
try {
final response = await _dio.head(url);
final acceptRanges = response.headers.value('accept-ranges');
return acceptRanges == 'bytes';
} catch (e) {
return false;
}
}
///
void pauseDownload(String taskId) {
final cancelToken = _cancelTokens[taskId];
if (cancelToken != null && !cancelToken.isCancelled) {
cancelToken.cancel('User paused');
final task = _tasks[taskId];
if (task != null) {
task.status = DownloadStatus.paused;
_notifyProgress(task);
}
}
}
///
Future<bool> resumeDownload(String taskId) async {
final task = _tasks[taskId];
if (task != null && task.status == DownloadStatus.paused) {
return startDownload(taskId);
}
return false;
}
///
void cancelDownload(String taskId) {
final cancelToken = _cancelTokens[taskId];
if (cancelToken != null && !cancelToken.isCancelled) {
cancelToken.cancel('User cancelled');
}
final task = _tasks[taskId];
if (task != null) {
task.status = DownloadStatus.cancelled;
_notifyProgress(task);
//
final file = File(task.savePath);
if (file.existsSync()) {
file.deleteSync();
}
}
_cleanupTask(taskId);
}
///
Future<bool> retryDownload(String taskId) async {
final task = _tasks[taskId];
if (task != null && task.status == DownloadStatus.failed) {
task.retryCount = 0;
task.errorMessage = null;
return startDownload(taskId);
}
return false;
}
///
DownloadTask? getTask(String taskId) => _tasks[taskId];
///
List<DownloadTask> getAllTasks() => _tasks.values.toList();
///
Stream<DownloadTask>? getProgressStream(String taskId) {
return _progressControllers[taskId]?.stream;
}
///
void _cleanupTask(String taskId) {
_tasks.remove(taskId);
_cancelTokens.remove(taskId);
_progressControllers[taskId]?.close();
_progressControllers.remove(taskId);
_speedSamples.remove(taskId);
}
///
void _notifyProgress(DownloadTask task) {
_progressControllers[task.id]?.add(task);
}
///
void _startSpeedCalculator(String taskId) {
_speedSamples[taskId] = [];
_speedCalculatorTimer?.cancel();
_speedCalculatorTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
final task = _tasks[taskId];
if (task != null && task.status == DownloadStatus.downloading) {
final samples = _speedSamples[taskId]!;
samples.add(task.downloadedSize);
// 10
if (samples.length > 10) {
samples.removeAt(0);
}
//
if (samples.length >= 2) {
final speed = samples.last - samples.first;
final timeSpan = samples.length - 1;
final avgSpeed = speed / timeSpan;
//
if (_config.debugMode) {
debugPrint('Download speed: ${_formatBytes(avgSpeed.toInt())}/s');
}
}
}
});
}
///
void _stopSpeedCalculator(String taskId) {
_speedSamples.remove(taskId);
if (_speedSamples.isEmpty) {
_speedCalculatorTimer?.cancel();
_speedCalculatorTimer = null;
}
}
///
Future<bool> _verifyFileIntegrity(DownloadTask task) async {
return compute(verifyFileInIsolate, {'filePath': task.savePath, 'md5': task.md5, 'sha256': task.sha256});
}
/// Isolate中验证文件
static Future<bool> verifyFileInIsolate(Map<String, dynamic> params) async {
final filePath = params['filePath'] as String;
final expectedMd5 = params['md5'] as String?;
final expectedSha256 = params['sha256'] as String?;
final file = File(filePath);
if (!await file.exists()) return false;
final bytes = await file.readAsBytes();
if (expectedMd5 != null) {
final md5Hash = md5.convert(bytes).toString();
if (md5Hash != expectedMd5) return false;
}
if (expectedSha256 != null) {
final sha256Hash = sha256.convert(bytes).toString();
if (sha256Hash != expectedSha256) return false;
}
return true;
}
///
Future<Directory> _getDownloadDirectory() async {
if (Platform.isAndroid) {
return (await getExternalStorageDirectory())!;
} else {
return getApplicationDocumentsDirectory();
}
}
///
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(2)} KB';
if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
}
///
void clearAllTasks() {
for (final taskId in _tasks.keys.toList()) {
cancelDownload(taskId);
}
_tasks.clear();
}
///
void dispose() {
clearAllTasks();
_speedCalculatorTimer?.cancel();
_dio.close();
}
}

50
lib/core/http_config.dart Normal file
View File

@ -0,0 +1,50 @@
/// HTTP配置类
class HttpConfig {
/// HTTPS证书验证使
final bool ignoreCertificate;
///
final int connectTimeout;
///
final int receiveTimeout;
///
final bool enableLog;
///
final Map<String, dynamic>? headers;
/// GET或POST
final String defaultMethod;
const HttpConfig({
this.ignoreCertificate = false,
this.connectTimeout = 30,
this.receiveTimeout = 60,
this.enableLog = true,
this.headers,
this.defaultMethod = 'GET',
});
///
static const HttpConfig development = HttpConfig(
ignoreCertificate: true,
enableLog: true,
);
///
static const HttpConfig production = HttpConfig(
ignoreCertificate: false,
enableLog: false,
);
///
static HttpConfig get auto => const bool.fromEnvironment('dart.vm.product') ? production : development;
/// 使
static const HttpConfig unsafe = HttpConfig(
ignoreCertificate: true,
enableLog: true,
);
}

View File

@ -0,0 +1,516 @@
import 'dart:async';
import 'dart:io';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; // Added for WidgetsBinding
///
enum NetworkType { none, mobile, wifi, ethernet, bluetooth, vpn, other }
///
enum NetworkQuality {
unknown,
poor, // (<100KB/s)
moderate, // (100KB/s - 500KB/s)
good, // (500KB/s - 2MB/s)
excellent, // (>2MB/s)
}
///
class NetworkStatus {
final NetworkType type;
final NetworkQuality quality;
final bool isConnected;
final bool isMetered;
final double? downloadSpeed;
final double? uploadSpeed;
final int? ping;
final DateTime timestamp;
NetworkStatus({
required this.type,
required this.quality,
required this.isConnected,
required this.isMetered,
this.downloadSpeed,
this.uploadSpeed,
this.ping,
DateTime? timestamp,
}) : timestamp = timestamp ?? DateTime.now();
///
bool get isSuitableForLargeDownload {
return isConnected && (type == NetworkType.wifi || type == NetworkType.ethernet) && quality != NetworkQuality.poor;
}
///
bool get shouldLimitSpeed {
return type == NetworkType.mobile || quality == NetworkQuality.poor;
}
Map<String, dynamic> toJson() => {
'type': type.toString(),
'quality': quality.toString(),
'isConnected': isConnected,
'isMetered': isMetered,
'downloadSpeed': downloadSpeed,
'uploadSpeed': uploadSpeed,
'ping': ping,
'timestamp': timestamp.toIso8601String(),
};
}
///
class NetworkMonitor {
static NetworkMonitor? _instance;
static NetworkMonitor get instance {
_instance ??= NetworkMonitor._();
return _instance!;
}
NetworkMonitor._() {
_init().catchError((e) {
debugPrint('NetworkMonitor initialization failed: $e');
});
}
final Connectivity _connectivity = Connectivity();
NetworkStatus? _currentStatus;
final _statusController = StreamController<NetworkStatus>.broadcast();
Stream<NetworkStatus> get statusStream => _statusController.stream;
StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;
Timer? _qualityCheckTimer;
final List<double> _speedSamples = [];
static const int _maxSamples = 10;
///
Future<void> _init() async {
debugPrint('NetworkMonitor: Starting initialization');
// Flutter绑定
_startMonitoring();
//
await _checkInitialStatus();
//
_startQualityCheck();
debugPrint('NetworkMonitor: Initialization completed');
}
///
Future<void> _checkInitialStatus() async {
try {
debugPrint('NetworkMonitor: Checking initial connectivity...');
final result = await _connectivity.checkConnectivity();
debugPrint('NetworkMonitor: Initial connectivity result: $result');
//
await _updateStatus(result);
} catch (e) {
debugPrint('NetworkMonitor: Failed to check initial network status: $e');
//
await _updateStatus(ConnectivityResult.none);
}
}
///
void _startMonitoring() {
debugPrint('NetworkMonitor: Starting connectivity monitoring...');
_connectivitySubscription?.cancel(); //
_connectivitySubscription = _connectivity.onConnectivityChanged.listen(
(List<ConnectivityResult> results) async {
debugPrint('NetworkMonitor: Connectivity changed: $results');
await _updateStatus(results);
},
onError: (error) {
debugPrint('NetworkMonitor: Connectivity monitoring error: $error');
},
cancelOnError: false,
);
}
///
Future<void> _updateStatus(dynamic results) async {
List<ConnectivityResult> connectivityResults;
//
if (results is ConnectivityResult) {
connectivityResults = [results];
} else if (results is List<ConnectivityResult>) {
connectivityResults = results;
} else {
debugPrint('NetworkMonitor: Invalid results type: ${results.runtimeType}');
return;
}
debugPrint('NetworkMonitor: Updating status with results: $results');
NetworkType type = NetworkType.none;
bool isConnected = false;
bool isMetered = false;
//
if (results.isNotEmpty && !results.contains(ConnectivityResult.none)) {
isConnected = true;
// WiFi > Ethernet > Mobile > VPN > Bluetooth > Other
if (results.contains(ConnectivityResult.wifi)) {
type = NetworkType.wifi;
isMetered = false;
debugPrint('NetworkMonitor: Detected WiFi connection');
} else if (results.contains(ConnectivityResult.ethernet)) {
type = NetworkType.ethernet;
isMetered = false;
debugPrint('NetworkMonitor: Detected Ethernet connection');
} else if (results.contains(ConnectivityResult.mobile)) {
type = NetworkType.mobile;
isMetered = true;
debugPrint('NetworkMonitor: Detected Mobile connection');
} else if (results.contains(ConnectivityResult.vpn)) {
type = NetworkType.vpn;
isMetered = false;
debugPrint('NetworkMonitor: Detected VPN connection');
} else if (results.contains(ConnectivityResult.bluetooth)) {
type = NetworkType.bluetooth;
isMetered = true;
debugPrint('NetworkMonitor: Detected Bluetooth connection');
} else {
type = NetworkType.other;
isMetered = true;
debugPrint('NetworkMonitor: Detected Other connection type');
}
} else {
debugPrint('NetworkMonitor: No network connection detected');
type = NetworkType.none;
isConnected = false;
}
//
final previousStatus = _currentStatus;
_currentStatus = NetworkStatus(
type: type,
quality: _currentStatus?.quality ?? NetworkQuality.unknown,
isConnected: isConnected,
isMetered: isMetered,
downloadSpeed: _currentStatus?.downloadSpeed,
uploadSpeed: _currentStatus?.uploadSpeed,
ping: _currentStatus?.ping,
);
debugPrint('NetworkMonitor: Status updated - Type: $type, Connected: $isConnected, Metered: $isMetered');
//
if (previousStatus == null ||
previousStatus.type != type ||
previousStatus.isConnected != isConnected ||
previousStatus.isMetered != isMetered) {
_statusController.add(_currentStatus!);
debugPrint('NetworkMonitor: Status change detected, notifying listeners');
}
//
if (isConnected && (type == NetworkType.wifi || type == NetworkType.ethernet)) {
_testNetworkQuality();
}
}
///
void _startQualityCheck() {
_qualityCheckTimer?.cancel();
_qualityCheckTimer = Timer.periodic(const Duration(seconds: 30), (_) {
if (_currentStatus?.isConnected == true) {
_testNetworkQuality();
}
});
}
///
Future<void> _testNetworkQuality() async {
try {
//
final ping = await _testPing();
//
final downloadSpeed = await _testDownloadSpeed();
//
final quality = _calculateQuality(downloadSpeed, ping);
_currentStatus = NetworkStatus(
type: _currentStatus?.type ?? NetworkType.other,
quality: quality,
isConnected: _currentStatus?.isConnected ?? true,
isMetered: _currentStatus?.isMetered ?? false,
downloadSpeed: downloadSpeed,
uploadSpeed: _currentStatus?.uploadSpeed,
ping: ping,
);
_statusController.add(_currentStatus!);
} catch (e) {
debugPrint('Failed to test network quality: $e');
}
}
///
Future<int> _testPing() async {
try {
final stopwatch = Stopwatch()..start();
final socket = await Socket.connect('8.8.8.8', 53, timeout: const Duration(seconds: 5));
socket.destroy();
stopwatch.stop();
return stopwatch.elapsedMilliseconds;
} catch (e) {
return 9999; //
}
}
///
Future<double> _testDownloadSpeed() async {
try {
// 使
const testUrl = 'https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png';
final stopwatch = Stopwatch()..start();
final client = HttpClient();
client.connectionTimeout = const Duration(seconds: 5);
final request = await client.getUrl(Uri.parse(testUrl));
final response = await request.close();
double bytes = 0;
await for (final chunk in response) {
bytes += chunk.length;
}
stopwatch.stop();
final seconds = stopwatch.elapsedMilliseconds / 1000;
final speed = bytes / seconds; // bytes per second
//
_speedSamples.add(speed);
if (_speedSamples.length > _maxSamples) {
_speedSamples.removeAt(0);
}
//
final avgSpeed = _speedSamples.reduce((a, b) => a + b) / _speedSamples.length;
return avgSpeed;
} catch (e) {
return 0;
}
}
///
NetworkQuality _calculateQuality(double downloadSpeed, int ping) {
//
if (downloadSpeed == 0 || ping > 1000) {
return NetworkQuality.poor;
}
// 70%30%
double score = 0;
// (0-70)
if (downloadSpeed > 2 * 1024 * 1024) {
// > 2MB/s
score += 70;
} else if (downloadSpeed > 500 * 1024) {
// > 500KB/s
score += 50;
} else if (downloadSpeed > 100 * 1024) {
// > 100KB/s
score += 30;
} else {
score += 10;
}
// (0-30)
if (ping < 50) {
score += 30;
} else if (ping < 100) {
score += 20;
} else if (ping < 200) {
score += 10;
} else {
score += 5;
}
//
if (score >= 80) {
return NetworkQuality.excellent;
} else if (score >= 60) {
return NetworkQuality.good;
} else if (score >= 40) {
return NetworkQuality.moderate;
} else {
return NetworkQuality.poor;
}
}
///
NetworkStatus? get currentStatus => _currentStatus;
///
Future<void> refreshNetworkStatus() async {
debugPrint('NetworkMonitor: Manual refresh requested');
try {
final result = await _connectivity.checkConnectivity();
debugPrint('NetworkMonitor: Manual refresh result: $result');
await _updateStatus(result);
} catch (e) {
debugPrint('NetworkMonitor: Manual refresh failed: $e');
await _updateStatus(ConnectivityResult.none);
}
}
///
bool get isConnected => _currentStatus?.isConnected ?? false;
/// WiFi连接
bool get isWifi => _currentStatus?.type == NetworkType.wifi;
///
bool get isMobile => _currentStatus?.type == NetworkType.mobile;
///
bool get isMetered => _currentStatus?.isMetered ?? false;
///
Future<bool> waitForConnection({Duration? timeout}) async {
if (isConnected) return true;
final completer = Completer<bool>();
StreamSubscription<NetworkStatus>? subscription;
Timer? timer;
subscription = statusStream.listen((status) {
if (status.isConnected) {
completer.complete(true);
subscription?.cancel();
timer?.cancel();
}
});
if (timeout != null) {
timer = Timer(timeout, () {
if (!completer.isCompleted) {
completer.complete(false);
subscription?.cancel();
}
});
}
return completer.future;
}
/// WiFi连接
Future<bool> waitForWifi({Duration? timeout}) async {
if (isWifi) return true;
final completer = Completer<bool>();
StreamSubscription<NetworkStatus>? subscription;
Timer? timer;
subscription = statusStream.listen((status) {
if (status.type == NetworkType.wifi) {
completer.complete(true);
subscription?.cancel();
timer?.cancel();
}
});
if (timeout != null) {
timer = Timer(timeout, () {
if (!completer.isCompleted) {
completer.complete(false);
subscription?.cancel();
}
});
}
return completer.future;
}
///
bool canDownload({required bool wifiOnly, required bool allowCellular}) {
if (!isConnected) return false;
if (wifiOnly && !isWifi) return false;
if (!allowCellular && isMobile) return false;
return true;
}
///
Map<String, dynamic> getSuggestedDownloadStrategy() {
if (!isConnected) {
return {'canDownload': false, 'reason': 'No network connection'};
}
final quality = _currentStatus?.quality ?? NetworkQuality.unknown;
final type = _currentStatus?.type ?? NetworkType.other;
return {
'canDownload': true,
'useParallelDownload': quality == NetworkQuality.excellent,
'maxConcurrentChunks': _getSuggestedChunks(quality),
'chunkSize': _getSuggestedChunkSize(quality),
'shouldCompress': type == NetworkType.mobile,
'recommendedTimeout': _getSuggestedTimeout(quality),
};
}
int _getSuggestedChunks(NetworkQuality quality) {
switch (quality) {
case NetworkQuality.excellent:
return 5;
case NetworkQuality.good:
return 3;
case NetworkQuality.moderate:
return 2;
default:
return 1;
}
}
int _getSuggestedChunkSize(NetworkQuality quality) {
switch (quality) {
case NetworkQuality.excellent:
return 2 * 1024 * 1024; // 2MB
case NetworkQuality.good:
return 1024 * 1024; // 1MB
case NetworkQuality.moderate:
return 512 * 1024; // 512KB
default:
return 256 * 1024; // 256KB
}
}
int _getSuggestedTimeout(NetworkQuality quality) {
switch (quality) {
case NetworkQuality.excellent:
return 30;
case NetworkQuality.good:
return 60;
case NetworkQuality.moderate:
return 120;
default:
return 300;
}
}
///
void dispose() {
_connectivitySubscription?.cancel();
_qualityCheckTimer?.cancel();
_statusController.close();
}
}

View File

@ -0,0 +1,108 @@
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
///
class NotificationHelper {
static final NotificationHelper _instance = NotificationHelper._();
static NotificationHelper get instance => _instance;
NotificationHelper._();
final FlutterLocalNotificationsPlugin _flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
///
Future<void> initialize(Future<void> Function(NotificationResponse)? onDidReceiveNotificationResponse) async {
const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher'); // 使App图标
const InitializationSettings initializationSettings = InitializationSettings(
android: initializationSettingsAndroid,
);
await _flutterLocalNotificationsPlugin.initialize(
initializationSettings,
onDidReceiveNotificationResponse: onDidReceiveNotificationResponse,
);
}
///
Future<void> showDownloadProgressNotification({
required int progress,
required String title,
required String body,
}) async {
final AndroidNotificationDetails androidPlatformChannelSpecifics = AndroidNotificationDetails(
'app_upgrade_download_channel',
'下载通知',
channelDescription: '显示应用更新的下载进度',
importance: Importance.low, // low
priority: Priority.low,
showProgress: true,
maxProgress: 100,
progress: progress,
onlyAlertOnce: true, //
);
final NotificationDetails platformChannelSpecifics = NotificationDetails(android: androidPlatformChannelSpecifics);
await _flutterLocalNotificationsPlugin.show(
0, // 使 ID便
title,
body,
platformChannelSpecifics,
payload: 'download_progress',
);
}
///
Future<void> showDownloadCompleteNotification({
required String title,
required String body,
required String filePath,
}) async {
final AndroidNotificationDetails androidPlatformChannelSpecifics = AndroidNotificationDetails(
'app_upgrade_download_channel', // 使
'下载通知',
channelDescription: '显示应用更新的下载进度',
importance: Importance.high, // 使便
priority: Priority.high,
autoCancel: true,
);
final NotificationDetails platformChannelSpecifics = NotificationDetails(android: androidPlatformChannelSpecifics);
await _flutterLocalNotificationsPlugin.show(
0, // ID 0
title,
body,
platformChannelSpecifics,
payload: 'download_complete:$filePath', // payload
);
}
///
Future<void> showDownloadFailedNotification({
required String title,
required String body,
}) async {
//
final AndroidNotificationDetails androidPlatformChannelSpecifics = AndroidNotificationDetails(
'app_upgrade_download_channel',
'下载通知',
channelDescription: '显示应用更新的下载进度',
importance: Importance.high,
priority: Priority.high,
autoCancel: true,
);
final NotificationDetails platformChannelSpecifics = NotificationDetails(android: androidPlatformChannelSpecifics);
await _flutterLocalNotificationsPlugin.show(
0,
title,
body,
platformChannelSpecifics,
payload: 'download_failed',
);
}
///
Future<void> cancelNotification() async {
await _flutterLocalNotificationsPlugin.cancel(0);
}
}

View File

@ -0,0 +1,309 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import '../app_upgrade_plugin.dart';
/// Enum representing the status of the install permission.
enum InstallPermissionStatus {
/// The user has granted the permission.
granted,
/// The user has denied the permission.
denied,
/// The user has denied the permission and selected "Don't ask again".
/// On Android, this means the user must go to settings to enable it.
permanentlyDenied,
/// The permission is restricted by the system (e.g., parental controls).
restricted,
}
/// A helper class for handling permissions required by the app upgrade process.
class PermissionHelper {
static final _plugin = AppUpgradePlugin();
/// Checks and requests storage permission (required for downloading files).
///
/// This method handles different Android versions appropriately:
/// - Android 13+ (API 33+): No storage permission needed for app-specific directories
/// - Android 11-12 (API 30-32): Uses MANAGE_EXTERNAL_STORAGE when needed
/// - Android 10 (API 29): Uses legacy storage permission with scoped storage fallback
/// - Android 9 and below: Uses legacy WRITE_EXTERNAL_STORAGE permission
static Future<bool> checkAndRequestStoragePermission({required BuildContext context}) async {
if (!Platform.isAndroid) return true;
final sdkVersion = await _getAndroidSdkVersion();
// Android 13+ (API 33+): No storage permission needed for app-specific directories
if (sdkVersion >= 33) {
debugPrint('Android 13+: Using scoped storage, no permission required');
return true;
}
// Android 11-12 (API 30-32): Try MANAGE_EXTERNAL_STORAGE first, fallback to scoped storage
if (sdkVersion >= 30) {
var status = await Permission.manageExternalStorage.status;
if (status.isGranted) return true;
if (context.mounted) {
final proceed = await _showRationaleDialog(
context,
title: '需要文件管理权限',
content: '为了下载更新文件到您选择的位置,我们需要访问设备存储。您也可以选择"拒绝",应用将使用私有存储空间。',
);
if (proceed) {
status = await Permission.manageExternalStorage.request();
if (status.isGranted) return true;
}
}
// If MANAGE_EXTERNAL_STORAGE is denied, we can still use app-specific directories
debugPrint('MANAGE_EXTERNAL_STORAGE denied, using app-specific storage');
return true;
}
// Android 10 (API 29): Use legacy storage permission, but it's limited due to scoped storage
if (sdkVersion >= 29) {
var status = await Permission.storage.status;
if (status.isGranted) return true;
if (context.mounted) {
final proceed = await _showRationaleDialog(
context,
title: '需要存储权限',
content: '为了下载更新文件,我们需要访问设备存储空间。',
);
if (!proceed) return false;
}
status = await Permission.storage.request();
// Even if denied, we can still use scoped storage
return true;
}
// Android 9 and below (API 28-): Use traditional WRITE_EXTERNAL_STORAGE
var status = await Permission.storage.status;
if (status.isGranted) return true;
if (context.mounted) {
final proceed = await _showRationaleDialog(
context,
title: '需要存储权限',
content: '为了下载更新文件,我们需要访问设备存储空间。',
);
if (!proceed) return false;
}
status = await Permission.storage.request();
return status.isGranted;
}
/// Checks and requests notification permission.
///
/// This is useful for showing download progress notifications.
static Future<bool> checkAndRequestNotificationPermission({required BuildContext context}) async {
if (!Platform.isAndroid) return true;
var status = await Permission.notification.status;
if (status.isGranted) return true;
// From Android 13, notification permission must be requested.
// On older versions, it's granted by default.
if (await _getAndroidSdkVersion() >= 33) {
if (context.mounted) {
final proceed = await _showRationaleDialog(
context,
title: '需要通知权限',
content: '为了在后台下载时向您显示更新进度,我们需要发送通知的权限。',
);
if (!proceed) return false;
}
status = await Permission.notification.request();
}
return status.isGranted;
}
/// Checks the current status of the "Install Unknown Apps" permission.
///
/// Returns [InstallPermissionStatus].
static Future<InstallPermissionStatus> checkInstallPermission() async {
if (!Platform.isAndroid) return InstallPermissionStatus.granted;
// On Android versions below 8.0, this permission doesn't exist.
if (await _getAndroidSdkVersion() < 26) {
return InstallPermissionStatus.granted;
}
final status = await Permission.requestInstallPackages.status;
if (status.isGranted) return InstallPermissionStatus.granted;
if (status.isPermanentlyDenied) return InstallPermissionStatus.permanentlyDenied;
if (status.isRestricted) return InstallPermissionStatus.restricted;
return InstallPermissionStatus.denied;
}
/// Requests the "Install Unknown Apps" permission.
///
/// On Android 8.0 (API 26) and above, this will navigate the user to the
/// system settings page for the app.
///
/// Shows a rationale dialog to the user before navigating to settings.
///
/// Returns the [InstallPermissionStatus] after the user returns from settings.
static Future<InstallPermissionStatus> requestInstallPermission({
required BuildContext context,
}) async {
if (!Platform.isAndroid || !context.mounted) {
return InstallPermissionStatus.granted;
}
if (await _getAndroidSdkVersion() < 26) {
return InstallPermissionStatus.granted;
}
// Show a rationale dialog explaining why we need the permission and that
// we are about to navigate to the system settings.
final proceed = await _showRationaleDialog(
context,
title: '需要安装权限',
content: '为了完成应用更新,您需要允许我们安装应用。接下来将跳转到系统设置页面,请在那里开启“允许来自此来源的应用”选项。',
);
if (!proceed) return InstallPermissionStatus.denied;
// For Android 8.0+, we need to open the system settings page manually
// because REQUEST_INSTALL_PACKAGES cannot be requested via normal dialog
try {
// First try the permission request (this will open settings on most devices)
await Permission.requestInstallPackages.request();
// Wait a moment for the user to potentially grant the permission
await Future.delayed(const Duration(milliseconds: 500));
// Check the permission status after the settings interaction
final finalStatus = await checkInstallPermission();
// If still not granted, try to open settings manually as fallback
if (finalStatus != InstallPermissionStatus.granted) {
if (context.mounted) {
final openSettings = await _showRationaleDialog(
context,
title: '需要手动开启权限',
content: '请在设置中找到"安装未知应用"选项并开启。是否现在前往设置?',
);
if (openSettings) {
await openAppSettings();
// Give user time to change settings
await Future.delayed(const Duration(seconds: 1));
}
}
}
// Final check after all attempts
return await checkInstallPermission();
} catch (e) {
debugPrint('Error requesting install permission: $e');
// If there's an error, try opening app settings as fallback
if (context.mounted) {
final openSettings = await _showRationaleDialog(
context,
title: '权限设置异常',
content: '无法自动跳转到权限设置。请手动前往应用设置开启"安装未知应用"权限。',
);
if (openSettings) {
await openAppSettings();
}
}
return InstallPermissionStatus.denied;
}
}
/// A comprehensive method to check and request the "Install Unknown Apps"
/// permission, handling all statuses.
///
/// 1. Checks permission status.
/// 2. If denied, it requests the permission (with a rationale dialog).
/// 3. Handles permanently denied and restricted states with user feedback.
///
/// Returns `true` if the permission is granted, `false` otherwise.
static Future<bool> checkAndRequestInstallPermission({
required BuildContext context,
}) async {
var status = await checkInstallPermission();
if (status == InstallPermissionStatus.granted) {
return true;
}
// If permission is denied, request it.
if (status == InstallPermissionStatus.denied) {
status = await requestInstallPermission(context: context);
}
// Handle final status after request attempt.
if (status != InstallPermissionStatus.granted && context.mounted) {
String message;
switch (status) {
case InstallPermissionStatus.permanentlyDenied:
message = '安装权限已被永久拒绝。请前往系统设置手动开启。';
break;
case InstallPermissionStatus.restricted:
message = '安装应用的功能受限(例如,被家长控制),无法完成操作。';
break;
case InstallPermissionStatus.denied:
default:
message = '未授予安装权限,无法完成更新。';
break;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
return status == InstallPermissionStatus.granted;
}
/// Shows a dialog explaining why a permission is needed.
///
/// Returns `true` if the user taps "Continue", `false` if they tap "Cancel".
static Future<bool> _showRationaleDialog(
BuildContext context, {
required String title,
required String content,
}) async {
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(content),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('继续'),
),
],
),
);
return result ?? false;
}
/// A cached value for the Android SDK version.
static int? _sdkVersion;
/// Gets the Android SDK version.
///
/// Caches the result to avoid repeated platform channel calls.
static Future<int> _getAndroidSdkVersion() async {
if (!Platform.isAndroid) return 0;
_sdkVersion ??= await _plugin.getAndroidSdkVersion() ?? 0;
return _sdkVersion!;
}
}

View File

@ -0,0 +1,232 @@
import 'package:flutter/foundation.dart';
///
class UpgradeConfig {
///
static UpgradeConfig? _instance;
///
static UpgradeConfig get instance {
_instance ??= UpgradeConfig._();
return _instance!;
}
UpgradeConfig._();
///
bool debugMode = kDebugMode;
///
int checkIntervalHours = 24;
///
bool autoCheck = true;
/// WiFi下自动下载
bool wifiOnly = true;
///
int downloadTimeout = 300;
///
int connectTimeout = 30;
///
int maxRetryCount = 3;
///
int retryDelay = 5;
/// 使
bool useCache = true;
///
int cacheValidHours = 1;
/// Android
bool showNotification = true;
///
bool supportBreakpoint = true;
///
int chunkSize = 1024 * 1024; // 1MB
///
int maxConcurrentDownloads = 3;
///
bool verifyIntegrity = true;
///
bool allowCellular = false;
///
bool silentDownload = false;
///
bool saveDownloadHistory = true;
///
Map<String, String> customHeaders = {};
///
String? proxyUrl;
///
void updateConfig({
bool? debugMode,
int? checkIntervalHours,
bool? autoCheck,
bool? wifiOnly,
int? downloadTimeout,
int? connectTimeout,
int? maxRetryCount,
int? retryDelay,
bool? useCache,
int? cacheValidHours,
bool? showNotification,
bool? supportBreakpoint,
int? chunkSize,
int? maxConcurrentDownloads,
bool? verifyIntegrity,
bool? allowCellular,
bool? silentDownload,
bool? saveDownloadHistory,
Map<String, String>? customHeaders,
String? proxyUrl,
}) {
if (debugMode != null) this.debugMode = debugMode;
if (checkIntervalHours != null) this.checkIntervalHours = checkIntervalHours;
if (autoCheck != null) this.autoCheck = autoCheck;
if (wifiOnly != null) this.wifiOnly = wifiOnly;
if (downloadTimeout != null) this.downloadTimeout = downloadTimeout;
if (connectTimeout != null) this.connectTimeout = connectTimeout;
if (maxRetryCount != null) this.maxRetryCount = maxRetryCount;
if (retryDelay != null) this.retryDelay = retryDelay;
if (useCache != null) this.useCache = useCache;
if (cacheValidHours != null) this.cacheValidHours = cacheValidHours;
if (showNotification != null) this.showNotification = showNotification;
if (supportBreakpoint != null) this.supportBreakpoint = supportBreakpoint;
if (chunkSize != null) this.chunkSize = chunkSize;
if (maxConcurrentDownloads != null) this.maxConcurrentDownloads = maxConcurrentDownloads;
if (verifyIntegrity != null) this.verifyIntegrity = verifyIntegrity;
if (allowCellular != null) this.allowCellular = allowCellular;
if (silentDownload != null) this.silentDownload = silentDownload;
if (saveDownloadHistory != null) this.saveDownloadHistory = saveDownloadHistory;
if (customHeaders != null) this.customHeaders = customHeaders;
if (proxyUrl != null) this.proxyUrl = proxyUrl;
}
///
void reset() {
debugMode = kDebugMode;
checkIntervalHours = 24;
autoCheck = true;
wifiOnly = true;
downloadTimeout = 300;
connectTimeout = 30;
maxRetryCount = 3;
retryDelay = 5;
useCache = true;
cacheValidHours = 1;
showNotification = true;
supportBreakpoint = true;
chunkSize = 1024 * 1024;
maxConcurrentDownloads = 3;
verifyIntegrity = true;
allowCellular = false;
silentDownload = false;
saveDownloadHistory = true;
customHeaders = {};
proxyUrl = null;
}
/// Map
Map<String, dynamic> toMap() {
return {
'debugMode': debugMode,
'checkIntervalHours': checkIntervalHours,
'autoCheck': autoCheck,
'wifiOnly': wifiOnly,
'downloadTimeout': downloadTimeout,
'connectTimeout': connectTimeout,
'maxRetryCount': maxRetryCount,
'retryDelay': retryDelay,
'useCache': useCache,
'cacheValidHours': cacheValidHours,
'showNotification': showNotification,
'supportBreakpoint': supportBreakpoint,
'chunkSize': chunkSize,
'maxConcurrentDownloads': maxConcurrentDownloads,
'verifyIntegrity': verifyIntegrity,
'allowCellular': allowCellular,
'silentDownload': silentDownload,
'saveDownloadHistory': saveDownloadHistory,
'customHeaders': customHeaders,
'proxyUrl': proxyUrl,
};
}
/// Map导入配置
void fromMap(Map<String, dynamic> map) {
updateConfig(
debugMode: map['debugMode'],
checkIntervalHours: map['checkIntervalHours'],
autoCheck: map['autoCheck'],
wifiOnly: map['wifiOnly'],
downloadTimeout: map['downloadTimeout'],
connectTimeout: map['connectTimeout'],
maxRetryCount: map['maxRetryCount'],
retryDelay: map['retryDelay'],
useCache: map['useCache'],
cacheValidHours: map['cacheValidHours'],
showNotification: map['showNotification'],
supportBreakpoint: map['supportBreakpoint'],
chunkSize: map['chunkSize'],
maxConcurrentDownloads: map['maxConcurrentDownloads'],
verifyIntegrity: map['verifyIntegrity'],
allowCellular: map['allowCellular'],
silentDownload: map['silentDownload'],
saveDownloadHistory: map['saveDownloadHistory'],
customHeaders: map['customHeaders'] != null ? Map<String, String>.from(map['customHeaders']) : null,
proxyUrl: map['proxyUrl'],
);
}
}
///
enum UpgradeStrategy {
///
immediate,
///
idle,
///
scheduled,
///
silent,
///
grayScale,
}
///
enum VersionCompareStrategy {
/// 1.2.3
numeric,
/// 1.2.3-beta.1
semantic,
///
timestamp,
///
buildNumber,
///
custom,
}

Some files were not shown because too many files have changed in this diff Show More