Refactor profiles

This commit is contained in:
problematicconsumer
2023-11-26 21:20:58 +03:30
parent e2f5f51176
commit 829d58a1a2
49 changed files with 1206 additions and 1024 deletions

View File

@@ -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<void> _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());

View File

@@ -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<void> 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,
};
}

View File

@@ -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<void> 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),
);
}
}

View File

@@ -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<void> 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),
),
],
);
}
}

View File

@@ -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),

View File

@@ -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,
),
};
}
}

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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<CoreServiceFailure, String>.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<CoreServiceFailure, Unit>.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();

View File

@@ -1,2 +1 @@
export 'core_facade_impl.dart';
export 'profiles_repository_impl.dart';

View File

@@ -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<String, List<String>> 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<String, dynamic> json) =>
_$ProfileFromJson(json);
}
@freezed
class ProfileOptions with _$ProfileOptions {
const factory ProfileOptions({
required Duration updateInterval,
}) = _ProfileOptions;
factory ProfileOptions.fromJson(Map<String, dynamic> 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<String, dynamic> 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,
);
}

View File

@@ -1,4 +0,0 @@
export 'profile.dart';
export 'profile_enums.dart';
export 'profiles_failure.dart';
export 'profiles_repository.dart';

View File

@@ -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<ProfileFailure, Profile?> get(String id);
Stream<Either<ProfileFailure, Profile?>> watchActiveProfile();
Stream<Either<ProfileFailure, bool>> watchHasAnyProfile();
Stream<Either<ProfileFailure, List<Profile>>> watchAll({
ProfilesSort sort = ProfilesSort.lastUpdate,
SortMode mode = SortMode.ascending,
});
TaskEither<ProfileFailure, Unit> addByUrl(
String url, {
bool markAsActive = false,
});
TaskEither<ProfileFailure, Unit> addByContent(
String content, {
required String name,
bool markAsActive = false,
});
TaskEither<ProfileFailure, Unit> add(RemoteProfile baseProfile);
TaskEither<ProfileFailure, Unit> update(RemoteProfile baseProfile);
TaskEither<ProfileFailure, Unit> edit(Profile profile);
TaskEither<ProfileFailure, Unit> setAsActive(String id);
TaskEither<ProfileFailure, Unit> delete(String id);
}

View File

@@ -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<Profile?> build() {
loggy.debug("watching active profile");
return ref
.watch(profilesRepositoryProvider)
.watchActiveProfile()
.map((event) => event.getOrElse((l) => throw l));
}
}

View File

@@ -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<bool> hasAnyProfile(
HasAnyProfileRef ref,
) {
return ref
.watch(profilesRepositoryProvider)
.watchHasAnyProfile()
.map((event) => event.getOrElse((l) => throw l));
}

View File

@@ -100,26 +100,4 @@ class AppUpdateNotifier extends _$AppUpdateNotifier with AppLogger {
await _ignoreReleasePref.update(versionInfo.version);
state = AppUpdateStateIgnored(versionInfo);
}
// Future<void> _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);
// }
// }
// },
// );
// }
}

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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';

View File

@@ -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),

View File

@@ -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,
),
};
}
}

View File

@@ -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> 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,
);
}

View File

