diff --git a/assets/translations/strings.i18n.json b/assets/translations/strings.i18n.json index b3972266..ae139e6f 100644 --- a/assets/translations/strings.i18n.json +++ b/assets/translations/strings.i18n.json @@ -239,6 +239,7 @@ "profiles": { "unexpected": "Unexpected Error", "notFound": "Profile Not Found", + "invalidUrl": "Invalid URL", "invalidConfig": "Invalid Configs" } } diff --git a/assets/translations/strings_fa.i18n.json b/assets/translations/strings_fa.i18n.json index 9926f296..7d441fc1 100644 --- a/assets/translations/strings_fa.i18n.json +++ b/assets/translations/strings_fa.i18n.json @@ -11,7 +11,7 @@ }, "sort": "مرتب‌سازی", "sortBy": "مرتب‌سازی براساس" - }, + }, "intro": { "termsAndPolicyCaution(rich)": "در صورت ادامه با ${tap(@:about.termsAndConditions)} موافقت میکنید", "start": "شروع" @@ -239,6 +239,7 @@ "profiles": { "unexpected": "خطای غیرمنتظره", "notFound": "پروفایل یافت نشد", + "invalidUrl": "لینک نامعتبر", "invalidConfig": "کانفیگ غیر معتبر" } } diff --git a/lib/data/local/data_mappers.dart b/lib/data/local/data_mappers.dart index 43ddccd2..0646a749 100644 --- a/lib/data/local/data_mappers.dart +++ b/lib/data/local/data_mappers.dart @@ -4,20 +4,31 @@ import 'package:hiddify/domain/profiles/profiles.dart'; extension ProfileMapper on Profile { ProfileEntriesCompanion toCompanion() { - return ProfileEntriesCompanion.insert( - id: id, - active: active, - 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), - webPageUrl: Value(extra?.webPageUrl), - supportUrl: Value(extra?.supportUrl), - ); + return switch (this) { + RemoteProfile(:final url, :final options, :final subInfo) => + ProfileEntriesCompanion.insert( + id: id, + type: ProfileType.remote, + active: active, + name: name, + 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(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( - id: e.id, - active: e.active, - name: e.name, - url: e.url, - lastUpdate: e.lastUpdate, - options: options, - subInfo: subInfo, - extra: extra, - ); + return switch (e.type) { + ProfileType.remote => RemoteProfile( + id: e.id, + active: e.active, + name: e.name, + url: e.url!, + lastUpdate: e.lastUpdate, + options: options, + subInfo: subInfo, + ), + ProfileType.local => LocalProfile( + id: e.id, + active: e.active, + name: e.name, + lastUpdate: e.lastUpdate, + ), + }; } } diff --git a/lib/data/local/database.dart b/lib/data/local/database.dart index 17abc549..a837c1ef 100644 --- a/lib/data/local/database.dart +++ b/lib/data/local/database.dart @@ -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("remote"), + }, + newColumns: [schema.profileEntries.type], + ), + ); + }, + ), + ); + } } LazyDatabase _openConnection() { diff --git a/lib/data/local/schema_versions.dart b/lib/data/local/schema_versions.dart new file mode 100644 index 00000000..7a743f02 --- /dev/null +++ b/lib/data/local/schema_versions.dart @@ -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 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 get id => + columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get type => + columnsByName['type']! as i1.GeneratedColumn; + i1.GeneratedColumn get active => + columnsByName['active']! as i1.GeneratedColumn; + i1.GeneratedColumn get name => + columnsByName['name']! as i1.GeneratedColumn; + i1.GeneratedColumn get url => + columnsByName['url']! as i1.GeneratedColumn; + i1.GeneratedColumn get lastUpdate => + columnsByName['last_update']! as i1.GeneratedColumn; + i1.GeneratedColumn get updateInterval => + columnsByName['update_interval']! as i1.GeneratedColumn; + i1.GeneratedColumn get upload => + columnsByName['upload']! as i1.GeneratedColumn; + i1.GeneratedColumn get download => + columnsByName['download']! as i1.GeneratedColumn; + i1.GeneratedColumn get total => + columnsByName['total']! as i1.GeneratedColumn; + i1.GeneratedColumn get expire => + columnsByName['expire']! as i1.GeneratedColumn; + i1.GeneratedColumn get webPageUrl => + columnsByName['web_page_url']! as i1.GeneratedColumn; + i1.GeneratedColumn get supportUrl => + columnsByName['support_url']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_0(String aliasedName) => + i1.GeneratedColumn('id', aliasedName, false, + type: i1.DriftSqlType.string); +i1.GeneratedColumn _column_1(String aliasedName) => + i1.GeneratedColumn('type', aliasedName, false, + type: i1.DriftSqlType.string); +i1.GeneratedColumn _column_2(String aliasedName) => + i1.GeneratedColumn('active', aliasedName, false, + type: i1.DriftSqlType.bool, + defaultConstraints: i1.GeneratedColumn.constraintIsAlways( + 'CHECK ("active" IN (0, 1))')); +i1.GeneratedColumn _column_3(String aliasedName) => + i1.GeneratedColumn('name', aliasedName, false, + additionalChecks: i1.GeneratedColumn.checkTextLength( + minTextLength: 1, + ), + type: i1.DriftSqlType.string); +i1.GeneratedColumn _column_4(String aliasedName) => + i1.GeneratedColumn('url', aliasedName, true, + type: i1.DriftSqlType.string); +i1.GeneratedColumn _column_5(String aliasedName) => + i1.GeneratedColumn('last_update', aliasedName, false, + type: i1.DriftSqlType.dateTime); +i1.GeneratedColumn _column_6(String aliasedName) => + i1.GeneratedColumn('update_interval', aliasedName, true, + type: i1.DriftSqlType.int); +i1.GeneratedColumn _column_7(String aliasedName) => + i1.GeneratedColumn('upload', aliasedName, true, + type: i1.DriftSqlType.int); +i1.GeneratedColumn _column_8(String aliasedName) => + i1.GeneratedColumn('download', aliasedName, true, + type: i1.DriftSqlType.int); +i1.GeneratedColumn _column_9(String aliasedName) => + i1.GeneratedColumn('total', aliasedName, true, + type: i1.DriftSqlType.int); +i1.GeneratedColumn _column_10(String aliasedName) => + i1.GeneratedColumn('expire', aliasedName, true, + type: i1.DriftSqlType.dateTime); +i1.GeneratedColumn _column_11(String aliasedName) => + i1.GeneratedColumn('web_page_url', aliasedName, true, + type: i1.DriftSqlType.string); +i1.GeneratedColumn _column_12(String aliasedName) => + i1.GeneratedColumn('support_url', aliasedName, true, + type: i1.DriftSqlType.string); +i0.MigrationStepWithVersion migrationSteps({ + required Future 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 Function(i1.Migrator m, _S2 schema) from1To2, +}) => + i0.VersionedSchema.stepByStepHelper( + step: migrationSteps( + from1To2: from1To2, + )); diff --git a/lib/data/local/schemas/drift_schema_v1.json b/lib/data/local/schemas/drift_schema_v1.json new file mode 100644 index 00000000..dab8da31 --- /dev/null +++ b/lib/data/local/schemas/drift_schema_v1.json @@ -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" + ] + } + } + ] +} \ No newline at end of file diff --git a/lib/data/local/schemas/drift_schema_v2.json b/lib/data/local/schemas/drift_schema_v2.json new file mode 100644 index 00000000..0c41a48b --- /dev/null +++ b/lib/data/local/schemas/drift_schema_v2.json @@ -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.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" + ] + } + } + ] +} \ No newline at end of file diff --git a/lib/data/local/tables.dart b/lib/data/local/tables.dart index 0ae36057..17bba834 100644 --- a/lib/data/local/tables.dart +++ b/lib/data/local/tables.dart @@ -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()(); 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())(); diff --git a/lib/data/repository/profiles_repository_impl.dart b/lib/data/repository/profiles_repository_impl.dart index bd2ae9d9..d9fef8fa 100644 --- a/lib/data/repository/profiles_repository_impl.dart +++ b/lib/data/repository/profiles_repository_impl.dart @@ -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 add(Profile baseProfile) { + TaskEither 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 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 update(Profile baseProfile) { + TaskEither 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 fetch( + TaskEither 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); diff --git a/lib/domain/profiles/profile.dart b/lib/domain/profiles/profile.dart index b3630917..2a542341 100644 --- a/lib/domain/profiles/profile.dart +++ b/lib/domain/profiles/profile.dart @@ -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> headers) { + const factory Profile.local({ + required String id, + required bool active, + required String name, + required DateTime lastUpdate, + }) = LocalProfile; + + static RemoteProfile fromResponse( + String url, + Map> 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 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(); diff --git a/lib/domain/profiles/profiles_failure.dart b/lib/domain/profiles/profiles_failure.dart index 11720b00..c564995e 100644 --- a/lib/domain/profiles/profiles_failure.dart +++ b/lib/domain/profiles/profiles_failure.dart @@ -15,6 +15,9 @@ sealed class ProfileFailure with _$ProfileFailure, Failure { const factory ProfileFailure.notFound() = ProfileNotFoundFailure; + @With() + 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 diff --git a/lib/domain/profiles/profiles_repository.dart b/lib/domain/profiles/profiles_repository.dart index 803417cb..7476d2a2 100644 --- a/lib/domain/profiles/profiles_repository.dart +++ b/lib/domain/profiles/profiles_repository.dart @@ -19,9 +19,15 @@ abstract class ProfilesRepository { bool markAsActive = false, }); - TaskEither add(Profile baseProfile); + TaskEither addByContent( + String content, { + required String name, + bool markAsActive = false, + }); - TaskEither update(Profile baseProfile); + TaskEither add(RemoteProfile baseProfile); + + TaskEither update(RemoteProfile baseProfile); TaskEither edit(Profile profile); diff --git a/lib/features/common/active_profile/active_profile_notifier.dart b/lib/features/common/active_profile/active_profile_notifier.dart index 3b8dcd3e..af11965d 100644 --- a/lib/features/common/active_profile/active_profile_notifier.dart +++ b/lib/features/common/active_profile/active_profile_notifier.dart @@ -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 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; - } } diff --git a/lib/features/common/profile_tile.dart b/lib/features/common/profile_tile.dart index f3db3077..805134ce 100644 --- a/lib/features/common/profile_tile.dart +++ b/lib/features/common/profile_tile.dart @@ -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,17 +63,19 @@ class ProfileTile extends HookConsumerWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - SizedBox( - width: 48, - child: Semantics( - sortKey: const OrdinalSortKey(1), - child: ProfileActionButton(profile, !isMain), + if (profile is RemoteProfile || !isMain) ...[ + SizedBox( + width: 48, + child: Semantics( + sortKey: const OrdinalSortKey(1), + child: ProfileActionButton(profile, !isMain), + ), ), - ), - VerticalDivider( - width: 1, - color: effectiveOutlineColor, - ), + VerticalDivider( + 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,20 +255,21 @@ class ProfileActionsMenu extends HookConsumerWidget { return MenuAnchor( builder: builder, menuChildren: [ - MenuItemButton( - leadingIcon: const Icon(Icons.update), - child: Text(t.profile.update.buttonTxt), - onPressed: () { - if (updateProfileMutation.state.isInProgress) { - return; - } - updateProfileMutation.setFuture( - ref - .read(profilesNotifierProvider.notifier) - .updateProfile(profile), - ); - }, - ), + if (profile case RemoteProfile()) + MenuItemButton( + leadingIcon: const Icon(Icons.update), + child: Text(t.profile.update.buttonTxt), + onPressed: () { + if (updateProfileMutation.state.isInProgress) { + return; + } + updateProfileMutation.setFuture( + ref + .read(profilesNotifierProvider.notifier) + .updateProfile(profile as RemoteProfile), + ); + }, + ), MenuItemButton( leadingIcon: const Icon(Icons.edit), child: Text(t.profile.edit.buttonTxt), diff --git a/lib/features/profile_detail/notifier/profile_detail_notifier.dart b/lib/features/profile_detail/notifier/profile_detail_notifier.dart index 27a803a7..67c81706 100644 --- a/lib/features/profile_detail/notifier/profile_detail_notifier.dart +++ b/lib/features/profile_detail/notifier/profile_detail_notifier.dart @@ -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,15 +52,20 @@ 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, - options: updateInterval == null - ? value.profile.options - : updateInterval.fold( - () => null, - (t) => ProfileOptions(updateInterval: Duration(hours: t)), - ), + profile: value.profile.map( + remote: (rp) => rp.copyWith( + name: name ?? rp.name, + url: url ?? rp.url, + options: updateInterval == null + ? rp.options + : updateInterval.fold( + () => null, + (t) => ProfileOptions( + updateInterval: Duration(hours: t), + ), + ), + ), + local: (lp) => lp.copyWith(name: name ?? lp.name), ), ), ); @@ -71,24 +76,33 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger { if (state case AsyncData(:final value)) { if (value.save.isInProgress) return; final profile = value.profile; - loggy.debug( - 'saving profile, url: [${profile.url}], name: [${profile.name}]', - ); - state = AsyncData(value.copyWith(save: const MutationInProgress())); Either? failureOrSuccess; - if (profile.name.isBlank || profile.url.isBlank) { - loggy.debug('profile save: invalid arguments'); - } else if (value.isEditing) { - if (_originalProfile?.url == profile.url) { + state = AsyncData(value.copyWith(save: const MutationInProgress())); + switch (profile) { + case RemoteProfile(): + loggy.debug( + 'saving profile, url: [${profile.url}], name: [${profile.name}]', + ); + if (profile.name.isBlank || profile.url.isBlank) { + loggy.debug('profile save: invalid arguments'); + } else if (value.isEditing) { + if (_originalProfile case RemoteProfile(:final url) + when url == profile.url) { + loggy.debug('editing profile'); + failureOrSuccess = await _profilesRepo.edit(profile).run(); + } else { + loggy.debug('updating profile'); + failureOrSuccess = await _profilesRepo.update(profile).run(); + } + } else { + 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(); - } else { - loggy.debug('updating profile'); - failureOrSuccess = await _profilesRepo.update(profile).run(); - } - } else { - loggy.debug('adding profile, url: [${profile.url}]'); - failureOrSuccess = await _profilesRepo.add(profile).run(); + default: + loggy.warning("local profile can't be added manually"); } state = AsyncData( value.copyWith( @@ -105,12 +119,17 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger { Future 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( diff --git a/lib/features/profile_detail/view/profile_detail_page.dart b/lib/features/profile_detail/view/profile_detail_page.dart index 66bf4f94..fe3c1c27 100644 --- a/lib/features/profile_detail/view/profile_detail_page.dart +++ b/lib/features/profile_detail/view/profile_detail_page.dart @@ -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,12 +94,13 @@ class ProfileDetailPage extends HookConsumerWidget with PresLogger { PopupMenuButton( itemBuilder: (context) { return [ - PopupMenuItem( - child: Text(t.profile.update.buttonTxt), - onTap: () async { - await notifier.updateProfile(); - }, - ), + if (state.profile case RemoteProfile()) + PopupMenuItem( + child: Text(t.profile.update.buttonTxt), + onTap: () async { + await notifier.updateProfile(); + }, + ), PopupMenuItem( child: Text(t.profile.delete.buttonTxt), onTap: () async { @@ -140,52 +142,55 @@ class ProfileDetailPage extends HookConsumerWidget with PresLogger { hint: t.profile.detailsForm.nameHint, ), ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, + if (state.profile + case RemoteProfile(:final url, :final options)) ...[ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: CustomTextFormField( + initialValue: url, + onChanged: (value) => + notifier.setField(url: value), + validator: (value) => + (value != null && !isUrl(value)) + ? t.profile.detailsForm.invalidUrlMsg + : null, + label: t.profile.detailsForm.urlLabel, + hint: t.profile.detailsForm.urlHint, + ), ), - child: CustomTextFormField( - initialValue: state.profile.url, - onChanged: (value) => notifier.setField(url: value), - validator: (value) => - (value != null && !isUrl(value)) - ? t.profile.detailsForm.invalidUrlMsg - : null, - label: t.profile.detailsForm.urlLabel, - hint: t.profile.detailsForm.urlHint, + ListTile( + title: Text(t.profile.detailsForm.updateInterval), + subtitle: Text( + options?.updateInterval.toApproximateTime( + isRelativeToNow: false, + ) ?? + t.general.toggle.disabled, + ), + leading: const Icon(Icons.update), + onTap: () async { + final intervalInHours = await SettingsInputDialog( + title: t.profile.detailsForm + .updateIntervalDialogTitle, + initialValue: options?.updateInterval.inHours, + optionalAction: ( + t.general.state.disable, + () => + notifier.setField(updateInterval: none()), + ), + validator: isPort, + mapTo: int.tryParse, + digitsOnly: true, + ).show(context); + if (intervalInHours == null) return; + notifier.setField( + updateInterval: optionOf(intervalInHours), + ); + }, ), - ), - ListTile( - title: Text(t.profile.detailsForm.updateInterval), - subtitle: Text( - state.profile.options?.updateInterval - .toApproximateTime( - isRelativeToNow: false, - ) ?? - t.general.toggle.disabled, - ), - leading: const Icon(Icons.update), - onTap: () async { - final intervalInHours = await SettingsInputDialog( - title: t.profile.detailsForm - .updateIntervalDialogTitle, - initialValue: - state.profile.options?.updateInterval.inHours, - optionalAction: ( - t.general.state.disable, - () => notifier.setField(updateInterval: none()), - ), - validator: isPort, - mapTo: int.tryParse, - digitsOnly: true, - ).show(context); - if (intervalInHours == null) return; - notifier.setField( - updateInterval: optionOf(intervalInHours), - ); - }, - ), + ], if (state.isEditing) ListTile( title: Text(t.profile.detailsForm.lastUpdate), diff --git a/lib/features/profiles/notifier/profiles_notifier.dart b/lib/features/profiles/notifier/profiles_notifier.dart index faa61089..a6feefa2 100644 --- a/lib/features/profiles/notifier/profiles_notifier.dart +++ b/lib/features/profiles/notifier/profiles_notifier.dart @@ -49,21 +49,39 @@ class ProfilesNotifier extends _$ProfilesNotifier with AppLogger { }).run(); } - Future addProfile(String url) async { + Future addProfile(String rawInput) async { final activeProfile = await ref.read(activeProfileProvider.future); final markAsActive = activeProfile == null || ref.read(markNewProfileActiveProvider); - loggy.debug("adding profile, url: [$url]"); - return ref - .read(profilesRepositoryProvider) - .addByUrl(url, markAsActive: markAsActive) - .getOrElse((l) { - loggy.warning("failed to add profile: $l"); - throw l; - }).run(); + if (LinkParser.parse(rawInput) case (final link)?) { + loggy.debug("adding profile, url: [${link.url}]"); + return ref + .read(profilesRepositoryProvider) + .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 updateProfile(Profile profile) async { + Future updateProfile(RemoteProfile profile) async { loggy.debug("updating profile"); return ref .read(profilesRepositoryProvider) diff --git a/lib/features/profiles/notifier/profiles_update_notifier.dart b/lib/features/profiles/notifier/profiles_update_notifier.dart index ed1ae442..5b3d2da7 100644 --- a/lib/features/profiles/notifier/profiles_update_notifier.dart +++ b/lib/features/profiles/notifier/profiles_update_notifier.dart @@ -50,20 +50,22 @@ class ProfilesUpdateNotifier extends _$ProfilesUpdateNotifier with AppLogger { await ref.read(profilesRepositoryProvider).watchAll().first; if (failureOrProfiles case Right(value: final profiles)) { for (final profile in profiles) { - loggy.debug("checking profile: [${profile.name}]"); - final updateInterval = profile.options?.updateInterval; - if (updateInterval != null && - updateInterval <= - DateTime.now().difference(profile.lastUpdate)) { - final failureOrSuccess = await ref - .read(profilesRepositoryProvider) - .update(profile) - .run(); - state = AsyncData( - (name: profile.name, failureOrSuccess: failureOrSuccess), - ); - } else { - loggy.debug("skipping profile: [${profile.name}]"); + if (profile case RemoteProfile()) { + loggy.debug("checking profile: [${profile.name}]"); + final updateInterval = profile.options?.updateInterval; + if (updateInterval != null && + updateInterval <= + DateTime.now().difference(profile.lastUpdate)) { + final failureOrSuccess = await ref + .read(profilesRepositoryProvider) + .update(profile) + .run(); + state = AsyncData( + (name: profile.name, failureOrSuccess: failureOrSuccess), + ); + } else { + loggy.debug("skipping profile: [${profile.name}]"); + } } } } diff --git a/lib/features/profiles/view/add_profile_modal.dart b/lib/features/profiles/view/add_profile_modal.dart index 2dd47ac7..4f619ac0 100644 --- a/lib/features/profiles/view/add_profile_modal.dart +++ b/lib/features/profiles/view/add_profile_modal.dart @@ -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); - CustomAlertDialog.fromErr(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) { - if (addProfileMutation.state.isInProgress) return; - mutationTriggered.value = true; - addProfileMutation.setFuture( - ref - .read(profilesNotifierProvider.notifier) - .addProfile(link.url), - ); - } else { - if (context.mounted) { - CustomToast.error( - t.profile.add.invalidUrlMsg, - ).show(context); - } - } + await Clipboard.getData(Clipboard.kTextPlain) + .then((value) => value?.text ?? ''); + if (addProfileMutation.state.isInProgress) return; + mutationTriggered.value = true; + addProfileMutation.setFuture( + ref + .read(profilesNotifierProvider.notifier) + .addProfile(captureResult), + ); }, ), const Gap(buttonsGap), @@ -134,24 +131,15 @@ 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; - } - mutationTriggered.value = true; - addProfileMutation.setFuture( - ref - .read(profilesNotifierProvider.notifier) - .addProfile(link.url), - ); - } else { - if (context.mounted) { - CustomToast.error( - t.profile.add.invalidUrlMsg, - ).show(context); - } + if (addProfileMutation.state.isInProgress) { + return; } + mutationTriggered.value = true; + addProfileMutation.setFuture( + ref + .read(profilesNotifierProvider.notifier) + .addProfile(captureResult), + ); }, ) else diff --git a/lib/services/files_editor_service.dart b/lib/services/files_editor_service.dart index 183da987..aba670fd 100644 --- a/lib/services/files_editor_service.dart +++ b/lib/services/files_editor_service.dart @@ -77,6 +77,8 @@ class FilesEditorService with InfraLogger { return p.join(_configsDir.path, "$fileName.json"); } + String tempConfigPath(String fileName) => configPath("temp_$fileName"); + Future deleteConfig(String fileName) { return File(configPath(fileName)).delete(); } diff --git a/lib/utils/link_parsers.dart b/lib/utils/link_parsers.dart index 3a7574cf..ad79d87d 100644 --- a/lib/utils/link_parsers.dart +++ b/lib/utils/link_parsers.dart @@ -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; + } +} diff --git a/test/data/local/generated_migrations/schema.dart b/test/data/local/generated_migrations/schema.dart new file mode 100644 index 00000000..c8c6ff59 --- /dev/null +++ b/test/data/local/generated_migrations/schema.dart @@ -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}); + } + } +} diff --git a/test/data/local/generated_migrations/schema_v1.dart b/test/data/local/generated_migrations/schema_v1.dart new file mode 100644 index 00000000..f8cbdde7 --- /dev/null +++ b/test/data/local/generated_migrations/schema_v1.dart @@ -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 id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn active = GeneratedColumn( + 'active', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("active" IN (0, 1))')); + late final GeneratedColumn name = + GeneratedColumn('name', aliasedName, false, + additionalChecks: GeneratedColumn.checkTextLength( + minTextLength: 1, + ), + type: DriftSqlType.string, + requiredDuringInsert: true); + late final GeneratedColumn url = GeneratedColumn( + 'url', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn lastUpdate = GeneratedColumn( + 'last_update', aliasedName, false, + type: DriftSqlType.dateTime, requiredDuringInsert: true); + late final GeneratedColumn updateInterval = GeneratedColumn( + 'update_interval', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn upload = GeneratedColumn( + 'upload', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn download = GeneratedColumn( + 'download', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn total = GeneratedColumn( + 'total', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn expire = GeneratedColumn( + 'expire', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + late final GeneratedColumn webPageUrl = GeneratedColumn( + 'web_page_url', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn supportUrl = GeneratedColumn( + 'support_url', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + @override + List 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 get $primaryKey => {id}; + @override + Never map(Map 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> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [profileEntries]; + @override + int get schemaVersion => 1; + @override + DriftDatabaseOptions get options => + const DriftDatabaseOptions(storeDateTimeAsText: true); +} diff --git a/test/data/local/generated_migrations/schema_v2.dart b/test/data/local/generated_migrations/schema_v2.dart new file mode 100644 index 00000000..846a5be5 --- /dev/null +++ b/test/data/local/generated_migrations/schema_v2.dart @@ -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 id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn type = GeneratedColumn( + 'type', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn active = GeneratedColumn( + 'active', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("active" IN (0, 1))')); + late final GeneratedColumn name = + GeneratedColumn('name', aliasedName, false, + additionalChecks: GeneratedColumn.checkTextLength( + minTextLength: 1, + ), + type: DriftSqlType.string, + requiredDuringInsert: true); + late final GeneratedColumn url = GeneratedColumn( + 'url', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn lastUpdate = GeneratedColumn( + 'last_update', aliasedName, false, + type: DriftSqlType.dateTime, requiredDuringInsert: true); + late final GeneratedColumn updateInterval = GeneratedColumn( + 'update_interval', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn upload = GeneratedColumn( + 'upload', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn download = GeneratedColumn( + 'download', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn total = GeneratedColumn( + 'total', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn expire = GeneratedColumn( + 'expire', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + late final GeneratedColumn webPageUrl = GeneratedColumn( + 'web_page_url', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn supportUrl = GeneratedColumn( + 'support_url', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + @override + List 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 get $primaryKey => {id}; + @override + Never map(Map 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> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [profileEntries]; + @override + int get schemaVersion => 2; + @override + DriftDatabaseOptions get options => + const DriftDatabaseOptions(storeDateTimeAsText: true); +} diff --git a/test/data/local/migrations_test.dart b/test/data/local/migrations_test.dart new file mode 100644 index 00000000..9a9a15b4 --- /dev/null +++ b/test/data/local/migrations_test.dart @@ -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); + }); +} diff --git a/test/domain/profiles/profile_test.dart b/test/domain/profiles/profile_test.dart index e46225d3..8af95cf5 100644 --- a/test/domain/profiles/profile_test.dart +++ b/test/domain/profiles/profile_test.dart @@ -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, ),