From cad4e47ee510b26154064928cc61e9a82a892c5f Mon Sep 17 00:00:00 2001 From: problematicconsumer Date: Tue, 25 Jul 2023 21:41:12 +0330 Subject: [PATCH] Add extra profile metadata --- lib/data/local/data_mappers.dart | 52 ++++++--- lib/data/local/tables.dart | 8 +- lib/domain/profiles/profile.dart | 125 ++++++++++++++++++++- lib/domain/profiles/profiles.dart | 1 - lib/domain/profiles/subscription_info.dart | 44 -------- lib/features/common/profile_tile.dart | 27 +++-- test/domain/profiles/profile_test.dart | 72 ++++++++++++ 7 files changed, 249 insertions(+), 80 deletions(-) delete mode 100644 lib/domain/profiles/subscription_info.dart create mode 100644 test/domain/profiles/profile_test.dart diff --git a/lib/data/local/data_mappers.dart b/lib/data/local/data_mappers.dart index 800fcfc3..43ddccd2 100644 --- a/lib/data/local/data_mappers.dart +++ b/lib/data/local/data_mappers.dart @@ -10,28 +10,52 @@ extension ProfileMapper on Profile { name: name, url: url, lastUpdate: lastUpdate, + updateInterval: Value(options?.updateInterval), upload: Value(subInfo?.upload), download: Value(subInfo?.download), total: Value(subInfo?.total), expire: Value(subInfo?.expire), - updateInterval: Value(updateInterval), + webPageUrl: Value(extra?.webPageUrl), + supportUrl: Value(extra?.supportUrl), ); } - static Profile fromEntry(ProfileEntry entry) { + 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!, + ); + } + + ProfileExtra? extra; + if (e.webPageUrl != null || e.supportUrl != null) { + extra = ProfileExtra( + webPageUrl: e.webPageUrl, + supportUrl: e.supportUrl, + ); + } + return Profile( - id: entry.id, - active: entry.active, - name: entry.name, - url: entry.url, - lastUpdate: entry.lastUpdate, - updateInterval: entry.updateInterval, - subInfo: SubscriptionInfo( - upload: entry.upload, - download: entry.download, - total: entry.total, - expire: entry.expire, - ), + id: e.id, + active: e.active, + name: e.name, + url: e.url, + lastUpdate: e.lastUpdate, + options: options, + subInfo: subInfo, + extra: extra, ); } } diff --git a/lib/data/local/tables.dart b/lib/data/local/tables.dart index 0f4726a1..0ae36057 100644 --- a/lib/data/local/tables.dart +++ b/lib/data/local/tables.dart @@ -7,13 +7,15 @@ class ProfileEntries extends Table { BoolColumn get active => boolean()(); TextColumn get name => text().withLength(min: 1)(); TextColumn get url => text()(); + DateTimeColumn get lastUpdate => dateTime()(); + IntColumn get updateInterval => + integer().nullable().map(DurationTypeConverter())(); IntColumn get upload => integer().nullable()(); IntColumn get download => integer().nullable()(); IntColumn get total => integer().nullable()(); DateTimeColumn get expire => dateTime().nullable()(); - IntColumn get updateInterval => - integer().nullable().map(DurationTypeConverter())(); - DateTimeColumn get lastUpdate => dateTime()(); + TextColumn get webPageUrl => text().nullable()(); + TextColumn get supportUrl => text().nullable()(); @override Set get primaryKey => {id}; diff --git a/lib/domain/profiles/profile.dart b/lib/domain/profiles/profile.dart index ac673d11..9a16e096 100644 --- a/lib/domain/profiles/profile.dart +++ b/lib/domain/profiles/profile.dart @@ -1,5 +1,8 @@ +import 'dart:convert'; + +import 'package:dartx/dartx.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:hiddify/domain/profiles/profiles.dart'; +import 'package:uuid/uuid.dart'; part 'profile.freezed.dart'; part 'profile.g.dart'; @@ -13,13 +16,127 @@ class Profile with _$Profile { required bool active, required String name, required String url, - SubscriptionInfo? subInfo, - Duration? updateInterval, required DateTime lastUpdate, + ProfileOptions? options, + SubscriptionInfo? subInfo, + ProfileExtra? extra, }) = _Profile; - bool get hasSubscriptionInfo => subInfo?.isValid ?? false; + // TODO add content disposition parsing + factory Profile.fromResponse( + String url, + Map> 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 part = url.split("/").lastOrNull; + if (part != null) { + final pattern = RegExp(r"\.(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; + ProfileExtra? extra; + if (webPageUrlHeader != null || supportUrlHeader != null) { + extra = ProfileExtra( + webPageUrl: webPageUrlHeader, + supportUrl: supportUrlHeader, + ); + } + + return Profile( + id: const Uuid().v4(), + active: false, + name: title, + url: url, + lastUpdate: DateTime.now(), + options: options, + subInfo: subInfo, + extra: extra, + ); + } 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 ProfileExtra with _$ProfileExtra { + const factory ProfileExtra({ + String? webPageUrl, + String? supportUrl, + }) = _ProfileExtra; + + factory ProfileExtra.fromJson(Map json) => + _$ProfileExtraFromJson(json); +} + +@freezed +class SubscriptionInfo with _$SubscriptionInfo { + const SubscriptionInfo._(); + + const factory SubscriptionInfo({ + required int upload, + required int download, + required int total, + @JsonKey(fromJson: _dateTimeFromSecondsSinceEpoch) required DateTime expire, + }) = _SubscriptionInfo; + + bool get isExpired => expire <= DateTime.now(); + + int get consumption => upload + download; + + double get ratio => consumption / total; + + 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: int.tryParse(v.split('=').second) + }; + return SubscriptionInfo.fromJson(map); + } + + factory SubscriptionInfo.fromJson(Map json) => + _$SubscriptionInfoFromJson(json); +} + +DateTime _dateTimeFromSecondsSinceEpoch(dynamic expire) => + DateTime.fromMillisecondsSinceEpoch((expire as int) * 1000); diff --git a/lib/domain/profiles/profiles.dart b/lib/domain/profiles/profiles.dart index fb00b35b..4ef76748 100644 --- a/lib/domain/profiles/profiles.dart +++ b/lib/domain/profiles/profiles.dart @@ -1,4 +1,3 @@ export 'profile.dart'; export 'profiles_failure.dart'; export 'profiles_repository.dart'; -export 'subscription_info.dart'; diff --git a/lib/domain/profiles/subscription_info.dart b/lib/domain/profiles/subscription_info.dart deleted file mode 100644 index dd8e5e74..00000000 --- a/lib/domain/profiles/subscription_info.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:dartx/dartx.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'subscription_info.freezed.dart'; -part 'subscription_info.g.dart'; - -// TODO: test and improve -@freezed -class SubscriptionInfo with _$SubscriptionInfo { - const SubscriptionInfo._(); - - const factory SubscriptionInfo({ - int? upload, - int? download, - int? total, - @JsonKey(fromJson: _dateTimeFromSecondsSinceEpoch) DateTime? expire, - }) = _SubscriptionInfo; - - bool get isValid => - total != null && download != null && upload != null && expire != null; - - bool get isExpired => expire! <= DateTime.now(); - - int get consumption => upload! + download!; - - double get ratio => consumption / total!; - - 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: int.tryParse(v.split('=').second) - }; - return SubscriptionInfo.fromJson(map); - } - - factory SubscriptionInfo.fromJson(Map json) => - _$SubscriptionInfoFromJson(json); -} - -DateTime? _dateTimeFromSecondsSinceEpoch(dynamic expire) => - DateTime.fromMillisecondsSinceEpoch((expire as int) * 1000); diff --git a/lib/features/common/profile_tile.dart b/lib/features/common/profile_tile.dart index 66240317..be34b002 100644 --- a/lib/features/common/profile_tile.dart +++ b/lib/features/common/profile_tile.dart @@ -115,9 +115,9 @@ class ProfileTile extends HookConsumerWidget { profile.name, style: theme.textTheme.titleMedium, ), - if (subInfo?.isValid ?? false) ...[ + if (subInfo != null) ...[ const Gap(4), - RemainingTrafficIndicator(subInfo!.ratio), + RemainingTrafficIndicator(subInfo.ratio), const Gap(4), ProfileSubscriptionInfo(subInfo), const Gap(4), @@ -290,19 +290,18 @@ class ProfileSubscriptionInfo extends HookConsumerWidget { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - if (subInfo.total != null) - Text.rich( - TextSpan( - children: [ - TextSpan(text: formatByte(subInfo.consumption, unit: 3).size), - const TextSpan(text: " / "), - TextSpan(text: formatByte(subInfo.total!, unit: 3).size), - const TextSpan(text: " "), - TextSpan(text: t.profile.subscription.gigaByte), - ], - ), - style: theme.textTheme.bodySmall, + Text.rich( + TextSpan( + children: [ + TextSpan(text: formatByte(subInfo.consumption, unit: 3).size), + const TextSpan(text: " / "), + TextSpan(text: formatByte(subInfo.total, unit: 3).size), + const TextSpan(text: " "), + TextSpan(text: t.profile.subscription.gigaByte), + ], ), + style: theme.textTheme.bodySmall, + ), Text( remaining.$1, style: theme.textTheme.bodySmall?.copyWith(color: remaining.$2), diff --git a/test/domain/profiles/profile_test.dart b/test/domain/profiles/profile_test.dart new file mode 100644 index 00000000..7ef8dd5f --- /dev/null +++ b/test/domain/profiles/profile_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:hiddify/domain/profiles/profiles.dart'; + +void main() { + const validBaseUrl = "https://example.com/configurations/user1/filename.yaml"; + const validExtendedUrl = + "https://example.com/configurations/user1/filename.yaml?test#b"; + const validSupportUrl = "https://example.com/support"; + + group( + "profile fromResponse", + () { + test( + "with no additional metadata", + () { + final profile = Profile.fromResponse(validExtendedUrl, {}); + + expect(profile.name, equals("filename")); + expect(profile.url, equals(validExtendedUrl)); + expect(profile.options, isNull); + expect(profile.subInfo, isNull); + expect(profile.extra, isNull); + }, + ); + + test( + "with all metadata", + () { + final headers = >{ + // decoded: exampleTitle + "profile-title": ["base64:ZXhhbXBsZVRpdGxl"], + "profile-update-interval": ["1"], + // expire: 2024/1/1 + "subscription-userinfo": [ + "upload=0;download=1024;total=10240;expire=1704054600" + ], + "profile-web-page-url": [validBaseUrl], + "support-url": [validSupportUrl], + }; + final profile = Profile.fromResponse(validExtendedUrl, headers); + + expect(profile.name, equals("exampleTitle")); + expect(profile.url, equals(validExtendedUrl)); + expect( + profile.options, + equals(const ProfileOptions(updateInterval: Duration(hours: 1))), + ); + expect( + profile.subInfo, + equals( + SubscriptionInfo( + upload: 0, + download: 1024, + total: 10240, + expire: DateTime(2024), + ), + ), + ); + expect( + profile.extra, + equals( + const ProfileExtra( + webPageUrl: validBaseUrl, + supportUrl: validSupportUrl, + ), + ), + ); + }, + ); + }, + ); +}