支持后端返回的各大应用市场判断 前往应用市场更新按钮是否可用

This commit is contained in:
DESKTOP-I3JPKHK\wy 2026-05-06 10:27:57 +08:00
parent 11a42dd186
commit fd9e4ef30f
16 changed files with 1562 additions and 367 deletions

View File

@ -1,12 +1,32 @@
# 应用市场白名单功能说明(简化版)
# 应用市场更新入口可点击状态说明
## 🎯 功能概述
## 功能概述
`appMarkets` 作为应用市场白名单使用,只需传入 `AppMarket` 枚举列表即可
`appMarkets` 用于控制 Android 设备上的“应用市场更新”入口是否可点击
## 📝 使用方法
它不是应用市场跳转白名单,也不再使用 `AppMarket` 枚举。后端直接返回字符串数组即可,例如 `["XIAOMI", "HUAWEI", "HONOR", "TENCENT"]`
### 1. Dart 代码配置
## 使用方法
### 后端字段约定
`appMarkets` 是一个可空字符串数组:
```json
{
"appMarkets": ["XIAOMI", "HUAWEI", "HONOR"]
}
```
字段含义只和“应用市场更新”入口是否可点击有关:
- 不返回或返回 `null`:不限制市场更新按钮,保持旧逻辑。
- 返回 `[]`:市场更新入口置灰不可点击,并提示暂未开放。
- 返回非空数组:当前 Android 设备匹配任意一个值时,市场更新入口可点击;不匹配时入口置灰并展示原因。
推荐后端统一返回大写标准值。SDK 会兼容小写、混合大小写、分隔符和常见别名。
### Dart 代码配置
```dart
await AppUpgradeSimple.instance.checkUpdate(
@ -17,18 +37,31 @@ await AppUpgradeSimple.instance.checkUpdate(
versionBuildNumber: 101,
updateContent: '修复已知问题',
downloadUrl: 'https://example.com/app.apk',
// 只支持华为、小米、OPPO应用市场
appMarkets: [
AppMarket.huawei,
AppMarket.xiaomi,
AppMarket.oppo,
],
// 控制 Android “应用市场更新”入口是否可点击
appMarkets: ['XIAOMI', 'HUAWEI', 'HONOR', 'TENCENT'],
);
},
);
```
### 2. 服务端返回
如果业务层是手动把服务端 JSON 转成 `AppUpgradeVersion`,建议这样写:
```dart
AppUpgradeVersion(
versionName: json['versionName'],
versionBuildNumber: json['versionBuildNumber'],
updateContent: json['updateContent'] ?? '',
downloadUrl: json['downloadUrl'],
appMarkets: AndroidMarketProvider.parseList(json['appMarkets']),
supportedMethods: const [
AppUpgradeMethod.market,
AppUpgradeMethod.browser,
AppUpgradeMethod.inApp,
],
);
```
### 服务端返回
```json
{
@ -36,90 +69,113 @@ await AppUpgradeSimple.instance.checkUpdate(
"versionName": "1.0.1",
"updateContent": "修复已知问题",
"downloadUrl": "https://example.com/app.apk",
"appMarkets": ["huawei", "xiaomi", "oppo"]
"appMarkets": ["XIAOMI", "HUAWEI", "HONOR", "TENCENT"]
}
```
## 🚀 工作流程
## 可点击规则
1. 用户点击"前往应用市场更新"
2. 插件检测设备已安装的应用市场
3. 判断:
- ✅ 设备有白名单中的应用市场 → 跳转到应用市场
- ❌ 设备没有白名单中的应用市场 → 提示"不支持当前设备的应用市场,请选择其他方式更新"
| appMarkets 值 | 行为 |
|---------------|------|
| `null` 或不返回 | 跳过校验,正常可点击“应用市场更新” |
| `[]` | “应用市场更新”置灰不可点击,提示暂未开放 |
| `["XIAOMI"]` | 小米、Redmi、POCO 设备可点击 |
| `["HUAWEI"]` | 华为设备可点击,不包含荣耀 |
| `["HONOR"]` | 荣耀设备可点击 |
| `["VIVO"]` | vivo、iQOO 设备可点击 |
| `["OPPO"]` | OPPO、OnePlus、realme 设备可点击 |
| `["TENCENT"]` | 已安装应用宝时可点击 |
## 📋 支持的应用市场
SDK 会自动做大小写和别名归一化,例如 `"xiaomi"`、`"REDMI"`、`"poco"` 都会按 `"XIAOMI"` 处理。
| AppMarket 枚举值 | 显示名称 |
|-----------------|---------|
| `AppMarket.googlePlay` | Google Play |
| `AppMarket.huawei` | 华为应用市场 |
| `AppMarket.xiaomi` | 小米应用商店 |
| `AppMarket.oppo` | OPPO软件商店 |
| `AppMarket.vivo` | vivo应用商店 |
| `AppMarket.tencent` | 腾讯应用宝 |
| `AppMarket.coolapk` | 酷安 |
## 支持的字符串
## 🎨 用户体验示例
后端推荐统一返回大写值:
### 场景 1: 华为手机 + 配置了华为和小米
| 字符串值 | 说明 |
|---------|------|
| `XIAOMI` | 小米、小米应用商店;兼容 `REDMI`、`POCO`、`MIUI` |
| `HUAWEI` | 华为、华为应用市场 |
| `HONOR` | 荣耀、荣耀应用市场 |
| `OPPO` | OPPO 软件商店;兼容 `ONEPLUS`、`REALME` |
| `VIVO` | vivo 应用商店;兼容 `IQOO` |
| `TENCENT` | 腾讯应用宝;兼容 `YINGYONGBAO`、`YYB` |
| `SAMSUNG` | 三星 Galaxy Store |
| `MEIZU` | 魅族应用商店 |
| `LENOVO` | 联想/摩托罗拉生态 |
| `ZTE` | 中兴/努比亚生态 |
| `GOOGLE_PLAY` | Google Play兼容 `GOOGLEPLAY`、`GOOGLE` |
| `QIHOO_360` | 360 手机助手;兼容 `360`、`QIHOO360` |
| `BAIDU` | 百度手机助手 |
| `WANDOUJIA` | 豌豆荚 |
| `COOLAPK` | 酷安 |
- ✅ 检测到华为应用市场已安装
- → 直接跳转到华为应用市场
## 典型场景
### 场景 2: OPPO手机 + 只配置了华为和小米
### 小米手机 + appMarkets 包含 XIAOMI
- ❌ 检测到设备没有配置的应用市场
- → 提示:"当前设备的应用市场暂不支持,请选择其他方式更新"
- → 显示其他更新方式(浏览器下载、应用内下载)
- “应用市场更新”可点击
- 点击后跳转设备默认应用市场
### 场景 3: 未配置 appMarkets
### Redmi / POCO 设备 + appMarkets 包含 XIAOMI
- 保持向后兼容
- → 使用默认行为:跳转到设备的默认应用市场
- 归一为小米生态
- “应用市场更新”可点击
## 🔍 调试方法
### 荣耀手机 + appMarkets 只包含 HUAWEI
- “应用市场更新”置灰不可点击
- 荣耀和华为按不同服务商处理,避免审核进度不一致时误展示
### 后端返回空数组
- “应用市场更新”置灰不可点击提示“当前设备暂不支持应用市场直接更新请使用其他方式更新APP”
- 浏览器更新、应用内下载更新仍按 `supportedMethods``downloadUrl` 正常展示
### 后端返回 TENCENT
- 如果设备已安装应用宝,“应用市场更新”可点击
- 如果未安装应用宝,“应用市场更新”置灰不可点击
### 应用宝、百度、360 等无对应手机品牌的市场
这类市场不按手机厂商判断,只按设备是否安装了对应应用市场 App 判断:
| appMarkets 值 | 需要安装的应用市场 |
|---------------|-------------------|
| `TENCENT` | 应用宝 |
| `BAIDU` | 百度手机助手 |
| `QIHOO_360` | 360 手机助手 |
| `WANDOUJIA` | 豌豆荚 |
| `COOLAPK` | 酷安 |
| `GOOGLE_PLAY` | Google Play |
例如服务端返回 `["BAIDU"]` 时,即使设备品牌字段里出现 `BAIDU`,也不会直接可点击;只有 Android 原生检测到已安装百度手机助手时才可点击。
### 未配置 appMarkets
- 保持兼容
- “应用市场更新”正常可点击
## 调试方法
查看设备已安装的应用市场:
```dart
final installedMarkets = await AppUpgradePlugin().getInstalledMarkets();
debugPrint('已安装的应用市场: $installedMarkets');
// 输出示例: [huawei, tencent]
```
## ⚠️ 注意事项
查看设备信息:
1. **iOS 平台**:此功能仅在 Android 平台生效iOS 会忽略此配置
2. **向后兼容**:不配置 `appMarkets` 时,保持原有行为不变
3. **极简设计**无需配置包名、URL等细节插件内部自动处理
## 📦 修改的文件
### 主要变更
- ✅ `appMarkets` 类型从 `List<AppMarketInfo>?` 改为 `List<AppMarket>?`
- ✅ Android 端新增检测已安装应用市场的方法
- ✅ Dart 端新增白名单匹配逻辑
- ✅ 自动提示用户选择其他更新方式
### 优势对比
**之前的方案(复杂):**
```dart
appMarkets: [
AppMarketInfo(
market: AppMarket.huawei,
packageName: 'com.huawei.appmarket',
url: 'https://appgallery.huawei.com/...',
),
]
final deviceInfo = await AppUpgradePlugin().getDeviceInfo();
debugPrint('设备信息: $deviceInfo');
```
**现在的方案(简单):**
```dart
appMarkets: [AppMarket.huawei]
```
✨ **更简单、更清晰、更易用!**
## 注意事项
1. 此功能只影响 AndroidiOS 会忽略 `appMarkets`
2. `appMarkets` 只控制市场更新入口是否可点击,不再作为点击后的应用市场白名单。
3. 后端可返回大写字符串SDK 仍兼容小写、混合大小写和常见别名。
4. 如果同时配置 `supportedMethods`,必须包含 `"market"`,否则即使 `appMarkets` 匹配也不会出现“应用市场更新”入口。

View File