@@ -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<ProfileEntry?> getById(String id);
Future<ProfileEntry?> getByUrl(String url);
Stream<ProfileEntry?> watchActiveProfile();
Stream<int> watchProfilesCount();
Stream<List<ProfileEntry>> watchAll({
required ProfilesSort sort,
required SortMode sortMode,
});
Future<void> insert(ProfileEntriesCompanion entry);
Future<void> edit(String id, ProfileEntriesCompanion entry);
Future<void> deleteById(String id);
}
Map<SortMode, OrderingMode> orderMap = {
SortMode.ascending: OrderingMode.asc,
@@ -14,41 +26,45 @@ Map<SortMode, OrderingMode> orderMap = {
};
@DriftAccessor(tables: [ProfileEntries])
class ProfilesDao extends DatabaseAccessor<AppDatabase>
with _$ProfilesDaoMixin, InfraLogger {
ProfilesDao(super.db);
class ProfileDao extends DatabaseAccessor<AppDatabase>
with _$ProfileDaoMixin, InfraLogger
implements ProfileDataSource {
ProfileDao(super.db);
Future<Profile?> getById(String id) async {
@override
Future<ProfileEntry?> getById(String id) async {
return (profileEntries.select()..where((tbl) => tbl.id.equals(id)))
.map(ProfileMapper.fromEntry)
.getSingleOrNull();
}
Future<Profile?> getProfileByUrl(String url) async {
return (select(profileEntries)..where((tbl) => tbl.url.like('%$url%')))
.map(ProfileMapper.fromEntry)
.get()
.then((value) => value.firstOrNull);
@override
Future<ProfileEntry?> getByUrl(String url) async {
return (select(profileEntries)
..where((tbl) => tbl.url.like('%$url%'))
..limit(1))
.getSingleOrNull();
}
Stream<Profile?> watchActiveProfile() {
@override
Stream<ProfileEntry?> watchActiveProfile() {
return (profileEntries.select()
..where((tbl) => tbl.active.equals(true))
..limit(1))
.map(ProfileMapper.fromEntry)
.watchSingleOrNull();
}
Stream<int> watchProfileCount() {
@override
Stream<int> watchProfilesCount() {
final count = profileEntries.id.count();
return (profileEntries.selectOnly()..addColumns([count]))
.map((exp) => exp.read(count)!)
.watchSingle();
}
Stream<List<Profile>> watchAll({
ProfilesSort sort = ProfilesSort.lastUpdate,
SortMode mode = SortMode.ascending,
@override
Stream<List<ProfileEntry>> watchAll({
required ProfilesSort sort,
required SortMode sortMode,
}) {
return (profileEntries.select()
..orderBy(
@@ -67,56 +83,47 @@ class ProfilesDao extends DatabaseAccessor<AppDatabase>
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<void> create(Profile profile) async {
@override
Future<void> 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<void> edit(Profile patch) async {
@override
Future<void> 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<void> 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<void> removeById(String id) async {
@override
Future<void> deleteById(String id) async {
await transaction(
() async {
await (delete(profileEntries)..where((tbl) => tbl.id.equals(id))).go();

View File

@@ -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<String, List<String>> 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;
}
}

View File

@@ -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");
}

View File

@@ -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<ProfileFailure, Unit> init();
TaskEither<ProfileFailure, ProfileEntity?> getById(String id);
Stream<Either<ProfileFailure, ProfileEntity?>> watchActiveProfile();
Stream<Either<ProfileFailure, bool>> watchHasAnyProfile();
Stream<Either<ProfileFailure, List<ProfileEntity>>> watchAll({
ProfilesSort sort = ProfilesSort.lastUpdate,
SortMode sortMode = SortMode.ascending,
});
TaskEither<ProfileFailure, Unit> addByUrl(
String url, {
bool markAsActive = false,
});
TaskEither<ProfileFailure, Unit> addByContent(
String content, {
required String name,
bool markAsActive = false,
});
TaskEither<ProfileFailure, Unit> add(RemoteProfileEntity baseProfile);
TaskEither<ProfileFailure, Unit> updateSubscription(
RemoteProfileEntity baseProfile,
);
TaskEither<ProfileFailure, Unit> patch(ProfileEntity profile);
TaskEither<ProfileFailure, Unit> setAsActive(String id);
TaskEither<ProfileFailure, Unit> 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<CoreServiceFailure, Unit> Function(
String path,
String tempPath,
bool debug,
) configValidator;
final Dio dio;
@override
TaskEither<ProfileFailure, Profile?> get(String id) {
return TaskEither.tryCatch(
() => profilesDao.getById(id),
TaskEither<ProfileFailure, Unit> init() {
return exceptionHandler(
() async {
if (!await profilePathResolver.directory.exists()) {
await profilePathResolver.directory.create(recursive: true);
}
return right(unit);
},
ProfileUnexpectedFailure.new,
);
}
@override
Stream<Either<ProfileFailure, Profile?>> watchActiveProfile() {
return profilesDao.watchActiveProfile().handleExceptions(
TaskEither<ProfileFailure, ProfileEntity?> getById(String id) {
return TaskEither.tryCatch(
() => profileDataSource.getById(id).then((value) => value?.toEntity()),
ProfileUnexpectedFailure.new,
);
}
@override
Stream<Either<ProfileFailure, ProfileEntity?>> 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<Either<ProfileFailure, bool>> watchHasAnyProfile() {
return profilesDao
.watchProfileCount()
return profileDataSource
.watchProfilesCount()
.map((event) => event != 0)
.handleExceptions(ProfileUnexpectedFailure.new);
}
@override
Stream<Either<ProfileFailure, List<Profile>>> watchAll({
Stream<Either<ProfileFailure, List<ProfileEntity>>> 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<ProfileFailure, Unit> add(RemoteProfile baseProfile) {
TaskEither<ProfileFailure, Unit> 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<ProfileFailure, Unit> update(RemoteProfile baseProfile) {
TaskEither<ProfileFailure, Unit> 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<ProfileFailure, Unit> edit(Profile profile) {
TaskEither<ProfileFailure, Unit> 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<ProfileFailure, Unit> 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<ProfileFailure, Unit> delete(String id) {
TaskEither<ProfileFailure, Unit> 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<ProfileFailure, RemoteProfile> fetch(
TaskEither<ProfileFailure, RemoteProfileEntity> 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();
}
},
);

View File

@@ -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<ProfileDetailState> build(
Future<ProfileDetailsState> 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<int>? updateInterval}) {
if (state case AsyncData(:final value)) {
@@ -74,41 +77,47 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger {
Future<void> save() async {
if (state case AsyncData(:final value)) {
if (value.save.isInProgress) return;
if (value.save case AsyncLoading()) return;
final profile = value.profile;
Either<ProfileFailure, Unit>? 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<void> 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<void> 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();
}),
),
);
}

View File

@@ -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,

View File

@@ -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<void>? save,
AsyncValue<void>? update,
AsyncValue<void>? delete,
}) = _ProfileDetailsState;
bool get isBusy =>
save is AsyncLoading || delete is AsyncLoading || update is AsyncLoading;
}

View File

@@ -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());
}

View File

@@ -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 {

View File

@@ -17,3 +17,5 @@ enum ProfilesSort {
name => Icons.sort_by_alpha,
};
}
enum SortMode { ascending, descending }

View File

@@ -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<ProfileEntity?> 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<bool> hasAnyProfile(
HasAnyProfileRef ref,
) {
return ref
.watch(profileRepositoryProvider)
.requireValue
.watchHasAnyProfile()
.map((event) => event.getOrElse((l) => throw l));
}

View File

@@ -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<Unit?> 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<void> 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<ProfileFailure, Unit> 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<Unit?> 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<void> 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();
},
);
}
}

View File

@@ -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<void> 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<void> 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<RemoteProfileEntity>(),
)
.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());
}
}
}

View File

@@ -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<List<ProfileEntity>> 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<Unit> 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<void> 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<void> 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();
}
}

View File

@@ -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,

View File

@@ -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),
);
}

View File

@@ -1,2 +0,0 @@
export 'profile_detail_notifier.dart';
export 'profile_detail_state.dart';

View File

@@ -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<ProfileFailure> save,
@Default(MutationState.initial()) MutationState<ProfileFailure> update,
@Default(MutationState.initial()) MutationState<ProfileFailure> delete,
}) = _ProfileDetailState;
bool get isBusy =>
save.isInProgress || delete.isInProgress || update.isInProgress;
}

