Files
app/lib/services/update_service.dart
T

435 lines
13 KiB
Dart

import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:open_file/open_file.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:xml/xml.dart';
import '../core/constants/storage_keys.dart';
import '../core/utils/app_logger.dart';
import '../data/models/app_update_model.dart';
import 'storage_service.dart';
class UpdateService extends ChangeNotifier {
UpdateService._()
: _dio = Dio(_dioOptions),
_packageInfoProvider = PackageInfo.fromPlatform;
@visibleForTesting
UpdateService.test({
Dio? dio,
Future<PackageInfo> Function()? packageInfoProvider,
}) : _dio = dio ?? Dio(_dioOptions),
_packageInfoProvider = packageInfoProvider ?? PackageInfo.fromPlatform;
static final UpdateService instance = UpdateService._();
static final Uri releasesRssUrl = Uri.parse(
'https://git.saont.net/gongyun/app/releases.rss',
);
static final Uri releasesPageUrl = Uri.parse(
'https://git.saont.net/gongyun/app/releases',
);
static final Uri downloadsPageUrl = Uri.parse(
'https://www.gongyun.org/downloads.html',
);
static final BaseOptions _dioOptions = BaseOptions(
connectTimeout: const Duration(seconds: 12),
receiveTimeout: const Duration(seconds: 20),
followRedirects: true,
responseType: ResponseType.plain,
);
final Dio _dio;
final Future<PackageInfo> Function() _packageInfoProvider;
bool _checking = false;
AppUpdateInfo? _availableUpdate;
AppUpdateInfo? get availableUpdate => _availableUpdate;
bool get hasUpdate => _availableUpdate != null;
Future<AppUpdateInfo?> checkForUpdate() async {
if (_checking) return null;
_checking = true;
try {
final current = await _packageInfoProvider();
final latest = await _fetchLatestRelease(currentVersion: current.version);
if (latest == null ||
_compareVersions(latest.version, current.version) <= 0) {
_setAvailableUpdate(null);
return null;
}
_setAvailableUpdate(latest);
return latest;
} catch (e, stackTrace) {
AppLogger.e('检查更新失败: $e\n$stackTrace');
return null;
} finally {
_checking = false;
}
}
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(
AppUpdateInfo update, {
void Function(int received, int total)? onProgress,
}) async {
final url = update.downloadUrl;
if (url == null) {
return null;
}
final dir = await getTemporaryDirectory();
final updateDir = Directory(p.join(dir.path, 'app_updates'));
await updateDir.create(recursive: true);
final fileName = update.fileName ?? _fileNameFromUrl(url);
final file = File(p.join(updateDir.path, fileName));
if (await file.exists()) {
await file.delete();
}
await _dio.downloadUri(
url,
file.path,
onReceiveProgress: onProgress,
options: Options(responseType: ResponseType.bytes),
);
return file;
}
Future<void> openInstaller(File file) async {
final result = await OpenFile.open(file.path);
if (result.type != ResultType.done) {
throw Exception(result.message);
}
}
Future<void> openReleasePage(AppUpdateInfo update) async {
final url = update.pageUrl;
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
throw Exception('无法打开更新页面: $url');
}
}
Future<void> openDownloadsPage() async {
if (!await launchUrl(
downloadsPageUrl,
mode: LaunchMode.externalApplication,
)) {
throw Exception('无法打开下载页面: $downloadsPageUrl');
}
}
Future<AppUpdateInfo?> _fetchLatestRelease({String? currentVersion}) async {
final response = await _dio.getUri<String>(releasesRssUrl);
final body = response.data;
if (body == null || body.trim().isEmpty) return null;
final document = XmlDocument.parse(body);
final items = document.findAllElements('item').toList();
if (items.isEmpty) return null;
final releases =
items.map(_releaseFromRssItem).whereType<_RssRelease>().toList()
..sort(_compareRssReleaseDesc);
if (releases.isEmpty) return null;
for (final release in releases) {
if (currentVersion != null &&
_compareVersions(release.version, currentVersion) <= 0) {
continue;
}
final update = await _buildUpdateInfo(release);
if (update == null) continue;
return update;
}
return null;
}
_RssRelease? _releaseFromRssItem(XmlElement item) {
final title = _elementText(item, 'title') ?? '';
final link = _elementText(item, 'link') ?? releasesPageUrl.toString();
final description = _elementText(item, 'description');
final content = _elementText(item, 'encoded');
final enclosure = item
.findElements('enclosure')
.map((e) => e.getAttribute('url'))
.whereType<String>()
.map(_absoluteUriOrNull)
.whereType<Uri>()
.toList();
final candidates = <Uri>[
...enclosure,
..._extractLinks(description ?? ''),
..._extractLinks(content ?? ''),
..._extractLinks(item.toXmlString()),
];
final pageUrl = _absoluteUriOrNull(link) ?? releasesPageUrl;
final version = _versionFromText(
'$title $link ${description ?? ''} ${content ?? ''}',
);
if (version == null) return null;
return _RssRelease(
version: version,
title: title.isEmpty ? 'v$version' : title,
description: _stripHtml(
(content ?? '').isNotEmpty ? content : description,
),
pageUrl: pageUrl,
candidates: candidates,
publishedAt: _parseRssDate(_elementText(item, 'pubDate')),
);
}
Future<AppUpdateInfo?> _buildUpdateInfo(_RssRelease release) async {
final candidates = <Uri>[...release.candidates];
final downloadUrl = _pickDownloadUrl(candidates);
Uri? resolvedDownloadUrl = downloadUrl;
resolvedDownloadUrl ??= await _downloadUrlFromReleasePage(release.pageUrl);
return AppUpdateInfo(
version: release.version,
title: release.title,
description: release.description,
pageUrl: release.pageUrl,
downloadUrl: resolvedDownloadUrl,
fileName: resolvedDownloadUrl == null
? null
: _fileNameFromUrl(resolvedDownloadUrl),
publishedAt: release.publishedAt,
);
}
Future<Uri?> _downloadUrlFromReleasePage(Uri pageUrl) async {
try {
final response = await _dio.getUri<String>(pageUrl);
final body = response.data;
if (body == null || body.trim().isEmpty) return null;
return _pickDownloadUrl(_extractLinks(body));
} catch (e) {
AppLogger.w('鑾峰彇鏇存柊闄勪欢澶辫触: $pageUrl $e');
return null;
}
}
Uri? _pickDownloadUrl(List<Uri> candidates) {
final normalized = candidates.toSet().toList();
final preferred = normalized
.where(_isReleaseAttachment)
.where(_isSupportedPackage)
.toList();
if (preferred.isNotEmpty) return preferred.first;
return null;
}
bool _isReleaseAttachment(Uri uri) {
return uri.path.contains('/releases/download/');
}
bool _isSupportedPackage(Uri uri) {
final name = _fileNameFromUrl(uri).toLowerCase();
if (Platform.isAndroid) {
return name.endsWith('.apk');
}
if (Platform.isWindows) {
return name.endsWith('.exe') ||
name.endsWith('.msix') ||
name.endsWith('.msi') ||
name.endsWith('.zip');
}
if (Platform.isLinux) {
return name.endsWith('.appimage') ||
name.endsWith('.deb') ||
name.endsWith('.rpm') ||
name.endsWith('.tar.gz') ||
name.endsWith('.zip');
}
if (Platform.isMacOS) {
return name.endsWith('.dmg') ||
name.endsWith('.pkg') ||
name.endsWith('.zip');
}
return false;
}
List<Uri> _extractLinks(String html) {
final matches = RegExp(
r'''https?://[^\s"'<>]+|href=["']([^"']+)["']''',
caseSensitive: false,
).allMatches(html);
return matches
.map((m) => m.group(1) ?? m.group(0))
.whereType<String>()
.map(_absoluteUriOrNull)
.whereType<Uri>()
.toList();
}
Uri? _absoluteUriOrNull(String value) {
final cleaned = _decodeHtmlEntities(value.trim());
final parsed = Uri.tryParse(cleaned);
if (parsed == null) return null;
if (parsed.hasScheme) return parsed;
return releasesPageUrl.resolveUri(parsed);
}
String? _elementText(XmlElement element, String name) {
for (final child in element.children.whereType<XmlElement>()) {
if (child.name.qualified == name || child.name.local == name) {
return child.innerText.trim();
}
}
return null;
}
String? _versionFromText(String text) {
final match = RegExp(
r'v?(\d+\.\d+\.\d+(?:[+\-][0-9A-Za-z.\-]+)?)',
).firstMatch(text);
return match?.group(1);
}
String? _stripHtml(String? value) {
if (value == null) return null;
final withoutTags = value.replaceAll(RegExp(r'<[^>]+>'), ' ');
return _decodeHtmlEntities(
withoutTags,
).replaceAll(RegExp(r'\s+'), ' ').trim();
}
String _decodeHtmlEntities(String value) {
return value
.replaceAll('&nbsp;', ' ')
.replaceAll('&amp;', '&')
.replaceAll('&quot;', '"')
.replaceAll('&#34;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&#43;', '+')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>');
}
DateTime? _parseRssDate(String? value) {
if (value == null || value.isEmpty) return null;
final direct = DateTime.tryParse(value);
if (direct != null) return direct;
try {
return HttpDate.parse(value);
} catch (_) {
return null;
}
}
int _compareRssReleaseDesc(_RssRelease a, _RssRelease b) {
final version = _compareVersions(b.version, a.version);
if (version != 0) return version;
final left = a.publishedAt;
final right = b.publishedAt;
if (left == null && right == null) return 0;
if (left == null) return 1;
if (right == null) return -1;
return right.compareTo(left);
}
int _compareVersions(String a, String b) {
final left = _versionParts(a);
final right = _versionParts(b);
final length = left.length > right.length ? left.length : right.length;
for (var i = 0; i < length; i++) {
final l = i < left.length ? left[i] : 0;
final r = i < right.length ? right[i] : 0;
if (l != r) return l.compareTo(r);
}
return 0;
}
List<int> _versionParts(String version) {
final core = version.split(RegExp(r'[+\-]')).first;
return core
.split('.')
.map((part) => int.tryParse(part) ?? 0)
.toList(growable: false);
}
String _fileNameFromUrl(Uri uri) {
final segment = uri.pathSegments.isEmpty
? 'app_update'
: uri.pathSegments.last;
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
Future<AppUpdateInfo?> debugFetchLatestRelease() => _fetchLatestRelease();
}
class _RssRelease {
final String version;
final String title;
final String? description;
final Uri pageUrl;
final List<Uri> candidates;
final DateTime? publishedAt;
const _RssRelease({
required this.version,
required this.title,
required this.pageUrl,
required this.candidates,
this.description,
this.publishedAt,
});
}