From abf660e10cca6f6e09787bb28e56803c4eeb0702 Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 5 Jul 2026 16:49:06 +0300 Subject: [PATCH] =?UTF-8?q?feat(update):=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BA=D0=B0=20=D0=BD=D0=BE=D0=B2=D0=BE=D0=B9=20=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=81=D0=B8=D0=B8=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=20GitHub=20Releases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 1 + lib/core/utils/update_checker.dart | 208 +++++++++++++++++++++++ lib/core/utils/update_installer.dart | 124 ++++++++++++++ lib/frontend/widgets/adaptive_shell.dart | 9 + lib/frontend/widgets/update_dialog.dart | 186 ++++++++++++++++++++ lib/l10n/app_en.arb | 17 +- lib/l10n/app_localizations.dart | 48 ++++++ lib/l10n/app_localizations_en.dart | 26 +++ lib/l10n/app_localizations_ru.dart | 26 +++ lib/l10n/app_ru.arb | 17 +- pubspec.yaml | 2 +- 11 files changed, 661 insertions(+), 3 deletions(-) create mode 100644 lib/core/utils/update_checker.dart create mode 100644 lib/core/utils/update_installer.dart create mode 100644 lib/frontend/widgets/update_dialog.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 7277238..e93a31e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ xmlns:tools="http://schemas.android.com/tools"> + diff --git a/lib/core/utils/update_checker.dart b/lib/core/utils/update_checker.dart new file mode 100644 index 0000000..cdf3dd4 --- /dev/null +++ b/lib/core/utils/update_checker.dart @@ -0,0 +1,208 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AppUpdateInfo { + final String version; + final int? build; + final String tag; + final String url; + final String notes; + final Map assets; + + const AppUpdateInfo({ + required this.version, + required this.build, + required this.tag, + required this.url, + required this.notes, + required this.assets, + }); +} + +abstract class UpdateChecker { + static const String _owner = 'KometTeam'; + static const String _repo = 'Komet'; + static const String _userAgent = 'KometUpdateChecker'; + + static const String _lastCheckKey = 'update_last_check_ms'; + static const String _skippedTagKey = 'update_skipped_tag'; + static const Duration _checkInterval = Duration(hours: 6); + static const Duration _timeout = Duration(seconds: 15); + + static Future fetchLatest() async { + final info = await PackageInfo.fromPlatform(); + final currentBase = info.version; + final currentBuild = _normalizeBuild(int.tryParse(info.buildNumber)); + + final release = await _fetchLatestRelease(); + if (release == null) return null; + + final tag = (release['tag_name'] as String?)?.trim(); + if (tag == null || tag.isEmpty) return null; + + final remoteBase = _baseVersion(tag); + final remoteBuild = _buildNumber(tag); + + if (!_isNewer( + currentBase: currentBase, + currentBuild: currentBuild, + remoteBase: remoteBase, + remoteBuild: remoteBuild, + )) { + return null; + } + + return AppUpdateInfo( + version: remoteBase, + build: remoteBuild, + tag: tag, + url: (release['html_url'] as String?) ?? _releasesPage, + notes: (release['body'] as String?)?.trim() ?? '', + assets: _parseAssets(release['assets']), + ); + } + + static Future check({bool force = false}) async { + final prefs = await SharedPreferences.getInstance(); + + if (!force) { + final last = prefs.getInt(_lastCheckKey); + if (last != null) { + final elapsed = DateTime.now().millisecondsSinceEpoch - last; + if (elapsed >= 0 && elapsed < _checkInterval.inMilliseconds) { + return null; + } + } + } + + AppUpdateInfo? update; + try { + update = await fetchLatest(); + } catch (_) { + return null; + } + + await prefs.setInt(_lastCheckKey, DateTime.now().millisecondsSinceEpoch); + + if (update == null) return null; + + if (!force && prefs.getString(_skippedTagKey) == update.tag) { + return null; + } + + return update; + } + + static Future skip(String tag) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_skippedTagKey, tag); + } + + static String get _releasesPage => + 'https://github.com/$_owner/$_repo/releases'; + + static Future?> _fetchLatestRelease() async { + final uri = Uri.parse( + 'https://api.github.com/repos/$_owner/$_repo/releases?per_page=10', + ); + final client = HttpClient()..connectionTimeout = _timeout; + try { + final req = await client.getUrl(uri); + req.headers + ..set(HttpHeaders.userAgentHeader, _userAgent) + ..set(HttpHeaders.acceptHeader, 'application/vnd.github+json'); + final resp = await req.close().timeout(_timeout); + if (resp.statusCode != HttpStatus.ok) { + await resp.drain(); + return null; + } + final body = await resp + .transform(const Utf8Decoder()) + .join() + .timeout(_timeout); + final decoded = jsonDecode(body); + if (decoded is! List) return null; + for (final entry in decoded) { + if (entry is! Map) continue; + if (entry['draft'] == true) continue; + return entry.cast(); + } + return null; + } catch (_) { + return null; + } finally { + client.close(force: true); + } + } + + static Map _parseAssets(dynamic raw) { + final result = {}; + if (raw is! List) return result; + for (final entry in raw) { + if (entry is! Map) continue; + final name = entry['name'] as String?; + final url = entry['browser_download_url'] as String?; + if (name != null && url != null) result[name] = url; + } + return result; + } + + static String _baseVersion(String tag) { + var s = tag.trim(); + if (s.startsWith('v') || s.startsWith('V')) s = s.substring(1); + final dash = s.indexOf('-'); + if (dash >= 0) s = s.substring(0, dash); + final plus = s.indexOf('+'); + if (plus >= 0) s = s.substring(0, plus); + return s; + } + + static const int _abiVersionCodeMultiplier = 1000; + + static int? _normalizeBuild(int? build) { + if (build == null) return null; + if (build >= _abiVersionCodeMultiplier) { + return build % _abiVersionCodeMultiplier; + } + return build; + } + + static int? _buildNumber(String tag) { + final matches = RegExp(r'\d+').allMatches(tag).toList(); + if (matches.isEmpty) return null; + return int.tryParse(matches.last.group(0)!); + } + + static bool _isNewer({ + required String currentBase, + required int? currentBuild, + required String remoteBase, + required int? remoteBuild, + }) { + final cmp = _compareSemver(remoteBase, currentBase); + if (cmp > 0) return true; + if (cmp < 0) return false; + if (remoteBuild != null && currentBuild != null) { + return remoteBuild > currentBuild; + } + return false; + } + + static int _compareSemver(String a, String b) { + final pa = _parts(a); + final pb = _parts(b); + final len = pa.length > pb.length ? pa.length : pb.length; + for (var i = 0; i < len; i++) { + final va = i < pa.length ? pa[i] : 0; + final vb = i < pb.length ? pb[i] : 0; + if (va != vb) return va > vb ? 1 : -1; + } + return 0; + } + + static List _parts(String v) => + v.split('.').map((p) => int.tryParse(p.trim()) ?? 0).toList(); +} diff --git a/lib/core/utils/update_installer.dart b/lib/core/utils/update_installer.dart new file mode 100644 index 0000000..84eabed --- /dev/null +++ b/lib/core/utils/update_installer.dart @@ -0,0 +1,124 @@ +import 'dart:io'; + +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:open_filex/open_filex.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:path_provider/path_provider.dart'; + +import 'update_checker.dart'; + +enum UpdateInstallStatus { done, noAsset, downloadFailed, installFailed } + +class UpdateInstallResult { + final UpdateInstallStatus status; + final String? error; + + const UpdateInstallResult(this.status, {this.error}); + + bool get ok => status == UpdateInstallStatus.done; +} + +abstract class UpdateInstaller { + static bool get isSupported => Platform.isAndroid; + + static Future downloadAndInstall( + AppUpdateInfo info, { + void Function(double progress)? onProgress, + }) async { + final url = await resolveApkUrl(info); + if (url == null) { + return const UpdateInstallResult(UpdateInstallStatus.noAsset); + } + + File file; + try { + file = await _download(url, info.tag, onProgress); + } catch (e) { + return UpdateInstallResult( + UpdateInstallStatus.downloadFailed, + error: e.toString(), + ); + } + + final opened = await OpenFilex.open( + file.path, + type: 'application/vnd.android.package-archive', + ); + if (opened.type != ResultType.done) { + return UpdateInstallResult( + UpdateInstallStatus.installFailed, + error: opened.message, + ); + } + return const UpdateInstallResult(UpdateInstallStatus.done); + } + + static Future resolveApkUrl(AppUpdateInfo info) async { + if (!Platform.isAndroid || info.assets.isEmpty) return null; + + final packageInfo = await PackageInfo.fromPlatform(); + final flavor = packageInfo.packageName == 'ru.oneme.app' ? 'oneme' : 'komet'; + + final androidInfo = await DeviceInfoPlugin().androidInfo; + final abis = androidInfo.supportedAbis; + + for (final abi in abis) { + final url = _findAsset(info.assets, '-$flavor-$abi.apk'); + if (url != null) return url; + } + return _findAsset(info.assets, '-$flavor-universal.apk'); + } + + static String? _findAsset(Map assets, String suffix) { + for (final entry in assets.entries) { + if (entry.key.endsWith(suffix)) return entry.value; + } + return null; + } + + static Future _download( + String url, + String tag, + void Function(double progress)? onProgress, + ) async { + final dir = await getTemporaryDirectory(); + final safeTag = tag.replaceAll(RegExp(r'[^A-Za-z0-9._-]'), '_'); + final file = File('${dir.path}/komet-update-$safeTag.apk'); + final part = File('${file.path}.part'); + + final client = HttpClient(); + try { + final request = await client.getUrl(Uri.parse(url)); + request.headers.set(HttpHeaders.userAgentHeader, 'KometUpdateInstaller'); + final response = await request.close(); + if (response.statusCode != HttpStatus.ok) { + await response.drain(); + throw HttpException('HTTP ${response.statusCode}', uri: Uri.parse(url)); + } + + final total = response.contentLength; + var received = 0; + final sink = part.openWrite(); + await for (final chunk in response) { + received += chunk.length; + sink.add(chunk); + if (onProgress != null && total > 0) { + onProgress(received / total); + } + } + await sink.close(); + if (await file.exists()) await file.delete(); + await part.rename(file.path); + return file; + } catch (e) { + if (await part.exists()) { + try { + await part.delete(); + } catch (_) {} + } + rethrow; + } finally { + client.close(); + } + } +} diff --git a/lib/frontend/widgets/adaptive_shell.dart b/lib/frontend/widgets/adaptive_shell.dart index 8e2ac57..7755f26 100644 --- a/lib/frontend/widgets/adaptive_shell.dart +++ b/lib/frontend/widgets/adaptive_shell.dart @@ -5,8 +5,10 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../../core/utils/update_checker.dart'; import '../screens/chats/chat_list_screen.dart'; import '../screens/chats/chat_screen.dart'; +import 'update_dialog.dart'; class AdaptiveShell extends StatefulWidget { const AdaptiveShell({super.key}); @@ -46,6 +48,13 @@ class _AdaptiveShellState extends State { void initState() { super.initState(); _loadListWidth(); + WidgetsBinding.instance.addPostFrameCallback((_) => _maybeCheckUpdate()); + } + + Future _maybeCheckUpdate() async { + final update = await UpdateChecker.check(); + if (update == null || !mounted) return; + await showUpdateDialog(context, update); } Future _loadListWidth() async { diff --git a/lib/frontend/widgets/update_dialog.dart b/lib/frontend/widgets/update_dialog.dart new file mode 100644 index 0000000..8cb9f9a --- /dev/null +++ b/lib/frontend/widgets/update_dialog.dart @@ -0,0 +1,186 @@ +import 'package:flutter/material.dart'; + +import '../../core/utils/update_checker.dart'; +import '../../core/utils/update_installer.dart'; +import '../../core/utils/link_opener.dart'; +import '../../l10n/app_localizations.dart'; +import 'custom_notification.dart'; + +Future showUpdateDialog( + BuildContext context, + AppUpdateInfo info, +) async { + final l10n = AppLocalizations.of(context)!; + await showDialog( + context: context, + builder: (dialogContext) { + final cs = Theme.of(dialogContext).colorScheme; + final notes = info.notes; + return AlertDialog( + backgroundColor: cs.surfaceContainerHigh, + title: Text( + l10n.updateAvailableTitle, + style: TextStyle( + fontFamily: 'Outfit', + fontWeight: FontWeight.w600, + fontSize: 18, + color: cs.onSurface, + ), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.updateAvailableBody(info.version), + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + height: 1.35, + ), + ), + if (notes.isNotEmpty) ...[ + const SizedBox(height: 16), + Text( + l10n.updateWhatsNew, + style: TextStyle( + color: cs.onSurface, + fontSize: 12, + fontWeight: FontWeight.w600, + letterSpacing: 0.4, + ), + ), + const SizedBox(height: 6), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 180), + child: SingleChildScrollView( + child: Text( + notes, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + height: 1.4, + ), + ), + ), + ), + ], + ], + ), + actionsOverflowButtonSpacing: 4, + actions: [ + TextButton( + onPressed: () { + UpdateChecker.skip(info.tag); + Navigator.pop(dialogContext); + }, + child: Text( + l10n.updateSkip, + style: TextStyle(color: cs.onSurfaceVariant), + ), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text( + l10n.updateLater, + style: TextStyle(color: cs.onSurfaceVariant), + ), + ), + FilledButton( + onPressed: () { + Navigator.pop(dialogContext); + _startUpdate(context, info); + }, + child: Text(l10n.updateAction), + ), + ], + ); + }, + ); +} + +Future _startUpdate(BuildContext context, AppUpdateInfo info) async { + if (!UpdateInstaller.isSupported) { + await openExternalUrl(context, info.url); + return; + } + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => _UpdateProgressDialog(info: info), + ); +} + +class _UpdateProgressDialog extends StatefulWidget { + final AppUpdateInfo info; + + const _UpdateProgressDialog({required this.info}); + + @override + State<_UpdateProgressDialog> createState() => _UpdateProgressDialogState(); +} + +class _UpdateProgressDialogState extends State<_UpdateProgressDialog> { + double _progress = 0; + + @override + void initState() { + super.initState(); + _run(); + } + + Future _run() async { + final result = await UpdateInstaller.downloadAndInstall( + widget.info, + onProgress: (p) { + if (mounted) setState(() => _progress = p.clamp(0.0, 1.0)); + }, + ); + if (!mounted) return; + Navigator.pop(context); + if (!result.ok) { + final l10n = AppLocalizations.of(context)!; + showCustomNotification(context, l10n.updateDownloadFailed); + await openExternalUrl(context, widget.info.url); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + final percent = (_progress * 100).round(); + return AlertDialog( + backgroundColor: cs.surfaceContainerHigh, + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.updateDownloading, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: LinearProgressIndicator( + value: _progress > 0 ? _progress : null, + minHeight: 6, + backgroundColor: cs.surfaceContainerHighest, + color: cs.primary, + ), + ), + const SizedBox(height: 10), + Text( + '$percent%', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ], + ), + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 143a7b8..7a7e9f6 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -817,5 +817,20 @@ "fontSettingsLoading": "Loading…", "fontSettingsSectionFontSize": "Font size", "fontSettingsPreviewLabel": "PREVIEW", - "fontSettingsReset": "Reset" + "fontSettingsReset": "Reset", + "updateAvailableTitle": "Update available", + "updateAvailableBody": "Version {version} is out. Update the app?", + "@updateAvailableBody": { + "placeholders": { + "version": { + "type": "String" + } + } + }, + "updateWhatsNew": "WHAT'S NEW", + "updateAction": "Update", + "updateLater": "Later", + "updateSkip": "Skip", + "updateDownloading": "Downloading update…", + "updateDownloadFailed": "Failed to download the update" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 89e2f9c..d940139 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3787,6 +3787,54 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Reset'** String get fontSettingsReset; + + /// No description provided for @updateAvailableTitle. + /// + /// In en, this message translates to: + /// **'Update available'** + String get updateAvailableTitle; + + /// No description provided for @updateAvailableBody. + /// + /// In en, this message translates to: + /// **'Version {version} is out. Update the app?'** + String updateAvailableBody(String version); + + /// No description provided for @updateWhatsNew. + /// + /// In en, this message translates to: + /// **'WHAT\'S NEW'** + String get updateWhatsNew; + + /// No description provided for @updateAction. + /// + /// In en, this message translates to: + /// **'Update'** + String get updateAction; + + /// No description provided for @updateLater. + /// + /// In en, this message translates to: + /// **'Later'** + String get updateLater; + + /// No description provided for @updateSkip. + /// + /// In en, this message translates to: + /// **'Skip'** + String get updateSkip; + + /// No description provided for @updateDownloading. + /// + /// In en, this message translates to: + /// **'Downloading update…'** + String get updateDownloading; + + /// No description provided for @updateDownloadFailed. + /// + /// In en, this message translates to: + /// **'Failed to download the update'** + String get updateDownloadFailed; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 59cff9b..02b0e19 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1949,4 +1949,30 @@ class AppLocalizationsEn extends AppLocalizations { @override String get fontSettingsReset => 'Reset'; + + @override + String get updateAvailableTitle => 'Update available'; + + @override + String updateAvailableBody(String version) { + return 'Version $version is out. Update the app?'; + } + + @override + String get updateWhatsNew => 'WHAT\'S NEW'; + + @override + String get updateAction => 'Update'; + + @override + String get updateLater => 'Later'; + + @override + String get updateSkip => 'Skip'; + + @override + String get updateDownloading => 'Downloading update…'; + + @override + String get updateDownloadFailed => 'Failed to download the update'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index fdc2e2a..031c554 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1959,4 +1959,30 @@ class AppLocalizationsRu extends AppLocalizations { @override String get fontSettingsReset => 'Сбросить'; + + @override + String get updateAvailableTitle => 'Доступно обновление'; + + @override + String updateAvailableBody(String version) { + return 'Вышла версия $version. Обновить приложение?'; + } + + @override + String get updateWhatsNew => 'ЧТО НОВОГО'; + + @override + String get updateAction => 'Обновить'; + + @override + String get updateLater => 'Позже'; + + @override + String get updateSkip => 'Пропустить'; + + @override + String get updateDownloading => 'Загрузка обновления…'; + + @override + String get updateDownloadFailed => 'Не удалось скачать обновление'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index eca4acc..fd206c3 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -639,5 +639,20 @@ "fontSettingsLoading": "Загрузка…", "fontSettingsSectionFontSize": "Размер шрифта", "fontSettingsPreviewLabel": "ПРЕДПРОСМОТР", - "fontSettingsReset": "Сбросить" + "fontSettingsReset": "Сбросить", + "updateAvailableTitle": "Доступно обновление", + "updateAvailableBody": "Вышла версия {version}. Обновить приложение?", + "@updateAvailableBody": { + "placeholders": { + "version": { + "type": "String" + } + } + }, + "updateWhatsNew": "ЧТО НОВОГО", + "updateAction": "Обновить", + "updateLater": "Позже", + "updateSkip": "Пропустить", + "updateDownloading": "Загрузка обновления…", + "updateDownloadFailed": "Не удалось скачать обновление" } diff --git a/pubspec.yaml b/pubspec.yaml index e18ca7c..22277aa 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.5.0+12 +version: 0.5.0+13 environment: sdk: ^3.10.4