Add extra profile metadata
This commit is contained in:
@@ -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 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(
|
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,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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';
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -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,19 +290,18 @@ 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),
|
],
|
||||||
],
|
|
||||||
),
|
|
||||||
style: theme.textTheme.bodySmall,
|
|
||||||
),
|
),
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
Text(
|
Text(
|
||||||
remaining.$1,
|
remaining.$1,
|
||||||
style: theme.textTheme.bodySmall?.copyWith(color: remaining.$2),
|
style: theme.textTheme.bodySmall?.copyWith(color: remaining.$2),
|
||||||
|
|||||||
72
test/domain/profiles/profile_test.dart
Normal file
72
test/domain/profiles/profile_test.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user