Add local profile

This commit is contained in:
problematicconsumer
2023-10-02 18:51:14 +03:30
parent a7e157c036
commit d50541f7a3
26 changed files with 1118 additions and 260 deletions

View File

@@ -239,6 +239,7 @@
"profiles": {
"unexpected": "Unexpected Error",
"notFound": "Profile Not Found",
"invalidUrl": "Invalid URL",
"invalidConfig": "Invalid Configs"
}
}

View File

@@ -239,6 +239,7 @@
"profiles": {
"unexpected": "خطای غیرمنتظره",
"notFound": "پروفایل یافت نشد",
"invalidUrl": "لینک نامعتبر",
"invalidConfig": "کانفیگ غیر معتبر"
}
}

View File

@@ -4,20 +4,31 @@ import 'package:hiddify/domain/profiles/profiles.dart';
extension ProfileMapper on Profile {
ProfileEntriesCompanion toCompanion() {
return ProfileEntriesCompanion.insert(
return switch (this) {
RemoteProfile(:final url, :final options, :final subInfo) =>
ProfileEntriesCompanion.insert(
id: id,
type: ProfileType.remote,
active: active,
name: name,
url: url,
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(extra?.webPageUrl),
supportUrl: Value(extra?.supportUrl),
);
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) {
@@ -36,26 +47,27 @@ extension ProfileMapper on Profile {
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 switch (e.type) {
ProfileType.remote => RemoteProfile(
id: e.id,
active: e.active,
name: e.name,
url: e.url,
url: e.url!,
lastUpdate: e.lastUpdate,
options: options,
subInfo: subInfo,
extra: extra,
);
),
ProfileType.local => LocalProfile(
id: e.id,
active: e.active,
name: e.name,
lastUpdate: e.lastUpdate,
),
};
}
}

View File

@@ -3,8 +3,10 @@ import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:hiddify/data/local/dao/dao.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/services/files_editor_service.dart';
import 'package:path/path.dart' as p;
@@ -17,7 +19,31 @@ class AppDatabase extends _$AppDatabase {
AppDatabase.connect() : super(_openConnection());
@override
int get schemaVersion => 1;
int get schemaVersion => 2;
@override
MigrationStrategy get migration {
return MigrationStrategy(
onCreate: (Migrator m) async {
await m.createAll();
},
onUpgrade: stepByStep(
// add type column to profile entries table
// make url column nullable
from1To2: (m, schema) async {
await m.alterTable(
TableMigration(
schema.profileEntries,
columnTransformer: {
schema.profileEntries.type: const Constant<String>("remote"),
},
newColumns: [schema.profileEntries.type],
),
);
},
),
);
}
}
LazyDatabase _openConnection() {

View File

@@ -0,0 +1,136 @@
import 'package:drift/internal/versioned_schema.dart' as i0;
import 'package:drift/drift.dart' as i1;
import 'package:drift/drift.dart'; // ignore_for_file: type=lint,unused_import
// GENERATED BY drift_dev, DO NOT MODIFY.
final class _S2 extends i0.VersionedSchema {
_S2({required super.database}) : super(version: 2);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
profileEntries,
];
late final Shape0 profileEntries = Shape0(
source: i0.VersionedTable(
entityName: 'profile_entries',
withoutRowId: false,
isStrict: false,
tableConstraints: [
'PRIMARY KEY(id)',
],
columns: [
_column_0,
_column_1,
_column_2,
_column_3,
_column_4,
_column_5,
_column_6,
_column_7,
_column_8,
_column_9,
_column_10,
_column_11,
_column_12,
],
attachedDatabase: database,
),
alias: null);
}
class Shape0 extends i0.VersionedTable {
Shape0({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get type =>
columnsByName['type']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get active =>
columnsByName['active']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get url =>
columnsByName['url']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get lastUpdate =>
columnsByName['last_update']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<int> get updateInterval =>
columnsByName['update_interval']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get upload =>
columnsByName['upload']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get download =>
columnsByName['download']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get total =>
columnsByName['total']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<DateTime> get expire =>
columnsByName['expire']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<String> get webPageUrl =>
columnsByName['web_page_url']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get supportUrl =>
columnsByName['support_url']! as i1.GeneratedColumn<String>;
}
i1.GeneratedColumn<String> _column_0(String aliasedName) =>
i1.GeneratedColumn<String>('id', aliasedName, false,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<String> _column_1(String aliasedName) =>
i1.GeneratedColumn<String>('type', aliasedName, false,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<bool> _column_2(String aliasedName) =>
i1.GeneratedColumn<bool>('active', aliasedName, false,
type: i1.DriftSqlType.bool,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'CHECK ("active" IN (0, 1))'));
i1.GeneratedColumn<String> _column_3(String aliasedName) =>
i1.GeneratedColumn<String>('name', aliasedName, false,
additionalChecks: i1.GeneratedColumn.checkTextLength(
minTextLength: 1,
),
type: i1.DriftSqlType.string);
i1.GeneratedColumn<String> _column_4(String aliasedName) =>
i1.GeneratedColumn<String>('url', aliasedName, true,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<DateTime> _column_5(String aliasedName) =>
i1.GeneratedColumn<DateTime>('last_update', aliasedName, false,
type: i1.DriftSqlType.dateTime);
i1.GeneratedColumn<int> _column_6(String aliasedName) =>
i1.GeneratedColumn<int>('update_interval', aliasedName, true,
type: i1.DriftSqlType.int);
i1.GeneratedColumn<int> _column_7(String aliasedName) =>
i1.GeneratedColumn<int>('upload', aliasedName, true,
type: i1.DriftSqlType.int);
i1.GeneratedColumn<int> _column_8(String aliasedName) =>
i1.GeneratedColumn<int>('download', aliasedName, true,
type: i1.DriftSqlType.int);
i1.GeneratedColumn<int> _column_9(String aliasedName) =>
i1.GeneratedColumn<int>('total', aliasedName, true,
type: i1.DriftSqlType.int);
i1.GeneratedColumn<DateTime> _column_10(String aliasedName) =>
i1.GeneratedColumn<DateTime>('expire', aliasedName, true,
type: i1.DriftSqlType.dateTime);
i1.GeneratedColumn<String> _column_11(String aliasedName) =>
i1.GeneratedColumn<String>('web_page_url', aliasedName, true,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<String> _column_12(String aliasedName) =>
i1.GeneratedColumn<String>('support_url', aliasedName, true,
type: i1.DriftSqlType.string);
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, _S2 schema) from1To2,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
case 1:
final schema = _S2(database: database);
final migrator = i1.Migrator(database, schema);
await from1To2(migrator, schema);
return 2;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
};
}
i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, _S2 schema) from1To2,
}) =>
i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
));

