Nikolas Rimikis
1 year ago
committed by
GitHub
19 changed files with 568 additions and 199 deletions
@ -0,0 +1,40 @@
|
||||
import 'dart:async'; |
||||
|
||||
import 'package:collection/collection.dart'; |
||||
import 'package:neon/src/settings/models/option.dart'; |
||||
|
||||
/// Exportable data interface. |
||||
abstract interface class Exportable { |
||||
/// Exports into a json map entry. |
||||
MapEntry<String, Object?> export(); |
||||
|
||||
/// Imports [data] from an export. |
||||
FutureOr<void> import(final Map<String, Object?> data); |
||||
} |
||||
|
||||
/// Serialization helpers for a collection of [Option]s. |
||||
extension SerializeOptions on Iterable<Option<dynamic>> { |
||||
/// Serializes into an [Iterable<JsonEntry>]. |
||||
/// |
||||
/// Use [Map.fromEntries] to get a json Map. |
||||
Iterable<MapEntry<String, Object?>> serialize() sync* { |
||||
for (final option in this) { |
||||
if (option.enabled) { |
||||
yield MapEntry(option.key.value, option.serialize()); |
||||
} |
||||
} |
||||
} |
||||
|
||||
/// Deserializes [data] and updates the [Option]s. |
||||
void deserialize(final Map<String, Object?> data) { |
||||
for (final entry in data.entries) { |
||||
final option = firstWhereOrNull((final option) => option.key.value == entry.key); |
||||
|
||||
if (entry.value != null) { |
||||
option?.load(entry.value); |
||||
} else { |
||||
option?.reset(); |
||||
} |
||||
} |
||||
} |
||||
} |
@ -1,23 +0,0 @@
|
||||
import 'package:neon/src/settings/models/option.dart'; |
||||
import 'package:neon/src/settings/models/options_category.dart'; |
||||
import 'package:neon/src/settings/models/storage.dart'; |
||||
|
||||
abstract class NextcloudAppOptions { |
||||
NextcloudAppOptions(this.storage); |
||||
|
||||
final AppStorage storage; |
||||
late final List<OptionsCategory> categories; |
||||
late final List<Option> options; |
||||
|
||||
void reset() { |
||||
for (final option in options) { |
||||
option.reset(); |
||||
} |
||||
} |
||||
|
||||
void dispose() { |
||||
for (final option in options) { |
||||
option.dispose(); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,65 @@
|
||||
import 'package:meta/meta.dart'; |
||||
import 'package:neon/src/settings/models/exportable.dart'; |
||||
import 'package:neon/src/settings/models/option.dart'; |
||||
import 'package:neon/src/settings/models/options_category.dart'; |
||||
import 'package:neon/src/settings/models/storage.dart'; |
||||
|
||||
/// Collection of [Option]s. |
||||
abstract class OptionsCollection implements Exportable { |
||||
OptionsCollection(this.storage); |
||||
|
||||
/// Storage backend to use. |
||||
@protected |
||||
final AppStorage storage; |
||||
|
||||
/// Collection of options. |
||||
@protected |
||||
Iterable<Option> get options; |
||||
|
||||
/// Resets all [options]. |
||||
/// |
||||
/// Implementers extending this must call super. |
||||
@mustCallSuper |
||||
void reset() { |
||||
for (final option in options) { |
||||
option.reset(); |
||||
} |
||||
} |
||||
|
||||
/// Disposes all [options]. |
||||
/// |
||||
/// Implementers extending this must call super. |
||||
@mustCallSuper |
||||
void dispose() { |
||||
for (final option in options) { |
||||
option.dispose(); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
MapEntry<String, Object?> export() { |
||||
final data = Map.fromEntries(options.serialize()); |
||||
|
||||
return MapEntry(storage.id, data); |
||||
} |
||||
|
||||
@override |
||||
void import(final Map<String, Object?> data) { |
||||
final values = data[storage.id] as Map<String, Object?>?; |
||||
|
||||
if (values != null) { |
||||
options.deserialize(values); |
||||
} |
||||
} |
||||
} |
||||
|
||||
/// OpptionsCollection for a neon app. |
||||
abstract class NextcloudAppOptions extends OptionsCollection { |
||||
NextcloudAppOptions(super.storage); |
||||
|
||||
/// Collection of categories to display the options in the settings. |
||||
late final Iterable<OptionsCategory> categories; |
||||
|
||||
@override |
||||
late final Iterable<Option> options; |
||||
} |
@ -0,0 +1,147 @@
|
||||
import 'dart:async'; |
||||
import 'dart:convert'; |
||||
import 'dart:typed_data'; |
||||
|
||||
import 'package:meta/meta.dart'; |
||||
import 'package:neon/src/blocs/accounts.dart'; |
||||
import 'package:neon/src/models/account.dart' show Account, AccountFind; |
||||
import 'package:neon/src/models/app_implementation.dart'; |
||||
import 'package:neon/src/settings/models/exportable.dart'; |
||||
import 'package:neon/src/settings/models/option.dart'; |
||||
import 'package:neon/src/settings/models/storage.dart'; |
||||
|
||||
/// Helper class to export all [Option]s. |
||||
/// |
||||
/// Json based operations: |
||||
/// * [exportToJson] |
||||
/// * [applyFromJson] |
||||
/// |
||||
/// [Uint8List] based operations: |
||||
/// * [exportToFile] |
||||
/// * [applyFromFile] |
||||
@internal |
||||
@immutable |
||||
class SettingsExportHelper { |
||||
const SettingsExportHelper({ |
||||
required this.exportables, |
||||
}); |
||||
|
||||
/// Collections of elements to export. |
||||
final Set<Exportable> exportables; |
||||
|
||||
/// Imports [file] and applies the stored [Option]s. |
||||
/// |
||||
/// See: |
||||
/// * [applyFromJson] to import a json map. |
||||
Future<void> applyFromFile(final Stream<List<int>>? file) async { |
||||
final transformer = const Utf8Decoder().fuse(const JsonDecoder()); |
||||
|
||||
final data = await file?.transform(transformer).single; |
||||
|
||||
if (data == null) { |
||||
return; |
||||
} |
||||
|
||||
await applyFromJson(data as Map<String, Object?>); |
||||
} |
||||
|
||||
/// Imports the [json] data and applies the stored [Option]s. |
||||
/// |
||||
/// See: |
||||
/// * [applyFromFile] to import data from a [Stream<Uint8List>]. |
||||
Future<void> applyFromJson(final Map<String, Object?> json) async { |
||||
for (final exportable in exportables) { |
||||
await exportable.import(json); |
||||
} |
||||
} |
||||
|
||||
/// Exports the stored [Option]s into a [Uint8List]. |
||||
/// |
||||
/// See: |
||||
/// * [exportToJson] to export to a json map. |
||||
Uint8List exportToFile() { |
||||
final transformer = JsonUtf8Encoder(); |
||||
|
||||
final json = exportToJson(); |
||||
return transformer.convert(json) as Uint8List; |
||||
} |
||||
|
||||
/// Exports the stored [Option]s into a json map. |
||||
/// |
||||
/// See: |
||||
/// * [exportToFile] to export data to a [Uint8List]. |
||||
Map<String, Object?> exportToJson() => Map.fromEntries(exportables.map((final e) => e.export())); |
||||
} |
||||
|
||||
/// Helper class to export [AppImplementation]s implementing the [Exportable] interface. |
||||
@immutable |
||||
class AppImplementationsExporter implements Exportable { |
||||
const AppImplementationsExporter(this.appImplementations); |
||||
|
||||
/// List of apps to export. |
||||
final Iterable<AppImplementation> appImplementations; |
||||
|
||||
/// Key the exported value will be stored at. |
||||
static final _key = StorageKeys.apps.value; |
||||
|
||||
@override |
||||
MapEntry<String, Object?> export() => MapEntry( |
||||
_key, |
||||
Map.fromEntries(appImplementations.map((final app) => app.options.export())), |
||||
); |
||||
|
||||
@override |
||||
void import(final Map<String, Object?> data) { |
||||
final values = data[_key] as Map<String, Object?>?; |
||||
|
||||
if (values == null) { |
||||
return; |
||||
} |
||||
|
||||
for (final element in values.entries) { |
||||
final app = appImplementations.tryFind(element.key); |
||||
|
||||
if (app != null) { |
||||
app.options.import(values); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
/// Helper class to export [Account]s implementing the [Exportable] interface. |
||||
@immutable |
||||
class AccountsBlocExporter implements Exportable { |
||||
const AccountsBlocExporter(this.accountsBloc); |
||||
|
||||
/// AccountsBloc containing the accounts to export. |
||||
final AccountsBloc accountsBloc; |
||||
|
||||
/// Key the exported value will be stored at. |
||||
static final _key = StorageKeys.accounts.value; |
||||
|
||||
@override |
||||
MapEntry<String, Object?> export() => MapEntry(_key, Map.fromEntries(_serialize())); |
||||
|
||||
Iterable<MapEntry<String, Object?>> _serialize() sync* { |
||||
for (final account in accountsBloc.accounts.value) { |
||||
yield accountsBloc.getOptionsFor(account).export(); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
void import(final Map<String, Object?> data) { |
||||
final values = data[_key] as Map<String, Object?>?; |
||||
|
||||
if (values == null) { |
||||
return; |
||||
} |
||||
|
||||
for (final element in values.entries) { |
||||
final account = accountsBloc.accounts.value.tryFind(element.key); |
||||
|
||||
if (account != null) { |
||||
accountsBloc.getOptionsFor(account).import(values); |
||||
} |
||||
} |
||||
} |
||||
} |
@ -1,98 +0,0 @@
|
||||
import 'package:meta/meta.dart'; |
||||
import 'package:neon/src/models/account.dart'; |
||||
import 'package:neon/src/models/app_implementation.dart'; |
||||
import 'package:neon/src/settings/models/option.dart'; |
||||
import 'package:neon/src/utils/global_options.dart'; |
||||
|
||||
@internal |
||||
class SettingsExportHelper { |
||||
SettingsExportHelper({ |
||||
required this.globalOptions, |
||||
required this.appImplementations, |
||||
required this.accountSpecificOptions, |
||||
}); |
||||
|
||||
final GlobalOptions globalOptions; |
||||
final Iterable<AppImplementation> appImplementations; |
||||
final Map<Account, List<Option>> accountSpecificOptions; |
||||
|
||||
Future applyFromJson(final Map<String, dynamic> data) async { |
||||
final globalOptionsData = data['global'] as Map<String, dynamic>; |
||||
await _applyOptionsMapToOptions( |
||||
globalOptions.options, |
||||
globalOptionsData, |
||||
); |
||||
|
||||
final appImplementationsData = data['apps'] as Map<String, dynamic>; |
||||
for (final appId in appImplementationsData.keys) { |
||||
final app = appImplementations.tryFind(appId); |
||||
if (app == null) { |
||||
return; |
||||
} |
||||
final appImplementationData = appImplementationsData[appId]! as Map<String, dynamic>; |
||||
await _applyOptionsMapToOptions( |
||||
app.options.options, |
||||
appImplementationData, |
||||
); |
||||
} |
||||
|
||||
final accountsData = data['accounts'] as Map<String, dynamic>; |
||||
for (final accountId in accountsData.keys) { |
||||
final account = accountSpecificOptions.keys.tryFind(accountId); |
||||
if (account == null) { |
||||
return; |
||||
} |
||||
final accountData = accountsData[accountId]! as Map<String, dynamic>; |
||||
await _applyOptionsMapToOptions( |
||||
accountSpecificOptions[account]!, |
||||
accountData, |
||||
); |
||||
} |
||||
} |
||||
|
||||
Future _applyOptionsMapToOptions(final List<Option> options, final Map<String, dynamic> data) async { |
||||
for (final optionKey in data.keys) { |
||||
for (final option in options) { |
||||
if (option.key.value == optionKey) { |
||||
final Object? value = data[optionKey]; |
||||
|
||||
if (value != null) { |
||||
option.value = await option.deserialize(value); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
Map<String, dynamic> toJsonExport() => { |
||||
'global': { |
||||
for (final option in globalOptions.options) ...{ |
||||
if (option.enabled) ...{ |
||||
option.key: option.serialize(), |
||||
}, |
||||
}, |
||||
}, |
||||
'apps': { |
||||
for (final appImplementation in appImplementations) ...{ |
||||
appImplementation.id: { |
||||
for (final option in appImplementation.options.options) ...{ |
||||
if (option.enabled) ...{ |
||||
option.key: option.serialize(), |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
'accounts': { |
||||
for (final account in accountSpecificOptions.keys) ...{ |
||||
account.id: { |
||||
for (final option in accountSpecificOptions[account]!) ...{ |
||||
if (option.enabled) ...{ |
||||
option.key: option.serialize(), |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}; |
||||
} |
@ -0,0 +1,84 @@
|
||||
import 'package:mocktail/mocktail.dart'; |
||||
import 'package:neon/settings.dart'; |
||||
import 'package:neon/src/settings/models/option.dart'; |
||||
import 'package:neon/src/settings/models/storage.dart'; |
||||
import 'package:test/test.dart'; |
||||
|
||||
// ignore: missing_override_of_must_be_overridden |
||||
class OptionMock extends Mock implements Option {} |
||||
|
||||
class Collection extends NextcloudAppOptions { |
||||
Collection(final List<Option> options) : super(const AppStorage(StorageKeys.apps)) { |
||||
super.options = options; |
||||
} |
||||
} |
||||
|
||||
enum Keys implements Storable { |
||||
key1._('key1'), |
||||
key2._('key2'); |
||||
|
||||
const Keys._(this.value); |
||||
|
||||
@override |
||||
final String value; |
||||
} |
||||
|
||||
void main() { |
||||
group('OptionsCollection', () { |
||||
final option1 = OptionMock(); |
||||
final option2 = OptionMock(); |
||||
final collection = Collection([ |
||||
option1, |
||||
option2, |
||||
]); |
||||
|
||||
test('reset', () { |
||||
collection.reset(); |
||||
|
||||
verify(option1.reset).called(1); |
||||
verify(option2.reset).called(1); |
||||
}); |
||||
|
||||
test('dispose', () { |
||||
collection.dispose(); |
||||
|
||||
verify(option1.dispose).called(1); |
||||
verify(option2.dispose).called(1); |
||||
}); |
||||
|
||||
test('export', () { |
||||
when(() => option1.key).thenReturn(Keys.key1); |
||||
when(option1.serialize).thenReturn('value1'); |
||||
when(() => option1.enabled).thenReturn(true); |
||||
|
||||
when(() => option2.key).thenReturn(Keys.key2); |
||||
when(option2.serialize).thenReturn('value2'); |
||||
when(() => option2.enabled).thenReturn(false); |
||||
|
||||
const json = { |
||||
'app': {'key1': 'value1'}, |
||||
}; |
||||
|
||||
final export = collection.export(); |
||||
|
||||
expect(Map.fromEntries([export]), equals(json)); |
||||
}); |
||||
|
||||
test('import', () { |
||||
when(() => option1.key).thenReturn(Keys.key1); |
||||
when(() => option2.key).thenReturn(Keys.key2); |
||||
|
||||
const json = { |
||||
'app': { |
||||
'key1': 'value1', |
||||
'key2': null, |
||||
}, |
||||
}; |
||||
|
||||
collection.import(json); |
||||
|
||||
verify(() => option1.load('value1')).called(1); |
||||
verify(option2.reset).called(1); |
||||
}); |
||||
}); |
||||
} |
@ -0,0 +1,130 @@
|
||||
// ignore_for_file: avoid_implementing_value_types |
||||
|
||||
import 'dart:convert'; |
||||
import 'dart:typed_data'; |
||||
|
||||
import 'package:mocktail/mocktail.dart'; |
||||
import 'package:neon/blocs.dart'; |
||||
import 'package:neon/src/models/account.dart'; |
||||
import 'package:neon/src/models/app_implementation.dart'; |
||||
import 'package:neon/src/settings/models/exportable.dart'; |
||||
import 'package:neon/src/settings/models/options_collection.dart'; |
||||
import 'package:neon/src/settings/utils/settings_export_helper.dart'; |
||||
import 'package:neon/src/utils/account_options.dart'; |
||||
import 'package:rxdart/rxdart.dart'; |
||||
import 'package:test/test.dart'; |
||||
|
||||
// ignore: missing_override_of_must_be_overridden |
||||
class FakeAppImplementation extends Mock implements AppImplementation {} |
||||
|
||||
class NextcloudAppOptionsMock extends Mock implements NextcloudAppOptions {} |
||||
|
||||
class AccountsBlocMock extends Mock implements AccountsBloc {} |
||||
|
||||
class FakeAccount extends Mock implements Account {} |
||||
|
||||
class AccountSpecificOptionsMock extends Mock implements AccountSpecificOptions {} |
||||
|
||||
class ExporterMock extends Mock implements Exportable {} |
||||
|
||||
void main() { |
||||
group('Exporter', () { |
||||
test('AccountsBlocExporter', () { |
||||
var exporter = const AppImplementationsExporter([]); |
||||
|
||||
var export = exporter.export(); |
||||
expect(Map.fromEntries([export]), {'app': {}}); |
||||
|
||||
final fakeApp = FakeAppImplementation(); |
||||
final fakeOptions = NextcloudAppOptionsMock(); |
||||
exporter = AppImplementationsExporter([fakeApp]); |
||||
|
||||
const appValue = MapEntry('appID', 'value'); |
||||
const appExport = { |
||||
'app': {'appID': 'value'}, |
||||
}; |
||||
|
||||
when(() => fakeApp.options).thenReturn(fakeOptions); |
||||
when(fakeOptions.export).thenReturn(appValue); |
||||
when(() => fakeApp.id).thenReturn('appID'); |
||||
|
||||
export = exporter.export(); |
||||
expect(Map.fromEntries([export]), appExport); |
||||
|
||||
exporter.import(Map.fromEntries([export])); |
||||
verify(() => fakeOptions.import(Map.fromEntries([appValue]))).called(1); |
||||
}); |
||||
|
||||
test('AccountsBlocExporter', () { |
||||
final bloc = AccountsBlocMock(); |
||||
final exporter = AccountsBlocExporter(bloc); |
||||
|
||||
const accountValue = MapEntry('accountID', 'value'); |
||||
const accountExport = { |
||||
'accounts': {'accountID': 'value'}, |
||||
}; |
||||
|
||||
when(() => bloc.accounts).thenAnswer((final _) => BehaviorSubject.seeded([])); |
||||
var export = exporter.export(); |
||||
expect(Map.fromEntries([export]), {'accounts': {}}); |
||||
|
||||
final fakeAccount = FakeAccount(); |
||||
final fakeOptions = AccountSpecificOptionsMock(); |
||||
when(() => bloc.accounts).thenAnswer((final _) => BehaviorSubject.seeded([fakeAccount])); |
||||
when(() => bloc.getOptionsFor(fakeAccount)).thenReturn(fakeOptions); |
||||
when(fakeOptions.export).thenReturn(accountValue); |
||||
when(() => fakeAccount.id).thenReturn('accountID'); |
||||
|
||||
export = exporter.export(); |
||||
expect(Map.fromEntries([export]), accountExport); |
||||
|
||||
exporter.import(Map.fromEntries([export])); |
||||
verify(() => fakeOptions.import(Map.fromEntries([accountValue]))).called(1); |
||||
}); |
||||
}); |
||||
|
||||
group('SettingsExportHelper', () { |
||||
test('SettingsExportHelper.json', () async { |
||||
final exportable = ExporterMock(); |
||||
final settingsExporter = SettingsExportHelper( |
||||
exportables: { |
||||
exportable, |
||||
}, |
||||
); |
||||
|
||||
const value = MapEntry('sxportableKey', 'value'); |
||||
const export = {'sxportableKey': 'value'}; |
||||
|
||||
when(exportable.export).thenAnswer((final _) => value); |
||||
|
||||
expect(settingsExporter.exportToJson(), equals(export)); |
||||
|
||||
await settingsExporter.applyFromJson(export); |
||||
verify(() => exportable.import(Map.fromEntries([value]))).called(1); |
||||
}); |
||||
|
||||
test('SettingsExportHelper.file', () async { |
||||
final exportable = ExporterMock(); |
||||
final settingsExporter = SettingsExportHelper( |
||||
exportables: { |
||||
exportable, |
||||
}, |
||||
); |
||||
|
||||
const value = MapEntry('sxportableKey', 'value'); |
||||
const jsonExport = {'sxportableKey': 'value'}; |
||||
final export = JsonUtf8Encoder().convert(jsonExport) as Uint8List; |
||||
|
||||
when(exportable.export).thenAnswer((final _) => value); |
||||
|
||||
expect(settingsExporter.exportToFile(), equals(export)); |
||||
|
||||
await settingsExporter.applyFromFile(_streamValue(export)); |
||||
verify(() => exportable.import(Map.fromEntries([value]))).called(1); |
||||
}); |
||||
}); |
||||
} |
||||
|
||||
Stream<T> _streamValue<T>(final T value) async* { |
||||
yield value; |
||||
} |
Loading…
Reference in new issue