diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index 7f4c42fe..465d22d6 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -10,9 +10,10 @@ import 'package:hiddify/core/prefs/prefs.dart'; import 'package:hiddify/data/data_providers.dart'; import 'package:hiddify/data/repository/app_repository_impl.dart'; import 'package:hiddify/domain/environment.dart'; -import 'package:hiddify/features/common/active_profile/active_profile_notifier.dart'; import 'package:hiddify/features/common/window/window_controller.dart'; import 'package:hiddify/features/geo_asset/data/geo_asset_data_providers.dart'; +import 'package:hiddify/features/profile/data/profile_data_providers.dart'; +import 'package:hiddify/features/profile/notifier/active_profile_notifier.dart'; import 'package:hiddify/features/system_tray/system_tray_controller.dart'; import 'package:hiddify/services/auto_start_service.dart'; import 'package:hiddify/services/deep_link_service.dart'; @@ -88,6 +89,7 @@ Future _lazyBootstrap( final filesEditor = container.read(filesEditorServiceProvider); await filesEditor.init(); await container.read(geoAssetRepositoryProvider.future); + await container.read(profileRepositoryProvider.future); initLoggers(container.read, debug); _logger.info(container.read(appInfoProvider).format()); diff --git a/lib/core/notification/in_app_notification_controller.dart b/lib/core/notification/in_app_notification_controller.dart new file mode 100644 index 00000000..1894e13e --- /dev/null +++ b/lib/core/notification/in_app_notification_controller.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:hiddify/domain/failures.dart'; +import 'package:hiddify/features/common/adaptive_root_scaffold.dart'; +import 'package:hiddify/utils/utils.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:toastification/toastification.dart'; + +part 'in_app_notification_controller.g.dart'; + +@Riverpod(keepAlive: true) +InAppNotificationController inAppNotificationController( + InAppNotificationControllerRef ref, +) { + return InAppNotificationController(); +} + +enum NotificationType { + info, + error, + success, +} + +class InAppNotificationController with AppLogger { + void showToast( + BuildContext context, + String message, { + NotificationType type = NotificationType.info, + Duration duration = const Duration(seconds: 3), + }) { + toastification.show( + context: context, + title: message, + type: type._toastificationType, + alignment: Alignment.bottomLeft, + autoCloseDuration: duration, + style: ToastificationStyle.fillColored, + pauseOnHover: true, + showProgressBar: false, + dragToClose: true, + closeOnClick: true, + closeButtonShowType: CloseButtonShowType.onHover, + ); + } + + void showErrorToast(String message) { + final context = RootScaffold.stateKey.currentContext; + if (context == null) { + loggy.warning("context is null"); + return; + } + showToast( + context, + message, + type: NotificationType.error, + duration: const Duration(seconds: 5), + ); + } + + void showSuccessToast(String message) { + final context = RootScaffold.stateKey.currentContext; + if (context == null) { + loggy.warning("context is null"); + return; + } + showToast( + context, + message, + type: NotificationType.success, + ); + } + + void showInfoToast(String message) { + final context = RootScaffold.stateKey.currentContext; + if (context == null) { + loggy.warning("context is null"); + return; + } + showToast(context, message); + } + + Future showErrorDialog(PresentableError error) async { + final context = RootScaffold.stateKey.currentContext; + if (context == null) { + loggy.warning("context is null"); + return; + } + CustomAlertDialog.fromErr(error).show(context); + } +} + +extension NotificationTypeX on NotificationType { + ToastificationType get _toastificationType => switch (this) { + NotificationType.success => ToastificationType.success, + NotificationType.error => ToastificationType.error, + NotificationType.info => ToastificationType.info, + }; +} diff --git a/lib/core/router/routes/shared_routes.dart b/lib/core/router/routes/shared_routes.dart index 76410f5f..1960b7cc 100644 --- a/lib/core/router/routes/shared_routes.dart +++ b/lib/core/router/routes/shared_routes.dart @@ -3,8 +3,9 @@ import 'package:go_router/go_router.dart'; import 'package:hiddify/core/router/app_router.dart'; import 'package:hiddify/features/home/view/view.dart'; import 'package:hiddify/features/intro/intro_page.dart'; -import 'package:hiddify/features/profile_detail/view/view.dart'; -import 'package:hiddify/features/profiles/view/view.dart'; +import 'package:hiddify/features/profile/add/add_profile_modal.dart'; +import 'package:hiddify/features/profile/details/profile_details_page.dart'; +import 'package:hiddify/features/profile/overview/profiles_overview_page.dart'; import 'package:hiddify/features/proxies/view/view.dart'; import 'package:hiddify/utils/utils.dart'; @@ -86,7 +87,8 @@ class ProfilesRoute extends GoRouteData { Page buildPage(BuildContext context, GoRouterState state) { return BottomSheetPage( name: name, - builder: (controller) => ProfilesModal(scrollController: controller), + builder: (controller) => + ProfilesOverviewModal(scrollController: controller), ); } } @@ -103,7 +105,7 @@ class NewProfileRoute extends GoRouteData { return const MaterialPage( fullscreenDialog: true, name: name, - child: ProfileDetailPage("new"), + child: ProfileDetailsPage("new"), ); } } @@ -121,7 +123,7 @@ class ProfileDetailsRoute extends GoRouteData { return MaterialPage( fullscreenDialog: true, name: name, - child: ProfileDetailPage(id), + child: ProfileDetailsPage(id), ); } } diff --git a/lib/core/widget/custom_alert_dialog.dart b/lib/core/widget/custom_alert_dialog.dart new file mode 100644 index 00000000..ad96ed1f --- /dev/null +++ b/lib/core/widget/custom_alert_dialog.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:hiddify/domain/failures.dart'; + +class CustomAlertDialog extends StatelessWidget { + const CustomAlertDialog({ + super.key, + this.title, + required this.message, + }); + + final String? title; + final String message; + + factory CustomAlertDialog.fromError(PresentableError error) => + CustomAlertDialog( + title: error.message == null ? null : error.type, + message: error.message ?? error.type, + ); + + Future show(BuildContext context) async { + await showDialog( + context: context, + useRootNavigator: true, + builder: (context) => this, + ); + } + + @override + Widget build(BuildContext context) { + final localizations = MaterialLocalizations.of(context); + + return AlertDialog( + title: title != null ? Text(title!) : null, + content: SingleChildScrollView( + child: SizedBox( + width: 468, + child: Text(message), + ), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text(localizations.okButtonLabel), + ), + ], + ); + } +} diff --git a/lib/data/data_providers.dart b/lib/data/data_providers.dart index 8ffd3327..d70bef5b 100644 --- a/lib/data/data_providers.dart +++ b/lib/data/data_providers.dart @@ -4,7 +4,6 @@ import 'package:dio/dio.dart'; import 'package:hiddify/core/core_providers.dart'; import 'package:hiddify/core/prefs/general_prefs.dart'; import 'package:hiddify/data/api/clash_api.dart'; -import 'package:hiddify/data/local/dao/profiles_dao.dart'; import 'package:hiddify/data/local/database.dart'; import 'package:hiddify/data/repository/app_repository_impl.dart'; import 'package:hiddify/data/repository/config_options_store.dart'; @@ -12,9 +11,9 @@ import 'package:hiddify/data/repository/repository.dart'; import 'package:hiddify/domain/app/app.dart'; import 'package:hiddify/domain/constants.dart'; import 'package:hiddify/domain/core_facade.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; import 'package:hiddify/domain/singbox/singbox.dart'; import 'package:hiddify/features/geo_asset/data/geo_asset_data_providers.dart'; +import 'package:hiddify/features/profile/data/profile_data_providers.dart'; import 'package:hiddify/services/service_providers.dart'; import 'package:native_dio_adapter/native_dio_adapter.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -48,20 +47,6 @@ Dio dio(DioRef ref) { return dio; } -@Riverpod(keepAlive: true) -ProfilesDao profilesDao(ProfilesDaoRef ref) => ProfilesDao( - ref.watch(appDatabaseProvider), - ); - -@Riverpod(keepAlive: true) -ProfilesRepository profilesRepository(ProfilesRepositoryRef ref) => - ProfilesRepositoryImpl( - profilesDao: ref.watch(profilesDaoProvider), - filesEditor: ref.watch(filesEditorServiceProvider), - singbox: ref.watch(coreFacadeProvider), - dio: ref.watch(dioProvider), - ); - @Riverpod(keepAlive: true) AppRepository appRepository(AppRepositoryRef ref) => AppRepositoryImpl(ref.watch(dioProvider)); @@ -99,6 +84,7 @@ CoreFacade coreFacade(CoreFacadeRef ref) => CoreFacadeImpl( ref.watch(singboxServiceProvider), ref.watch(filesEditorServiceProvider), ref.watch(geoAssetPathResolverProvider), + ref.watch(profilePathResolverProvider), ref.watch(platformServicesProvider), ref.watch(clashApiProvider), ref.read(debugModeNotifierProvider), diff --git a/lib/data/local/data_mappers.dart b/lib/data/local/data_mappers.dart deleted file mode 100644 index 0646a749..00000000 --- a/lib/data/local/data_mappers.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:drift/drift.dart'; -import 'package:hiddify/data/local/database.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; - -extension ProfileMapper on Profile { - ProfileEntriesCompanion toCompanion() { - return switch (this) { - RemoteProfile(:final url, :final options, :final subInfo) => - ProfileEntriesCompanion.insert( - id: id, - type: ProfileType.remote, - active: active, - name: name, - url: Value(url), - lastUpdate: lastUpdate, - updateInterval: Value(options?.updateInterval), - upload: Value(subInfo?.upload), - download: Value(subInfo?.download), - total: Value(subInfo?.total), - expire: Value(subInfo?.expire), - webPageUrl: Value(subInfo?.webPageUrl), - supportUrl: Value(subInfo?.supportUrl), - ), - LocalProfile() => ProfileEntriesCompanion.insert( - id: id, - type: ProfileType.local, - active: active, - name: name, - lastUpdate: lastUpdate, - ), - }; - } - - static Profile fromEntry(ProfileEntry e) { - ProfileOptions? options; - if (e.updateInterval != null) { - options = ProfileOptions(updateInterval: e.updateInterval!); - } - - SubscriptionInfo? subInfo; - if (e.upload != null && - e.download != null && - e.total != null && - e.expire != null) { - subInfo = SubscriptionInfo( - upload: e.upload!, - download: e.download!, - total: e.total!, - expire: e.expire!, - webPageUrl: e.webPageUrl, - supportUrl: e.supportUrl, - ); - } - - return switch (e.type) { - ProfileType.remote => RemoteProfile( - id: e.id, - active: e.active, - name: e.name, - url: e.url!, - lastUpdate: e.lastUpdate, - options: options, - subInfo: subInfo, - ), - ProfileType.local => LocalProfile( - id: e.id, - active: e.active, - name: e.name, - lastUpdate: e.lastUpdate, - ), - }; - } -} diff --git a/lib/data/local/database.dart b/lib/data/local/database.dart index f1ecaea9..e18897d6 100644 --- a/lib/data/local/database.dart +++ b/lib/data/local/database.dart @@ -5,10 +5,10 @@ import 'package:drift/native.dart'; import 'package:hiddify/data/local/schema_versions.dart'; import 'package:hiddify/data/local/tables.dart'; import 'package:hiddify/data/local/type_converters.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; import 'package:hiddify/features/geo_asset/data/geo_asset_data_mapper.dart'; import 'package:hiddify/features/geo_asset/model/default_geo_assets.dart'; import 'package:hiddify/features/geo_asset/model/geo_asset_entity.dart'; +import 'package:hiddify/features/profile/model/profile_entity.dart'; import 'package:hiddify/services/files_editor_service.dart'; import 'package:path/path.dart' as p; diff --git a/lib/data/local/tables.dart b/lib/data/local/tables.dart index f8a09291..ab1d9a59 100644 --- a/lib/data/local/tables.dart +++ b/lib/data/local/tables.dart @@ -1,7 +1,7 @@ import 'package:drift/drift.dart'; import 'package:hiddify/data/local/type_converters.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; import 'package:hiddify/features/geo_asset/model/geo_asset_entity.dart'; +import 'package:hiddify/features/profile/model/profile_entity.dart'; @DataClassName('ProfileEntry') class ProfileEntries extends Table { diff --git a/lib/data/repository/core_facade_impl.dart b/lib/data/repository/core_facade_impl.dart index 15bdde09..be953ec1 100644 --- a/lib/data/repository/core_facade_impl.dart +++ b/lib/data/repository/core_facade_impl.dart @@ -11,6 +11,7 @@ import 'package:hiddify/domain/core_facade.dart'; import 'package:hiddify/domain/core_service_failure.dart'; import 'package:hiddify/domain/singbox/singbox.dart'; import 'package:hiddify/features/geo_asset/data/geo_asset_path_resolver.dart'; +import 'package:hiddify/features/profile/data/profile_path_resolver.dart'; import 'package:hiddify/services/files_editor_service.dart'; import 'package:hiddify/services/platform_services.dart'; import 'package:hiddify/services/singbox/singbox_service.dart'; @@ -21,6 +22,7 @@ class CoreFacadeImpl with ExceptionHandler, InfraLogger implements CoreFacade { this.singbox, this.filesEditor, this.geoAssetPathResolver, + this.profilePathResolver, this.platformServices, this.clash, this.debug, @@ -30,6 +32,7 @@ class CoreFacadeImpl with ExceptionHandler, InfraLogger implements CoreFacade { final SingboxService singbox; final FilesEditorService filesEditor; final GeoAssetPathResolver geoAssetPathResolver; + final ProfilePathResolver profilePathResolver; final PlatformServices platformServices; final ClashApi clash; final bool debug; @@ -115,12 +118,14 @@ class CoreFacadeImpl with ExceptionHandler, InfraLogger implements CoreFacade { ) { return TaskEither.Do( ($) async { - final configPath = filesEditor.configPath(fileName); + final configFile = profilePathResolver.file(fileName); final options = await $(_getConfigOptions()); await $(setup()); await $(changeConfigOptions(options)); return await $( - singbox.generateConfig(configPath).mapLeft(CoreServiceFailure.other), + singbox + .generateConfig(configFile.path) + .mapLeft(CoreServiceFailure.other), ); }, ).handleExceptions(CoreServiceFailure.unexpected); @@ -133,7 +138,7 @@ class CoreFacadeImpl with ExceptionHandler, InfraLogger implements CoreFacade { ) { return TaskEither.Do( ($) async { - final configPath = filesEditor.configPath(fileName); + final configFile = profilePathResolver.file(fileName); final options = await $(_getConfigOptions()); loggy.info( "config options: ${options.format()}\nMemory Limit: ${!disableMemoryLimit}", @@ -155,7 +160,7 @@ class CoreFacadeImpl with ExceptionHandler, InfraLogger implements CoreFacade { await $(changeConfigOptions(options)); return await $( singbox - .start(configPath, disableMemoryLimit) + .start(configFile.path, disableMemoryLimit) .mapLeft(CoreServiceFailure.start), ); }, @@ -177,12 +182,12 @@ class CoreFacadeImpl with ExceptionHandler, InfraLogger implements CoreFacade { ) { return exceptionHandler( () async { - final configPath = filesEditor.configPath(fileName); + final configFile = profilePathResolver.file(fileName); return _getConfigOptions() .flatMap((options) => changeConfigOptions(options)) .andThen( () => singbox - .restart(configPath, disableMemoryLimit) + .restart(configFile.path, disableMemoryLimit) .mapLeft(CoreServiceFailure.start), ) .run(); diff --git a/lib/data/repository/repository.dart b/lib/data/repository/repository.dart index 4f454cc4..a5687644 100644 --- a/lib/data/repository/repository.dart +++ b/lib/data/repository/repository.dart @@ -1,2 +1 @@ export 'core_facade_impl.dart'; -export 'profiles_repository_impl.dart'; diff --git a/lib/domain/profiles/profile.dart b/lib/domain/profiles/profile.dart deleted file mode 100644 index c1f826e3..00000000 --- a/lib/domain/profiles/profile.dart +++ /dev/null @@ -1,174 +0,0 @@ -import 'dart:convert'; - -import 'package:dartx/dartx.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:hiddify/utils/utils.dart'; -import 'package:loggy/loggy.dart'; -import 'package:uuid/uuid.dart'; - -part 'profile.freezed.dart'; -part 'profile.g.dart'; - -final _loggy = Loggy('Profile'); - -enum ProfileType { remote, local } - -@freezed -sealed class Profile with _$Profile { - const Profile._(); - - const factory Profile.remote({ - required String id, - required bool active, - required String name, - required String url, - required DateTime lastUpdate, - ProfileOptions? options, - SubscriptionInfo? subInfo, - }) = RemoteProfile; - - const factory Profile.local({ - required String id, - required bool active, - required String name, - required DateTime lastUpdate, - }) = LocalProfile; - - // ignore: prefer_constructors_over_static_methods - static RemoteProfile fromResponse( - String url, - Map> headers, - ) { - _loggy.debug("Profile Headers: $headers"); - - final titleHeader = headers['profile-title']?.single; - var title = ''; - if (titleHeader != null) { - if (titleHeader.startsWith("base64:")) { - // TODO handle errors - title = - utf8.decode(base64.decode(titleHeader.replaceFirst("base64:", ""))); - } else { - title = titleHeader; - } - } - - if (title.isEmpty) { - final contentDisposition = headers['content-disposition']?.single; - if (contentDisposition != null) { - final RegExp regExp = RegExp('filename="([^"]*)"'); - final match = regExp.firstMatch(contentDisposition); - if (match != null && match.groupCount >= 1) { - title = match.group(1) ?? ''; - } - } - } - if (title.isEmpty) { - final part = url.split("#").lastOrNull; - if (part != null) { - title = part; - } - } - if (title.isEmpty) { - final part = url.split("/").lastOrNull; - if (part != null) { - final pattern = RegExp(r"\.(json|yaml|yml|txt)[\s\S]*"); - title = part.replaceFirst(pattern, ""); - } - } - - final updateIntervalHeader = headers['profile-update-interval']?.single; - ProfileOptions? options; - if (updateIntervalHeader != null) { - final updateInterval = Duration(hours: int.parse(updateIntervalHeader)); - options = ProfileOptions(updateInterval: updateInterval); - } - - final subscriptionInfoHeader = headers['subscription-userinfo']?.single; - SubscriptionInfo? subInfo; - if (subscriptionInfoHeader != null) { - subInfo = SubscriptionInfo.fromResponseHeader(subscriptionInfoHeader); - } - - final webPageUrlHeader = headers['profile-web-page-url']?.single; - final supportUrlHeader = headers['support-url']?.single; - if (subInfo != null) { - subInfo = subInfo.copyWith( - webPageUrl: isUrl(webPageUrlHeader ?? "") ? webPageUrlHeader : null, - supportUrl: isUrl(supportUrlHeader ?? "") ? supportUrlHeader : null, - ); - } - - return RemoteProfile( - id: const Uuid().v4(), - active: false, - name: title.isBlank ? "Remote Profile" : title, - url: url, - lastUpdate: DateTime.now(), - options: options, - subInfo: subInfo, - ); - } - - factory Profile.fromJson(Map json) => - _$ProfileFromJson(json); -} - -@freezed -class ProfileOptions with _$ProfileOptions { - const factory ProfileOptions({ - required Duration updateInterval, - }) = _ProfileOptions; - - factory ProfileOptions.fromJson(Map json) => - _$ProfileOptionsFromJson(json); -} - -@freezed -class SubscriptionInfo with _$SubscriptionInfo { - const SubscriptionInfo._(); - - const factory SubscriptionInfo({ - required int upload, - required int download, - @JsonKey(fromJson: _fromJsonTotal, defaultValue: 9223372036854775807) - required int total, - @JsonKey(fromJson: _dateTimeFromSecondsSinceEpoch) required DateTime expire, - String? webPageUrl, - String? supportUrl, - }) = _SubscriptionInfo; - - bool get isExpired => expire <= DateTime.now(); - - int get consumption => upload + download; - - double get ratio => (consumption / total).clamp(0, 1); - - Duration get remaining => expire.difference(DateTime.now()); - - factory SubscriptionInfo.fromResponseHeader(String header) { - final values = header.split(';'); - final map = { - for (final v in values) - v.split('=').first.trim(): - num.tryParse(v.split('=').second.trim())?.toInt(), - }; - _loggy.debug("Subscription Info: $map"); - return SubscriptionInfo.fromJson(map); - } - - factory SubscriptionInfo.fromJson(Map json) => - _$SubscriptionInfoFromJson(json); -} - -int _fromJsonTotal(dynamic total) { - final totalInt = total as int? ?? -1; - return totalInt > 0 ? totalInt : 9223372036854775807; -} - -DateTime _dateTimeFromSecondsSinceEpoch(dynamic expire) { - final expireInt = expire as int? ?? -1; - return DateTime.fromMillisecondsSinceEpoch( - (expireInt > 0 ? expireInt : 92233720368) * 1000, - ); -} diff --git a/lib/domain/profiles/profiles.dart b/lib/domain/profiles/profiles.dart deleted file mode 100644 index fb63afe8..00000000 --- a/lib/domain/profiles/profiles.dart +++ /dev/null @@ -1,4 +0,0 @@ -export 'profile.dart'; -export 'profile_enums.dart'; -export 'profiles_failure.dart'; -export 'profiles_repository.dart'; diff --git a/lib/domain/profiles/profiles_repository.dart b/lib/domain/profiles/profiles_repository.dart deleted file mode 100644 index 7476d2a2..00000000 --- a/lib/domain/profiles/profiles_repository.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:fpdart/fpdart.dart'; -import 'package:hiddify/domain/enums.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; - -abstract class ProfilesRepository { - TaskEither get(String id); - - Stream> watchActiveProfile(); - - Stream> watchHasAnyProfile(); - - Stream>> watchAll({ - ProfilesSort sort = ProfilesSort.lastUpdate, - SortMode mode = SortMode.ascending, - }); - - TaskEither addByUrl( - String url, { - bool markAsActive = false, - }); - - TaskEither addByContent( - String content, { - required String name, - bool markAsActive = false, - }); - - TaskEither add(RemoteProfile baseProfile); - - TaskEither update(RemoteProfile baseProfile); - - TaskEither edit(Profile profile); - - TaskEither setAsActive(String id); - - TaskEither delete(String id); -} diff --git a/lib/features/common/active_profile/active_profile_notifier.dart b/lib/features/common/active_profile/active_profile_notifier.dart deleted file mode 100644 index af11965d..00000000 --- a/lib/features/common/active_profile/active_profile_notifier.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:hiddify/data/data_providers.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; -import 'package:hiddify/utils/utils.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'active_profile_notifier.g.dart'; - -@Riverpod(keepAlive: true) -class ActiveProfile extends _$ActiveProfile with AppLogger { - @override - Stream build() { - loggy.debug("watching active profile"); - return ref - .watch(profilesRepositoryProvider) - .watchActiveProfile() - .map((event) => event.getOrElse((l) => throw l)); - } -} diff --git a/lib/features/common/active_profile/has_any_profile_notifier.dart b/lib/features/common/active_profile/has_any_profile_notifier.dart deleted file mode 100644 index 8ac28b21..00000000 --- a/lib/features/common/active_profile/has_any_profile_notifier.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:hiddify/data/data_providers.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'has_any_profile_notifier.g.dart'; - -@Riverpod(keepAlive: true) -Stream hasAnyProfile( - HasAnyProfileRef ref, -) { - return ref - .watch(profilesRepositoryProvider) - .watchHasAnyProfile() - .map((event) => event.getOrElse((l) => throw l)); -} diff --git a/lib/features/common/app_update_notifier.dart b/lib/features/common/app_update_notifier.dart index 669c718a..a3a39092 100644 --- a/lib/features/common/app_update_notifier.dart +++ b/lib/features/common/app_update_notifier.dart @@ -100,26 +100,4 @@ class AppUpdateNotifier extends _$AppUpdateNotifier with AppLogger { await _ignoreReleasePref.update(versionInfo.version); state = AppUpdateStateIgnored(versionInfo); } - - // Future _schedule() async { - // loggy.debug("scheduling app update checker"); - // return ref.read(cronServiceProvider).schedule( - // key: 'app_update', - // duration: const Duration(hours: 8), - // 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 3854b879..a8172a49 100644 --- a/lib/features/common/common_controllers.dart +++ b/lib/features/common/common_controllers.dart @@ -1,9 +1,8 @@ import 'package:hiddify/core/prefs/general_prefs.dart'; import 'package:hiddify/features/common/connectivity/connectivity_controller.dart'; import 'package:hiddify/features/common/window/window_controller.dart'; -import 'package:hiddify/features/profiles/notifier/notifier.dart'; +import 'package:hiddify/features/profile/notifier/profiles_update_notifier.dart'; import 'package:hiddify/features/system_tray/system_tray_controller.dart'; -import 'package:hiddify/services/service_providers.dart'; import 'package:hiddify/utils/platform_utils.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -17,7 +16,7 @@ void commonControllers(CommonControllersRef ref) { introCompletedProvider, (_, completed) async { if (completed) { - await ref.read(cronServiceProvider).startScheduler(); + await ref.read(foregroundProfilesUpdateNotifierProvider.future); } }, fireImmediately: true, @@ -27,11 +26,6 @@ void commonControllers(CommonControllersRef ref) { (previous, next) {}, fireImmediately: true, ); - ref.listen( - profilesUpdateNotifierProvider, - (previous, next) {}, - fireImmediately: true, - ); if (PlatformUtils.isDesktop) { ref.listen( windowControllerProvider, diff --git a/lib/features/common/connectivity/connectivity_controller.dart b/lib/features/common/connectivity/connectivity_controller.dart index cea73393..8f835b6b 100644 --- a/lib/features/common/connectivity/connectivity_controller.dart +++ b/lib/features/common/connectivity/connectivity_controller.dart @@ -3,7 +3,7 @@ import 'package:hiddify/core/prefs/service_prefs.dart'; import 'package:hiddify/data/data_providers.dart'; import 'package:hiddify/domain/connectivity/connectivity.dart'; import 'package:hiddify/domain/core_facade.dart'; -import 'package:hiddify/features/common/active_profile/active_profile_notifier.dart'; +import 'package:hiddify/features/profile/notifier/active_profile_notifier.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:rxdart/rxdart.dart'; @@ -25,7 +25,7 @@ class ConnectivityController extends _$ConnectivityController with AppLogger { }, ); return _core.watchConnectionStatus().doOnData((event) { - if (event case Disconnected(:final connectionFailure?) + if (event case Disconnected(connectionFailure: final _?) when PlatformUtils.isDesktop) { ref.read(startedByUserProvider.notifier).update(false); } diff --git a/lib/features/home/view/home_page.dart b/lib/features/home/view/home_page.dart index e3ed99ba..86512ad8 100644 --- a/lib/features/home/view/home_page.dart +++ b/lib/features/home/view/home_page.dart @@ -3,11 +3,10 @@ import 'package:flutter/material.dart'; import 'package:hiddify/core/core_providers.dart'; import 'package:hiddify/core/router/router.dart'; import 'package:hiddify/domain/failures.dart'; -import 'package:hiddify/features/common/active_profile/active_profile_notifier.dart'; -import 'package:hiddify/features/common/active_profile/has_any_profile_notifier.dart'; import 'package:hiddify/features/common/nested_app_bar.dart'; -import 'package:hiddify/features/common/profile_tile.dart'; import 'package:hiddify/features/home/widgets/widgets.dart'; +import 'package:hiddify/features/profile/notifier/active_profile_notifier.dart'; +import 'package:hiddify/features/profile/widget/profile_tile.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; diff --git a/lib/features/profiles/view/add_profile_modal.dart b/lib/features/profile/add/add_profile_modal.dart similarity index 80% rename from lib/features/profiles/view/add_profile_modal.dart rename to lib/features/profile/add/add_profile_modal.dart index a0ce2ecb..11217720 100644 --- a/lib/features/profiles/view/add_profile_modal.dart +++ b/lib/features/profile/add/add_profile_modal.dart @@ -5,10 +5,8 @@ import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; import 'package:hiddify/core/core_providers.dart'; import 'package:hiddify/core/router/router.dart'; -import 'package:hiddify/domain/failures.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; import 'package:hiddify/features/common/qr_code_scanner_screen.dart'; -import 'package:hiddify/features/profiles/notifier/notifier.dart'; +import 'package:hiddify/features/profile/notifier/profile_notifier.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -25,40 +23,26 @@ class AddProfileModal extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final t = ref.watch(translationsProvider); + final addProfileState = ref.watch(addProfileProvider); - final mutationTriggered = useState(false); - final addProfileMutation = useMutation( - initialOnFailure: (err) { - mutationTriggered.value = false; - if (err case ProfileInvalidUrlFailure()) { - CustomToast.error( - t.failure.profiles.invalidUrl, - ).show(context); - } else { - CustomAlertDialog.fromErr( - t.presentError(err, action: t.profile.add.failureMsg), - ).show(context); + ref.listen( + addProfileProvider, + (previous, next) { + if (next case AsyncData(value: final _?)) { + WidgetsBinding.instance.addPostFrameCallback( + (_) { + if (context.mounted) context.pop(); + }, + ); } }, - initialOnSuccess: () { - CustomToast.success(t.profile.save.successMsg).show(context); - WidgetsBinding.instance.addPostFrameCallback( - (_) { - if (context.mounted) context.pop(); - }, - ); - }, ); - final showProgressIndicator = - addProfileMutation.state.isInProgress || mutationTriggered.value; - useMemoized(() async { await Future.delayed(const Duration(milliseconds: 200)); if (url != null && context.mounted) { - addProfileMutation.setFuture( - ref.read(profilesNotifierProvider.notifier).addProfile(url!), - ); + if (addProfileState.isLoading) return; + ref.read(addProfileProvider.notifier).add(url!); } }); @@ -112,13 +96,10 @@ class AddProfileModal extends HookConsumerWidget { final captureResult = await Clipboard.getData(Clipboard.kTextPlain) .then((value) => value?.text ?? ''); - if (addProfileMutation.state.isInProgress) return; - mutationTriggered.value = true; - addProfileMutation.setFuture( - ref - .read(profilesNotifierProvider.notifier) - .addProfile(captureResult), - ); + if (addProfileState.isLoading) return; + ref + .read(addProfileProvider.notifier) + .add(captureResult); }, ), const Gap(buttonsGap), @@ -133,15 +114,10 @@ class AddProfileModal extends HookConsumerWidget { await const QRCodeScannerScreen() .open(context); if (captureResult == null) return; - if (addProfileMutation.state.isInProgress) { - return; - } - mutationTriggered.value = true; - addProfileMutation.setFuture( - ref - .read(profilesNotifierProvider.notifier) - .addProfile(captureResult), - ); + if (addProfileState.isLoading) return; + ref + .read(addProfileProvider.notifier) + .add(captureResult); }, ) else @@ -205,7 +181,7 @@ class AddProfileModal extends HookConsumerWidget { const Gap(24), ], ), - crossFadeState: showProgressIndicator + crossFadeState: addProfileState.isLoading ? CrossFadeState.showFirst : CrossFadeState.showSecond, duration: const Duration(milliseconds: 250), diff --git a/lib/features/profile/data/profile_data_mapper.dart b/lib/features/profile/data/profile_data_mapper.dart new file mode 100644 index 00000000..38d15061 --- /dev/null +++ b/lib/features/profile/data/profile_data_mapper.dart @@ -0,0 +1,85 @@ +import 'package:drift/drift.dart'; +import 'package:hiddify/data/local/database.dart'; +import 'package:hiddify/features/profile/model/profile_entity.dart'; + +extension ProfileEntityMapper on ProfileEntity { + ProfileEntriesCompanion toEntry() { + return switch (this) { + RemoteProfileEntity(:final url, :final options, :final subInfo) => + ProfileEntriesCompanion.insert( + id: id, + type: ProfileType.remote, + active: active, + name: name, + url: Value(url), + lastUpdate: lastUpdate, + updateInterval: Value(options?.updateInterval), + upload: Value(subInfo?.upload), + download: Value(subInfo?.download), + total: Value(subInfo?.total), + expire: Value(subInfo?.expire), + webPageUrl: Value(subInfo?.webPageUrl), + supportUrl: Value(subInfo?.supportUrl), + ), + LocalProfileEntity() => ProfileEntriesCompanion.insert( + id: id, + type: ProfileType.local, + active: active, + name: name, + lastUpdate: lastUpdate, + ), + }; + } +} + +extension RemoteProfileEntityMapper on RemoteProfileEntity { + ProfileEntriesCompanion subInfoPatch() { + return ProfileEntriesCompanion( + upload: Value(subInfo?.upload), + download: Value(subInfo?.download), + total: Value(subInfo?.total), + expire: Value(subInfo?.expire), + webPageUrl: Value(subInfo?.webPageUrl), + supportUrl: Value(subInfo?.supportUrl), + ); + } +} + +extension ProfileEntryMapper on ProfileEntry { + ProfileEntity toEntity() { + ProfileOptions? options; + if (updateInterval != null) { + options = ProfileOptions(updateInterval: updateInterval!); + } + + SubscriptionInfo? subInfo; + if (upload != null && download != null && total != null && expire != null) { + subInfo = SubscriptionInfo( + upload: upload!, + download: download!, + total: total!, + expire: expire!, + webPageUrl: webPageUrl, + supportUrl: supportUrl, + ); + } + + return switch (type) { + ProfileType.remote => RemoteProfileEntity( + id: id, + active: active, + name: name, + url: url!, + lastUpdate: lastUpdate, + options: options, + subInfo: subInfo, + ), + ProfileType.local => LocalProfileEntity( + id: id, + active: active, + name: name, + lastUpdate: lastUpdate, + ), + }; + } +} diff --git a/lib/features/profile/data/profile_data_providers.dart b/lib/features/profile/data/profile_data_providers.dart new file mode 100644 index 00000000..5ff0f515 --- /dev/null +++ b/lib/features/profile/data/profile_data_providers.dart @@ -0,0 +1,32 @@ +import 'package:hiddify/data/data_providers.dart'; +import 'package:hiddify/features/profile/data/profile_data_source.dart'; +import 'package:hiddify/features/profile/data/profile_path_resolver.dart'; +import 'package:hiddify/features/profile/data/profile_repository.dart'; +import 'package:hiddify/services/service_providers.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'profile_data_providers.g.dart'; + +@Riverpod(keepAlive: true) +Future profileRepository(ProfileRepositoryRef ref) async { + final repo = ProfileRepositoryImpl( + profileDataSource: ref.watch(profileDataSourceProvider), + profilePathResolver: ref.watch(profilePathResolverProvider), + configValidator: ref.watch(coreFacadeProvider).parseConfig, + dio: ref.watch(dioProvider), + ); + await repo.init().getOrElse((l) => throw l).run(); + return repo; +} + +@Riverpod(keepAlive: true) +ProfileDataSource profileDataSource(ProfileDataSourceRef ref) { + return ProfileDao(ref.watch(appDatabaseProvider)); +} + +@Riverpod(keepAlive: true) +ProfilePathResolver profilePathResolver(ProfilePathResolverRef ref) { + return ProfilePathResolver( + ref.watch(filesEditorServiceProvider).dirs.workingDir, + ); +} diff --git a/lib/data/local/dao/profiles_dao.dart b/lib/features/profile/data/profile_data_source.dart similarity index 57% rename from lib/data/local/dao/profiles_dao.dart rename to lib/features/profile/data/profile_data_source.dart index 3d41c934..a5f6b241 100644 --- a/lib/data/local/dao/profiles_dao.dart +++ b/lib/features/profile/data/profile_data_source.dart @@ -1,12 +1,24 @@ import 'package:drift/drift.dart'; -import 'package:hiddify/data/local/data_mappers.dart'; import 'package:hiddify/data/local/database.dart'; import 'package:hiddify/data/local/tables.dart'; -import 'package:hiddify/domain/enums.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; +import 'package:hiddify/features/profile/model/profile_sort_enum.dart'; import 'package:hiddify/utils/utils.dart'; -part 'profiles_dao.g.dart'; +part 'profile_data_source.g.dart'; + +abstract interface class ProfileDataSource { + Future getById(String id); + Future getByUrl(String url); + Stream watchActiveProfile(); + Stream watchProfilesCount(); + Stream> watchAll({ + required ProfilesSort sort, + required SortMode sortMode, + }); + Future insert(ProfileEntriesCompanion entry); + Future edit(String id, ProfileEntriesCompanion entry); + Future deleteById(String id); +} Map orderMap = { SortMode.ascending: OrderingMode.asc, @@ -14,41 +26,45 @@ Map orderMap = { }; @DriftAccessor(tables: [ProfileEntries]) -class ProfilesDao extends DatabaseAccessor - with _$ProfilesDaoMixin, InfraLogger { - ProfilesDao(super.db); +class ProfileDao extends DatabaseAccessor + with _$ProfileDaoMixin, InfraLogger + implements ProfileDataSource { + ProfileDao(super.db); - Future getById(String id) async { + @override + Future getById(String id) async { return (profileEntries.select()..where((tbl) => tbl.id.equals(id))) - .map(ProfileMapper.fromEntry) .getSingleOrNull(); } - Future getProfileByUrl(String url) async { - return (select(profileEntries)..where((tbl) => tbl.url.like('%$url%'))) - .map(ProfileMapper.fromEntry) - .get() - .then((value) => value.firstOrNull); + @override + Future getByUrl(String url) async { + return (select(profileEntries) + ..where((tbl) => tbl.url.like('%$url%')) + ..limit(1)) + .getSingleOrNull(); } - Stream watchActiveProfile() { + @override + Stream watchActiveProfile() { return (profileEntries.select() ..where((tbl) => tbl.active.equals(true)) ..limit(1)) - .map(ProfileMapper.fromEntry) .watchSingleOrNull(); } - Stream watchProfileCount() { + @override + Stream watchProfilesCount() { final count = profileEntries.id.count(); return (profileEntries.selectOnly()..addColumns([count])) .map((exp) => exp.read(count)!) .watchSingle(); } - Stream> watchAll({ - ProfilesSort sort = ProfilesSort.lastUpdate, - SortMode mode = SortMode.ascending, + @override + Stream> watchAll({ + required ProfilesSort sort, + required SortMode sortMode, }) { return (profileEntries.select() ..orderBy( @@ -67,56 +83,47 @@ class ProfilesDao extends DatabaseAccessor switch (sort) { ProfilesSort.name => (tbl) => OrderingTerm( expression: tbl.name, - mode: orderMap[mode]!, + mode: orderMap[sortMode]!, ), ProfilesSort.lastUpdate => (tbl) => OrderingTerm( expression: tbl.lastUpdate, - mode: orderMap[mode]!, + mode: orderMap[sortMode]!, ), }, ], )) - .map(ProfileMapper.fromEntry) .watch(); } - Future create(Profile profile) async { + @override + Future insert(ProfileEntriesCompanion entry) async { await transaction( () async { - if (profile.active) { + if (entry.active.present && entry.active.value) { await update(profileEntries) .write(const ProfileEntriesCompanion(active: Value(false))); } - await into(profileEntries).insert(profile.toCompanion()); + await into(profileEntries).insert(entry); }, ); } - Future edit(Profile patch) async { + @override + Future edit(String id, ProfileEntriesCompanion entry) async { await transaction( () async { - if (patch.active) { + if (entry.active.present && entry.active.value) { await update(profileEntries) .write(const ProfileEntriesCompanion(active: Value(false))); } - await (update(profileEntries)..where((tbl) => tbl.id.equals(patch.id))) - .write(patch.toCompanion()); - }, - ); - } - - Future setAsActive(String id) async { - await transaction( - () async { - await update(profileEntries) - .write(const ProfileEntriesCompanion(active: Value(false))); await (update(profileEntries)..where((tbl) => tbl.id.equals(id))) - .write(const ProfileEntriesCompanion(active: Value(true))); + .write(entry); }, ); } - Future removeById(String id) async { + @override + Future deleteById(String id) async { await transaction( () async { await (delete(profileEntries)..where((tbl) => tbl.id.equals(id))).go(); diff --git a/lib/features/profile/data/profile_parser.dart b/lib/features/profile/data/profile_parser.dart new file mode 100644 index 00000000..5496658b --- /dev/null +++ b/lib/features/profile/data/profile_parser.dart @@ -0,0 +1,105 @@ +import 'dart:convert'; + +import 'package:dartx/dartx.dart'; +import 'package:hiddify/features/profile/model/profile_entity.dart'; +import 'package:hiddify/utils/utils.dart'; +import 'package:uuid/uuid.dart'; + +/// parse profile subscription url and headers for data +/// +/// ***name parser hierarchy:*** +/// - `profile-title` header +/// - `content-disposition` header +/// - url fragment (example: `https://example.com/config#user`) -> name=`user` +/// - url filename extension (example: `https://example.com/config.json`) -> name=`config` +/// - if none of these methods return a non-blank string, fallback to `Remote Profile` +abstract class ProfileParser { + static RemoteProfileEntity parse( + String url, + Map> headers, + ) { + var name = ''; + if (headers['profile-title'] case [final titleHeader]) { + if (titleHeader.startsWith("base64:")) { + name = + utf8.decode(base64.decode(titleHeader.replaceFirst("base64:", ""))); + } else { + name = titleHeader.trim(); + } + } + if (headers['content-disposition'] case [final contentDispositionHeader] + when name.isEmpty) { + final regExp = RegExp('filename="([^"]*)"'); + final match = regExp.firstMatch(contentDispositionHeader); + if (match != null && match.groupCount >= 1) { + name = match.group(1) ?? ''; + } + } + if (Uri.parse(url).fragment case final fragment when name.isEmpty) { + name = fragment; + } + if (url.split("/").lastOrNull case final part? when name.isEmpty) { + final pattern = RegExp(r"\.(json|yaml|yml|txt)[\s\S]*"); + name = part.replaceFirst(pattern, ""); + } + if (name.isBlank) name = "Remote Profile"; + + ProfileOptions? options; + if (headers['profile-update-interval'] case [final updateIntervalStr]) { + final updateInterval = Duration(hours: int.parse(updateIntervalStr)); + options = ProfileOptions(updateInterval: updateInterval); + } + + SubscriptionInfo? subInfo; + if (headers['subscription-userinfo'] case [final subInfoStr]) { + subInfo = parseSubscriptionInfo(subInfoStr); + } + + if (subInfo != null) { + if (headers['profile-web-page-url'] case [final profileWebPageUrl] + when isUrl(profileWebPageUrl)) { + subInfo = subInfo.copyWith(webPageUrl: profileWebPageUrl); + } + if (headers['support-url'] case [final profileSupportUrl] + when isUrl(profileSupportUrl)) { + subInfo = subInfo.copyWith(supportUrl: profileSupportUrl); + } + } + + return RemoteProfileEntity( + id: const Uuid().v4(), + active: false, + name: name, + url: url, + lastUpdate: DateTime.now(), + options: options, + subInfo: subInfo, + ); + } + + static SubscriptionInfo? parseSubscriptionInfo(String subInfoStr) { + final values = subInfoStr.split(';'); + final map = { + for (final v in values) + v.split('=').first.trim(): + num.tryParse(v.split('=').second.trim())?.toInt(), + }; + if (map + case { + "upload": final upload?, + "download": final download?, + "total": final total, + "expire": final expire + }) { + return SubscriptionInfo( + upload: upload, + download: download, + total: total ?? 9223372036854775807, + expire: DateTime.fromMillisecondsSinceEpoch( + (expire ?? 92233720368) * 1000, + ), + ); + } + return null; + } +} diff --git a/lib/features/profile/data/profile_path_resolver.dart b/lib/features/profile/data/profile_path_resolver.dart new file mode 100644 index 00000000..ea340344 --- /dev/null +++ b/lib/features/profile/data/profile_path_resolver.dart @@ -0,0 +1,17 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +class ProfilePathResolver { + const ProfilePathResolver(this._workingDir); + + final Directory _workingDir; + + Directory get directory => Directory(p.join(_workingDir.path, "configs")); + + File file(String fileName) { + return File(p.join(directory.path, "$fileName.json")); + } + + File tempFile(String fileName) => file("$fileName.tmp"); +} diff --git a/lib/data/repository/profiles_repository_impl.dart b/lib/features/profile/data/profile_repository.dart similarity index 53% rename from lib/data/repository/profiles_repository_impl.dart rename to lib/features/profile/data/profile_repository.dart index d73eb585..4a099e1a 100644 --- a/lib/data/repository/profiles_repository_impl.dart +++ b/lib/features/profile/data/profile_repository.dart @@ -1,44 +1,103 @@ import 'dart:io'; import 'package:dio/dio.dart'; +import 'package:drift/drift.dart'; import 'package:fpdart/fpdart.dart'; -import 'package:hiddify/data/local/dao/profiles_dao.dart'; +import 'package:hiddify/data/local/database.dart'; import 'package:hiddify/data/repository/exception_handlers.dart'; -import 'package:hiddify/domain/enums.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; -import 'package:hiddify/domain/singbox/singbox.dart'; -import 'package:hiddify/services/files_editor_service.dart'; -import 'package:hiddify/utils/utils.dart'; +import 'package:hiddify/domain/core_service_failure.dart'; +import 'package:hiddify/features/profile/data/profile_data_mapper.dart'; +import 'package:hiddify/features/profile/data/profile_data_source.dart'; +import 'package:hiddify/features/profile/data/profile_parser.dart'; +import 'package:hiddify/features/profile/data/profile_path_resolver.dart'; +import 'package:hiddify/features/profile/model/profile_entity.dart'; +import 'package:hiddify/features/profile/model/profile_failure.dart'; +import 'package:hiddify/features/profile/model/profile_sort_enum.dart'; +import 'package:hiddify/utils/custom_loggers.dart'; +import 'package:hiddify/utils/link_parsers.dart'; import 'package:meta/meta.dart'; import 'package:retry/retry.dart'; import 'package:uuid/uuid.dart'; -class ProfilesRepositoryImpl +abstract interface class ProfileRepository { + TaskEither init(); + TaskEither getById(String id); + Stream> watchActiveProfile(); + Stream> watchHasAnyProfile(); + + Stream>> watchAll({ + ProfilesSort sort = ProfilesSort.lastUpdate, + SortMode sortMode = SortMode.ascending, + }); + + TaskEither addByUrl( + String url, { + bool markAsActive = false, + }); + + TaskEither addByContent( + String content, { + required String name, + bool markAsActive = false, + }); + + TaskEither add(RemoteProfileEntity baseProfile); + + TaskEither updateSubscription( + RemoteProfileEntity baseProfile, + ); + + TaskEither patch(ProfileEntity profile); + TaskEither setAsActive(String id); + TaskEither deleteById(String id); +} + +class ProfileRepositoryImpl with ExceptionHandler, InfraLogger - implements ProfilesRepository { - ProfilesRepositoryImpl({ - required this.profilesDao, - required this.filesEditor, - required this.singbox, + implements ProfileRepository { + ProfileRepositoryImpl({ + required this.profileDataSource, + required this.profilePathResolver, + required this.configValidator, required this.dio, }); - final ProfilesDao profilesDao; - final FilesEditorService filesEditor; - final SingboxFacade singbox; + final ProfileDataSource profileDataSource; + final ProfilePathResolver profilePathResolver; + final TaskEither Function( + String path, + String tempPath, + bool debug, + ) configValidator; final Dio dio; @override - TaskEither get(String id) { - return TaskEither.tryCatch( - () => profilesDao.getById(id), + TaskEither init() { + return exceptionHandler( + () async { + if (!await profilePathResolver.directory.exists()) { + await profilePathResolver.directory.create(recursive: true); + } + return right(unit); + }, ProfileUnexpectedFailure.new, ); } @override - Stream> watchActiveProfile() { - return profilesDao.watchActiveProfile().handleExceptions( + TaskEither getById(String id) { + return TaskEither.tryCatch( + () => profileDataSource.getById(id).then((value) => value?.toEntity()), + ProfileUnexpectedFailure.new, + ); + } + + @override + Stream> watchActiveProfile() { + return profileDataSource + .watchActiveProfile() + .map((event) => event?.toEntity()) + .handleExceptions( (error, stackTrace) { loggy.error("error watching active profile", error, stackTrace); return ProfileUnexpectedFailure(error, stackTrace); @@ -48,19 +107,20 @@ class ProfilesRepositoryImpl @override Stream> watchHasAnyProfile() { - return profilesDao - .watchProfileCount() + return profileDataSource + .watchProfilesCount() .map((event) => event != 0) .handleExceptions(ProfileUnexpectedFailure.new); } @override - Stream>> watchAll({ + Stream>> watchAll({ ProfilesSort sort = ProfilesSort.lastUpdate, - SortMode mode = SortMode.ascending, + SortMode sortMode = SortMode.ascending, }) { - return profilesDao - .watchAll(sort: sort, mode: mode) + return profileDataSource + .watchAll(sort: sort, sortMode: sortMode) + .map((event) => event.map((e) => e.toEntity()).toList()) .handleExceptions(ProfileUnexpectedFailure.new); } @@ -71,13 +131,15 @@ class ProfilesRepositoryImpl }) { return exceptionHandler( () async { - final existingProfile = await profilesDao.getProfileByUrl(url); - if (existingProfile case RemoteProfile()) { + final existingProfile = await profileDataSource + .getByUrl(url) + .then((value) => value?.toEntity()); + if (existingProfile case RemoteProfileEntity()) { loggy.info("profile with same url already exists, updating"); final baseProfile = markAsActive ? existingProfile.copyWith(active: true) : existingProfile; - return update(baseProfile).run(); + return updateSubscription(baseProfile).run(); } final profileId = const Uuid().v4(); @@ -85,11 +147,10 @@ class ProfilesRepositoryImpl .flatMap( (profile) => TaskEither( () async { - await profilesDao.create( - profile.copyWith( - id: profileId, - active: markAsActive, - ), + await profileDataSource.insert( + profile + .copyWith(id: profileId, active: markAsActive) + .toEntry(), ); return right(unit); }, @@ -113,30 +174,31 @@ class ProfilesRepositoryImpl return exceptionHandler( () async { final profileId = const Uuid().v4(); - final tempPath = filesEditor.tempConfigPath(profileId); - final path = filesEditor.configPath(profileId); + final file = profilePathResolver.file(profileId); + final tempFile = profilePathResolver.tempFile(profileId); + try { - await File(tempPath).writeAsString(content); + await tempFile.writeAsString(content); final parseResult = - await singbox.parseConfig(path, tempPath, false).run(); + await configValidator(file.path, tempFile.path, false).run(); return parseResult.fold( (err) async { loggy.warning("error parsing config", err); return left(ProfileFailure.invalidConfig(err.msg)); }, (_) async { - final profile = LocalProfile( + final profile = LocalProfileEntity( id: profileId, active: markAsActive, name: name, lastUpdate: DateTime.now(), ); - await profilesDao.create(profile); + await profileDataSource.insert(profile.toEntry()); return right(unit); }, ); } finally { - if (File(tempPath).existsSync()) File(tempPath).deleteSync(); + if (tempFile.existsSync()) tempFile.deleteSync(); } }, (error, stackTrace) { @@ -147,17 +209,19 @@ class ProfilesRepositoryImpl } @override - TaskEither add(RemoteProfile baseProfile) { + TaskEither add(RemoteProfileEntity baseProfile) { return exceptionHandler( () async { return fetch(baseProfile.url, baseProfile.id) .flatMap( (remoteProfile) => TaskEither(() async { - await profilesDao.create( - baseProfile.copyWith( - subInfo: remoteProfile.subInfo, - lastUpdate: DateTime.now(), - ), + await profileDataSource.insert( + baseProfile + .copyWith( + subInfo: remoteProfile.subInfo, + lastUpdate: DateTime.now(), + ) + .toEntry(), ); return right(unit); }), @@ -172,7 +236,9 @@ class ProfilesRepositoryImpl } @override - TaskEither update(RemoteProfile baseProfile) { + TaskEither updateSubscription( + RemoteProfileEntity baseProfile, + ) { return exceptionHandler( () async { loggy.debug( @@ -181,11 +247,11 @@ class ProfilesRepositoryImpl return fetch(baseProfile.url, baseProfile.id) .flatMap( (remoteProfile) => TaskEither(() async { - await profilesDao.edit( - baseProfile.copyWith( - subInfo: remoteProfile.subInfo, - lastUpdate: DateTime.now(), - ), + await profileDataSource.edit( + baseProfile.id, + remoteProfile + .subInfoPatch() + .copyWith(lastUpdate: Value(DateTime.now())), ); return right(unit); }), @@ -200,13 +266,13 @@ class ProfilesRepositoryImpl } @override - TaskEither edit(Profile profile) { + TaskEither patch(ProfileEntity profile) { return exceptionHandler( () async { loggy.debug( "editing profile [${profile.name} (${profile.id})]", ); - await profilesDao.edit(profile); + await profileDataSource.edit(profile.id, profile.toEntry()); return right(unit); }, (error, stackTrace) { @@ -220,7 +286,10 @@ class ProfilesRepositoryImpl TaskEither setAsActive(String id) { return TaskEither.tryCatch( () async { - await profilesDao.setAsActive(id); + await profileDataSource.edit( + id, + const ProfileEntriesCompanion(active: Value(true)), + ); return unit; }, ProfileUnexpectedFailure.new, @@ -228,11 +297,11 @@ class ProfilesRepositoryImpl } @override - TaskEither delete(String id) { + TaskEither deleteById(String id) { return TaskEither.tryCatch( () async { - await profilesDao.removeById(id); - await filesEditor.deleteConfig(id); + await profileDataSource.deleteById(id); + await profilePathResolver.file(id).delete(); return unit; }, ProfileUnexpectedFailure.new, @@ -249,35 +318,35 @@ class ProfilesRepositoryImpl ]; @visibleForTesting - TaskEither fetch( + TaskEither fetch( String url, String fileName, ) { return TaskEither( () async { - final tempPath = filesEditor.tempConfigPath(fileName); - final path = filesEditor.configPath(fileName); + final file = profilePathResolver.file(fileName); + final tempFile = profilePathResolver.tempFile(fileName); try { final response = await retry( - () async => dio.download(url.trim(), tempPath), + () async => dio.download(url.trim(), tempFile.path), maxAttempts: 3, ); final headers = - await _populateHeaders(response.headers.map, tempPath); + await _populateHeaders(response.headers.map, tempFile.path); final parseResult = - await singbox.parseConfig(path, tempPath, false).run(); + await configValidator(file.path, tempFile.path, false).run(); return parseResult.fold( (err) async { loggy.warning("error parsing config", err); return left(ProfileFailure.invalidConfig(err.msg)); }, (_) async { - final profile = Profile.fromResponse(url, headers); + final profile = ProfileParser.parse(url, headers); return right(profile); }, ); } finally { - if (File(tempPath).existsSync()) File(tempPath).deleteSync(); + if (tempFile.existsSync()) tempFile.deleteSync(); } }, ); diff --git a/lib/features/profile_detail/notifier/profile_detail_notifier.dart b/lib/features/profile/details/profile_details_notifier.dart similarity index 60% rename from lib/features/profile_detail/notifier/profile_detail_notifier.dart rename to lib/features/profile/details/profile_details_notifier.dart index 6397164e..6cd5df65 100644 --- a/lib/features/profile_detail/notifier/profile_detail_notifier.dart +++ b/lib/features/profile/details/profile_details_notifier.dart @@ -1,25 +1,27 @@ import 'package:dartx/dartx.dart'; import 'package:fpdart/fpdart.dart'; -import 'package:hiddify/data/data_providers.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; -import 'package:hiddify/features/profile_detail/notifier/profile_detail_state.dart'; +import 'package:hiddify/features/profile/data/profile_data_providers.dart'; +import 'package:hiddify/features/profile/data/profile_repository.dart'; +import 'package:hiddify/features/profile/details/profile_details_state.dart'; +import 'package:hiddify/features/profile/model/profile_entity.dart'; +import 'package:hiddify/features/profile/model/profile_failure.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:uuid/uuid.dart'; -part 'profile_detail_notifier.g.dart'; +part 'profile_details_notifier.g.dart'; @riverpod -class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger { +class ProfileDetailsNotifier extends _$ProfileDetailsNotifier with AppLogger { @override - Future build( + Future build( String id, { String? url, String? profileName, }) async { if (id == 'new') { - return ProfileDetailState( - profile: RemoteProfile( + return ProfileDetailsState( + profile: RemoteProfileEntity( id: const Uuid().v4(), active: true, name: profileName ?? "", @@ -28,7 +30,7 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger { ), ); } - final failureOrProfile = await _profilesRepo.get(id).run(); + final failureOrProfile = await _profilesRepo.getById(id).run(); return failureOrProfile.match( (err) { loggy.warning('failed to load profile', err); @@ -40,13 +42,14 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger { throw const ProfileNotFoundFailure(); } _originalProfile = profile; - return ProfileDetailState(profile: profile, isEditing: true); + return ProfileDetailsState(profile: profile, isEditing: true); }, ); } - ProfilesRepository get _profilesRepo => ref.read(profilesRepositoryProvider); - Profile? _originalProfile; + ProfileRepository get _profilesRepo => + ref.read(profileRepositoryProvider).requireValue; + ProfileEntity? _originalProfile; void setField({String? name, String? url, Option? updateInterval}) { if (state case AsyncData(:final value)) { @@ -74,41 +77,47 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger { Future save() async { if (state case AsyncData(:final value)) { - if (value.save.isInProgress) return; + if (value.save case AsyncLoading()) return; + final profile = value.profile; Either? failureOrSuccess; - state = AsyncData(value.copyWith(save: const MutationInProgress())); + state = AsyncData(value.copyWith(save: const AsyncLoading())); + switch (profile) { - case RemoteProfile(): + case RemoteProfileEntity(): loggy.debug( 'saving profile, url: [${profile.url}], name: [${profile.name}]', ); if (profile.name.isBlank || profile.url.isBlank) { - loggy.debug('profile save: invalid arguments'); + loggy.debug('save: invalid arguments'); } else if (value.isEditing) { - if (_originalProfile case RemoteProfile(:final url) + if (_originalProfile case RemoteProfileEntity(:final url) when url == profile.url) { loggy.debug('editing profile'); - failureOrSuccess = await _profilesRepo.edit(profile).run(); + failureOrSuccess = await _profilesRepo.patch(profile).run(); } else { loggy.debug('updating profile'); - failureOrSuccess = await _profilesRepo.update(profile).run(); + failureOrSuccess = + await _profilesRepo.updateSubscription(profile).run(); } } else { loggy.debug('adding profile, url: [${profile.url}]'); failureOrSuccess = await _profilesRepo.add(profile).run(); } - case LocalProfile() when value.isEditing: + + case LocalProfileEntity() when value.isEditing: loggy.debug('editing profile'); - failureOrSuccess = await _profilesRepo.edit(profile).run(); + failureOrSuccess = await _profilesRepo.patch(profile).run(); + default: loggy.warning("local profile can't be added manually"); } + state = AsyncData( value.copyWith( save: failureOrSuccess?.fold( - (l) => MutationFailure(l), - (_) => const MutationSuccess(), + (l) => AsyncError(l, StackTrace.current), + (_) => const AsyncData(null), ) ?? value.save, showErrorMessages: true, @@ -119,24 +128,25 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger { Future updateProfile() async { if (state case AsyncData(:final value)) { - loggy.debug('updating profile'); - if (value.profile case LocalProfile()) { + if (value.update?.isLoading ?? false || !value.isEditing) return; + if (value.profile case LocalProfileEntity()) { loggy.warning("local profile can't be updated"); return; } - if (value.update.isInProgress || !value.isEditing) return; + final profile = value.profile; - loggy.debug('updating profile'); - state = AsyncData(value.copyWith(update: const MutationInProgress())); + state = AsyncData(value.copyWith(update: const AsyncLoading())); + final failureOrUpdatedProfile = await _profilesRepo - .update(profile as RemoteProfile) - .flatMap((_) => _profilesRepo.get(id)) + .updateSubscription(profile as RemoteProfileEntity) + .flatMap((_) => _profilesRepo.getById(id)) .run(); + state = AsyncData( value.copyWith( update: failureOrUpdatedProfile.match( - (l) => MutationFailure(l), - (_) => const MutationSuccess(), + (l) => AsyncError(l, StackTrace.current), + (_) => const AsyncData(null), ), profile: failureOrUpdatedProfile.match( (_) => profile, @@ -149,17 +159,18 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger { Future delete() async { if (state case AsyncData(:final value)) { - if (value.delete.isInProgress) return; + if (value.delete case AsyncLoading()) return; final profile = value.profile; - loggy.debug('deleting profile'); - state = AsyncData(value.copyWith(delete: const MutationInProgress())); - final result = await _profilesRepo.delete(profile.id).run(); + state = AsyncData(value.copyWith(delete: const AsyncLoading())); + state = AsyncData( value.copyWith( - delete: result.match( - (l) => MutationFailure(l), - (_) => const MutationSuccess(), - ), + delete: await AsyncValue.guard(() async { + await _profilesRepo + .deleteById(profile.id) + .getOrElse((l) => throw l) + .run(); + }), ), ); } diff --git a/lib/features/profile_detail/view/profile_detail_page.dart b/lib/features/profile/details/profile_details_page.dart similarity index 79% rename from lib/features/profile_detail/view/profile_detail_page.dart rename to lib/features/profile/details/profile_details_page.dart index 2c8eb0ff..ce1e5b23 100644 --- a/lib/features/profile_detail/view/profile_detail_page.dart +++ b/lib/features/profile/details/profile_details_page.dart @@ -3,16 +3,16 @@ import 'package:fpdart/fpdart.dart'; import 'package:go_router/go_router.dart'; import 'package:hiddify/core/core_providers.dart'; import 'package:hiddify/domain/failures.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; import 'package:hiddify/features/common/confirmation_dialogs.dart'; -import 'package:hiddify/features/profile_detail/notifier/notifier.dart'; +import 'package:hiddify/features/profile/details/profile_details_notifier.dart'; +import 'package:hiddify/features/profile/model/profile_entity.dart'; import 'package:hiddify/features/settings/widgets/widgets.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:humanizer/humanizer.dart'; -class ProfileDetailPage extends HookConsumerWidget with PresLogger { - const ProfileDetailPage(this.id, {super.key}); +class ProfileDetailsPage extends HookConsumerWidget with PresLogger { + const ProfileDetailsPage(this.id, {super.key}); final String id; @@ -20,65 +20,59 @@ class ProfileDetailPage extends HookConsumerWidget with PresLogger { Widget build(BuildContext context, WidgetRef ref) { final t = ref.watch(translationsProvider); - final provider = profileDetailNotifierProvider(id); + final provider = profileDetailsNotifierProvider(id); final notifier = ref.watch(provider.notifier); ref.listen( - provider.select((data) => data.whenData((value) => value.save)), - (_, asyncSave) { - if (asyncSave case AsyncData(value: final save)) { - switch (save) { - case MutationFailure(:final failure): - final String action; - if (ref.read(provider) case AsyncData(value: final data) - when data.isEditing) { - action = t.profile.save.failureMsg; - } else { - action = t.profile.add.failureMsg; - } - CustomAlertDialog.fromErr(t.presentError(failure, action: action)) - .show(context); - case MutationSuccess(): - CustomToast.success(t.profile.save.successMsg).show(context); - WidgetsBinding.instance.addPostFrameCallback( - (_) { - if (context.mounted) context.pop(); - }, - ); - } + provider.selectAsync((data) => data.save), + (_, next) async { + switch (await next) { + case AsyncData(): + CustomToast.success(t.profile.save.successMsg).show(context); + WidgetsBinding.instance.addPostFrameCallback( + (_) { + if (context.mounted) context.pop(); + }, + ); + case AsyncError(:final error): + final String action; + if (ref.read(provider) case AsyncData(value: final data) + when data.isEditing) { + action = t.profile.save.failureMsg; + } else { + action = t.profile.add.failureMsg; + } + CustomAlertDialog.fromErr(t.presentError(error, action: action)) + .show(context); } }, ); ref.listen( - provider.select((data) => data.whenData((value) => value.update)), - (_, asyncUpdate) { - if (asyncUpdate case AsyncData(value: final update)) { - switch (update) { - case MutationFailure(:final failure): - CustomAlertDialog.fromErr(t.presentError(failure)).show(context); - case MutationSuccess(): - CustomToast.success(t.profile.update.successMsg).show(context); - } + provider.selectAsync((data) => data.update), + (_, next) async { + switch (await next) { + case AsyncData(): + CustomToast.success(t.profile.update.successMsg).show(context); + case AsyncError(:final error): + CustomAlertDialog.fromErr(t.presentError(error)).show(context); } }, ); ref.listen( - provider.select((data) => data.whenData((value) => value.delete)), - (_, asyncDelete) { - if (asyncDelete case AsyncData(value: final delete)) { - switch (delete) { - case MutationFailure(:final failure): - CustomToast.error(t.presentShortError(failure)).show(context); - case MutationSuccess(): - CustomToast.success(t.profile.delete.successMsg).show(context); - WidgetsBinding.instance.addPostFrameCallback( - (_) { - if (context.mounted) context.pop(); - }, - ); - } + provider.selectAsync((data) => data.delete), + (_, next) async { + switch (await next) { + case AsyncData(): + CustomToast.success(t.profile.delete.successMsg).show(context); + WidgetsBinding.instance.addPostFrameCallback( + (_) { + if (context.mounted) context.pop(); + }, + ); + case AsyncError(:final error): + CustomToast.error(t.presentShortError(error)).show(context); } }, ); @@ -102,7 +96,7 @@ class ProfileDetailPage extends HookConsumerWidget with PresLogger { PopupMenuButton( itemBuilder: (context) { return [ - if (state.profile case RemoteProfile()) + if (state.profile case RemoteProfileEntity()) PopupMenuItem( child: Text(t.profile.update.buttonTxt), onTap: () async { @@ -151,7 +145,10 @@ class ProfileDetailPage extends HookConsumerWidget with PresLogger { ), ), if (state.profile - case RemoteProfile(:final url, :final options)) ...[ + case RemoteProfileEntity( + :final url, + :final options + )) ...[ Padding( padding: const EdgeInsets.symmetric( horizontal: 16, diff --git a/lib/features/profile/details/profile_details_state.dart b/lib/features/profile/details/profile_details_state.dart new file mode 100644 index 00000000..894abf24 --- /dev/null +++ b/lib/features/profile/details/profile_details_state.dart @@ -0,0 +1,22 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:hiddify/features/profile/model/profile_entity.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +part 'profile_details_state.freezed.dart'; + +@freezed +class ProfileDetailsState with _$ProfileDetailsState { + const ProfileDetailsState._(); + + const factory ProfileDetailsState({ + required ProfileEntity profile, + @Default(false) bool isEditing, + @Default(false) bool showErrorMessages, + AsyncValue? save, + AsyncValue? update, + AsyncValue? delete, + }) = _ProfileDetailsState; + + bool get isBusy => + save is AsyncLoading || delete is AsyncLoading || update is AsyncLoading; +} diff --git a/lib/features/profile/model/profile_entity.dart b/lib/features/profile/model/profile_entity.dart new file mode 100644 index 00000000..144546be --- /dev/null +++ b/lib/features/profile/model/profile_entity.dart @@ -0,0 +1,57 @@ +import 'package:dartx/dartx.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'profile_entity.freezed.dart'; + +enum ProfileType { remote, local } + +@freezed +sealed class ProfileEntity with _$ProfileEntity { + const ProfileEntity._(); + + const factory ProfileEntity.remote({ + required String id, + required bool active, + required String name, + required String url, + required DateTime lastUpdate, + ProfileOptions? options, + SubscriptionInfo? subInfo, + }) = RemoteProfileEntity; + + const factory ProfileEntity.local({ + required String id, + required bool active, + required String name, + required DateTime lastUpdate, + }) = LocalProfileEntity; +} + +@freezed +class ProfileOptions with _$ProfileOptions { + const factory ProfileOptions({ + required Duration updateInterval, + }) = _ProfileOptions; +} + +@freezed +class SubscriptionInfo with _$SubscriptionInfo { + const SubscriptionInfo._(); + + const factory SubscriptionInfo({ + required int upload, + required int download, + required int total, + required DateTime expire, + String? webPageUrl, + String? supportUrl, + }) = _SubscriptionInfo; + + bool get isExpired => expire <= DateTime.now(); + + int get consumption => upload + download; + + double get ratio => (consumption / total).clamp(0, 1); + + Duration get remaining => expire.difference(DateTime.now()); +} diff --git a/lib/domain/profiles/profiles_failure.dart b/lib/features/profile/model/profile_failure.dart similarity index 97% rename from lib/domain/profiles/profiles_failure.dart rename to lib/features/profile/model/profile_failure.dart index 7a9edb8a..529b5269 100644 --- a/lib/domain/profiles/profiles_failure.dart +++ b/lib/features/profile/model/profile_failure.dart @@ -2,7 +2,7 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hiddify/core/prefs/prefs.dart'; import 'package:hiddify/domain/failures.dart'; -part 'profiles_failure.freezed.dart'; +part 'profile_failure.freezed.dart'; @freezed sealed class ProfileFailure with _$ProfileFailure, Failure { diff --git a/lib/domain/profiles/profile_enums.dart b/lib/features/profile/model/profile_sort_enum.dart similarity index 91% rename from lib/domain/profiles/profile_enums.dart rename to lib/features/profile/model/profile_sort_enum.dart index 1ad081bd..04b8f6d4 100644 --- a/lib/domain/profiles/profile_enums.dart +++ b/lib/features/profile/model/profile_sort_enum.dart @@ -17,3 +17,5 @@ enum ProfilesSort { name => Icons.sort_by_alpha, }; } + +enum SortMode { ascending, descending } diff --git a/lib/features/profile/notifier/active_profile_notifier.dart b/lib/features/profile/notifier/active_profile_notifier.dart new file mode 100644 index 00000000..74e4214a --- /dev/null +++ b/lib/features/profile/notifier/active_profile_notifier.dart @@ -0,0 +1,31 @@ +import 'package:hiddify/features/profile/data/profile_data_providers.dart'; +import 'package:hiddify/features/profile/model/profile_entity.dart'; +import 'package:hiddify/utils/utils.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'active_profile_notifier.g.dart'; + +@Riverpod(keepAlive: true) +class ActiveProfile extends _$ActiveProfile with AppLogger { + @override + Stream build() { + loggy.debug("watching active profile"); + return ref + .watch(profileRepositoryProvider) + .requireValue + .watchActiveProfile() + .map((event) => event.getOrElse((l) => throw l)); + } +} + +// TODO: move to specific feature +@Riverpod(keepAlive: true) +Stream hasAnyProfile( + HasAnyProfileRef ref, +) { + return ref + .watch(profileRepositoryProvider) + .requireValue + .watchHasAnyProfile() + .map((event) => event.getOrElse((l) => throw l)); +} diff --git a/lib/features/profile/notifier/profile_notifier.dart b/lib/features/profile/notifier/profile_notifier.dart new file mode 100644 index 00000000..263fdf85 --- /dev/null +++ b/lib/features/profile/notifier/profile_notifier.dart @@ -0,0 +1,140 @@ +import 'package:fpdart/fpdart.dart'; +import 'package:hiddify/core/core_providers.dart'; +import 'package:hiddify/core/notification/in_app_notification_controller.dart'; +import 'package:hiddify/core/prefs/general_prefs.dart'; +import 'package:hiddify/domain/failures.dart'; +import 'package:hiddify/features/common/connectivity/connectivity_controller.dart'; +import 'package:hiddify/features/profile/data/profile_data_providers.dart'; +import 'package:hiddify/features/profile/data/profile_repository.dart'; +import 'package:hiddify/features/profile/model/profile_entity.dart'; +import 'package:hiddify/features/profile/model/profile_failure.dart'; +import 'package:hiddify/features/profile/notifier/active_profile_notifier.dart'; +import 'package:hiddify/utils/riverpod_utils.dart'; +import 'package:hiddify/utils/utils.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'profile_notifier.g.dart'; + +@riverpod +class AddProfile extends _$AddProfile with AppLogger { + @override + AsyncValue build() { + ref.disposeDelay(const Duration(minutes: 1)); + ref.listenSelf( + (previous, next) { + final t = ref.read(translationsProvider); + final notification = ref.read(inAppNotificationControllerProvider); + switch (next) { + case AsyncData(value: final _?): + notification.showSuccessToast(t.profile.save.successMsg); + case AsyncError(:final error): + if (error case ProfileInvalidUrlFailure()) { + notification.showErrorToast(t.failure.profiles.invalidUrl); + } else { + notification.showErrorDialog( + t.presentError(error, action: t.profile.add.failureMsg), + ); + } + } + }, + ); + return const AsyncData(null); + } + + ProfileRepository get _profilesRepo => + ref.read(profileRepositoryProvider).requireValue; + + Future add(String rawInput) async { + if (state.isLoading) return; + state = const AsyncLoading(); + state = await AsyncValue.guard( + () async { + final activeProfile = await ref.read(activeProfileProvider.future); + final markAsActive = + activeProfile == null || ref.read(markNewProfileActiveProvider); + final TaskEither task; + if (LinkParser.parse(rawInput) case (final link)?) { + loggy.debug("adding profile, url: [${link.url}]"); + task = _profilesRepo.addByUrl(link.url, markAsActive: markAsActive); + } else if (LinkParser.protocol(rawInput) case (final parsed)?) { + loggy.debug("adding profile, content"); + task = _profilesRepo.addByContent( + parsed.content, + name: parsed.name, + markAsActive: markAsActive, + ); + } else { + loggy.debug("invalid content"); + throw const ProfileInvalidUrlFailure(); + } + return task.match( + (err) { + loggy.warning("failed to add profile", err); + throw err; + }, + (_) { + loggy.info( + "successfully added profile, mark as active? [$markAsActive]", + ); + return unit; + }, + ).run(); + }, + ); + } +} + +@riverpod +class UpdateProfile extends _$UpdateProfile with AppLogger { + @override + AsyncValue build(String id) { + ref.disposeDelay(const Duration(minutes: 1)); + ref.listenSelf( + (previous, next) { + final t = ref.read(translationsProvider); + final notification = ref.read(inAppNotificationControllerProvider); + switch (next) { + case AsyncData(value: final _?): + notification.showSuccessToast(t.profile.update.successMsg); + case AsyncError(:final error): + notification.showErrorDialog( + t.presentError(error, action: t.profile.update.failureMsg), + ); + } + }, + ); + return const AsyncData(null); + } + + ProfileRepository get _profilesRepo => + ref.read(profileRepositoryProvider).requireValue; + + Future updateProfile(RemoteProfileEntity profile) async { + if (state.isLoading) return; + state = const AsyncLoading(); + state = await AsyncValue.guard( + () async { + return await _profilesRepo.updateSubscription(profile).match( + (err) { + loggy.warning("failed to update profile", err); + throw err; + }, + (_) async { + loggy.info( + 'successfully updated profile, was active? [${profile.active}]', + ); + + await ref.read(activeProfileProvider.future).then((active) async { + if (active != null && active.id == profile.id) { + await ref + .read(connectivityControllerProvider.notifier) + .reconnect(profile.id); + } + }); + return unit; + }, + ).run(); + }, + ); + } +} diff --git a/lib/features/profile/notifier/profiles_update_notifier.dart b/lib/features/profile/notifier/profiles_update_notifier.dart new file mode 100644 index 00000000..5f4325b3 --- /dev/null +++ b/lib/features/profile/notifier/profiles_update_notifier.dart @@ -0,0 +1,92 @@ +import 'package:dartx/dartx.dart'; +import 'package:hiddify/data/data_providers.dart'; +import 'package:hiddify/features/profile/data/profile_data_providers.dart'; +import 'package:hiddify/features/profile/model/profile_entity.dart'; +import 'package:hiddify/utils/custom_loggers.dart'; +import 'package:meta/meta.dart'; +import 'package:neat_periodic_task/neat_periodic_task.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'profiles_update_notifier.g.dart'; + +@Riverpod(keepAlive: true) +class ForegroundProfilesUpdateNotifier + extends _$ForegroundProfilesUpdateNotifier with AppLogger { + static const prefKey = "profiles_update_check"; + static const interval = Duration(minutes: 15); + + @override + Future build() async { + loggy.debug("initializing"); + var cycleCount = 0; + final scheduler = NeatPeriodicTaskScheduler( + name: 'profiles update worker', + interval: interval, + timeout: const Duration(minutes: 5), + task: () async { + loggy.debug("cycle [${cycleCount++}]"); + await updateProfiles(); + }, + ); + + ref.onDispose(() async { + await scheduler.stop(); + }); + + return scheduler.start(); + } + + @visibleForTesting + Future updateProfiles() async { + try { + final previousRun = DateTime.tryParse( + ref.read(sharedPreferencesProvider).getString(prefKey) ?? "", + ); + + if (previousRun != null && previousRun.add(interval) > DateTime.now()) { + loggy.debug("too soon! previous run: [$previousRun]"); + return; + } + loggy.debug("running, previous run: [$previousRun]"); + + final remoteProfiles = await ref + .read(profileRepositoryProvider) + .requireValue + .watchAll() + .map( + (event) => event.getOrElse((f) { + loggy.error("error getting profiles"); + throw f; + }).whereType(), + ) + .first; + + await for (final profile in Stream.fromIterable(remoteProfiles)) { + final updateInterval = profile.options?.updateInterval; + if (updateInterval != null && + updateInterval <= DateTime.now().difference(profile.lastUpdate)) { + await ref + .read(profileRepositoryProvider) + .requireValue + .updateSubscription(profile) + .mapLeft( + (l) => loggy.debug("error updating profile [${profile.id}]", l), + ) + .map( + (_) => + loggy.debug("profile [${profile.id}] updated successfully"), + ) + .run(); + } else { + loggy.debug( + "skipping profile [${profile.id}] update. last successful update: [${profile.lastUpdate}] - interval: [${profile.options?.updateInterval}]", + ); + } + } + } finally { + await ref + .read(sharedPreferencesProvider) + .setString(prefKey, DateTime.now().toIso8601String()); + } + } +} diff --git a/lib/features/profile/overview/profiles_overview_notifier.dart b/lib/features/profile/overview/profiles_overview_notifier.dart new file mode 100644 index 00000000..9434a2d9 --- /dev/null +++ b/lib/features/profile/overview/profiles_overview_notifier.dart @@ -0,0 +1,83 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:hiddify/data/data_providers.dart'; +import 'package:hiddify/features/profile/data/profile_data_providers.dart'; +import 'package:hiddify/features/profile/data/profile_repository.dart'; +import 'package:hiddify/features/profile/model/profile_entity.dart'; +import 'package:hiddify/features/profile/model/profile_sort_enum.dart'; +import 'package:hiddify/utils/utils.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'profiles_overview_notifier.g.dart'; + +@riverpod +class ProfilesOverviewSortNotifier extends _$ProfilesOverviewSortNotifier + with AppLogger { + @override + ({ProfilesSort by, SortMode mode}) build() { + return (by: ProfilesSort.lastUpdate, mode: SortMode.descending); + } + + void changeSort(ProfilesSort sortBy) => + state = (by: sortBy, mode: state.mode); + + void toggleMode() => state = ( + by: state.by, + mode: state.mode == SortMode.ascending + ? SortMode.descending + : SortMode.ascending + ); +} + +@riverpod +class ProfilesOverviewNotifier extends _$ProfilesOverviewNotifier + with AppLogger { + @override + Stream> build() { + final sort = ref.watch(profilesOverviewSortNotifierProvider); + return _profilesRepo + .watchAll(sort: sort.by, sortMode: sort.mode) + .map((event) => event.getOrElse((l) => throw l)); + } + + ProfileRepository get _profilesRepo => + ref.read(profileRepositoryProvider).requireValue; + + Future selectActiveProfile(String id) async { + loggy.debug('changing active profile to: [$id]'); + return _profilesRepo.setAsActive(id).getOrElse((err) { + loggy.warning('failed to set [$id] as active profile', err); + throw err; + }).run(); + } + + Future deleteProfile(ProfileEntity profile) async { + loggy.debug('deleting profile: ${profile.name}'); + await _profilesRepo.deleteById(profile.id).match( + (err) { + loggy.warning('failed to delete profile', err); + throw err; + }, + (_) { + loggy.info( + 'successfully deleted profile, was active? [${profile.active}]', + ); + return unit; + }, + ).run(); + } + + Future exportConfigToClipboard(ProfileEntity profile) async { + await ref.read(coreFacadeProvider).generateConfig(profile.id).match( + (err) { + loggy.warning('error generating config', err); + throw err; + }, + (configJson) async { + await Clipboard.setData(ClipboardData(text: configJson)); + }, + ).run(); + } +} diff --git a/lib/features/profiles/view/profiles_modal.dart b/lib/features/profile/overview/profiles_overview_page.dart similarity index 81% rename from lib/features/profiles/view/profiles_modal.dart rename to lib/features/profile/overview/profiles_overview_page.dart index f207d9e8..105177d4 100644 --- a/lib/features/profiles/view/profiles_modal.dart +++ b/lib/features/profile/overview/profiles_overview_page.dart @@ -2,16 +2,15 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:hiddify/core/core_providers.dart'; import 'package:hiddify/core/router/router.dart'; -import 'package:hiddify/domain/enums.dart'; import 'package:hiddify/domain/failures.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; -import 'package:hiddify/features/common/profile_tile.dart'; -import 'package:hiddify/features/profiles/notifier/notifier.dart'; -import 'package:hiddify/utils/utils.dart'; +import 'package:hiddify/features/profile/model/profile_sort_enum.dart'; +import 'package:hiddify/features/profile/overview/profiles_overview_notifier.dart'; +import 'package:hiddify/features/profile/widget/profile_tile.dart'; +import 'package:hiddify/utils/placeholders.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -class ProfilesModal extends HookConsumerWidget { - const ProfilesModal({ +class ProfilesOverviewModal extends HookConsumerWidget { + const ProfilesOverviewModal({ super.key, this.scrollController, }); @@ -21,7 +20,7 @@ class ProfilesModal extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final t = ref.watch(translationsProvider); - final asyncProfiles = ref.watch(profilesNotifierProvider); + final asyncProfiles = ref.watch(profilesOverviewNotifierProvider); return Stack( children: [ @@ -85,12 +84,14 @@ class ProfilesSortModal extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final t = ref.watch(translationsProvider); + final sortNotifier = + ref.watch(profilesOverviewSortNotifierProvider.notifier); return AlertDialog( title: Text(t.general.sortBy), content: Consumer( builder: (context, ref, child) { - final sort = ref.watch(profilesSortNotifierProvider); + final sort = ref.watch(profilesOverviewSortNotifierProvider); return SingleChildScrollView( child: Column( children: [ @@ -104,13 +105,9 @@ class ProfilesSortModal extends HookConsumerWidget { title: Text(e.present(t)), onTap: () { if (selected) { - ref - .read(profilesSortNotifierProvider.notifier) - .toggleMode(); + sortNotifier.toggleMode(); } else { - ref - .read(profilesSortNotifierProvider.notifier) - .changeSort(e); + sortNotifier.changeSort(e); } }, selected: selected, @@ -118,9 +115,7 @@ class ProfilesSortModal extends HookConsumerWidget { trailing: selected ? IconButton( onPressed: () { - ref - .read(profilesSortNotifierProvider.notifier) - .toggleMode(); + sortNotifier.toggleMode(); }, icon: AnimatedRotation( turns: arrowTurn, diff --git a/lib/features/common/profile_tile.dart b/lib/features/profile/widget/profile_tile.dart similarity index 87% rename from lib/features/common/profile_tile.dart rename to lib/features/profile/widget/profile_tile.dart index c0c7781f..7d526491 100644 --- a/lib/features/common/profile_tile.dart +++ b/lib/features/profile/widget/profile_tile.dart @@ -7,10 +7,11 @@ import 'package:hiddify/core/core_providers.dart'; import 'package:hiddify/core/prefs/prefs.dart'; import 'package:hiddify/core/router/router.dart'; import 'package:hiddify/domain/failures.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; import 'package:hiddify/features/common/confirmation_dialogs.dart'; import 'package:hiddify/features/common/qr_code_dialog.dart'; -import 'package:hiddify/features/profiles/notifier/notifier.dart'; +import 'package:hiddify/features/profile/model/profile_entity.dart'; +import 'package:hiddify/features/profile/notifier/profile_notifier.dart'; +import 'package:hiddify/features/profile/overview/profiles_overview_notifier.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:percent_indicator/percent_indicator.dart'; @@ -22,7 +23,7 @@ class ProfileTile extends HookConsumerWidget { this.isMain = false, }); - final Profile profile; + final ProfileEntity profile; /// home screen active profile card final bool isMain; @@ -42,7 +43,7 @@ class ProfileTile extends HookConsumerWidget { ); final subInfo = switch (profile) { - RemoteProfile(:final subInfo) => subInfo, + RemoteProfileEntity(:final subInfo) => subInfo, _ => null, }; @@ -65,7 +66,7 @@ class ProfileTile extends HookConsumerWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (profile is RemoteProfile || !isMain) ...[ + if (profile is RemoteProfileEntity || !isMain) ...[ SizedBox( width: 48, child: Semantics( @@ -95,7 +96,7 @@ class ProfileTile extends HookConsumerWidget { if (profile.active) return; selectActiveMutation.setFuture( ref - .read(profilesNotifierProvider.notifier) + .read(profilesOverviewNotifierProvider.notifier) .selectActiveProfile(profile.id), ); } @@ -173,39 +174,27 @@ class ProfileTile extends HookConsumerWidget { class ProfileActionButton extends HookConsumerWidget { const ProfileActionButton(this.profile, this.showAllActions, {super.key}); - final Profile profile; + final ProfileEntity profile; final bool showAllActions; @override Widget build(BuildContext context, WidgetRef ref) { final t = ref.watch(translationsProvider); - final updateProfileMutation = useMutation( - initialOnFailure: (err) { - CustomAlertDialog.fromErr( - t.presentError(err, action: t.profile.update.failureMsg), - ).show(context); - }, - initialOnSuccess: () => - CustomToast.success(t.profile.update.successMsg).show(context), - ); - - if (profile case RemoteProfile() when !showAllActions) { + if (profile case RemoteProfileEntity() when !showAllActions) { return Semantics( button: true, - enabled: !updateProfileMutation.state.isInProgress, + enabled: !ref.watch(updateProfileProvider(profile.id)).isLoading, child: Tooltip( message: t.profile.update.tooltip, child: InkWell( onTap: () { - if (updateProfileMutation.state.isInProgress) { + if (ref.read(updateProfileProvider(profile.id)).isLoading) { return; } - updateProfileMutation.setFuture( - ref - .read(profilesNotifierProvider.notifier) - .updateProfile(profile as RemoteProfile), - ); + ref + .read(updateProfileProvider(profile.id).notifier) + .updateProfile(profile as RemoteProfileEntity); }, child: const Icon(Icons.update), ), @@ -239,7 +228,7 @@ class ProfileActionButton extends HookConsumerWidget { class ProfileActionsMenu extends HookConsumerWidget { const ProfileActionsMenu(this.profile, this.builder, {super.key, this.child}); - final Profile profile; + final ProfileEntity profile; final MenuAnchorChildBuilder builder; final Widget? child; @@ -247,15 +236,6 @@ class ProfileActionsMenu extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final t = ref.watch(translationsProvider); - final updateProfileMutation = useMutation( - initialOnFailure: (err) { - CustomAlertDialog.fromErr( - t.presentError(err, action: t.profile.update.failureMsg), - ).show(context); - }, - initialOnSuccess: () => - CustomToast.success(t.profile.update.successMsg).show(context), - ); final exportConfigMutation = useMutation( initialOnFailure: (err) { CustomToast.error(t.presentShortError(err)).show(context); @@ -273,24 +253,22 @@ class ProfileActionsMenu extends HookConsumerWidget { return MenuAnchor( builder: builder, menuChildren: [ - if (profile case RemoteProfile()) + if (profile case RemoteProfileEntity()) MenuItemButton( leadingIcon: const Icon(Icons.update), child: Text(t.profile.update.buttonTxt), onPressed: () { - if (updateProfileMutation.state.isInProgress) { + if (ref.read(updateProfileProvider(profile.id)).isLoading) { return; } - updateProfileMutation.setFuture( - ref - .read(profilesNotifierProvider.notifier) - .updateProfile(profile as RemoteProfile), - ); + ref + .read(updateProfileProvider(profile.id).notifier) + .updateProfile(profile as RemoteProfileEntity); }, ), SubmenuButton( menuChildren: [ - if (profile case RemoteProfile(:final url, :final name)) ...[ + if (profile case RemoteProfileEntity(:final url, :final name)) ...[ MenuItemButton( child: Text(t.profile.share.exportSubLinkToClipboard), onPressed: () async { @@ -325,7 +303,7 @@ class ProfileActionsMenu extends HookConsumerWidget { } exportConfigMutation.setFuture( ref - .read(profilesNotifierProvider.notifier) + .read(profilesOverviewNotifierProvider.notifier) .exportConfigToClipboard(profile), ); }, @@ -356,7 +334,7 @@ class ProfileActionsMenu extends HookConsumerWidget { if (deleteConfirmed) { deleteProfileMutation.setFuture( ref - .read(profilesNotifierProvider.notifier) + .read(profilesOverviewNotifierProvider.notifier) .deleteProfile(profile), ); } diff --git a/lib/features/profile_detail/notifier/notifier.dart b/lib/features/profile_detail/notifier/notifier.dart deleted file mode 100644 index a6381143..00000000 --- a/lib/features/profile_detail/notifier/notifier.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'profile_detail_notifier.dart'; -export 'profile_detail_state.dart'; diff --git a/lib/features/profile_detail/notifier/profile_detail_state.dart b/lib/features/profile_detail/notifier/profile_detail_state.dart deleted file mode 100644 index 344cd7c5..00000000 --- a/lib/features/profile_detail/notifier/profile_detail_state.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; -import 'package:hiddify/utils/utils.dart'; - -part 'profile_detail_state.freezed.dart'; - -@freezed -class ProfileDetailState with _$ProfileDetailState { - const ProfileDetailState._(); - - const factory ProfileDetailState({ - required Profile profile, - @Default(false) bool isEditing, - @Default(false) bool showErrorMessages, - @Default(MutationState.initial()) MutationState save, - @Default(MutationState.initial()) MutationState update, - @Default(MutationState.initial()) MutationState delete, - }) = _ProfileDetailState; - - bool get isBusy => - save.isInProgress || delete.isInProgress || update.isInProgress; -} diff --git a/lib/features/profile_detail/view/view.dart b/lib/features/profile_detail/view/view.dart deleted file mode 100644 index bcb57dd1..00000000 --- a/lib/features/profile_detail/view/view.dart +++ /dev/null @@ -1 +0,0 @@ -export 'profile_detail_page.dart'; diff --git a/lib/features/profiles/notifier/notifier.dart b/lib/features/profiles/notifier/notifier.dart deleted file mode 100644 index 7fc6e689..00000000 --- a/lib/features/profiles/notifier/notifier.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'profiles_notifier.dart'; -export 'profiles_update_notifier.dart'; diff --git a/lib/features/profiles/notifier/profiles_notifier.dart b/lib/features/profiles/notifier/profiles_notifier.dart deleted file mode 100644 index 6fb165e5..00000000 --- a/lib/features/profiles/notifier/profiles_notifier.dart +++ /dev/null @@ -1,140 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/services.dart'; -import 'package:fpdart/fpdart.dart'; -import 'package:hiddify/core/prefs/prefs.dart'; -import 'package:hiddify/data/data_providers.dart'; -import 'package:hiddify/domain/enums.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; -import 'package:hiddify/features/common/active_profile/active_profile_notifier.dart'; -import 'package:hiddify/features/common/connectivity/connectivity_controller.dart'; -import 'package:hiddify/utils/utils.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'profiles_notifier.g.dart'; - -@riverpod -class ProfilesSortNotifier extends _$ProfilesSortNotifier with AppLogger { - @override - ({ProfilesSort by, SortMode mode}) build() { - return (by: ProfilesSort.lastUpdate, mode: SortMode.descending); - } - - void changeSort(ProfilesSort sortBy) => - state = (by: sortBy, mode: state.mode); - - void toggleMode() => state = ( - by: state.by, - mode: state.mode == SortMode.ascending - ? SortMode.descending - : SortMode.ascending - ); -} - -@riverpod -class ProfilesNotifier extends _$ProfilesNotifier with AppLogger { - @override - Stream> build() { - final sort = ref.watch(profilesSortNotifierProvider); - return _profilesRepo - .watchAll(sort: sort.by, mode: sort.mode) - .map((event) => event.getOrElse((l) => throw l)); - } - - ProfilesRepository get _profilesRepo => ref.read(profilesRepositoryProvider); - - Future selectActiveProfile(String id) async { - loggy.debug('changing active profile to: [$id]'); - return _profilesRepo.setAsActive(id).getOrElse((err) { - loggy.warning('failed to set [$id] as active profile', err); - throw err; - }).run(); - } - - Future addProfile(String rawInput) async { - final activeProfile = await ref.read(activeProfileProvider.future); - final markAsActive = - activeProfile == null || ref.read(markNewProfileActiveProvider); - final TaskEither task; - if (LinkParser.parse(rawInput) case (final link)?) { - loggy.debug("adding profile, url: [${link.url}]"); - task = ref - .read(profilesRepositoryProvider) - .addByUrl(link.url, markAsActive: markAsActive); - } else if (LinkParser.protocol(rawInput) case (final parsed)?) { - loggy.debug("adding profile, content"); - task = ref.read(profilesRepositoryProvider).addByContent( - parsed.content, - name: parsed.name, - markAsActive: markAsActive, - ); - } else { - loggy.debug("invalid content"); - throw const ProfileInvalidUrlFailure(); - } - return task.match( - (err) { - loggy.warning("failed to add profile", err); - throw err; - }, - (_) { - loggy.info( - "successfully added profile, mark as active? [$markAsActive]", - ); - return unit; - }, - ).run(); - } - - Future updateProfile(RemoteProfile profile) async { - loggy.debug("updating profile"); - return await ref.read(profilesRepositoryProvider).update(profile).match( - (err) { - loggy.warning("failed to update profile", err); - throw err; - }, - (_) async { - loggy.info( - 'successfully updated profile, was active? [${profile.active}]', - ); - - await ref.read(activeProfileProvider.future).then((active) async { - if (active != null && active.id == profile.id) { - await ref - .read(connectivityControllerProvider.notifier) - .reconnect(profile.id); - } - }); - return unit; - }, - ).run(); - } - - Future deleteProfile(Profile profile) async { - loggy.debug('deleting profile: ${profile.name}'); - await _profilesRepo.delete(profile.id).match( - (err) { - loggy.warning('failed to delete profile', err); - throw err; - }, - (_) { - loggy.info( - 'successfully deleted profile, was active? [${profile.active}]', - ); - return unit; - }, - ).run(); - } - - Future exportConfigToClipboard(Profile profile) async { - await ref.read(coreFacadeProvider).generateConfig(profile.id).match( - (err) { - loggy.warning('error generating config', err); - throw err; - }, - (configJson) async { - await Clipboard.setData(ClipboardData(text: configJson)); - }, - ).run(); - } -} diff --git a/lib/features/profiles/notifier/profiles_update_notifier.dart b/lib/features/profiles/notifier/profiles_update_notifier.dart deleted file mode 100644 index cccc9588..00000000 --- a/lib/features/profiles/notifier/profiles_update_notifier.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:fpdart/fpdart.dart'; -import 'package:hiddify/data/data_providers.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; -import 'package:hiddify/services/service_providers.dart'; -import 'package:hiddify/utils/utils.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'profiles_update_notifier.g.dart'; - -typedef ProfileUpdateResult = ({ - String name, - Either failureOrSuccess -}); - -@Riverpod(keepAlive: true) -class ProfilesUpdateNotifier extends _$ProfilesUpdateNotifier with AppLogger { - @override - Stream build() { - _schedule(); - return const Stream.empty(); - } - - Future _schedule() async { - loggy.debug("scheduling profiles update worker"); - return ref.read(cronServiceProvider).schedule( - key: 'profiles_update', - duration: const Duration(minutes: 10), - callback: () async { - final failureOrProfiles = - await ref.read(profilesRepositoryProvider).watchAll().first; - if (failureOrProfiles case Right(value: final profiles)) { - for (final profile in profiles) { - if (profile case RemoteProfile()) { - loggy.debug("checking profile: [${profile.name}]"); - final updateInterval = profile.options?.updateInterval; - if (updateInterval != null && - updateInterval <= - DateTime.now().difference(profile.lastUpdate)) { - final failureOrSuccess = await ref - .read(profilesRepositoryProvider) - .update(profile) - .run(); - state = AsyncData( - (name: profile.name, failureOrSuccess: failureOrSuccess), - ); - } else { - loggy.debug("skipping profile: [${profile.name}]"); - } - } - } - } - }, - ); - } -} diff --git a/lib/features/profiles/view/view.dart b/lib/features/profiles/view/view.dart deleted file mode 100644 index cb18b1bf..00000000 --- a/lib/features/profiles/view/view.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'add_profile_modal.dart'; -export 'profiles_modal.dart'; diff --git a/lib/services/cron_service.dart b/lib/services/cron_service.dart deleted file mode 100644 index d3fe1cb9..00000000 --- a/lib/services/cron_service.dart +++ /dev/null @@ -1,73 +0,0 @@ -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); - } - - 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: 10), - timeout: const Duration(minutes: 5), - minCycle: const Duration(minutes: 2), - 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/files_editor_service.dart b/lib/services/files_editor_service.dart index 8b6995d3..9de8515e 100644 --- a/lib/services/files_editor_service.dart +++ b/lib/services/files_editor_service.dart @@ -1,6 +1,5 @@ import 'dart:io'; -import 'package:hiddify/domain/constants.dart'; import 'package:hiddify/services/platform_services.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:path/path.dart' as p; @@ -20,8 +19,6 @@ class FilesEditorService with InfraLogger { late final Directories dirs; Directory get workingDir => dirs.workingDir; - Directory get configsDir => - Directory(p.join(workingDir.path, Constants.configsFolderName)); Directory get logsDir => dirs.workingDir; File get appLogsFile => File(p.join(logsDir.path, "app.log")); @@ -43,9 +40,6 @@ class FilesEditorService with InfraLogger { if (!await dirs.workingDir.exists()) { await dirs.workingDir.create(recursive: true); } - if (!await configsDir.exists()) { - await configsDir.create(recursive: true); - } if (await appLogsFile.exists()) { await appLogsFile.writeAsString(""); @@ -68,14 +62,4 @@ class FilesEditorService with InfraLogger { } return getApplicationDocumentsDirectory(); } - - String configPath(String fileName) { - return p.join(configsDir.path, "$fileName.json"); - } - - String tempConfigPath(String fileName) => configPath("temp_$fileName"); - - Future deleteConfig(String fileName) { - return File(configPath(fileName)).delete(); - } } diff --git a/lib/services/service_providers.dart b/lib/services/service_providers.dart index ae7445e0..6b92132a 100644 --- a/lib/services/service_providers.dart +++ b/lib/services/service_providers.dart @@ -1,5 +1,3 @@ -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_services.dart'; import 'package:hiddify/services/singbox/singbox_service.dart'; @@ -17,10 +15,3 @@ SingboxService singboxService(SingboxServiceRef ref) => SingboxService(); @Riverpod(keepAlive: true) PlatformServices platformServices(PlatformServicesRef ref) => PlatformServices(); - -@Riverpod(keepAlive: true) -CronService cronService(CronServiceRef ref) { - final service = CronService(ref.watch(sharedPreferencesProvider)); - ref.onDispose(() => service.stopScheduler()); - return service; -} diff --git a/test/domain/profiles/profile_test.dart b/test/features/profile/data/profile_parser_test.dart similarity index 69% rename from test/domain/profiles/profile_test.dart rename to test/features/profile/data/profile_parser_test.dart index 15bfa2d0..96737245 100644 --- a/test/domain/profiles/profile_test.dart +++ b/test/features/profile/data/profile_parser_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; +import 'package:hiddify/features/profile/data/profile_parser.dart'; +import 'package:hiddify/features/profile/model/profile_entity.dart'; void main() { const validBaseUrl = "https://example.com/configurations/user1/filename.yaml"; @@ -8,14 +9,26 @@ void main() { const validSupportUrl = "https://example.com/support"; group( - "profile fromResponse", + "parse", () { test( - "with no additional metadata", + "url with file extension, no headers", () { - final profile = Profile.fromResponse(validExtendedUrl, {}); + final profile = ProfileParser.parse(validBaseUrl, {}); expect(profile.name, equals("filename")); + expect(profile.url, equals(validBaseUrl)); + expect(profile.options, isNull); + expect(profile.subInfo, isNull); + }, + ); + + test( + "url with url, no headers", + () { + final profile = ProfileParser.parse(validExtendedUrl, {}); + + expect(profile.name, equals("b")); expect(profile.url, equals(validExtendedUrl)); expect(profile.options, isNull); expect(profile.subInfo, isNull); @@ -23,7 +36,7 @@ void main() { ); test( - "with all metadata", + "with base64 profile-title header", () { final headers = >{ "profile-title": ["base64:ZXhhbXBsZVRpdGxl"], @@ -34,7 +47,7 @@ void main() { "profile-web-page-url": [validBaseUrl], "support-url": [validSupportUrl], }; - final profile = Profile.fromResponse(validExtendedUrl, headers); + final profile = ProfileParser.parse(validExtendedUrl, headers); expect(profile.name, equals("exampleTitle")); expect(profile.url, equals(validExtendedUrl));