Initial project import
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:miaoji_zhang/shared/update/update_config.dart';
|
||||
import 'package:miaoji_zhang/shared/update/update_models.dart';
|
||||
|
||||
enum UpdateFailureKind { unavailable, rateLimited, network, invalidResponse }
|
||||
|
||||
class UpdateCheckException implements Exception {
|
||||
final UpdateFailureKind kind;
|
||||
final String message;
|
||||
|
||||
const UpdateCheckException(this.kind, this.message);
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
class UpdateApi {
|
||||
UpdateApi({Dio? dio})
|
||||
: _dio =
|
||||
dio ??
|
||||
Dio(
|
||||
BaseOptions(
|
||||
baseUrl: UpdateConfig.baseUrl,
|
||||
connectTimeout: const Duration(seconds: 8),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
sendTimeout: const Duration(seconds: 8),
|
||||
),
|
||||
);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
Future<UpdateCheckResponse> check({
|
||||
required String platform,
|
||||
required String channel,
|
||||
required int currentBuild,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.get<Object?>(
|
||||
'/api/client/v1/update',
|
||||
queryParameters: {
|
||||
'appKey': UpdateConfig.appKey,
|
||||
'platform': platform,
|
||||
'channel': channel,
|
||||
'currentBuild': currentBuild,
|
||||
},
|
||||
);
|
||||
final data = response.data;
|
||||
if (data is! Map) {
|
||||
throw const UpdateCheckException(
|
||||
UpdateFailureKind.invalidResponse,
|
||||
'更新服务返回格式异常',
|
||||
);
|
||||
}
|
||||
return UpdateCheckResponse.fromJson(Map<String, dynamic>.from(data));
|
||||
} on DioException catch (error) {
|
||||
switch (error.response?.statusCode) {
|
||||
case 404:
|
||||
throw const UpdateCheckException(
|
||||
UpdateFailureKind.unavailable,
|
||||
'当前版本尚未配置更新渠道',
|
||||
);
|
||||
case 429:
|
||||
throw const UpdateCheckException(
|
||||
UpdateFailureKind.rateLimited,
|
||||
'检查更新过于频繁,请稍后再试',
|
||||
);
|
||||
}
|
||||
throw const UpdateCheckException(
|
||||
UpdateFailureKind.network,
|
||||
'暂时无法连接更新服务,请检查网络后重试',
|
||||
);
|
||||
} on UpdateCheckException {
|
||||
rethrow;
|
||||
} on FormatException catch (error) {
|
||||
throw UpdateCheckException(
|
||||
UpdateFailureKind.invalidResponse,
|
||||
error.message.toString(),
|
||||
);
|
||||
} catch (_) {
|
||||
throw const UpdateCheckException(
|
||||
UpdateFailureKind.invalidResponse,
|
||||
'更新服务返回格式异常',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'dart:io';
|
||||
|
||||
class UpdateConfig {
|
||||
UpdateConfig._();
|
||||
|
||||
static const baseUrl = String.fromEnvironment(
|
||||
'UPDATE_BASE_URL',
|
||||
defaultValue: 'https://version.nxsir.cn',
|
||||
);
|
||||
static const appKey = String.fromEnvironment(
|
||||
'UPDATE_APP_KEY',
|
||||
defaultValue: 'MvSyHJ7d8mlLbylIub04epC7g5AsCSqB',
|
||||
);
|
||||
static const internalBuild = bool.fromEnvironment('INTERNAL_BUILD');
|
||||
|
||||
static String? get platform {
|
||||
if (Platform.isAndroid) return 'android';
|
||||
if (Platform.isIOS) return 'ios';
|
||||
return null;
|
||||
}
|
||||
|
||||
static String get channel => internalBuild ? 'beta' : 'stable';
|
||||
|
||||
static bool get supportsUpdates => platform != null;
|
||||
|
||||
static bool get canInstallInApp => Platform.isAndroid && internalBuild;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:miaoji_zhang/shared/update/update_api.dart';
|
||||
import 'package:miaoji_zhang/shared/update/update_config.dart';
|
||||
import 'package:miaoji_zhang/shared/update/update_downloader.dart';
|
||||
import 'package:miaoji_zhang/shared/update/update_models.dart';
|
||||
import 'package:miaoji_zhang/shared/update/update_prompt.dart';
|
||||
import 'package:miaoji_zhang/shared/version.dart';
|
||||
|
||||
class UpdateCoordinator extends ChangeNotifier {
|
||||
UpdateCoordinator({UpdateApi? api}) : _api = api ?? UpdateApi();
|
||||
|
||||
static final instance = UpdateCoordinator();
|
||||
|
||||
final UpdateApi _api;
|
||||
SharedPreferences? _preferences;
|
||||
GlobalKey<NavigatorState>? _navigatorKey;
|
||||
Future<UpdateCheckResponse>? _inFlight;
|
||||
bool _startupScheduled = false;
|
||||
bool _showingPrompt = false;
|
||||
bool _checking = false;
|
||||
AppRelease? _forcedRelease;
|
||||
|
||||
bool get checking => _checking;
|
||||
|
||||
Future<void> initialize() async {
|
||||
_preferences ??= await SharedPreferences.getInstance();
|
||||
await UpdateDownloader().cleanupStale();
|
||||
}
|
||||
|
||||
void bindNavigator(GlobalKey<NavigatorState> navigatorKey) {
|
||||
_navigatorKey = navigatorKey;
|
||||
}
|
||||
|
||||
void scheduleStartupCheck() {
|
||||
if (_startupScheduled || !UpdateConfig.supportsUpdates) return;
|
||||
_startupScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
Future<void>.delayed(
|
||||
const Duration(milliseconds: 350),
|
||||
_checkAutomatically,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> checkManually(BuildContext context) async {
|
||||
if (!UpdateConfig.supportsUpdates) {
|
||||
_message(context, '当前平台暂不支持应用内更新检查');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final response = await _check();
|
||||
final release = _availableRelease(response);
|
||||
if (release == null) {
|
||||
if (context.mounted) _message(context, '已是最新版');
|
||||
return;
|
||||
}
|
||||
if (response.forceUpdate) _forcedRelease = release;
|
||||
if (context.mounted) {
|
||||
await _present(context, release, response.forceUpdate);
|
||||
}
|
||||
} on UpdateCheckException catch (error) {
|
||||
if (context.mounted) _message(context, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkAutomatically() async {
|
||||
try {
|
||||
final response = await _check();
|
||||
final release = _availableRelease(response);
|
||||
if (release == null) return;
|
||||
final ignoredId = await _ignoredReleaseId();
|
||||
if (!UpdatePolicy.shouldPresent(
|
||||
release: release,
|
||||
forced: response.forceUpdate,
|
||||
manual: false,
|
||||
ignoredReleaseId: ignoredId,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
if (response.forceUpdate) _forcedRelease = release;
|
||||
final context = _navigatorKey?.currentContext;
|
||||
if (context != null && context.mounted) {
|
||||
await _present(context, release, response.forceUpdate);
|
||||
}
|
||||
} catch (_) {
|
||||
// Automatic checks never block startup when the update service is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<UpdateCheckResponse> debugCheck({required String platform}) =>
|
||||
_check(platformOverride: platform);
|
||||
|
||||
Future<UpdateCheckResponse> _check({String? platformOverride}) {
|
||||
final existing = _inFlight;
|
||||
if (existing != null) return existing;
|
||||
_checking = true;
|
||||
notifyListeners();
|
||||
final future = _api
|
||||
.check(
|
||||
platform: platformOverride ?? UpdateConfig.platform!,
|
||||
channel: UpdateConfig.channel,
|
||||
currentBuild: AppVersion.buildNumber,
|
||||
)
|
||||
.whenComplete(() {
|
||||
_inFlight = null;
|
||||
_checking = false;
|
||||
notifyListeners();
|
||||
});
|
||||
_inFlight = future;
|
||||
return future;
|
||||
}
|
||||
|
||||
AppRelease? _availableRelease(UpdateCheckResponse response) =>
|
||||
UpdatePolicy.availableRelease(
|
||||
response,
|
||||
currentBuild: AppVersion.buildNumber,
|
||||
);
|
||||
|
||||
Future<String?> _ignoredReleaseId() async {
|
||||
final preferences = _preferences ??= await SharedPreferences.getInstance();
|
||||
return preferences.getString(_ignoredKey);
|
||||
}
|
||||
|
||||
Future<void> _ignore(AppRelease release) async {
|
||||
final preferences = _preferences ??= await SharedPreferences.getInstance();
|
||||
await preferences.setString(_ignoredKey, release.id);
|
||||
}
|
||||
|
||||
String get _ignoredKey =>
|
||||
'ignored_update_${UpdateConfig.platform}_${UpdateConfig.channel}';
|
||||
|
||||
Future<void> _present(
|
||||
BuildContext context,
|
||||
AppRelease release,
|
||||
bool forced,
|
||||
) async {
|
||||
if (_showingPrompt) return;
|
||||
_showingPrompt = true;
|
||||
try {
|
||||
await showUpdatePrompt(
|
||||
context,
|
||||
release: release,
|
||||
forced: forced,
|
||||
onIgnore: () => _ignore(release),
|
||||
);
|
||||
} finally {
|
||||
_showingPrompt = false;
|
||||
if (forced && _forcedRelease != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final retryContext = _navigatorKey?.currentContext;
|
||||
if (retryContext != null && retryContext.mounted) {
|
||||
unawaited(_present(retryContext, _forcedRelease!, true));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _message(BuildContext context, String message) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:miaoji_zhang/shared/update/update_installer.dart';
|
||||
import 'package:miaoji_zhang/shared/update/update_models.dart';
|
||||
|
||||
class UpdateDownloadProgress {
|
||||
final int received;
|
||||
final int total;
|
||||
final double bytesPerSecond;
|
||||
|
||||
const UpdateDownloadProgress({
|
||||
required this.received,
|
||||
required this.total,
|
||||
required this.bytesPerSecond,
|
||||
});
|
||||
|
||||
double? get fraction => total > 0 ? (received / total).clamp(0, 1) : null;
|
||||
}
|
||||
|
||||
class UpdateDownloadException implements Exception {
|
||||
final String message;
|
||||
const UpdateDownloadException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
class UpdateDownloader {
|
||||
UpdateDownloader({Dio? dio})
|
||||
: _dio =
|
||||
dio ??
|
||||
Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(minutes: 10),
|
||||
followRedirects: true,
|
||||
maxRedirects: 5,
|
||||
),
|
||||
);
|
||||
|
||||
final Dio _dio;
|
||||
CancelToken? _cancelToken;
|
||||
|
||||
bool get isDownloading => _cancelToken != null;
|
||||
|
||||
void cancel() => _cancelToken?.cancel('用户取消下载');
|
||||
|
||||
Future<void> cleanupStale() async {
|
||||
if (!Platform.isAndroid) return;
|
||||
final directory = await _updateDirectory();
|
||||
if (!await directory.exists()) return;
|
||||
final cutoff = DateTime.now().subtract(const Duration(hours: 24));
|
||||
await for (final entity in directory.list()) {
|
||||
if (entity is! File) continue;
|
||||
final stat = await entity.stat();
|
||||
if (entity.path.endsWith('.part') || stat.modified.isBefore(cutoff)) {
|
||||
await _delete(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> downloadAndVerify(
|
||||
AppRelease release, {
|
||||
required void Function(UpdateDownloadProgress progress) onProgress,
|
||||
}) async {
|
||||
if (_cancelToken != null) {
|
||||
throw const UpdateDownloadException('更新包正在下载中');
|
||||
}
|
||||
final uri = Uri.tryParse(release.downloadUrl);
|
||||
if (uri == null || uri.scheme != 'https' || uri.host.isEmpty) {
|
||||
throw const UpdateDownloadException('更新包地址无效,必须使用 HTTPS');
|
||||
}
|
||||
final expectedSha = release.sha256?.trim().toLowerCase();
|
||||
if (expectedSha == null ||
|
||||
!RegExp(r'^[0-9a-f]{64}$').hasMatch(expectedSha)) {
|
||||
throw const UpdateDownloadException('发布记录缺少有效的 SHA-256,已禁止安装');
|
||||
}
|
||||
|
||||
final directory = await _updateDirectory();
|
||||
await directory.create(recursive: true);
|
||||
final partial = File(
|
||||
'${directory.path}${Platform.pathSeparator}jizhi-${release.buildNumber}.apk.part',
|
||||
);
|
||||
final target = File(
|
||||
'${directory.path}${Platform.pathSeparator}jizhi-${release.buildNumber}.apk',
|
||||
);
|
||||
await _delete(partial);
|
||||
await _delete(target);
|
||||
|
||||
final token = CancelToken();
|
||||
_cancelToken = token;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
var lastBytes = 0;
|
||||
var lastElapsed = Duration.zero;
|
||||
var keepTarget = false;
|
||||
try {
|
||||
await _dio.download(
|
||||
release.downloadUrl,
|
||||
partial.path,
|
||||
cancelToken: token,
|
||||
deleteOnError: true,
|
||||
onReceiveProgress: (received, total) {
|
||||
final elapsed = stopwatch.elapsed;
|
||||
final deltaMicros = (elapsed - lastElapsed).inMicroseconds;
|
||||
final speed = deltaMicros <= 0
|
||||
? 0.0
|
||||
: (received - lastBytes) *
|
||||
Duration.microsecondsPerSecond /
|
||||
deltaMicros;
|
||||
if (elapsed - lastElapsed >= const Duration(milliseconds: 250) ||
|
||||
received == total) {
|
||||
lastBytes = received;
|
||||
lastElapsed = elapsed;
|
||||
onProgress(
|
||||
UpdateDownloadProgress(
|
||||
received: received,
|
||||
total: total > 0 ? total : release.fileSize ?? 0,
|
||||
bytesPerSecond: speed,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
final actualSize = await partial.length();
|
||||
if (release.fileSize case final expectedSize?
|
||||
when expectedSize > 0 && actualSize != expectedSize) {
|
||||
throw UpdateDownloadException(
|
||||
'更新包大小不一致(预期 $expectedSize 字节,实际 $actualSize 字节)',
|
||||
);
|
||||
}
|
||||
final actualSha = (await sha256.bind(partial.openRead()).first)
|
||||
.toString();
|
||||
if (actualSha.toLowerCase() != expectedSha) {
|
||||
throw const UpdateDownloadException('更新包完整性校验失败,文件已删除');
|
||||
}
|
||||
await partial.rename(target.path);
|
||||
final inspection = await UpdateInstaller.inspect(
|
||||
path: target.path,
|
||||
expectedBuild: release.buildNumber,
|
||||
);
|
||||
if (!inspection.valid) {
|
||||
throw UpdateDownloadException(inspection.message ?? '更新包身份校验失败,文件已删除');
|
||||
}
|
||||
keepTarget = true;
|
||||
return target.path;
|
||||
} on DioException catch (error) {
|
||||
if (CancelToken.isCancel(error)) {
|
||||
throw const UpdateDownloadException('下载已取消');
|
||||
}
|
||||
throw const UpdateDownloadException('更新包下载失败,请检查网络后重试');
|
||||
} on UpdateDownloadException {
|
||||
rethrow;
|
||||
} catch (_) {
|
||||
throw const UpdateDownloadException('更新包处理失败,请重新下载');
|
||||
} finally {
|
||||
_cancelToken = null;
|
||||
stopwatch.stop();
|
||||
if (!keepTarget) {
|
||||
await _delete(partial);
|
||||
await _delete(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteFile(String path) => _delete(File(path));
|
||||
|
||||
Future<void> scheduleCleanup(String path) async {
|
||||
unawaited(
|
||||
Future<void>.delayed(const Duration(minutes: 10), () async {
|
||||
await _delete(File(path));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Directory> _updateDirectory() async {
|
||||
final temporary = await getTemporaryDirectory();
|
||||
return Directory('${temporary.path}${Platform.pathSeparator}updates');
|
||||
}
|
||||
|
||||
Future<void> _delete(File file) async {
|
||||
try {
|
||||
if (await file.exists()) await file.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
enum ApkInstallStatus { launched, permissionRequested, unsupported }
|
||||
|
||||
class ApkInspection {
|
||||
final bool valid;
|
||||
final String? message;
|
||||
|
||||
const ApkInspection({required this.valid, this.message});
|
||||
}
|
||||
|
||||
class UpdateInstaller {
|
||||
UpdateInstaller._();
|
||||
|
||||
static const _channel = MethodChannel('com.nx.miaoji/update');
|
||||
|
||||
static Future<ApkInspection> inspect({
|
||||
required String path,
|
||||
required int expectedBuild,
|
||||
}) async {
|
||||
final result = await _channel.invokeMapMethod<String, dynamic>(
|
||||
'inspectApk',
|
||||
{'path': path, 'expectedBuild': expectedBuild},
|
||||
);
|
||||
return ApkInspection(
|
||||
valid: result?['valid'] == true,
|
||||
message: result?['message']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<ApkInstallStatus> install({
|
||||
required String path,
|
||||
required int expectedBuild,
|
||||
}) async {
|
||||
final result = await _channel.invokeMapMethod<String, dynamic>(
|
||||
'installApk',
|
||||
{'path': path, 'expectedBuild': expectedBuild},
|
||||
);
|
||||
return switch (result?['status']) {
|
||||
'launched' => ApkInstallStatus.launched,
|
||||
'permission_requested' => ApkInstallStatus.permissionRequested,
|
||||
_ => ApkInstallStatus.unsupported,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
class AppRelease {
|
||||
final String id;
|
||||
final String versionName;
|
||||
final int buildNumber;
|
||||
final String downloadUrl;
|
||||
final String releaseNotes;
|
||||
final String? sha256;
|
||||
final int? fileSize;
|
||||
final DateTime? publishedAt;
|
||||
|
||||
const AppRelease({
|
||||
required this.id,
|
||||
required this.versionName,
|
||||
required this.buildNumber,
|
||||
required this.downloadUrl,
|
||||
required this.releaseNotes,
|
||||
required this.sha256,
|
||||
required this.fileSize,
|
||||
required this.publishedAt,
|
||||
});
|
||||
|
||||
factory AppRelease.fromJson(Map<String, dynamic> json) {
|
||||
final id = json['id']?.toString().trim() ?? '';
|
||||
final versionName = json['versionName']?.toString().trim() ?? '';
|
||||
final buildNumber = (json['buildNumber'] as num?)?.toInt() ?? -1;
|
||||
final downloadUrl = json['downloadUrl']?.toString().trim() ?? '';
|
||||
if (id.isEmpty ||
|
||||
versionName.isEmpty ||
|
||||
buildNumber < 0 ||
|
||||
downloadUrl.isEmpty) {
|
||||
throw const FormatException('更新服务返回了不完整的版本信息');
|
||||
}
|
||||
return AppRelease(
|
||||
id: id,
|
||||
versionName: versionName,
|
||||
buildNumber: buildNumber,
|
||||
downloadUrl: downloadUrl,
|
||||
releaseNotes: json['releaseNotes']?.toString().trim() ?? '',
|
||||
sha256: json['sha256']?.toString().trim().nullIfEmpty,
|
||||
fileSize: (json['fileSize'] as num?)?.toInt(),
|
||||
publishedAt: DateTime.tryParse(json['publishedAt']?.toString() ?? ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UpdateCheckResponse {
|
||||
final bool hasUpdate;
|
||||
final bool forceUpdate;
|
||||
final AppRelease? release;
|
||||
|
||||
const UpdateCheckResponse({
|
||||
required this.hasUpdate,
|
||||
required this.forceUpdate,
|
||||
required this.release,
|
||||
});
|
||||
|
||||
factory UpdateCheckResponse.fromJson(Map<String, dynamic> json) {
|
||||
final releaseJson = json['release'];
|
||||
final release = releaseJson is Map
|
||||
? AppRelease.fromJson(Map<String, dynamic>.from(releaseJson))
|
||||
: null;
|
||||
final hasUpdate = json['hasUpdate'] == true;
|
||||
if (hasUpdate && release == null) {
|
||||
throw const FormatException('更新服务未返回目标版本');
|
||||
}
|
||||
return UpdateCheckResponse(
|
||||
hasUpdate: hasUpdate,
|
||||
forceUpdate: json['forceUpdate'] == true,
|
||||
release: release,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension on String {
|
||||
String? get nullIfEmpty => isEmpty ? null : this;
|
||||
}
|
||||
|
||||
class UpdatePolicy {
|
||||
UpdatePolicy._();
|
||||
|
||||
static AppRelease? availableRelease(
|
||||
UpdateCheckResponse response, {
|
||||
required int currentBuild,
|
||||
}) {
|
||||
final release = response.release;
|
||||
if (!response.hasUpdate || release == null) return null;
|
||||
return release.buildNumber > currentBuild ? release : null;
|
||||
}
|
||||
|
||||
static bool shouldPresent({
|
||||
required AppRelease release,
|
||||
required bool forced,
|
||||
required bool manual,
|
||||
required String? ignoredReleaseId,
|
||||
}) {
|
||||
if (forced || manual) return true;
|
||||
return ignoredReleaseId != release.id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
|
||||
import 'package:miaoji_zhang/shared/update/update_config.dart';
|
||||
import 'package:miaoji_zhang/shared/update/update_downloader.dart';
|
||||
import 'package:miaoji_zhang/shared/update/update_installer.dart';
|
||||
import 'package:miaoji_zhang/shared/update/update_models.dart';
|
||||
import 'package:miaoji_zhang/shared/version.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
|
||||
|
||||
Future<void> showUpdatePrompt(
|
||||
BuildContext context, {
|
||||
required AppRelease release,
|
||||
required bool forced,
|
||||
required Future<void> Function() onIgnore,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
isDismissible: false,
|
||||
enableDrag: false,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) =>
|
||||
UpdatePromptSheet(release: release, forced: forced, onIgnore: onIgnore),
|
||||
);
|
||||
}
|
||||
|
||||
class UpdatePromptSheet extends StatefulWidget {
|
||||
final AppRelease release;
|
||||
final bool forced;
|
||||
final Future<void> Function() onIgnore;
|
||||
|
||||
const UpdatePromptSheet({
|
||||
super.key,
|
||||
required this.release,
|
||||
required this.forced,
|
||||
required this.onIgnore,
|
||||
});
|
||||
|
||||
@override
|
||||
State<UpdatePromptSheet> createState() => _UpdatePromptSheetState();
|
||||
}
|
||||
|
||||
class _UpdatePromptSheetState extends State<UpdatePromptSheet> {
|
||||
final _downloader = UpdateDownloader();
|
||||
UpdateDownloadProgress? _progress;
|
||||
bool _working = false;
|
||||
String? _status;
|
||||
String? _error;
|
||||
String? _downloadedPath;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_downloader.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _ignore() async {
|
||||
if (_working) return;
|
||||
await widget.onIgnore();
|
||||
if (mounted) Navigator.pop(context);
|
||||
}
|
||||
|
||||
Future<void> _update() async {
|
||||
if (_working) return;
|
||||
final uri = Uri.tryParse(widget.release.downloadUrl);
|
||||
if (uri == null || uri.scheme != 'https' || uri.host.isEmpty) {
|
||||
setState(() => _error = '更新地址无效,必须使用 HTTPS');
|
||||
return;
|
||||
}
|
||||
if (!UpdateConfig.canInstallInApp) {
|
||||
setState(() {
|
||||
_working = true;
|
||||
_error = null;
|
||||
_status = '正在打开更新页面…';
|
||||
});
|
||||
try {
|
||||
final opened = await launchUrl(
|
||||
uri,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
if (!opened) throw StateError('无法打开更新页面');
|
||||
if (!widget.forced && mounted) Navigator.pop(context);
|
||||
if (mounted && widget.forced) {
|
||||
setState(() => _status = '更新页面已打开,完成更新后重新打开记之');
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _error = '无法打开更新页面,请稍后重试');
|
||||
} finally {
|
||||
if (mounted) setState(() => _working = false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_working = true;
|
||||
_error = null;
|
||||
_status = '正在下载更新包…';
|
||||
_progress = null;
|
||||
});
|
||||
String? path;
|
||||
try {
|
||||
final cachedPath = _downloadedPath;
|
||||
if (cachedPath != null && await File(cachedPath).exists()) {
|
||||
path = cachedPath;
|
||||
} else {
|
||||
_downloadedPath = null;
|
||||
path = await _downloader.downloadAndVerify(
|
||||
widget.release,
|
||||
onProgress: (progress) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_progress = progress;
|
||||
_status = '正在下载更新包…';
|
||||
});
|
||||
},
|
||||
);
|
||||
_downloadedPath = path;
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_status = '校验完成,正在打开安装界面…';
|
||||
_progress = null;
|
||||
});
|
||||
final status = await UpdateInstaller.install(
|
||||
path: path,
|
||||
expectedBuild: widget.release.buildNumber,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_status = switch (status) {
|
||||
ApkInstallStatus.launched => '安装界面已打开,完成后重新启动记之',
|
||||
ApkInstallStatus.permissionRequested => '请允许安装未知应用,返回后将继续安装',
|
||||
ApkInstallStatus.unsupported => '当前设备无法调起安装,请重新下载',
|
||||
};
|
||||
if (status == ApkInstallStatus.unsupported) {
|
||||
_error = _status;
|
||||
}
|
||||
});
|
||||
if (status == ApkInstallStatus.launched) {
|
||||
await _downloader.scheduleCleanup(path);
|
||||
} else if (status == ApkInstallStatus.unsupported) {
|
||||
await _downloader.deleteFile(path);
|
||||
_downloadedPath = null;
|
||||
}
|
||||
} on UpdateDownloadException catch (error) {
|
||||
if (mounted) setState(() => _error = error.message);
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _error = '更新失败,请重新尝试');
|
||||
} finally {
|
||||
if (mounted) setState(() => _working = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _cancelDownload() {
|
||||
_downloader.cancel();
|
||||
setState(() => _status = '正在取消下载…');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final release = widget.release;
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
child: SafeArea(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.sizeOf(context).height * 0.84,
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 18),
|
||||
decoration: BoxDecoration(
|
||||
color: context.jz.card,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
JzSheetHeader(
|
||||
title: widget.forced ? '必须更新后继续使用' : '发现新版本',
|
||||
subtitle:
|
||||
'v${release.versionName} (${release.buildNumber}) · 当前 ${AppVersion.display}',
|
||||
),
|
||||
if (widget.forced)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(top: 12),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.jz.warningBackground,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppTheme.orange.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'此版本已停止支持,更新完成前暂时不能进入 App。',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: context.jz.text2,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Flexible(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: context.jz.background,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: context.jz.line),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: MarkdownBody(
|
||||
data: release.releaseNotes.isEmpty
|
||||
? '本次更新包含体验优化与问题修复。'
|
||||
: release.releaseNotes,
|
||||
selectable: true,
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
p: TextStyle(
|
||||
fontSize: 13,
|
||||
color: context.jz.text2,
|
||||
height: 1.65,
|
||||
),
|
||||
h1: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: context.jz.text,
|
||||
),
|
||||
h2: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: context.jz.text,
|
||||
),
|
||||
listBullet: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppTheme.primaryDeep,
|
||||
),
|
||||
code: TextStyle(
|
||||
fontSize: 12,
|
||||
color: context.jz.text,
|
||||
backgroundColor: context.jz.card,
|
||||
),
|
||||
blockquoteDecoration: BoxDecoration(
|
||||
color: context.jz.card,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: const Border(
|
||||
left: BorderSide(color: AppTheme.ai, width: 3),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_progress != null || _status != null || _error != null) ...[
|
||||
const SizedBox(height: 14),
|
||||
if (_progress != null) _UpdateProgressBar(progress: _progress!),
|
||||
if (_progress != null) const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
_error ?? _status ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: _error == null ? context.jz.text2 : AppTheme.red,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 18),
|
||||
if (_working && _downloader.isDownloading && !widget.forced)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: JzActionButton(
|
||||
label: '取消下载',
|
||||
secondary: true,
|
||||
onPressed: _cancelDownload,
|
||||
),
|
||||
)
|
||||
else
|
||||
Row(
|
||||
children: [
|
||||
if (!widget.forced) ...[
|
||||
Expanded(
|
||||
child: JzActionButton(
|
||||
label: '忽略此版本',
|
||||
secondary: true,
|
||||
onPressed: _working ? null : _ignore,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
Expanded(
|
||||
child: JzActionButton(
|
||||
label: UpdateConfig.canInstallInApp
|
||||
? (_downloadedPath == null ? '下载并安装' : '继续安装')
|
||||
: '立即更新',
|
||||
loading: _working,
|
||||
onPressed: _working ? null : _update,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UpdateProgressBar extends StatelessWidget {
|
||||
final UpdateDownloadProgress progress;
|
||||
const _UpdateProgressBar({required this.progress});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fraction = progress.fraction ?? 0;
|
||||
return Column(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: SizedBox(
|
||||
height: 7,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ColoredBox(color: context.jz.line),
|
||||
FractionallySizedBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
widthFactor: fraction,
|
||||
child: const ColoredBox(color: AppTheme.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
progress.total > 0
|
||||
? '${(fraction * 100).toStringAsFixed(0)}%'
|
||||
: _formatBytes(progress.received),
|
||||
style: TextStyle(fontSize: 10.5, color: context.jz.text3),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${_formatBytes(progress.bytesPerSecond.round())}/s',
|
||||
style: TextStyle(fontSize: 10.5, color: context.jz.text3),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatBytes(int bytes) {
|
||||
if (bytes >= 1024 * 1024) {
|
||||
return '${(bytes / 1024 / 1024).toStringAsFixed(1)} MB';
|
||||
}
|
||||
if (bytes >= 1024) {
|
||||
return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
}
|
||||
return '$bytes B';
|
||||
}
|
||||
Reference in New Issue
Block a user