View File

@@ -0,0 +1,160 @@
{
"_meta": {
"description": "This file contains a serialized version of schema entities for drift.",
"version": "1.1.0"
},
"options": {
"store_date_time_values_as_text": true
},
"entities": [
{
"id": 0,
"references": [],
"type": "table",
"data": {
"name": "profile_entries",
"was_declared_in_moor": false,
"columns": [
{
"name": "id",
"getter_name": "id",
"moor_type": "string",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "active",
"getter_name": "active",
"moor_type": "bool",
"nullable": false,
"customConstraints": null,
"defaultConstraints": "CHECK (\"active\" IN (0, 1))",
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "name",
"getter_name": "name",
"moor_type": "string",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": [
{
"allowed-lengths": {
"min": 1,
"max": null
}
}
]
},
{
"name": "url",
"getter_name": "url",
"moor_type": "string",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "last_update",
"getter_name": "lastUpdate",
"moor_type": "dateTime",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "update_interval",
"getter_name": "updateInterval",
"moor_type": "int",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": [],
"type_converter": {
"dart_expr": "DurationTypeConverter()",
"dart_type_name": "Duration"
}
},
{
"name": "upload",
"getter_name": "upload",
"moor_type": "int",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "download",
"getter_name": "download",
"moor_type": "int",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "total",
"getter_name": "total",
"moor_type": "int",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "expire",
"getter_name": "expire",
"moor_type": "dateTime",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "web_page_url",
"getter_name": "webPageUrl",
"moor_type": "string",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "support_url",
"getter_name": "supportUrl",
"moor_type": "string",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
}
],
"is_virtual": false,
"without_rowid": false,
"constraints": [],
"explicit_pk": [
"id"
]
}
}
]
}

View File

@@ -0,0 +1,174 @@
{
"_meta": {
"description": "This file contains a serialized version of schema entities for drift.",
"version": "1.1.0"
},
"options": {
"store_date_time_values_as_text": true
},
"entities": [
{
"id": 0,
"references": [],
"type": "table",
"data": {
"name": "profile_entries",
"was_declared_in_moor": false,
"columns": [
{
"name": "id",
"getter_name": "id",
"moor_type": "string",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "type",
"getter_name": "type",
"moor_type": "string",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": [],
"type_converter": {
"dart_expr": "const EnumNameConverter<ProfileType>(ProfileType.values)",
"dart_type_name": "ProfileType"
}
},
{
"name": "active",
"getter_name": "active",
"moor_type": "bool",
"nullable": false,
"customConstraints": null,
"defaultConstraints": "CHECK (\"active\" IN (0, 1))",
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "name",
"getter_name": "name",
"moor_type": "string",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": [
{
"allowed-lengths": {
"min": 1,
"max": null
}
}
]
},
{
"name": "url",
"getter_name": "url",
"moor_type": "string",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "last_update",
"getter_name": "lastUpdate",
"moor_type": "dateTime",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "update_interval",
"getter_name": "updateInterval",
"moor_type": "int",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": [],
"type_converter": {
"dart_expr": "DurationTypeConverter()",
"dart_type_name": "Duration"
}
},
{
"name": "upload",
"getter_name": "upload",
"moor_type": "int",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "download",
"getter_name": "download",
"moor_type": "int",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "total",
"getter_name": "total",
"moor_type": "int",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "expire",
"getter_name": "expire",
"moor_type": "dateTime",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "web_page_url",
"getter_name": "webPageUrl",
"moor_type": "string",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "support_url",
"getter_name": "supportUrl",
"moor_type": "string",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
}
],
"is_virtual": false,
"without_rowid": false,
"constraints": [],
"explicit_pk": [
"id"
]
}
}
]
}

