Add extra profile metadata

This commit is contained in:
problematicconsumer
2023-07-25 21:41:12 +03:30
parent 2eb182f512
commit cad4e47ee5
7 changed files with 249 additions and 80 deletions

View File

@@ -10,28 +10,52 @@ extension ProfileMapper on Profile {
name: name, name: name,
url: url, url: url,
lastUpdate: lastUpdate, lastUpdate: lastUpdate,
updateInterval: Value(options?.updateInterval),
upload: Value(subInfo?.upload), upload: Value(subInfo?.upload),
download: Value(subInfo?.download), download: Value(subInfo?.download),
total: Value(subInfo?.total), total: Value(subInfo?.total),
expire: Value(subInfo?.expire), expire: Value(subInfo?.expire),
updateInterval: Value(updateInterval), webPageUrl: Value(extra?.webPageUrl),
supportUrl: Value(extra?.supportUrl),
);
}
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,
); );
} }
static Profile fromEntry(ProfileEntry entry) {
return Profile( return Profile(
id: entry.id, id: e.id,
active: entry.active, active: e.active,
name: entry.name, name: e.name,
url: entry.url, url: e.url,
lastUpdate: entry.lastUpdate, lastUpdate: e.lastUpdate,
updateInterval: entry.updateInterval, options: options,
subInfo: SubscriptionInfo( subInfo: subInfo,
upload: entry.upload, extra: extra,
download: entry.download,
total: entry.total,
expire: entry.expire,
),
); );
} }
} }

View File

@@ -7,13 +7,15 @@ class ProfileEntries extends Table {
BoolColumn get active => boolean()(); BoolColumn get active => boolean()();
TextColumn get name => text().withLength(min: 1)(); TextColumn get name => text().withLength(min: 1)();
TextColumn get url => text()(); TextColumn get url => text()();
DateTimeColumn get lastUpdate => dateTime()();
IntColumn get updateInterval =>
integer().nullable().map(DurationTypeConverter())();
IntColumn get upload => integer().nullable()(); IntColumn get upload => integer().nullable()();
IntColumn get download => integer().nullable()(); IntColumn get download => integer().nullable()();
IntColumn get total => integer().nullable()(); IntColumn get total => integer().nullable()();
DateTimeColumn get expire => dateTime().nullable()(); DateTimeColumn get expire => dateTime().nullable()();
IntColumn get updateInterval => TextColumn get webPageUrl => text().nullable()();
integer().nullable().map(DurationTypeConverter())(); TextColumn get supportUrl => text().nullable()();
DateTimeColumn get lastUpdate => dateTime()();
@override @override
Set<Column> get primaryKey => {id}; Set<Column> get primaryKey => {id};

View File

@@ -1,5 +1,8 @@
import 'dart:convert';
import 'package:dartx/dartx.dart';
import 'package:freezed_annotation/freezed_annotation.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.freezed.dart';
part 'profile.g.dart'; part 'profile.g.dart';
@@ -13,13 +16,127 @@ class Profile with _$Profile {
required bool active, required bool active,
required String name, required String name,
required String url, required String url,
SubscriptionInfo? subInfo,
Duration? updateInterval,
required DateTime lastUpdate, required DateTime lastUpdate,
ProfileOptions? options,
SubscriptionInfo? subInfo,
ProfileExtra? extra,
}) = _Profile; }) = _Profile;
bool get hasSubscriptionInfo => subInfo?.isValid ?? false; // TODO add content disposition parsing
factory Profile.fromResponse(
String url,
Map<String, List<String>> 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<String, dynamic> json) => factory Profile.fromJson(Map<String, dynamic> json) =>
_$ProfileFromJson(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 ProfileExtra with _$ProfileExtra {
const factory ProfileExtra({
String? webPageUrl,
String? supportUrl,
}) = _ProfileExtra;
factory ProfileExtra.fromJson(Map<String, dynamic> 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<String, dynamic> json) =>
_$SubscriptionInfoFromJson(json);
}
DateTime _dateTimeFromSecondsSinceEpoch(dynamic expire) =>
DateTime.fromMillisecondsSinceEpoch((expire as int) * 1000);

View File

@@ -1,4 +1,3 @@
export 'profile.dart'; export 'profile.dart';
export 'profiles_failure.dart'; export 'profiles_failure.dart';
export 'profiles_repository.dart'; export 'profiles_repository.dart';
export 'subscription_info.dart';

View File

@@ -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<String, dynamic> json) =>
_$SubscriptionInfoFromJson(json);
}
DateTime? _dateTimeFromSecondsSinceEpoch(dynamic expire) =>
DateTime.fromMillisecondsSinceEpoch((expire as int) * 1000);

View File

@@ -115,9 +115,9 @@ class ProfileTile extends HookConsumerWidget {
profile.name, profile.name,
style: theme.textTheme.titleMedium, style: theme.textTheme.titleMedium,
), ),
if (subInfo?.isValid ?? false) ...[ if (subInfo != null) ...[
const Gap(4), const Gap(4),
RemainingTrafficIndicator(subInfo!.ratio), RemainingTrafficIndicator(subInfo.ratio),
const Gap(4), const Gap(4),
ProfileSubscriptionInfo(subInfo), ProfileSubscriptionInfo(subInfo),
const Gap(4), const Gap(4),
@@ -290,13 +290,12 @@ class ProfileSubscriptionInfo extends HookConsumerWidget {
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
if (subInfo.total != null)
Text.rich( Text.rich(
TextSpan( TextSpan(
children: [ children: [
TextSpan(text: formatByte(subInfo.consumption, unit: 3).size), TextSpan(text: formatByte(subInfo.consumption, unit: 3).size),
const TextSpan(text: " / "), const TextSpan(text: " / "),
TextSpan(text: formatByte(subInfo.total!, unit: 3).size), TextSpan(text: formatByte(subInfo.total, unit: 3).size),
const TextSpan(text: " "), const TextSpan(text: " "),
TextSpan(text: t.profile.subscription.gigaByte), TextSpan(text: t.profile.subscription.gigaByte),
], ],

View File

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