Optimize automatic updates
This commit is contained in:
@@ -39,4 +39,8 @@ class StorageKeys {
|
|||||||
// 公告
|
// 公告
|
||||||
static const String siteAnnouncementDismissedFingerprint =
|
static const String siteAnnouncementDismissedFingerprint =
|
||||||
'site_announcement_dismissed_fingerprint';
|
'site_announcement_dismissed_fingerprint';
|
||||||
|
|
||||||
|
// 版本更新
|
||||||
|
static const String updatePromptSkipUntil = 'update_prompt_skip_until';
|
||||||
|
static const String updatePromptSkipVersion = 'update_prompt_skip_version';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:url_launcher/url_launcher.dart';
|
|||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import '../../../data/models/user_model.dart';
|
import '../../../data/models/user_model.dart';
|
||||||
import '../../../data/models/user_setting_model.dart';
|
import '../../../data/models/user_setting_model.dart';
|
||||||
|
import '../../../services/update_service.dart';
|
||||||
import '../../../services/user_setting_service.dart';
|
import '../../../services/user_setting_service.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
import '../../providers/user_setting_provider.dart';
|
import '../../providers/user_setting_provider.dart';
|
||||||
@@ -154,11 +155,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
title: const Text('应用名称'),
|
title: const Text('应用名称'),
|
||||||
subtitle: const Text('公云存储'),
|
subtitle: const Text('公云存储'),
|
||||||
),
|
),
|
||||||
ListTile(
|
_buildVersionTile(),
|
||||||
leading: const Icon(Icons.tag),
|
|
||||||
title: const Text('版本号'),
|
|
||||||
subtitle: Text(_appVersion.isEmpty ? '加载中...' : _appVersion),
|
|
||||||
),
|
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.copyright),
|
leading: const Icon(Icons.copyright),
|
||||||
title: const Text('License'),
|
title: const Text('License'),
|
||||||
@@ -404,6 +401,22 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildVersionTile() {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: UpdateService.instance,
|
||||||
|
builder: (context, _) {
|
||||||
|
return ListTile(
|
||||||
|
leading: const Icon(Icons.tag),
|
||||||
|
title: const Text('版本号'),
|
||||||
|
subtitle: Text(_appVersion.isEmpty ? '加载中...' : _appVersion),
|
||||||
|
trailing: UpdateService.instance.hasUpdate
|
||||||
|
? const _BlinkingUpdateDot()
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildLogoutButton(BuildContext context, AuthProvider auth) {
|
Widget _buildLogoutButton(BuildContext context, AuthProvider auth) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
@@ -628,6 +641,53 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _BlinkingUpdateDot extends StatefulWidget {
|
||||||
|
const _BlinkingUpdateDot();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_BlinkingUpdateDot> createState() => _BlinkingUpdateDotState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BlinkingUpdateDotState extends State<_BlinkingUpdateDot>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _controller;
|
||||||
|
late final Animation<double> _opacity;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 900),
|
||||||
|
)..repeat(reverse: true);
|
||||||
|
_opacity = Tween<double>(
|
||||||
|
begin: 0.3,
|
||||||
|
end: 1,
|
||||||
|
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: _opacity,
|
||||||
|
child: Container(
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.red,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _NetworkTestConsentText extends StatefulWidget {
|
class _NetworkTestConsentText extends StatefulWidget {
|
||||||
final VoidCallback onOpenPrivacy;
|
final VoidCallback onOpenPrivacy;
|
||||||
final VoidCallback onOpenTerms;
|
final VoidCallback onOpenTerms;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../data/models/app_update_model.dart';
|
import '../../data/models/app_update_model.dart';
|
||||||
@@ -31,6 +29,8 @@ class _UpdatePromptState extends State<UpdatePrompt> {
|
|||||||
|
|
||||||
final update = await UpdateService.instance.checkForUpdate();
|
final update = await UpdateService.instance.checkForUpdate();
|
||||||
if (update == null || !mounted) return;
|
if (update == null || !mounted) return;
|
||||||
|
final shouldShow = await UpdateService.instance.shouldShowPrompt(update);
|
||||||
|
if (!shouldShow || !mounted) return;
|
||||||
|
|
||||||
_dialogVisible = true;
|
_dialogVisible = true;
|
||||||
try {
|
try {
|
||||||
@@ -41,98 +41,55 @@ class _UpdatePromptState extends State<UpdatePrompt> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showUpdateDialog(AppUpdateInfo update) async {
|
Future<void> _showUpdateDialog(AppUpdateInfo update) async {
|
||||||
var downloading = false;
|
|
||||||
var progress = 0.0;
|
|
||||||
String? status;
|
|
||||||
|
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
return StatefulBuilder(
|
Future<void> openDownloadsPage() async {
|
||||||
builder: (context, setDialogState) {
|
try {
|
||||||
final canDownload =
|
await UpdateService.instance.openDownloadsPage();
|
||||||
update.downloadUrl != null &&
|
if (dialogContext.mounted) Navigator.of(dialogContext).pop();
|
||||||
(Platform.isAndroid || Platform.isWindows);
|
} catch (e) {
|
||||||
|
ToastHelper.failure('打开下载页面失败: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> startUpdate() async {
|
Future<void> skipPrompt() async {
|
||||||
if (downloading || !canDownload) return;
|
await UpdateService.instance.skipPromptForFiveDays(update);
|
||||||
|
if (dialogContext.mounted) Navigator.of(dialogContext).pop();
|
||||||
|
}
|
||||||
|
|
||||||
setDialogState(() {
|
final releaseNotes = (update.description ?? '').trim();
|
||||||
downloading = true;
|
|
||||||
progress = 0;
|
|
||||||
status = '正在下载更新...';
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
return AlertDialog(
|
||||||
final file = await UpdateService.instance.downloadUpdate(
|
title: Text('发现新版本 ${update.version}'),
|
||||||
update,
|
content: ConstrainedBox(
|
||||||
onProgress: (received, total) {
|
constraints: const BoxConstraints(maxWidth: 420, maxHeight: 360),
|
||||||
if (!mounted || total <= 0) return;
|
child: SingleChildScrollView(
|
||||||
setDialogState(() {
|
child: Column(
|
||||||
progress = received / total;
|
mainAxisSize: MainAxisSize.min,
|
||||||
});
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
},
|
children: [
|
||||||
);
|
Text('新版本号:${update.version}'),
|
||||||
if (file == null) return;
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
setDialogState(() {
|
'版本日志',
|
||||||
progress = 1;
|
style: Theme.of(dialogContext).textTheme.titleSmall,
|
||||||
status = '下载完成,正在打开安装包...';
|
),
|
||||||
});
|
const SizedBox(height: 6),
|
||||||
await UpdateService.instance.openInstaller(file);
|
Text(releaseNotes.isEmpty ? update.title : releaseNotes),
|
||||||
if (dialogContext.mounted) Navigator.of(dialogContext).pop();
|
],
|
||||||
} catch (e) {
|
|
||||||
setDialogState(() {
|
|
||||||
downloading = false;
|
|
||||||
status = '下载或打开安装包失败';
|
|
||||||
});
|
|
||||||
ToastHelper.failure('更新失败: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return AlertDialog(
|
|
||||||
title: Text('发现新版本 ${update.version}'),
|
|
||||||
content: ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 420),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(update.title),
|
|
||||||
if ((update.description ?? '').isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
|
||||||
update.description!,
|
|
||||||
maxLines: 6,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
if (downloading) ...[
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
LinearProgressIndicator(
|
|
||||||
value: progress == 0 ? null : progress,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(status ?? ''),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
actions: [
|
),
|
||||||
TextButton(
|
),
|
||||||
onPressed: downloading
|
actions: [
|
||||||
? null
|
TextButton(onPressed: skipPrompt, child: const Text('跳过')),
|
||||||
: () => Navigator.of(dialogContext).pop(),
|
TextButton(
|
||||||
child: const Text('稍后'),
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||||
),
|
child: const Text('取消'),
|
||||||
FilledButton(
|
),
|
||||||
onPressed: downloading ? null : startUpdate,
|
FilledButton(onPressed: openDownloadsPage, child: const Text('更新')),
|
||||||
child: const Text('立即更新'),
|
],
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import 'package:path_provider/path_provider.dart';
|
|||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:xml/xml.dart';
|
import 'package:xml/xml.dart';
|
||||||
|
|
||||||
|
import '../core/constants/storage_keys.dart';
|
||||||
import '../core/utils/app_logger.dart';
|
import '../core/utils/app_logger.dart';
|
||||||
import '../data/models/app_update_model.dart';
|
import '../data/models/app_update_model.dart';
|
||||||
|
import 'storage_service.dart';
|
||||||
|
|
||||||
class UpdateService {
|
class UpdateService extends ChangeNotifier {
|
||||||
UpdateService._();
|
UpdateService._();
|
||||||
|
|
||||||
static final UpdateService instance = UpdateService._();
|
static final UpdateService instance = UpdateService._();
|
||||||
@@ -23,6 +25,9 @@ class UpdateService {
|
|||||||
static final Uri releasesPageUrl = Uri.parse(
|
static final Uri releasesPageUrl = Uri.parse(
|
||||||
'https://git.saont.net/gongyun/app/releases',
|
'https://git.saont.net/gongyun/app/releases',
|
||||||
);
|
);
|
||||||
|
static final Uri downloadsPageUrl = Uri.parse(
|
||||||
|
'https://www.gongyun.org/downloads.html',
|
||||||
|
);
|
||||||
|
|
||||||
final Dio _dio = Dio(
|
final Dio _dio = Dio(
|
||||||
BaseOptions(
|
BaseOptions(
|
||||||
@@ -34,17 +39,24 @@ class UpdateService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
bool _checking = false;
|
bool _checking = false;
|
||||||
|
AppUpdateInfo? _availableUpdate;
|
||||||
|
|
||||||
|
AppUpdateInfo? get availableUpdate => _availableUpdate;
|
||||||
|
bool get hasUpdate => _availableUpdate != null;
|
||||||
|
|
||||||
Future<AppUpdateInfo?> checkForUpdate() async {
|
Future<AppUpdateInfo?> checkForUpdate() async {
|
||||||
if (_checking) return null;
|
if (_checking) return null;
|
||||||
if (!_supportsAutoDownload) return null;
|
|
||||||
|
|
||||||
_checking = true;
|
_checking = true;
|
||||||
try {
|
try {
|
||||||
final current = await PackageInfo.fromPlatform();
|
final current = await PackageInfo.fromPlatform();
|
||||||
final latest = await _fetchLatestRelease(currentVersion: current.version);
|
final latest = await _fetchLatestRelease(currentVersion: current.version);
|
||||||
if (latest == null) return null;
|
if (latest == null ||
|
||||||
if (_compareVersions(latest.version, current.version) <= 0) return null;
|
_compareVersions(latest.version, current.version) <= 0) {
|
||||||
|
_setAvailableUpdate(null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
_setAvailableUpdate(latest);
|
||||||
return latest;
|
return latest;
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
AppLogger.e('检查更新失败: $e\n$stackTrace');
|
AppLogger.e('检查更新失败: $e\n$stackTrace');
|
||||||
@@ -54,6 +66,34 @@ class UpdateService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> shouldShowPrompt(AppUpdateInfo update) async {
|
||||||
|
final storage = StorageService.instance;
|
||||||
|
final skipVersion = await storage.getString(
|
||||||
|
StorageKeys.updatePromptSkipVersion,
|
||||||
|
);
|
||||||
|
final skipUntil = await storage.getInt(StorageKeys.updatePromptSkipUntil);
|
||||||
|
if (skipVersion != update.version || skipUntil == null) return true;
|
||||||
|
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
if (skipUntil > now) return false;
|
||||||
|
|
||||||
|
await storage.remove(StorageKeys.updatePromptSkipUntil);
|
||||||
|
await storage.remove(StorageKeys.updatePromptSkipVersion);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> skipPromptForFiveDays(AppUpdateInfo update) async {
|
||||||
|
final skipUntil = DateTime.now()
|
||||||
|
.add(const Duration(days: 5))
|
||||||
|
.millisecondsSinceEpoch;
|
||||||
|
final storage = StorageService.instance;
|
||||||
|
await storage.setString(
|
||||||
|
StorageKeys.updatePromptSkipVersion,
|
||||||
|
update.version,
|
||||||
|
);
|
||||||
|
await storage.setInt(StorageKeys.updatePromptSkipUntil, skipUntil);
|
||||||
|
}
|
||||||
|
|
||||||
Future<File?> downloadUpdate(
|
Future<File?> downloadUpdate(
|
||||||
AppUpdateInfo update, {
|
AppUpdateInfo update, {
|
||||||
void Function(int received, int total)? onProgress,
|
void Function(int received, int total)? onProgress,
|
||||||
@@ -96,6 +136,15 @@ class UpdateService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> openDownloadsPage() async {
|
||||||
|
if (!await launchUrl(
|
||||||
|
downloadsPageUrl,
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
|
)) {
|
||||||
|
throw Exception('无法打开下载页面: $downloadsPageUrl');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<AppUpdateInfo?> _fetchLatestRelease({String? currentVersion}) async {
|
Future<AppUpdateInfo?> _fetchLatestRelease({String? currentVersion}) async {
|
||||||
final response = await _dio.getUri<String>(releasesRssUrl);
|
final response = await _dio.getUri<String>(releasesRssUrl);
|
||||||
final body = response.data;
|
final body = response.data;
|
||||||
@@ -119,7 +168,6 @@ class UpdateService {
|
|||||||
final update = await _buildUpdateInfo(release);
|
final update = await _buildUpdateInfo(release);
|
||||||
if (update == null) continue;
|
if (update == null) continue;
|
||||||
|
|
||||||
if (update.downloadUrl == null) continue;
|
|
||||||
return update;
|
return update;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,8 +254,6 @@ class UpdateService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get _supportsAutoDownload => Platform.isAndroid || Platform.isWindows;
|
|
||||||
|
|
||||||
bool _isReleaseAttachment(Uri uri) {
|
bool _isReleaseAttachment(Uri uri) {
|
||||||
return uri.path.contains('/releases/download/');
|
return uri.path.contains('/releases/download/');
|
||||||
}
|
}
|
||||||
@@ -345,6 +391,16 @@ class UpdateService {
|
|||||||
return Uri.decodeComponent(segment);
|
return Uri.decodeComponent(segment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _setAvailableUpdate(AppUpdateInfo? update) {
|
||||||
|
final previous = _availableUpdate;
|
||||||
|
if (previous?.version == update?.version &&
|
||||||
|
previous?.pageUrl == update?.pageUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_availableUpdate = update;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
Future<AppUpdateInfo?> debugFetchLatestRelease() => _fetchLatestRelease();
|
Future<AppUpdateInfo?> debugFetchLatestRelease() => _fetchLatestRelease();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user