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