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._(); 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', ); final Dio _dio = Dio( BaseOptions( connectTimeout: const Duration(seconds: 12), receiveTimeout: const Duration(seconds: 20), followRedirects: true, responseType: ResponseType.plain, ), ); bool _checking = false; AppUpdateInfo? _availableUpdate; AppUpdateInfo? get availableUpdate => _availableUpdate; bool get hasUpdate => _availableUpdate != null; Future checkForUpdate() async { if (_checking) return null; _checking = true; try { final current = await PackageInfo.fromPlatform(); 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 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 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 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 openInstaller(File file) async { final result = await OpenFile.open(file.path); if (result.type != ResultType.done) { throw Exception(result.message); } } Future openReleasePage(AppUpdateInfo update) async { final url = update.pageUrl; if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { throw Exception('无法打开更新页面: $url'); } } Future openDownloadsPage() async { if (!await launchUrl( downloadsPageUrl, mode: LaunchMode.externalApplication, )) { throw Exception('无法打开下载页面: $downloadsPageUrl'); } } Future _fetchLatestRelease({String? currentVersion}) async { final response = await _dio.getUri(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() .map(_absoluteUriOrNull) .whereType() .toList(); final candidates = [ ...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 _buildUpdateInfo(_RssRelease release) async { final candidates = [...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 _downloadUrlFromReleasePage(Uri pageUrl) async { try { final response = await _dio.getUri(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 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 _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() .map(_absoluteUriOrNull) .whereType() .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()) { 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(' ', ' ') .replaceAll('&', '&') .replaceAll('"', '"') .replaceAll('"', '"') .replaceAll(''', "'") .replaceAll('+', '+') .replaceAll('<', '<') .replaceAll('>', '>'); } 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 _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 debugFetchLatestRelease() => _fetchLatestRelease(); } class _RssRelease { final String version; final String title; final String? description; final Uri pageUrl; final List candidates; final DateTime? publishedAt; const _RssRelease({ required this.version, required this.title, required this.pageUrl, required this.candidates, this.description, this.publishedAt, }); }