diff --git a/assets/translations/strings.i18n.json b/assets/translations/strings.i18n.json index d5ec3150..105d0a69 100644 --- a/assets/translations/strings.i18n.json +++ b/assets/translations/strings.i18n.json @@ -1,6 +1,6 @@ { "general": { - "appTitle": "hiddify", + "appTitle": "Hiddify Next", "reset": "reset", "toggle": { "enabled": "enabled", @@ -125,6 +125,15 @@ "telegramChannel": "telegram channel", "checkForUpdate": "check for update" }, + "appUpdate": { + "dialogTitle": "update available", + "updateMsg": "A new version of @:general.appTitle is available! Would you like to update now?", + "currentVersionLbl": "current version", + "newVersionLbl": "new version", + "updateNowBtnTxt": "update now", + "laterBtnTxt": "later", + "ignoreBtnTxt": "ignore" + }, "tray": { "dashboard": "dashboard", "quit": "quit", diff --git a/assets/translations/strings_fa.i18n.json b/assets/translations/strings_fa.i18n.json index 5661f8e3..499bacfc 100644 --- a/assets/translations/strings_fa.i18n.json +++ b/assets/translations/strings_fa.i18n.json @@ -1,6 +1,6 @@ { "general": { - "appTitle": "هیدیفای", + "appTitle": "هیدیفای نکست", "reset": "ریست", "toggle": { "enabled": "فعال", @@ -125,6 +125,15 @@ "telegramChannel": "کانال تلگرام", "checkForUpdate": "بررسی آپدیت جدید" }, + "appUpdate": { + "dialogTitle": "نسخه جدید", + "updateMsg": "نسخه جدیدی از @:general.appTitle موجود است! الان بروزرسانی شود؟", + "currentVersionLbl": "نسخه فعلی", + "newVersionLbl": "نسخه جدید", + "updateNowBtnTxt": "بروزرسانی", + "laterBtnTxt": "بعدا", + "ignoreBtnTxt": "نادیده‌گرفتن" + }, "tray": { "dashboard": "داشبورد", "quit": "خروج", diff --git a/lib/data/data_providers.dart b/lib/data/data_providers.dart index f49e403f..b07d31b1 100644 --- a/lib/data/data_providers.dart +++ b/lib/data/data_providers.dart @@ -2,6 +2,8 @@ import 'package:dio/dio.dart'; import 'package:hiddify/data/local/dao/dao.dart'; import 'package:hiddify/data/local/database.dart'; import 'package:hiddify/data/repository/repository.dart'; +import 'package:hiddify/data/repository/update_repository_impl.dart'; +import 'package:hiddify/domain/app/app.dart'; import 'package:hiddify/domain/clash/clash.dart'; import 'package:hiddify/domain/profiles/profiles.dart'; import 'package:hiddify/services/service_providers.dart'; @@ -40,3 +42,7 @@ ProfilesRepository profilesRepository(ProfilesRepositoryRef ref) => clashFacade: ref.watch(clashFacadeProvider), dio: ref.watch(dioProvider), ); + +@Riverpod(keepAlive: true) +UpdateRepository updateRepository(UpdateRepositoryRef ref) => + UpdateRepositoryImpl(ref.watch(dioProvider)); diff --git a/lib/data/repository/update_repository_impl.dart b/lib/data/repository/update_repository_impl.dart new file mode 100644 index 00000000..b6830fd4 --- /dev/null +++ b/lib/data/repository/update_repository_impl.dart @@ -0,0 +1,59 @@ +import 'package:dio/dio.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:hiddify/data/repository/exception_handlers.dart'; +import 'package:hiddify/domain/app/app.dart'; +import 'package:hiddify/domain/constants.dart'; +import 'package:hiddify/utils/custom_loggers.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +class UpdateRepositoryImpl + with ExceptionHandler, InfraLogger + implements UpdateRepository { + UpdateRepositoryImpl(this.dio); + + final Dio dio; + + @override + TaskEither getCurrentVersion() { + return exceptionHandler( + () async { + final packageInfo = await PackageInfo.fromPlatform(); + return right( + InstalledVersionInfo( + version: packageInfo.version, + buildNumber: packageInfo.buildNumber, + installerMedia: packageInfo.installerStore, + ), + ); + }, + UpdateFailure.unexpected, + ); + } + + @override + TaskEither getLatestVersion({ + bool includePreReleases = false, + }) { + return exceptionHandler( + () async { + final response = await dio.get(Constants.githubReleasesApiUrl); + + if (response.statusCode != 200 || response.data == null) { + loggy.warning("failed to fetch latest version info"); + return left(const UpdateFailure.unexpected()); + } + + final releases = response.data! + .map((e) => RemoteVersionInfo.fromJson(e as Map)); + late RemoteVersionInfo latest; + if (includePreReleases) { + latest = releases.first; + } else { + latest = releases.firstWhere((e) => e.preRelease == false); + } + return right(latest); + }, + UpdateFailure.unexpected, + ); + } +} diff --git a/lib/domain/app/app.dart b/lib/domain/app/app.dart new file mode 100644 index 00000000..32895acb --- /dev/null +++ b/lib/domain/app/app.dart @@ -0,0 +1,3 @@ +export 'update_failure.dart'; +export 'update_repository.dart'; +export 'version_info.dart'; diff --git a/lib/domain/app/update_failure.dart b/lib/domain/app/update_failure.dart new file mode 100644 index 00000000..7fc77027 --- /dev/null +++ b/lib/domain/app/update_failure.dart @@ -0,0 +1,22 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:hiddify/core/locale/locale.dart'; +import 'package:hiddify/domain/failures.dart'; + +part 'update_failure.freezed.dart'; + +@freezed +sealed class UpdateFailure with _$UpdateFailure, Failure { + const UpdateFailure._(); + + const factory UpdateFailure.unexpected([ + Object? error, + StackTrace? stackTrace, + ]) = UpdateUnexpectedFailure; + + @override + String present(TranslationsEn t) { + return switch (this) { + UpdateUnexpectedFailure() => t.failure.unexpected, + }; + } +} diff --git a/lib/domain/app/update_repository.dart b/lib/domain/app/update_repository.dart new file mode 100644 index 00000000..66a76fc7 --- /dev/null +++ b/lib/domain/app/update_repository.dart @@ -0,0 +1,11 @@ +import 'package:fpdart/fpdart.dart'; +import 'package:hiddify/domain/app/update_failure.dart'; +import 'package:hiddify/domain/app/version_info.dart'; + +abstract interface class UpdateRepository { + TaskEither getCurrentVersion(); + + TaskEither getLatestVersion({ + bool includePreReleases = false, + }); +} diff --git a/lib/domain/app/version_info.dart b/lib/domain/app/version_info.dart new file mode 100644 index 00000000..94edce67 --- /dev/null +++ b/lib/domain/app/version_info.dart @@ -0,0 +1,56 @@ +import 'package:dartx/dartx.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'version_info.freezed.dart'; +part 'version_info.g.dart'; + +@freezed +class InstalledVersionInfo with _$InstalledVersionInfo { + const InstalledVersionInfo._(); + + const factory InstalledVersionInfo({ + required String version, + required String buildNumber, + String? installerMedia, + }) = _InstalledVersionInfo; + + String get fullVersion => + buildNumber.isBlank ? version : "$version+$buildNumber"; + + factory InstalledVersionInfo.fromJson(Map json) => + _$InstalledVersionInfoFromJson(json); +} + +// TODO ignore drafts +@Freezed() +class RemoteVersionInfo with _$RemoteVersionInfo { + const RemoteVersionInfo._(); + + const factory RemoteVersionInfo({ + required String version, + required String buildNumber, + required String releaseTag, + required bool preRelease, + required DateTime publishedAt, + }) = _RemoteVersionInfo; + + String get fullVersion => + buildNumber.isBlank ? version : "$version+$buildNumber"; + + // ignore: prefer_constructors_over_static_methods + static RemoteVersionInfo fromJson(Map json) { + final fullTag = json['tag_name'] as String; + final fullVersion = fullTag.removePrefix("v").split("-").first.split("+"); + final version = fullVersion.first; + final buildNumber = fullVersion.elementAtOrElse(1, (index) => ""); + final preRelease = json["prerelease"] as bool; + final publishedAt = DateTime.parse(json["published_at"] as String); + return RemoteVersionInfo( + version: version, + buildNumber: buildNumber, + releaseTag: fullTag, + preRelease: preRelease, + publishedAt: publishedAt, + ); + } +} diff --git a/lib/domain/constants.dart b/lib/domain/constants.dart index e5451003..2a8ab643 100644 --- a/lib/domain/constants.dart +++ b/lib/domain/constants.dart @@ -5,5 +5,9 @@ abstract class Constants { static const configFileName = "config"; static const countryMMDBFileName = "Country"; static const githubUrl = "https://github.com/hiddify/hiddify-next"; + static const githubReleasesApiUrl = + "https://api.github.com/repos/hiddify/hiddify-next/releases"; + static const githubLatestReleaseUrl = + "https://github.com/hiddify/hiddify-next/releases/latest"; static const telegramChannelUrl = "https://t.me/hiddify"; } diff --git a/lib/features/about/view/about_page.dart b/lib/features/about/view/about_page.dart index 888989fc..0516efe1 100644 --- a/lib/features/about/view/about_page.dart +++ b/lib/features/about/view/about_page.dart @@ -2,8 +2,11 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:hiddify/core/core_providers.dart'; import 'package:hiddify/domain/constants.dart'; +import 'package:hiddify/domain/failures.dart'; +import 'package:hiddify/features/common/new_version_dialog.dart'; import 'package:hiddify/features/common/runtime_details.dart'; import 'package:hiddify/gen/assets.gen.dart'; +import 'package:hiddify/utils/alerts.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:recase/recase.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -14,7 +17,36 @@ class AboutPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final t = ref.watch(translationsProvider); - final details = ref.watch(runtimeDetailsNotifierProvider); + final appVersion = ref.watch(appVersionProvider); + + final isCheckingForUpdate = ref.watch( + runtimeDetailsNotifierProvider.select( + (value) => value.maybeWhen( + data: (data) => data.latestVersion.isLoading, + orElse: () => false, + ), + ), + ); + + ref.listen( + runtimeDetailsNotifierProvider, + (_, next) async { + if (next case AsyncData(:final value)) { + switch (value.latestVersion) { + case AsyncError(:final error): + CustomToast.error(t.presentError(error)).show(context); + default: + if (value.newVersionAvailable) { + await NewVersionDialog( + value.appVersion, + value.latestVersion.value!, + canIgnore: false, + ).show(context); + } + } + } + }, + ); return Scaffold( body: CustomScrollView( @@ -22,7 +54,7 @@ class AboutPage extends HookConsumerWidget { SliverAppBar( title: Text(t.about.pageTitle.titleCase), ), - ...switch (details) { + ...switch (appVersion) { AsyncData(:final value) => [ SliverToBoxAdapter( child: Padding( @@ -77,6 +109,18 @@ class AboutPage extends HookConsumerWidget { ), ListTile( title: Text(t.about.checkForUpdate.sentenceCase), + trailing: isCheckingForUpdate + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(), + ) + : const Icon(Icons.update), + onTap: () async { + await ref + .read(runtimeDetailsNotifierProvider.notifier) + .checkForUpdates(); + }, ), ], ), diff --git a/lib/features/common/new_version_dialog.dart b/lib/features/common/new_version_dialog.dart new file mode 100644 index 00000000..25f196a1 --- /dev/null +++ b/lib/features/common/new_version_dialog.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hiddify/core/core_providers.dart'; +import 'package:hiddify/domain/app/app.dart'; +import 'package:hiddify/domain/constants.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:recase/recase.dart'; +import 'package:url_launcher/url_launcher.dart'; + +// TODO add release notes +class NewVersionDialog extends HookConsumerWidget { + const NewVersionDialog( + this.currentVersion, + this.newVersion, { + super.key, + this.canIgnore = true, + }); + + final InstalledVersionInfo currentVersion; + final RemoteVersionInfo newVersion; + final bool canIgnore; + + Future show(BuildContext context) { + return showDialog( + context: context, + builder: (context) => this, + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final t = ref.watch(translationsProvider); + final theme = Theme.of(context); + + return AlertDialog( + title: Text(t.appUpdate.dialogTitle.titleCase), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t.appUpdate.updateMsg), + const Gap(8), + Text.rich( + TextSpan( + children: [ + TextSpan( + text: "${t.appUpdate.currentVersionLbl}: ", + style: theme.textTheme.bodySmall, + ), + TextSpan( + text: currentVersion.fullVersion, + style: theme.textTheme.labelMedium, + ), + ], + ), + ), + Text.rich( + TextSpan( + children: [ + TextSpan( + text: "${t.appUpdate.newVersionLbl}: ", + style: theme.textTheme.bodySmall, + ), + TextSpan( + text: newVersion.fullVersion, + style: theme.textTheme.labelMedium, + ), + ], + ), + ), + ], + ), + actions: [ + if (canIgnore) + TextButton( + onPressed: () { + // TODO add prefs for ignoring version + context.pop(); + }, + child: Text(t.appUpdate.ignoreBtnTxt.titleCase), + ), + TextButton( + onPressed: context.pop, + child: Text(t.appUpdate.laterBtnTxt.titleCase), + ), + TextButton( + onPressed: () async { + await launchUrl( + Uri.parse(Constants.githubLatestReleaseUrl), + mode: LaunchMode.externalApplication, + ); + }, + child: Text(t.appUpdate.updateNowBtnTxt.titleCase), + ), + ], + ); + } +} diff --git a/lib/features/common/runtime_details.dart b/lib/features/common/runtime_details.dart index f40aa84c..bd61d380 100644 --- a/lib/features/common/runtime_details.dart +++ b/lib/features/common/runtime_details.dart @@ -1,39 +1,78 @@ import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:hiddify/data/data_providers.dart'; +import 'package:hiddify/domain/app/app.dart'; import 'package:hiddify/utils/utils.dart'; -import 'package:package_info_plus/package_info_plus.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'runtime_details.freezed.dart'; part 'runtime_details.g.dart'; -// TODO implement clash version +// TODO add clash version @Riverpod(keepAlive: true) class RuntimeDetailsNotifier extends _$RuntimeDetailsNotifier with AppLogger { @override Future build() async { - final packageInfo = await PackageInfo.fromPlatform(); - return RuntimeDetails( - version: packageInfo.version, - buildNumber: packageInfo.buildNumber, - installerStore: packageInfo.installerStore, - clashVersion: "", - ); + final appVersion = await ref + .watch(updateRepositoryProvider) + .getCurrentVersion() + .getOrElse((l) => throw l) + .run(); + return RuntimeDetails(appVersion: appVersion); + } + + Future checkForUpdates() async { + if (state case AsyncData(:final value)) { + switch (value.latestVersion) { + case AsyncLoading(): + return; + default: + loggy.debug("checking for updates"); + state = + AsyncData(value.copyWith(latestVersion: const AsyncLoading())); + // TODO use prefs + const includePreReleases = true; + await ref + .read(updateRepositoryProvider) + .getLatestVersion(includePreReleases: includePreReleases) + .match( + (l) { + loggy.warning("failed to get latest version, $l"); + state = AsyncData( + value.copyWith( + latestVersion: AsyncError(l, StackTrace.current), + ), + ); + }, + (r) { + state = AsyncData( + value.copyWith(latestVersion: AsyncData(r)), + ); + }, + ).run(); + } + } } } +@Riverpod(keepAlive: true) +AsyncValue appVersion(AppVersionRef ref) => ref.watch( + runtimeDetailsNotifierProvider + .select((value) => value.whenData((value) => value.appVersion)), + ); + @freezed class RuntimeDetails with _$RuntimeDetails { const RuntimeDetails._(); const factory RuntimeDetails({ - required String version, - required String buildNumber, - String? installerStore, - required String clashVersion, + required InstalledVersionInfo appVersion, + @Default(AsyncData(null)) AsyncValue latestVersion, }) = _RuntimeDetails; - String get fullVersion => version + buildNumber; - - factory RuntimeDetails.fromJson(Map json) => - _$RuntimeDetailsFromJson(json); + bool get newVersionAvailable => latestVersion.maybeWhen( + data: (data) => + data != null && + data.fullVersion.compareTo(this.appVersion.fullVersion) > 0, + orElse: () => false, + ); } diff --git a/lib/features/home/view/home_page.dart b/lib/features/home/view/home_page.dart index f3620844..a6a9e0a0 100644 --- a/lib/features/home/view/home_page.dart +++ b/lib/features/home/view/home_page.dart @@ -98,7 +98,7 @@ class AppVersionLabel extends HookConsumerWidget { final theme = Theme.of(context); final version = ref.watch( - runtimeDetailsNotifierProvider.select( + appVersionProvider.select( (value) => switch (value) { AsyncData(:final value) => value.fullVersion, _ => "",