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

View File

@@ -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<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: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<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) =>
_$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 'profiles_failure.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,
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),

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