diff --git a/app/lib/features/content_block/data/database/daos/host.dart b/app/lib/features/content_block/data/database/daos/host.dart new file mode 100644 index 00000000..76cbdf2d --- /dev/null +++ b/app/lib/features/content_block/data/database/daos/host.dart @@ -0,0 +1,25 @@ +import 'package:bang_navigator/features/content_block/data/database/database.dart'; +import 'package:bang_navigator/features/content_block/data/models/host.dart'; +import 'package:drift/drift.dart'; + +part 'host.g.dart'; + +@DriftAccessor() +class HostDao extends DatabaseAccessor with _$HostDaoMixin { + HostDao(super.db); + + Selectable getHostList({Iterable? sources}) { + final selectable = select(db.host); + if (sources != null) { + selectable.where((t) => t.source.isInValues(sources)); + } + + return selectable; + } + + SingleSelectable getHostCount({Iterable? sources}) { + return db.host.count( + where: (sources != null) ? (t) => t.source.isInValues(sources) : null, + ); + } +} diff --git a/app/lib/features/content_block/data/database/daos/host.g.dart b/app/lib/features/content_block/data/database/daos/host.g.dart new file mode 100644 index 00000000..ebaa6250 --- /dev/null +++ b/app/lib/features/content_block/data/database/daos/host.g.dart @@ -0,0 +1,6 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'host.dart'; + +// ignore_for_file: type=lint +mixin _$HostDaoMixin on DatabaseAccessor {} diff --git a/app/lib/features/content_block/data/database/daos/sync.dart b/app/lib/features/content_block/data/database/daos/sync.dart new file mode 100644 index 00000000..41a2ca00 --- /dev/null +++ b/app/lib/features/content_block/data/database/daos/sync.dart @@ -0,0 +1,66 @@ +import 'package:bang_navigator/features/content_block/data/database/database.dart'; +import 'package:bang_navigator/features/content_block/data/models/host.dart'; +import 'package:drift/drift.dart'; + +part 'sync.g.dart'; + +@DriftAccessor() +class SyncDao extends DatabaseAccessor with _$SyncDaoMixin { + SyncDao(super.db); + + SingleOrNullSelectable lastSyncOfSource(HostSource source) { + final query = selectOnly(db.hostSync) + ..addColumns([db.hostSync.lastSync]) + ..where(db.hostSync.source.equalsValue(source)); + + return query.map((row) => row.read(db.hostSync.lastSync)); + } + + Future upsertLastSyncOfSource(HostSource source, DateTime lastSync) { + return db.hostSync.insertOne( + HostSyncCompanion.insert( + source: Value(source), + lastSync: lastSync, + ), + onConflict: DoUpdate( + (old) => HostSyncCompanion.custom(lastSync: Variable(lastSync)), + ), + ); + } + + Future insertHosts(HostSource source, Iterable hosts) { + return db.host.insertAll( + hosts.map((host) => HostCompanion.insert(hostname: host, source: source)), + // It is possible that we get conflicts, in case the host is added in + // multiple lists. In this case we just gonna igore it, and make sure, + // the unified list is inserted first. + mode: InsertMode.insertOrIgnore, + ); + } + + Future deleteHosts(Iterable hosts) { + return db.host.deleteWhere((t) => t.hostname.isIn(hosts)); + } + + Future syncHosts({ + required HostSource source, + required Set remoteHosts, + required DateTime syncTime, + }) async { + final localHosts = await db.hostDao + .getHostList(sources: [source]) + .get() + .then((hosts) => hosts.map((host) => host.hostname).toSet()); + + final removedHosts = localHosts.difference(remoteHosts); + final addedHosts = remoteHosts.difference(localHosts); + + await db.transaction( + () async { + await deleteHosts(removedHosts); + await insertHosts(source, addedHosts); + await upsertLastSyncOfSource(source, syncTime); + }, + ); + } +} diff --git a/app/lib/features/content_block/data/database/daos/sync.g.dart b/app/lib/features/content_block/data/database/daos/sync.g.dart new file mode 100644 index 00000000..ccd14eda --- /dev/null +++ b/app/lib/features/content_block/data/database/daos/sync.g.dart @@ -0,0 +1,6 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sync.dart'; + +// ignore_for_file: type=lint +mixin _$SyncDaoMixin on DatabaseAccessor {} diff --git a/app/lib/features/content_block/data/database/database.dart b/app/lib/features/content_block/data/database/database.dart new file mode 100644 index 00000000..cd9343b8 --- /dev/null +++ b/app/lib/features/content_block/data/database/database.dart @@ -0,0 +1,17 @@ +import 'package:bang_navigator/features/content_block/data/database/daos/host.dart'; +import 'package:bang_navigator/features/content_block/data/database/daos/sync.dart'; +import 'package:bang_navigator/features/content_block/data/models/host.dart'; +import 'package:drift/drift.dart'; + +part 'database.g.dart'; + +@DriftDatabase( + include: {'database.drift'}, + daos: [HostDao, SyncDao], +) +class HostDatabase extends _$HostDatabase { + @override + final int schemaVersion = 1; + + HostDatabase(super.e); +} diff --git a/app/lib/features/content_block/data/database/database.drift b/app/lib/features/content_block/data/database/database.drift new file mode 100644 index 00000000..159d78f7 --- /dev/null +++ b/app/lib/features/content_block/data/database/database.drift @@ -0,0 +1,11 @@ +import 'package:bang_navigator/features/content_block/data/models/host.dart'; + +CREATE TABLE host ( + hostname TEXT PRIMARY KEY NOT NULL, + source ENUM(HostSource) NOT NULL +); + +CREATE TABLE host_sync ( + source ENUM(HostSource) PRIMARY KEY NOT NULL, + last_sync DATETIME NOT NULL +); \ No newline at end of file diff --git a/app/lib/features/content_block/data/database/database.g.dart b/app/lib/features/content_block/data/database/database.g.dart new file mode 100644 index 00000000..ae8bfd4c --- /dev/null +++ b/app/lib/features/content_block/data/database/database.g.dart @@ -0,0 +1,521 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'database.dart'; + +// ignore_for_file: type=lint +class Host extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Host(this.attachedDatabase, [this._alias]); + late final GeneratedColumn hostname = GeneratedColumn( + 'hostname', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL'); + late final GeneratedColumnWithTypeConverter source = + GeneratedColumn('source', aliasedName, false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL') + .withConverter(Host.$convertersource); + @override + List get $columns => [hostname, source]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'host'; + @override + Set get $primaryKey => {hostname}; + @override + HostData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return HostData( + hostname: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}hostname'])!, + source: Host.$convertersource.fromSql(attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}source'])!), + ); + } + + @override + Host createAlias(String alias) { + return Host(attachedDatabase, alias); + } + + static JsonTypeConverter2 $convertersource = + const EnumIndexConverter(HostSource.values); + @override + bool get dontWriteConstraints => true; +} + +class HostData extends DataClass implements Insertable { + final String hostname; + final HostSource source; + const HostData({required this.hostname, required this.source}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['hostname'] = Variable(hostname); + { + map['source'] = Variable(Host.$convertersource.toSql(source)); + } + return map; + } + + factory HostData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return HostData( + hostname: serializer.fromJson(json['hostname']), + source: Host.$convertersource + .fromJson(serializer.fromJson(json['source'])), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'hostname': serializer.toJson(hostname), + 'source': serializer.toJson(Host.$convertersource.toJson(source)), + }; + } + + HostData copyWith({String? hostname, HostSource? source}) => HostData( + hostname: hostname ?? this.hostname, + source: source ?? this.source, + ); + @override + String toString() { + return (StringBuffer('HostData(') + ..write('hostname: $hostname, ') + ..write('source: $source') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(hostname, source); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is HostData && + other.hostname == this.hostname && + other.source == this.source); +} + +class HostCompanion extends UpdateCompanion { + final Value hostname; + final Value source; + final Value rowid; + const HostCompanion({ + this.hostname = const Value.absent(), + this.source = const Value.absent(), + this.rowid = const Value.absent(), + }); + HostCompanion.insert({ + required String hostname, + required HostSource source, + this.rowid = const Value.absent(), + }) : hostname = Value(hostname), + source = Value(source); + static Insertable custom({ + Expression? hostname, + Expression? source, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (hostname != null) 'hostname': hostname, + if (source != null) 'source': source, + if (rowid != null) 'rowid': rowid, + }); + } + + HostCompanion copyWith( + {Value? hostname, Value? source, Value? rowid}) { + return HostCompanion( + hostname: hostname ?? this.hostname, + source: source ?? this.source, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (hostname.present) { + map['hostname'] = Variable(hostname.value); + } + if (source.present) { + map['source'] = Variable(Host.$convertersource.toSql(source.value)); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('HostCompanion(') + ..write('hostname: $hostname, ') + ..write('source: $source, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class HostSync extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + HostSync(this.attachedDatabase, [this._alias]); + late final GeneratedColumnWithTypeConverter source = + GeneratedColumn('source', aliasedName, false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'PRIMARY KEY NOT NULL') + .withConverter(HostSync.$convertersource); + late final GeneratedColumn lastSync = GeneratedColumn( + 'last_sync', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL'); + @override + List get $columns => [source, lastSync]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'host_sync'; + @override + Set get $primaryKey => {source}; + @override + HostSyncData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return HostSyncData( + source: HostSync.$convertersource.fromSql(attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}source'])!), + lastSync: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}last_sync'])!, + ); + } + + @override + HostSync createAlias(String alias) { + return HostSync(attachedDatabase, alias); + } + + static JsonTypeConverter2 $convertersource = + const EnumIndexConverter(HostSource.values); + @override + bool get dontWriteConstraints => true; +} + +class HostSyncData extends DataClass implements Insertable { + final HostSource source; + final DateTime lastSync; + const HostSyncData({required this.source, required this.lastSync}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + { + map['source'] = Variable(HostSync.$convertersource.toSql(source)); + } + map['last_sync'] = Variable(lastSync); + return map; + } + + factory HostSyncData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return HostSyncData( + source: HostSync.$convertersource + .fromJson(serializer.fromJson(json['source'])), + lastSync: serializer.fromJson(json['last_sync']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'source': + serializer.toJson(HostSync.$convertersource.toJson(source)), + 'last_sync': serializer.toJson(lastSync), + }; + } + + HostSyncData copyWith({HostSource? source, DateTime? lastSync}) => + HostSyncData( + source: source ?? this.source, + lastSync: lastSync ?? this.lastSync, + ); + @override + String toString() { + return (StringBuffer('HostSyncData(') + ..write('source: $source, ') + ..write('lastSync: $lastSync') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(source, lastSync); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is HostSyncData && + other.source == this.source && + other.lastSync == this.lastSync); +} + +class HostSyncCompanion extends UpdateCompanion { + final Value source; + final Value lastSync; + const HostSyncCompanion({ + this.source = const Value.absent(), + this.lastSync = const Value.absent(), + }); + HostSyncCompanion.insert({ + this.source = const Value.absent(), + required DateTime lastSync, + }) : lastSync = Value(lastSync); + static Insertable custom({ + Expression? source, + Expression? lastSync, + }) { + return RawValuesInsertable({ + if (source != null) 'source': source, + if (lastSync != null) 'last_sync': lastSync, + }); + } + + HostSyncCompanion copyWith( + {Value? source, Value? lastSync}) { + return HostSyncCompanion( + source: source ?? this.source, + lastSync: lastSync ?? this.lastSync, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (source.present) { + map['source'] = + Variable(HostSync.$convertersource.toSql(source.value)); + } + if (lastSync.present) { + map['last_sync'] = Variable(lastSync.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('HostSyncCompanion(') + ..write('source: $source, ') + ..write('lastSync: $lastSync') + ..write(')')) + .toString(); + } +} + +abstract class _$HostDatabase extends GeneratedDatabase { + _$HostDatabase(QueryExecutor e) : super(e); + _$HostDatabaseManager get managers => _$HostDatabaseManager(this); + late final Host host = Host(this); + late final HostSync hostSync = HostSync(this); + late final HostDao hostDao = HostDao(this as HostDatabase); + late final SyncDao syncDao = SyncDao(this as HostDatabase); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [host, hostSync]; +} + +typedef $HostInsertCompanionBuilder = HostCompanion Function({ + required String hostname, + required HostSource source, + Value rowid, +}); +typedef $HostUpdateCompanionBuilder = HostCompanion Function({ + Value hostname, + Value source, + Value rowid, +}); + +class $HostTableManager extends RootTableManager< + _$HostDatabase, + Host, + HostData, + $HostFilterComposer, + $HostOrderingComposer, + $HostProcessedTableManager, + $HostInsertCompanionBuilder, + $HostUpdateCompanionBuilder> { + $HostTableManager(_$HostDatabase db, Host table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: $HostFilterComposer(ComposerState(db, table)), + orderingComposer: $HostOrderingComposer(ComposerState(db, table)), + getChildManagerBuilder: (p) => $HostProcessedTableManager(p), + getUpdateCompanionBuilder: ({ + Value hostname = const Value.absent(), + Value source = const Value.absent(), + Value rowid = const Value.absent(), + }) => + HostCompanion( + hostname: hostname, + source: source, + rowid: rowid, + ), + getInsertCompanionBuilder: ({ + required String hostname, + required HostSource source, + Value rowid = const Value.absent(), + }) => + HostCompanion.insert( + hostname: hostname, + source: source, + rowid: rowid, + ), + )); +} + +class $HostProcessedTableManager extends ProcessedTableManager< + _$HostDatabase, + Host, + HostData, + $HostFilterComposer, + $HostOrderingComposer, + $HostProcessedTableManager, + $HostInsertCompanionBuilder, + $HostUpdateCompanionBuilder> { + $HostProcessedTableManager(super.$state); +} + +class $HostFilterComposer extends FilterComposer<_$HostDatabase, Host> { + $HostFilterComposer(super.$state); + ColumnFilters get hostname => $state.composableBuilder( + column: $state.table.hostname, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters get source => + $state.composableBuilder( + column: $state.table.source, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); +} + +class $HostOrderingComposer extends OrderingComposer<_$HostDatabase, Host> { + $HostOrderingComposer(super.$state); + ColumnOrderings get hostname => $state.composableBuilder( + column: $state.table.hostname, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get source => $state.composableBuilder( + column: $state.table.source, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); +} + +typedef $HostSyncInsertCompanionBuilder = HostSyncCompanion Function({ + Value source, + required DateTime lastSync, +}); +typedef $HostSyncUpdateCompanionBuilder = HostSyncCompanion Function({ + Value source, + Value lastSync, +}); + +class $HostSyncTableManager extends RootTableManager< + _$HostDatabase, + HostSync, + HostSyncData, + $HostSyncFilterComposer, + $HostSyncOrderingComposer, + $HostSyncProcessedTableManager, + $HostSyncInsertCompanionBuilder, + $HostSyncUpdateCompanionBuilder> { + $HostSyncTableManager(_$HostDatabase db, HostSync table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: $HostSyncFilterComposer(ComposerState(db, table)), + orderingComposer: $HostSyncOrderingComposer(ComposerState(db, table)), + getChildManagerBuilder: (p) => $HostSyncProcessedTableManager(p), + getUpdateCompanionBuilder: ({ + Value source = const Value.absent(), + Value lastSync = const Value.absent(), + }) => + HostSyncCompanion( + source: source, + lastSync: lastSync, + ), + getInsertCompanionBuilder: ({ + Value source = const Value.absent(), + required DateTime lastSync, + }) => + HostSyncCompanion.insert( + source: source, + lastSync: lastSync, + ), + )); +} + +class $HostSyncProcessedTableManager extends ProcessedTableManager< + _$HostDatabase, + HostSync, + HostSyncData, + $HostSyncFilterComposer, + $HostSyncOrderingComposer, + $HostSyncProcessedTableManager, + $HostSyncInsertCompanionBuilder, + $HostSyncUpdateCompanionBuilder> { + $HostSyncProcessedTableManager(super.$state); +} + +class $HostSyncFilterComposer extends FilterComposer<_$HostDatabase, HostSync> { + $HostSyncFilterComposer(super.$state); + ColumnWithTypeConverterFilters get source => + $state.composableBuilder( + column: $state.table.source, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnFilters get lastSync => $state.composableBuilder( + column: $state.table.lastSync, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); +} + +class $HostSyncOrderingComposer + extends OrderingComposer<_$HostDatabase, HostSync> { + $HostSyncOrderingComposer(super.$state); + ColumnOrderings get source => $state.composableBuilder( + column: $state.table.source, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get lastSync => $state.composableBuilder( + column: $state.table.lastSync, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); +} + +class _$HostDatabaseManager { + final _$HostDatabase _db; + _$HostDatabaseManager(this._db); + $HostTableManager get host => $HostTableManager(_db, _db.host); + $HostSyncTableManager get hostSync => + $HostSyncTableManager(_db, _db.hostSync); +} diff --git a/app/lib/features/content_block/data/models/host.dart b/app/lib/features/content_block/data/models/host.dart new file mode 100644 index 00000000..38d3b5ce --- /dev/null +++ b/app/lib/features/content_block/data/models/host.dart @@ -0,0 +1,21 @@ +enum HostSource { + stevenBlackUnified( + 'https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts', + ), + stevenBlackFakeNews( + 'https://raw.githubusercontent.com/StevenBlack/hosts/master/alternates/fakenews-only/hosts', + ), + stevenBlackSocial( + 'https://raw.githubusercontent.com/StevenBlack/hosts/master/alternates/social-only/hosts', + ), + stevenBlackGambling( + 'https://raw.githubusercontent.com/StevenBlack/hosts/master/alternates/gambling-only/hosts', + ), + stevenBlackPorn( + 'https://raw.githubusercontent.com/StevenBlack/hosts/master/alternates/porn-only/hosts', + ); + + final String url; + + const HostSource(this.url); +} diff --git a/app/lib/features/content_block/data/providers.dart b/app/lib/features/content_block/data/providers.dart new file mode 100644 index 00000000..80968469 --- /dev/null +++ b/app/lib/features/content_block/data/providers.dart @@ -0,0 +1,37 @@ +import 'package:bang_navigator/features/content_block/data/database/database.dart'; +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart' as path_provider; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:sqlite3/sqlite3.dart'; +import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart'; +import 'package:universal_io/io.dart'; + +part 'providers.g.dart'; + +@Riverpod(keepAlive: true) +HostDatabase hostDatabase(HostDatabaseRef ref) { + return HostDatabase( + LazyDatabase(() async { + // put the database file, called db.sqlite here, into the documents folder + // for your app. + final dbFolder = await path_provider.getApplicationDocumentsDirectory(); + final file = File(p.join(dbFolder.path, 'host.db')); + + // Also work around limitations on old Android versions + if (Platform.isAndroid) { + await applyWorkaroundToOpenSqlite3OnOldAndroidVersions(); + } + + // Make sqlite3 pick a more suitable location for temporary files - the + // one from the system may be inaccessible due to sandboxing. + final cachebase = (await path_provider.getTemporaryDirectory()).path; + // We can't access /tmp on Android, which sqlite3 would try by default. + // Explicitly tell it about the correct temporary directory. + sqlite3.tempDirectory = cachebase; + + return NativeDatabase.createInBackground(file); + }), + ); +} diff --git a/app/lib/features/content_block/data/providers.g.dart b/app/lib/features/content_block/data/providers.g.dart new file mode 100644 index 00000000..757e965e --- /dev/null +++ b/app/lib/features/content_block/data/providers.g.dart @@ -0,0 +1,24 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'providers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$hostDatabaseHash() => r'6fb4a1b259c9a049d8ab9b688ea5fb1cc227c117'; + +/// See also [hostDatabase]. +@ProviderFor(hostDatabase) +final hostDatabaseProvider = Provider.internal( + hostDatabase, + name: r'hostDatabaseProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$hostDatabaseHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef HostDatabaseRef = ProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/content_block/data/services/source.dart b/app/lib/features/content_block/data/services/source.dart new file mode 100644 index 00000000..e612b7b7 --- /dev/null +++ b/app/lib/features/content_block/data/services/source.dart @@ -0,0 +1,44 @@ +import 'dart:convert'; + +import 'package:bang_navigator/core/http_error_handler.dart'; +import 'package:collection/collection.dart'; +import 'package:exceptions/exceptions.dart'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'source.g.dart'; + +@Riverpod() +class HostSourceService extends _$HostSourceService { + late http.Client _client; + + @override + void build() { + _client = http.Client(); + } + + Future>> getHosts(Uri url) async { + return Result.fromAsync( + () async { + final response = await _client.get(url); + return await compute( + (args) { + final hostRegex = RegExp( + r'^\s*([0-9a-fA-F:.]+)\s+(\S+)', + multiLine: true, + ); + + final matches = hostRegex.allMatches(utf8.decode(args[0])); + return matches + .map((match) => match.group(2)) + .whereNotNull() + .toSet(); + }, + [response.bodyBytes], + ); + }, + exceptionHandler: handleHttpError, + ); + } +} diff --git a/app/lib/features/content_block/data/services/source.g.dart b/app/lib/features/content_block/data/services/source.g.dart new file mode 100644 index 00000000..78ecceeb --- /dev/null +++ b/app/lib/features/content_block/data/services/source.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'source.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$hostSourceServiceHash() => r'26ac09fcd34cc37440eb643075984707e0e9bba3'; + +/// See also [HostSourceService]. +@ProviderFor(HostSourceService) +final hostSourceServiceProvider = + AutoDisposeNotifierProvider.internal( + HostSourceService.new, + name: r'hostSourceServiceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$hostSourceServiceHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$HostSourceService = AutoDisposeNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/content_block/domain/providers.dart b/app/lib/features/content_block/domain/providers.dart new file mode 100644 index 00000000..4edbb5df --- /dev/null +++ b/app/lib/features/content_block/domain/providers.dart @@ -0,0 +1,24 @@ +import 'package:bang_navigator/features/content_block/data/models/host.dart'; +import 'package:bang_navigator/features/content_block/domain/repositories/host.dart'; +import 'package:bang_navigator/features/content_block/domain/repositories/sync.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'providers.g.dart'; + +@Riverpod() +Stream lastSyncOfSource( + LastSyncOfSourceRef ref, + HostSource source, +) { + final repository = ref.watch(hostSyncRepositoryProvider.notifier); + return repository.watchLastSyncOfSource(source); +} + +@Riverpod() +Stream hostCountOfSource( + HostCountOfSourceRef ref, + HostSource source, +) { + final repository = ref.watch(hostRepositoryProvider.notifier); + return repository.watchHostCount(source); +} diff --git a/app/lib/features/content_block/domain/providers.g.dart b/app/lib/features/content_block/domain/providers.g.dart new file mode 100644 index 00000000..2a7a3816 --- /dev/null +++ b/app/lib/features/content_block/domain/providers.g.dart @@ -0,0 +1,287 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'providers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$lastSyncOfSourceHash() => r'0f96dc6c5ffc44654734bd4be187fd0c4d34a310'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [lastSyncOfSource]. +@ProviderFor(lastSyncOfSource) +const lastSyncOfSourceProvider = LastSyncOfSourceFamily(); + +/// See also [lastSyncOfSource]. +class LastSyncOfSourceFamily extends Family> { + /// See also [lastSyncOfSource]. + const LastSyncOfSourceFamily(); + + /// See also [lastSyncOfSource]. + LastSyncOfSourceProvider call( + HostSource source, + ) { + return LastSyncOfSourceProvider( + source, + ); + } + + @override + LastSyncOfSourceProvider getProviderOverride( + covariant LastSyncOfSourceProvider provider, + ) { + return call( + provider.source, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'lastSyncOfSourceProvider'; +} + +/// See also [lastSyncOfSource]. +class LastSyncOfSourceProvider extends AutoDisposeStreamProvider { + /// See also [lastSyncOfSource]. + LastSyncOfSourceProvider( + HostSource source, + ) : this._internal( + (ref) => lastSyncOfSource( + ref as LastSyncOfSourceRef, + source, + ), + from: lastSyncOfSourceProvider, + name: r'lastSyncOfSourceProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$lastSyncOfSourceHash, + dependencies: LastSyncOfSourceFamily._dependencies, + allTransitiveDependencies: + LastSyncOfSourceFamily._allTransitiveDependencies, + source: source, + ); + + LastSyncOfSourceProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.source, + }) : super.internal(); + + final HostSource source; + + @override + Override overrideWith( + Stream Function(LastSyncOfSourceRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: LastSyncOfSourceProvider._internal( + (ref) => create(ref as LastSyncOfSourceRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + source: source, + ), + ); + } + + @override + AutoDisposeStreamProviderElement createElement() { + return _LastSyncOfSourceProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is LastSyncOfSourceProvider && other.source == source; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, source.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin LastSyncOfSourceRef on AutoDisposeStreamProviderRef { + /// The parameter `source` of this provider. + HostSource get source; +} + +class _LastSyncOfSourceProviderElement + extends AutoDisposeStreamProviderElement + with LastSyncOfSourceRef { + _LastSyncOfSourceProviderElement(super.provider); + + @override + HostSource get source => (origin as LastSyncOfSourceProvider).source; +} + +String _$hostCountOfSourceHash() => r'3bd118037e5762fd5134838ac12ed6d0d2283e76'; + +/// See also [hostCountOfSource]. +@ProviderFor(hostCountOfSource) +const hostCountOfSourceProvider = HostCountOfSourceFamily(); + +/// See also [hostCountOfSource]. +class HostCountOfSourceFamily extends Family> { + /// See also [hostCountOfSource]. + const HostCountOfSourceFamily(); + + /// See also [hostCountOfSource]. + HostCountOfSourceProvider call( + HostSource source, + ) { + return HostCountOfSourceProvider( + source, + ); + } + + @override + HostCountOfSourceProvider getProviderOverride( + covariant HostCountOfSourceProvider provider, + ) { + return call( + provider.source, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'hostCountOfSourceProvider'; +} + +/// See also [hostCountOfSource]. +class HostCountOfSourceProvider extends AutoDisposeStreamProvider { + /// See also [hostCountOfSource]. + HostCountOfSourceProvider( + HostSource source, + ) : this._internal( + (ref) => hostCountOfSource( + ref as HostCountOfSourceRef, + source, + ), + from: hostCountOfSourceProvider, + name: r'hostCountOfSourceProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$hostCountOfSourceHash, + dependencies: HostCountOfSourceFamily._dependencies, + allTransitiveDependencies: + HostCountOfSourceFamily._allTransitiveDependencies, + source: source, + ); + + HostCountOfSourceProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.source, + }) : super.internal(); + + final HostSource source; + + @override + Override overrideWith( + Stream Function(HostCountOfSourceRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: HostCountOfSourceProvider._internal( + (ref) => create(ref as HostCountOfSourceRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + source: source, + ), + ); + } + + @override + AutoDisposeStreamProviderElement createElement() { + return _HostCountOfSourceProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is HostCountOfSourceProvider && other.source == source; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, source.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin HostCountOfSourceRef on AutoDisposeStreamProviderRef { + /// The parameter `source` of this provider. + HostSource get source; +} + +class _HostCountOfSourceProviderElement + extends AutoDisposeStreamProviderElement with HostCountOfSourceRef { + _HostCountOfSourceProviderElement(super.provider); + + @override + HostSource get source => (origin as HostCountOfSourceProvider).source; +} +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/content_block/domain/repositories/host.dart b/app/lib/features/content_block/domain/repositories/host.dart new file mode 100644 index 00000000..a0ec6717 --- /dev/null +++ b/app/lib/features/content_block/domain/repositories/host.dart @@ -0,0 +1,29 @@ +import 'package:bang_navigator/features/content_block/data/database/database.dart'; +import 'package:bang_navigator/features/content_block/data/models/host.dart'; +import 'package:bang_navigator/features/content_block/data/providers.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'host.g.dart'; + +@Riverpod(keepAlive: true) +class HostRepository extends _$HostRepository { + late HostDatabase _db; + + @override + void build() { + _db = ref.watch(hostDatabaseProvider); + } + + Stream> watchHosts({Iterable? sources}) { + return _db.hostDao + .getHostList(sources: sources) + .map( + (hostData) => hostData.hostname, + ) + .watch(); + } + + Stream watchHostCount(HostSource source) { + return _db.hostDao.getHostCount(sources: [source]).watchSingle(); + } +} diff --git a/app/lib/features/content_block/domain/repositories/host.g.dart b/app/lib/features/content_block/domain/repositories/host.g.dart new file mode 100644 index 00000000..f0acb812 --- /dev/null +++ b/app/lib/features/content_block/domain/repositories/host.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'host.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$hostRepositoryHash() => r'b28362146b75391f55fb353fc6fde72dc1d2fcb9'; + +/// See also [HostRepository]. +@ProviderFor(HostRepository) +final hostRepositoryProvider = NotifierProvider.internal( + HostRepository.new, + name: r'hostRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$hostRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$HostRepository = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/content_block/domain/repositories/sync.dart b/app/lib/features/content_block/domain/repositories/sync.dart new file mode 100644 index 00000000..3925cbc0 --- /dev/null +++ b/app/lib/features/content_block/domain/repositories/sync.dart @@ -0,0 +1,105 @@ +import 'package:bang_navigator/features/content_block/data/database/database.dart'; +import 'package:bang_navigator/features/content_block/data/models/host.dart'; +import 'package:bang_navigator/features/content_block/data/providers.dart'; +import 'package:bang_navigator/features/content_block/data/services/source.dart'; +import 'package:drift/isolate.dart'; +import 'package:exceptions/exceptions.dart'; +import 'package:riverpod/riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'sync.g.dart'; + +@Riverpod(keepAlive: true) +class HostSyncRepository extends _$HostSyncRepository { + late HostDatabase _db; + + HostSyncRepository(); + + static Future> _fetchAndSync({ + required HostSourceService sourceService, + required HostDatabase db, + required Uri url, + required HostSource source, + required Duration? syncInterval, + }) async { + if (syncInterval != null) { + final lastSync = + await db.syncDao.lastSyncOfSource(source).getSingleOrNull(); + + if (lastSync != null && + lastSync.difference(DateTime.now()) < syncInterval) { + return Result.success(null); + } + } + + final result = await sourceService.getHosts(url); + return result.flatMapAsync( + (remoteHosts) async { + await db.syncDao.syncHosts( + source: source, + remoteHosts: remoteHosts, + syncTime: DateTime.now(), + ); + }, + ); + } + + Future> syncHostSource( + HostSource source, + Duration? syncInterval, + ) async { + try { + return Result.success( + await _db.computeWithDatabase( + connect: HostDatabase.new, + computation: (db) async { + final ref = ProviderContainer(); + final result = await _fetchAndSync( + sourceService: ref.read(hostSourceServiceProvider.notifier), + db: db, + url: Uri.parse(source.url), + source: source, + syncInterval: syncInterval, + ); + + //Throw if necessary + return result.value; + }, + ), + ); + } catch (e) { + return Result.failure( + ErrorMessage( + message: "Failed to sync Hosts (${source.name})", + source: 'HostSync', + details: e, + ), + ); + } + } + + Stream watchLastSyncOfSource(HostSource source) { + return _db.syncDao.lastSyncOfSource(source).watchSingleOrNull(); + } + + Future>> syncHostSources({ + Set? sources, + Duration? syncInterval, + }) async { + //Default to all sources + sources ??= HostSource.values.toSet(); + + //Run isolated operations + final futures = sources.map( + (source) => syncHostSource(source, syncInterval) + .then((result) => MapEntry(source, result)), + ); + + return Map.fromEntries(await Future.wait(futures)); + } + + @override + void build() { + _db = ref.watch(hostDatabaseProvider); + } +} diff --git a/app/lib/features/content_block/domain/repositories/sync.g.dart b/app/lib/features/content_block/domain/repositories/sync.g.dart new file mode 100644 index 00000000..9abc4e0e --- /dev/null +++ b/app/lib/features/content_block/domain/repositories/sync.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sync.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$hostSyncRepositoryHash() => + r'22b89d3687c415441f99caa4d77dabaeb4969534'; + +/// See also [HostSyncRepository]. +@ProviderFor(HostSyncRepository) +final hostSyncRepositoryProvider = + NotifierProvider.internal( + HostSyncRepository.new, + name: r'hostSyncRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$hostSyncRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$HostSyncRepository = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member