You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
484 lines
19 KiB
484 lines
19 KiB
import 'dart:io';
|
|
|
|
import 'package:get/get.dart';
|
|
import 'package:package_info_plus/package_info_plus.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
import 'package:logger/logger.dart';
|
|
import 'package:device_info_plus/device_info_plus.dart';
|
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
|
import 'package:get_storage/get_storage.dart';
|
|
import '../models/channel_app_model.dart';
|
|
import 'network/api.dart';
|
|
|
|
/// 版本更新服务类
|
|
class VersionUpdateService extends GetxService {
|
|
static VersionUpdateService get to => Get.find();
|
|
|
|
// 静态应用信息(内存缓存)
|
|
static String? _appVersion = ""; //应用版本号
|
|
static String? _buildNumber = ""; //构建号
|
|
static String? _platform = ""; //平台类型
|
|
static String? _deviceBrand = ""; //设备品牌
|
|
static UserChannel? _channel; //渠道
|
|
static String? _latestVersion = ""; //最新版本号+构建号
|
|
static bool? _hasUpdate = false; //是否有更新
|
|
static bool? _isForceUpdate = false; //是否强制更新
|
|
static bool? _isReviewing = false; //本机版本是否正处于渠道审核期(服务端判定)
|
|
static String? _updateUrl = ""; //更新链接
|
|
static AppInfo? _appInfo; //应用信息
|
|
static bool? _isShowTourists = false; //是否显示游客登录
|
|
static bool? _allowSkipDeviceBinding = false; //是否允许跳过设备绑定
|
|
static DateTime? _lastCheckTime; //上次检查时间
|
|
static const int _cacheIntervalMinutes = 10; // 可配置化
|
|
static final String? _countryCode = Get.deviceLocale?.countryCode; //国家地区编码
|
|
static final String? _languageCode = Get.deviceLocale?.languageCode; //语言编码
|
|
// ignore: non_constant_identifier_names
|
|
// static final String? _SERVER_URL = (_countryCode == 'CN')
|
|
// ? dotenv.env['SERVER_URL']
|
|
// : dotenv.env['HW_SERVER_URL']; //服务器地址
|
|
|
|
// 添加 getter 方法来访问缓存的静态数据
|
|
/// 获取缓存的应用版本
|
|
String get cachedAppVersion => _appVersion ?? '';
|
|
|
|
/// 获取缓存的构建号
|
|
String get cachedBuildNumber => _buildNumber ?? '';
|
|
|
|
/// 获取缓存的平台信息
|
|
String get cachedPlatform => _platform ?? '';
|
|
|
|
/// 获取缓存的设备品牌
|
|
String get cachedDeviceBrand => _deviceBrand ?? '';
|
|
|
|
/// 获取缓存的渠道信息
|
|
UserChannel get cachedChannel =>
|
|
_channel ??
|
|
(_platform?.toLowerCase() == 'ios'
|
|
? UserChannel.GLOBAL_APPLE
|
|
: UserChannel.GLOBAL_GOOGLE);
|
|
|
|
/// 获取缓存的最新版本
|
|
String get cachedLatestVersion => _latestVersion ?? '';
|
|
|
|
/// 获取缓存的是否有更新
|
|
bool get cachedHasUpdate => _hasUpdate ?? false;
|
|
|
|
/// 获取缓存的是否强制更新
|
|
bool get cachedIsForceUpdate => _isForceUpdate ?? false;
|
|
|
|
/// 获取缓存的更新地址
|
|
String get cachedUpdateUrl => _updateUrl ?? '';
|
|
|
|
/// 本机版本是否正处于渠道审核期(后台「版本控制」填的送审版本号等于本机版本)。
|
|
/// 服务端算好的结论,供「审核期隐藏未过审功能」用;目前还没有 UI 消费它。
|
|
bool get cachedIsReviewing => _isReviewing ?? false;
|
|
|
|
/// 获取缓存的应用信息
|
|
AppInfo? get cachedAppInfo => _appInfo;
|
|
|
|
/// 获取缓存的游客登录状态
|
|
bool get cachedShowTourists => _isShowTourists ?? false;
|
|
|
|
/// 获取缓存的是否允许跳过设备绑定
|
|
bool get cachedAllowSkipDeviceBinding => _allowSkipDeviceBinding ?? false;
|
|
|
|
/// 获取上次检查时间
|
|
DateTime? get lastCheckTime => _lastCheckTime;
|
|
|
|
/// 获取缓存的国家地区编码
|
|
String get cachedCountryCode => _countryCode ?? '';
|
|
|
|
/// 获取缓存的语言编码
|
|
String get cachedLanguageCode => _languageCode ?? '';
|
|
|
|
/// 获取缓存的服务器地址
|
|
// String get cachedServerUrl => _SERVER_URL ?? '';
|
|
|
|
final Logger _logger = Logger();
|
|
|
|
// @override
|
|
// void onInit() {
|
|
// getCurrentVersion();
|
|
// getCurrentPlatformInfo();
|
|
// super.onInit();
|
|
// }
|
|
|
|
/// 检查版本更新。
|
|
///
|
|
/// 走**单数**接口 `user_getchannelapp`:只要本渠道这一条,且服务端把
|
|
/// `needupdate/forceupdate/reviewing` 三个结论直接算好下发(见 api_getchannelapp.go),
|
|
/// 客户端不再自己比版本号。
|
|
///
|
|
/// 为什么不是复数的 `user_getchannelapps`:
|
|
/// - 它取回全部渠道,而客户端只用得上自己这一条 —— 早先那套「环境渠道 + 品牌渠道」
|
|
/// 双取的逻辑早已失效(`_channel` 在 getCurrentPlatformInfo 里就被改写成品牌渠道,
|
|
/// 两个变量取到的是同一条),留着只是多传一堆用不到的数据。
|
|
/// - 更要命的是它**不在网关白名单里**,未登录时返回 `code:18 NoLogin`。而游客登录入口
|
|
/// 恰恰只在未登录时才需要显示,于是入口在任何未登录设备上都不可能出现,且不报错。
|
|
/// 单数接口本来就在白名单里。
|
|
/// 网页下载页:Android 上打开服务端给的地址失败时的兜底(页面会按品牌跳商店 / 直接给安装包)。
|
|
/// 与后台「版本控制」页推荐填的默认地址、服务端 comm.DefaultDistDomain 是同一个。
|
|
static const String officialDownloadPage = 'https://dl.ymaikj.com';
|
|
|
|
/// 推荐更新的「稍后提醒」:记住被推迟的版本号与时刻,同一版本 [_snoozeHours] 小时内冷启动不再弹。
|
|
/// 只作用于冷启动的自动检查;设置页手动「检查更新」不看它。强更从不受它影响。
|
|
static const String _snoozeKey = 'update_snooze';
|
|
static const int _snoozeHours = 24;
|
|
|
|
void snoozeVersion(String version) {
|
|
try {
|
|
GetStorage().write(_snoozeKey, {
|
|
'version': version,
|
|
'ts': DateTime.now().millisecondsSinceEpoch,
|
|
});
|
|
} catch (e) {
|
|
_logger.w('记录稍后提醒失败: $e');
|
|
}
|
|
}
|
|
|
|
bool isSnoozed(String version) {
|
|
try {
|
|
final raw = GetStorage().read(_snoozeKey);
|
|
if (raw is! Map) return false;
|
|
if (raw['version']?.toString() != version) return false;
|
|
final ts = raw['ts'];
|
|
if (ts is! int) return false;
|
|
return DateTime.now().millisecondsSinceEpoch - ts <
|
|
Duration(hours: _snoozeHours).inMilliseconds;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// [bypassCache] 为 true 时跳过 10 分钟缓存真的发一次请求——设置页手动「检查更新」用,
|
|
/// 否则刚发完版用户主动点检查可能拿到缓存里的「已是最新」。
|
|
Future<VersionUpdateInfo> checkForUpdate({bool bypassCache = false}) async {
|
|
if (!bypassCache &&
|
|
_lastCheckTime != null &&
|
|
DateTime.now().difference(_lastCheckTime!).inMinutes <
|
|
_cacheIntervalMinutes) {
|
|
// [1]没有超过10分钟,返回缓存数据
|
|
_logger.d('没有超过10分钟,返回缓存数据');
|
|
return VersionUpdateInfo(
|
|
hasUpdate: _hasUpdate ?? false,
|
|
currentVersion: _appVersion ?? '1.0.0',
|
|
latestVersion: (_latestVersion ?? '1.0.0').split('+')[0],
|
|
appInfo: _appInfo,
|
|
updateType: (_hasUpdate ?? false)
|
|
? ((_isForceUpdate ?? false) ? UpdateType.force : UpdateType.optional)
|
|
: UpdateType.none,
|
|
);
|
|
}
|
|
|
|
final currentVersion = await getCurrentVersion();
|
|
try {
|
|
final platformInfo = await getCurrentPlatformInfo();
|
|
_platform = platformInfo.platform;
|
|
|
|
// 必须带上本机版本号:游客登录入口是否显示、是否需要强更,都由服务端按这个版本判定,
|
|
// 不传服务端认不出是哪一版,后台限定了生效版本时会一律隐藏。
|
|
// 传净版本号(去掉 "+构建号"):后台填的是 1.0.2 这种商店版本号,两边口径必须一致。
|
|
// 直接传 "1.0.1+3" 虽然也能被服务端的 leadingInt 吃掉后缀而碰巧判对,但那是巧合不是约定。
|
|
final response = await Api.getChannelApp({
|
|
'channel': platformInfo.channel.value,
|
|
'version': currentVersion.split('+')[0],
|
|
});
|
|
|
|
_logger.d('lxm--获取渠道包信息(channel=${platformInfo.channel.name}):$response');
|
|
|
|
final AppInfo? app = _parseApp(response);
|
|
|
|
// ⚠️ 缓存时间戳只在**请求成功之后**才写。早先是在发请求之前写的,于是一次网络抖动
|
|
// 就把结果锁死 10 分钟:失败分支把 _isShowTourists 置 false,而缓存命中分支
|
|
// 根本不重算它 —— 表现为 splash 那次失败之后,登录页怎么重进游客入口都不出现。
|
|
_lastCheckTime = DateTime.now();
|
|
|
|
if (app == null) {
|
|
// 后台还没给这个渠道配任何东西。按「无更新 + 不显示游客入口」处理:
|
|
// 游客入口判宽会在送审期把不该露的入口露给审核员,判严只是入口少显示。
|
|
_logger.w('lxm--该渠道未配置,按无更新处理');
|
|
_appInfo = null;
|
|
_isShowTourists = false;
|
|
_allowSkipDeviceBinding = false;
|
|
_hasUpdate = false;
|
|
_isForceUpdate = false;
|
|
_isReviewing = false;
|
|
return VersionUpdateInfo(
|
|
hasUpdate: false,
|
|
currentVersion: currentVersion.split('+')[0],
|
|
latestVersion: currentVersion.split('+')[0],
|
|
updateType: UpdateType.none,
|
|
);
|
|
}
|
|
|
|
_appInfo = app;
|
|
_latestVersion = app.version;
|
|
_updateUrl = app.address;
|
|
|
|
// 这里的 tourists 已是服务端按本机版本算好的结论,直接用,不要再做版本比较。
|
|
_isShowTourists = app.tourists;
|
|
_allowSkipDeviceBinding = app.allowskipdevicebinding;
|
|
_logger.d('lxm--从服务器获取游客登录配置(最终): $_isShowTourists');
|
|
|
|
// 升级判定同样取服务端结论:版本号逐段比较各端各写一遍必然出现实现不一致,
|
|
// 而强更判错的代价是全员无法使用。服务端没回这几个字段时按 false 处理。
|
|
_hasUpdate = _asBool(response, 'needupdate');
|
|
_isForceUpdate = _asBool(response, 'forceupdate');
|
|
_isReviewing = _asBool(response, 'reviewing');
|
|
|
|
final UpdateType updateType = _hasUpdate!
|
|
? (_isForceUpdate! ? UpdateType.force : UpdateType.optional)
|
|
: UpdateType.none;
|
|
|
|
return VersionUpdateInfo(
|
|
hasUpdate: _hasUpdate!,
|
|
currentVersion: currentVersion.split('+')[0],
|
|
latestVersion: app.version.split('+')[0],
|
|
appInfo: app,
|
|
updateType: updateType,
|
|
);
|
|
} catch (e) {
|
|
_logger.e('检查版本更新失败: $e');
|
|
// 失败方向偏向「不显示游客入口 / 不提示更新」。注意**不写** _lastCheckTime:
|
|
// 失败不该占用 10 分钟缓存窗口,下一次调用要能真的重试。
|
|
_isShowTourists = false;
|
|
_allowSkipDeviceBinding = false;
|
|
_hasUpdate = false;
|
|
_isForceUpdate = false;
|
|
_isReviewing = false;
|
|
return VersionUpdateInfo(
|
|
hasUpdate: false,
|
|
currentVersion: currentVersion.split('+')[0],
|
|
latestVersion: currentVersion.split('+')[0],
|
|
updateType: UpdateType.none,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 从 `user_getchannelapp` 的响应里取 app 段;该渠道没配置时服务端回的是空对象。
|
|
AppInfo? _parseApp(dynamic response) {
|
|
try {
|
|
final appJson = (response is Map) ? response['app'] : null;
|
|
if (appJson is Map<String, dynamic>) return AppInfo.fromJson(appJson);
|
|
} catch (e) {
|
|
_logger.e('解析渠道包信息失败: $e');
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// 取响应里的布尔字段;缺字段或类型不对一律 false(服务端 omitempty 会吃掉 false)。
|
|
static bool _asBool(dynamic response, String key) {
|
|
if (response is! Map) return false;
|
|
final v = response[key];
|
|
if (v is bool) return v;
|
|
if (v is num) return v != 0;
|
|
if (v is String) return v == 'true' || v == '1';
|
|
return false;
|
|
}
|
|
|
|
/// 获取当前应用版本信息
|
|
Future<String> getCurrentVersion() async {
|
|
try {
|
|
final packageInfo = await PackageInfo.fromPlatform();
|
|
_appVersion = packageInfo.version;
|
|
_buildNumber = packageInfo.buildNumber;
|
|
return '${packageInfo.version}+${packageInfo.buildNumber}';
|
|
} catch (e) {
|
|
_logger.e('获取当前版本信息失败: $e');
|
|
return '1.0.0';
|
|
}
|
|
}
|
|
|
|
/// 获取当前平台信息
|
|
Future<PlatformInfo> getCurrentPlatformInfo() async {
|
|
if (Platform.isIOS) {
|
|
_channel = UserChannel.GLOBAL_APPLE;
|
|
await _getIosDeviceBrand();
|
|
return const PlatformInfo(
|
|
platform: 'ios',
|
|
storeType: AppStoreType.appStore,
|
|
channel: UserChannel.GLOBAL_APPLE,
|
|
);
|
|
}
|
|
|
|
if (Platform.isAndroid) {
|
|
// 根据设备品牌判断应用商店类型
|
|
return await getAndroidStoreInfoByBrand();
|
|
}
|
|
|
|
return const PlatformInfo(
|
|
platform: 'unknown',
|
|
storeType: AppStoreType.unknown,
|
|
channel: UserChannel.UNKNOWN,
|
|
);
|
|
}
|
|
|
|
/// 根据设备品牌获取应用商店信息
|
|
///
|
|
/// 该方法根据设备品牌判断应用商店类型,并返回对应的 [PlatformInfo] 对象。
|
|
/// 如果品牌不在预设列表中,将使用环境变量 [Channel] 或默认值 [UserChannel.GLOBAL_GOOGLE]。
|
|
Future<PlatformInfo> getAndroidStoreInfoByBrand() async {
|
|
try {
|
|
final deviceInfo = DeviceInfoPlugin();
|
|
final androidInfo = await deviceInfo.androidInfo;
|
|
final brand = androidInfo.brand.toLowerCase();
|
|
final manufacturer = androidInfo.manufacturer.toLowerCase();
|
|
|
|
_deviceBrand = brand;
|
|
// 国内 5 家商店合成一个包之后,.env 里的 Channel 只是兜底:识别不出品牌
|
|
// 的设备(三星、魅族……)才用它,其余一律按设备品牌推导渠道。
|
|
String channelFromEnv = dotenv.env['Channel'] ?? 'OFFICIAL';
|
|
Logger().d('channelFromEnv: $channelFromEnv');
|
|
final envChannel = UserChannel.fromName(channelFromEnv);
|
|
|
|
UserChannel brandChannel; //品牌渠道
|
|
|
|
// 海外包与官方包不参与品牌推导:Google 包装在华为手机上仍要连海外后端
|
|
// (baseUrl 只认 GLOBAL_GOOGLE),官方/内测包也要保住 OFFICIAL 才有的入口。
|
|
if (envChannel == UserChannel.GLOBAL_GOOGLE ||
|
|
envChannel == UserChannel.GLOBAL_APPLE ||
|
|
envChannel == UserChannel.OFFICIAL) {
|
|
brandChannel = envChannel;
|
|
} else {
|
|
switch (brand) {
|
|
case 'huawei':
|
|
brandChannel = UserChannel.CHINA_HUAWEI;
|
|
break;
|
|
case 'xiaomi':
|
|
case 'redmi':
|
|
brandChannel = UserChannel.CHINA_XIAOMI;
|
|
break;
|
|
case 'oppo':
|
|
case 'oneplus':
|
|
brandChannel = UserChannel.CHINA_OPPO;
|
|
break;
|
|
case 'vivo':
|
|
case 'iqoo':
|
|
brandChannel = UserChannel.CHINA_VIVO;
|
|
break;
|
|
case 'honor':
|
|
brandChannel = UserChannel.CHINA_HONOR;
|
|
break;
|
|
default:
|
|
// brand 认不出时再看 manufacturer(老荣耀 brand=HONOR/厂商=HUAWEI,
|
|
// 也有反过来的机型),仍认不出才回退到 .env 的兜底渠道。
|
|
if (manufacturer == 'huawei') {
|
|
brandChannel = UserChannel.CHINA_HUAWEI;
|
|
} else if (manufacturer == 'honor') {
|
|
brandChannel = UserChannel.CHINA_HONOR;
|
|
} else {
|
|
brandChannel = envChannel;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
// 渠道以品牌推导结果为准:单包多商店后,游客登录开关、渠道统计、
|
|
// 版本更新地址都走这一个值。
|
|
_channel = brandChannel;
|
|
|
|
return PlatformInfo(
|
|
platform: 'android',
|
|
storeType: AppStoreType.official,
|
|
channel: brandChannel,
|
|
);
|
|
} catch (e) {
|
|
_logger.e('获取Android设备信息失败: $e');
|
|
return const PlatformInfo(
|
|
platform: 'android',
|
|
storeType: AppStoreType.googlePlay,
|
|
channel: UserChannel.UNKNOWN,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 获取iOS设备品牌信息
|
|
Future<void> _getIosDeviceBrand() async {
|
|
try {
|
|
final deviceInfo = DeviceInfoPlugin();
|
|
final iosInfo = await deviceInfo.iosInfo;
|
|
|
|
// 获取设备型号信息
|
|
final model =
|
|
iosInfo.model.toLowerCase(); // 例如: "iPhone", "iPad", "iPod touch"
|
|
//print('lxm---model: $model');
|
|
final utsname = iosInfo.utsname; // 包含更详细的设备信息
|
|
//print('lxm---utsname: $utsname');
|
|
final machineId = utsname.machine.toLowerCase(); // 例如: "iPhone14,2"
|
|
//print('lxm---machineId: $machineId');
|
|
|
|
// 根据设备型号确定品牌
|
|
if (model.contains('iphone') || machineId.contains('iphone')) {
|
|
_deviceBrand = 'iphone';
|
|
} else if (model.contains('ipad') || machineId.contains('ipad')) {
|
|
_deviceBrand = 'ipad';
|
|
} else if (model.contains('ipod') || machineId.contains('ipod')) {
|
|
_deviceBrand = 'ipod';
|
|
} else if (model.contains('mac') || machineId.contains('mac')) {
|
|
_deviceBrand = 'mac';
|
|
} else {
|
|
_deviceBrand = 'ios'; // 默认值
|
|
}
|
|
|
|
_logger.d('iOS设备品牌: $_deviceBrand (model: $model, machine: $machineId)');
|
|
} catch (e) {
|
|
_logger.e('获取iOS设备品牌失败: $e');
|
|
_deviceBrand = 'ios'; // 默认值
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// 执行更新操作
|
|
Future<bool> performUpdate(VersionUpdateInfo updateInfo) async {
|
|
//print('lxm---updateInfo详情:');
|
|
//print('lxm---hasUpdate: ${updateInfo.hasUpdate}');
|
|
//print('lxm---currentVersion: ${updateInfo.currentVersion}');
|
|
//print('lxm---latestVersion: ${updateInfo.latestVersion}');
|
|
//print('lxm---updateType: ${updateInfo.updateType}');
|
|
//print('lxm---appInfo: ${updateInfo.appInfo?.toJson()}');
|
|
if (!updateInfo.hasUpdate || updateInfo.appInfo == null) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
final address = updateInfo.appInfo!.address ?? '';
|
|
_logger.d('lxm---执行更新操作,下载地址: $address');
|
|
if (address.isNotEmpty && await _openDownloadUrl(address)) return true;
|
|
// Android 兜底:服务端给的地址打不开(商店未装 / 链接失效)就去网页下载页,
|
|
// 它按品牌跳商店、认不出就直接给安装包。iOS 不兜底——那页面给的是 apk。
|
|
if (Platform.isAndroid) {
|
|
_logger.w('下载地址打不开,回退网页下载页: $officialDownloadPage');
|
|
return await _openDownloadUrl(officialDownloadPage);
|
|
}
|
|
return false;
|
|
} catch (e) {
|
|
_logger.e('执行更新失败: $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// 打开下载链接
|
|
Future<bool> _openDownloadUrl(String downloadUrl) async {
|
|
return await _launchUrl(downloadUrl);
|
|
}
|
|
|
|
/// 启动URL
|
|
Future<bool> _launchUrl(String url) async {
|
|
try {
|
|
final uri = Uri.parse(url);
|
|
if (await canLaunchUrl(uri)) {
|
|
return await launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
}
|
|
return false;
|
|
} catch (e) {
|
|
_logger.e('启动URL失败: $e');
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|