diff --git a/MARKET_WHITELIST_SIMPLE.md b/MARKET_WHITELIST_SIMPLE.md index d71d83f..8d5cc0a 100644 --- a/MARKET_WHITELIST_SIMPLE.md +++ b/MARKET_WHITELIST_SIMPLE.md @@ -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?` 改为 `List?` -- ✅ 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. 此功能只影响 Android,iOS 会忽略 `appMarkets`。 +2. `appMarkets` 只控制市场更新入口是否可点击,不再作为点击后的应用市场白名单。 +3. 后端可返回大写字符串;SDK 仍兼容小写、混合大小写和常见别名。 +4. 如果同时配置 `supportedMethods`,必须包含 `"market"`,否则即使 `appMarkets` 匹配也不会出现“应用市场更新”入口。 diff --git a/README.md b/README.md index 390b1b8..87fd84e 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ - **🛡️ 权限适配完善**:针对不同 Android 版本的存储、安装、通知权限自动处理 - **🌐 网络可配置**:证书校验、超时、默认方法、Headers 等 - **🔧 安装策略灵活**:系统流程/预检查权限/智能策略可选 -- **🏪 应用市场支持**:支持多应用市场白名单,智能检测设备已安装的市场 +- **🏪 应用市场支持**:支持按 Android 厂商/服务商控制应用市场更新入口 - **📦 多种更新方式**:应用市场、浏览器下载、应用内下载 ## 📦 安装 @@ -270,7 +270,7 @@ debugPrint('bodyItems: ${parsed.bodyItems.map((e) => e.text).toList()}'); - + @@ -280,6 +280,18 @@ debugPrint('bodyItems: ${parsed.bodyItems.map((e) => e.text).toList()}'); + + + + + + + + + + + + ``` @@ -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) 网络配置 diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index cdebb58..88241a9 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,3 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/src/main/kotlin/com/example/app_upgrade_plugin/AppUpgradePlugin.kt b/android/src/main/kotlin/com/example/app_upgrade_plugin/AppUpgradePlugin.kt index 040e55c..fac52b1 100644 --- a/android/src/main/kotlin/com/example/app_upgrade_plugin/AppUpgradePlugin.kt +++ b/android/src/main/kotlin/com/example/app_upgrade_plugin/AppUpgradePlugin.kt @@ -407,16 +407,25 @@ class AppUpgradePlugin: FlutterPlugin, MethodCallHandler, ActivityAware { private fun getInstalledMarkets(result: Result) { try { val pm = context.packageManager - val installedMarkets = mutableListOf() + val installedMarkets = mutableSetOf() // 常见应用市场的包名映射 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 } -} \ No newline at end of file +} diff --git a/example/.fvmrc b/example/.fvmrc new file mode 100644 index 0000000..1d108d2 --- /dev/null +++ b/example/.fvmrc @@ -0,0 +1,3 @@ +{ + "flutter": "3.35.5" +} \ No newline at end of file diff --git a/example/lib/main.dart b/example/lib/main.dart index 9478f68..0500995 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -123,7 +123,8 @@ class _HomePageState extends State { /// 将 UpdateappResult 转换为 AppUpgradeVersion AppUpgradeVersion _convertToAppUpgradeVersion(Map 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 { 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 { 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"); diff --git a/lib/app_upgrade_plugin.dart b/lib/app_upgrade_plugin.dart index 85afa20..312ee87 100644 --- a/lib/app_upgrade_plugin.dart +++ b/lib/app_upgrade_plugin.dart @@ -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'; diff --git a/lib/app_upgrade_simple.dart b/lib/app_upgrade_simple.dart index 513ed58..cda79d3 100644 --- a/lib/app_upgrade_simple.dart +++ b/lib/app_upgrade_simple.dart @@ -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 downloadApk(String url, - {Function(DownloadProgress)? onProgress}) { - return AppUpgradePluginPlatform.instance - .downloadApk(url, onProgress: onProgress); + Future downloadApk(String url, {Function(DownloadProgress)? onProgress}) { + return AppUpgradePluginPlatform.instance.downloadApk(url, onProgress: onProgress); } Future installApk(String filePath) { @@ -34,14 +33,17 @@ class _SimpleAppUpgradePlugin { } Future goToAppStore(String url, {required BuildContext context}) { - return AppUpgradePluginPlatform.instance - .goToAppStore(url, context: context); + return AppUpgradePluginPlatform.instance.goToAppStore(url, context: context); } Future> getAppInfo() { return AppUpgradePluginPlatform.instance.getAppInfo(); } + Future?> getDeviceInfo() { + return AppUpgradePluginPlatform.instance.getDeviceInfo(); + } + /// 获取已安装的应用市场列表 Future> 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升级管理器 /// 提供最简单的API,一行代码即可实现App升级功能 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( - context, MaterialLocalizations) != - null; + return Localizations.of(context, MaterialLocalizations) != null; } catch (_) { return false; } @@ -750,8 +748,7 @@ mixin _UpgradeDialogLogic on State { } void onAppLifecycleStateChanged(AppLifecycleState state) { - debugPrint( - '🔄 应用生命周期状态变化: $state, _isWaitingForInstallation=$_isWaitingForInstallation'); + debugPrint('🔄 应用生命周期状态变化: $state, _isWaitingForInstallation=$_isWaitingForInstallation'); if (_isWaitingForInstallation && state == AppLifecycleState.resumed) { debugPrint('⚡ 应用回到前台,检查安装状态'); @@ -808,14 +805,12 @@ mixin _UpgradeDialogLogic on State { 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 on State { 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 on State { Future _checkInstallationResult() async { if (!mounted || !_isWaitingForInstallation) { - debugPrint( - '跳过安装结果检查: mounted=$mounted, _isWaitingForInstallation=$_isWaitingForInstallation'); + debugPrint('跳过安装结果检查: mounted=$mounted, _isWaitingForInstallation=$_isWaitingForInstallation'); return; } @@ -958,30 +950,23 @@ mixin _UpgradeDialogLogic on State { 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 on State { } 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 on State { 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 on State { icon: Icons.update, label: '已安装版本', // 显示版本号 - value: - '${info.currentVersionName} +${info.currentBuildNumber}', + value: '${info.currentVersionName} +${info.currentBuildNumber}', colorScheme: colorScheme, ), ), @@ -1214,8 +1195,7 @@ mixin _UpgradeDialogLogic on State { 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 on State { 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 on State { 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 on State { // [高亮] 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 on State { } // **粗体** 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 on State { } // __斜体__ 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 on State { return _RichTextParseResult(spans, index, false); } - List _applyStyleToSpans( - List spans, TextStyle style, String fallbackText) { + List _applyStyleToSpans(List spans, TextStyle style, String fallbackText) { if (spans.isEmpty) { return [ TextSpan( @@ -1493,10 +1463,8 @@ mixin _UpgradeDialogLogic on State { } 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 on State { ); } - 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 on State { 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 on State { '系统将自动检测安装结果', 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 on State { 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 on State { ), ], ), - 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 on State { 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 on State { 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 on State { 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 on State { Future _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 on State { Future _handleAndroidAction() async { final List 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 on State { 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 on State { 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? deviceInfo; + List installedMarkets = const []; + + 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 _performMarketAction() async { @@ -1953,37 +1961,16 @@ mixin _UpgradeDialogLogic on State { 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 on State { } Future _showDownloadChoiceSheet( - List availableMethods) async { + List availableMethods, { + _MarketMethodState marketState = const _MarketMethodState.unsupported(), + }) async { if (!mounted) return; final choice = await showModalBottomSheet( @@ -2038,9 +2027,7 @@ mixin _UpgradeDialogLogic on State { 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 on State { ], ), ), - 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 on State { 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 on State { ), ), 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); diff --git a/lib/models/android_market_provider.dart b/lib/models/android_market_provider.dart new file mode 100644 index 0000000..4a1a241 --- /dev/null +++ b/lib/models/android_market_provider.dart @@ -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 _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 _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> _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? parseList(dynamic value) { + if (value == null) { + return null; + } + + final Iterable items; + if (value is String) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + return const []; + } + items = trimmed.split(_listSeparatorRegExp); + } else if (value is Iterable) { + items = value; + } else { + return null; + } + + final normalized = []; + final seen = {}; + + 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? allowedProviders, + Map? 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 shouldShowMarketUpdateButtonWithDeviceInfo({ + required List? allowedProviders, + required Future?> 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? deviceInfo) { + if (deviceInfo == null || deviceInfo.isEmpty) { + return null; + } + + final candidates = [ + 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? allowedProviders, + Map? deviceInfo, + List installedMarkets = const [], + }) { + 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? allowedProviders, + Map? deviceInfo, + List installedMarkets = const [], + }) { + final allowed = parseList(allowedProviders); + final installedProviders = parseList(installedMarkets) ?? const []; + + 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? allowedProviders; + final String? deviceProvider; + final List installedProviders; + final String? disabledReason; + + bool get isRestricted => allowedProviders != null; +} diff --git a/lib/models/app_market.dart b/lib/models/app_market.dart deleted file mode 100644 index 5664a04..0000000 --- a/lib/models/app_market.dart +++ /dev/null @@ -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 '未知'; - } - } -} diff --git a/lib/models/app_upgrade_version.dart b/lib/models/app_upgrade_version.dart index bf3dc86..1689308 100644 --- a/lib/models/app_upgrade_version.dart +++ b/lib/models/app_upgrade_version.dart @@ -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? appMarkets; + /// Android 应用市场服务商列表 (用于控制 market 更新入口是否可点击) + /// + /// 后端推荐返回大写字符串,如 ["XIAOMI", "HUAWEI", "HONOR", "TENCENT"]。 + /// SDK 会兼容大小写,并将 REDMI/POCO 归一为 XIAOMI,iQOO 归一为 VIVO。 + /// null 表示跳过校验;空数组表示 market 更新方式置灰不可点击。 + final List? appMarkets; /// 支持的更新方式 (如果为null,默认使用所有可用的方式) final List? 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地址 diff --git a/lib/models/index.dart b/lib/models/index.dart index 43816c6..a57c872 100644 --- a/lib/models/index.dart +++ b/lib/models/index.dart @@ -1,5 +1,5 @@ library; -export 'app_market.dart'; export 'app_upgrade_method.dart'; export 'app_upgrade_version.dart'; +export 'android_market_provider.dart'; diff --git a/lib/models/upgrade_info.dart b/lib/models/upgrade_info.dart index 0f8e71e..8e68f2c 100644 --- a/lib/models/upgrade_info.dart +++ b/lib/models/upgrade_info.dart @@ -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? appMarkets; + /// Android 应用市场服务商列表(用于控制 market 更新入口是否可点击) + /// + /// 由后端返回字符串数组,例如 ["XIAOMI", "HUAWEI", "HONOR", "TENCENT"]。 + /// - null: 跳过校验,保持旧逻辑,market 更新方式可点击 + /// - 空数组: market 更新方式置灰不可点击 + /// - 非空数组: 当前设备厂商或已安装独立市场匹配时才可点击 + final List? appMarkets; /// 支持的更新方式 final List 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?)?.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(), }; } diff --git a/pubspec.yaml b/pubspec.yaml index 74ced87..c9807f4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/android_market_provider_test.dart b/test/android_market_provider_test.dart new file mode 100644 index 0000000..b14329e --- /dev/null +++ b/test/android_market_provider_test.dart @@ -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 = { + '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 [], + deviceInfo: {'manufacturer': 'Xiaomi'}, + ), + isFalse, + ); + }); + + test('returns disabled reason for explicit empty appMarkets', () { + final result = AndroidMarketProvider.checkMarketUpdate( + allowedProviders: const [], + deviceInfo: {'manufacturer': 'Xiaomi'}, + ); + + expect(result.allowed, isFalse); + expect(result.isRestricted, isTrue); + expect(result.allowedProviders, const []); + 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 []); + expect( + AndroidMarketProvider.parseList(' , ; | , ; 、 '), const []); + 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|oppo,vivo;yyb、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 [], + ), + isFalse, + ); + }); + + test('requires installation for app markets without matching phone brand', + () { + final cases = { + '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 [], + ); + + 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': [], + }, + currentBuildNumber: 1, + currentVersionName: '1.0.0', + ); + + expect(info.appMarkets, const []); + expect(info.toJson()['appMarkets'], const []); + }); + + 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']); + }); + }); +} diff --git a/test/android_market_update_button_visibility_test.dart b/test/android_market_update_button_visibility_test.dart new file mode 100644 index 0000000..d0ef4fd --- /dev/null +++ b/test/android_market_update_button_visibility_test.dart @@ -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 shouldShowMarketUpdateButtonWithPlugin({ + required List? 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 [], + 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); + }); + }); +}