diff --git a/packages/neon/neon_dashboard/test/widget_test.dart b/packages/neon/neon_dashboard/test/widget_test.dart index dbb949c0301..6863147e4a7 100644 --- a/packages/neon/neon_dashboard/test/widget_test.dart +++ b/packages/neon/neon_dashboard/test/widget_test.dart @@ -40,6 +40,11 @@ void main() { ), ); + setUp(() { + final storage = MockNeonStorage(); + when(() => storage.requestCache).thenReturn(null); + }); + group('Widget item', () { final item = dashboard.WidgetItem( (b) => b diff --git a/packages/neon/neon_files/lib/src/options.dart b/packages/neon/neon_files/lib/src/options.dart index b9135e9ec22..7ff34fafcc9 100644 --- a/packages/neon/neon_files/lib/src/options.dart +++ b/packages/neon/neon_files/lib/src/options.dart @@ -2,6 +2,7 @@ import 'package:filesize/filesize.dart'; import 'package:neon_files/l10n/localizations.dart'; import 'package:neon_framework/settings.dart'; import 'package:neon_framework/sort_box.dart'; +import 'package:neon_framework/storage.dart'; class FilesOptions extends AppImplementationOptions { FilesOptions(super.storage) { diff --git a/packages/neon/neon_news/lib/src/options.dart b/packages/neon/neon_news/lib/src/options.dart index 2b7d7c10f5d..7702f147635 100644 --- a/packages/neon/neon_news/lib/src/options.dart +++ b/packages/neon/neon_news/lib/src/options.dart @@ -1,6 +1,7 @@ import 'package:neon_framework/platform.dart'; import 'package:neon_framework/settings.dart'; import 'package:neon_framework/sort_box.dart'; +import 'package:neon_framework/storage.dart'; import 'package:neon_news/l10n/localizations.dart'; import 'package:neon_news/src/blocs/articles.dart'; diff --git a/packages/neon/neon_notes/lib/src/options.dart b/packages/neon/neon_notes/lib/src/options.dart index 221f7fcc726..652fa3b2885 100644 --- a/packages/neon/neon_notes/lib/src/options.dart +++ b/packages/neon/neon_notes/lib/src/options.dart @@ -1,5 +1,6 @@ import 'package:neon_framework/settings.dart'; import 'package:neon_framework/sort_box.dart'; +import 'package:neon_framework/storage.dart'; import 'package:neon_notes/l10n/localizations.dart'; class NotesOptions extends AppImplementationOptions { diff --git a/packages/neon_framework/lib/neon.dart b/packages/neon_framework/lib/neon.dart index 317a870db84..bc211a2c36d 100644 --- a/packages/neon_framework/lib/neon.dart +++ b/packages/neon_framework/lib/neon.dart @@ -11,12 +11,11 @@ import 'package:neon_framework/src/models/account.dart'; import 'package:neon_framework/src/models/app_implementation.dart'; import 'package:neon_framework/src/models/disposable.dart'; import 'package:neon_framework/src/platform/platform.dart'; -import 'package:neon_framework/src/settings/models/storage.dart'; import 'package:neon_framework/src/theme/neon.dart'; import 'package:neon_framework/src/utils/global_options.dart'; import 'package:neon_framework/src/utils/provider.dart'; -import 'package:neon_framework/src/utils/request_manager.dart'; import 'package:neon_framework/src/utils/user_agent.dart'; +import 'package:neon_framework/storage.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:provider/provider.dart'; @@ -35,8 +34,7 @@ Future runNeon({ FlutterNativeSplash.preserve(widgetsBinding: binding); await NeonPlatform.setup(); - await RequestManager.instance.initCache(); - await NeonStorage.init(); + await NeonStorage().init(); final packageInfo = await PackageInfo.fromPlatform(); buildUserAgent(packageInfo); diff --git a/packages/neon_framework/lib/settings.dart b/packages/neon_framework/lib/settings.dart index 82e21908275..609563f07be 100644 --- a/packages/neon_framework/lib/settings.dart +++ b/packages/neon_framework/lib/settings.dart @@ -2,5 +2,4 @@ export 'package:neon_framework/src/models/label_builder.dart'; export 'package:neon_framework/src/settings/models/option.dart'; export 'package:neon_framework/src/settings/models/options_category.dart'; export 'package:neon_framework/src/settings/models/options_collection.dart'; -export 'package:neon_framework/src/settings/models/storage.dart' show Storable; export 'package:neon_framework/src/settings/widgets/settings_list.dart'; diff --git a/packages/neon_framework/lib/src/blocs/accounts.dart b/packages/neon_framework/lib/src/blocs/accounts.dart index 2fafde5ddad..e4ea27c9928 100644 --- a/packages/neon_framework/lib/src/blocs/accounts.dart +++ b/packages/neon_framework/lib/src/blocs/accounts.dart @@ -15,15 +15,14 @@ import 'package:neon_framework/src/models/account.dart'; import 'package:neon_framework/src/models/account_cache.dart'; import 'package:neon_framework/src/models/app_implementation.dart'; import 'package:neon_framework/src/models/disposable.dart'; -import 'package:neon_framework/src/settings/models/storage.dart'; +import 'package:neon_framework/src/storage/keys.dart'; import 'package:neon_framework/src/utils/account_options.dart'; import 'package:neon_framework/src/utils/findable.dart'; import 'package:neon_framework/src/utils/global_options.dart'; +import 'package:neon_framework/storage.dart'; import 'package:nextcloud/core.dart' as core; import 'package:rxdart/rxdart.dart'; -const _keyAccounts = 'accounts'; - /// The Bloc responsible for managing the [Account]s @sealed abstract interface class AccountsBloc implements Disposable { @@ -153,7 +152,7 @@ class _AccountsBloc extends Bloc implements AccountsBloc { this.globalOptions, this.allAppImplementations, ) { - const lastUsedStorage = SingleValueStorage(StorageKeys.lastUsedAccount); + final lastUsedStorage = NeonStorage().singleValueStore(StorageKeys.lastUsedAccount); accounts ..add(loadAccounts()) @@ -313,7 +312,7 @@ class _AccountsBloc extends Bloc implements AccountsBloc { @override AccountOptions getOptionsFor(Account account) => accountsOptions[account] ??= AccountOptions( - AppStorage(StorageKeys.accounts, account.id), + NeonStorage().settingsStore(StorageKeys.accountOptions, account.id), getAppsBlocFor(account), ); @@ -369,21 +368,19 @@ class _AccountsBloc extends Bloc implements AccountsBloc { /// /// It is not checked whether the stored information is still valid. List loadAccounts() { - const storage = AppStorage(StorageKeys.accounts); + final storage = NeonStorage().singleValueStore(StorageKeys.accounts); - if (storage.containsKey(_keyAccounts)) { - return storage - .getStringList(_keyAccounts)! - .map((a) => Account.fromJson(json.decode(a) as Map)) - .toList(); + if (storage.hasValue()) { + return storage.getStringList()!.map((a) => Account.fromJson(json.decode(a) as Map)).toList(); } + return []; } /// Saves the given [accounts] to the storage. Future saveAccounts(List accounts) async { - const storage = AppStorage(StorageKeys.accounts); + final storage = NeonStorage().singleValueStore(StorageKeys.accounts); final values = accounts.map((a) => json.encode(a.toJson())).toList(); - await storage.setStringList(_keyAccounts, values); + await storage.setStringList(values); } diff --git a/packages/neon_framework/lib/src/blocs/first_launch.dart b/packages/neon_framework/lib/src/blocs/first_launch.dart index 23a01a91e24..78270a9d829 100644 --- a/packages/neon_framework/lib/src/blocs/first_launch.dart +++ b/packages/neon_framework/lib/src/blocs/first_launch.dart @@ -3,7 +3,8 @@ import 'dart:async'; import 'package:meta/meta.dart'; import 'package:neon_framework/src/bloc/bloc.dart'; import 'package:neon_framework/src/models/disposable.dart'; -import 'package:neon_framework/src/settings/models/storage.dart'; +import 'package:neon_framework/src/storage/keys.dart'; +import 'package:neon_framework/storage.dart'; import 'package:rxdart/rxdart.dart'; /// Bloc that manages tasks that only need to run at the first launch of the app. @@ -23,7 +24,7 @@ class _FirstLaunchBloc extends Bloc implements FirstLaunchBloc { _FirstLaunchBloc({ bool disabled = false, }) { - const storage = SingleValueStorage(StorageKeys.firstLaunch); + final storage = NeonStorage().singleValueStore(StorageKeys.firstLaunch); if (!disabled && !storage.hasValue()) { onFirstLaunch.add(null); diff --git a/packages/neon_framework/lib/src/blocs/push_notifications.dart b/packages/neon_framework/lib/src/blocs/push_notifications.dart index 100aec50a71..1e3146db4b1 100644 --- a/packages/neon_framework/lib/src/blocs/push_notifications.dart +++ b/packages/neon_framework/lib/src/blocs/push_notifications.dart @@ -7,10 +7,11 @@ import 'package:neon_framework/src/bloc/bloc.dart'; import 'package:neon_framework/src/blocs/accounts.dart'; import 'package:neon_framework/src/models/account.dart'; import 'package:neon_framework/src/platform/platform.dart'; -import 'package:neon_framework/src/settings/models/storage.dart'; +import 'package:neon_framework/src/storage/keys.dart'; import 'package:neon_framework/src/utils/findable.dart'; import 'package:neon_framework/src/utils/global_options.dart'; import 'package:neon_framework/src/utils/push_utils.dart'; +import 'package:neon_framework/storage.dart'; import 'package:nextcloud/notifications.dart' as notifications; import 'package:unifiedpush/unifiedpush.dart'; @@ -42,7 +43,7 @@ class _PushNotificationsBloc extends Bloc implements PushNotificationsBloc { } final AccountsBloc accountsBloc; - late final storage = const AppStorage(StorageKeys.lastEndpoint); + late final storage = NeonStorage().settingsStore(StorageKeys.lastEndpoint); final GlobalOptions globalOptions; StreamSubscription>? accountsListener; diff --git a/packages/neon_framework/lib/src/models/app_implementation.dart b/packages/neon_framework/lib/src/models/app_implementation.dart index 61100a277c0..1b88abc5434 100644 --- a/packages/neon_framework/lib/src/models/app_implementation.dart +++ b/packages/neon_framework/lib/src/models/app_implementation.dart @@ -10,10 +10,12 @@ import 'package:neon_framework/src/models/account.dart'; import 'package:neon_framework/src/models/account_cache.dart'; import 'package:neon_framework/src/models/disposable.dart'; import 'package:neon_framework/src/settings/models/options_collection.dart'; -import 'package:neon_framework/src/settings/models/storage.dart'; +import 'package:neon_framework/src/storage/keys.dart'; + import 'package:neon_framework/src/utils/findable.dart'; import 'package:neon_framework/src/utils/provider.dart'; import 'package:neon_framework/src/widgets/drawer_destination.dart'; +import 'package:neon_framework/storage.dart'; import 'package:nextcloud/core.dart' as core; import 'package:nextcloud/nextcloud.dart' show VersionCheck; import 'package:provider/provider.dart'; @@ -46,9 +48,9 @@ abstract class AppImplementation nameFromLocalization(NeonLocalizations.of(context)); - /// The [SettingsStorage] for this app. + /// The storage bucket for this app. @protected - late final AppStorage storage = AppStorage(StorageKeys.apps, id); + late final SettingsStore storage = NeonStorage().settingsStore(StorageKeys.apps, id); /// The options associated with this app. /// diff --git a/packages/neon_framework/lib/src/settings/models/option.dart b/packages/neon_framework/lib/src/settings/models/option.dart index 7f875a6f433..15183997698 100644 --- a/packages/neon_framework/lib/src/settings/models/option.dart +++ b/packages/neon_framework/lib/src/settings/models/option.dart @@ -6,10 +6,10 @@ import 'package:meta/meta.dart'; import 'package:neon_framework/src/models/disposable.dart'; import 'package:neon_framework/src/models/label_builder.dart'; import 'package:neon_framework/src/settings/models/options_category.dart'; -import 'package:neon_framework/src/settings/models/storage.dart'; +import 'package:neon_framework/storage.dart'; import 'package:rxdart/rxdart.dart'; -/// Listenable option that is persisted in the [SettingsStorage]. +/// Listenable option that is persisted in the [SettingsStore]. /// /// See: /// * [ToggleOption] for an Option @@ -44,7 +44,7 @@ sealed class Option extends ChangeNotifier implements ValueListenable, Dis } /// Storage to persist the state. - final SettingsStorage storage; + final SettingsStore storage; /// Storage key to save the state at. final Storable key; diff --git a/packages/neon_framework/lib/src/settings/models/options_collection.dart b/packages/neon_framework/lib/src/settings/models/options_collection.dart index 57b60b2ff80..89e6ce29576 100644 --- a/packages/neon_framework/lib/src/settings/models/options_collection.dart +++ b/packages/neon_framework/lib/src/settings/models/options_collection.dart @@ -3,7 +3,7 @@ import 'package:neon_framework/src/models/disposable.dart'; import 'package:neon_framework/src/settings/models/exportable.dart'; import 'package:neon_framework/src/settings/models/option.dart'; import 'package:neon_framework/src/settings/models/options_category.dart'; -import 'package:neon_framework/src/settings/models/storage.dart'; +import 'package:neon_framework/storage.dart'; /// Collection of [Option]s. abstract class OptionsCollection implements Exportable, Disposable { @@ -12,7 +12,7 @@ abstract class OptionsCollection implements Exportable, Disposable { /// Storage backend to use. @protected - final AppStorage storage; + final SettingsStore storage; /// Collection of options. @protected diff --git a/packages/neon_framework/lib/src/settings/models/storage.dart b/packages/neon_framework/lib/src/settings/models/storage.dart deleted file mode 100644 index 4829cfae90a..00000000000 --- a/packages/neon_framework/lib/src/settings/models/storage.dart +++ /dev/null @@ -1,261 +0,0 @@ -import 'package:meta/meta.dart'; -import 'package:nextcloud/ids.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -/// Storage interface used by `Option`s. -/// -/// Mimics the interface of [SharedPreferences]. -/// -/// See: -/// * [SingleValueStorage] for a storage that saves a single value. -/// * [AppStorage] for a storage that fully implements the [SharedPreferences] interface. -/// * [NeonStorage] that manages the storage backend. -@internal -abstract interface class SettingsStorage { - /// {@template NeonStorage.getString} - /// Reads a value from persistent storage, throwing an `Exception` if it's not a `String`. - /// {@endtemplate} - String? getString(String key); - - /// {@template NeonStorage.setString} - /// Saves a `String` [value] to persistent storage in the background. - /// - /// Note: Due to limitations in Android's SharedPreferences, - /// values cannot start with any one of the following: - /// - /// - 'VGhpcyBpcyB0aGUgcHJlZml4IGZvciBhIGxpc3Qu' - /// - 'VGhpcyBpcyB0aGUgcHJlZml4IGZvciBCaWdJbnRlZ2Vy' - /// - 'VGhpcyBpcyB0aGUgcHJlZml4IGZvciBEb3VibGUu' - /// {@endtemplate} - Future setString(String key, String value); - - /// {@template NeonStorage.getBool} - /// Reads a value from persistent storage, throwing an `Exception` if it's not a `bool`. - /// {@endtemplate} - bool? getBool(String key); - - /// {@template NeonStorage.setBool} - /// Saves a `bool` [value] to persistent storage in the background. - /// {@endtemplate} - // ignore: avoid_positional_boolean_parameters - // ignore: avoid_positional_boolean_parameters - Future setBool(String key, bool value); - - /// {@template NeonStorage.remove} - /// Removes an entry from persistent storage. - /// {@endtemplate} - Future remove(String key); -} - -/// Interface of a storable element. -/// -/// Usually used in enhanced enums to ensure uniqueness of the storage keys. -abstract interface class Storable { - /// The key of this storage element. - String get value; -} - -/// Unique storage keys. -/// -/// Required by the users of the [NeonStorage] storage backend. -/// -/// See: -/// * [AppStorage] for a storage that fully implements the [SharedPreferences] interface. -/// * [SettingsStorage] for the public interface used in `Option`s. -@internal -enum StorageKeys implements Storable { - /// The key for the `AppImplementation`s. - apps._('app'), - - /// The key for the `Account`s and their `AccountOptions`. - accounts._('accounts'), - - /// The key for the `GlobalOptions`. - global._('global'), - - /// The key for the `AccountsBloc` last used account. - lastUsedAccount._('last-used-account'), - - /// The key used by the `PushNotificationsBloc` to persist the last used endpoint. - lastEndpoint._('last-endpoint'), - - /// The key for the `FirstLaunchBloc`. - firstLaunch._('first-launch'), - - /// The key for the `PushUtils`. - notifications._(AppIDs.notifications); - - const StorageKeys._(this.value); - - @override - final String value; -} - -/// Neon storage that manages the storage backend. -/// -/// [init] must be called and completed before accessing individual storages. -/// -/// See: -/// * [SingleValueStorage] for a storage that saves a single value. -/// * [AppStorage] for a storage that fully implements the [SharedPreferences] interface. -/// * [SettingsStorage] for the public interface used in `Option`s. -@internal -final class NeonStorage { - const NeonStorage._(); - - /// Shared preferences instance. - /// - /// Use [database] to access it. - /// Make sure it has been initialized with [init] before. - static SharedPreferences? _sharedPreferences; - - /// Initializes the database instance with a mocked value. - @visibleForTesting - // ignore: use_setters_to_change_properties - static void mock(SharedPreferences mock) => _sharedPreferences = mock; - - /// Sets up the [SharedPreferences] instance. - /// - /// Required to be called before accessing [database]. - static Future init() async { - if (_sharedPreferences != null) { - return; - } - - _sharedPreferences = await SharedPreferences.getInstance(); - } - - /// Returns the database instance. - /// - /// Throws a `StateError` if [init] has not completed. - @visibleForTesting - static SharedPreferences get database { - if (_sharedPreferences == null) { - throw StateError( - 'NeonStorage has not been initialized yet. Please make sure NeonStorage.init() has been called before and completed.', - ); - } - - return _sharedPreferences!; - } -} - -/// A storage that saves a single value. -/// -/// [NeonStorage.init] must be called and completed before accessing individual values. -/// -/// See: -/// * [NeonStorage] to initialize the storage backend. -/// * [AppStorage] for a storage that fully implements the [SharedPreferences] interface. -/// * [SettingsStorage] for the public interface used in `Option`s. -@immutable -@internal -final class SingleValueStorage { - /// Creates a new storage for a single value. - const SingleValueStorage(this.key); - - /// The key used by the storage backend. - final StorageKeys key; - - /// {@macro NeonStorage.containsKey} - bool hasValue() => NeonStorage.database.containsKey(key.value); - - /// {@macro NeonStorage.remove} - Future remove() => NeonStorage.database.remove(key.value); - - /// {@macro NeonStorage.getString} - String? getString() => NeonStorage.database.getString(key.value); - - /// {@macro NeonStorage.setString} - Future setString(String value) => NeonStorage.database.setString(key.value, value); - - /// {@macro NeonStorage.getBool} - bool? getBool() => NeonStorage.database.getBool(key.value); - - /// {@macro NeonStorage.setBool} - // ignore: avoid_positional_boolean_parameters - Future setBool(bool value) => NeonStorage.database.setBool(key.value, value); - - /// {@macro NeonStorage.getStringList} - List? getStringList() => NeonStorage.database.getStringList(key.value); - - /// {@macro NeonStorage.setStringList} - Future setStringList(List value) => NeonStorage.database.setStringList(key.value, value); -} - -/// A storage that can save a group of values. -/// -/// Implements the interface of [SharedPreferences]. -/// [NeonStorage.init] must be called and completed before accessing individual values. -/// -/// See: -/// * [NeonStorage] to initialize the storage backend. -/// * [SingleValueStorage] for a storage that saves a single value. -/// * [SettingsStorage] for the public interface used in `Option`s. -@immutable -@internal -final class AppStorage implements SettingsStorage { - /// Creates a new app storage. - const AppStorage( - this.groupKey, [ - this.suffix, - ]); - - /// The group key for this app storage. - /// - /// Keys are formatted with [formatKey] - final StorageKeys groupKey; - - /// The optional suffix of the storage key. - /// - /// Used to differentiate between multiple AppStorages with the same [groupKey]. - final String? suffix; - - /// Returns the id for this app storage. - /// - /// Uses the [suffix] and falling back to the [groupKey] if not present. - /// This uniquely identifies the storage and is used in `Exportable` classes. - String get id => suffix ?? groupKey.value; - - /// Concatenates the [groupKey], [suffix] and [key] to build a unique key - /// used in the storage backend. - @visibleForTesting - String formatKey(String key) { - if (suffix != null) { - return '${groupKey.value}-$suffix-$key'; - } - - return '${groupKey.value}-$key'; - } - - /// {@template NeonStorage.containsKey} - /// Returns true if the persistent storage contains the given [key]. - /// {@endtemplate} - bool containsKey(String key) => NeonStorage.database.containsKey(formatKey(key)); - - @override - Future remove(String key) => NeonStorage.database.remove(formatKey(key)); - - @override - String? getString(String key) => NeonStorage.database.getString(formatKey(key)); - - @override - Future setString(String key, String value) => NeonStorage.database.setString(formatKey(key), value); - - @override - bool? getBool(String key) => NeonStorage.database.getBool(formatKey(key)); - - @override - Future setBool(String key, bool value) => NeonStorage.database.setBool(formatKey(key), value); - - /// {@template NeonStorage.getStringList} - /// Reads a set of string values from persistent storage, throwing an `Exception` if it's not a `String` set. - /// {@endtemplate} - List? getStringList(String key) => NeonStorage.database.getStringList(formatKey(key)); - - /// {@template NeonStorage.setStringList} - /// Saves a list of `String` [value]s to persistent storage in the background. - /// {@endtemplate} - Future setStringList(String key, List value) => - NeonStorage.database.setStringList(formatKey(key), value); -} diff --git a/packages/neon_framework/lib/src/settings/utils/settings_export_helper.dart b/packages/neon_framework/lib/src/settings/utils/settings_export_helper.dart index cc0d7e4eced..16446c5470e 100644 --- a/packages/neon_framework/lib/src/settings/utils/settings_export_helper.dart +++ b/packages/neon_framework/lib/src/settings/utils/settings_export_helper.dart @@ -8,7 +8,8 @@ import 'package:neon_framework/src/models/account.dart' show Account; import 'package:neon_framework/src/models/app_implementation.dart'; import 'package:neon_framework/src/settings/models/exportable.dart'; import 'package:neon_framework/src/settings/models/option.dart'; -import 'package:neon_framework/src/settings/models/storage.dart'; +import 'package:neon_framework/src/storage/keys.dart'; + import 'package:neon_framework/src/utils/findable.dart'; /// Helper class to export all [Option]s. @@ -123,7 +124,7 @@ class AccountsBlocExporter implements Exportable { final AccountsBloc accountsBloc; /// Key the exported value will be stored at. - static final _key = StorageKeys.accounts.value; + static final _key = StorageKeys.accountOptions.value; @override MapEntry export() => MapEntry(_key, Map.fromEntries(_serialize())); diff --git a/packages/neon_framework/lib/src/storage/keys.dart b/packages/neon_framework/lib/src/storage/keys.dart new file mode 100644 index 00000000000..1b7cdf8e1ef --- /dev/null +++ b/packages/neon_framework/lib/src/storage/keys.dart @@ -0,0 +1,46 @@ +import 'package:meta/meta.dart'; +import 'package:neon_framework/storage.dart'; +import 'package:nextcloud/ids.dart'; + +/// Interface of a storable element. +/// +/// Usually used in enhanced enums to ensure uniqueness of the storage keys. +abstract interface class Storable { + /// The key of this storage element. + String get value; +} + +/// Unique storage keys. +/// +/// Required by the users of the [NeonStorage] storage backend. +@internal +enum StorageKeys implements Storable { + /// The key for the `AppImplementation`s. + apps._('app'), + + /// The key for the `Account`s and their `AccountOptions`. + accountOptions._('accounts'), + + /// The key for the list of logged in `Account`s. + accounts._('accounts-accounts'), + + /// The key for the `GlobalOptions`. + global._('global'), + + /// The key for the `AccountsBloc` last used account. + lastUsedAccount._('last-used-account'), + + /// The key used by the `PushNotificationsBloc` to persist the last used endpoint. + lastEndpoint._('last-endpoint'), + + /// The key for the `FirstLaunchBloc`. + firstLaunch._('first-launch'), + + /// The key for the `PushUtils`. + notifications._(AppIDs.notifications); + + const StorageKeys._(this.value); + + @override + final String value; +} diff --git a/packages/neon_framework/lib/src/storage/request_cache.dart b/packages/neon_framework/lib/src/storage/request_cache.dart new file mode 100644 index 00000000000..68f264882d1 --- /dev/null +++ b/packages/neon_framework/lib/src/storage/request_cache.dart @@ -0,0 +1,150 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:meta/meta.dart'; +import 'package:neon_framework/src/utils/request_manager.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +/// A storage used to cache a key value pair. +abstract interface class RequestCache { + /// Get's the cached value for the given [key]. + /// + /// Use [getParameters] if you only need to check whether the cache is still + /// valid. + Future get(String key); + + /// Set's the cached [value] at the given [key]. + /// + /// If a value is already present it will be updated with the new one. + Future set(String key, String value, CacheParameters? parameters); + + /// Retrieves the cache parameters for the given [key]. + Future getParameters(String key); + + /// Updates the cache [parameters] for a given [key] without modifying the `value`. + Future updateParameters(String key, CacheParameters? parameters); +} + +@internal +class DefaultRequestCache implements RequestCache { + DefaultRequestCache(); + + Database? _database; + + Future init() async { + if (_database != null) { + return; + } + + final cacheDir = await getApplicationCacheDirectory(); + _database = await openDatabase( + p.join(cacheDir.path, 'cache.db'), + version: 2, + onCreate: (db, version) async { + await db.execute( + 'CREATE TABLE cache (id INTEGER PRIMARY KEY, key TEXT, value TEXT, etag TEXT, expires INTEGER, UNIQUE(key))', + ); + }, + onUpgrade: (db, oldVersion, newVersion) async { + final batch = db.batch(); + if (oldVersion == 1) { + batch + ..execute('ALTER TABLE cache ADD COLUMN etag TEXT') + ..execute('ALTER TABLE cache ADD COLUMN expires INTEGER'); + } + await batch.commit(); + }, + ); + } + + Database get _requireDatabase { + final database = _database; + if (database == null) { + throw StateError( + 'Cache has not been set up yet. Please make sure DefaultRequestCache.init() has been called before and completed.', + ); + } + + return database; + } + + @override + Future get(String key) async { + List>? result; + try { + result = await _requireDatabase.rawQuery('SELECT value FROM cache WHERE key = ?', [key]); + } on DatabaseException catch (e, s) { + debugPrint(e.toString()); + debugPrintStack(stackTrace: s, maxFrames: 5); + } + + return result?.firstOrNull?['value'] as String?; + } + + @override + Future set(String key, String value, CacheParameters? parameters) async { + try { + // UPSERT is only available since SQLite 3.24.0 (June 4, 2018). + // Using a manual solution from https://stackoverflow.com/a/38463024 + final batch = _requireDatabase.batch() + ..update( + 'cache', + { + 'key': key, + 'value': value, + 'etag': parameters?.etag, + 'expires': parameters?.expires?.millisecondsSinceEpoch, + }, + where: 'key = ?', + whereArgs: [key], + ) + ..rawInsert( + 'INSERT INTO cache (key, value, etag, expires) SELECT ?, ?, ?, ? WHERE (SELECT changes() = 0)', + [key, value, parameters?.etag, parameters?.expires?.millisecondsSinceEpoch], + ); + await batch.commit(noResult: true); + } on DatabaseException catch (e, s) { + debugPrint(e.toString()); + debugPrintStack(stackTrace: s, maxFrames: 5); + } + } + + @override + Future getParameters(String key) async { + List>? result; + try { + result = await _requireDatabase.rawQuery('SELECT etag, expires FROM cache WHERE key = ?', [key]); + } on DatabaseException catch (e, s) { + debugPrint(e.toString()); + debugPrintStack(stackTrace: s, maxFrames: 5); + } + + final row = result?.firstOrNull; + + final expires = row?['expires'] as int?; + return CacheParameters( + etag: row?['etag'] as String?, + expires: expires != null ? DateTime.fromMillisecondsSinceEpoch(expires) : null, + ); + } + + @override + Future updateParameters(String key, CacheParameters? parameters) async { + try { + await _requireDatabase.update( + 'cache', + { + 'etag': parameters?.etag, + 'expires': parameters?.expires?.millisecondsSinceEpoch, + }, + where: 'key = ?', + whereArgs: [key], + ); + } on DatabaseException catch (e, s) { + debugPrint(e.toString()); + debugPrintStack(stackTrace: s, maxFrames: 5); + } + } +} diff --git a/packages/neon_framework/lib/src/storage/settings_store.dart b/packages/neon_framework/lib/src/storage/settings_store.dart new file mode 100644 index 00000000000..0b6cb08ab1b --- /dev/null +++ b/packages/neon_framework/lib/src/storage/settings_store.dart @@ -0,0 +1,116 @@ +import 'package:meta/meta.dart'; +import 'package:neon_framework/src/storage/keys.dart'; +import 'package:neon_framework/src/storage/storage_manager.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Storage interface used by `Option`s. +/// +/// See: +/// * [NeonStorage] that manages the storage backend. +abstract interface class SettingsStore { + /// The group key for this app storage. + StorageKeys get groupKey; + + /// The optional suffix of the storage key. + /// + /// Used to differentiate between multiple AppStorages with the same [groupKey]. + String? get suffix; + + /// The id for this app storage. + String get id; + + /// {@template NeonStorage.getString} + /// Reads a value from persistent storage, throwing an `Exception` if it's not a `String`. + /// {@endtemplate} + String? getString(String key); + + /// {@template NeonStorage.setString} + /// Saves a `String` [value] to persistent storage in the background. + /// + /// Note: Due to limitations in Android's SharedPreferences, + /// values cannot start with any one of the following: + /// + /// - 'VGhpcyBpcyB0aGUgcHJlZml4IGZvciBhIGxpc3Qu' + /// - 'VGhpcyBpcyB0aGUgcHJlZml4IGZvciBCaWdJbnRlZ2Vy' + /// - 'VGhpcyBpcyB0aGUgcHJlZml4IGZvciBEb3VibGUu' + /// {@endtemplate} + Future setString(String key, String value); + + /// {@template NeonStorage.getBool} + /// Reads a value from persistent storage, throwing an `Exception` if it's not a `bool`. + /// {@endtemplate} + bool? getBool(String key); + + /// {@template NeonStorage.setBool} + /// Saves a `bool` [value] to persistent storage in the background. + /// {@endtemplate} + // ignore: avoid_positional_boolean_parameters + Future setBool(String key, bool value); + + /// {@template NeonStorage.remove} + /// Removes an entry from persistent storage. + /// {@endtemplate} + Future remove(String key); +} + +/// A storage that can save a group of values. +/// +/// Implements the interface of `SharedPreferences`. +/// [NeonStorage.init] must be called and completed before accessing individual values. +/// +/// See: +/// * [NeonStorage] to initialize and manage the storage backends. +@immutable +@internal +final class DefaultSettingsStore implements SettingsStore { + /// Creates a new app storage. + const DefaultSettingsStore( + this._database, + this.groupKey, [ + this.suffix, + ]); + + final SharedPreferences _database; + + /// The group key for this app storage. + /// + /// Keys are formatted with [formatKey] + @override + final StorageKeys groupKey; + + @override + final String? suffix; + + /// Returns the id for this app storage. + /// + /// Uses the [suffix] and falling back to the [groupKey] if not present. + /// This uniquely identifies the storage and is used in `Exportable` classes. + @override + String get id => suffix ?? groupKey.value; + + /// Concatenates the [groupKey], [suffix] and [key] to build a unique key + /// used in the storage backend. + @visibleForTesting + String formatKey(String key) { + if (suffix != null) { + return '${groupKey.value}-$suffix-$key'; + } + + return '${groupKey.value}-$key'; + } + + @override + Future remove(String key) => _database.remove(formatKey(key)); + + @override + String? getString(String key) => _database.getString(formatKey(key)); + + @override + Future setString(String key, String value) => _database.setString(formatKey(key), value); + + @override + bool? getBool(String key) => _database.getBool(formatKey(key)); + + @override + Future setBool(String key, bool value) => _database.setBool(formatKey(key), value); +} diff --git a/packages/neon_framework/lib/src/storage/single_value_store.dart b/packages/neon_framework/lib/src/storage/single_value_store.dart new file mode 100644 index 00000000000..5171b5dee97 --- /dev/null +++ b/packages/neon_framework/lib/src/storage/single_value_store.dart @@ -0,0 +1,99 @@ +import 'package:meta/meta.dart'; +import 'package:neon_framework/src/storage/keys.dart'; +import 'package:neon_framework/src/storage/storage_manager.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// A storage that itself is a single entry of a key value store. +/// +/// Mimics the interface of [SharedPreferences]. +/// +/// See: +/// * `NeonStorage` to initialize and manage the storage backends. +abstract interface class SingleValueStore { + /// The key used by the storage backend. + StorageKeys get key; + + /// {@macro NeonStorage.containsKey} + bool hasValue(); + + /// {@template NeonStorage.getString} + /// Reads a value from persistent storage, throwing an `Exception` if it's not a `String`. + /// {@endtemplate} + String? getString(); + + /// {@template NeonStorage.setString} + /// Saves a `String` [value] to persistent storage in the background. + /// + /// Note: Due to limitations in Android's SharedPreferences, + /// values cannot start with any one of the following: + /// + /// - 'VGhpcyBpcyB0aGUgcHJlZml4IGZvciBhIGxpc3Qu' + /// - 'VGhpcyBpcyB0aGUgcHJlZml4IGZvciBCaWdJbnRlZ2Vy' + /// - 'VGhpcyBpcyB0aGUgcHJlZml4IGZvciBEb3VibGUu' + /// {@endtemplate} + Future setString(String value); + + /// {@template NeonStorage.getBool} + /// Reads a value from persistent storage, throwing an `Exception` if it's not a `bool`. + /// {@endtemplate} + bool? getBool(); + + /// {@template NeonStorage.setBool} + /// Saves a `bool` [value] to persistent storage in the background. + /// {@endtemplate} + // ignore: avoid_positional_boolean_parameters + Future setBool(bool value); + + /// {@template NeonStorage.remove} + /// Removes an entry from persistent storage. + /// {@endtemplate} + Future remove(); + + /// {@macro NeonStorage.getStringList} + List? getStringList(); + + /// {@macro NeonStorage.setStringList} + Future setStringList(List value); +} + +/// A storage that saves a single value. +/// +/// [NeonStorage.init] must be called and completed before accessing individual values. +/// +/// See: +/// * [NeonStorage] to initialize and manage the storage backends. +@immutable +@internal +final class DefaultSingleValueStore implements SingleValueStore { + /// Creates a new storage for a single value. + const DefaultSingleValueStore(this._database, this.key); + + final SharedPreferences _database; + + @override + final StorageKeys key; + + @override + bool hasValue() => _database.containsKey(key.value); + + @override + Future remove() => _database.remove(key.value); + + @override + String? getString() => _database.getString(key.value); + + @override + Future setString(String value) => _database.setString(key.value, value); + + @override + bool? getBool() => _database.getBool(key.value); + + @override + Future setBool(bool value) => _database.setBool(key.value, value); + + @override + List? getStringList() => _database.getStringList(key.value); + + @override + Future setStringList(List value) => _database.setStringList(key.value, value); +} diff --git a/packages/neon_framework/lib/src/storage/storage_manager.dart b/packages/neon_framework/lib/src/storage/storage_manager.dart new file mode 100644 index 00000000000..9f2780bc8de --- /dev/null +++ b/packages/neon_framework/lib/src/storage/storage_manager.dart @@ -0,0 +1,88 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; +import 'package:neon_framework/src/storage/keys.dart'; +import 'package:neon_framework/src/storage/request_cache.dart'; +import 'package:neon_framework/src/storage/settings_store.dart'; +import 'package:neon_framework/src/storage/single_value_store.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Neon storage that manages the storage backend. +/// +/// [init] must be called and completed before accessing individual storages. +@sealed +class NeonStorage { + /// Accesses the current instance of the storage, creating a new one if non + /// existent. + factory NeonStorage() => instance ??= NeonStorage._(); + + /// Mocks the storage for testing. + @visibleForTesting + factory NeonStorage.mocked(NeonStorage mocked) => instance = mocked; + + NeonStorage._(); + + /// The current instance of the storage. + /// + /// Setting this to null resets the singleton leading to a new storage being + /// created on the next access of the constructor. + @visibleForTesting + static NeonStorage? instance; + + bool _initialized = false; + + /// Whether the storages have been initialized. + bool get initialized => _initialized; + + /// Shared preferences instance. + late SharedPreferences _sharedPreferences; + + /// Sets the individual storages. + /// + /// Required to be called before accessing any individual one. + Future init() async { + if (_initialized) { + return; + } + + final requestCache = DefaultRequestCache(); + await requestCache.init(); + _requestCache = requestCache; + + _sharedPreferences = await SharedPreferences.getInstance(); + + _initialized = true; + } + + /// Request cache instance. + RequestCache? _requestCache; + + /// The current request cache if available. + RequestCache? get requestCache { + _assertInitialized(); + + return _requestCache; + } + + /// Initializes a new `SettingsStorage`. + SettingsStore settingsStore(StorageKeys groupKey, [String? suffix]) { + _assertInitialized(); + + return DefaultSettingsStore(_sharedPreferences, groupKey, suffix); + } + + /// Initializes a new `KeyValueStorage`. + SingleValueStore singleValueStore(StorageKeys key) { + _assertInitialized(); + + return DefaultSingleValueStore(_sharedPreferences, key); + } + + void _assertInitialized() { + if (!_initialized) { + throw StateError( + 'NeonStorage has not been initialized yet. Please make sure NeonStorage.init() has been called before and completed.', + ); + } + } +} diff --git a/packages/neon_framework/lib/src/testing/mocks.dart b/packages/neon_framework/lib/src/testing/mocks.dart index 0801a3934d7..fd144900fc6 100644 --- a/packages/neon_framework/lib/src/testing/mocks.dart +++ b/packages/neon_framework/lib/src/testing/mocks.dart @@ -12,9 +12,8 @@ import 'package:neon_framework/src/blocs/apps.dart'; import 'package:neon_framework/src/blocs/capabilities.dart'; import 'package:neon_framework/src/models/disposable.dart'; import 'package:neon_framework/src/settings/models/exportable.dart'; -import 'package:neon_framework/src/settings/models/storage.dart'; import 'package:neon_framework/src/utils/account_options.dart'; -import 'package:neon_framework/src/utils/request_manager.dart'; +import 'package:neon_framework/storage.dart'; import 'package:shared_preferences/shared_preferences.dart'; class MockAccount extends Mock implements Account {} @@ -33,7 +32,7 @@ class MockAppsBloc extends Mock implements AppsBloc {} class MockCapabilitiesBloc extends Mock implements CapabilitiesBloc {} -class MockStorage extends Mock implements SettingsStorage {} +class MockStorage extends Mock implements SettingsStore {} class MockAccountOptions extends Mock implements AccountOptions {} @@ -43,10 +42,18 @@ class MockOption extends Mock implements ToggleOption {} class MockAppImplementationOptions extends Mock implements AppImplementationOptions {} -class MockCache extends Mock implements Cache {} +class MockRequestCache extends Mock implements RequestCache {} + +class MockNeonStorage extends Mock implements NeonStorage { + MockNeonStorage() { + NeonStorage.mocked(this); + } +} class MockSharedPreferences extends Mock implements SharedPreferences {} +class MockSettingsStore extends Mock implements SettingsStore {} + class MockCallbackFunction extends Mock { FutureOr call(); } diff --git a/packages/neon_framework/lib/src/utils/account_options.dart b/packages/neon_framework/lib/src/utils/account_options.dart index d4470c4ed75..7477a9788b9 100644 --- a/packages/neon_framework/lib/src/utils/account_options.dart +++ b/packages/neon_framework/lib/src/utils/account_options.dart @@ -3,7 +3,7 @@ import 'package:neon_framework/l10n/localizations.dart'; import 'package:neon_framework/src/blocs/apps.dart'; import 'package:neon_framework/src/settings/models/option.dart'; import 'package:neon_framework/src/settings/models/options_collection.dart'; -import 'package:neon_framework/src/settings/models/storage.dart'; +import 'package:neon_framework/storage.dart'; /// Account related options. @internal diff --git a/packages/neon_framework/lib/src/utils/global_options.dart b/packages/neon_framework/lib/src/utils/global_options.dart index 5ce55454573..2e321b051e7 100644 --- a/packages/neon_framework/lib/src/utils/global_options.dart +++ b/packages/neon_framework/lib/src/utils/global_options.dart @@ -5,7 +5,9 @@ import 'package:neon_framework/src/models/account.dart'; import 'package:neon_framework/src/models/label_builder.dart'; import 'package:neon_framework/src/settings/models/option.dart'; import 'package:neon_framework/src/settings/models/options_collection.dart'; -import 'package:neon_framework/src/settings/models/storage.dart'; +import 'package:neon_framework/src/storage/keys.dart'; + +import 'package:neon_framework/storage.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:universal_io/io.dart'; @@ -20,7 +22,7 @@ class GlobalOptions extends OptionsCollection { /// Creates a new global options collection. GlobalOptions( this._packageInfo, - ) : super(const AppStorage(StorageKeys.global)) { + ) : super(NeonStorage().settingsStore(StorageKeys.global)) { pushNotificationsEnabled.addListener(_pushNotificationsEnabledListener); rememberLastUsedAccount.addListener(_rememberLastUsedAccountListener); } diff --git a/packages/neon_framework/lib/src/utils/push_utils.dart b/packages/neon_framework/lib/src/utils/push_utils.dart index f035fc8e6af..7c71dfe2950 100644 --- a/packages/neon_framework/lib/src/utils/push_utils.dart +++ b/packages/neon_framework/lib/src/utils/push_utils.dart @@ -14,12 +14,14 @@ import 'package:neon_framework/src/bloc/result.dart'; import 'package:neon_framework/src/blocs/accounts.dart'; import 'package:neon_framework/src/models/account.dart'; import 'package:neon_framework/src/models/push_notification.dart'; -import 'package:neon_framework/src/settings/models/storage.dart'; +import 'package:neon_framework/src/storage/keys.dart'; + import 'package:neon_framework/src/theme/colors.dart'; import 'package:neon_framework/src/utils/findable.dart'; import 'package:neon_framework/src/utils/image_utils.dart'; import 'package:neon_framework/src/utils/localizations.dart'; import 'package:neon_framework/src/utils/request_manager.dart'; +import 'package:neon_framework/storage.dart'; import 'package:nextcloud/notifications.dart' as notifications; import 'package:rxdart/rxdart.dart'; @@ -39,18 +41,19 @@ class PushUtils { static Future Function(PushNotification notification)? onLocalNotificationClicked; static RSAKeypair loadRSAKeypair() { - const storage = AppStorage(StorageKeys.notifications); + final storage = NeonStorage().settingsStore(StorageKeys.notifications); const keyDevicePrivateKey = 'device-private-key'; final RSAKeypair keypair; - if (!storage.containsKey(keyDevicePrivateKey) || (storage.getString(keyDevicePrivateKey)!.isEmpty)) { + final privateKey = storage.getString(keyDevicePrivateKey); + if (privateKey == null || privateKey.isEmpty) { debugPrint('Generating RSA keys for push notifications'); // The key size has to be 2048, other sizes are not accepted by Nextcloud (at the moment at least) // ignore: avoid_redundant_argument_values keypair = RSAKeypair.fromRandom(keySize: 2048); unawaited(storage.setString(keyDevicePrivateKey, keypair.privateKey.toPEM())); } else { - keypair = RSAKeypair(RSAPrivateKey.fromPEM(storage.getString(keyDevicePrivateKey)!)); + keypair = RSAKeypair(RSAPrivateKey.fromPEM(privateKey)); } return keypair; @@ -87,7 +90,7 @@ class PushUtils { } }, ); - await NeonStorage.init(); + await NeonStorage().init(); final keypair = loadRSAKeypair(); for (final message in Uri(query: utf8.decode(messages)).queryParameters.values) { diff --git a/packages/neon_framework/lib/src/utils/request_manager.dart b/packages/neon_framework/lib/src/utils/request_manager.dart index 6b2419bc7a0..7a30d8afedf 100644 --- a/packages/neon_framework/lib/src/utils/request_manager.dart +++ b/packages/neon_framework/lib/src/utils/request_manager.dart @@ -9,11 +9,9 @@ import 'package:meta/meta.dart'; import 'package:neon_framework/models.dart'; import 'package:neon_framework/src/bloc/result.dart'; import 'package:neon_framework/src/models/account.dart'; +import 'package:neon_framework/storage.dart'; import 'package:nextcloud/nextcloud.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; import 'package:rxdart/rxdart.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:xml/xml.dart' as xml; /// A callback that unwraps elements of type [R] into [T]. @@ -51,10 +49,7 @@ final httpDateFormat = DateFormat('E, d MMM yyyy HH:mm:ss v', 'en_US'); /// Requests need to be made through the [nextcloud](https://pub.dev/packages/nextcloud) /// package. /// -/// Requests can be persisted in the local cache if enabled. The local cache -/// must be initialized through [initCache]. A network request is always made, -/// even if a value already exists in the cache. The cached value will only be -/// emitted when the network request has not yet finished. +/// Requests can be persisted in the local cache if enabled and set up by the [NeonStorage]. class RequestManager { RequestManager._(); @@ -71,17 +66,7 @@ class RequestManager { @visibleForTesting static set instance(RequestManager? requestManager) => _requestManager = requestManager; - /// Initializes the cache. - /// - /// Requests made before this method has completed will not be persisted in the cache. - Future initCache() async { - final cache = Cache(); - await cache.init(); - - _cache = cache; - } - - Cache? _cache; + final RequestCache? _cache = NeonStorage().requestCache; /// Executes a request to a Nextcloud API endpoint. Future wrapNextcloud({ @@ -354,134 +339,7 @@ class RequestManager { } } -@internal -class Cache { - factory Cache() => instance ??= Cache._(); - - Cache._(); - - @visibleForTesting - factory Cache.mocked(Cache mocked) => instance = mocked; - - @visibleForTesting - static Cache? instance; - - Database? _database; - - Future init() async { - if (_database != null) { - return; - } - - final cacheDir = await getApplicationCacheDirectory(); - _database = await openDatabase( - p.join(cacheDir.path, 'cache.db'), - version: 2, - onCreate: (db, version) async { - await db.execute( - 'CREATE TABLE cache (id INTEGER PRIMARY KEY, key TEXT, value TEXT, etag TEXT, expires INTEGER, UNIQUE(key))', - ); - }, - onUpgrade: (db, oldVersion, newVersion) async { - final batch = db.batch(); - if (oldVersion == 1) { - batch - ..execute('ALTER TABLE cache ADD COLUMN etag TEXT') - ..execute('ALTER TABLE cache ADD COLUMN expires INTEGER'); - } - await batch.commit(); - }, - ); - } - - Database get _requireDatabase { - final database = _database; - if (database == null) { - throw StateError( - 'Cache has not been set up yet. Please make sure Cache.init() has been called before and completed.', - ); - } - - return database; - } - - Future get(String key) async { - List>? result; - try { - result = await _requireDatabase.rawQuery('SELECT value FROM cache WHERE key = ?', [key]); - } on DatabaseException catch (e, s) { - debugPrint(e.toString()); - debugPrintStack(stackTrace: s, maxFrames: 5); - } - - return result?.firstOrNull?['value'] as String?; - } - - Future set(String key, String value, CacheParameters? parameters) async { - try { - // UPSERT is only available since SQLite 3.24.0 (June 4, 2018). - // Using a manual solution from https://stackoverflow.com/a/38463024 - final batch = _requireDatabase.batch() - ..update( - 'cache', - { - 'key': key, - 'value': value, - 'etag': parameters?.etag, - 'expires': parameters?.expires?.millisecondsSinceEpoch, - }, - where: 'key = ?', - whereArgs: [key], - ) - ..rawInsert( - 'INSERT INTO cache (key, value, etag, expires) SELECT ?, ?, ?, ? WHERE (SELECT changes() = 0)', - [key, value, parameters?.etag, parameters?.expires?.millisecondsSinceEpoch], - ); - await batch.commit(noResult: true); - } on DatabaseException catch (e, s) { - debugPrint(e.toString()); - debugPrintStack(stackTrace: s, maxFrames: 5); - } - } - - Future getParameters(String key) async { - List>? result; - try { - result = await _requireDatabase.rawQuery('SELECT etag, expires FROM cache WHERE key = ?', [key]); - } on DatabaseException catch (e, s) { - debugPrint(e.toString()); - debugPrintStack(stackTrace: s, maxFrames: 5); - } - - final row = result?.firstOrNull; - - final expires = row?['expires'] as int?; - return CacheParameters( - etag: row?['etag'] as String?, - expires: expires != null ? DateTime.fromMillisecondsSinceEpoch(expires) : null, - ); - } - - /// Updates the cache [parameters] for a given [key] without modifying the `value`. - Future updateParameters(String key, CacheParameters? parameters) async { - try { - await _requireDatabase.update( - 'cache', - { - 'etag': parameters?.etag, - 'expires': parameters?.expires?.millisecondsSinceEpoch, - }, - where: 'key = ?', - whereArgs: [key], - ); - } on DatabaseException catch (e, s) { - debugPrint(e.toString()); - debugPrintStack(stackTrace: s, maxFrames: 5); - } - } -} - -/// Parameters for values in [Cache]. +/// Parameters for values in [RequestCache]. @immutable class CacheParameters { /// Creates new cache parameters. diff --git a/packages/neon_framework/lib/storage.dart b/packages/neon_framework/lib/storage.dart new file mode 100644 index 00000000000..6cf00b16351 --- /dev/null +++ b/packages/neon_framework/lib/storage.dart @@ -0,0 +1,10 @@ +/// Storage interfaces for the Neon app framework. +/// +/// The `NeonStorage` manages all storage backends. +library; + +export 'package:neon_framework/src/storage/keys.dart' show Storable; +export 'package:neon_framework/src/storage/request_cache.dart' show RequestCache; +export 'package:neon_framework/src/storage/settings_store.dart' show SettingsStore; +export 'package:neon_framework/src/storage/single_value_store.dart' show SingleValueStore; +export 'package:neon_framework/src/storage/storage_manager.dart'; diff --git a/packages/neon_framework/test/option_test.dart b/packages/neon_framework/test/option_test.dart index 7a9c51ece47..526b086ac3a 100644 --- a/packages/neon_framework/test/option_test.dart +++ b/packages/neon_framework/test/option_test.dart @@ -4,7 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:neon_framework/src/settings/models/option.dart'; -import 'package:neon_framework/src/settings/models/storage.dart'; +import 'package:neon_framework/storage.dart'; import 'package:neon_framework/testing.dart'; enum StorageKey implements Storable { diff --git a/packages/neon_framework/test/options_collection_test.dart b/packages/neon_framework/test/options_collection_test.dart index 639cbaf7f5d..231fd537e6e 100644 --- a/packages/neon_framework/test/options_collection_test.dart +++ b/packages/neon_framework/test/options_collection_test.dart @@ -1,11 +1,13 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:neon_framework/settings.dart'; -import 'package:neon_framework/src/settings/models/storage.dart'; +import 'package:neon_framework/src/storage/keys.dart'; import 'package:neon_framework/testing.dart'; class Collection extends AppImplementationOptions { - Collection(List> options) : super(const AppStorage(StorageKeys.apps)) { + Collection(List> options, MockSettingsStore storage) : super(storage) { + when(() => storage.id).thenReturn('app'); + super.options = options; } } @@ -24,10 +26,13 @@ void main() { group('OptionsCollection', () { final option1 = MockOption(); final option2 = MockOption(); - final collection = Collection([ - option1, - option2, - ]); + final collection = Collection( + [ + option1, + option2, + ], + MockSettingsStore(), + ); test('reset', () { collection.reset(); diff --git a/packages/neon_framework/test/request_manager_test.dart b/packages/neon_framework/test/request_manager_test.dart index 70ba2de1c35..323d6058080 100644 --- a/packages/neon_framework/test/request_manager_test.dart +++ b/packages/neon_framework/test/request_manager_test.dart @@ -17,16 +17,15 @@ String base64String(String value) => base64.encode(utf8.encode(value)); void main() { final account = MockAccount(); when(() => account.id).thenReturn('clientID'); + late MockNeonStorage storage; - tearDown(() { - RequestManager.instance = null; - Cache.instance = null; + setUp(() { + storage = MockNeonStorage(); + when(() => storage.requestCache).thenReturn(null); }); - group('Cache', () { - test('singleton', () { - expect(identical(Cache(), Cache()), isTrue); - }); + tearDown(() { + RequestManager.instance = null; }); group('RequestManager', () { @@ -266,11 +265,10 @@ void main() { }); group('wrap with cache', () { - late Cache cache; + late MockRequestCache cache; setUp(() async { - cache = MockCache(); - Cache.mocked(cache); + cache = MockRequestCache(); when(() => cache.get(any())).thenAnswer( (_) => Future.value('Cached value'), @@ -288,11 +286,7 @@ void main() { (_) => Future.value(), ); - when(() => cache.init()).thenAnswer( - (_) => Future.value(), - ); - - await RequestManager.instance.initCache(); + when(() => storage.requestCache).thenReturn(cache); }); test('successful request', () async { diff --git a/packages/neon_framework/test/settings_test.dart b/packages/neon_framework/test/settings_test.dart index aee39e2d0d9..df7b9afb7a7 100644 --- a/packages/neon_framework/test/settings_test.dart +++ b/packages/neon_framework/test/settings_test.dart @@ -4,8 +4,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:neon_framework/src/settings/models/option.dart'; -import 'package:neon_framework/src/settings/models/storage.dart'; import 'package:neon_framework/src/settings/widgets/option_settings_tile.dart'; +import 'package:neon_framework/storage.dart'; import 'package:neon_framework/testing.dart'; enum StorageKey implements Storable { diff --git a/packages/neon_framework/test/storage_test.dart b/packages/neon_framework/test/storage_test.dart index 9f9d7f7413d..cca0e03cd76 100644 --- a/packages/neon_framework/test/storage_test.dart +++ b/packages/neon_framework/test/storage_test.dart @@ -1,46 +1,37 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; -import 'package:neon_framework/src/settings/models/storage.dart'; +import 'package:neon_framework/src/storage/keys.dart'; +import 'package:neon_framework/src/storage/settings_store.dart'; +import 'package:neon_framework/src/storage/single_value_store.dart'; import 'package:neon_framework/testing.dart'; -import 'package:shared_preferences/shared_preferences.dart'; void main() { - test('NeonStorage', () async { - expect(() => NeonStorage.database, throwsA(isA())); + group('Storages', () { + late MockSharedPreferences sharedPreferences; - SharedPreferences.setMockInitialValues({}); - await NeonStorage.init(); - - expect(NeonStorage.database, isA()); - }); + setUp(() { + sharedPreferences = MockSharedPreferences(); + }); - group('AppStorage', () { - test('formatKey', () async { - var appStorage = const AppStorage(StorageKeys.accounts); + test('AppStorage formatKey', () async { + var appStorage = DefaultSettingsStore(sharedPreferences, StorageKeys.accountOptions); var key = appStorage.formatKey('test-key'); expect(key, 'accounts-test-key'); - expect(appStorage.id, StorageKeys.accounts.value); + expect(appStorage.id, StorageKeys.accountOptions.value); - appStorage = const AppStorage(StorageKeys.accounts, 'test-suffix'); + appStorage = DefaultSettingsStore(sharedPreferences, StorageKeys.accountOptions, 'test-suffix'); key = appStorage.formatKey('test-key'); expect(key, 'accounts-test-suffix-test-key'); expect(appStorage.id, 'test-suffix'); }); - test('interface', () async { - final sharedPreferences = MockSharedPreferences(); - NeonStorage.mock(sharedPreferences); - const appStorage = AppStorage(StorageKeys.accounts); + test('AppStorage interface', () async { + final appStorage = DefaultSettingsStore(sharedPreferences, StorageKeys.accountOptions); const key = 'key'; final formattedKey = appStorage.formatKey(key); - when(() => sharedPreferences.containsKey(formattedKey)).thenReturn(true); - dynamic result = appStorage.containsKey(key); - expect(result, equals(true)); - verify(() => sharedPreferences.containsKey(formattedKey)).called(1); - when(() => sharedPreferences.remove(formattedKey)).thenAnswer((_) => Future.value(false)); - result = await appStorage.remove(key); + dynamic result = await appStorage.remove(key); expect(result, equals(false)); verify(() => sharedPreferences.remove(formattedKey)).called(1); @@ -63,63 +54,51 @@ void main() { result = await appStorage.setBool(key, true); expect(result, true); verify(() => sharedPreferences.setBool(formattedKey, true)).called(1); + }); + + test('SingleValueStorage', () async { + final storage = DefaultSingleValueStore(sharedPreferences, StorageKeys.global); + final key = StorageKeys.global.value; + + when(() => sharedPreferences.containsKey(key)).thenReturn(true); + dynamic result = storage.hasValue(); + expect(result, equals(true)); + verify(() => sharedPreferences.containsKey(key)).called(1); + + when(() => sharedPreferences.remove(key)).thenAnswer((_) => Future.value(false)); + result = await storage.remove(); + expect(result, equals(false)); + verify(() => sharedPreferences.remove(key)).called(1); - when(() => sharedPreferences.getStringList(formattedKey)).thenReturn(['hi there']); - result = appStorage.getStringList(key); + when(() => sharedPreferences.getString(key)).thenReturn(null); + result = storage.getString(); + expect(result, isNull); + verify(() => sharedPreferences.getString(key)).called(1); + + when(() => sharedPreferences.setString(key, 'value')).thenAnswer((_) => Future.value(false)); + result = await storage.setString('value'); + expect(result, false); + verify(() => sharedPreferences.setString(key, 'value')).called(1); + + when(() => sharedPreferences.getBool(key)).thenReturn(true); + result = storage.getBool(); + expect(result, equals(true)); + verify(() => sharedPreferences.getBool(key)).called(1); + + when(() => sharedPreferences.setBool(key, true)).thenAnswer((_) => Future.value(true)); + result = await storage.setBool(true); + expect(result, true); + verify(() => sharedPreferences.setBool(key, true)).called(1); + + when(() => sharedPreferences.getStringList(key)).thenReturn(['hi there']); + result = storage.getStringList(); expect(result, equals(['hi there'])); - verify(() => sharedPreferences.getStringList(formattedKey)).called(1); + verify(() => sharedPreferences.getStringList(key)).called(1); - when(() => sharedPreferences.setStringList(formattedKey, ['hi there'])).thenAnswer((_) => Future.value(false)); - result = await appStorage.setStringList(key, ['hi there']); + when(() => sharedPreferences.setStringList(key, ['hi there'])).thenAnswer((_) => Future.value(false)); + result = await storage.setStringList(['hi there']); expect(result, false); - verify(() => sharedPreferences.setStringList(formattedKey, ['hi there'])).called(1); + verify(() => sharedPreferences.setStringList(key, ['hi there'])).called(1); }); }); - - test('SingleValueStorage', () async { - final sharedPreferences = MockSharedPreferences(); - NeonStorage.mock(sharedPreferences); - const storage = SingleValueStorage(StorageKeys.global); - final key = StorageKeys.global.value; - - when(() => sharedPreferences.containsKey(key)).thenReturn(true); - dynamic result = storage.hasValue(); - expect(result, equals(true)); - verify(() => sharedPreferences.containsKey(key)).called(1); - - when(() => sharedPreferences.remove(key)).thenAnswer((_) => Future.value(false)); - result = await storage.remove(); - expect(result, equals(false)); - verify(() => sharedPreferences.remove(key)).called(1); - - when(() => sharedPreferences.getString(key)).thenReturn(null); - result = storage.getString(); - expect(result, isNull); - verify(() => sharedPreferences.getString(key)).called(1); - - when(() => sharedPreferences.setString(key, 'value')).thenAnswer((_) => Future.value(false)); - result = await storage.setString('value'); - expect(result, false); - verify(() => sharedPreferences.setString(key, 'value')).called(1); - - when(() => sharedPreferences.getBool(key)).thenReturn(true); - result = storage.getBool(); - expect(result, equals(true)); - verify(() => sharedPreferences.getBool(key)).called(1); - - when(() => sharedPreferences.setBool(key, true)).thenAnswer((_) => Future.value(true)); - result = await storage.setBool(true); - expect(result, true); - verify(() => sharedPreferences.setBool(key, true)).called(1); - - when(() => sharedPreferences.getStringList(key)).thenReturn(['hi there']); - result = storage.getStringList(); - expect(result, equals(['hi there'])); - verify(() => sharedPreferences.getStringList(key)).called(1); - - when(() => sharedPreferences.setStringList(key, ['hi there'])).thenAnswer((_) => Future.value(false)); - result = await storage.setStringList(['hi there']); - expect(result, false); - verify(() => sharedPreferences.setStringList(key, ['hi there'])).called(1); - }); } diff --git a/packages/neon_framework/test/user_status_bloc_test.dart b/packages/neon_framework/test/user_status_bloc_test.dart index ac9e1a412a4..0f3578af3cf 100644 --- a/packages/neon_framework/test/user_status_bloc_test.dart +++ b/packages/neon_framework/test/user_status_bloc_test.dart @@ -155,6 +155,9 @@ void main() { final platform = MockNeonPlatform(); when(() => platform.canUseWindowManager).thenReturn(false); NeonPlatform.mocked(platform); + + final storage = MockNeonStorage(); + when(() => storage.requestCache).thenReturn(null); }); setUp(() { diff --git a/packages/neon_framework/test/weather_status_bloc_test.dart b/packages/neon_framework/test/weather_status_bloc_test.dart index c2b06afbe6f..502155097c3 100644 --- a/packages/neon_framework/test/weather_status_bloc_test.dart +++ b/packages/neon_framework/test/weather_status_bloc_test.dart @@ -3,8 +3,10 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart'; import 'package:http/testing.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:neon_framework/blocs.dart'; import 'package:neon_framework/src/models/account.dart'; +import 'package:neon_framework/testing.dart'; import 'package:nextcloud/core.dart' as core; import 'package:rxdart/rxdart.dart'; @@ -152,6 +154,11 @@ void main() { late BehaviorSubject> capabilities; late WeatherStatusBloc bloc; + setUpAll(() { + final storage = MockNeonStorage(); + when(() => storage.requestCache).thenReturn(null); + }); + setUp(() { account = mockWeatherStatusAccount(); capabilities = BehaviorSubject>();