diff --git a/packages/neon_framework/example/pubspec.lock b/packages/neon_framework/example/pubspec.lock index 9254e0a2c09..528c4f8330c 100644 --- a/packages/neon_framework/example/pubspec.lock +++ b/packages/neon_framework/example/pubspec.lock @@ -881,6 +881,13 @@ packages: relative: true source: path version: "1.0.0" + neon_storage: + dependency: "direct overridden" + description: + path: "../packages/neon_storage" + relative: true + source: path + version: "0.1.0" nested: dependency: transitive description: @@ -1337,14 +1344,6 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.0" - sqflite: - dependency: transitive - description: - name: sqflite - sha256: a43e5a27235518c03ca238e7b4732cf35eabe863a369ceba6cbefa537a66f16d - url: "https://pub.dev" - source: hosted - version: "2.3.3+1" sqflite_common: dependency: transitive description: diff --git a/packages/neon_framework/example/pubspec_overrides.yaml b/packages/neon_framework/example/pubspec_overrides.yaml index 3839f558d53..35c14ed7402 100644 --- a/packages/neon_framework/example/pubspec_overrides.yaml +++ b/packages/neon_framework/example/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: account_repository,cookie_store,dashboard_app,dynamite_runtime,files_app,interceptor_http_client,neon_framework,neon_http_client,neon_lints,news_app,nextcloud,notes_app,notifications_app,sort_box,talk_app +# melos_managed_dependency_overrides: account_repository,cookie_store,dashboard_app,dynamite_runtime,files_app,interceptor_http_client,neon_framework,neon_http_client,neon_lints,neon_storage,news_app,nextcloud,notes_app,notifications_app,sort_box,talk_app dependency_overrides: account_repository: path: ../packages/account_repository @@ -18,6 +18,8 @@ dependency_overrides: path: ../packages/neon_http_client neon_lints: path: ../../neon_lints + neon_storage: + path: ../packages/neon_storage news_app: path: ../packages/news_app nextcloud: diff --git a/packages/neon_framework/lib/neon.dart b/packages/neon_framework/lib/neon.dart index c141bd6a412..00ae554edd2 100644 --- a/packages/neon_framework/lib/neon.dart +++ b/packages/neon_framework/lib/neon.dart @@ -16,7 +16,6 @@ import 'package:neon_framework/src/blocs/push_notifications.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/storage/keys.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'; @@ -53,7 +52,12 @@ Future runNeon({ tz.setLocalLocation(location); } - await NeonStorage().init(); + final accountStorage = AccountStorage(); + await NeonStorage().init( + dataTables: [ + accountStorage, + ], + ); final packageInfo = await PackageInfo.fromPlatform(); @@ -61,15 +65,11 @@ Future runNeon({ packageInfo, ); - final accountStorage = AccountStorage( - accountsPersistence: NeonStorage().singleValueStore(StorageKeys.accounts), - lastAccountPersistence: NeonStorage().singleValueStore(StorageKeys.lastUsedAccount), - ); - final accountRepository = AccountRepository( userAgent: buildUserAgent(packageInfo, theme.branding.name), httpClient: httpClient ?? http.Client(), storage: accountStorage, + enableCookieStore: !kIsWeb, ); await accountRepository.loadAccounts( diff --git a/packages/neon_framework/lib/src/blocs/accounts.dart b/packages/neon_framework/lib/src/blocs/accounts.dart index 541b8e34b15..b596b0c5725 100644 --- a/packages/neon_framework/lib/src/blocs/accounts.dart +++ b/packages/neon_framework/lib/src/blocs/accounts.dart @@ -10,7 +10,6 @@ import 'package:neon_framework/src/blocs/maintenance_mode.dart'; import 'package:neon_framework/src/blocs/unified_search.dart'; import 'package:neon_framework/src/models/account_cache.dart'; import 'package:neon_framework/src/models/disposable.dart'; -import 'package:neon_framework/src/storage/keys.dart'; import 'package:neon_framework/src/utils/account_options.dart'; import 'package:neon_framework/storage.dart'; import 'package:rxdart/rxdart.dart'; diff --git a/packages/neon_framework/lib/src/blocs/first_launch.dart b/packages/neon_framework/lib/src/blocs/first_launch.dart index 34a98a8127e..c0b811fd05e 100644 --- a/packages/neon_framework/lib/src/blocs/first_launch.dart +++ b/packages/neon_framework/lib/src/blocs/first_launch.dart @@ -4,7 +4,6 @@ import 'package:logging/logging.dart'; 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/storage/keys.dart'; import 'package:neon_framework/storage.dart'; import 'package:rxdart/rxdart.dart'; diff --git a/packages/neon_framework/lib/src/blocs/push_notifications.dart b/packages/neon_framework/lib/src/blocs/push_notifications.dart index 111b36cd829..ce78faca6f7 100644 --- a/packages/neon_framework/lib/src/blocs/push_notifications.dart +++ b/packages/neon_framework/lib/src/blocs/push_notifications.dart @@ -7,7 +7,6 @@ import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; import 'package:neon_framework/src/bloc/bloc.dart'; import 'package:neon_framework/src/platform/platform.dart'; -import 'package:neon_framework/src/storage/keys.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'; diff --git a/packages/neon_framework/lib/src/models/app_implementation.dart b/packages/neon_framework/lib/src/models/app_implementation.dart index c9f7cf1af34..ebafbc3a0d1 100644 --- a/packages/neon_framework/lib/src/models/app_implementation.dart +++ b/packages/neon_framework/lib/src/models/app_implementation.dart @@ -10,7 +10,6 @@ import 'package:neon_framework/src/bloc/bloc.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/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'; diff --git a/packages/neon_framework/lib/src/platform/linux.dart b/packages/neon_framework/lib/src/platform/linux.dart index 73a52bcbfb1..9bd4d983e20 100644 --- a/packages/neon_framework/lib/src/platform/linux.dart +++ b/packages/neon_framework/lib/src/platform/linux.dart @@ -3,7 +3,6 @@ import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; import 'package:meta/meta.dart'; import 'package:neon_framework/src/platform/platform.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:universal_io/io.dart'; /// Linux specific platform information. @@ -43,10 +42,7 @@ class LinuxNeonPlatform implements NeonPlatform { bool get canUsePaths => true; @override - void init() { - sqfliteFfiInit(); - databaseFactory = databaseFactoryFfi; - } + void init() {} @override Future saveFileWithPickDialog(String fileName, String mimeType, Uint8List data) async { diff --git a/packages/neon_framework/lib/src/platform/web.dart b/packages/neon_framework/lib/src/platform/web.dart index 14dd819dd32..48869d7abf0 100644 --- a/packages/neon_framework/lib/src/platform/web.dart +++ b/packages/neon_framework/lib/src/platform/web.dart @@ -4,8 +4,6 @@ import 'dart:typed_data'; import 'package:meta/meta.dart'; import 'package:neon_framework/src/platform/platform.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; -import 'package:sqflite_common_ffi_web/sqflite_ffi_web.dart'; import 'package:web/web.dart'; @immutable @@ -39,9 +37,7 @@ class WebNeonPlatform implements NeonPlatform { bool get canUsePaths => false; @override - Future init() async { - databaseFactory = databaseFactoryFfiWeb; - } + void init() {} @override Future saveFileWithPickDialog(String fileName, String mimeType, Uint8List data) async { 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 3a87dd3c603..1cb105d3f04 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,8 +8,8 @@ import 'package:neon_framework/models.dart'; import 'package:neon_framework/src/blocs/accounts.dart'; import 'package:neon_framework/src/settings/models/exportable.dart'; import 'package:neon_framework/src/settings/models/option.dart'; -import 'package:neon_framework/src/storage/keys.dart'; import 'package:neon_framework/src/utils/findable.dart'; +import 'package:neon_framework/storage.dart'; /// Helper class to export all [Option]s. /// diff --git a/packages/neon_framework/lib/src/storage/neon_cache_db.dart b/packages/neon_framework/lib/src/storage/neon_cache_db.dart new file mode 100644 index 00000000000..720a62ea748 --- /dev/null +++ b/packages/neon_framework/lib/src/storage/neon_cache_db.dart @@ -0,0 +1,48 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:neon_storage/neon_sqlite.dart'; +import 'package:neon_storage/neon_storage.dart'; + +import 'package:path_provider/path_provider.dart'; + +/// Database holding the neon cache. +final class NeonCacheDB extends MultiTableDatabase { + /// Creates a new database with the given [tables]. + factory NeonCacheDB({ + Iterable? tables, + }) { + return NeonCacheDB._( + tables: [ + ...?tables, + if (!kIsWeb) SQLiteRequestCache.table, + if (!kIsWeb) SQLiteCookiePersistence.table, + ], + ); + } + + NeonCacheDB._({ + required super.tables, + }); + + @override + String get name => 'cache'; + + @override + Future get path async { + final cacheDir = await getApplicationCacheDirectory(); + + return buildDatabasePath(cacheDir.path, name); + } + + /// The current request cache if available. + RequestCache? get requestCache { + if (kIsWeb) { + return null; + } + + assertInitialized(); + + return const SQLiteRequestCache(); + } +} diff --git a/packages/neon_framework/lib/src/storage/neon_data_db.dart b/packages/neon_framework/lib/src/storage/neon_data_db.dart new file mode 100644 index 00000000000..73b769cf117 --- /dev/null +++ b/packages/neon_framework/lib/src/storage/neon_data_db.dart @@ -0,0 +1,34 @@ +import 'dart:async'; + +import 'package:neon_storage/neon_sqlite.dart'; +import 'package:neon_storage/neon_storage.dart'; +import 'package:path_provider/path_provider.dart'; + +/// Database holding the neon data. +final class NeonDataDB extends MultiTableDatabase { + /// Creates a new database with the given [tables]. + factory NeonDataDB({ + Iterable
? tables, + }) { + return NeonDataDB._( + tables: [ + ...?tables, + SQLiteCachedPersistence.table, + ], + ); + } + + NeonDataDB._({ + required super.tables, + }); + + @override + String get name => 'preferences'; + + @override + Future get path async { + final cacheDir = await getApplicationSupportDirectory(); + + return buildDatabasePath(cacheDir.path, name); + } +} diff --git a/packages/neon_framework/lib/src/storage/settings_store.dart b/packages/neon_framework/lib/src/storage/settings_store.dart index 42fe1f4ad69..396863502c6 100644 --- a/packages/neon_framework/lib/src/storage/settings_store.dart +++ b/packages/neon_framework/lib/src/storage/settings_store.dart @@ -1,41 +1,7 @@ // ignore_for_file: avoid_positional_boolean_parameters import 'package:meta/meta.dart'; -import 'package:neon_framework/src/storage/persistence.dart'; - -/// A storage that can save a group of values primarily used by `Option`s. -/// -/// Mimics a subset of the `SharedPreferences` interface to synchronously -/// access data while persisting changes in the background. -/// -/// See: -/// * `NeonStorage` to initialize and manage different storage backends. -abstract interface class SettingsStore { - /// The id that uniquely identifies this app storage. - /// - /// Used in `Exportable` classes. - String get id; - - /// Reads a value from persistent storage, throwing an `Exception` if it is - /// not a `String`. - String? getString(String key); - - /// Saves a `String` [value] to persistent storage in the background. - Future setString(String key, String value); - - /// Reads a value from persistent storage, throwing an `Exception` if it is - /// not a `bool`. - bool? getBool(String key); - - /// Saves a `bool` [value] to persistent storage in the background. - Future setBool(String key, bool value); - - /// Removes an entry from persistent storage. - Future remove(String key); - - /// Returns all keys in the persistent storage. - List keys(); -} +import 'package:neon_framework/src/storage/storage.dart'; /// Default implementation of the [SettingsStore] backed by the given [persistence]. @immutable diff --git a/packages/neon_framework/lib/src/storage/single_value_store.dart b/packages/neon_framework/lib/src/storage/single_value_store.dart index ae3830e91e4..316a27bddad 100644 --- a/packages/neon_framework/lib/src/storage/single_value_store.dart +++ b/packages/neon_framework/lib/src/storage/single_value_store.dart @@ -2,47 +2,7 @@ import 'package:built_collection/built_collection.dart'; import 'package:meta/meta.dart'; -import 'package:neon_framework/src/storage/keys.dart'; -import 'package:neon_framework/src/storage/persistence.dart'; - -/// A storage that itself is a single entry of a key value store. -/// -/// Mimics a subset of the `SharedPreferences` interface to synchronously -/// access a single value while persisting changes in the background. -/// -/// See: -/// * `NeonStorage` to initialize and manage different storage backends. -abstract interface class SingleValueStore { - /// The key used by the storage backend. - StorageKeys get key; - - /// Returns true if the persistent storage contains a value at the given [key]. - bool hasValue(); - - /// Reads a value from persistent storage, throwing an `Exception` if it is - /// not a `String`. - String? getString(); - - /// Saves a `String` [value] to persistent storage in the background. - Future setString(String value); - - /// Reads a value from persistent storage, throwing an `Exception` if it is - /// not a `bool`. - bool? getBool(); - - /// Saves a `bool` [value] to persistent storage in the background. - Future setBool(bool value); - - /// Removes an entry from persistent storage. - Future remove(); - - /// Reads a set of string values from persistent storage, throwing an - /// `Exception` if it's not a `String` collection. - BuiltList? getStringList(); - - /// Saves a list of strings [value] to persistent storage in the background. - Future setStringList(BuiltList value); -} +import 'package:neon_framework/src/storage/storage.dart'; /// Default implementation of the [SingleValueStore] backed by the given [persistence]. @immutable diff --git a/packages/neon_framework/lib/src/storage/storage.dart b/packages/neon_framework/lib/src/storage/storage.dart new file mode 100644 index 00000000000..7e9c2e64767 --- /dev/null +++ b/packages/neon_framework/lib/src/storage/storage.dart @@ -0,0 +1,7 @@ +export 'package:neon_framework/src/storage/neon_cache_db.dart'; +export 'package:neon_framework/src/storage/neon_data_db.dart'; +export 'package:neon_framework/src/storage/settings_store.dart'; +export 'package:neon_framework/src/storage/single_value_store.dart'; +export 'package:neon_framework/src/storage/storage_keys.dart'; +export 'package:neon_framework/src/storage/storage_manager.dart'; +export 'package:neon_framework/storage.dart'; diff --git a/packages/neon_framework/lib/src/storage/keys.dart b/packages/neon_framework/lib/src/storage/storage_keys.dart similarity index 77% rename from packages/neon_framework/lib/src/storage/keys.dart rename to packages/neon_framework/lib/src/storage/storage_keys.dart index cf80c4c48bd..81f31cc792c 100644 --- a/packages/neon_framework/lib/src/storage/keys.dart +++ b/packages/neon_framework/lib/src/storage/storage_keys.dart @@ -1,17 +1,8 @@ -import 'package:meta/meta.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; -} +import 'package:neon_framework/src/storage/storage.dart'; /// 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'), diff --git a/packages/neon_framework/lib/src/storage/storage_manager.dart b/packages/neon_framework/lib/src/storage/storage_manager.dart index 10844c2e22e..d76f926429c 100644 --- a/packages/neon_framework/lib/src/storage/storage_manager.dart +++ b/packages/neon_framework/lib/src/storage/storage_manager.dart @@ -1,14 +1,8 @@ import 'dart:async'; -import 'package:cookie_store/cookie_store.dart'; -import 'package:flutter/foundation.dart'; 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:neon_framework/src/storage/sqlite_cookie_persistence.dart'; -import 'package:neon_framework/src/storage/sqlite_persistence.dart'; +import 'package:neon_framework/src/storage/storage.dart'; +import 'package:neon_storage/neon_sqlite.dart'; /// Neon storage that manages the storage backend. /// @@ -40,32 +34,31 @@ class NeonStorage { /// Sets the individual storages. /// /// Required to be called before accessing any individual one. - Future init() async { + Future init({ + Iterable
? cacheTables, + Iterable
? dataTables, + }) async { if (_initialized) { return; } - if (!kIsWeb) { - final requestCache = DefaultRequestCache(); - await requestCache.init(); - _requestCache = requestCache; + _neonCache = NeonCacheDB(tables: cacheTables); + _neonData = NeonDataDB(tables: dataTables); - await SQLiteCookiePersistence.init(); - } - - await SQLiteCachedPersistence.init(); + await _neonCache.init(); + await _neonData.init(); _initialized = true; } - /// Request cache instance. - RequestCache? _requestCache; + late NeonCacheDB _neonCache; + late NeonDataDB _neonData; /// The current request cache if available. RequestCache? get requestCache { _assertInitialized(); - return _requestCache; + return _neonCache.requestCache; } /// Initializes a new `SettingsStorage`. @@ -97,22 +90,6 @@ class NeonStorage { return DefaultSingleValueStore(storage, key); } - /// Creates a new `CookieStore` scoped to the given [accountID] and [serverURL]. - /// - /// Cookies will only be sent to cookies matching the [serverURL]. - CookieStore? cookieStore({required String accountID, required Uri serverURL}) { - if (kIsWeb) { - return null; - } - - final persistence = SQLiteCookiePersistence( - accountID: accountID, - allowedBaseUri: serverURL, - ); - - return DefaultCookieStore(persistence); - } - void _assertInitialized() { if (!_initialized) { throw StateError( diff --git a/packages/neon_framework/lib/src/testing/mocks.dart b/packages/neon_framework/lib/src/testing/mocks.dart index 299d7363e64..91eae2f296f 100644 --- a/packages/neon_framework/lib/src/testing/mocks.dart +++ b/packages/neon_framework/lib/src/testing/mocks.dart @@ -84,15 +84,8 @@ class FakeNeonStorage extends Fake implements NeonStorage { @override Null get requestCache => null; - - @override - Null cookieStore({required String accountID, required Uri serverURL}) => null; } -class MockCachedPersistence extends Mock implements CachedPersistence {} - -class MockPersistence extends Mock implements Persistence {} - class MockSettingsStore extends Mock implements SettingsStore {} class MockSharedPreferencesPlatform extends Mock implements SharedPreferencesStorePlatform { diff --git a/packages/neon_framework/lib/src/utils/global_options.dart b/packages/neon_framework/lib/src/utils/global_options.dart index a844099185a..e9d49954562 100644 --- a/packages/neon_framework/lib/src/utils/global_options.dart +++ b/packages/neon_framework/lib/src/utils/global_options.dart @@ -5,7 +5,6 @@ import 'package:neon_framework/l10n/localizations.dart'; import 'package:neon_framework/models.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/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'; diff --git a/packages/neon_framework/lib/src/utils/push_utils.dart b/packages/neon_framework/lib/src/utils/push_utils.dart index 9ee530d57d4..bbb0b56100a 100644 --- a/packages/neon_framework/lib/src/utils/push_utils.dart +++ b/packages/neon_framework/lib/src/utils/push_utils.dart @@ -1,11 +1,11 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:typed_data'; import 'dart:ui'; import 'package:account_repository/account_repository.dart'; import 'package:crypto/crypto.dart'; import 'package:crypton/crypton.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_svg/flutter_svg.dart' show SvgBytesLoader, vg; @@ -15,7 +15,6 @@ import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; import 'package:neon_framework/src/bloc/result.dart'; import 'package:neon_framework/src/models/push_notification.dart'; -import 'package:neon_framework/src/storage/keys.dart'; import 'package:neon_framework/src/theme/colors.dart'; import 'package:neon_framework/src/utils/account_client_extension.dart'; import 'package:neon_framework/src/utils/image_utils.dart'; @@ -95,8 +94,13 @@ class PushUtils { } }, ); - await NeonStorage().init(); + final accountStorage = AccountStorage(); + await NeonStorage().init( + dataTables: [ + accountStorage, + ], + ); final keypair = loadRSAKeypair(); for (final message in Uri(query: utf8.decode(messages)).queryParameters.values) { final data = json.decode(message) as Map; @@ -116,14 +120,11 @@ class PushUtils { } else { final localizations = await appLocalizationsFromSystem(); final packageInfo = await PackageInfo.fromPlatform(); - final accountStorage = AccountStorage( - accountsPersistence: NeonStorage().singleValueStore(StorageKeys.accounts), - lastAccountPersistence: NeonStorage().singleValueStore(StorageKeys.lastUsedAccount), - ); final accountRepository = AccountRepository( userAgent: buildUserAgent(packageInfo), httpClient: http.Client(), storage: accountStorage, + enableCookieStore: !kIsWeb, ); await accountRepository.loadAccounts(); diff --git a/packages/neon_framework/lib/src/utils/request_manager.dart b/packages/neon_framework/lib/src/utils/request_manager.dart index e39e7101fe5..f5d2c17bbd3 100644 --- a/packages/neon_framework/lib/src/utils/request_manager.dart +++ b/packages/neon_framework/lib/src/utils/request_manager.dart @@ -85,7 +85,7 @@ class RequestManager { var request = getRequest(); - final cachedResponse = await _cache?.get(account, request); + final cachedResponse = await _cache?.get(account.id, request); if (subject.isClosed) { return; } @@ -126,7 +126,7 @@ class RequestManager { if (newParameters.etag == etag) { unawaited( _cache?.updateHeaders( - account, + account.id, request, newHeaders, ), @@ -173,7 +173,7 @@ class RequestManager { subject.add(Result.success(unwrap(converter.convert(response)))); await _cache?.set( - account, + account.id, request, response, ); diff --git a/packages/neon_framework/lib/storage.dart b/packages/neon_framework/lib/storage.dart index 3288cd61612..c62500f7c99 100644 --- a/packages/neon_framework/lib/storage.dart +++ b/packages/neon_framework/lib/storage.dart @@ -3,9 +3,5 @@ /// The `NeonStorage` manages all storage backends. library; -export 'package:neon_framework/src/storage/keys.dart' show Storable; -export 'package:neon_framework/src/storage/persistence.dart'; -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'; +export 'package:neon_framework/src/storage/storage.dart' show NeonStorage, StorageKeys; +export 'package:neon_storage/neon_storage.dart'; diff --git a/packages/neon_framework/packages/account_repository/lib/src/account_repository.dart b/packages/neon_framework/packages/account_repository/lib/src/account_repository.dart index a8bdc465372..360a11e16b1 100644 --- a/packages/neon_framework/packages/account_repository/lib/src/account_repository.dart +++ b/packages/neon_framework/packages/account_repository/lib/src/account_repository.dart @@ -1,16 +1,17 @@ import 'dart:async'; -import 'dart:convert'; import 'package:account_repository/src/models/models.dart'; import 'package:account_repository/src/utils/utils.dart'; import 'package:built_collection/built_collection.dart'; import 'package:equatable/equatable.dart'; import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; -import 'package:neon_framework/storage.dart'; +import 'package:neon_storage/neon_sqlite.dart'; import 'package:nextcloud/core.dart' as core; import 'package:nextcloud/nextcloud.dart'; import 'package:rxdart/rxdart.dart'; +import 'package:sqflite_common/sqlite_api.dart'; part 'account_storage.dart'; @@ -79,13 +80,16 @@ class AccountRepository { required String userAgent, required http.Client httpClient, required AccountStorage storage, + required bool enableCookieStore, }) : _userAgent = userAgent, _httpClient = httpClient, - _storage = storage; + _storage = storage, + _enableCookieStore = enableCookieStore; final String _userAgent; final http.Client _httpClient; final AccountStorage _storage; + final bool _enableCookieStore; final BehaviorSubject<({String? active, BuiltMap accounts})> _accounts = BehaviorSubject.seeded((active: null, accounts: BuiltMap())); @@ -134,6 +138,7 @@ class AccountRepository { httpClient: _httpClient, userAgent: _userAgent, credentials: credentials, + enableCookieStore: _enableCookieStore, ); }); } @@ -238,6 +243,7 @@ class AccountRepository { credentials: credentials, userAgent: _userAgent, httpClient: _httpClient, + enableCookieStore: _enableCookieStore, ); try { @@ -267,8 +273,8 @@ class AccountRepository { _accounts.add((active: active, accounts: accounts)); await Future.wait([ - _storage.saveCredentials(accounts.values.map((e) => e.credentials)), - _storage.saveLastAccount(active), + _storage.addCredentials(account.credentials), + _storage.saveLastAccount(account.credentials), ]); } @@ -288,13 +294,16 @@ class AccountRepository { } var active = value.active; - if (active == accountID) { + if (value.active == accountID) { active = accounts.keys.firstOrNull; } await Future.wait([ - _storage.saveCredentials(accounts.values.map((e) => e.credentials)), - _storage.saveLastAccount(active), + _storage.removeCredentials(account!.credentials), + if (active != null) + _storage.saveLastAccount( + accountByID(active)!.credentials, + ), ]); _accounts.add((active: active, accounts: accounts)); @@ -324,6 +333,7 @@ class AccountRepository { } _accounts.add((active: accountID, accounts: value.accounts)); - await _storage.saveLastAccount(accountID); + final active = value.accounts[accountID]!; + await _storage.saveLastAccount(active.credentials); } } diff --git a/packages/neon_framework/packages/account_repository/lib/src/account_storage.dart b/packages/neon_framework/packages/account_repository/lib/src/account_storage.dart index 4be66f4c555..af31ee8f88d 100644 --- a/packages/neon_framework/packages/account_repository/lib/src/account_storage.dart +++ b/packages/neon_framework/packages/account_repository/lib/src/account_storage.dart @@ -3,52 +3,205 @@ part of 'account_repository.dart'; /// {@template account_repository_storage} /// Storage for the [AccountRepository]. /// {@endtemplate} -@immutable -class AccountStorage { +class AccountStorage with Table { /// {@macro account_repository_storage} - const AccountStorage({ - required this.accountsPersistence, - required this.lastAccountPersistence, - }); + AccountStorage(); - /// The store for the account list. - final SingleValueStore accountsPersistence; + @override + String get name => 'account_credentials'; - /// The store for the last used account. - final SingleValueStore lastAccountPersistence; + @override + int get version => 1; + + /// Column for the [Credentials.serverURL]. + @protected + static const String serverURL = 'server_urlL'; + + /// Column for the [Credentials.username]. + @protected + static const String loginName = 'login_name'; + + /// Column for the [Credentials.appPassword]. + @protected + static const String appPassword = 'app_password'; + + /// Column to store the active account. + @protected + static const String active = 'active'; + + @override + void onCreate(Batch db, int version) { + db.execute(''' +CREATE TABLE "$name" ( + "$serverURL" TEXT NOT NULL, + "$loginName" TEXT NOT NULL, + "$appPassword" TEXT, + "$active" BOOLEAN NOT NULL DEFAULT 0, + PRIMARY KEY("$serverURL", "$loginName") +); +'''); + } + + static final Logger _log = Logger('AccountStorage'); + + Database get _database => controller.database; /// Gets a list of logged in credentials from storage. /// /// It is not checked whether the stored information is still valid. Future> readCredentials() async { - if (accountsPersistence.hasValue()) { - return accountsPersistence - .getStringList()! - .map((a) => Credentials.fromJson(json.decode(a) as Map)) - .toBuiltList(); + try { + final results = await _database.query( + name, + columns: [ + serverURL, + loginName, + appPassword, + ], + ); + + final credentials = ListBuilder(); + + for (final result in results) { + credentials.add( + Credentials((b) { + b + ..serverURL = Uri.parse(result[serverURL]! as String) + ..username = result[loginName]! as String + ..appPassword = result[appPassword] as String?; + }), + ); + } + + return credentials.build(); + } on DatabaseException catch (error, stackTrace) { + _log.warning( + 'Error loading cookies.', + error, + stackTrace, + ); } return BuiltList(); } - /// Saves the given [credentials] to the storage. - Future saveCredentials(Iterable credentials) async { - final values = credentials.map((a) => json.encode(a.toJson())).toBuiltList(); + /// Persists the given [credentials] on disk. + Future addCredentials(Credentials credentials) 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 = _database.batch() + ..update( + name, + { + serverURL: credentials.serverURL.toString(), + loginName: credentials.username, + appPassword: credentials.appPassword, + }, + where: '$serverURL = ? AND $loginName = ?', + whereArgs: [ + credentials.serverURL.toString(), + credentials.username, + ], + ) + ..rawInsert( + ''' +INSERT INTO $name ($serverURL, $loginName, $appPassword) +SELECT ?, ?, ? +WHERE (SELECT changes() = 0) +''', + [ + credentials.serverURL.toString(), + credentials.username, + credentials.appPassword, + ], + ); + await batch.commit(noResult: true); + } on DatabaseException catch (error, stackTrace) { + _log.warning( + 'Error loading cookies.', + error, + stackTrace, + ); + } + } - await accountsPersistence.setStringList(values); + /// Removes the given [credentials] from storage. + Future removeCredentials(Credentials credentials) async { + try { + await _database.delete( + name, + where: '$serverURL = ? AND $loginName = ?', + whereArgs: [ + credentials.serverURL.toString(), + credentials.username, + ], + ); + } on DatabaseException catch (error, stackTrace) { + _log.warning( + 'Error loading cookies.', + error, + stackTrace, + ); + } } /// Retrieves the id of the last used account. Future readLastAccount() async { - return lastAccountPersistence.getString(); + try { + final results = await _database.query( + name, + where: '$active = 1', + columns: [ + serverURL, + loginName, + ], + ); + + if (results.isNotEmpty) { + final result = results.single; + return Credentials((b) { + b + ..serverURL = Uri.parse(result[serverURL]! as String) + ..username = result[loginName]! as String; + }).id; + } + } on DatabaseException catch (error, stackTrace) { + _log.warning( + 'Error loading cookies.', + error, + stackTrace, + ); + } + + return null; } - /// Sets the last used account to the given [accountID]. - Future saveLastAccount(String? accountID) async { - if (accountID == null) { - await lastAccountPersistence.remove(); - } else { - await lastAccountPersistence.setString(accountID); + /// Sets the last used account to the given [credentials]. + Future saveLastAccount(Credentials credentials) async { + try { + final batch = _database.batch() + ..update( + name, + {active: 0}, + where: '$active = 1', + ) + ..update( + name, + {active: 1}, + where: '$serverURL = ? AND $loginName = ?', + whereArgs: [ + credentials.serverURL.toString(), + credentials.username, + ], + ); + await batch.commit(noResult: true); + } on DatabaseException catch (error, stackTrace) { + _log.warning( + 'Error loading cookies.', + error, + stackTrace, + ); } } } diff --git a/packages/neon_framework/packages/account_repository/lib/src/testing/testing_account.dart b/packages/neon_framework/packages/account_repository/lib/src/testing/testing_account.dart index f9546990689..9f8eaffae75 100644 --- a/packages/neon_framework/packages/account_repository/lib/src/testing/testing_account.dart +++ b/packages/neon_framework/packages/account_repository/lib/src/testing/testing_account.dart @@ -27,6 +27,7 @@ Account createAccount({ httpClient: httpClient ?? _mockClient, userAgent: 'neon', credentials: credentials, + enableCookieStore: false, ); }); } diff --git a/packages/neon_framework/packages/account_repository/lib/src/utils/http_client_builder.dart b/packages/neon_framework/packages/account_repository/lib/src/utils/http_client_builder.dart index e40c0f5bb23..23a806b54ac 100644 --- a/packages/neon_framework/packages/account_repository/lib/src/utils/http_client_builder.dart +++ b/packages/neon_framework/packages/account_repository/lib/src/utils/http_client_builder.dart @@ -1,7 +1,8 @@ import 'package:account_repository/src/models/models.dart'; +import 'package:cookie_store/cookie_store.dart'; import 'package:http/http.dart' as http; -import 'package:neon_framework/storage.dart'; import 'package:neon_http_client/neon_http_client.dart'; +import 'package:neon_storage/neon_storage.dart'; import 'package:nextcloud/nextcloud.dart'; /// Builds a [NextcloudClient] authenticated with the given [credentials]. @@ -9,11 +10,17 @@ NextcloudClient buildClient({ required http.Client httpClient, required String userAgent, required Credentials credentials, + bool enableCookieStore = true, }) { - final cookieStore = NeonStorage().cookieStore( - accountID: credentials.id, - serverURL: credentials.serverURL, - ); + CookieStore? cookieStore; + if (enableCookieStore) { + final persistence = SQLiteCookiePersistence( + accountID: credentials.id, + allowedBaseUri: credentials.serverURL, + ); + + cookieStore = DefaultCookieStore(persistence); + } final neonHttpClient = NeonHttpClient( cookieStore: cookieStore, diff --git a/packages/neon_framework/packages/account_repository/pubspec.yaml b/packages/neon_framework/packages/account_repository/pubspec.yaml index 34b25ff923c..d45190a32e1 100644 --- a/packages/neon_framework/packages/account_repository/pubspec.yaml +++ b/packages/neon_framework/packages/account_repository/pubspec.yaml @@ -5,32 +5,35 @@ publish_to: none environment: sdk: ^3.0.0 - flutter: ^3.22.0 dependencies: built_collection: ^5.1.1 built_value: ^8.9.2 + cookie_store: + git: + url: https://github.com/nextcloud/neon + path: packages/cookie_store crypto: ^3.0.3 equatable: ^2.0.5 http: ^1.2.2 + logging: ^1.2.0 meta: ^1.0.0 - neon_framework: - git: - url: https://github.com/nextcloud/neon - path: packages/neon_framework neon_http_client: git: url: https://github.com/nextcloud/neon path: packages/neon_framework/packages/neon_http_client + neon_storage: + git: + url: https://github.com/nextcloud/neon + path: packages/neon_framework/packages/neon_storage nextcloud: ^8.0.0 rxdart: ^0.28.0 + sqflite_common: ^2.0.0 dev_dependencies: build_runner: ^2.4.12 built_value_generator: ^8.9.2 built_value_test: ^8.9.2 - flutter: - sdk: flutter mocktail: ^1.0.4 neon_lints: git: diff --git a/packages/neon_framework/packages/account_repository/pubspec_overrides.yaml b/packages/neon_framework/packages/account_repository/pubspec_overrides.yaml index 5c2f161f46d..57c834f8078 100644 --- a/packages/neon_framework/packages/account_repository/pubspec_overrides.yaml +++ b/packages/neon_framework/packages/account_repository/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,nextcloud,sort_box +# melos_managed_dependency_overrides: cookie_store,dynamite_runtime,interceptor_http_client,neon_http_client,neon_lints,neon_storage,nextcloud dependency_overrides: cookie_store: path: ../../../cookie_store @@ -6,13 +6,11 @@ dependency_overrides: path: ../../../dynamite/packages/dynamite_runtime interceptor_http_client: path: ../../../interceptor_http_client - neon_framework: - path: ../.. neon_http_client: path: ../neon_http_client neon_lints: path: ../../../neon_lints + neon_storage: + path: ../neon_storage nextcloud: path: ../../../nextcloud - sort_box: - path: ../sort_box diff --git a/packages/neon_framework/packages/account_repository/test/account_repository_test.dart b/packages/neon_framework/packages/account_repository/test/account_repository_test.dart index 3a2c0c0a647..5e8e98b4e3d 100644 --- a/packages/neon_framework/packages/account_repository/test/account_repository_test.dart +++ b/packages/neon_framework/packages/account_repository/test/account_repository_test.dart @@ -7,7 +7,6 @@ import 'package:built_collection/built_collection.dart'; import 'package:built_value_test/matcher.dart'; import 'package:http/http.dart' as http; import 'package:mocktail/mocktail.dart'; -import 'package:neon_framework/testing.dart' show MockNeonStorage; import 'package:nextcloud/core.dart' as core; import 'package:nextcloud/nextcloud.dart'; import 'package:nextcloud/provisioning_api.dart' as provisioning_api; @@ -19,6 +18,8 @@ class _FakeUri extends Fake implements Uri {} class _FakeClient extends Fake implements http.Client {} +class _FakeCredentials extends Fake implements Credentials {} + class _FakePollRequest extends Fake implements core.ClientFlowLoginV2PollRequestApplicationJson {} class _DynamiteResponseMock extends Mock implements DynamiteResponse {} @@ -54,7 +55,7 @@ void main() { setUpAll(() { registerFallbackValue(_FakeUri()); registerFallbackValue(_FakePollRequest()); - MockNeonStorage(); + registerFallbackValue(_FakeCredentials()); }); setUp(() { @@ -76,6 +77,7 @@ void main() { userAgent: 'userAgent', httpClient: _FakeClient(), storage: storage, + enableCookieStore: false, ); }); @@ -467,7 +469,7 @@ void main() { group('logIn', () { setUp(() async { - when(() => storage.saveCredentials(any())).thenAnswer((_) async => {}); + when(() => storage.addCredentials(any())).thenAnswer((_) async => {}); when(() => storage.saveLastAccount(any())).thenAnswer((_) async => {}); }); @@ -486,8 +488,8 @@ void main() { .having((e) => e.active, 'active', equals(account)), ), ); - verify(() => storage.saveCredentials(any(that: contains(account.credentials)))).called(1); - verify(() => storage.saveLastAccount(account.id)).called(1); + verify(() => storage.addCredentials(account.credentials)).called(1); + verify(() => storage.saveLastAccount(account.credentials)).called(1); }); }); @@ -495,7 +497,7 @@ void main() { setUp(() async { when(() => storage.readCredentials()).thenAnswer((_) async => credentialsList); when(() => storage.saveLastAccount(any())).thenAnswer((_) async => {}); - when(() => storage.saveCredentials(any())).thenAnswer((_) async => {}); + when(() => storage.removeCredentials(any())).thenAnswer((_) async => {}); await repository.loadAccounts(); @@ -518,8 +520,8 @@ void main() { ); verify(() => appPassword.deleteAppPassword()).called(1); - verify(() => storage.saveLastAccount(credentialsList[1].id)).called(1); - verify(() => storage.saveCredentials(any(that: equals([credentialsList[1]])))).called(1); + verify(() => storage.saveLastAccount(credentialsList[1])).called(1); + verify(() => storage.removeCredentials(credentialsList[0])).called(1); }); test('tries to remove invalid account', () async { @@ -532,7 +534,7 @@ void main() { verifyNever(() => appPassword.deleteAppPassword()); verifyNever(() => storage.saveLastAccount(any())); - verifyNever(() => storage.saveCredentials(any())); + verifyNever(() => storage.removeCredentials(any())); }); test('rethrows http exceptions as `DeleteCredentialsFailure`', () async { @@ -553,8 +555,8 @@ void main() { ); verify(() => appPassword.deleteAppPassword()).called(1); - verify(() => storage.saveLastAccount(credentialsList[1].id)).called(1); - verify(() => storage.saveCredentials(any(that: equals([credentialsList[1]])))).called(1); + verify(() => storage.saveLastAccount(credentialsList[1])).called(1); + verify(() => storage.removeCredentials(credentialsList[0])).called(1); }); }); @@ -595,7 +597,7 @@ void main() { ), ); - verify(() => storage.saveLastAccount(credentialsList[1].id)).called(1); + verify(() => storage.saveLastAccount(credentialsList[1])).called(1); }); }); }); diff --git a/packages/neon_framework/packages/account_repository/test/account_storage_test.dart b/packages/neon_framework/packages/account_repository/test/account_storage_test.dart index 91201c2ab68..f20bf45077e 100644 --- a/packages/neon_framework/packages/account_repository/test/account_storage_test.dart +++ b/packages/neon_framework/packages/account_repository/test/account_storage_test.dart @@ -1,126 +1,131 @@ import 'package:account_repository/account_repository.dart'; -import 'package:built_collection/built_collection.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:neon_framework/storage.dart'; +import 'package:account_repository/src/testing/testing.dart'; +import 'package:neon_storage/neon_storage.dart'; +import 'package:neon_storage/testing.dart'; import 'package:test/test.dart'; -// ignore: avoid_implementing_value_types -class _FakeBuiltList extends Fake implements BuiltList {} - -class _SingleValueStoreMock extends Mock implements SingleValueStore {} - void main() { - late SingleValueStore accountsStore; - late SingleValueStore lastAccountStore; + late TestTableDatabase database; late AccountStorage storage; - setUp(() { - registerFallbackValue(_FakeBuiltList()); - - accountsStore = _SingleValueStoreMock(); - lastAccountStore = _SingleValueStoreMock(); + setUp(() async { + storage = AccountStorage(); + database = TestTableDatabase(storage); + await database.init(); + }); - storage = AccountStorage( - accountsPersistence: accountsStore, - lastAccountPersistence: lastAccountStore, - ); + tearDown(() async { + SQLiteCachedPersistence.globalCache.clear(); + await database.close(); }); - final credentialsList = [ - Credentials((b) { - b - ..serverURL = Uri.https('serverUrl') - ..username = 'username' - ..appPassword = 'appPassword'; - }), - Credentials((b) { - b - ..serverURL = Uri.https('other-serverUrl') - ..username = 'username' - ..appPassword = 'appPassword'; - }), - ]; - - final serializedCredentials = BuiltList([ - '{"serverURL":"https://serverurl","username":"username","appPassword":"appPassword"}', - '{"serverURL":"https://other-serverurl","username":"username","appPassword":"appPassword"}', - ]); + final credentials = createCredentials(); group('AccountStorage', () { group('readCredentials', () { test('returns empty list when no value is stored', () async { - when(() => accountsStore.hasValue()).thenReturn(false); - await expectLater( storage.readCredentials(), completion(isEmpty), ); + }); + + test('returns list with deserialized credentials', () async { + await storage.addCredentials(credentials); - verifyNever(() => accountsStore.getStringList()); + await expectLater( + storage.readCredentials(), + completion(equals([credentials])), + ); }); + }); - test('returns list with deserialized accounts', () async { - when(() => accountsStore.hasValue()).thenReturn(true); - when(() => accountsStore.getStringList()).thenReturn(serializedCredentials); + group('addCredentials', () { + test('updates credentials when already present', () async { + await storage.addCredentials(credentials); + await storage.addCredentials( + createCredentials(appPassword: 'new-appPassword'), + ); await expectLater( storage.readCredentials(), - completion(credentialsList), + completion( + equals([ + createCredentials(appPassword: 'new-appPassword'), + ]), + ), ); + }); - verify(() => accountsStore.getStringList()).called(1); + test('adds multiple accounts', () async { + await storage.addCredentials(credentials); + await storage.addCredentials( + createCredentials(username: 'new-username'), + ); + + await expectLater( + storage.readCredentials(), + completion( + equals([ + credentials, + createCredentials(username: 'new-username'), + ]), + ), + ); }); }); - group('saveCredentials', () { - test('persists accounts to storage', () async { - when(() => accountsStore.setStringList(any())).thenAnswer((_) async => true); - - await storage.saveCredentials(credentialsList); + group('removeCredentials', () { + setUp(() async { + await storage.addCredentials(credentials); + await storage.addCredentials( + createCredentials(username: 'new-username'), + ); + }); + test('deletes credentials', () async { + await storage.removeCredentials( + createCredentials(username: 'new-username'), + ); - verify(() => accountsStore.setStringList(any(that: equals(serializedCredentials)))).called(1); + await expectLater( + storage.readCredentials(), + completion(equals([credentials])), + ); }); }); group('readLastAccount', () { test('returns null when no value is stored', () async { - when(() => lastAccountStore.getString()).thenReturn(null); - await expectLater( storage.readLastAccount(), completion(isNull), ); - - verify(() => lastAccountStore.getString()).called(1); }); test('returns account id for the stored value', () async { - when(() => lastAccountStore.getString()).thenReturn('accountID'); + await storage.addCredentials(credentials); + await storage.saveLastAccount(credentials); await expectLater( storage.readLastAccount(), - completion('accountID'), + completion(credentials.id), ); - - verify(() => lastAccountStore.getString()).called(1); }); }); group('saveLastAccount', () { - test('persists account id to disk', () async { - when(() => lastAccountStore.setString(any())).thenAnswer((_) async => true); - - await storage.saveLastAccount('accountID'); - - verify(() => lastAccountStore.setString('accountID')).called(1); - }); + test('updates active account', () async { + await storage.addCredentials(credentials); + await storage.saveLastAccount(credentials); - test('deletes last account when id is null', () async { - when(() => lastAccountStore.remove()).thenAnswer((_) async => true); + final newCredentials = createCredentials(username: 'new-username'); + await storage.addCredentials(newCredentials); + await storage.saveLastAccount(newCredentials); - await storage.saveLastAccount(null); - - verify(() => lastAccountStore.remove()).called(1); + await expectLater( + storage.readLastAccount(), + completion(newCredentials.id), + ); }); }); }); diff --git a/packages/neon_framework/packages/account_repository/test/utils/http_client_builder_test.dart b/packages/neon_framework/packages/account_repository/test/utils/http_client_builder_test.dart index 80104f97edc..151b5828e33 100644 --- a/packages/neon_framework/packages/account_repository/test/utils/http_client_builder_test.dart +++ b/packages/neon_framework/packages/account_repository/test/utils/http_client_builder_test.dart @@ -2,7 +2,6 @@ import 'package:account_repository/src/models/models.dart'; import 'package:account_repository/src/utils/utils.dart'; import 'package:http/http.dart' as http; import 'package:mocktail/mocktail.dart'; -import 'package:neon_framework/storage.dart'; import 'package:neon_http_client/neon_http_client.dart'; import 'package:nextcloud/nextcloud.dart'; import 'package:test/test.dart'; @@ -11,59 +10,69 @@ class _FakeClient extends Fake implements http.Client {} class _FakeUri extends Fake implements Uri {} -// ignore: subtype_of_sealed_class -class _NeonStorageMock extends Mock implements NeonStorage {} - void main() { final httpClient = _FakeClient(); const userAgent = 'neon'; - late NeonStorage storageMock; setUpAll(() { registerFallbackValue(_FakeUri()); - - storageMock = _NeonStorageMock(); - NeonStorage.mocked(storageMock); - when( - () => storageMock.cookieStore( - accountID: any(named: 'accountID'), - serverURL: any(named: 'serverURL'), - ), - ).thenReturn(null); }); - test('buildClient', () { - final credentials = Credentials((b) { - b - ..username = 'username' - ..appPassword = 'appPassword' - ..serverURL = Uri.https('serverURL'); + group('buildClient', () { + test('builds client without cookie store', () { + final credentials = Credentials((b) { + b + ..username = 'username' + ..appPassword = 'appPassword' + ..serverURL = Uri.https('serverURL'); + }); + + final client = buildClient( + httpClient: httpClient, + userAgent: userAgent, + credentials: credentials, + enableCookieStore: false, + ); + + expect( + client, + isA() + .having((c) => c.authentications, 'not empty authentications', isNotEmpty) + .having((c) => c.baseURL, 'baseURL', equals(Uri.https('serverURL'))) + .having( + (c) => c.httpClient, + 'httpClient', + isA().having((c) => c.interceptors, 'interceptors', hasLength(3)), + ), + ); }); - final client = buildClient( - httpClient: httpClient, - userAgent: userAgent, - credentials: credentials, - ); + test('build client with cookie store', () { + final credentials = Credentials((b) { + b + ..username = 'username' + ..appPassword = 'appPassword' + ..serverURL = Uri.https('serverURL'); + }); - expect( - client, - isA() - .having((c) => c.authentications, 'not empty authentications', isNotEmpty) - .having((c) => c.baseURL, 'baseURL', equals(Uri.https('serverURL'))) - .having( - (c) => c.httpClient, - 'httpClient', - isA().having((c) => c.interceptors, 'interceptors', hasLength(3)), - ), - ); + final client = buildClient( + httpClient: httpClient, + userAgent: userAgent, + credentials: credentials, + ); - verify( - () => storageMock.cookieStore( - accountID: any(named: 'accountID', that: equals(credentials.id)), - serverURL: any(named: 'serverURL', that: equals(Uri.https('serverURL'))), - ), - ).called(1); + expect( + client, + isA() + .having((c) => c.authentications, 'not empty authentications', isNotEmpty) + .having((c) => c.baseURL, 'baseURL', equals(Uri.https('serverURL'))) + .having( + (c) => c.httpClient, + 'httpClient', + isA().having((c) => c.interceptors, 'interceptors', hasLength(4)), + ), + ); + }); }); test('buildUnauthenticatedClient', () { @@ -84,12 +93,5 @@ void main() { isA().having((c) => c.interceptors, 'interceptors', hasLength(3)), ), ); - - verifyNever( - () => storageMock.cookieStore( - accountID: any(named: 'accountID'), - serverURL: any(named: 'serverURL'), - ), - ); }); } diff --git a/packages/neon_framework/packages/dashboard_app/pubspec_overrides.yaml b/packages/neon_framework/packages/dashboard_app/pubspec_overrides.yaml index 0abe46d2bdd..c24ede0eb8c 100644 --- a/packages/neon_framework/packages/dashboard_app/pubspec_overrides.yaml +++ b/packages/neon_framework/packages/dashboard_app/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: account_repository,cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,nextcloud,sort_box +# melos_managed_dependency_overrides: account_repository,cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,neon_storage,nextcloud,sort_box dependency_overrides: account_repository: path: ../account_repository @@ -14,6 +14,8 @@ dependency_overrides: path: ../neon_http_client neon_lints: path: ../../../neon_lints + neon_storage: + path: ../neon_storage nextcloud: path: ../../../nextcloud sort_box: diff --git a/packages/neon_framework/packages/files_app/pubspec_overrides.yaml b/packages/neon_framework/packages/files_app/pubspec_overrides.yaml index 0abe46d2bdd..c24ede0eb8c 100644 --- a/packages/neon_framework/packages/files_app/pubspec_overrides.yaml +++ b/packages/neon_framework/packages/files_app/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: account_repository,cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,nextcloud,sort_box +# melos_managed_dependency_overrides: account_repository,cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,neon_storage,nextcloud,sort_box dependency_overrides: account_repository: path: ../account_repository @@ -14,6 +14,8 @@ dependency_overrides: path: ../neon_http_client neon_lints: path: ../../../neon_lints + neon_storage: + path: ../neon_storage nextcloud: path: ../../../nextcloud sort_box: diff --git a/packages/neon_framework/packages/neon_storage/analysis_options.yaml b/packages/neon_framework/packages/neon_storage/analysis_options.yaml index bff1b129f3c..f0a42286945 100644 --- a/packages/neon_framework/packages/neon_storage/analysis_options.yaml +++ b/packages/neon_framework/packages/neon_storage/analysis_options.yaml @@ -3,3 +3,4 @@ include: package:neon_lints/dart.yaml custom_lint: rules: - avoid_exports: false + - avoid_dart_io: false diff --git a/packages/neon_framework/packages/neon_storage/lib/neon_storage.dart b/packages/neon_framework/packages/neon_storage/lib/neon_storage.dart new file mode 100644 index 00000000000..be46547b403 --- /dev/null +++ b/packages/neon_framework/packages/neon_storage/lib/neon_storage.dart @@ -0,0 +1,5 @@ +/// Storage abstractions for the Neon Framework. +library; + +export 'src/interfaces/interfaces.dart'; +export 'src/storage/storage.dart' show SQLiteCachedPersistence, SQLiteCookiePersistence, SQLiteRequestCache; diff --git a/packages/neon_framework/lib/src/storage/persistence.dart b/packages/neon_framework/packages/neon_storage/lib/src/interfaces/cached_persistence.dart similarity index 68% rename from packages/neon_framework/lib/src/storage/persistence.dart rename to packages/neon_framework/packages/neon_storage/lib/src/interfaces/cached_persistence.dart index 1a1311c17f8..a8b13fc6b70 100644 --- a/packages/neon_framework/lib/src/storage/persistence.dart +++ b/packages/neon_framework/packages/neon_storage/lib/src/interfaces/cached_persistence.dart @@ -1,28 +1,7 @@ import 'dart:async'; -import 'package:meta/meta.dart' show protected; - -/// A persistent key value storage. -abstract interface class Persistence { - /// Whether a value exists at the given [key]. - FutureOr containsKey(String key); - - /// Returns all keys in the persistent storage. - FutureOr> keys(); - - /// Clears all values from persistent storage. - FutureOr clear(); - - /// Removes an entry from persistent storage. - FutureOr remove(String key); - - /// Saves a [value] to persistent storage. - FutureOr setValue(String key, T value); - - /// Fetches the value persisted at the given [key] from the persistent - /// storage. - FutureOr getValue(String key); -} +import 'package:meta/meta.dart'; +import 'package:neon_storage/neon_storage.dart'; /// A key value persistence that caches read values to be accessed /// synchronously. @@ -42,6 +21,7 @@ abstract class CachedPersistence implements Persistence { /// /// It is NOT guaranteed that this cache and the backing database will remain /// in sync since the setter method might fail for any reason. + @visibleForTesting @protected final Map cache = {}; diff --git a/packages/neon_framework/packages/neon_storage/lib/src/interfaces/interfaces.dart b/packages/neon_framework/packages/neon_storage/lib/src/interfaces/interfaces.dart new file mode 100644 index 00000000000..afda03ccf40 --- /dev/null +++ b/packages/neon_framework/packages/neon_storage/lib/src/interfaces/interfaces.dart @@ -0,0 +1,6 @@ +export 'cached_persistence.dart'; +export 'persistence_interface.dart'; +export 'request_cache_interface.dart'; +export 'settings_store_interface.dart'; +export 'single_value_store_interface.dart'; +export 'storable_interface.dart'; diff --git a/packages/neon_framework/packages/neon_storage/lib/src/interfaces/persistence_interface.dart b/packages/neon_framework/packages/neon_storage/lib/src/interfaces/persistence_interface.dart new file mode 100644 index 00000000000..eaca045b7b6 --- /dev/null +++ b/packages/neon_framework/packages/neon_storage/lib/src/interfaces/persistence_interface.dart @@ -0,0 +1,23 @@ +import 'dart:async'; + +/// A persistent key value storage. +abstract interface class Persistence { + /// Whether a value exists at the given [key]. + FutureOr containsKey(String key); + + /// Clears all values from persistent storage. + FutureOr clear(); + + /// Removes an entry from persistent storage. + FutureOr remove(String key); + + /// Saves a [value] to persistent storage. + FutureOr setValue(String key, T value); + + /// Fetches the value persisted at the given [key] from the persistent + /// storage. + FutureOr getValue(String key); + + /// Returns all keys in the persistent storage. + FutureOr> keys(); +} diff --git a/packages/neon_framework/packages/neon_storage/lib/src/interfaces/request_cache_interface.dart b/packages/neon_framework/packages/neon_storage/lib/src/interfaces/request_cache_interface.dart new file mode 100644 index 00000000000..1b7c1bd3fa2 --- /dev/null +++ b/packages/neon_framework/packages/neon_storage/lib/src/interfaces/request_cache_interface.dart @@ -0,0 +1,15 @@ +import 'package:http/http.dart' as http; + +/// A storage used to cache HTTP requests. +abstract interface class RequestCache { + /// Gets the cached status code, body and headers for the [request]. + Future get(String accountID, http.Request request); + + /// Sets the cached [response] for the [request]. + /// + /// If a request is already present it will be updated with the new one. + Future set(String accountID, http.Request request, http.Response response); + + /// Updates the cache [headers] for the [request] without modifying anything else. + Future updateHeaders(String accountID, http.Request request, Map headers); +} diff --git a/packages/neon_framework/packages/neon_storage/lib/src/interfaces/settings_store_interface.dart b/packages/neon_framework/packages/neon_storage/lib/src/interfaces/settings_store_interface.dart new file mode 100644 index 00000000000..3b43be68a47 --- /dev/null +++ b/packages/neon_framework/packages/neon_storage/lib/src/interfaces/settings_store_interface.dart @@ -0,0 +1,32 @@ +// ignore_for_file: avoid_positional_boolean_parameters + +/// A storage that can save a group of values primarily used by `Option`s. +/// +/// Mimics a subset of the `SharedPreferences` interface to synchronously +/// access data while persisting changes in the background. +abstract interface class SettingsStore { + /// The id that uniquely identifies this app storage. + /// + /// Used in `Exportable` classes. + String get id; + + /// Reads a value from persistent storage, throwing an `Exception` if it is + /// not a `String`. + String? getString(String key); + + /// Saves a `String` [value] to persistent storage in the background. + Future setString(String key, String value); + + /// Reads a value from persistent storage, throwing an `Exception` if it is + /// not a `bool`. + bool? getBool(String key); + + /// Saves a `bool` [value] to persistent storage in the background. + Future setBool(String key, bool value); + + /// Removes an entry from persistent storage. + Future remove(String key); + + /// Returns all keys in the persistent storage. + List keys(); +} diff --git a/packages/neon_framework/packages/neon_storage/lib/src/interfaces/single_value_store_interface.dart b/packages/neon_framework/packages/neon_storage/lib/src/interfaces/single_value_store_interface.dart new file mode 100644 index 00000000000..f7630bd31e1 --- /dev/null +++ b/packages/neon_framework/packages/neon_storage/lib/src/interfaces/single_value_store_interface.dart @@ -0,0 +1,43 @@ +// ignore_for_file: avoid_positional_boolean_parameters + +import 'package:built_collection/built_collection.dart'; +import 'package:neon_storage/neon_storage.dart'; + +/// A storage that itself is a single entry of a key value store. +/// +/// Mimics a subset of the `SharedPreferences` interface to synchronously +/// access a single value while persisting changes in the background. +/// +/// See: +/// * `NeonStorage` to initialize and manage different storage backends. +abstract interface class SingleValueStore { + /// The key used by the storage backend. + T get key; + + /// Returns true if the persistent storage contains a value at the given [key]. + bool hasValue(); + + /// Reads a value from persistent storage, throwing an `Exception` if it is + /// not a `String`. + String? getString(); + + /// Saves a `String` [value] to persistent storage in the background. + Future setString(String value); + + /// Reads a value from persistent storage, throwing an `Exception` if it is + /// not a `bool`. + bool? getBool(); + + /// Saves a `bool` [value] to persistent storage in the background. + Future setBool(bool value); + + /// Removes an entry from persistent storage. + Future remove(); + + /// Reads a set of string values from persistent storage, throwing an + /// `Exception` if it's not a `String` collection. + BuiltList? getStringList(); + + /// Saves a list of strings [value] to persistent storage in the background. + Future setStringList(BuiltList value); +} diff --git a/packages/neon_framework/packages/neon_storage/lib/src/interfaces/storable_interface.dart b/packages/neon_framework/packages/neon_storage/lib/src/interfaces/storable_interface.dart new file mode 100644 index 00000000000..e3f5ff3640c --- /dev/null +++ b/packages/neon_framework/packages/neon_storage/lib/src/interfaces/storable_interface.dart @@ -0,0 +1,7 @@ +/// 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; +} diff --git a/packages/neon_framework/packages/neon_storage/lib/src/sqlite/multi_table_database.dart b/packages/neon_framework/packages/neon_storage/lib/src/sqlite/multi_table_database.dart index 4d0e1c8993b..cc9eda707d8 100644 --- a/packages/neon_framework/packages/neon_storage/lib/src/sqlite/multi_table_database.dart +++ b/packages/neon_framework/packages/neon_storage/lib/src/sqlite/multi_table_database.dart @@ -102,11 +102,11 @@ abstract class MultiTableDatabase { for (final table in _tables) { table.controller = this; } + + _database = database; await Future.wait( _tables.map((t) => t.onOpen()), ); - - _database = database; } Future _createMetaTable(Database db, int version) async { diff --git a/packages/neon_framework/lib/src/storage/request_cache.dart b/packages/neon_framework/packages/neon_storage/lib/src/storage/request_cache.dart similarity index 58% rename from packages/neon_framework/lib/src/storage/request_cache.dart rename to packages/neon_framework/packages/neon_storage/lib/src/storage/request_cache.dart index 24f7a49db10..2fe66067b26 100644 --- a/packages/neon_framework/lib/src/storage/request_cache.dart +++ b/packages/neon_framework/packages/neon_storage/lib/src/storage/request_cache.dart @@ -4,81 +4,22 @@ import 'dart:typed_data'; import 'package:http/http.dart' as http; import 'package:logging/logging.dart'; -import 'package:meta/meta.dart'; -import 'package:neon_framework/models.dart'; -import 'package:neon_framework/src/platform/platform.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:neon_storage/neon_sqlite.dart'; +import 'package:neon_storage/neon_storage.dart'; +import 'package:sqflite_common/sqlite_api.dart'; final _log = Logger('RequestCache'); -/// A storage used to cache HTTP requests. -abstract interface class RequestCache { - /// Gets the cached status code, body and headers for the [request]. - Future get(Account account, http.Request request); - - /// Sets the cached [response] for the [request]. - /// - /// If a request is already present it will be updated with the new one. - Future set(Account account, http.Request request, http.Response response); - - /// Updates the cache [headers] for the [request] without modifying anything else. - Future updateHeaders(Account account, http.Request request, Map headers); -} - -/// Default implementation of the [RequestCache]. -/// -/// Values are persisted locally in an SQLite database in the application cache -/// directory. -/// -/// The database must be initialized with by calling `DefaultRequestCache().init()` -/// and awaiting it's completion. If the database is not yet initialized a -/// `StateError` will be thrown. -@internal -final class DefaultRequestCache implements RequestCache { - /// Creates a new request cache instance. - /// - /// There should be no need to create multiple instances. - DefaultRequestCache(); - - @visibleForTesting - Database? database; - - /// Initializes this request cache by setting up the backing SQLite database. - /// - /// This must called and completed before accessing other methods of the cache. - Future init() async { - if (database != null) { - return; - } +final class _SQLiteRequestCacheTable extends Table { + @override + String get name => 'request_cache'; - assert( - NeonPlatform.instance.canUsePaths, - 'Tried to initialize DefaultRequestCache on a platform without support for paths', - ); - final cacheDir = await getApplicationCacheDirectory(); - database = await openDatabase( - p.join(cacheDir.path, 'cache.db'), - version: 7, - onCreate: onCreate, - onUpgrade: (db, oldVersion, newVersion) async { - // We can safely drop the table as it only contains cached data. - // Non breaking migrations should not drop the cache. The next - // breaking change should remove all non breaking migrations before it. - await db.transaction((txn) async { - if (oldVersion <= 6) { - await txn.execute('DROP TABLE cache'); - await onCreate(txn); - } - }); - }, - ); - } + @override + int get version => 7; - @visibleForTesting - static Future onCreate(DatabaseExecutor db, [int? version]) async { - await db.execute(''' + @override + void onCreate(Batch db, int version) { + db.execute(''' CREATE TABLE "cache" ( "account" TEXT NOT NULL, "request_method" TEXT NOT NULL, @@ -92,23 +33,32 @@ CREATE TABLE "cache" ( ); '''); } +} - Database get _requireDatabase { - final database = this.database; - if (database == null) { - throw StateError( - 'Cache has not been set up yet. Please make sure DefaultRequestCache.init() has been called before and completed.', - ); - } +/// Default implementation of the [RequestCache]. +/// +/// Values are persisted locally in an SQLite table.database in the application cache +/// directory. +/// +/// The table.database must be initialized with by calling `DefaultRequestCache().init()` +/// and awaiting it's completion. If the table.database is not yet initialized a +/// `StateError` will be thrown. +final class SQLiteRequestCache implements RequestCache { + /// Creates a new request cache instance. + /// + /// There should be no need to create multiple instances. + const SQLiteRequestCache(); - return database; - } + /// The sqlite table backing this storage. + static final Table table = _SQLiteRequestCacheTable(); + + static Database get _database => table.controller.database; @override - Future get(Account account, http.Request request) async { + Future get(String accountID, http.Request request) async { List>? result; try { - result = await _requireDatabase.rawQuery( + result = await _database.rawQuery( ''' SELECT response_status_code, response_headers, @@ -121,7 +71,7 @@ WHERE account = ? AND request_body = ? ''', [ - account.id, + accountID, request.method.toUpperCase(), request.url.toString(), json.encode(sanitizeRequestHeaders(request.headers)), @@ -149,18 +99,18 @@ WHERE account = ? } @override - Future set(Account account, http.Request request, http.Response response) async { + Future set(String accountID, http.Request request, http.Response response) async { final encodedRequestHeaders = json.encode(sanitizeRequestHeaders(request.headers)); final encodedResponseHeaders = json.encode(response.headers); 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() + final batch = _database.batch() ..update( 'cache', { - 'account': account.id, + 'account': accountID, 'request_method': request.method.toUpperCase(), 'request_url': request.url.toString(), 'request_headers': encodedRequestHeaders, @@ -171,7 +121,7 @@ WHERE account = ? }, where: 'account = ? AND request_method = ? AND request_url = ? AND request_headers = ? AND request_body = ?', whereArgs: [ - account.id, + accountID, request.method.toUpperCase(), request.url.toString(), encodedRequestHeaders, @@ -194,7 +144,7 @@ SELECT ?, ?, ?, ?, ?, ?, ?, ? WHERE (SELECT changes() = 0) ''', [ - account.id, + accountID, request.method.toUpperCase(), request.url.toString(), encodedRequestHeaders, @@ -215,17 +165,17 @@ WHERE (SELECT changes() = 0) } @override - Future updateHeaders(Account account, http.Request request, Map headers) async { + Future updateHeaders(String accountID, http.Request request, Map headers) async { try { - await _requireDatabase.update( + await _database.update( 'cache', { - 'account': account.id, + 'account': accountID, 'response_headers': json.encode(headers), }, where: 'account = ? AND request_method = ? AND request_url = ? AND request_headers = ? AND request_body = ?', whereArgs: [ - account.id, + accountID, request.method.toUpperCase(), request.url.toString(), json.encode(sanitizeRequestHeaders(request.headers)), diff --git a/packages/neon_framework/lib/src/storage/sqlite_cookie_persistence.dart b/packages/neon_framework/packages/neon_storage/lib/src/storage/sqlite_cookie_persistence.dart similarity index 83% rename from packages/neon_framework/lib/src/storage/sqlite_cookie_persistence.dart rename to packages/neon_framework/packages/neon_storage/lib/src/storage/sqlite_cookie_persistence.dart index 0cc54189f32..435e0d2a988 100644 --- a/packages/neon_framework/lib/src/storage/sqlite_cookie_persistence.dart +++ b/packages/neon_framework/packages/neon_storage/lib/src/storage/sqlite_cookie_persistence.dart @@ -1,27 +1,65 @@ -import 'dart:async'; +import 'dart:io' show Cookie; import 'package:cookie_store/cookie_store.dart'; import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; -import 'package:neon_framework/platform.dart'; -import 'package:nextcloud/utils.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; -import 'package:sqflite/sqflite.dart'; -import 'package:universal_io/io.dart' show Cookie; +import 'package:neon_storage/neon_sqlite.dart'; +import 'package:nextcloud/utils.dart' show DateTimeUtils; +import 'package:sqflite_common/sqlite_api.dart'; + +final _log = Logger('SQLiteCookiePersistence'); const String _unsupportedCookieManagementMessage = 'The neon project does not plan to support individual cookie management.'; const String _lastAccessMaxDuration = "STRFTIME('%s', 'now', '-1 years')"; +final class _SQLiteCookiePersistenceTable extends Table { + @override + String get name => 'cookies'; + + @override + int get version => 1; + + @override + void onCreate(Batch db, int version) { + // https://httpwg.org/specs/rfc6265.html#storage-model with an extra account key. + db.execute(''' +CREATE TABLE "cookies" ( + "account" TEXT NOT NULL, + "name" TEXT NOT NULL, + "value" TEXT NOT NULL, + "expiry-time" INTEGER NOT NULL, + "domain" TEXT NOT NULL, + "path" TEXT NOT NULL, + "creation-time" INTEGER NOT NULL, + "last-access-time" INTEGER NOT NULL, + "persistent-flag" BOOLEAN NOT NULL, + "host-only-flag" BOOLEAN NOT NULL, + "secure-only-flag" BOOLEAN NOT NULL, + "http-only-flag" BOOLEAN NOT NULL, + PRIMARY KEY("account", "name", "domain", "path") +); + +CREATE TRIGGER cookies_delete_outdated + AFTER UPDATE + ON cookies +BEGIN + DELETE FROM cookies + WHERE + "expiry-time" <= STRFTIME('%s') + OR "last-access-time" <= $_lastAccessMaxDuration; +END; +'''); + } +} + /// An SQLite backed cookie persistence. /// /// No maximum cookie age is set as mentioned in [RFC6265 section 7.3](https://httpwg.org/specs/rfc6265.html#rfc.section.7.3). /// /// This persistence does not support cookie management through the [deleteAll] /// and [deleteWhere] methods. Calling them will throw a [UnsupportedError]. -@internal final class SQLiteCookiePersistence implements CookiePersistence { /// Creates a new SQLite backed cookie persistence for the given account. /// @@ -38,8 +76,6 @@ final class SQLiteCookiePersistence implements CookiePersistence { _ => allowedBaseUri, }; - final _log = Logger('SQLiteCookiePersistence'); - /// The allowed cookie base uri. /// /// When not null, only cookies domain and path matching this uri will be @@ -53,70 +89,10 @@ final class SQLiteCookiePersistence implements CookiePersistence { /// The id of the requesting account. final String accountID; - @visibleForTesting - static Database? database; + /// The sqlite table backing this storage. + static final Table table = _SQLiteCookiePersistenceTable(); - /// Initializes this cookie persistence by setting up the backing SQLite database. - /// - /// This must called and completed before accessing other methods of the cache. - static Future init() async { - if (database != null) { - return; - } - - assert( - NeonPlatform.instance.canUsePaths, - 'Tried to initialize SQLiteCookiePersistence on a platform without support for paths', - ); - final cacheDir = await getApplicationCacheDirectory(); - database = await openDatabase( - p.join(cacheDir.path, 'cookies.db'), - version: 1, - onCreate: onCreate, - ); - } - - @visibleForTesting - static Future onCreate(Database db, int version) async { - // https://httpwg.org/specs/rfc6265.html#storage-model with an extra account key. - await db.execute(''' -CREATE TABLE "cookies" ( - "account" TEXT NOT NULL, - "name" TEXT NOT NULL, - "value" TEXT NOT NULL, - "expiry-time" INTEGER NOT NULL, - "domain" TEXT NOT NULL, - "path" TEXT NOT NULL, - "creation-time" INTEGER NOT NULL, - "last-access-time" INTEGER NOT NULL, - "persistent-flag" BOOLEAN NOT NULL, - "host-only-flag" BOOLEAN NOT NULL, - "secure-only-flag" BOOLEAN NOT NULL, - "http-only-flag" BOOLEAN NOT NULL, - PRIMARY KEY("account", "name", "domain", "path") -); - -CREATE TRIGGER delete_outdated_cookies - AFTER UPDATE - ON cookies -BEGIN - DELETE FROM cookies - WHERE - "expiry-time" <= STRFTIME('%s') - OR "last-access-time" <= $_lastAccessMaxDuration; -END; -'''); - } - - Database get _requireDatabase { - if (database == null) { - throw StateError( - 'Cookie persistence has not been set up yet. Please make sure SQLiteCookiePersistence.init() has been called before and completed.', - ); - } - - return database!; - } + static Database get _database => table.controller.database; @override Future> loadForRequest(Uri uri) async { @@ -136,7 +112,7 @@ END; // domain and patch matching is error prone in SQL; // use the dart helpers for now. try { - final results = await _requireDatabase.rawQuery( + final results = await _database.rawQuery( ''' SELECT "name", "domain", "path", "value" FROM "cookies" @@ -159,7 +135,7 @@ ORDER BY ); final list = []; - final batch = _requireDatabase.batch(); + final batch = _database.batch(); for (final result in results) { final name = result['name']! as String; @@ -214,12 +190,12 @@ WHERE "account" = ? return true; } - final batch = _requireDatabase.batch(); + final batch = _database.batch(); for (final cookie in cookies) { batch ..update( - 'cookies', + table.name, { '"name"': cookie.name, '"value"': cookie.value, @@ -291,7 +267,7 @@ WHERE (SELECT changes() = 0) @override Future endSession() async { try { - await _requireDatabase.execute( + await _database.execute( ''' DELETE FROM cookies WHERE @@ -333,7 +309,7 @@ WHERE final list = []; try { - final results = await _requireDatabase.rawQuery( + final results = await _database.rawQuery( ''' SELECT "name", "value" FROM "cookies" diff --git a/packages/neon_framework/lib/src/storage/sqlite_persistence.dart b/packages/neon_framework/packages/neon_storage/lib/src/storage/sqlite_persistence.dart similarity index 74% rename from packages/neon_framework/lib/src/storage/sqlite_persistence.dart rename to packages/neon_framework/packages/neon_storage/lib/src/storage/sqlite_persistence.dart index 636a91bb4cb..21dcd242125 100644 --- a/packages/neon_framework/lib/src/storage/sqlite_persistence.dart +++ b/packages/neon_framework/packages/neon_storage/lib/src/storage/sqlite_persistence.dart @@ -2,17 +2,40 @@ import 'dart:async'; import 'dart:convert'; import 'package:built_collection/built_collection.dart'; -import 'package:flutter/foundation.dart'; import 'package:logging/logging.dart'; -import 'package:meta/meta.dart' show internal; -import 'package:neon_framework/platform.dart'; -import 'package:neon_framework/src/storage/persistence.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:meta/meta.dart'; +import 'package:neon_storage/neon_sqlite.dart'; +import 'package:neon_storage/neon_storage.dart'; +import 'package:sqflite_common/sqlite_api.dart'; final _log = Logger('SQLitePersistence'); +final class _SQLiteCachedPersistenceTable extends Table { + @override + String get name => 'preferences'; + + @override + int get version => 1; + + @override + void onCreate(Batch db, int version) { + db.execute(''' +CREATE TABLE IF NOT EXISTS "preferences" ( + "prefix" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + PRIMARY KEY("prefix","key"), + UNIQUE("key","prefix") +); +'''); + } + + @override + Future onOpen() async { + await SQLiteCachedPersistence.getAll(); + } +} + /// An SQLite backed cached persistence for preferences. /// /// There is only one cache backing all `SQLitePersistence` instances. @@ -22,7 +45,6 @@ final _log = Logger('SQLitePersistence'); /// The persistence must be initialized with by calling `SQLitePersistence().init()` /// and awaiting it's completion. If it has not yet initialized a `StateError` /// will be thrown. -@internal final class SQLiteCachedPersistence extends CachedPersistence { /// Creates a new sqlite persistence. SQLiteCachedPersistence({this.prefix = ''}); @@ -35,64 +57,20 @@ final class SQLiteCachedPersistence extends CachedPersistence { @override Map get cache => globalCache[prefix] ??= {}; + /// Global cache for all persistences. @visibleForTesting static final Map> globalCache = {}; - @visibleForTesting - static Database? database; + /// The sqlite table for this persistence. + static Table table = _SQLiteCachedPersistenceTable(); - /// Initializes all persistences by setting up the backing SQLite database - /// and priming the global cache. - /// - /// This must be called and completed before accessing any other methods of - /// the sqlite persistence. - static Future init() async { - if (database != null) { - return; - } - - var path = 'preferences.db'; - if (NeonPlatform.instance.canUsePaths) { - final appDir = await getApplicationSupportDirectory(); - path = p.join(appDir.path, path); - } - - database = await openDatabase( - path, - version: 1, - onCreate: onCreate, - ); - - await getAll(); - } - - @visibleForTesting - static Future onCreate(Database db, int version) async { - await db.execute(''' -CREATE TABLE "preferences" ( - "prefix" TEXT NOT NULL, - "key" TEXT NOT NULL, - "value" TEXT NOT NULL, - PRIMARY KEY("prefix","key"), - UNIQUE("key","prefix") -); -'''); - } - - static Database get _requireDatabase { - if (database == null) { - throw StateError( - 'Persistence has not been set up yet. Please make sure SQLitePersistence.init() has been called before and completed.', - ); - } - - return database!; - } + static Database get _database => table.controller.database; + /// Loads all saved values into the cache. @visibleForTesting static Future getAll() async { try { - final results = await _requireDatabase.rawQuery(''' + final results = await _database.rawQuery(''' SELECT prefix, key, value FROM preferences '''); @@ -117,7 +95,7 @@ FROM preferences @override Future clear() async { try { - await _requireDatabase.rawDelete( + await _database.rawDelete( ''' DELETE FROM preferences WHERE prefix = ? @@ -142,7 +120,7 @@ WHERE prefix = ? try { final fromSystem = {}; - final results = await _requireDatabase.rawQuery( + final results = await _database.rawQuery( ''' SELECT key, value FROM preferences @@ -171,7 +149,7 @@ WHERE prefix = ? @override Future remove(String key) async { try { - await _requireDatabase.rawDelete( + await _database.rawDelete( ''' DELETE FROM preferences WHERE @@ -200,7 +178,7 @@ WHERE 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() + final batch = _database.batch() ..update( 'preferences', { diff --git a/packages/neon_framework/packages/neon_storage/lib/src/storage/storage.dart b/packages/neon_framework/packages/neon_storage/lib/src/storage/storage.dart new file mode 100644 index 00000000000..45803b538c3 --- /dev/null +++ b/packages/neon_framework/packages/neon_storage/lib/src/storage/storage.dart @@ -0,0 +1,3 @@ +export 'request_cache.dart'; +export 'sqlite_cookie_persistence.dart'; +export 'sqlite_persistence.dart'; diff --git a/packages/neon_framework/packages/neon_storage/pubspec_overrides.yaml b/packages/neon_framework/packages/neon_storage/pubspec_overrides.yaml index d4b2d2ff99a..fbf277d4fc5 100644 --- a/packages/neon_framework/packages/neon_storage/pubspec_overrides.yaml +++ b/packages/neon_framework/packages/neon_storage/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: neon_lints,cookie_store,cookie_store_conformance_tests,dynamite_runtime,nextcloud +# melos_managed_dependency_overrides: cookie_store,cookie_store_conformance_tests,dynamite_runtime,neon_lints,nextcloud dependency_overrides: cookie_store: path: ../../../cookie_store diff --git a/packages/neon_framework/packages/neon_storage/test/interfaces/cached_persistence_test.dart b/packages/neon_framework/packages/neon_storage/test/interfaces/cached_persistence_test.dart new file mode 100644 index 00000000000..94f4a935bd0 --- /dev/null +++ b/packages/neon_framework/packages/neon_storage/test/interfaces/cached_persistence_test.dart @@ -0,0 +1,53 @@ +import 'package:neon_storage/neon_storage.dart'; +import 'package:test/test.dart'; + +class _TestCachedPersistence extends CachedPersistence { + @override + Future clear() => throw UnimplementedError(); + + @override + Future reload() => throw UnimplementedError(); + + @override + Future remove(String key) => throw UnimplementedError(); + + @override + Future setValue(String key, Object value) => throw UnimplementedError(); +} + +void main() { + group('CachedPersistence', () { + late CachedPersistence persistence; + + setUp(() { + persistence = _TestCachedPersistence(); + }); + + test('setCache', () { + persistence.setCache('key', 'value'); + + expect( + persistence.cache, + equals({'key': 'value'}), + ); + }); + + test('getValue', () { + persistence.cache['key'] = 'value'; + + expect( + persistence.getValue('key'), + equals('value'), + ); + }); + + test('getValue', () { + persistence.cache['key'] = 'value'; + + expect( + persistence.containsKey('key'), + isTrue, + ); + }); + }); +} diff --git a/packages/neon_framework/packages/neon_storage/test/interfaces/persistence_interface_test.dart b/packages/neon_framework/packages/neon_storage/test/interfaces/persistence_interface_test.dart new file mode 100644 index 00000000000..a8952f399fb --- /dev/null +++ b/packages/neon_framework/packages/neon_storage/test/interfaces/persistence_interface_test.dart @@ -0,0 +1,32 @@ +import 'dart:async'; + +import 'package:neon_storage/neon_storage.dart'; +import 'package:test/test.dart'; + +class _TestPersistence implements Persistence { + @override + FutureOr clear() => throw UnimplementedError(); + + @override + FutureOr containsKey(String key) => throw UnimplementedError(); + + @override + FutureOr getValue(String key) => throw UnimplementedError(); + + @override + FutureOr remove(String key) => throw UnimplementedError(); + + @override + FutureOr setValue(String key, Object value) => throw UnimplementedError(); + + @override + List keys() => throw UnimplementedError(); +} + +void main() { + group('Persistence', () { + test('can be implemented', () { + expect(_TestPersistence(), isNotNull); + }); + }); +} diff --git a/packages/neon_framework/packages/neon_storage/test/interfaces/request_cache_interface_test.dart b/packages/neon_framework/packages/neon_storage/test/interfaces/request_cache_interface_test.dart new file mode 100644 index 00000000000..2f4196018ed --- /dev/null +++ b/packages/neon_framework/packages/neon_storage/test/interfaces/request_cache_interface_test.dart @@ -0,0 +1,26 @@ +import 'dart:async'; + +import 'package:http/src/request.dart'; +import 'package:http/src/response.dart'; +import 'package:neon_storage/neon_storage.dart'; +import 'package:test/test.dart'; + +class _TestRequestCache implements RequestCache { + @override + Future get(String accountID, Request request) => throw UnimplementedError(); + + @override + Future set(String accountID, Request request, Response response) => throw UnimplementedError(); + + @override + Future updateHeaders(String accountID, Request request, Map headers) => + throw UnimplementedError(); +} + +void main() { + group('RequestCache', () { + test('can be implemented', () { + expect(_TestRequestCache(), isNotNull); + }); + }); +} diff --git a/packages/neon_framework/packages/neon_storage/test/interfaces/settings_store_interface_test.dart b/packages/neon_framework/packages/neon_storage/test/interfaces/settings_store_interface_test.dart new file mode 100644 index 00000000000..b60af09109b --- /dev/null +++ b/packages/neon_framework/packages/neon_storage/test/interfaces/settings_store_interface_test.dart @@ -0,0 +1,33 @@ +import 'package:neon_storage/neon_storage.dart'; +import 'package:test/test.dart'; + +class _TestSettingsStore implements SettingsStore { + @override + bool? getBool(String key) => throw UnimplementedError(); + + @override + String? getString(String key) => throw UnimplementedError(); + + @override + String get id => throw UnimplementedError(); + + @override + Future remove(String key) => throw UnimplementedError(); + + @override + Future setBool(String key, bool value) => throw UnimplementedError(); + + @override + Future setString(String key, String value) => throw UnimplementedError(); + + @override + List keys() => throw UnimplementedError(); +} + +void main() { + group('SettingsStore', () { + test('can be implemented', () { + expect(_TestSettingsStore(), isNotNull); + }); + }); +} diff --git a/packages/neon_framework/packages/neon_storage/test/interfaces/single_value_interface_test.dart b/packages/neon_framework/packages/neon_storage/test/interfaces/single_value_interface_test.dart new file mode 100644 index 00000000000..4e4de022a3f --- /dev/null +++ b/packages/neon_framework/packages/neon_storage/test/interfaces/single_value_interface_test.dart @@ -0,0 +1,40 @@ +import 'package:built_collection/src/list.dart'; +import 'package:neon_storage/neon_storage.dart'; +import 'package:test/test.dart'; + +class _TestSingleValueStore implements SingleValueStore { + @override + bool? getBool() => throw UnimplementedError(); + + @override + String? getString() => throw UnimplementedError(); + + @override + BuiltList? getStringList() => throw UnimplementedError(); + + @override + bool hasValue() => throw UnimplementedError(); + + @override + Storable get key => throw UnimplementedError(); + + @override + Future remove() => throw UnimplementedError(); + + @override + Future setBool(bool value) => throw UnimplementedError(); + + @override + Future setString(String value) => throw UnimplementedError(); + + @override + Future setStringList(BuiltList value) => throw UnimplementedError(); +} + +void main() { + group('SingleValueStore', () { + test('can be implemented', () { + expect(_TestSingleValueStore(), isNotNull); + }); + }); +} diff --git a/packages/neon_framework/packages/neon_storage/test/interfaces/storable_interface_test.dart b/packages/neon_framework/packages/neon_storage/test/interfaces/storable_interface_test.dart new file mode 100644 index 00000000000..7190bc72fd3 --- /dev/null +++ b/packages/neon_framework/packages/neon_storage/test/interfaces/storable_interface_test.dart @@ -0,0 +1,15 @@ +import 'package:neon_storage/neon_storage.dart'; +import 'package:test/test.dart'; + +class _TestStorable implements Storable { + @override + String get value => throw UnimplementedError(); +} + +void main() { + group('Storable', () { + test('can be implemented', () { + expect(_TestStorable(), isNotNull); + }); + }); +} diff --git a/packages/neon_framework/packages/neon_storage/test/storage/request_cache_test.dart b/packages/neon_framework/packages/neon_storage/test/storage/request_cache_test.dart new file mode 100644 index 00000000000..be86b106728 --- /dev/null +++ b/packages/neon_framework/packages/neon_storage/test/storage/request_cache_test.dart @@ -0,0 +1,77 @@ +import 'package:http/http.dart' as http; +import 'package:neon_storage/src/storage/storage.dart'; +import 'package:neon_storage/testing.dart'; +import 'package:test/test.dart'; + +void main() { + group('RequestCache', () { + late TestTableDatabase database; + const cache = SQLiteRequestCache(); + + setUp(() async { + database = TestTableDatabase(SQLiteRequestCache.table); + await database.init(); + }); + + tearDown(() async { + await database.close(); + }); + + test('throws when unutilized', () async { + await database.close(); + + expect(() async => cache.get('accountID', http.Request('GET', Uri())), throwsA(isA())); + }); + + test('RequestCache', () async { + final request = http.Request('GET', Uri(host: 'example.com')) + ..headers.addAll({'a': 'b'}) + ..body = 'c'; + + var result = await cache.get('accountID', request); + expect(result, isNull); + + await cache.set('accountID', request, http.Response('value', 200, headers: {'key': 'value'})); + result = await cache.get('accountID', request); + expect(result?.statusCode, 200); + expect(result?.body, 'value'); + expect(result?.headers, {'key': 'value'}); + + for (final modifiedRequest in [ + http.Request('POST', Uri(host: 'example.com')) + ..headers.addAll({'a': 'b'}) + ..body = 'c', + http.Request('GET', Uri(host: 'example.org')) + ..headers.addAll({'a': 'b'}) + ..body = 'c', + http.Request('GET', Uri(host: 'example.com')) + ..headers.addAll({'a': 'd'}) + ..body = 'c', + http.Request('GET', Uri(host: 'example.com')) + ..headers.addAll({'a': 'b'}) + ..body = 'e', + ]) { + result = await cache.get('accountID', modifiedRequest); + expect(result, isNull); + } + + await cache.set('accountID', request, http.Response('upsert', 201, headers: {'key': 'updated'})); + result = await cache.get('accountID', request); + expect(result?.statusCode, 201); + expect(result?.body, 'upsert'); + expect(result?.headers, {'key': 'updated'}); + + await cache.set('accountID', request, http.Response('value', 200, headers: {'key': 'value'})); + result = await cache.get('accountID', request); + expect(result?.statusCode, 200); + expect(result?.body, 'value'); + expect(result?.headers, {'key': 'value'}); + + await cache.updateHeaders('accountID', request, {'key': 'updated'}); + result = await cache.get('accountID', request); + expect(result?.statusCode, 200); + expect(result?.body, 'value'); + expect(result?.headers, {'key': 'updated'}); + }); + }); +} diff --git a/packages/neon_framework/packages/neon_storage/test/storage/sqlite_cookie_persistence_test.dart b/packages/neon_framework/packages/neon_storage/test/storage/sqlite_cookie_persistence_test.dart new file mode 100644 index 00000000000..67417c527f3 --- /dev/null +++ b/packages/neon_framework/packages/neon_storage/test/storage/sqlite_cookie_persistence_test.dart @@ -0,0 +1,76 @@ +import 'dart:io' show Cookie; + +import 'package:cookie_store/cookie_store.dart'; +import 'package:cookie_store_conformance_tests/cookie_store_conformance_tests.dart' as cookie_jar_conformance; +import 'package:neon_storage/src/storage/storage.dart'; +import 'package:neon_storage/testing.dart'; +import 'package:test/test.dart'; + +void main() { + group(SQLiteCookiePersistence, () { + late TestTableDatabase database; + + setUp(() async { + database = TestTableDatabase(SQLiteCookiePersistence.table); + await database.init(); + }); + + tearDown(() async { + await database.close(); + }); + + test('Uninitialized', () async { + await database.close(); + + final persistence = SQLiteCookiePersistence(accountID: 'accountID'); + expect(() async => persistence.loadAll(), throwsA(isA())); + }); + + cookie_jar_conformance.testAll( + () async { + final persistence = SQLiteCookiePersistence(accountID: 'accountID'); + return DefaultCookieStore(persistence); + }, + canDeleteAll: false, + canDeleteByTest: false, + ); + + group('Restrict allowedBaseUri', () { + late CookieStore cookieStore; + + setUpAll(() async { + cookieStore = DefaultCookieStore( + SQLiteCookiePersistence( + accountID: 'accountID', + allowedBaseUri: Uri(host: 'example.com', path: '/subpath/'), + ), + ); + }); + + for (final element in [ + (Uri(host: 'example.com', path: '/subpath/other'), isNotEmpty), + (Uri(host: 'example.com', path: '/subpath'), isNotEmpty), + (Uri(host: 'example.com', path: '/subpathTest'), isEmpty), + (Uri(host: 'example.com', path: '/'), isEmpty), + (Uri(host: 'test.com', path: '/subpath/other'), isEmpty), + ]) { + final uri = element.$1; + test(uri, () async { + final cookies = [ + Cookie(uri.host, 'value') + ..domain = uri.host + ..path = uri.path + ..httpOnly = false + ..secure = false, + ]; + + await cookieStore.saveFromResponse(uri, cookies); + expect( + await cookieStore.loadForRequest(uri), + element.$2, + ); + }); + } + }); + }); +} diff --git a/packages/neon_framework/packages/neon_storage/test/storage/sqlite_persistence_test.dart b/packages/neon_framework/packages/neon_storage/test/storage/sqlite_persistence_test.dart new file mode 100644 index 00000000000..61bc540fed8 --- /dev/null +++ b/packages/neon_framework/packages/neon_storage/test/storage/sqlite_persistence_test.dart @@ -0,0 +1,131 @@ +// ignore_for_file: inference_failure_on_instance_creation, strict_raw_type, inference_failure_on_collection_literal + +import 'package:built_collection/built_collection.dart'; +import 'package:neon_storage/src/storage/storage.dart'; +import 'package:neon_storage/testing.dart'; +import 'package:test/test.dart'; + +void main() { + group('SQLitePersistence', () { + late TestTableDatabase database; + + setUp(() async { + database = TestTableDatabase(SQLiteCachedPersistence.table); + await database.init(); + }); + + tearDown(() async { + SQLiteCachedPersistence.globalCache.clear(); + await database.close(); + }); + + test('Uninitialized', () async { + await database.close(); + + expect(() async => SQLiteCachedPersistence().clear(), throwsA(isA())); + }); + + test('init reloads all values', () async { + await SQLiteCachedPersistence().setValue('key', 'value'); + + SQLiteCachedPersistence(prefix: 'garbage').setCache('key2', 'value2'); + SQLiteCachedPersistence(prefix: 'garbage2').setCache('key2', 'value2'); + + await SQLiteCachedPersistence.getAll(); + expect( + SQLiteCachedPersistence.globalCache, + equals( + { + '': {'key': 'value'}, + }, + ), + ); + }); + + test('reload unrelated persistence', () async { + await SQLiteCachedPersistence().setValue('key', 'value'); + + SQLiteCachedPersistence().setCache('key2', 'value2'); + SQLiteCachedPersistence(prefix: 'garbage').setCache('key2', 'value2'); + + await SQLiteCachedPersistence().reload(); + expect( + SQLiteCachedPersistence.globalCache, + equals( + { + '': {'key': 'value'}, + 'garbage': {'key2': 'value2'}, + }, + ), + ); + }); + + test('remove unrelated persistence', () async { + await SQLiteCachedPersistence().setValue('key', 'value'); + await SQLiteCachedPersistence(prefix: 'prefix').setValue('pKey', 'pValue'); + + await SQLiteCachedPersistence().remove('key'); + expect( + SQLiteCachedPersistence.globalCache, + equals( + { + '': {}, + 'prefix': {'pKey': 'pValue'}, + }, + ), + ); + + await SQLiteCachedPersistence().reload(); + expect( + SQLiteCachedPersistence.globalCache, + equals( + { + '': {}, + 'prefix': {'pKey': 'pValue'}, + }, + ), + ); + }); + + test('persist built_collection', () async { + await SQLiteCachedPersistence().setValue('string-key', 'value'); + await SQLiteCachedPersistence().setValue('num-key', 4); + await SQLiteCachedPersistence().setValue('bool-key', false); + await SQLiteCachedPersistence().setValue('built-list-key', BuiltList(['hi', 'there'])); + await SQLiteCachedPersistence().setValue('built-map-key', BuiltMap({'hi': 'again'})); + + await SQLiteCachedPersistence().reload(); + expect( + SQLiteCachedPersistence().cache, + equals({ + 'bool-key': false, + 'built-list-key': BuiltList(['hi', 'there']), + 'built-map-key': BuiltMap({'hi': 'again'}), + 'num-key': 4, + 'string-key': 'value', + }), + ); + expect(SQLiteCachedPersistence().getValue('built-list-key'), isA()); + expect(SQLiteCachedPersistence().getValue('built-map-key'), isA()); + }); + + test('clear unrelated persistence', () async { + await SQLiteCachedPersistence().setValue('key', 'value'); + await SQLiteCachedPersistence(prefix: 'prefix').setValue('pKey', 'pValue'); + + await SQLiteCachedPersistence(prefix: 'prefix').clear(); + expect(SQLiteCachedPersistence(prefix: 'prefix').cache, isEmpty); + expect(SQLiteCachedPersistence().cache, isNotEmpty); + + await SQLiteCachedPersistence(prefix: 'prefix').reload(); + expect(SQLiteCachedPersistence(prefix: 'prefix').cache, isEmpty); + expect(SQLiteCachedPersistence().cache, isNotEmpty); + }); + + test('contains key', () async { + SQLiteCachedPersistence().setCache('key', 'value'); + expect(SQLiteCachedPersistence().containsKey('key'), isTrue); + expect(SQLiteCachedPersistence(prefix: 'prefix').containsKey('key'), isFalse); + }); + }); +} diff --git a/packages/neon_framework/packages/news_app/pubspec_overrides.yaml b/packages/neon_framework/packages/news_app/pubspec_overrides.yaml index 0abe46d2bdd..c24ede0eb8c 100644 --- a/packages/neon_framework/packages/news_app/pubspec_overrides.yaml +++ b/packages/neon_framework/packages/news_app/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: account_repository,cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,nextcloud,sort_box +# melos_managed_dependency_overrides: account_repository,cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,neon_storage,nextcloud,sort_box dependency_overrides: account_repository: path: ../account_repository @@ -14,6 +14,8 @@ dependency_overrides: path: ../neon_http_client neon_lints: path: ../../../neon_lints + neon_storage: + path: ../neon_storage nextcloud: path: ../../../nextcloud sort_box: diff --git a/packages/neon_framework/packages/notes_app/pubspec_overrides.yaml b/packages/neon_framework/packages/notes_app/pubspec_overrides.yaml index 0abe46d2bdd..c24ede0eb8c 100644 --- a/packages/neon_framework/packages/notes_app/pubspec_overrides.yaml +++ b/packages/neon_framework/packages/notes_app/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: account_repository,cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,nextcloud,sort_box +# melos_managed_dependency_overrides: account_repository,cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,neon_storage,nextcloud,sort_box dependency_overrides: account_repository: path: ../account_repository @@ -14,6 +14,8 @@ dependency_overrides: path: ../neon_http_client neon_lints: path: ../../../neon_lints + neon_storage: + path: ../neon_storage nextcloud: path: ../../../nextcloud sort_box: diff --git a/packages/neon_framework/packages/notifications_app/pubspec_overrides.yaml b/packages/neon_framework/packages/notifications_app/pubspec_overrides.yaml index 0abe46d2bdd..c24ede0eb8c 100644 --- a/packages/neon_framework/packages/notifications_app/pubspec_overrides.yaml +++ b/packages/neon_framework/packages/notifications_app/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: account_repository,cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,nextcloud,sort_box +# melos_managed_dependency_overrides: account_repository,cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,neon_storage,nextcloud,sort_box dependency_overrides: account_repository: path: ../account_repository @@ -14,6 +14,8 @@ dependency_overrides: path: ../neon_http_client neon_lints: path: ../../../neon_lints + neon_storage: + path: ../neon_storage nextcloud: path: ../../../nextcloud sort_box: diff --git a/packages/neon_framework/packages/notifications_push_repository/pubspec_overrides.yaml b/packages/neon_framework/packages/notifications_push_repository/pubspec_overrides.yaml index 0abe46d2bdd..4fdf9452af6 100644 --- a/packages/neon_framework/packages/notifications_push_repository/pubspec_overrides.yaml +++ b/packages/neon_framework/packages/notifications_push_repository/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: account_repository,cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,nextcloud,sort_box +# melos_managed_dependency_overrides: account_repository,cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,nextcloud,sort_box,neon_storage dependency_overrides: account_repository: path: ../account_repository @@ -14,6 +14,8 @@ dependency_overrides: path: ../neon_http_client neon_lints: path: ../../../neon_lints + neon_storage: + path: ../neon_storage nextcloud: path: ../../../nextcloud sort_box: diff --git a/packages/neon_framework/packages/talk_app/pubspec_overrides.yaml b/packages/neon_framework/packages/talk_app/pubspec_overrides.yaml index 0abe46d2bdd..c24ede0eb8c 100644 --- a/packages/neon_framework/packages/talk_app/pubspec_overrides.yaml +++ b/packages/neon_framework/packages/talk_app/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: account_repository,cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,nextcloud,sort_box +# melos_managed_dependency_overrides: account_repository,cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,neon_storage,nextcloud,sort_box dependency_overrides: account_repository: path: ../account_repository @@ -14,6 +14,8 @@ dependency_overrides: path: ../neon_http_client neon_lints: path: ../../../neon_lints + neon_storage: + path: ../neon_storage nextcloud: path: ../../../nextcloud sort_box: diff --git a/packages/neon_framework/pubspec.yaml b/packages/neon_framework/pubspec.yaml index 6b947faea4d..5ab64e8ab11 100644 --- a/packages/neon_framework/pubspec.yaml +++ b/packages/neon_framework/pubspec.yaml @@ -16,10 +16,6 @@ dependencies: built_collection: ^5.0.0 built_value: ^8.9.0 collection: ^1.0.0 - cookie_store: - git: - url: https://github.com/nextcloud/neon - path: packages/cookie_store crypto: ^3.0.0 crypton: ^2.0.0 cupertino_icons: ^1.0.0 # Do not remove this, is it needed on iOS/macOS. It will not include icons on other platforms because Apple forbids it. @@ -50,10 +46,13 @@ dependencies: neon_http_client: git: url: https://github.com/nextcloud/neon - path: packages/neon_http_client + path: packages/neon_framework/packages/neon_http_client + neon_storage: + git: + url: https://github.com/nextcloud/neon + path: packages/neon_framework/packages/neon_storage nextcloud: ^8.0.0 package_info_plus: ^8.0.0 - path: ^1.0.0 path_provider: ^2.1.0 permission_handler: ^11.0.0 provider: ^6.0.0 @@ -65,9 +64,6 @@ dependencies: git: url: https://github.com/nextcloud/neon path: packages/neon_framework/packages/sort_box - sqflite: ^2.3.0 - sqflite_common_ffi: ^2.3.2 - sqflite_common_ffi_web: ^0.4.2+3 timezone: ^0.9.4 unifiedpush: ^5.0.0 unifiedpush_android: ^2.0.0 diff --git a/packages/neon_framework/pubspec_overrides.yaml b/packages/neon_framework/pubspec_overrides.yaml index 73b6a2ad440..f858cdf6e64 100644 --- a/packages/neon_framework/pubspec_overrides.yaml +++ b/packages/neon_framework/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: account_repository,cookie_store,cookie_store_conformance_tests,dynamite_runtime,interceptor_http_client,neon_http_client,neon_lints,nextcloud,sort_box +# melos_managed_dependency_overrides: account_repository,cookie_store,cookie_store_conformance_tests,dynamite_runtime,interceptor_http_client,neon_http_client,neon_lints,neon_storage,nextcloud,sort_box dependency_overrides: account_repository: path: packages/account_repository @@ -14,6 +14,8 @@ dependency_overrides: path: packages/neon_http_client neon_lints: path: ../neon_lints + neon_storage: + path: packages/neon_storage nextcloud: path: ../nextcloud sort_box: diff --git a/packages/neon_framework/test/options_collection_test.dart b/packages/neon_framework/test/options_collection_test.dart index bc3696fa866..a2496e9ca36 100644 --- a/packages/neon_framework/test/options_collection_test.dart +++ b/packages/neon_framework/test/options_collection_test.dart @@ -1,7 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:neon_framework/settings.dart'; -import 'package:neon_framework/src/storage/keys.dart'; +import 'package:neon_framework/storage.dart'; import 'package:neon_framework/testing.dart'; class Collection extends AppImplementationOptions { diff --git a/packages/neon_framework/test/persistence_test.dart b/packages/neon_framework/test/persistence_test.dart deleted file mode 100644 index 7f5108ac976..00000000000 --- a/packages/neon_framework/test/persistence_test.dart +++ /dev/null @@ -1,302 +0,0 @@ -// ignore_for_file: inference_failure_on_instance_creation, strict_raw_type, inference_failure_on_collection_literal - -import 'package:built_collection/built_collection.dart'; -import 'package:cookie_store/cookie_store.dart'; -import 'package:cookie_store_conformance_tests/cookie_store_conformance_tests.dart' as cookie_jar_conformance; -import 'package:flutter_test/flutter_test.dart'; -import 'package:http/http.dart' as http; -import 'package:neon_framework/src/storage/request_cache.dart'; -import 'package:neon_framework/src/storage/sqlite_cookie_persistence.dart'; -import 'package:neon_framework/src/storage/sqlite_persistence.dart'; -import 'package:neon_framework/testing.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; -import 'package:universal_io/io.dart' show Cookie; - -void main() { - group('Persistences', () { - group('RequestCache', () { - final account = MockAccount(); - - final cache = DefaultRequestCache(); - - setUpAll(() { - sqfliteFfiInit(); - databaseFactory = databaseFactoryFfi; - }); - - setUp(() async { - cache.database = await openDatabase( - inMemoryDatabasePath, - version: 1, - onCreate: DefaultRequestCache.onCreate, - singleInstance: false, - ); - }); - - test('init', () { - cache.database = null; - expect(() async => cache.get(account, http.Request('GET', Uri())), throwsA(isA())); - }); - - test('RequestCache', () async { - final request = http.Request('GET', Uri(host: 'example.com')) - ..headers.addAll({'a': 'b'}) - ..body = 'c'; - - var result = await cache.get(account, request); - expect(result, isNull); - - await cache.set(account, request, http.Response('value', 200, headers: {'key': 'value'})); - result = await cache.get(account, request); - expect(result?.statusCode, 200); - expect(result?.body, 'value'); - expect(result?.headers, {'key': 'value'}); - - for (final modifiedRequest in [ - http.Request('POST', Uri(host: 'example.com')) - ..headers.addAll({'a': 'b'}) - ..body = 'c', - http.Request('GET', Uri(host: 'example.org')) - ..headers.addAll({'a': 'b'}) - ..body = 'c', - http.Request('GET', Uri(host: 'example.com')) - ..headers.addAll({'a': 'd'}) - ..body = 'c', - http.Request('GET', Uri(host: 'example.com')) - ..headers.addAll({'a': 'b'}) - ..body = 'e', - ]) { - result = await cache.get(account, modifiedRequest); - expect(result, isNull); - } - - await cache.set(account, request, http.Response('upsert', 201, headers: {'key': 'updated'})); - result = await cache.get(account, request); - expect(result?.statusCode, 201); - expect(result?.body, 'upsert'); - expect(result?.headers, {'key': 'updated'}); - - await cache.set(account, request, http.Response('value', 200, headers: {'key': 'value'})); - result = await cache.get(account, request); - expect(result?.statusCode, 200); - expect(result?.body, 'value'); - expect(result?.headers, {'key': 'value'}); - - await cache.updateHeaders(account, request, {'key': 'updated'}); - result = await cache.get(account, request); - expect(result?.statusCode, 200); - expect(result?.body, 'value'); - expect(result?.headers, {'key': 'updated'}); - }); - }); - - group('SQLitePersistence', () { - setUpAll(() { - sqfliteFfiInit(); - databaseFactory = databaseFactoryFfi; - }); - - setUp(() async { - SQLiteCachedPersistence.database = await openDatabase( - inMemoryDatabasePath, - version: 1, - onCreate: SQLiteCachedPersistence.onCreate, - singleInstance: false, - ); - }); - - tearDown(() { - SQLiteCachedPersistence.globalCache.clear(); - SQLiteCachedPersistence.database = null; - }); - - test('Uninitialized', () { - SQLiteCachedPersistence.database = null; - - expect(() async => SQLiteCachedPersistence().clear(), throwsA(isA())); - }); - - test('init reloads all values', () async { - await SQLiteCachedPersistence().setValue('key', 'value'); - - SQLiteCachedPersistence(prefix: 'garbage').setCache('key2', 'value2'); - SQLiteCachedPersistence(prefix: 'garbage2').setCache('key2', 'value2'); - - await SQLiteCachedPersistence.getAll(); - expect( - SQLiteCachedPersistence.globalCache, - equals( - { - '': {'key': 'value'}, - }, - ), - ); - }); - - test('reload unrelated persistence', () async { - await SQLiteCachedPersistence().setValue('key', 'value'); - - SQLiteCachedPersistence().setCache('key2', 'value2'); - SQLiteCachedPersistence(prefix: 'garbage').setCache('key2', 'value2'); - - await SQLiteCachedPersistence().reload(); - expect( - SQLiteCachedPersistence.globalCache, - equals( - { - '': {'key': 'value'}, - 'garbage': {'key2': 'value2'}, - }, - ), - ); - }); - - test('remove unrelated persistence', () async { - await SQLiteCachedPersistence().setValue('key', 'value'); - await SQLiteCachedPersistence(prefix: 'prefix').setValue('pKey', 'pValue'); - - await SQLiteCachedPersistence().remove('key'); - expect( - SQLiteCachedPersistence.globalCache, - equals( - { - '': {}, - 'prefix': {'pKey': 'pValue'}, - }, - ), - ); - - await SQLiteCachedPersistence().reload(); - expect( - SQLiteCachedPersistence.globalCache, - equals( - { - '': {}, - 'prefix': {'pKey': 'pValue'}, - }, - ), - ); - }); - - test('persist built_collection', () async { - await SQLiteCachedPersistence().setValue('string-key', 'value'); - await SQLiteCachedPersistence().setValue('num-key', 4); - await SQLiteCachedPersistence().setValue('bool-key', false); - await SQLiteCachedPersistence().setValue('built-list-key', BuiltList(['hi', 'there'])); - await SQLiteCachedPersistence().setValue('built-map-key', BuiltMap({'hi': 'again'})); - - await SQLiteCachedPersistence().reload(); - expect( - SQLiteCachedPersistence().cache, - equals({ - 'bool-key': false, - 'built-list-key': BuiltList(['hi', 'there']), - 'built-map-key': BuiltMap({'hi': 'again'}), - 'num-key': 4, - 'string-key': 'value', - }), - ); - expect(SQLiteCachedPersistence().getValue('built-list-key'), isA()); - expect(SQLiteCachedPersistence().getValue('built-map-key'), isA()); - }); - - test('clear unrelated persistence', () async { - await SQLiteCachedPersistence().setValue('key', 'value'); - await SQLiteCachedPersistence(prefix: 'prefix').setValue('pKey', 'pValue'); - - await SQLiteCachedPersistence(prefix: 'prefix').clear(); - expect(SQLiteCachedPersistence(prefix: 'prefix').cache, isEmpty); - expect(SQLiteCachedPersistence().cache, isNotEmpty); - - await SQLiteCachedPersistence(prefix: 'prefix').reload(); - expect(SQLiteCachedPersistence(prefix: 'prefix').cache, isEmpty); - expect(SQLiteCachedPersistence().cache, isNotEmpty); - }); - - test('contains key', () async { - SQLiteCachedPersistence().setCache('key', 'value'); - expect(SQLiteCachedPersistence().containsKey('key'), isTrue); - expect(SQLiteCachedPersistence(prefix: 'prefix').containsKey('key'), isFalse); - }); - }); - - group(SQLiteCookiePersistence, () { - setUpAll(() { - sqfliteFfiInit(); - databaseFactory = databaseFactoryFfi; - }); - - tearDown(() { - SQLiteCookiePersistence.database = null; - }); - - test('Uninitialized', () { - SQLiteCookiePersistence.database = null; - - final persistence = SQLiteCookiePersistence(accountID: 'accountID'); - expect(() async => persistence.loadAll(), throwsA(isA())); - }); - - cookie_jar_conformance.testAll( - () async { - SQLiteCookiePersistence.database = await openDatabase( - inMemoryDatabasePath, - version: 1, - onCreate: SQLiteCookiePersistence.onCreate, - singleInstance: false, - ); - - final persistence = SQLiteCookiePersistence(accountID: 'accountID'); - return DefaultCookieStore(persistence); - }, - canDeleteAll: false, - canDeleteByTest: false, - ); - - group('Restrict allowedBaseUri', () { - late CookieStore cookieStore; - - setUpAll(() async { - SQLiteCookiePersistence.database = await openDatabase( - inMemoryDatabasePath, - version: 1, - onCreate: SQLiteCookiePersistence.onCreate, - singleInstance: false, - ); - - cookieStore = DefaultCookieStore( - SQLiteCookiePersistence( - accountID: 'accountID', - allowedBaseUri: Uri(host: 'example.com', path: '/subpath/'), - ), - ); - }); - - for (final element in [ - (Uri(host: 'example.com', path: '/subpath/other'), isNotEmpty), - (Uri(host: 'example.com', path: '/subpath'), isNotEmpty), - (Uri(host: 'example.com', path: '/subpathTest'), isEmpty), - (Uri(host: 'example.com', path: '/'), isEmpty), - (Uri(host: 'test.com', path: '/subpath/other'), isEmpty), - ]) { - final uri = element.$1; - test(uri, () async { - final cookies = [ - Cookie(uri.host, 'value') - ..domain = uri.host - ..path = uri.path - ..httpOnly = false - ..secure = false, - ]; - - await cookieStore.saveFromResponse(uri, cookies); - expect( - await cookieStore.loadForRequest(uri), - element.$2, - ); - }); - } - }); - }); - }); -} diff --git a/packages/neon_framework/test/request_manager_test.dart b/packages/neon_framework/test/request_manager_test.dart index a775c85cf89..20edbf91dac 100644 --- a/packages/neon_framework/test/request_manager_test.dart +++ b/packages/neon_framework/test/request_manager_test.dart @@ -264,10 +264,10 @@ void main() { ); await subject.close(); - verify(() => cache.get(account, any())).called(1); + verify(() => cache.get(account.id, any())).called(1); verify( () => cache.set( - account, + account.id, any(), any( that: isA() @@ -299,10 +299,10 @@ void main() { ); await subject.close(); - verify(() => cache.get(account, any())).called(1); + verify(() => cache.get(account.id, any())).called(1); verify( () => cache.set( - account, + account.id, any(), any( that: isA() @@ -336,7 +336,7 @@ void main() { ); await subject.close(); - verify(() => cache.get(account, any())).called(1); + verify(() => cache.get(account.id, any())).called(1); verifyNever(() => cache.set(any(), any(), any())); subject = BehaviorSubject>.seeded(Result.success('Seed value')); @@ -360,7 +360,7 @@ void main() { ); await subject.close(); - verify(() => cache.get(account, any())).called(1); + verify(() => cache.get(account.id, any())).called(1); verifyNever(() => cache.set(any(), any(), any())); }); @@ -397,7 +397,7 @@ void main() { ); await subject.close(); - verify(() => cache.get(account, any())).called(1); + verify(() => cache.get(account.id, any())).called(1); verifyNever(() => cache.set(any(), any(), any())); when(() => cache.get(any(), any())).thenAnswer( @@ -433,7 +433,7 @@ void main() { ); await subject.close(); - verify(() => cache.get(account, any())).called(1); + verify(() => cache.get(account.id, any())).called(1); verify(() => cache.set(any(), any(), any())).called(1); }); @@ -486,9 +486,9 @@ void main() { ); await subject.close(); - verify(() => cache.get(account, any())).called(1); + verify(() => cache.get(account.id, any())).called(1); verify(callback.call).called(1); - verify(() => cache.updateHeaders(account, any(), {'etag': 'a', 'expires': newExpires})).called(1); + verify(() => cache.updateHeaders(account.id, any(), {'etag': 'a', 'expires': newExpires})).called(1); verifyNever(() => cache.set(any(), any(), any())); when(() => cache.get(any(), any())).thenAnswer( @@ -531,11 +531,11 @@ void main() { ); await subject.close(); - verify(() => cache.get(account, any())).called(1); + verify(() => cache.get(account.id, any())).called(1); verify(callback.call).called(1); verify( () => cache.set( - account, + account.id, any(), any( that: isA() @@ -589,10 +589,10 @@ void main() { ); await subject.close(); - verify(() => cache.get(account, any())).called(1); + verify(() => cache.get(account.id, any())).called(1); verify( () => cache.set( - account, + account.id, any(), any( that: isA() diff --git a/packages/neon_framework/test/storage/settings_store_test.dart b/packages/neon_framework/test/storage/settings_store_test.dart new file mode 100644 index 00000000000..66639bbb392 --- /dev/null +++ b/packages/neon_framework/test/storage/settings_store_test.dart @@ -0,0 +1,48 @@ +// ignore_for_file: inference_failure_on_instance_creation + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:neon_framework/src/storage/settings_store.dart'; +import 'package:neon_framework/storage.dart'; + +class _MockCachedPersistence extends Mock implements CachedPersistence {} + +void main() { + group('Storages', () { + late CachedPersistence persistence; + + setUp(() { + persistence = _MockCachedPersistence(); + }); + + test('DefaultSettingsStore', () async { + final appStorage = DefaultSettingsStore(persistence, StorageKeys.accountOptions.value); + const key = 'key'; + + when(() => persistence.remove(key)).thenAnswer((_) => Future.value(false)); + dynamic result = await appStorage.remove(key); + expect(result, equals(false)); + verify(() => persistence.remove(key)).called(1); + + when(() => persistence.getValue(key)).thenReturn(null); + result = appStorage.getString(key); + expect(result, isNull); + verify(() => persistence.getValue(key)).called(1); + + when(() => persistence.setValue(key, 'value')).thenAnswer((_) => Future.value(false)); + result = await appStorage.setString(key, 'value'); + expect(result, false); + verify(() => persistence.setValue(key, 'value')).called(1); + + when(() => persistence.getValue(key)).thenReturn(true); + result = appStorage.getBool(key); + expect(result, equals(true)); + verify(() => persistence.getValue(key)).called(1); + + when(() => persistence.setValue(key, true)).thenAnswer((_) => Future.value(true)); + result = await appStorage.setBool(key, true); + expect(result, true); + verify(() => persistence.setValue(key, true)).called(1); + }); + }); +} diff --git a/packages/neon_framework/test/storage_test.dart b/packages/neon_framework/test/storage/single_value_store_test.dart similarity index 59% rename from packages/neon_framework/test/storage_test.dart rename to packages/neon_framework/test/storage/single_value_store_test.dart index 492552982d9..540f15e0ec0 100644 --- a/packages/neon_framework/test/storage_test.dart +++ b/packages/neon_framework/test/storage/single_value_store_test.dart @@ -3,50 +3,20 @@ import 'package:built_collection/built_collection.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.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:neon_framework/storage.dart'; + +class _MockCachedPersistence extends Mock implements CachedPersistence {} void main() { group('Storages', () { - late MockCachedPersistence persistence; + late CachedPersistence persistence; setUp(() { - persistence = MockCachedPersistence(); - }); - - test('AppStorage interface', () async { - final appStorage = DefaultSettingsStore(persistence, StorageKeys.accountOptions.value); - const key = 'key'; - - when(() => persistence.remove(key)).thenAnswer((_) => Future.value(false)); - dynamic result = await appStorage.remove(key); - expect(result, equals(false)); - verify(() => persistence.remove(key)).called(1); - - when(() => persistence.getValue(key)).thenReturn(null); - result = appStorage.getString(key); - expect(result, isNull); - verify(() => persistence.getValue(key)).called(1); - - when(() => persistence.setValue(key, 'value')).thenAnswer((_) => Future.value(false)); - result = await appStorage.setString(key, 'value'); - expect(result, false); - verify(() => persistence.setValue(key, 'value')).called(1); - - when(() => persistence.getValue(key)).thenReturn(true); - result = appStorage.getBool(key); - expect(result, equals(true)); - verify(() => persistence.getValue(key)).called(1); - - when(() => persistence.setValue(key, true)).thenAnswer((_) => Future.value(true)); - result = await appStorage.setBool(key, true); - expect(result, true); - verify(() => persistence.setValue(key, true)).called(1); + persistence = _MockCachedPersistence(); }); - test('SingleValueStorage', () async { + test('DefaultSingleValueStore', () async { final storage = DefaultSingleValueStore(persistence, StorageKeys.global); final key = StorageKeys.global.value;