@ -16,7 +16,7 @@
- **🛡️ 权限适配完善**:针对不同 Android 版本的存储、安装、通知权限自动处理
- **🌐 网络可配置**证书校验、超时、默认方法、Headers 等
- **🔧 安装策略灵活**:系统流程/预检查权限/智能策略可选
- **🏪 应用市场支持**:支持多应用市场白名单,智能检测设备已安装的市场
- **🏪 应用市场支持**:支持按 Android 厂商/服务商控制应用市场更新入口
- **📦 多种更新方式**:应用市场、浏览器下载、应用内下载
## 📦 安装
@ -270,7 +270,7 @@ debugPrint('bodyItems: ${parsed.bodyItems.map((e) => e.text).toList()}');
</provider>
</application>
<!-- Android 11+ 查询声明:允许打开浏览器处理 HTTP/HTTPS URL -->
<!-- Android 11+ 查询声明:允许打开浏览器和应用市场 -->
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
@ -280,6 +280,18 @@ debugPrint('bodyItems: ${parsed.bodyItems.map((e) => e.text).toList()}');
<action android:name="android.intent.action.VIEW" />
<data android:scheme="http" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="market" />
</intent>
<package android:name="com.huawei.appmarket" />
<package android:name="com.hihonor.appmarket" />
<package android:name="com.xiaomi.market" />
<package android:name="com.oppo.market" />
<package android:name="com.heytap.market" />
<package android:name="com.bbk.appstore" />
<package android:name="com.vivo.appstore" />
<package android:name="com.tencent.android.qqdownloader" />
</queries>
</manifest>
```
@ -314,11 +326,32 @@ debugPrint('bodyItems: ${parsed.bodyItems.map((e) => e.text).toList()}');
"appStoreUrl": "https://apps.apple.com/app/id123456789",
"apkSize": 25165824,
"apkMd5": "d41d8cd98f00b204e9800998ecf8427e",
"appMarkets": ["huawei", "xiaomi", "oppo"],
"appMarkets": ["XIAOMI", "HUAWEI", "HONOR", "TENCENT"],
"supportedMethods": ["market", "browser", "inApp"]
}
```
`appMarkets` 用于控制 Android 设备上的“应用市场更新”入口是否可点击:不返回或为 `null` 时保持旧逻辑;空数组时入口置灰不可点击;非空数组时会按当前设备厂商/已安装独立市场匹配未匹配时入口置灰并展示原因。SDK 会将 `REDMI`、`POCO` 归一为 `XIAOMI`,将 `IQOO` 归一为 `VIVO`,将 `YINGYONGBAO`/`YYB` 归一为 `TENCENT`。此字段只控制入口是否可点击,不作为点击后的应用市场白名单。
手动转换服务端 JSON 时可以直接复用 SDK 的解析工具:
```dart
AppUpgradeVersion(
versionName: json['versionName'],
versionBuildNumber: json['versionBuildNumber'],
updateContent: json['updateContent'] ?? '',
downloadUrl: json['downloadUrl'],
appMarkets: AndroidMarketProvider.parseList(json['appMarkets']),
supportedMethods: const [
AppUpgradeMethod.market,
AppUpgradeMethod.browser,
AppUpgradeMethod.inApp,
],
);
```
`appMarkets` 常用值:`XIAOMI`、`HUAWEI`、`HONOR`、`OPPO`、`VIVO`、`TENCENT`、`SAMSUNG`、`MEIZU`、`LENOVO`、`ZTE`、`GOOGLE_PLAY`、`QIHOO_360`、`BAIDU`、`WANDOUJIA`、`COOLAPK`。其中 `HUAWEI``HONOR` 会分开匹配,适合处理不同应用市场审核进度不一致的场景。
## 🔧 进阶能力
### 1) 网络配置

View File

@ -1,3 +1,25 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.app_upgrade_plugin">
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="market" />
</intent>
<package android:name="com.android.vending" />
<package android:name="com.huawei.appmarket" />
<package android:name="com.hihonor.appmarket" />
<package android:name="com.oppo.market" />
<package android:name="com.heytap.market" />
<package android:name="com.bbk.appstore" />
<package android:name="com.vivo.appstore" />
<package android:name="com.xiaomi.market" />
<package android:name="com.tencent.android.qqdownloader" />
<package android:name="com.sec.android.app.samsungapps" />
<package android:name="com.meizu.mstore" />
<package android:name="com.lenovo.leos.appstore" />
<package android:name="com.qihoo.appstore" />
<package android:name="com.baidu.appsearch" />
<package android:name="com.wandoujia.phoenix2" />
<package android:name="com.coolapk.market" />
</queries>
</manifest>

View File

@ -407,16 +407,25 @@ class AppUpgradePlugin: FlutterPlugin, MethodCallHandler, ActivityAware {
private fun getInstalledMarkets(result: Result) {
try {
val pm = context.packageManager
val installedMarkets = mutableListOf<String>()
val installedMarkets = mutableSetOf<String>()
// 常见应用市场的包名映射
val marketPackages = mapOf(
"com.android.vending" to "googlePlay",
"com.huawei.appmarket" to "huawei",
"com.hihonor.appmarket" to "honor",
"com.oppo.market" to "oppo",
"com.heytap.market" to "oppo",
"com.bbk.appstore" to "vivo",
"com.vivo.appstore" to "vivo",
"com.xiaomi.market" to "xiaomi",
"com.tencent.android.qqdownloader" to "tencent",
"com.sec.android.app.samsungapps" to "samsung",
"com.meizu.mstore" to "meizu",
"com.lenovo.leos.appstore" to "lenovo",
"com.qihoo.appstore" to "qihoo360",
"com.baidu.appsearch" to "baidu",
"com.wandoujia.phoenix2" to "wandoujia",
"com.coolapk.market" to "coolapk"
)
@ -430,7 +439,7 @@ class AppUpgradePlugin: FlutterPlugin, MethodCallHandler, ActivityAware {
}
}
result.success(installedMarkets)
result.success(installedMarkets.toList())
} catch (e: Exception) {
result.error("GET_MARKETS_ERROR", "Failed to get installed markets", e.message)
}
@ -514,4 +523,4 @@ class AppUpgradePlugin: FlutterPlugin, MethodCallHandler, ActivityAware {
override fun onDetachedFromActivity() {
activity = null
}
}
}

3
example/.fvmrc Normal file
View File

@ -0,0 +1,3 @@
{
"flutter": "3.35.5"
}

View File