View File

@@ -1 +0,0 @@
export 'profile_detail_page.dart';

View File

@@ -1,2 +0,0 @@
export 'profiles_notifier.dart';
export 'profiles_update_notifier.dart';

View File

@@ -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<List<Profile>> 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<Unit> 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<Unit> addProfile(String rawInput) async {
final activeProfile = await ref.read(activeProfileProvider.future);
final markAsActive =
activeProfile == null || ref.read(markNewProfileActiveProvider);
final TaskEither<ProfileFailure, Unit> 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<Unit?> 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<void> 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<void> 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();
}
}

View File

@@ -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<ProfileFailure, Unit> failureOrSuccess
});
@Riverpod(keepAlive: true)
class ProfilesUpdateNotifier extends _$ProfilesUpdateNotifier with AppLogger {
@override
Stream<ProfileUpdateResult> build() {
_schedule();
return const Stream.empty();
}
Future<void> _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}]");
}
}
}
}
},
);
}
}

View File

@@ -1,2 +0,0 @@
export 'add_profile_modal.dart';
export 'profiles_modal.dart';

View File

@@ -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<T> = (
String key,
Duration duration,
FutureOr<T?> Function() callback,
);
class CronService with InfraLogger {
CronService(this.prefs);
final SharedPreferences prefs;
NeatPeriodicTaskScheduler? _scheduler;
Map<String, Job> jobs = {};
void schedule<T>({
required String key,
required Duration duration,
required FutureOr<T?> Function() callback,
}) {
loggy.debug("scheduling [$key]");
jobs[key] = (key, duration, callback);
}
Future<void> 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<void> 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<void> stopScheduler() async {
loggy.debug("stopping job scheduler");
return _scheduler?.stop();
}
}

View File

@@ -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<void> deleteConfig(String fileName) {
return File(configPath(fileName)).delete();
}
}

View File

@@ -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;
}

View File

@@ -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 = <String, List<String>>{
"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));