diff --git a/assets/translations/strings.i18n.json b/assets/translations/strings.i18n.json index e47e1e0e..417e7810 100644 --- a/assets/translations/strings.i18n.json +++ b/assets/translations/strings.i18n.json @@ -195,6 +195,7 @@ "termsAndConditions": "Terms and conditions" }, "appUpdate": { + "notAvailableMsg": "Already using the latest version", "dialogTitle": "Update Available", "updateMsg": "A new version of @:general.appTitle is available. Would you like to update now?", "currentVersionLbl": "Current Version", diff --git a/assets/translations/strings_fa.i18n.json b/assets/translations/strings_fa.i18n.json index c0b727a7..30b46f35 100644 --- a/assets/translations/strings_fa.i18n.json +++ b/assets/translations/strings_fa.i18n.json @@ -195,6 +195,7 @@ "termsAndConditions": "شرایط و ضوابط استفاده" }, "appUpdate": { + "notAvailableMsg": "نسخه جدیدی یافت نشد", "dialogTitle": "نسخه جدید موجود است", "updateMsg": "نسخه جدیدی از @:general.appTitle موجود است! الان بروزرسانی شود؟", "currentVersionLbl": "نسخه فعلی", diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index 721e693e..2607ddc8 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -135,6 +135,7 @@ Future initAppServices( await Future.wait( [ read(singboxServiceProvider).init(), + read(cronServiceProvider).startScheduler(), ], ); _logger.debug('initialized app services'); diff --git a/lib/core/prefs/general_prefs.dart b/lib/core/prefs/general_prefs.dart index 82cd1fa2..ef787669 100644 --- a/lib/core/prefs/general_prefs.dart +++ b/lib/core/prefs/general_prefs.dart @@ -74,6 +74,23 @@ class EnableAnalytics extends _$EnableAnalytics { } } +@Riverpod(keepAlive: true) +class CheckForPreReleaseUpdates extends _$CheckForPreReleaseUpdates { + late final _pref = Pref( + ref.watch(sharedPreferencesProvider), + "check_for_pre_release_updates", + true, + ); + + @override + bool build() => _pref.getValue(); + + Future update(bool value) { + state = value; + return _pref.update(value); + } +} + @Riverpod(keepAlive: true) class DebugModeNotifier extends _$DebugModeNotifier { late final _pref = Pref( diff --git a/lib/features/about/view/about_page.dart b/lib/features/about/view/about_page.dart index 98fbe248..ceea789f 100644 --- a/lib/features/about/view/about_page.dart +++ b/lib/features/about/view/about_page.dart @@ -22,16 +22,19 @@ class AboutPage extends HookConsumerWidget { ref.listen( appUpdateNotifierProvider, (_, next) async { + if (!context.mounted) return; switch (next) { - case AsyncData(value: final remoteVersion?): - await NewVersionDialog( + case AppUpdateStateAvailable(:final versionInfo): + return NewVersionDialog( appInfo.presentVersion, - remoteVersion, + versionInfo, canIgnore: false, ).show(context); - case AsyncError(:final error): - if (!context.mounted) return; - CustomToast.error(t.printError(error)).show(context); + case AppUpdateStateError(:final error): + return CustomToast.error(t.printError(error)).show(context); + case AppUpdateStateNotAvailable(): + return CustomToast.success(t.appUpdate.notAvailableMsg) + .show(context); } }, ); @@ -91,15 +94,18 @@ class AboutPage extends HookConsumerWidget { if (appInfo.release.allowCustomUpdateChecker) ListTile( title: Text(t.about.checkForUpdate), - trailing: appUpdate.isLoading - ? const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(), - ) - : const Icon(Icons.update), - onTap: () { - ref.invalidate(appUpdateNotifierProvider); + trailing: switch (appUpdate) { + AppUpdateStateChecking() => const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(), + ), + _ => const Icon(Icons.update), + }, + onTap: () async { + await ref + .read(appUpdateNotifierProvider.notifier) + .check(); }, ), ListTile( diff --git a/lib/features/common/app_update_notifier.dart b/lib/features/common/app_update_notifier.dart index 83038007..7b0864b9 100644 --- a/lib/features/common/app_update_notifier.dart +++ b/lib/features/common/app_update_notifier.dart @@ -1,43 +1,90 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hiddify/core/core_providers.dart'; +import 'package:hiddify/core/prefs/prefs.dart'; +import 'package:hiddify/core/router/routes/routes.dart'; import 'package:hiddify/data/data_providers.dart'; import 'package:hiddify/domain/app/app.dart'; +import 'package:hiddify/features/common/new_version_dialog.dart'; +import 'package:hiddify/services/service_providers.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +part 'app_update_notifier.freezed.dart'; part 'app_update_notifier.g.dart'; +@freezed +class AppUpdateState with _$AppUpdateState { + const factory AppUpdateState.initial() = AppUpdateStateInitial; + const factory AppUpdateState.disabled() = AppUpdateStateDisabled; + const factory AppUpdateState.checking() = AppUpdateStateChecking; + const factory AppUpdateState.error(AppFailure error) = AppUpdateStateError; + const factory AppUpdateState.available(RemoteVersionInfo versionInfo) = + AppUpdateStateAvailable; + const factory AppUpdateState.notAvailable() = AppUpdateStateNotAvailable; +} + @Riverpod(keepAlive: true) class AppUpdateNotifier extends _$AppUpdateNotifier with AppLogger { @override - Future build() async { + AppUpdateState build() { + _schedule(); + return const AppUpdateState.initial(); + } + + Future check() async { loggy.debug("checking for update"); + state = const AppUpdateState.checking(); final appInfo = ref.watch(appInfoProvider); // TODO use market-specific update checkers if (!appInfo.release.allowCustomUpdateChecker) { loggy.debug( "custom update checkers are not allowed for [${appInfo.release.name}] release", ); - return null; + return state = const AppUpdateState.disabled(); } final currentVersion = appInfo.version; return ref .watch(appRepositoryProvider) - .getLatestVersion(includePreReleases: true) + .getLatestVersion( + includePreReleases: ref.read(checkForPreReleaseUpdatesProvider), + ) .match( - (l) { - loggy.warning("failed to get latest version, $l"); - throw l; + (err) { + loggy.warning("failed to get latest version, $err"); + return state = AppUpdateState.error(err); }, (remote) { if (remote.version.compareTo(currentVersion) > 0) { loggy.info("new version available: $remote"); - return remote; + return state = AppUpdateState.available(remote); } loggy.info( "already using latest version[$currentVersion], remote: $remote", ); - return null; + return state = const AppUpdateState.notAvailable(); }, ).run(); } + + void _schedule() { + loggy.debug("scheduling app update checker"); + ref.watch(cronServiceProvider).schedule( + key: 'app_update', + duration: const Duration(hours: 4), + callback: () async { + await Future.delayed(const Duration(seconds: 5)); + final updateState = await check(); + final context = rootNavigatorKey.currentContext; + if (context != null && context.mounted) { + if (updateState + case AppUpdateStateAvailable(:final versionInfo)) { + await NewVersionDialog( + ref.read(appInfoProvider).presentVersion, + versionInfo, + ).show(context); + } + } + }, + ); + } } diff --git a/lib/features/common/common_controllers.dart b/lib/features/common/common_controllers.dart index 7cd23a0a..d94102b3 100644 --- a/lib/features/common/common_controllers.dart +++ b/lib/features/common/common_controllers.dart @@ -1,3 +1,4 @@ +import 'package:hiddify/features/common/app_update_notifier.dart'; import 'package:hiddify/features/common/connectivity/connectivity_controller.dart'; import 'package:hiddify/features/common/window/window_controller.dart'; import 'package:hiddify/features/logs/notifier/notifier.dart'; @@ -20,6 +21,11 @@ void commonControllers(CommonControllersRef ref) { (previous, next) {}, fireImmediately: true, ); + ref.listen( + appUpdateNotifierProvider, + (previous, next) {}, + fireImmediately: true, + ); if (PlatformUtils.isDesktop) { ref.listen( windowControllerProvider, diff --git a/lib/services/cron_service.dart b/lib/services/cron_service.dart new file mode 100644 index 00000000..fc65a513 --- /dev/null +++ b/lib/services/cron_service.dart @@ -0,0 +1,74 @@ +import 'dart:async'; + +import 'package:dartx/dartx.dart'; +import 'package:hiddify/utils/custom_loggers.dart'; +import 'package:neat_periodic_task/neat_periodic_task.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const _cronKeyPrefix = "cron_"; + +typedef Job = ( + String key, + Duration duration, + FutureOr Function() callback, +); + +class CronService with InfraLogger { + CronService(this.prefs); + + final SharedPreferences prefs; + + NeatPeriodicTaskScheduler? _scheduler; + Map jobs = {}; + + void schedule({ + required String key, + required Duration duration, + required FutureOr Function() callback, + }) { + loggy.debug("scheduling [$key]"); + jobs[key] = (key, duration, callback); + _scheduler?.trigger(); + } + + Future run(Job job) async { + final key = job.$1; + final prefKey = "$_cronKeyPrefix$key"; + final previousRunTime = DateTime.tryParse(prefs.getString(prefKey) ?? ""); + loggy.debug( + "[$key] > ${previousRunTime == null ? "first run" : "previous run on [$previousRunTime]"}", + ); + + if (previousRunTime != null && + previousRunTime.add(job.$2) > DateTime.now()) { + loggy.debug("[$key] > didn't meet criteria"); + return; + } + + final result = await job.$3(); + await prefs.setString(prefKey, DateTime.now().toIso8601String()); + return result; + } + + Future startScheduler() async { + loggy.debug("starting job scheduler"); + await _scheduler?.stop(); + int runCount = 0; + _scheduler = NeatPeriodicTaskScheduler( + name: "cron job scheduler", + interval: const Duration(minutes: 5), + timeout: const Duration(seconds: 15), + minCycle: const Duration(minutes: 1), + task: () { + loggy.debug("in run ${runCount++}"); + return Future.wait(jobs.values.map(run)); + }, + ); + _scheduler!.start(); + } + + Future stopScheduler() async { + loggy.debug("stopping job scheduler"); + return _scheduler?.stop(); + } +} diff --git a/lib/services/service_providers.dart b/lib/services/service_providers.dart index 187e04bb..8b01ab9c 100644 --- a/lib/services/service_providers.dart +++ b/lib/services/service_providers.dart @@ -1,3 +1,5 @@ +import 'package:hiddify/data/data_providers.dart'; +import 'package:hiddify/services/cron_service.dart'; import 'package:hiddify/services/files_editor_service.dart'; import 'package:hiddify/services/platform_settings.dart'; import 'package:hiddify/services/singbox/singbox_service.dart'; @@ -15,3 +17,10 @@ SingboxService singboxService(SingboxServiceRef ref) => SingboxService(); @riverpod PlatformSettings platformSettings(PlatformSettingsRef ref) => PlatformSettings(); + +@Riverpod(keepAlive: true) +CronService cronService(CronServiceRef ref) { + final service = CronService(ref.watch(sharedPreferencesProvider)); + ref.onDispose(() => service.stopScheduler()); + return service; +} diff --git a/pubspec.lock b/pubspec.lock index 3ee33511..9b271e6b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -821,6 +821,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.1" + neat_periodic_task: + dependency: "direct main" + description: + name: neat_periodic_task + sha256: e0dda74c996781e154f6145028dbacbcd9dbef242f5a140fa774e39381c2bf97 + url: "https://pub.dev" + source: hosted + version: "2.0.1" package_config: dependency: transitive description: @@ -1013,6 +1021,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + retry: + dependency: transitive + description: + name: retry + sha256: "822e118d5b3aafed083109c72d5f484c6dc66707885e07c0fbcb8b986bba7efc" + url: "https://pub.dev" + source: hosted + version: "3.1.2" riverpod: dependency: transitive description: @@ -1234,6 +1250,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.12" + slugid: + dependency: transitive + description: + name: slugid + sha256: e0cc54637b666c9c590f0d76df76e5e2bbf6234ae398a182aac82fd70ddd60ab + url: "https://pub.dev" + source: hosted + version: "1.1.2" source_gen: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4273cc9e..960cef0b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -71,6 +71,7 @@ dependencies: uuid: ^3.0.7 tint: ^2.0.1 accessibility_tools: ^1.0.0 + neat_periodic_task: ^2.0.1 # widgets go_router: ^10.1.2