@ -123,7 +123,8 @@ class _HomePageState extends State<HomePage> {
/// UpdateappResult AppUpgradeVersion
AppUpgradeVersion _convertToAppUpgradeVersion(Map<String, dynamic> model) {
// KB
final int? apkSizeBytes = model['fileSize'] != null ? model['fileSize'] * 1024 : null;
final int? apkSizeBytes =
model['fileSize'] != null ? model['fileSize'] * 1024 : null;
final filePath = model['filePath'];
final appUpgradeVersion = AppUpgradeVersion(
versionName: model['versionName'],
@ -134,11 +135,16 @@ class _HomePageState extends State<HomePage> {
appStoreUrl: filePath,
apkSize: apkSizeBytes,
apkMd5: null, // UpdateappResult MD5
// appMarkets: null, // UpdateappResult
supportedMethods: [AppUpgradeMethod.browser, AppUpgradeMethod.inApp, AppUpgradeMethod.market],
// ["XIAOMI", "HUAWEI", "HONOR", "TENCENT"]
appMarkets: AndroidMarketProvider.parseList(model['appMarkets']),
supportedMethods: [
AppUpgradeMethod.browser,
AppUpgradeMethod.inApp,
AppUpgradeMethod.market
],
// appMarkets: [
// AppMarket.huawei,
// // AppMarket.xiaomi,
// 'HUAWEI',
// 'XIAOMI',
// ],
);
return appUpgradeVersion;
@ -159,7 +165,8 @@ class _HomePageState extends State<HomePage> {
ElevatedButton(
onPressed: () async {
// 1. UI
final upgradeInfo = await AppUpgradeSimple.instance.silentCheckUpdate(
final upgradeInfo =
await AppUpgradeSimple.instance.silentCheckUpdate(
future: () async {
final updateAppEvent = await _getUpdateAppEvent();
debugPrint("获取最新版本: $updateAppEvent");

View File

@ -15,6 +15,7 @@ export 'core/http_config.dart';
export 'core/permission_helper.dart';
//
export 'models/app_upgrade_method.dart';
export 'models/android_market_provider.dart';
//
export 'models/app_upgrade_version.dart';
export 'models/install_strategy.dart';

View File

@ -7,6 +7,7 @@ import 'package:yx_app_upgrade_flutter/core/upgrade_utils.dart';
import 'app_upgrade_plugin_platform_interface.dart';
import 'core/permission_helper.dart';
import 'models/android_market_provider.dart';
import 'models/app_upgrade_method.dart';
import 'models/app_upgrade_version.dart';
import 'models/upgrade_info.dart';
@ -23,10 +24,8 @@ class _SimpleAppUpgradePlugin {
return AppUpgradePluginPlatform.instance.checkUpdate(url, params: params);
}
Future<String?> downloadApk(String url,
{Function(DownloadProgress)? onProgress}) {
return AppUpgradePluginPlatform.instance
.downloadApk(url, onProgress: onProgress);
Future<String?> downloadApk(String url, {Function(DownloadProgress)? onProgress}) {
return AppUpgradePluginPlatform.instance.downloadApk(url, onProgress: onProgress);
}
Future<bool> installApk(String filePath) {
@ -34,14 +33,17 @@ class _SimpleAppUpgradePlugin {
}
Future<bool> goToAppStore(String url, {required BuildContext context}) {
return AppUpgradePluginPlatform.instance
.goToAppStore(url, context: context);
return AppUpgradePluginPlatform.instance.goToAppStore(url, context: context);
}
Future<Map<String, String>> getAppInfo() {
return AppUpgradePluginPlatform.instance.getAppInfo();
}
Future<Map<String, dynamic>?> getDeviceInfo() {
return AppUpgradePluginPlatform.instance.getDeviceInfo();
}
///
Future<List<String>> getInstalledMarkets() {
return AppUpgradePluginPlatform.instance.getInstalledMarkets();
@ -139,6 +141,25 @@ class ParsedUpgradeContentItem {
final bool hasLeadingMarker;
}
class _MarketMethodState {
const _MarketMethodState({
required this.supported,
required this.enabled,
this.disabledReason,
});
const _MarketMethodState.unsupported()
: supported = false,
enabled = false,
disabledReason = null;
final bool supported;
final bool enabled;
final String? disabledReason;
bool get disabled => supported && !enabled;
}
/// App升级管理器
/// APIApp升级功能
class AppUpgradeSimple {
@ -156,8 +177,7 @@ class AppUpgradeSimple {
}
@visibleForTesting
AppUpgradeSimple.private({_SimpleAppUpgradePlugin? plugin})
: _plugin = plugin ?? _SimpleAppUpgradePlugin.instance;
AppUpgradeSimple.private({_SimpleAppUpgradePlugin? plugin}) : _plugin = plugin ?? _SimpleAppUpgradePlugin.instance;
AppUpgradeSimple._() : _plugin = _SimpleAppUpgradePlugin.instance;
@ -187,10 +207,7 @@ class AppUpgradeSimple {
if (downloadPath != null) {
final dir = Directory(downloadPath);
if (await dir.exists()) {
final files = await dir
.list()
.where((file) => file.path.endsWith('.apk'))
.toList();
final files = await dir.list().where((file) => file.path.endsWith('.apk')).toList();
for (final file in files) {
try {
await file.delete();
@ -265,14 +282,12 @@ class AppUpgradeSimple {
}) async {
// 使
final effectiveConfig = config ?? _config;
final finalShowNoUpdateToast =
showNoUpdateToast ?? effectiveConfig.showNoUpdateToast;
final finalShowNoUpdateToast = showNoUpdateToast ?? effectiveConfig.showNoUpdateToast;
final finalAutoInstall = autoInstall ?? effectiveConfig.autoInstall;
try {
assert(_canShowMaterialDialog(context), '请在 MaterialApp 环境内调用');
final info =
await _prepareUpgradeInfo(future: future, config: effectiveConfig);
final info = await _prepareUpgradeInfo(future: future, config: effectiveConfig);
if (info == null) {
return;
}
@ -316,8 +331,7 @@ class AppUpgradeSimple {
}) async {
final effectiveConfig = config ?? _config;
try {
final info =
await _prepareUpgradeInfo(future: future, config: effectiveConfig);
final info = await _prepareUpgradeInfo(future: future, config: effectiveConfig);
if (effectiveConfig.enableDebugLog) {
if (info == null) {
debugPrint('🔕 静默检查结果: 未返回版本信息');
@ -413,15 +427,13 @@ class AppUpgradeSimple {
if (versionBuildNumber > currentBuildNumber) {
hasUpdate = true;
} else {
if (versionName != null &&
compareVersionStrings(versionName, currentVersionName) > 0) {
if (versionName != null && compareVersionStrings(versionName, currentVersionName) > 0) {
hasUpdate = true;
}
}
} else {
//
if (versionName != null &&
compareVersionStrings(versionName, currentVersionName) > 0) {
if (versionName != null && compareVersionStrings(versionName, currentVersionName) > 0) {
hasUpdate = true;
}
}
@ -450,8 +462,7 @@ class AppUpgradeSimple {
// UpgradeInfo
// serverInfo
final safeVersionName = serverInfo.versionName ?? currentVersionName;
final safeVersionBuildNumber =
serverInfo.versionBuildNumber ?? currentBuildNumber;
final safeVersionBuildNumber = serverInfo.versionBuildNumber ?? currentBuildNumber;
return UpgradeInfo(
hasUpdate: hasUpdate,
@ -465,13 +476,9 @@ class AppUpgradeSimple {
appStoreUrl: serverInfo.appStoreUrl,
apkSize: serverInfo.apkSize,
apkMd5: serverInfo.apkMd5,
appMarkets: serverInfo.appMarkets,
appMarkets: AndroidMarketProvider.parseList(serverInfo.appMarkets),
supportedMethods: serverInfo.supportedMethods ??
const [
AppUpgradeMethod.market,
AppUpgradeMethod.browser,
AppUpgradeMethod.inApp
],
const [AppUpgradeMethod.market, AppUpgradeMethod.browser, AppUpgradeMethod.inApp],
);
}
@ -523,8 +530,7 @@ class AppUpgradeSimple {
}
static ParsedUpgradeContent parseUpdateContent(String content) {
final normalizedContent =
content.replaceAll('\r\n', '\n').replaceAll('\r', '\n').trim();
final normalizedContent = content.replaceAll('\r\n', '\n').replaceAll('\r', '\n').trim();
if (normalizedContent.isEmpty) {
return const ParsedUpgradeContent();
@ -557,8 +563,7 @@ class AppUpgradeSimple {
final v1Parts = v1.split('.').map((e) => int.tryParse(e) ?? 0).toList();
final v2Parts = v2.split('.').map((e) => int.tryParse(e) ?? 0).toList();
final maxLength =
v1Parts.length > v2Parts.length ? v1Parts.length : v2Parts.length;
final maxLength = v1Parts.length > v2Parts.length ? v1Parts.length : v2Parts.length;
for (int i = 0; i < maxLength; i++) {
final part1 = i < v1Parts.length ? v1Parts[i] : 0;
@ -603,8 +608,7 @@ class AppUpgradeSimple {
autoInstall: autoInstall,
onUpdateLater: onUpdateLater,
config: effectiveConfig,
showToast: (message) =>
_showToast(message, context, effectiveConfig),
showToast: (message) => _showToast(message, context, effectiveConfig),
);
}
},
@ -612,8 +616,7 @@ class AppUpgradeSimple {
}
/// 使 Overlay Toast Scaffold
void _showOverlayToast(
BuildContext context, String message, UpgradeConfig config) {
void _showOverlayToast(BuildContext context, String message, UpgradeConfig config) {
if (!context.mounted) {
debugPrint('Toast消息context已卸载: $message');
return;
@ -648,8 +651,7 @@ class AppUpgradeSimple {
}
/// 使 ScaffoldMessenger SnackBar
void _tryShowSnackBar(
BuildContext context, String message, UpgradeConfig config) {
void _tryShowSnackBar(BuildContext context, String message, UpgradeConfig config) {
if (!context.mounted) {
_showOverlayToast(context, message, config);
return;
@ -675,9 +677,8 @@ class AppUpgradeSimple {
}
// 使 context ScaffoldMessenger
final messenger = rootContext != null && rootContext.mounted
? ScaffoldMessenger.maybeOf(rootContext)
: scaffoldMessenger;
final messenger =
rootContext != null && rootContext.mounted ? ScaffoldMessenger.maybeOf(rootContext) : scaffoldMessenger;
if (messenger == null) {
_showOverlayToast(context, message, config);
@ -702,8 +703,7 @@ class AppUpgradeSimple {
}
/// Toast提示
void _showToast(String message, BuildContext context,
[UpgradeConfig? config]) {
void _showToast(String message, BuildContext context, [UpgradeConfig? config]) {
final effectiveConfig = config ?? _config;
if (effectiveConfig.customToast != null) {
effectiveConfig.customToast!(message);
@ -716,9 +716,7 @@ class AppUpgradeSimple {
bool _canShowMaterialDialog(BuildContext context) {
if (!context.mounted) return false;
try {
return Localizations.of<MaterialLocalizations>(
context, MaterialLocalizations) !=
null;
return Localizations.of<MaterialLocalizations>(context, MaterialLocalizations) != null;
} catch (_) {
return false;
}
@ -750,8 +748,7 @@ mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
}
void onAppLifecycleStateChanged(AppLifecycleState state) {
debugPrint(
'🔄 应用生命周期状态变化: $state, _isWaitingForInstallation=$_isWaitingForInstallation');
debugPrint('🔄 应用生命周期状态变化: $state, _isWaitingForInstallation=$_isWaitingForInstallation');
if (_isWaitingForInstallation && state == AppLifecycleState.resumed) {
debugPrint('⚡ 应用回到前台,检查安装状态');
@ -808,14 +805,12 @@ mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
if (!Platform.isAndroid || info.downloadUrl == null) return;
if (!mounted) return;
final hasStorage = await PermissionHelper.checkAndRequestStoragePermission(
context: context);
final hasStorage = await PermissionHelper.checkAndRequestStoragePermission(context: context);
if (!hasStorage) {
showToast('缺少存储权限,无法下载');
return;
}
await PermissionHelper.checkAndRequestNotificationPermission(
context: context);
await PermissionHelper.checkAndRequestNotificationPermission(context: context);
setState(() {
_isDownloading = true;
@ -878,9 +873,7 @@ mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
if (config.requireInstallPermission) {
debugPrint('🔐 检查安装权限(配置要求)');
final hasPermission =
await PermissionHelper.checkAndRequestInstallPermission(
context: context);
final hasPermission = await PermissionHelper.checkAndRequestInstallPermission(context: context);
if (!hasPermission) {
if (mounted) {
setState(() {
@ -948,8 +941,7 @@ mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
Future<void> _checkInstallationResult() async {
if (!mounted || !_isWaitingForInstallation) {
debugPrint(
'跳过安装结果检查: mounted=$mounted, _isWaitingForInstallation=$_isWaitingForInstallation');
debugPrint('跳过安装结果检查: mounted=$mounted, _isWaitingForInstallation=$_isWaitingForInstallation');
return;
}
@ -958,30 +950,23 @@ mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
try {
final appInfo = await _plugin.getAppInfo();
final currentVersion = appInfo['version'] ?? '';
final currentBuildNumber =
int.tryParse(appInfo['buildNumber'] ?? '0') ?? 0;
final currentBuildNumber = int.tryParse(appInfo['buildNumber'] ?? '0') ?? 0;
debugPrint('📱 当前版本: $currentVersion, 构建号: $currentBuildNumber');
debugPrint(
'🎯 目标版本: ${info.versionName}, 构建号: ${info.versionBuildNumber}');
debugPrint('🎯 目标版本: ${info.versionName}, 构建号: ${info.versionBuildNumber}');
bool isUpdated = false;
if (info.versionBuildNumber > 0) {
if (currentBuildNumber > info.versionBuildNumber) {
isUpdated = true;
} else {
isUpdated = AppUpgradeSimple.compareVersionStrings(
currentVersion, info.versionName) >
0;
isUpdated = AppUpgradeSimple.compareVersionStrings(currentVersion, info.versionName) > 0;
}
debugPrint(
'📊 构建号比较: $currentBuildNumber vs ${info.versionBuildNumber}, 版本比较(如需): ${info.versionName} -> $isUpdated');
} else {
isUpdated = AppUpgradeSimple.compareVersionStrings(
currentVersion, info.versionName) >
0;
debugPrint(
'📊 版本号比较: $currentVersion vs ${info.versionName} = $isUpdated');
isUpdated = AppUpgradeSimple.compareVersionStrings(currentVersion, info.versionName) > 0;
debugPrint('📊 版本号比较: $currentVersion vs ${info.versionName} = $isUpdated');
}
if (isUpdated) {
@ -1035,9 +1020,7 @@ mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
}
if (_statusText == '权限被拒绝' && config.requireInstallPermission) {
final hasPermission =
await PermissionHelper.checkAndRequestInstallPermission(
context: context);
final hasPermission = await PermissionHelper.checkAndRequestInstallPermission(context: context);
if (!hasPermission) {
showToast('仍未获得安装权限,请在设置中手动开启');
return;
@ -1106,8 +1089,7 @@ mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
if (info.isForceUpdate) ...[
const SizedBox(width: 8),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: colorScheme.error,
borderRadius: BorderRadius.circular(4),
@ -1149,8 +1131,7 @@ mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
icon: Icons.update,
label: '已安装版本',
//
value:
'${info.currentVersionName} +${info.currentBuildNumber}',
value: '${info.currentVersionName} +${info.currentBuildNumber}',
colorScheme: colorScheme,
),
),
@ -1214,8 +1195,7 @@ mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
Widget _buildUpdateContent(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final parsedContent =
AppUpgradeSimple.parseUpdateContent(info.updateContent);
final parsedContent = AppUpgradeSimple.parseUpdateContent(info.updateContent);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -1266,8 +1246,7 @@ mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
parsedContent.header!,
colorScheme,
),
if (parsedContent.hasBodyItems)
const SizedBox(height: 10),
if (parsedContent.hasBodyItems) const SizedBox(height: 10),
],
...parsedContent.bodyItems.asMap().entries.map((entry) {
final index = entry.key;
@ -1275,9 +1254,7 @@ mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
return Container(
width: double.infinity,
margin: EdgeInsets.only(
bottom: index < parsedContent.bodyItems.length - 1
? 8
: 0,
bottom: index < parsedContent.bodyItems.length - 1 ? 8 : 0,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
@ -1385,12 +1362,10 @@ mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
// []
if (currentChar == '[') {
final innerResult =
_parseRichTextInternal(text, styles, index + 1, ']');
final innerResult = _parseRichTextInternal(text, styles, index + 1, ']');
if (innerResult.closed) {
flushBuffer();
final innerText =
text.substring(index + 1, innerResult.nextIndex - 1);
final innerText = text.substring(index + 1, innerResult.nextIndex - 1);
spans.addAll(_applyStyleToSpans(
innerResult.spans,
styles.highlightStyle,
@ -1426,12 +1401,10 @@ mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
}
// ****
else if (text.startsWith('**', index)) {
final innerResult =
_parseRichTextInternal(text, styles, index + 2, '**');
final innerResult = _parseRichTextInternal(text, styles, index + 2, '**');
if (innerResult.closed) {
flushBuffer();
final innerText =
text.substring(index + 2, innerResult.nextIndex - 2);
final innerText = text.substring(index + 2, innerResult.nextIndex - 2);
spans.addAll(_applyStyleToSpans(
innerResult.spans,
styles.boldStyle,
@ -1448,12 +1421,10 @@ mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
}
// __斜体__
else if (text.startsWith('__', index)) {
final innerResult =
_parseRichTextInternal(text, styles, index + 2, '__');
final innerResult = _parseRichTextInternal(text, styles, index + 2, '__');
if (innerResult.closed) {
flushBuffer();
final innerText =
text.substring(index + 2, innerResult.nextIndex - 2);
final innerText = text.substring(index + 2, innerResult.nextIndex - 2);
spans.addAll(_applyStyleToSpans(
innerResult.spans,
styles.italicStyle,
@ -1478,8 +1449,7 @@ mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
return _RichTextParseResult(spans, index, false);
}
List<TextSpan> _applyStyleToSpans(
List<TextSpan> spans, TextStyle style, String fallbackText) {
List<TextSpan> _applyStyleToSpans(List<TextSpan> spans, TextStyle style, String fallbackText) {
if (spans.isEmpty) {
return [
TextSpan(
@ -1493,10 +1463,8 @@ mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
}
TextSpan _mergeTextSpanStyle(TextSpan span, TextStyle style) {
final mergedChildren = span.children
?.map((child) =>
child is TextSpan ? _mergeTextSpanStyle(child, style) : child)
.toList();
final mergedChildren =
span.children?.map((child) => child is TextSpan ? _mergeTextSpanStyle(child, style) : child).toList();
final mergedStyle = span.style != null ? style.merge(span.style) : style;
@ -1507,8 +1475,7 @@ mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
);
}
Widget _buildEnhancedDownloadProgress(
BuildContext context, ColorScheme colorScheme) {
Widget _buildEnhancedDownloadProgress(BuildContext context, ColorScheme colorScheme) {
final bool showRetryButton = _downloadedFilePath != null &&
!_isDownloading &&
!_isInstalling &&
@ -1612,8 +1579,7 @@ mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.primaryContainer
.withOpacity(0.2),
color: colorScheme.primaryContainer.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: colorScheme.primary.withOpacity(0.3),
@ -1642,8 +1608,7 @@ mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
'系统将自动检测安装结果',
style: TextStyle(
fontSize: 11,
color: colorScheme.onSurface
.withOpacity(0.7),
color: colorScheme.onSurface.withOpacity(0.7),
),
textAlign: TextAlign.center,
),
@ -1660,11 +1625,9 @@ mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
child: ElevatedButton.icon(
onPressed: _retryInstall,
icon: Icon(_getRetryButtonIcon(), size: 16),
label: Text(_getRetryButtonText(),
style: const TextStyle(fontSize: 12)),
label: Text(_getRetryButtonText(), style: const TextStyle(fontSize: 12)),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
backgroundColor: _getRetryButtonColor(colorScheme),
@ -1673,8 +1636,7 @@ mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
),
],
),
if (_isDownloading ||
(_downloadProgress > 0 && _downloadProgress < 1.0)) ...[
if (_isDownloading || (_downloadProgress > 0 && _downloadProgress < 1.0)) ...[
const SizedBox(height: 16),
ClipRRect(
borderRadius: BorderRadius.circular(4),
@ -1749,9 +1711,7 @@ mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
return Icons.cancel_outlined;
} else if (_statusText == '安装超时' || _statusText == '检测失败') {
return Icons.schedule;
} else if (_statusText == '安装失败' ||
_statusText == '安装异常' ||
_statusText == '权限被拒绝') {
} else if (_statusText == '安装失败' || _statusText == '安装异常' || _statusText == '权限被拒绝') {
return Icons.error_outline;
} else if (_downloadProgress >= 1.0) {
return Icons.check_circle_outline;
@ -1777,9 +1737,7 @@ mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
return colorScheme.secondary.withOpacity(0.8);
} else if (_statusText == '安装超时' || _statusText == '检测失败') {
return colorScheme.secondary.withOpacity(0.7);
} else if (_statusText == '安装失败' ||
_statusText == '安装异常' ||
_statusText == '权限被拒绝') {
} else if (_statusText == '安装失败' || _statusText == '安装异常' || _statusText == '权限被拒绝') {
return colorScheme.error;
} else if (_downloadProgress >= 1.0) {
return colorScheme.tertiary;
@ -1831,10 +1789,7 @@ mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
return colorScheme.secondary;
} else if (_statusText == '安装失败' || _statusText == '安装异常') {
return colorScheme.error;
} else if (_statusText == '安装超时' ||
_statusText == '安装被取消' ||
_statusText == '检测失败' ||
_statusText == '等待安装中') {
} else if (_statusText == '安装超时' || _statusText == '安装被取消' || _statusText == '检测失败' || _statusText == '等待安装中') {
return colorScheme.secondary.withOpacity(0.8);
} else if (_statusText == '请完成安装') {
return colorScheme.secondary;
@ -1894,8 +1849,7 @@ mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
Future<void> _handleIosAction(BuildContext context) async {
if (info.appStoreUrl != null) {
final success =
await _plugin.goToAppStore(info.appStoreUrl!, context: context);
final success = await _plugin.goToAppStore(info.appStoreUrl!, context: context);
if (!success) {
showToast('无法打开App Store请稍后重试');
}
@ -1908,8 +1862,11 @@ mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
Future<void> _handleAndroidAction() async {
final List<AppUpgradeMethod> availableMethods = [];
final supported = info.supportedMethods;
final marketState = supported.contains(AppUpgradeMethod.market)
? await _resolveMarketMethodState()
: const _MarketMethodState.unsupported();
if (supported.contains(AppUpgradeMethod.market)) {
if (marketState.enabled) {
availableMethods.add(AppUpgradeMethod.market);
}
@ -1925,11 +1882,18 @@ mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
debugPrint('可用更新方式: $availableMethods');
if (availableMethods.isEmpty) {
if (marketState.disabled) {
await _showDownloadChoiceSheet(
availableMethods,
marketState: marketState,
);
return;
}
showToast('未找到可用的更新方式');
return;
}
if (availableMethods.length == 1) {
if (availableMethods.length == 1 && !marketState.disabled) {
final method = availableMethods.first;
switch (method) {
case AppUpgradeMethod.market:
@ -1945,7 +1909,51 @@ mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
return;
}
await _showDownloadChoiceSheet(availableMethods);
await _showDownloadChoiceSheet(
availableMethods,
marketState: marketState,
);
}
Future<_MarketMethodState> _resolveMarketMethodState() async {
final providers = info.appMarkets;
if (providers == null) {
return const _MarketMethodState(supported: true, enabled: true);
}
Map<String, dynamic>? deviceInfo;
List<String> installedMarkets = const <String>[];
try {
deviceInfo = await _plugin.getDeviceInfo();
} catch (e) {
debugPrint('获取设备厂商信息失败: $e');
}
try {
installedMarkets = await _plugin.getInstalledMarkets();
} catch (e) {
debugPrint('获取已安装应用市场失败: $e');
}
final result = AndroidMarketProvider.checkMarketUpdate(
allowedProviders: providers,
deviceInfo: deviceInfo,
installedMarkets: installedMarkets,
);
debugPrint(
'应用市场更新按钮校验: allowedProviders=${AndroidMarketProvider.parseList(providers)}, '
'deviceProvider=${result.deviceProvider}, '
'installedMarkets=${result.installedProviders}, '
'enabled=${result.allowed}, disabledReason=${result.disabledReason}',
);
return _MarketMethodState(
supported: true,
enabled: result.allowed,
disabledReason: result.disabledReason,
);
}
Future<void> _performMarketAction() async {
@ -1953,37 +1961,16 @@ mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
final installedMarkets = await _plugin.getInstalledMarkets();
debugPrint('设备已安装的应用市场: $installedMarkets');
final hasWhitelist = info.appMarkets?.isNotEmpty ?? false;
if (hasWhitelist) {
debugPrint('配置的应用市场白名单: ${info.appMarkets}');
//
final availableMarkets = info.appMarkets!
.where((market) => installedMarkets.contains(market.name))
.toList();
debugPrint('可用的应用市场: $availableMarkets');
if (availableMarkets.isEmpty) {
//
showToast('当前设备的应用市场不在支持列表中,请选择其他方式更新');
return;
}
} else {
//
if (installedMarkets.isEmpty) {
showToast('当前设备未安装应用市场');
return;
}
if (installedMarkets.isEmpty) {
showToast('当前设备未安装应用市场');
return;
}
// 使 market://
final appInfo = await _plugin.getAppInfo();
final pkg = appInfo['packageName'] ?? '';
if (pkg.isNotEmpty) {
final success = await _plugin.goToAppStore('market://details?id=$pkg',
context: context);
final success = await _plugin.goToAppStore('market://details?id=$pkg', context: context);
if (!success) {
showToast('当前APP没有上架当前设备对应的应用市场请选择其他方式更新');
}
@ -1999,7 +1986,9 @@ mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
}
Future<void> _showDownloadChoiceSheet(
List<AppUpgradeMethod> availableMethods) async {
List<AppUpgradeMethod> availableMethods, {
_MarketMethodState marketState = const _MarketMethodState.unsupported(),
}) async {
if (!mounted) return;
final choice = await showModalBottomSheet<AppUpgradeMethod>(
@ -2038,9 +2027,7 @@ mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
Expanded(
child: Text('选择更新方式',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold))),
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold))),
],
),
Positioned(
@ -2053,33 +2040,51 @@ mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
],
),
),
if (availableMethods.contains(AppUpgradeMethod.market))
if (marketState.supported)
ListTile(
leading: const Icon(Icons.storefront_outlined),
title:
const Text('前往应用市场更新', style: TextStyle(fontSize: 16)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
onTap: () => Navigator.of(ctx).pop(AppUpgradeMethod.market),
enabled: marketState.enabled,
leading: Icon(
Icons.storefront_outlined,
color: marketState.enabled ? null : Theme.of(ctx).disabledColor,
),
title: Text(
'前往应用市场更新',
style: TextStyle(
fontSize: 16,
color: marketState.enabled ? null : Theme.of(ctx).disabledColor,
),
),
subtitle: marketState.disabled
? Text(
marketState.disabledReason ?? AndroidMarketProvider.marketUpdateDisabledReason,
style: TextStyle(
fontSize: 12,
color: Theme.of(ctx).disabledColor,
),
)
: null,
trailing: marketState.disabled
? Icon(
Icons.info_outline,
color: Theme.of(ctx).disabledColor,
)
: null,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onTap: marketState.enabled ? () => Navigator.of(ctx).pop(AppUpgradeMethod.market) : null,
),
if (availableMethods.contains(AppUpgradeMethod.inApp))
ListTile(
leading: const Icon(Icons.system_update),
title: const Text('APP内更新', style: TextStyle(fontSize: 16)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onTap: () => Navigator.of(ctx).pop(AppUpgradeMethod.inApp),
),
if (availableMethods.contains(AppUpgradeMethod.browser))
ListTile(
leading: const Icon(Icons.download_for_offline_outlined),
title: const Text('前往浏览器下载安装包',
textAlign: TextAlign.left,
style: TextStyle(fontSize: 16)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
onTap: () =>
Navigator.of(ctx).pop(AppUpgradeMethod.browser),
title: const Text('前往浏览器下载安装包', textAlign: TextAlign.left, style: TextStyle(fontSize: 16)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onTap: () => Navigator.of(ctx).pop(AppUpgradeMethod.browser),
),
const Divider(height: 24),
SizedBox(
@ -2088,8 +2093,7 @@ mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
backgroundColor: Colors.white,
foregroundColor:
Theme.of(context).textTheme.bodyLarge?.color,
foregroundColor: Theme.of(context).textTheme.bodyLarge?.color,
elevation: 2,
shadowColor: Colors.grey.withOpacity(0.5),
shape: RoundedRectangleBorder(
@ -2098,9 +2102,7 @@ mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
),
),
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('取消',
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w500)),
child: const Text('取消', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
),
),
],
@ -2146,8 +2148,7 @@ class _SimpleUpgradeDialog extends StatefulWidget {
State<_SimpleUpgradeDialog> createState() => _SimpleUpgradeDialogState();
}
class _SimpleUpgradeDialogState extends State<_SimpleUpgradeDialog>
with _UpgradeDialogLogic, WidgetsBindingObserver {
class _SimpleUpgradeDialogState extends State<_SimpleUpgradeDialog> with _UpgradeDialogLogic, WidgetsBindingObserver {
@override
UpgradeInfo get info => widget.info;
@override
@ -2222,13 +2223,11 @@ class _SimpleUpgradeDialogState extends State<_SimpleUpgradeDialog>
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 10),
_buildVersionInfoCard(
context, Theme.of(context).colorScheme),
_buildVersionInfoCard(context, Theme.of(context).colorScheme),
const SizedBox(height: 16),
_buildUpdateContent(context),
if (_isDownloading || _downloadedFilePath != null)
_buildEnhancedDownloadProgress(
context, Theme.of(context).colorScheme),
_buildEnhancedDownloadProgress(context, Theme.of(context).colorScheme),
],
),
),
@ -2276,13 +2275,12 @@ class _ForceUpgradeDialog extends StatefulWidget {
State<_ForceUpgradeDialog> createState() => _ForceUpgradeDialogState();
}
class _ForceUpgradeDialogState extends State<_ForceUpgradeDialog>
with _UpgradeDialogLogic, WidgetsBindingObserver {
class _ForceUpgradeDialogState extends State<_ForceUpgradeDialog> with _UpgradeDialogLogic, WidgetsBindingObserver {
@override
UpgradeInfo get info => widget.info;
@override
void Function(String) get showToast => (message) =>
AppUpgradeSimple.instance._showToast(message, context, widget.config);
void Function(String) get showToast =>
(message) => AppUpgradeSimple.instance._showToast(message, context, widget.config);
@override
bool get autoInstall => widget.autoInstall;
@override
@ -2332,8 +2330,7 @@ class _ForceUpgradeDialogState extends State<_ForceUpgradeDialog>
const Expanded(
child: Text(
'发现新版本',
style: TextStyle(
fontSize: 18, fontWeight: FontWeight.w600),
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
textAlign: TextAlign.center,
),
),
@ -2348,13 +2345,11 @@ class _ForceUpgradeDialogState extends State<_ForceUpgradeDialog>
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 10),
_buildVersionInfoCard(
context, Theme.of(context).colorScheme),
_buildVersionInfoCard(context, Theme.of(context).colorScheme),
const SizedBox(height: 16),
_buildUpdateContent(context),
if (_isDownloading || _downloadedFilePath != null)
_buildEnhancedDownloadProgress(
context, Theme.of(context).colorScheme),
_buildEnhancedDownloadProgress(context, Theme.of(context).colorScheme),
],
),
),
@ -2407,8 +2402,7 @@ class _ToastWidget extends StatelessWidget {
);
},
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(8),
@ -2533,9 +2527,7 @@ _UpgradeContentHeaderExtraction _extractUpgradeContentHeader(
return;
}
if (pendingBlankLine &&
headerLines.isNotEmpty &&
headerLines.last.isNotEmpty) {
if (pendingBlankLine && headerLines.isNotEmpty && headerLines.last.isNotEmpty) {
headerLines.add('');
}
headerLines.add(normalizedLine);

View File

@ -0,0 +1,407 @@
/// Android app market provider codes accepted from backend strings.
///
/// These are intentionally strings instead of enums because the SDK receives
/// provider values from different backend services. Values are normalized to
/// uppercase canonical codes before matching.
class AndroidMarketProvider {
AndroidMarketProvider._();
static const String xiaomi = 'XIAOMI';
static const String huawei = 'HUAWEI';
static const String honor = 'HONOR';
static const String oppo = 'OPPO';
static const String vivo = 'VIVO';
static const String samsung = 'SAMSUNG';
static const String meizu = 'MEIZU';
static const String lenovo = 'LENOVO';
static const String zte = 'ZTE';
static const String coolpad = 'COOLPAD';
static const String googlePlay = 'GOOGLE_PLAY';
static const String tencent = 'TENCENT';
static const String qihoo360 = 'QIHOO_360';
static const String baidu = 'BAIDU';
static const String wandoujia = 'WANDOUJIA';
static const String coolapk = 'COOLAPK';
static const String sony = 'SONY';
static const String asus = 'ASUS';
static const String htc = 'HTC';
static const String tcl = 'TCL';
static const String smartisan = 'SMARTISAN';
static const String gionee = 'GIONEE';
static const String hisense = 'HISENSE';
static const String marketUpdateDisabledReason =
'当前设备暂不支持应用市场直接更新请使用其他方式更新APP';
static final RegExp _separatorRegExp = RegExp(r'[\s_\-\.]+');
static final RegExp _listSeparatorRegExp = RegExp(r'[,;|,;、]+');
static const Map<String, String> _exactAliases = {
'XIAOMI': xiaomi,
'MI': xiaomi,
'MIUI': xiaomi,
'REDMI': xiaomi,
'POCO': xiaomi,
'HUAWEI': huawei,
'HONOR': honor,
'HIHONOR': honor,
'OPPO': oppo,
'HEY TAP': oppo,
'HEY_TAP': oppo,
'HEYTAP': oppo,
'ONEPLUS': oppo,
'ONEPLUSONE': oppo,
'REALME': oppo,
'VIVO': vivo,
'IQOO': vivo,
'SAMSUNG': samsung,
'GALAXY': samsung,
'MEIZU': meizu,
'LENOVO': lenovo,
'MOTOROLA': lenovo,
'MOTO': lenovo,
'ZTE': zte,
'NUBIA': zte,
'COOLPAD': coolpad,
'GOOGLE': googlePlay,
'PIXEL': googlePlay,
'GOOGLEPLAY': googlePlay,
'GOOGLESTORE': googlePlay,
'ANDROIDMARKET': googlePlay,
'TENCENT': tencent,
'YINGYONGBAO': tencent,
'YYB': tencent,
'APPBAO': tencent,
'QQDOWNLOADER': tencent,
'TENCENTAPPSTORE': tencent,
'360': qihoo360,
'QIHOO': qihoo360,
'QIHOO360': qihoo360,
'360APPSTORE': qihoo360,
'BAIDU': baidu,
'BAIDUAPPSEARCH': baidu,
'WANDOUJIA': wandoujia,
'COOLAPK': coolapk,
'SONY': sony,
'ASUS': asus,
'HTC': htc,
'TCL': tcl,
'SMARTISAN': smartisan,
'GIONEE': gionee,
'HISENSE': hisense,
};
static const Map<String, String> _deviceExactAliases = {
'XIAOMI': xiaomi,
'MI': xiaomi,
'MIUI': xiaomi,
'REDMI': xiaomi,
'POCO': xiaomi,
'HUAWEI': huawei,
'HONOR': honor,
'HIHONOR': honor,
'OPPO': oppo,
'HEY TAP': oppo,
'HEY_TAP': oppo,
'HEYTAP': oppo,
'ONEPLUS': oppo,
'ONEPLUSONE': oppo,
'REALME': oppo,
'VIVO': vivo,
'IQOO': vivo,
'SAMSUNG': samsung,
'GALAXY': samsung,
'MEIZU': meizu,
'LENOVO': lenovo,
'MOTOROLA': lenovo,
'MOTO': lenovo,
'ZTE': zte,
'NUBIA': zte,
'COOLPAD': coolpad,
'GOOGLE': googlePlay,
'PIXEL': googlePlay,
'SONY': sony,
'ASUS': asus,
'HTC': htc,
'TCL': tcl,
'SMARTISAN': smartisan,
'GIONEE': gionee,
'HISENSE': hisense,
};
static const List<MapEntry<String, String>> _deviceTextAliases = [
MapEntry('XIAOMI', xiaomi),
MapEntry('MI', xiaomi),
MapEntry('REDMI', xiaomi),
MapEntry('POCO', xiaomi),
MapEntry('MIUI', xiaomi),
MapEntry('HUAWEI', huawei),
MapEntry('HIHONOR', honor),
MapEntry('HONOR', honor),
MapEntry('ONEPLUS', oppo),
MapEntry('REALME', oppo),
MapEntry('OPPO', oppo),
MapEntry('IQOO', vivo),
MapEntry('VIVO', vivo),
MapEntry('SAMSUNG', samsung),
MapEntry('GALAXY', samsung),
MapEntry('MEIZU', meizu),
MapEntry('MOTOROLA', lenovo),
MapEntry('LENOVO', lenovo),
MapEntry('NUBIA', zte),
MapEntry('ZTE', zte),
MapEntry('COOLPAD', coolpad),
MapEntry('PIXEL', googlePlay),
MapEntry('GOOGLE', googlePlay),
MapEntry('SONY', sony),
MapEntry('ASUS', asus),
MapEntry('HTC', htc),
MapEntry('TCL', tcl),
MapEntry('SMARTISAN', smartisan),
MapEntry('GIONEE', gionee),
MapEntry('HISENSE', hisense),
];
/// Normalize one backend or native provider string to a canonical code.
///
/// Examples:
/// - "xiaomi", "REDMI", "poco" -> "XIAOMI"
/// - "iQOO" -> "VIVO"
/// - "yingyongbao", "yyb" -> "TENCENT"
static String? normalize(String? provider) {
final raw = provider?.trim();
if (raw == null || raw.isEmpty) {
return null;
}
final upper = raw.toUpperCase();
final compact = upper.replaceAll(_separatorRegExp, '');
return _exactAliases[upper] ?? _exactAliases[compact] ?? compact;
}
/// Parse and normalize a backend value. Accepts a List or a comma-separated
/// String for compatibility with loosely typed JSON mapping code.
static List<String>? parseList(dynamic value) {
if (value == null) {
return null;
}
final Iterable<dynamic> items;
if (value is String) {
final trimmed = value.trim();
if (trimmed.isEmpty) {
return const <String>[];
}
items = trimmed.split(_listSeparatorRegExp);
} else if (value is Iterable) {
items = value;
} else {
return null;
}
final normalized = <String>[];
final seen = <String>{};
for (final item in items) {
final provider = normalize(item?.toString());
if (provider != null && seen.add(provider)) {
normalized.add(provider);
}
}
return normalized;
}
/// Returns whether the Android market update entry should be visible.
///
/// This method only answers the display question. Use [checkMarketUpdate]
/// when the UI needs to know whether the visible entry is clickable.
static bool shouldShowMarketUpdateButton({
required List<String>? allowedProviders,
Map<String, dynamic>? deviceInfo,
}) {
final allowed = parseList(allowedProviders);
if (allowed == null || allowed.isEmpty) {
return true;
}
final deviceProvider = resolveDeviceProvider(deviceInfo);
return deviceProvider != null && allowed.contains(deviceProvider);
}
/// Async helper for callers that need to read device info before deciding
/// whether the Android market update entry should be visible.
static Future<bool> shouldShowMarketUpdateButtonWithDeviceInfo({
required List<String>? allowedProviders,
required Future<Map<String, dynamic>?> Function() getDeviceInfo,
}) async {
final allowed = parseList(allowedProviders);
if (allowed == null || allowed.isEmpty) {
return true;
}
try {
return shouldShowMarketUpdateButton(
allowedProviders: allowed,
deviceInfo: await getDeviceInfo(),
);
} catch (_) {
return false;
}
}
/// Resolve the current Android device to a market provider code.
///
/// Manufacturer-like aliases are intentionally mapped to the market ecosystem
/// they use. For example REDMI and POCO both resolve to XIAOMI, while iQOO
/// resolves to VIVO.
static String? resolveDeviceProvider(Map<String, dynamic>? deviceInfo) {
if (deviceInfo == null || deviceInfo.isEmpty) {
return null;
}
final candidates = <String?>[
deviceInfo['manufacturer']?.toString(),
deviceInfo['brand']?.toString(),
deviceInfo['device']?.toString(),
deviceInfo['model']?.toString(),
];
// Older Honor devices may report manufacturer=HUAWEI while brand/model
// still contains HONOR. Prefer HONOR so Huawei and Honor reviews can be
// controlled independently.
for (final candidate in candidates) {
if (_resolveDeviceText(candidate) == honor) {
return honor;
}
}
for (final candidate in candidates) {
final provider = _resolveDeviceText(candidate);
if (provider != null) {
return provider;
}
}
return null;
}
/// Returns true when market update should be shown for the current device.
///
/// Matching rules:
/// - null allow-list: skip validation and keep old behavior
/// - empty allow-list: market update is disabled
/// - device provider match: market update is enabled
/// - installed standalone market match, such as TENCENT: market update is enabled
static bool allowsMarketUpdate({
required List<String>? allowedProviders,
Map<String, dynamic>? deviceInfo,
List<String> installedMarkets = const <String>[],
}) {
return checkMarketUpdate(
allowedProviders: allowedProviders,
deviceInfo: deviceInfo,
installedMarkets: installedMarkets,
).allowed;
}
/// Check whether market update is enabled and return a user-facing disabled
/// reason when it is not.
static AndroidMarketProviderCheckResult checkMarketUpdate({
required List<String>? allowedProviders,
Map<String, dynamic>? deviceInfo,
List<String> installedMarkets = const <String>[],
}) {
final allowed = parseList(allowedProviders);
final installedProviders = parseList(installedMarkets) ?? const <String>[];
if (allowed == null) {
return AndroidMarketProviderCheckResult(
allowed: true,
allowedProviders: null,
deviceProvider: resolveDeviceProvider(deviceInfo),
installedProviders: installedProviders,
);
}
if (allowed.isEmpty) {
return AndroidMarketProviderCheckResult(
allowed: false,
allowedProviders: allowed,
deviceProvider: resolveDeviceProvider(deviceInfo),
installedProviders: installedProviders,
disabledReason: marketUpdateDisabledReason,
);
}
final allowedSet = allowed.toSet();
final deviceProvider = resolveDeviceProvider(deviceInfo);
if (deviceProvider != null && allowedSet.contains(deviceProvider)) {
return AndroidMarketProviderCheckResult(
allowed: true,
allowedProviders: allowed,
deviceProvider: deviceProvider,
installedProviders: installedProviders,
);
}
final installedProviderMatched =
installedProviders.any(allowedSet.contains);
if (installedProviderMatched) {
return AndroidMarketProviderCheckResult(
allowed: true,
allowedProviders: allowed,
deviceProvider: deviceProvider,
installedProviders: installedProviders,
);
}
return AndroidMarketProviderCheckResult(
allowed: false,
allowedProviders: allowed,
deviceProvider: deviceProvider,
installedProviders: installedProviders,
disabledReason: marketUpdateDisabledReason,
);
}
static String? _resolveDeviceText(String? value) {
final raw = value?.trim();
if (raw == null || raw.isEmpty) {
return null;
}
final upper = raw.toUpperCase();
final compact = upper.replaceAll(_separatorRegExp, '');
final exact = _deviceExactAliases[upper] ?? _deviceExactAliases[compact];
if (exact != null) {
return exact;
}
for (final alias in _deviceTextAliases) {
if (compact.contains(alias.key)) {
return alias.value;
}
}
return null;
}
}
class AndroidMarketProviderCheckResult {
const AndroidMarketProviderCheckResult({
required this.allowed,
required this.allowedProviders,
required this.deviceProvider,
required this.installedProviders,
this.disabledReason,
});
final bool allowed;
final List<String>? allowedProviders;
final String? deviceProvider;
final List<String> installedProviders;
final String? disabledReason;
bool get isRestricted => allowedProviders != null;
}

View File

@ -1,65 +0,0 @@
///
enum AppMarket {
googlePlay,
appStore,
huawei,
oppo,
vivo,
xiaomi,
tencent,
coolapk,
custom,
unknown;
///
static AppMarket fromString(String? market) {
switch (market?.toLowerCase()) {
case 'googleplay':
return AppMarket.googlePlay;
case 'appstore':
return AppMarket.appStore;
case 'huawei':
return AppMarket.huawei;
case 'oppo':
return AppMarket.oppo;
case 'vivo':
return AppMarket.vivo;
case 'xiaomi':
return AppMarket.xiaomi;
case 'tencent':
return AppMarket.tencent;
case 'coolapk':
return AppMarket.coolapk;
case 'custom':
return AppMarket.custom;
default:
return AppMarket.unknown;
}
}
///
String get displayName {
switch (this) {
case AppMarket.googlePlay:
return 'Google Play';
case AppMarket.appStore:
return 'App Store';
case AppMarket.huawei:
return '华为应用市场';
case AppMarket.oppo:
return 'OPPO软件商店';
case AppMarket.vivo:
return 'vivo应用商店';
case AppMarket.xiaomi:
return '小米应用商店';
case AppMarket.tencent:
return '腾讯应用宝';
case AppMarket.coolapk:
return '酷安';
case AppMarket.custom:
return '自定义';
default:
return '未知';
}
}
}

View File

@ -1,4 +1,3 @@
import 'app_market.dart';
import 'app_upgrade_method.dart';
///
@ -28,10 +27,12 @@ class AppUpgradeVersion {
/// APK文件的MD5值 ()
final String? apkMd5;
/// (Android多渠道更新)
///
///
final List<AppMarket>? appMarkets;
/// Android ( market )
///
/// ["XIAOMI", "HUAWEI", "HONOR", "TENCENT"]
/// SDK REDMI/POCO XIAOMIiQOO VIVO
/// null market
final List<String>? appMarkets;
/// (null使)
final List<AppUpgradeMethod>? supportedMethods;
@ -51,7 +52,7 @@ class AppUpgradeVersion {
@override
String toString() {
return 'AppUpgradeVersion(versionName: $versionName, versionBuildNumber: $versionBuildNumber, isForce: $isForce, downloadUrl: $downloadUrl, supportedMethods: $supportedMethods)';
return 'AppUpgradeVersion(versionName: $versionName, versionBuildNumber: $versionBuildNumber, isForce: $isForce, downloadUrl: $downloadUrl, appMarkets: $appMarkets, supportedMethods: $supportedMethods)';
}
/// App Store地址

View File

@ -1,5 +1,5 @@
library;
export 'app_market.dart';
export 'app_upgrade_method.dart';
export 'app_upgrade_version.dart';
export 'android_market_provider.dart';

View File

@ -1,5 +1,5 @@
import 'app_market.dart';
import 'app_upgrade_method.dart';
import 'android_market_provider.dart';
/// App升级信息模型
class UpgradeInfo {
@ -36,10 +36,13 @@ class UpgradeInfo {
/// APK MD5值
final String? apkMd5;
/// Android多渠道更新
///
///
final List<AppMarket>? appMarkets;
/// Android market
///
/// ["XIAOMI", "HUAWEI", "HONOR", "TENCENT"]
/// - null: market
/// - : market
/// - :
final List<String>? appMarkets;
///
final List<AppUpgradeMethod> supportedMethods;
@ -57,7 +60,11 @@ class UpgradeInfo {
this.apkSize,
this.apkMd5,
this.appMarkets,
this.supportedMethods = const [AppUpgradeMethod.market, AppUpgradeMethod.browser, AppUpgradeMethod.inApp],
this.supportedMethods = const [
AppUpgradeMethod.market,
AppUpgradeMethod.browser,
AppUpgradeMethod.inApp
],
});
/// JSON创建
@ -99,11 +106,16 @@ class UpgradeInfo {
return AppUpgradeMethod.inApp;
}).toList();
} else {
supportedMethods = [AppUpgradeMethod.market, AppUpgradeMethod.browser, AppUpgradeMethod.inApp];
supportedMethods = [
AppUpgradeMethod.market,
AppUpgradeMethod.browser,
AppUpgradeMethod.inApp
];
}
return UpgradeInfo(
hasUpdate: versionBuildNumber != currentBuildNumber || versionName != currentVersionName,
hasUpdate: versionBuildNumber != currentBuildNumber ||
versionName != currentVersionName,
isForceUpdate: json['isForceUpdate'] ?? false,
versionBuildNumber: versionBuildNumber,
versionName: versionName,
@ -114,7 +126,7 @@ class UpgradeInfo {
appStoreUrl: json['appStoreUrl'] as String?,
apkSize: json['apkSize'] as int?,
apkMd5: json['apkMd5'] as String?,
appMarkets: (json['appMarkets'] as List<dynamic>?)?.map((e) => AppMarket.fromString(e as String)).toList(),
appMarkets: AndroidMarketProvider.parseList(json['appMarkets']),
supportedMethods: supportedMethods,
);
}
@ -132,7 +144,7 @@ class UpgradeInfo {
'appStoreUrl': appStoreUrl,
'apkSize': apkSize,
'apkMd5': apkMd5,
'appMarkets': appMarkets?.map((e) => e.name).toList(),
'appMarkets': appMarkets,
'supportedMethods': supportedMethods.map((e) => e.name).toList(),
};
}

View File

@ -24,6 +24,8 @@ dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
mocktail: ^1.0.5
device_info_plus: ^12.4.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec

View File

@ -0,0 +1,589 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:yx_app_upgrade_flutter/models/android_market_provider.dart';
import 'package:yx_app_upgrade_flutter/models/upgrade_info.dart';
void main() {
group('AndroidMarketProvider', () {
test('normalizes Xiaomi ecosystem aliases to XIAOMI', () {
expect(AndroidMarketProvider.normalize('xiaomi'), 'XIAOMI');
expect(AndroidMarketProvider.normalize('REDMI'), 'XIAOMI');
expect(AndroidMarketProvider.normalize('poco'), 'XIAOMI');
expect(AndroidMarketProvider.normalize('miui'), 'XIAOMI');
});
test('normalizes common backend aliases and installed-market values', () {
final cases = <String, String>{
'googlePlay': 'GOOGLE_PLAY',
'google-play': 'GOOGLE_PLAY',
'GOOGLE_PLAY': 'GOOGLE_PLAY',
'yingyongbao': 'TENCENT',
'YYB': 'TENCENT',
'qq_downloader': 'TENCENT',
'qihoo360': 'QIHOO_360',
'360': 'QIHOO_360',
'baidu_app_search': 'BAIDU',
'wandoujia': 'WANDOUJIA',
'coolapk': 'COOLAPK',
'motorola': 'LENOVO',
'nubia': 'ZTE',
'hey tap': 'OPPO',
};
for (final entry in cases.entries) {
expect(
AndroidMarketProvider.normalize(entry.key),
entry.value,
reason: '${entry.key} should normalize to ${entry.value}',
);
}
});
test('normalizes null, blank, unknown and separated values safely', () {
expect(AndroidMarketProvider.normalize(null), isNull);
expect(AndroidMarketProvider.normalize(''), isNull);
expect(AndroidMarketProvider.normalize(' '), isNull);
expect(AndroidMarketProvider.normalize('custom_vendor'), 'CUSTOMVENDOR');
expect(AndroidMarketProvider.normalize('foo-bar.baz'), 'FOOBARBAZ');
});
test('resolves device aliases to their app market ecosystem', () {
expect(
AndroidMarketProvider.resolveDeviceProvider({
'manufacturer': 'Redmi',
'brand': 'Xiaomi',
'model': 'Redmi Note 12',
}),
'XIAOMI',
);
expect(
AndroidMarketProvider.resolveDeviceProvider({
'manufacturer': 'iQOO',
'brand': 'vivo',
}),
'VIVO',
);
expect(
AndroidMarketProvider.resolveDeviceProvider({
'manufacturer': 'OnePlus',
'brand': 'OnePlus',
}),
'OPPO',
);
expect(
AndroidMarketProvider.resolveDeviceProvider({
'manufacturer': 'HONOR',
'brand': 'HONOR',
}),
'HONOR',
);
expect(
AndroidMarketProvider.resolveDeviceProvider({
'manufacturer': 'HUAWEI',
'brand': 'HONOR',
'model': 'HONOR 30',
}),
'HONOR',
);
});
test(
'resolves provider from brand, device or model when manufacturer is unclear',
() {
expect(
AndroidMarketProvider.resolveDeviceProvider({
'manufacturer': 'unknown',
'brand': 'POCO',
'model': 'POCO F5',
}),
'XIAOMI',
);
expect(
AndroidMarketProvider.resolveDeviceProvider({
'manufacturer': 'unknown',
'brand': 'unknown',
'device': 'VIVO2019',
}),
'VIVO',
);
expect(
AndroidMarketProvider.resolveDeviceProvider({
'manufacturer': 'unknown',
'brand': 'unknown',
'model': 'Samsung SM-S9280',
}),
'SAMSUNG',
);
});
test('resolves Google or Pixel emulator as GOOGLE_PLAY provider', () {
final googleEmulator = {
'manufacturer': 'Google',
'brand': 'google',
'device': 'emulator64_x86_64',
'model': 'sdk_gphone64_x86_64',
};
expect(
AndroidMarketProvider.resolveDeviceProvider(googleEmulator),
'GOOGLE_PLAY',
);
expect(
AndroidMarketProvider.allowsMarketUpdate(
allowedProviders: const ['GOOGLE_PLAY'],
deviceInfo: googleEmulator,
),
isTrue,
);
expect(
AndroidMarketProvider.allowsMarketUpdate(
allowedProviders: const ['XIAOMI'],
deviceInfo: googleEmulator,
),
isFalse,
);
});
test('treats generic AOSP emulator without market ecosystem as unknown',
() {
final aospEmulator = {
'manufacturer': 'unknown',
'brand': 'Android',
'device': 'generic_x86',
'model': 'Android SDK built for x86',
};
final result = AndroidMarketProvider.checkMarketUpdate(
allowedProviders: const ['GOOGLE_PLAY'],
deviceInfo: aospEmulator,
);
expect(AndroidMarketProvider.resolveDeviceProvider(aospEmulator), isNull);
expect(result.allowed, isFalse);
expect(
result.disabledReason,
AndroidMarketProvider.marketUpdateDisabledReason,
);
});
test('returns null for missing or unknown device provider', () {
expect(AndroidMarketProvider.resolveDeviceProvider(null), isNull);
expect(AndroidMarketProvider.resolveDeviceProvider({}), isNull);
expect(
AndroidMarketProvider.resolveDeviceProvider({
'manufacturer': 'unknown',
'brand': 'unknown',
'model': 'unknown',
}),
isNull,
);
});
test('does not resolve standalone app markets as device providers', () {
final standaloneValues = [
'TENCENT',
'YINGYONGBAO',
'YYB',
'BAIDU',
'BAIDUAPPSEARCH',
'QIHOO360',
'360',
'WANDOUJIA',
'COOLAPK',
];
for (final value in standaloneValues) {
expect(
AndroidMarketProvider.resolveDeviceProvider({
'manufacturer': value,
'brand': value,
'model': value,
}),
isNull,
reason: '$value is an app market, not a device provider',
);
}
});
test('keeps null allow-list as old behavior and disables empty allow-list',
() {
expect(
AndroidMarketProvider.allowsMarketUpdate(
allowedProviders: null,
deviceInfo: {'manufacturer': 'Unknown'},
),
isTrue,
);
expect(
AndroidMarketProvider.allowsMarketUpdate(
allowedProviders: const <String>[],
deviceInfo: {'manufacturer': 'Xiaomi'},
),
isFalse,
);
});
test('returns disabled reason for explicit empty appMarkets', () {
final result = AndroidMarketProvider.checkMarketUpdate(
allowedProviders: const <String>[],
deviceInfo: {'manufacturer': 'Xiaomi'},
);
expect(result.allowed, isFalse);
expect(result.isRestricted, isTrue);
expect(result.allowedProviders, const <String>[]);
expect(result.deviceProvider, 'XIAOMI');
expect(
result.disabledReason,
AndroidMarketProvider.marketUpdateDisabledReason,
);
});
test('returns disabled reason when current device provider is not allowed',
() {
final result = AndroidMarketProvider.checkMarketUpdate(
allowedProviders: const ['HUAWEI', 'HONOR'],
deviceInfo: {'manufacturer': 'Redmi'},
installedMarkets: const ['xiaomi'],
);
expect(result.allowed, isFalse);
expect(result.allowedProviders, const ['HUAWEI', 'HONOR']);
expect(result.deviceProvider, 'XIAOMI');
expect(result.installedProviders, const ['XIAOMI']);
expect(
result.disabledReason,
AndroidMarketProvider.marketUpdateDisabledReason,
);
});
test('returns disabled reason when device provider cannot be recognized',
() {
final result = AndroidMarketProvider.checkMarketUpdate(
allowedProviders: const ['HUAWEI'],
deviceInfo: {'manufacturer': 'UnknownVendor'},
);
expect(result.allowed, isFalse);
expect(result.deviceProvider, isNull);
expect(
result.disabledReason,
AndroidMarketProvider.marketUpdateDisabledReason,
);
});
test('returns enabled result when appMarkets is null', () {
final result = AndroidMarketProvider.checkMarketUpdate(
allowedProviders: null,
deviceInfo: {'manufacturer': 'UnknownVendor'},
);
expect(result.allowed, isTrue);
expect(result.isRestricted, isFalse);
expect(result.disabledReason, isNull);
});
test('returns enabled result with normalized fields on provider match', () {
final result = AndroidMarketProvider.checkMarketUpdate(
allowedProviders: const ['XIAOMI'],
deviceInfo: {'manufacturer': 'Redmi'},
);
expect(result.allowed, isTrue);
expect(result.allowedProviders, const ['XIAOMI']);
expect(result.deviceProvider, 'XIAOMI');
expect(result.disabledReason, isNull);
});
test('parses list values, removes blanks and deduplicates aliases', () {
expect(AndroidMarketProvider.parseList(null), isNull);
expect(AndroidMarketProvider.parseList(''), const <String>[]);
expect(
AndroidMarketProvider.parseList(' , ; | '), const <String>[]);
expect(
AndroidMarketProvider.parseList([
'xiaomi',
'REDMI',
' poco ',
null,
'',
'TENCENT',
'yingyongbao',
360,
]),
const ['XIAOMI', 'TENCENT', 'QIHOO_360'],
);
});
test('parses mixed separators from backend strings', () {
expect(
AndroidMarketProvider.parseList(
'xiaomi, huawei;honor|oppovivoyyb、360'),
const [
'XIAOMI',
'HUAWEI',
'HONOR',
'OPPO',
'VIVO',
'TENCENT',
'QIHOO_360',
],
);
});
test('matches device provider when allow-list is not empty', () {
expect(
AndroidMarketProvider.allowsMarketUpdate(
allowedProviders: const ['xiaomi', 'HUAWEI'],
deviceInfo: {'manufacturer': 'Redmi'},
),
isTrue,
);
expect(
AndroidMarketProvider.allowsMarketUpdate(
allowedProviders: const ['HUAWEI'],
deviceInfo: {'manufacturer': 'HONOR'},
),
isFalse,
);
expect(
AndroidMarketProvider.allowsMarketUpdate(
allowedProviders: const ['HONOR'],
deviceInfo: {'manufacturer': 'HONOR'},
),
isTrue,
);
});
test('treats Huawei and Honor as separate app market providers', () {
final oldHonorDevice = {
'manufacturer': 'HUAWEI',
'brand': 'HONOR',
'model': 'HONOR 30',
};
expect(
AndroidMarketProvider.allowsMarketUpdate(
allowedProviders: const ['HUAWEI'],
deviceInfo: oldHonorDevice,
),
isFalse,
);
expect(
AndroidMarketProvider.allowsMarketUpdate(
allowedProviders: const ['HONOR'],
deviceInfo: oldHonorDevice,
),
isTrue,
);
expect(
AndroidMarketProvider.allowsMarketUpdate(
allowedProviders: const ['HUAWEI'],
deviceInfo: {'manufacturer': 'HUAWEI', 'brand': 'HUAWEI'},
),
isTrue,
);
});
test('does not allow market update when device and installed market miss',
() {
expect(
AndroidMarketProvider.allowsMarketUpdate(
allowedProviders: const ['HUAWEI', 'HONOR'],
deviceInfo: {'manufacturer': 'Redmi'},
installedMarkets: const ['xiaomi'],
),
isFalse,
);
});
test('matches standalone market providers by installed markets', () {
expect(
AndroidMarketProvider.allowsMarketUpdate(
allowedProviders: const ['TENCENT'],
deviceInfo: {'manufacturer': 'Xiaomi'},
installedMarkets: const ['tencent'],
),
isTrue,
);
expect(
AndroidMarketProvider.allowsMarketUpdate(
allowedProviders: const ['TENCENT'],
deviceInfo: {'manufacturer': 'Xiaomi'},
installedMarkets: const <String>[],
),
isFalse,
);
});
test('requires installation for app markets without matching phone brand',
() {
final cases = <String, String>{
'TENCENT': 'tencent',
'BAIDU': 'baidu',
'QIHOO_360': 'qihoo360',
'WANDOUJIA': 'wandoujia',
'COOLAPK': 'coolapk',
};
for (final entry in cases.entries) {
final notInstalled = AndroidMarketProvider.checkMarketUpdate(
allowedProviders: [entry.key],
deviceInfo: {
'manufacturer': entry.key,
'brand': entry.key,
'model': entry.key,
},
installedMarkets: const <String>[],
);
expect(notInstalled.allowed, isFalse,
reason: '${entry.key} must not match by device brand');
expect(notInstalled.deviceProvider, isNull);
expect(
notInstalled.disabledReason,
AndroidMarketProvider.marketUpdateDisabledReason,
);
final installed = AndroidMarketProvider.checkMarketUpdate(
allowedProviders: [entry.key],
deviceInfo: {
'manufacturer': 'UnknownVendor',
'brand': 'UnknownVendor',
},
installedMarkets: [entry.value],
);
expect(installed.allowed, isTrue,
reason: '${entry.key} should match when installed market exists');
expect(installed.deviceProvider, isNull);
expect(installed.installedProviders, [entry.key]);
}
});
test('matches installed OEM markets with normalized package mapping values',
() {
expect(
AndroidMarketProvider.allowsMarketUpdate(
allowedProviders: const ['GOOGLE_PLAY'],
deviceInfo: {'manufacturer': 'Samsung'},
installedMarkets: const ['googlePlay'],
),
isTrue,
);
expect(
AndroidMarketProvider.allowsMarketUpdate(
allowedProviders: const ['QIHOO_360'],
deviceInfo: {'manufacturer': 'Xiaomi'},
installedMarkets: const ['qihoo360'],
),
isTrue,
);
expect(
AndroidMarketProvider.allowsMarketUpdate(
allowedProviders: const ['BAIDU'],
deviceInfo: {'manufacturer': 'Xiaomi'},
installedMarkets: const ['baidu'],
),
isTrue,
);
});
test(
'allows unknown custom providers only when installed market also matches',
() {
expect(
AndroidMarketProvider.allowsMarketUpdate(
allowedProviders: const ['CUSTOM_VENDOR'],
deviceInfo: {'manufacturer': 'Xiaomi'},
installedMarkets: const ['custom-vendor'],
),
isTrue,
);
expect(
AndroidMarketProvider.allowsMarketUpdate(
allowedProviders: const ['CUSTOM_VENDOR'],
deviceInfo: {'manufacturer': 'Xiaomi'},
installedMarkets: const ['tencent'],
),
isFalse,
);
});
test('parses comma-separated provider strings for compatibility', () {
expect(
AndroidMarketProvider.parseList('xiaomi, redmi; yingyongbao|iqoo'),
const ['XIAOMI', 'TENCENT', 'VIVO'],
);
});
});
group('UpgradeInfo', () {
test('parses appMarkets from json and normalizes aliases', () {
final info = UpgradeInfo.fromJson(
{
'isForceUpdate': false,
'versionBuildNumber': 2,
'versionName': '2.0.0',
'updateContent': 'test',
'appMarkets': ['xiaomi', 'REDMI', 'yingyongbao'],
},
currentBuildNumber: 1,
currentVersionName: '1.0.0',
);
expect(
info.appMarkets,
const ['XIAOMI', 'TENCENT'],
);
});
test('keeps null appMarkets when server does not return the field', () {
final info = UpgradeInfo.fromJson(
{
'isForceUpdate': false,
'versionBuildNumber': 2,
'versionName': '2.0.0',
'updateContent': 'test',
},
currentBuildNumber: 1,
currentVersionName: '1.0.0',
);
expect(info.appMarkets, isNull);
expect(info.toJson()['appMarkets'], isNull);
});
test('keeps empty appMarkets as explicit disabled-market instruction', () {
final info = UpgradeInfo.fromJson(
{
'isForceUpdate': false,
'versionBuildNumber': 2,
'versionName': '2.0.0',
'updateContent': 'test',
'appMarkets': <String>[],
},
currentBuildNumber: 1,
currentVersionName: '1.0.0',
);
expect(info.appMarkets, const <String>[]);
expect(info.toJson()['appMarkets'], const <String>[]);
});
test('parses appMarkets from comma-separated server string', () {
final info = UpgradeInfo.fromJson(
{
'isForceUpdate': false,
'versionBuildNumber': 2,
'versionName': '2.0.0',
'updateContent': 'test',
'appMarkets': 'huawei, honor, xiaomi, redmi',
},
currentBuildNumber: 1,
currentVersionName: '1.0.0',
);
expect(info.appMarkets, const ['HUAWEI', 'HONOR', 'XIAOMI']);
});
});
}

View File

@ -0,0 +1,126 @@
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:yx_app_upgrade_flutter/models/android_market_provider.dart';
class MockDeviceInfoPlugin extends Mock implements DeviceInfoPlugin {}
class MockAndroidDeviceInfo extends Mock implements AndroidDeviceInfo {}
Future<bool> shouldShowMarketUpdateButtonWithPlugin({
required List<String>? appMarkets,
required DeviceInfoPlugin deviceInfoPlugin,
}) {
return AndroidMarketProvider.shouldShowMarketUpdateButtonWithDeviceInfo(
allowedProviders: appMarkets,
getDeviceInfo: () async {
final androidInfo = await deviceInfoPlugin.androidInfo;
return {
'manufacturer': androidInfo.manufacturer,
};
},
);
}
void main() {
group('shouldShowMarketUpdateButton', () {
late MockDeviceInfoPlugin deviceInfoPlugin;
late MockAndroidDeviceInfo androidInfo;
setUp(() {
deviceInfoPlugin = MockDeviceInfoPlugin();
androidInfo = MockAndroidDeviceInfo();
});
void mockManufacturer(String manufacturer) {
when(() => androidInfo.manufacturer).thenReturn(manufacturer);
when(() => deviceInfoPlugin.androidInfo)
.thenAnswer((_) async => androidInfo);
}
test('returns true when backend whitelist is null', () async {
final result = await shouldShowMarketUpdateButtonWithPlugin(
appMarkets: null,
deviceInfoPlugin: deviceInfoPlugin,
);
expect(result, isTrue);
verifyNever(() => deviceInfoPlugin.androidInfo);
});
test('returns true when backend whitelist is empty', () async {
final result = await shouldShowMarketUpdateButtonWithPlugin(
appMarkets: const <String>[],
deviceInfoPlugin: deviceInfoPlugin,
);
expect(result, isTrue);
verifyNever(() => deviceInfoPlugin.androidInfo);
});
test('returns true when current manufacturer is in whitelist ignoring case',
() async {
mockManufacturer('Xiaomi');
final result = await shouldShowMarketUpdateButtonWithPlugin(
appMarkets: const ['XIAOMI'],
deviceInfoPlugin: deviceInfoPlugin,
);
expect(result, isTrue);
verify(() => deviceInfoPlugin.androidInfo).called(1);
});
test('returns true for Google emulator when whitelist contains GOOGLE_PLAY',
() async {
mockManufacturer('Google');
final result = await shouldShowMarketUpdateButtonWithPlugin(
appMarkets: const ['GOOGLE_PLAY'],
deviceInfoPlugin: deviceInfoPlugin,
);
expect(result, isTrue);
verify(() => deviceInfoPlugin.androidInfo).called(1);
});
test('returns false when current manufacturer is not in whitelist',
() async {
mockManufacturer('Samsung');
final result = await shouldShowMarketUpdateButtonWithPlugin(
appMarkets: const ['XIAOMI', 'HUAWEI'],
deviceInfoPlugin: deviceInfoPlugin,
);
expect(result, isFalse);
verify(() => deviceInfoPlugin.androidInfo).called(1);
});
test('returns false when reading manufacturer throws', () async {
when(() => deviceInfoPlugin.androidInfo).thenThrow(
Exception('failed to read device info'),
);
final result = await shouldShowMarketUpdateButtonWithPlugin(
appMarkets: const ['XIAOMI'],
deviceInfoPlugin: deviceInfoPlugin,
);
expect(result, isFalse);
verify(() => deviceInfoPlugin.androidInfo).called(1);
});
test('returns false when manufacturer is blank', () async {
mockManufacturer('');
final result = await shouldShowMarketUpdateButtonWithPlugin(
appMarkets: const ['XIAOMI'],
deviceInfoPlugin: deviceInfoPlugin,
);
expect(result, isFalse);
verify(() => deviceInfoPlugin.androidInfo).called(1);
});
});
}