View File

@@ -1,12 +1,14 @@
import 'package:drift/drift.dart';
import 'package:hiddify/data/local/type_converters.dart';
import 'package:hiddify/domain/profiles/profiles.dart';
@DataClassName('ProfileEntry')
class ProfileEntries extends Table {
TextColumn get id => text()();
TextColumn get type => textEnum<ProfileType>()();
BoolColumn get active => boolean()();
TextColumn get name => text().withLength(min: 1)();
TextColumn get url => text()();
TextColumn get url => text().nullable()();
DateTimeColumn get lastUpdate => dateTime()();
IntColumn get updateInterval =>
integer().nullable().map(DurationTypeConverter())();

View File

@@ -72,7 +72,7 @@ class ProfilesRepositoryImpl
return exceptionHandler(
() async {
final existingProfile = await profilesDao.getProfileByUrl(url);
if (existingProfile != null) {
if (existingProfile case RemoteProfile()) {
loggy.info("profile with url[$url] already exists, updating");
final baseProfile = markAsActive
? existingProfile.copyWith(active: true)
@@ -105,7 +105,49 @@ class ProfilesRepositoryImpl
}
@override
TaskEither<ProfileFailure, Unit> add(Profile baseProfile) {
TaskEither<ProfileFailure, Unit> addByContent(
String content, {
required String name,
bool markAsActive = false,
}) {
return exceptionHandler(
() async {
final profileId = const Uuid().v4();
final tempPath = filesEditor.tempConfigPath(profileId);
final path = filesEditor.configPath(profileId);
try {
await File(tempPath).writeAsString(content);
final parseResult =
await singbox.parseConfig(path, tempPath, false).run();
return parseResult.fold(
(l) async {
loggy.warning("error parsing config: $l");
return left(ProfileFailure.invalidConfig(l.msg));
},
(_) async {
final profile = LocalProfile(
id: profileId,
active: markAsActive,
name: name,
lastUpdate: DateTime.now(),
);
await profilesDao.create(profile);
return right(unit);
},
);
} finally {
if (await File(tempPath).exists()) await File(tempPath).delete();
}
},
(error, stackTrace) {
loggy.warning("error adding profile by content", error, stackTrace);
return ProfileUnexpectedFailure(error, stackTrace);
},
);
}
@override
TaskEither<ProfileFailure, Unit> add(RemoteProfile baseProfile) {
return exceptionHandler(
() async {
return fetch(baseProfile.url, baseProfile.id)
@@ -114,7 +156,6 @@ class ProfilesRepositoryImpl
await profilesDao.create(
baseProfile.copyWith(
subInfo: remoteProfile.subInfo,
extra: remoteProfile.extra,
lastUpdate: DateTime.now(),
),
);
@@ -131,7 +172,7 @@ class ProfilesRepositoryImpl
}
@override
TaskEither<ProfileFailure, Unit> update(Profile baseProfile) {
TaskEither<ProfileFailure, Unit> update(RemoteProfile baseProfile) {
return exceptionHandler(
() async {
loggy.debug(
@@ -143,7 +184,6 @@ class ProfilesRepositoryImpl
await profilesDao.edit(
baseProfile.copyWith(
subInfo: remoteProfile.subInfo,
extra: remoteProfile.extra,
lastUpdate: DateTime.now(),
),
);
@@ -209,13 +249,13 @@ class ProfilesRepositoryImpl
];
@visibleForTesting
TaskEither<ProfileFailure, Profile> fetch(
TaskEither<ProfileFailure, RemoteProfile> fetch(
String url,
String fileName,
) {
return TaskEither(
() async {
final tempPath = filesEditor.configPath("temp_$fileName");
final tempPath = filesEditor.tempConfigPath(fileName);
final path = filesEditor.configPath(fileName);
try {
final response = await dio.download(url.trim(), tempPath);

View File

@@ -2,6 +2,7 @@ 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';
@@ -10,11 +11,13 @@ part 'profile.g.dart';
final _loggy = Loggy('Profile');
enum ProfileType { remote, local }
@freezed
class Profile with _$Profile {
sealed class Profile with _$Profile {
const Profile._();
const factory Profile({
const factory Profile.remote({
required String id,
required bool active,
required String name,
@@ -22,10 +25,19 @@ class Profile with _$Profile {
required DateTime lastUpdate,
ProfileOptions? options,
SubscriptionInfo? subInfo,
ProfileExtra? extra,
}) = _Profile;
}) = RemoteProfile;
factory Profile.fromResponse(String url, Map<String, List<String>> headers) {
const factory Profile.local({
required String id,
required bool active,
required String name,
required DateTime lastUpdate,
}) = LocalProfile;
static RemoteProfile fromResponse(
String url,
Map<String, List<String>> headers,
) {
_loggy.debug("Profile Headers: $headers");
final titleHeader = headers['profile-title']?.single;
@@ -59,7 +71,7 @@ class Profile with _$Profile {
if (title.isEmpty) {
final part = url.split("/").lastOrNull;
if (part != null) {
final pattern = RegExp(r"\.(yaml|yml|txt)[\s\S]*");
final pattern = RegExp(r"\.(json|yaml|yml|txt)[\s\S]*");
title = part.replaceFirst(pattern, "");
}
}
@@ -79,15 +91,14 @@ class Profile with _$Profile {
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,
if (subInfo != null) {
subInfo = subInfo.copyWith(
webPageUrl: isUrl(webPageUrlHeader ?? "") ? webPageUrlHeader : null,
supportUrl: isUrl(supportUrlHeader ?? "") ? supportUrlHeader : null,
);
}
return Profile(
return RemoteProfile(
id: const Uuid().v4(),
active: false,
name: title.isBlank ? "Remote Profile" : title,
@@ -95,7 +106,6 @@ class Profile with _$Profile {
lastUpdate: DateTime.now(),
options: options,
subInfo: subInfo,
extra: extra,
);
}
@@ -113,17 +123,6 @@ class ProfileOptions with _$ProfileOptions {
_$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._();
@@ -134,6 +133,8 @@ class SubscriptionInfo with _$SubscriptionInfo {
@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();

View File

@@ -15,6 +15,9 @@ sealed class ProfileFailure with _$ProfileFailure, Failure {
const factory ProfileFailure.notFound() = ProfileNotFoundFailure;
@With<ExpectedException>()
const factory ProfileFailure.invalidUrl() = ProfileInvalidUrlFailure;
const factory ProfileFailure.invalidConfig([String? message]) =
ProfileInvalidConfigFailure;
@@ -29,6 +32,10 @@ sealed class ProfileFailure with _$ProfileFailure, Failure {
type: t.failure.profiles.notFound,
message: null
),
ProfileInvalidUrlFailure() => (
type: t.failure.profiles.invalidUrl,
message: null,
),
ProfileInvalidConfigFailure(:final message) => (
type: t.failure.profiles.invalidConfig,
message: message

View File

@@ -19,9 +19,15 @@ abstract class ProfilesRepository {
bool markAsActive = false,
});
TaskEither<ProfileFailure, Unit> add(Profile baseProfile);
TaskEither<ProfileFailure, Unit> addByContent(
String content, {
required String name,
bool markAsActive = false,
});
TaskEither<ProfileFailure, Unit> update(Profile baseProfile);
TaskEither<ProfileFailure, Unit> add(RemoteProfile baseProfile);
TaskEither<ProfileFailure, Unit> update(RemoteProfile baseProfile);
TaskEither<ProfileFailure, Unit> edit(Profile profile);

View File

@@ -1,4 +1,3 @@
import 'package:fpdart/fpdart.dart';
import 'package:hiddify/data/data_providers.dart';
import 'package:hiddify/domain/profiles/profiles.dart';
import 'package:hiddify/utils/utils.dart';
@@ -16,16 +15,4 @@ class ActiveProfile extends _$ActiveProfile with AppLogger {
.watchActiveProfile()
.map((event) => event.getOrElse((l) => throw l));
}
Future<Unit?> updateProfile() async {
if (state case AsyncData(value: final profile?)) {
loggy.debug("updating active profile");
return ref
.read(profilesRepositoryProvider)
.update(profile)
.getOrElse((l) => throw l)
.run();
}
return null;
}
}

View File

@@ -39,7 +39,10 @@ class ProfileTile extends HookConsumerWidget {
},
);
final subInfo = profile.subInfo;
final subInfo = switch (profile) {
RemoteProfile(:final subInfo) => subInfo,
_ => null,
};
final effectiveMargin = isMain
? const EdgeInsets.symmetric(horizontal: 16, vertical: 8)
@@ -60,6 +63,7 @@ class ProfileTile extends HookConsumerWidget {
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (profile is RemoteProfile || !isMain) ...[
SizedBox(
width: 48,
child: Semantics(
@@ -71,6 +75,7 @@ class ProfileTile extends HookConsumerWidget {
width: 1,
color: effectiveOutlineColor,
),
],
Expanded(
child: Semantics(
button: true,
@@ -177,7 +182,7 @@ class ProfileActionButton extends HookConsumerWidget {
CustomToast.success(t.profile.update.successMsg).show(context),
);
if (!showAllActions) {
if (profile case RemoteProfile() when !showAllActions) {
return Semantics(
button: true,
enabled: !updateProfileMutation.state.isInProgress,
@@ -191,7 +196,7 @@ class ProfileActionButton extends HookConsumerWidget {
updateProfileMutation.setFuture(
ref
.read(profilesNotifierProvider.notifier)
.updateProfile(profile),
.updateProfile(profile as RemoteProfile),
);
},
child: const Icon(Icons.update),
@@ -250,6 +255,7 @@ class ProfileActionsMenu extends HookConsumerWidget {
return MenuAnchor(
builder: builder,
menuChildren: [
if (profile case RemoteProfile())
MenuItemButton(
leadingIcon: const Icon(Icons.update),
child: Text(t.profile.update.buttonTxt),
@@ -260,7 +266,7 @@ class ProfileActionsMenu extends HookConsumerWidget {
updateProfileMutation.setFuture(
ref
.read(profilesNotifierProvider.notifier)
.updateProfile(profile),
.updateProfile(profile as RemoteProfile),
);
},
),

View File

@@ -19,7 +19,7 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger {
}) async {
if (id == 'new') {
return ProfileDetailState(
profile: Profile(
profile: RemoteProfile(
id: const Uuid().v4(),
active: true,
name: profileName ?? "",
@@ -52,17 +52,22 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger {
if (state case AsyncData(:final value)) {
state = AsyncData(
value.copyWith(
profile: value.profile.copyWith(
name: name ?? value.profile.name,
url: url ?? value.profile.url,
profile: value.profile.map(
remote: (rp) => rp.copyWith(
name: name ?? rp.name,
url: url ?? rp.url,
options: updateInterval == null
? value.profile.options
? rp.options
: updateInterval.fold(
() => null,
(t) => ProfileOptions(updateInterval: Duration(hours: t)),
(t) => ProfileOptions(
updateInterval: Duration(hours: t),
),
),
),
local: (lp) => lp.copyWith(name: name ?? lp.name),
),
),
);
}
}
@@ -71,15 +76,18 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger {
if (state case AsyncData(:final value)) {
if (value.save.isInProgress) return;
final profile = value.profile;
Either<ProfileFailure, Unit>? failureOrSuccess;
state = AsyncData(value.copyWith(save: const MutationInProgress()));
switch (profile) {
case RemoteProfile():
loggy.debug(
'saving profile, url: [${profile.url}], name: [${profile.name}]',
);
state = AsyncData(value.copyWith(save: const MutationInProgress()));
Either<ProfileFailure, Unit>? failureOrSuccess;
if (profile.name.isBlank || profile.url.isBlank) {
loggy.debug('profile save: invalid arguments');
} else if (value.isEditing) {
if (_originalProfile?.url == profile.url) {
if (_originalProfile case RemoteProfile(:final url)
when url == profile.url) {
loggy.debug('editing profile');
failureOrSuccess = await _profilesRepo.edit(profile).run();
} else {
@@ -90,6 +98,12 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger {
loggy.debug('adding profile, url: [${profile.url}]');
failureOrSuccess = await _profilesRepo.add(profile).run();
}
case LocalProfile() when value.isEditing:
loggy.debug('editing profile');
failureOrSuccess = await _profilesRepo.edit(profile).run();
default:
loggy.warning("local profile can't be added manually");
}
state = AsyncData(
value.copyWith(
save: failureOrSuccess?.fold(
@@ -105,12 +119,17 @@ 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()) {
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()));
final failureOrUpdatedProfile = await _profilesRepo
.update(profile)
.update(profile as RemoteProfile)
.flatMap((_) => _profilesRepo.get(id))
.run();
state = AsyncData(

View File

@@ -3,6 +3,7 @@ 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/settings/widgets/widgets.dart';
@@ -93,6 +94,7 @@ class ProfileDetailPage extends HookConsumerWidget with PresLogger {
PopupMenuButton(
itemBuilder: (context) {
return [
if (state.profile case RemoteProfile())
PopupMenuItem(
child: Text(t.profile.update.buttonTxt),
onTap: () async {
@@ -140,14 +142,17 @@ class ProfileDetailPage extends HookConsumerWidget with PresLogger {
hint: t.profile.detailsForm.nameHint,
),
),
if (state.profile
case RemoteProfile(:final url, :final options)) ...[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: CustomTextFormField(
initialValue: state.profile.url,
onChanged: (value) => notifier.setField(url: value),
initialValue: url,
onChanged: (value) =>
notifier.setField(url: value),
validator: (value) =>
(value != null && !isUrl(value))
? t.profile.detailsForm.invalidUrlMsg
@@ -159,8 +164,7 @@ class ProfileDetailPage extends HookConsumerWidget with PresLogger {
ListTile(
title: Text(t.profile.detailsForm.updateInterval),
subtitle: Text(
state.profile.options?.updateInterval
.toApproximateTime(
options?.updateInterval.toApproximateTime(
isRelativeToNow: false,
) ??
t.general.toggle.disabled,
@@ -170,11 +174,11 @@ class ProfileDetailPage extends HookConsumerWidget with PresLogger {
final intervalInHours = await SettingsInputDialog(
title: t.profile.detailsForm
.updateIntervalDialogTitle,
initialValue:
state.profile.options?.updateInterval.inHours,
initialValue: options?.updateInterval.inHours,
optionalAction: (
t.general.state.disable,
() => notifier.setField(updateInterval: none()),
() =>
notifier.setField(updateInterval: none()),
),
validator: isPort,
mapTo: int.tryParse,
@@ -186,6 +190,7 @@ class ProfileDetailPage extends HookConsumerWidget with PresLogger {
);
},
),
],
if (state.isEditing)
ListTile(
title: Text(t.profile.detailsForm.lastUpdate),

View File

@@ -49,21 +49,39 @@ class ProfilesNotifier extends _$ProfilesNotifier with AppLogger {
}).run();
}
Future<Unit> addProfile(String url) async {
Future<Unit> addProfile(String rawInput) async {
final activeProfile = await ref.read(activeProfileProvider.future);
final markAsActive =
activeProfile == null || ref.read(markNewProfileActiveProvider);
loggy.debug("adding profile, url: [$url]");
if (LinkParser.parse(rawInput) case (final link)?) {
loggy.debug("adding profile, url: [${link.url}]");
return ref
.read(profilesRepositoryProvider)
.addByUrl(url, markAsActive: markAsActive)
.addByUrl(link.url, markAsActive: markAsActive)
.getOrElse((l) {
loggy.warning("failed to add profile: $l");
throw l;
}).run();
} else if (LinkParser.protocol(rawInput) case (final parsed)?) {
loggy.debug("adding profile, content");
return ref
.read(profilesRepositoryProvider)
.addByContent(
parsed.content,
name: parsed.name,
markAsActive: markAsActive,
)
.getOrElse((l) {
loggy.warning("failed to add profile: $l");
throw l;
}).run();
} else {
loggy.debug("invalid content");
throw const ProfileInvalidUrlFailure();
}
}
Future<Unit?> updateProfile(Profile profile) async {
Future<Unit?> updateProfile(RemoteProfile profile) async {
loggy.debug("updating profile");
return ref
.read(profilesRepositoryProvider)

View File

@@ -50,6 +50,7 @@ class ProfilesUpdateNotifier extends _$ProfilesUpdateNotifier with AppLogger {
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 &&
@@ -67,6 +68,7 @@ class ProfilesUpdateNotifier extends _$ProfilesUpdateNotifier with AppLogger {
}
}
}
}
},
);
}

View File

@@ -6,6 +6,7 @@ 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/utils/utils.dart';
@@ -29,8 +30,13 @@ class AddProfileModal extends HookConsumerWidget {
final addProfileMutation = useMutation(
initialOnFailure: (err) {
mutationTriggered.value = false;
// CustomToast.error(t.presentError(err)).show(context);
if (err case ProfileInvalidUrlFailure()) {
CustomToast.error(
t.profile.add.invalidUrlMsg,
).show(context);
} else {
CustomAlertDialog.fromErr(t.presentError(err)).show(context);
}
},
initialOnSuccess: () {
CustomToast.success(t.profile.save.successMsg).show(context);
@@ -102,24 +108,15 @@ class AddProfileModal extends HookConsumerWidget {
size: buttonWidth,
onTap: () async {
final captureResult =
await Clipboard.getData(Clipboard.kTextPlain);
final link =
LinkParser.parse(captureResult?.text ?? '');
if (link != null && context.mounted) {
await Clipboard.getData(Clipboard.kTextPlain)
.then((value) => value?.text ?? '');
if (addProfileMutation.state.isInProgress) return;
mutationTriggered.value = true;
addProfileMutation.setFuture(
ref
.read(profilesNotifierProvider.notifier)
.addProfile(link.url),
.addProfile(captureResult),
);
} else {
if (context.mounted) {
CustomToast.error(
t.profile.add.invalidUrlMsg,
).show(context);
}
}
},
),
const Gap(buttonsGap),
@@ -134,8 +131,6 @@ class AddProfileModal extends HookConsumerWidget {
await const QRCodeScannerScreen()
.open(context);
if (captureResult == null) return;
final link = LinkParser.simple(captureResult);
if (link != null && context.mounted) {
if (addProfileMutation.state.isInProgress) {
return;
}
@@ -143,15 +138,8 @@ class AddProfileModal extends HookConsumerWidget {
addProfileMutation.setFuture(
ref
.read(profilesNotifierProvider.notifier)
.addProfile(link.url),
.addProfile(captureResult),
);
} else {
if (context.mounted) {
CustomToast.error(
t.profile.add.invalidUrlMsg,
).show(context);
}
}
},
)
else

View File

@@ -77,6 +77,8 @@ class FilesEditorService with InfraLogger {
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,10 +1,15 @@
import 'dart:convert';
import 'package:hiddify/domain/clash/clash.dart';
import 'package:hiddify/utils/validators.dart';
typedef ProfileLink = ({String url, String name});
// TODO: test and improve
abstract class LinkParser {
static const protocols = ['clash', 'clashmeta', 'sing-box', 'hiddify'];
// protocols schemas
static const protocols = {'clash', 'clashmeta', 'sing-box', 'hiddify'};
static const rawProtocols = {'vmess', 'vless', 'trojan', 'ss', 'tuic'};
static ProfileLink? parse(String link) {
return simple(link) ?? deep(link);
@@ -13,24 +18,39 @@ abstract class LinkParser {
static ProfileLink? simple(String link) {
if (!isUrl(link)) return null;
final uri = Uri.parse(link.trim());
final params = uri.queryParameters;
return (
url: uri.toString(),
// .replace(queryParameters: {})
// .toString()
// .removeSuffix('?')
// .split('&')
// .first,
name: params['name'] ?? '',
name: uri.queryParameters['name'] ?? '',
);
}
static ({String content, String name})? protocol(String content) {
final lines = safeDecodeBase64(content).split('\n');
for (final line in lines) {
final uri = Uri.tryParse(line);
if (uri == null) continue;
final fragment =
uri.hasFragment ? Uri.decodeComponent(uri.fragment) : null;
final name = switch (uri.scheme) {
'ss' => fragment ?? ProxyType.shadowSocks.label,
'vless' => fragment ?? ProxyType.vless.label,
'tuic' => fragment ?? ProxyType.tuic.label,
'vmess' => ProxyType.vmess.label,
_ => null,
};
if (name != null) {
return (content: content, name: name);
}
}
return null;
}
static ProfileLink? deep(String link) {
final uri = Uri.tryParse(link.trim());
if (uri == null || !uri.hasScheme || !uri.hasAuthority) return null;
final queryParams = uri.queryParameters;
switch (uri.scheme) {
case 'clash' || 'clashmeta':
case 'clash' || 'clashmeta' when uri.authority == 'install-config':
if (uri.authority != 'install-config' ||
!queryParams.containsKey('url')) return null;
return (url: queryParams['url']!, name: queryParams['name'] ?? '');
@@ -48,3 +68,11 @@ abstract class LinkParser {
}
}
}
String safeDecodeBase64(String str) {
try {
return utf8.decode(base64Decode(str));
} catch (e) {
return str;
}
}

View File

@@ -0,0 +1,21 @@
// GENERATED CODE, DO NOT EDIT BY HAND.
// ignore_for_file: type=lint
//@dart=2.12
import 'package:drift/drift.dart';
import 'package:drift/internal/migrations.dart';
import 'schema_v1.dart' as v1;
import 'schema_v2.dart' as v2;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
GeneratedDatabase databaseForVersion(QueryExecutor db, int version) {
switch (version) {
case 1:
return v1.DatabaseAtV1(db);
case 2:
return v2.DatabaseAtV2(db);
default:
throw MissingSchemaException(version, const {1, 2});
}
}
}

View File

@@ -0,0 +1,100 @@
// GENERATED CODE, DO NOT EDIT BY HAND.
// ignore_for_file: type=lint
//@dart=2.12
import 'package:drift/drift.dart';
class ProfileEntries extends Table with TableInfo {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
ProfileEntries(this.attachedDatabase, [this._alias]);
late final GeneratedColumn<String> id = GeneratedColumn<String>(
'id', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
late final GeneratedColumn<bool> active = GeneratedColumn<bool>(
'active', aliasedName, false,
type: DriftSqlType.bool,
requiredDuringInsert: true,
defaultConstraints:
GeneratedColumn.constraintIsAlways('CHECK ("active" IN (0, 1))'));
late final GeneratedColumn<String> name =
GeneratedColumn<String>('name', aliasedName, false,
additionalChecks: GeneratedColumn.checkTextLength(
minTextLength: 1,
),
type: DriftSqlType.string,
requiredDuringInsert: true);
late final GeneratedColumn<String> url = GeneratedColumn<String>(
'url', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
late final GeneratedColumn<DateTime> lastUpdate = GeneratedColumn<DateTime>(
'last_update', aliasedName, false,
type: DriftSqlType.dateTime, requiredDuringInsert: true);
late final GeneratedColumn<int> updateInterval = GeneratedColumn<int>(
'update_interval', aliasedName, true,
type: DriftSqlType.int, requiredDuringInsert: false);
late final GeneratedColumn<int> upload = GeneratedColumn<int>(
'upload', aliasedName, true,
type: DriftSqlType.int, requiredDuringInsert: false);
late final GeneratedColumn<int> download = GeneratedColumn<int>(
'download', aliasedName, true,
type: DriftSqlType.int, requiredDuringInsert: false);
late final GeneratedColumn<int> total = GeneratedColumn<int>(
'total', aliasedName, true,
type: DriftSqlType.int, requiredDuringInsert: false);
late final GeneratedColumn<DateTime> expire = GeneratedColumn<DateTime>(
'expire', aliasedName, true,
type: DriftSqlType.dateTime, requiredDuringInsert: false);
late final GeneratedColumn<String> webPageUrl = GeneratedColumn<String>(
'web_page_url', aliasedName, true,
type: DriftSqlType.string, requiredDuringInsert: false);
late final GeneratedColumn<String> supportUrl = GeneratedColumn<String>(
'support_url', aliasedName, true,
type: DriftSqlType.string, requiredDuringInsert: false);
@override
List<GeneratedColumn> get $columns => [
id,
active,
name,
url,
lastUpdate,
updateInterval,
upload,
download,
total,
expire,
webPageUrl,
supportUrl
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'profile_entries';
@override
Set<GeneratedColumn> get $primaryKey => {id};
@override
Never map(Map<String, dynamic> data, {String? tablePrefix}) {
throw UnsupportedError('TableInfo.map in schema verification code');
}
@override
ProfileEntries createAlias(String alias) {
return ProfileEntries(attachedDatabase, alias);
}
}
class DatabaseAtV1 extends GeneratedDatabase {
DatabaseAtV1(QueryExecutor e) : super(e);
late final ProfileEntries profileEntries = ProfileEntries(this);
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@override
List<DatabaseSchemaEntity> get allSchemaEntities => [profileEntries];
@override
int get schemaVersion => 1;
@override
DriftDatabaseOptions get options =>
const DriftDatabaseOptions(storeDateTimeAsText: true);
}

View File

@@ -0,0 +1,104 @@
// GENERATED CODE, DO NOT EDIT BY HAND.
// ignore_for_file: type=lint
//@dart=2.12
import 'package:drift/drift.dart';
class ProfileEntries extends Table with TableInfo {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
ProfileEntries(this.attachedDatabase, [this._alias]);
late final GeneratedColumn<String> id = GeneratedColumn<String>(
'id', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
late final GeneratedColumn<String> type = GeneratedColumn<String>(
'type', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
late final GeneratedColumn<bool> active = GeneratedColumn<bool>(
'active', aliasedName, false,
type: DriftSqlType.bool,
requiredDuringInsert: true,
defaultConstraints:
GeneratedColumn.constraintIsAlways('CHECK ("active" IN (0, 1))'));
late final GeneratedColumn<String> name =
GeneratedColumn<String>('name', aliasedName, false,
additionalChecks: GeneratedColumn.checkTextLength(
minTextLength: 1,
),
type: DriftSqlType.string,
requiredDuringInsert: true);
late final GeneratedColumn<String> url = GeneratedColumn<String>(
'url', aliasedName, true,
type: DriftSqlType.string, requiredDuringInsert: false);
late final GeneratedColumn<DateTime> lastUpdate = GeneratedColumn<DateTime>(
'last_update', aliasedName, false,
type: DriftSqlType.dateTime, requiredDuringInsert: true);
late final GeneratedColumn<int> updateInterval = GeneratedColumn<int>(
'update_interval', aliasedName, true,
type: DriftSqlType.int, requiredDuringInsert: false);
late final GeneratedColumn<int> upload = GeneratedColumn<int>(
'upload', aliasedName, true,
type: DriftSqlType.int, requiredDuringInsert: false);
late final GeneratedColumn<int> download = GeneratedColumn<int>(
'download', aliasedName, true,
type: DriftSqlType.int, requiredDuringInsert: false);
late final GeneratedColumn<int> total = GeneratedColumn<int>(
'total', aliasedName, true,
type: DriftSqlType.int, requiredDuringInsert: false);
late final GeneratedColumn<DateTime> expire = GeneratedColumn<DateTime>(
'expire', aliasedName, true,
type: DriftSqlType.dateTime, requiredDuringInsert: false);
late final GeneratedColumn<String> webPageUrl = GeneratedColumn<String>(
'web_page_url', aliasedName, true,
type: DriftSqlType.string, requiredDuringInsert: false);
late final GeneratedColumn<String> supportUrl = GeneratedColumn<String>(
'support_url', aliasedName, true,
type: DriftSqlType.string, requiredDuringInsert: false);
@override
List<GeneratedColumn> get $columns => [
id,
type,
active,
name,
url,
lastUpdate,
updateInterval,
upload,
download,
total,
expire,
webPageUrl,
supportUrl
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'profile_entries';
@override
Set<GeneratedColumn> get $primaryKey => {id};
@override
Never map(Map<String, dynamic> data, {String? tablePrefix}) {
throw UnsupportedError('TableInfo.map in schema verification code');
}
@override
ProfileEntries createAlias(String alias) {
return ProfileEntries(attachedDatabase, alias);
}
}
class DatabaseAtV2 extends GeneratedDatabase {
DatabaseAtV2(QueryExecutor e) : super(e);
late final ProfileEntries profileEntries = ProfileEntries(this);
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@override
List<DatabaseSchemaEntity> get allSchemaEntities => [profileEntries];
@override
int get schemaVersion => 2;
@override
DriftDatabaseOptions get options =>
const DriftDatabaseOptions(storeDateTimeAsText: true);
}

View File

@@ -0,0 +1,20 @@
import 'package:drift_dev/api/migrations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hiddify/data/local/database.dart';
import 'generated_migrations/schema.dart';
void main() {
late SchemaVerifier verifier;
setUpAll(() {
verifier = SchemaVerifier(GeneratedHelper());
});
test('upgrade from v1 to v2', () async {
final connection = await verifier.startAt(1);
final db = AppDatabase(connection: connection);
await verifier.migrateAndValidate(db, 2);
});
}

View File

@@ -19,7 +19,6 @@ void main() {
expect(profile.url, equals(validExtendedUrl));
expect(profile.options, isNull);
expect(profile.subInfo, isNull);
expect(profile.extra, isNull);
},
);
@@ -53,13 +52,6 @@ void main() {
download: 1024,
total: 10240,
expire: DateTime(2024),
),
),
);
expect(
profile.extra,
equals(
const ProfileExtra(
webPageUrl: validBaseUrl,
supportUrl: validSupportUrl,
),