From be5fb0da3cb5e635b71ac8e8b4b38faccefa18b9 Mon Sep 17 00:00:00 2001 From: Nikolas Rimikis Date: Sat, 21 Sep 2024 13:59:29 +0200 Subject: [PATCH 1/5] refactor(neon_framework): make request cache account independent Signed-off-by: Nikolas Rimikis --- .../lib/src/storage/request_cache.dart | 25 ++++++++--------- .../lib/src/utils/request_manager.dart | 6 ++-- .../neon_framework/test/persistence_test.dart | 25 ++++++++--------- .../test/request_manager_test.dart | 28 +++++++++---------- 4 files changed, 40 insertions(+), 44 deletions(-) diff --git a/packages/neon_framework/lib/src/storage/request_cache.dart b/packages/neon_framework/lib/src/storage/request_cache.dart index 24f7a49db10..dd5c53be198 100644 --- a/packages/neon_framework/lib/src/storage/request_cache.dart +++ b/packages/neon_framework/lib/src/storage/request_cache.dart @@ -5,7 +5,6 @@ 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'; @@ -16,15 +15,15 @@ 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); + 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(Account account, http.Request request, http.Response response); + Future set(String accountID, 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); + Future updateHeaders(String accountID, http.Request request, Map headers); } /// Default implementation of the [RequestCache]. @@ -105,7 +104,7 @@ CREATE TABLE "cache" ( } @override - Future get(Account account, http.Request request) async { + Future get(String accountID, http.Request request) async { List>? result; try { result = await _requireDatabase.rawQuery( @@ -121,7 +120,7 @@ WHERE account = ? AND request_body = ? ''', [ - account.id, + accountID, request.method.toUpperCase(), request.url.toString(), json.encode(sanitizeRequestHeaders(request.headers)), @@ -149,7 +148,7 @@ 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); @@ -160,7 +159,7 @@ WHERE account = ? ..update( 'cache', { - 'account': account.id, + 'account': accountID, 'request_method': request.method.toUpperCase(), 'request_url': request.url.toString(), 'request_headers': encodedRequestHeaders, @@ -171,7 +170,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 +193,7 @@ SELECT ?, ?, ?, ?, ?, ?, ?, ? WHERE (SELECT changes() = 0) ''', [ - account.id, + accountID, request.method.toUpperCase(), request.url.toString(), encodedRequestHeaders, @@ -215,17 +214,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( '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/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/test/persistence_test.dart b/packages/neon_framework/test/persistence_test.dart index 7f5108ac976..afd49b4cef4 100644 --- a/packages/neon_framework/test/persistence_test.dart +++ b/packages/neon_framework/test/persistence_test.dart @@ -8,15 +8,12 @@ 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(() { @@ -35,7 +32,7 @@ void main() { test('init', () { cache.database = null; - expect(() async => cache.get(account, http.Request('GET', Uri())), throwsA(isA())); + expect(() async => cache.get('accountID', http.Request('GET', Uri())), throwsA(isA())); }); test('RequestCache', () async { @@ -43,11 +40,11 @@ void main() { ..headers.addAll({'a': 'b'}) ..body = 'c'; - var result = await cache.get(account, request); + var result = await cache.get('accountID', request); expect(result, isNull); - await cache.set(account, request, http.Response('value', 200, headers: {'key': 'value'})); - result = await cache.get(account, request); + 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'}); @@ -66,24 +63,24 @@ void main() { ..headers.addAll({'a': 'b'}) ..body = 'e', ]) { - result = await cache.get(account, modifiedRequest); + result = await cache.get('accountID', modifiedRequest); expect(result, isNull); } - await cache.set(account, request, http.Response('upsert', 201, headers: {'key': 'updated'})); - result = await cache.get(account, request); + 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(account, request, http.Response('value', 200, headers: {'key': 'value'})); - result = await cache.get(account, request); + 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(account, request, {'key': 'updated'}); - result = await cache.get(account, request); + 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/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() From e3de7c94f529dd8dcd896da8b86290fa453807c4 Mon Sep 17 00:00:00 2001 From: Nikolas Rimikis Date: Sat, 21 Sep 2024 14:00:25 +0200 Subject: [PATCH 2/5] feat(neon_storage): migrate neon storages into the neon_storage library This is temporary and the long term goal is to remove most of these classes as the framework transitions to direct sqlite access. Signed-off-by: Nikolas Rimikis --- .../neon_storage/analysis_options.yaml | 1 + .../neon_storage/lib/neon_storage.dart | 5 + .../src/interfaces/cached_persistence.dart | 53 +++ .../lib/src/interfaces/interfaces.dart | 6 + .../src/interfaces/persistence_interface.dart | 23 ++ .../interfaces/request_cache_interface.dart | 15 + .../interfaces/settings_store_interface.dart | 32 ++ .../single_value_store_interface.dart | 43 +++ .../src/interfaces/storable_interface.dart | 7 + .../lib/src/sqlite/multi_table_database.dart | 4 +- .../lib/src/storage/request_cache.dart | 201 ++++++++++ .../storage/sqlite_cookie_persistence.dart | 356 ++++++++++++++++++ .../lib/src/storage/sqlite_persistence.dart | 242 ++++++++++++ .../neon_storage/lib/src/storage/storage.dart | 3 + .../interfaces/cached_persistence_test.dart | 53 +++ .../persistence_interface_test.dart | 32 ++ .../request_cache_interface_test.dart | 26 ++ .../settings_store_interface_test.dart | 33 ++ .../single_value_interface_test.dart | 40 ++ .../interfaces/storable_interface_test.dart | 15 + .../test/storage/request_cache_test.dart | 77 ++++ .../sqlite_cookie_persistence_test.dart | 76 ++++ .../test/storage/sqlite_persistence_test.dart | 131 +++++++ 23 files changed, 1472 insertions(+), 2 deletions(-) create mode 100644 packages/neon_framework/packages/neon_storage/lib/neon_storage.dart create mode 100644 packages/neon_framework/packages/neon_storage/lib/src/interfaces/cached_persistence.dart create mode 100644 packages/neon_framework/packages/neon_storage/lib/src/interfaces/interfaces.dart create mode 100644 packages/neon_framework/packages/neon_storage/lib/src/interfaces/persistence_interface.dart create mode 100644 packages/neon_framework/packages/neon_storage/lib/src/interfaces/request_cache_interface.dart create mode 100644 packages/neon_framework/packages/neon_storage/lib/src/interfaces/settings_store_interface.dart create mode 100644 packages/neon_framework/packages/neon_storage/lib/src/interfaces/single_value_store_interface.dart create mode 100644 packages/neon_framework/packages/neon_storage/lib/src/interfaces/storable_interface.dart create mode 100644 packages/neon_framework/packages/neon_storage/lib/src/storage/request_cache.dart create mode 100644 packages/neon_framework/packages/neon_storage/lib/src/storage/sqlite_cookie_persistence.dart create mode 100644 packages/neon_framework/packages/neon_storage/lib/src/storage/sqlite_persistence.dart create mode 100644 packages/neon_framework/packages/neon_storage/lib/src/storage/storage.dart create mode 100644 packages/neon_framework/packages/neon_storage/test/interfaces/cached_persistence_test.dart create mode 100644 packages/neon_framework/packages/neon_storage/test/interfaces/persistence_interface_test.dart create mode 100644 packages/neon_framework/packages/neon_storage/test/interfaces/request_cache_interface_test.dart create mode 100644 packages/neon_framework/packages/neon_storage/test/interfaces/settings_store_interface_test.dart create mode 100644 packages/neon_framework/packages/neon_storage/test/interfaces/single_value_interface_test.dart create mode 100644 packages/neon_framework/packages/neon_storage/test/interfaces/storable_interface_test.dart create mode 100644 packages/neon_framework/packages/neon_storage/test/storage/request_cache_test.dart create mode 100644 packages/neon_framework/packages/neon_storage/test/storage/sqlite_cookie_persistence_test.dart create mode 100644 packages/neon_framework/packages/neon_storage/test/storage/sqlite_persistence_test.dart 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/packages/neon_storage/lib/src/interfaces/cached_persistence.dart b/packages/neon_framework/packages/neon_storage/lib/src/interfaces/cached_persistence.dart new file mode 100644 index 00000000000..a8b13fc6b70 --- /dev/null +++ b/packages/neon_framework/packages/neon_storage/lib/src/interfaces/cached_persistence.dart @@ -0,0 +1,53 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; +import 'package:neon_storage/neon_storage.dart'; + +/// A key value persistence that caches read values to be accessed +/// synchronously. +/// +/// Mutating values is asynchronous. +abstract class CachedPersistence implements Persistence { + /// Fetches the latest values from the host platform. + /// + /// Use this method to observe modifications that were made in the background + /// like another isolate or native code while the app is already running. + Future reload(); + + /// The cache that holds all values. + /// + /// It is instantiated to the current state of the backing database and then + /// kept in sync via setter methods in this class. + /// + /// 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 = {}; + + @override + T? getValue(String key) => cache[key]; + + /// Saves a [value] to the cached storage. + /// + /// Use this method to cache type conversions of the value that do not change + /// the meaning of the actual value like a `BuiltList` to `List` conversion. + /// Changes will not be persisted to the backing storage and will be lost + /// when the app is restarted. + void setCache(String key, T value) => cache[key] = value; + + @override + List keys() => cache.keys.toList(); + + @override + bool containsKey(String key) => cache.containsKey(key); + + @override + Future clear(); + + @override + Future remove(String key); + + @override + Future setValue(String key, T value); +} 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/packages/neon_storage/lib/src/storage/request_cache.dart b/packages/neon_framework/packages/neon_storage/lib/src/storage/request_cache.dart new file mode 100644 index 00000000000..2fe66067b26 --- /dev/null +++ b/packages/neon_framework/packages/neon_storage/lib/src/storage/request_cache.dart @@ -0,0 +1,201 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:http/http.dart' as http; +import 'package:logging/logging.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'); + +final class _SQLiteRequestCacheTable extends Table { + @override + String get name => 'request_cache'; + + @override + int get version => 7; + + @override + void onCreate(Batch db, int version) { + db.execute(''' +CREATE TABLE "cache" ( + "account" TEXT NOT NULL, + "request_method" TEXT NOT NULL, + "request_url" TEXT NOT NULL, + "request_headers" TEXT NOT NULL, + "request_body" BLOB NOT NULL, + "response_status_code" INTEGER NOT NULL, + "response_headers" TEXT NOT NULL, + "response_body" BLOB NOT NULL, + PRIMARY KEY("account", "request_method", "request_url", "request_headers", "request_body") +); +'''); + } +} + +/// 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(); + + /// The sqlite table backing this storage. + static final Table table = _SQLiteRequestCacheTable(); + + static Database get _database => table.controller.database; + + @override + Future get(String accountID, http.Request request) async { + List>? result; + try { + result = await _database.rawQuery( + ''' +SELECT response_status_code, + response_headers, + response_body +FROM cache +WHERE account = ? + AND request_method = ? + AND request_url = ? + AND request_headers = ? + AND request_body = ? +''', + [ + accountID, + request.method.toUpperCase(), + request.url.toString(), + json.encode(sanitizeRequestHeaders(request.headers)), + request.bodyBytes, + ], + ); + } on DatabaseException catch (error, stackTrace) { + _log.severe( + 'Error while getting `$request` from cache.', + error, + stackTrace, + ); + } + + final row = result?.singleOrNull; + if (row == null) { + return null; + } + + return http.Response.bytes( + row['response_body']! as Uint8List, + row['response_status_code']! as int, + headers: (json.decode(row['response_headers']! as String) as Map).cast(), + ); + } + + @override + 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 = _database.batch() + ..update( + 'cache', + { + 'account': accountID, + 'request_method': request.method.toUpperCase(), + 'request_url': request.url.toString(), + 'request_headers': encodedRequestHeaders, + 'request_body': request.bodyBytes, + 'response_status_code': response.statusCode, + 'response_headers': encodedResponseHeaders, + 'response_body': response.bodyBytes, + }, + where: 'account = ? AND request_method = ? AND request_url = ? AND request_headers = ? AND request_body = ?', + whereArgs: [ + accountID, + request.method.toUpperCase(), + request.url.toString(), + encodedRequestHeaders, + request.bodyBytes, + ], + ) + ..rawInsert( + ''' +INSERT INTO cache ( + account, + request_method, + request_url, + request_headers, + request_body, + response_status_code, + response_headers, + response_body +) +SELECT ?, ?, ?, ?, ?, ?, ?, ? +WHERE (SELECT changes() = 0) +''', + [ + accountID, + request.method.toUpperCase(), + request.url.toString(), + encodedRequestHeaders, + request.bodyBytes, + response.statusCode, + encodedResponseHeaders, + response.bodyBytes, + ], + ); + await batch.commit(noResult: true); + } on DatabaseException catch (error, stackTrace) { + _log.severe( + 'Error while setting `$request` in the cache.', + error, + stackTrace, + ); + } + } + + @override + Future updateHeaders(String accountID, http.Request request, Map headers) async { + try { + await _database.update( + 'cache', + { + 'account': accountID, + 'response_headers': json.encode(headers), + }, + where: 'account = ? AND request_method = ? AND request_url = ? AND request_headers = ? AND request_body = ?', + whereArgs: [ + accountID, + request.method.toUpperCase(), + request.url.toString(), + json.encode(sanitizeRequestHeaders(request.headers)), + request.bodyBytes, + ], + ); + } on DatabaseException catch (error, stackTrace) { + _log.severe( + 'Error while updating headers at `$request`.', + error, + stackTrace, + ); + } + } + + /// Ensures header names are case-insensitive and removes headers that would potentially prevent caching. + Map sanitizeRequestHeaders(Map headers) { + return headers.map((key, value) => MapEntry(key.toLowerCase(), value)) + ..remove('authorization') + ..remove('cookie') + ..remove('user-agent'); + } +} diff --git a/packages/neon_framework/packages/neon_storage/lib/src/storage/sqlite_cookie_persistence.dart b/packages/neon_framework/packages/neon_storage/lib/src/storage/sqlite_cookie_persistence.dart new file mode 100644 index 00000000000..435e0d2a988 --- /dev/null +++ b/packages/neon_framework/packages/neon_storage/lib/src/storage/sqlite_cookie_persistence.dart @@ -0,0 +1,356 @@ +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_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]. +final class SQLiteCookiePersistence implements CookiePersistence { + /// Creates a new SQLite backed cookie persistence for the given account. + /// + /// Optionally [allowedBaseUri] can be used to restrict storage and loading of cookies to a certain domain and path. + SQLiteCookiePersistence({ + required this.accountID, + Uri? allowedBaseUri, + }) : allowedBaseUri = switch (allowedBaseUri) { + null => null, + Uri(:final path) when path.endsWith('/') => Uri( + host: allowedBaseUri.host, + path: path.substring(0, path.length - 1), + ), + _ => allowedBaseUri, + }; + + /// The allowed cookie base uri. + /// + /// When not null, only cookies domain and path matching this uri will be + /// persisted. + /// + /// See: + /// * domain matching [RFC6265 section 5.1.3](https://httpwg.org/specs/rfc6265.html#rfc.section.5.1.3) + /// * path matching [RFC6265 section 5.1.4](https://httpwg.org/specs/rfc6265.html#rfc.section.5.1.4) + final Uri? allowedBaseUri; + + /// The id of the requesting account. + final String accountID; + + /// The sqlite table backing this storage. + static final Table table = _SQLiteCookiePersistenceTable(); + + static Database get _database => table.controller.database; + + @override + Future> loadForRequest(Uri uri) async { + final requestHost = uri.host; + var requestPath = uri.path; + if (requestPath.isEmpty) { + requestPath = '/'; + } + + if (!_isAllowedUri(requestHost, requestPath)) { + return []; + } + + final isHttpRequest = isHttpUri(uri); + final isSecureRequest = isSecureUri(uri); + + // domain and patch matching is error prone in SQL; + // use the dart helpers for now. + try { + final results = await _database.rawQuery( + ''' +SELECT "name", "domain", "path", "value" +FROM "cookies" +WHERE "account" = ? + AND ("domain" = ? OR "host-only-flag" = 0) + AND ("secure-only-flag" = 0 OR ?) + AND ("http-only-flag" = 0 OR ?) + AND "expiry-time" >= STRFTIME('%s') + AND "last-access-time" >= $_lastAccessMaxDuration +ORDER BY + length("path") DESC, + "creation-time" ASC +''', + [ + accountID, + requestHost, + if (isSecureRequest) 1 else 0, + if (isHttpRequest) 1 else 0, + ], + ); + + final list = []; + final batch = _database.batch(); + + for (final result in results) { + final name = result['name']! as String; + final domain = result['domain']! as String; + final path = result['path']! as String; + final value = result['value']! as String; + + if (!isDomainMatch(requestHost, domain) || !isPathMatch(requestPath, path)) { + continue; + } + + final cookie = Cookie(name, value); + list.add(cookie); + + batch.rawUpdate( + ''' +UPDATE "cookies" +SET "last-access-time" = STRFTIME('%s') +WHERE "account" = ? + AND "name" = ? + AND "domain" = ? + AND "path" = ? +''', + [ + accountID, + name, + domain, + path, + ], + ); + } + + if (batch.length >= 1) { + await batch.commit(noResult: true); + } + + return list; + } on DatabaseException catch (error, stackTrace) { + _log.warning( + 'Error loading cookies.', + error, + stackTrace, + ); + } + + return []; + } + + @override + Future saveFromResponse(Set cookies, {required bool isHttpRequest}) async { + if (cookies.isEmpty) { + return true; + } + + final batch = _database.batch(); + + for (final cookie in cookies) { + batch + ..update( + table.name, + { + '"name"': cookie.name, + '"value"': cookie.value, + '"expiry-time"': cookie.expiryTime.secondsSinceEpoch, + '"domain"': cookie.domain, + '"path"': cookie.path, + // DO NOT update the creation time + '"last-access-time"': cookie.lastAccessTime.secondsSinceEpoch, + '"persistent-flag"': cookie.persistentFlag ? 1 : 0, + '"host-only-flag"': cookie.hostOnlyFlag ? 1 : 0, + '"secure-only-flag"': cookie.secureOnlyFlag ? 1 : 0, + '"http-only-flag"': cookie.httpOnlyFlag ? 1 : 0, + }, + where: ''' +"account" = ? +AND "name" = ? +AND "domain" = ? +AND "path" = ? +AND ("http-only-flag" = 0 OR ? = 1) +''', + whereArgs: [ + accountID, + cookie.name, + cookie.domain, + cookie.path, + if (isHttpRequest) 1 else 0, + ], + ) + // Ignore already present records. + // This happens when the cookie was not updated because of the http-only-flag + ..rawInsert( + ''' +INSERT OR IGNORE INTO cookies ("account", "name", "value", "expiry-time", "domain", "path", "creation-time", "last-access-time", "persistent-flag", "host-only-flag", "secure-only-flag", "http-only-flag") +SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? +WHERE (SELECT changes() = 0) +''', + [ + accountID, + cookie.name, + cookie.value, + cookie.expiryTime.secondsSinceEpoch, + cookie.domain, + cookie.path, + cookie.creationTime.secondsSinceEpoch, + cookie.lastAccessTime.secondsSinceEpoch, + if (cookie.persistentFlag) 1 else 0, + if (cookie.hostOnlyFlag) 1 else 0, + if (cookie.secureOnlyFlag) 1 else 0, + if (cookie.httpOnlyFlag) 1 else 0, + ], + ); + } + + try { + await batch.commit(noResult: true); + } on DatabaseException catch (error, stackTrace) { + _log.warning( + 'Error persisting cookies.', + error, + stackTrace, + ); + + return false; + } + + return true; + } + + @override + Future endSession() async { + try { + await _database.execute( + ''' +DELETE FROM cookies +WHERE + "account" = ? + AND ("persistent-flag" = 0 + OR "expiry-time" <= STRFTIME('%s')) +''', + [accountID], + ); + + return true; + } on DatabaseException catch (error, stackTrace) { + _log.warning( + 'Error removing session cookies.', + error, + stackTrace, + ); + + return false; + } + } + + /// Not implemented by this persistence. + @override + bool deleteWhere(bool Function(Cookie cookie) test) { + throw UnsupportedError(_unsupportedCookieManagementMessage); + } + + /// Not implemented by this persistence. + @override + bool deleteAll() { + throw UnsupportedError(_unsupportedCookieManagementMessage); + } + + /// This method is only meant for testing. + @visibleForTesting + @override + Future> loadAll() async { + final list = []; + + try { + final results = await _database.rawQuery( + ''' +SELECT "name", "value" +FROM "cookies" +WHERE "account" = ? +''', + [accountID], + ); + + for (final result in results) { + final cookie = Cookie( + result['name']! as String, + result['value']! as String, + ); + + list.add(cookie); + } + } on DatabaseException catch (error, stackTrace) { + _log.warning( + 'Error loading cookies.', + error, + stackTrace, + ); + } + + return list; + } + + bool _isAllowedUri(String domain, String path) { + final allowedBaseUri = this.allowedBaseUri; + if (allowedBaseUri == null) { + return true; + } + + if (allowedBaseUri.host != domain) { + return false; + } + + if (allowedBaseUri.path.isNotEmpty && !isPathMatch(path, allowedBaseUri.path)) { + return false; + } + + return true; + } +} diff --git a/packages/neon_framework/packages/neon_storage/lib/src/storage/sqlite_persistence.dart b/packages/neon_framework/packages/neon_storage/lib/src/storage/sqlite_persistence.dart new file mode 100644 index 00000000000..21dcd242125 --- /dev/null +++ b/packages/neon_framework/packages/neon_storage/lib/src/storage/sqlite_persistence.dart @@ -0,0 +1,242 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:built_collection/built_collection.dart'; +import 'package:logging/logging.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. +/// Use the [prefix] to separate different storages. +/// Keys within a storage must be unique. +/// +/// 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. +final class SQLiteCachedPersistence extends CachedPersistence { + /// Creates a new sqlite persistence. + SQLiteCachedPersistence({this.prefix = ''}); + + /// The prefix of this persistence. + /// + /// Keys within it must be unique. + final String prefix; + + @override + Map get cache => globalCache[prefix] ??= {}; + + /// Global cache for all persistences. + @visibleForTesting + static final Map> globalCache = {}; + + /// The sqlite table for this persistence. + static Table table = _SQLiteCachedPersistenceTable(); + + static Database get _database => table.controller.database; + + /// Loads all saved values into the cache. + @visibleForTesting + static Future getAll() async { + try { + final results = await _database.rawQuery(''' +SELECT prefix, key, value +FROM preferences +'''); + + globalCache.clear(); + for (final result in results) { + final prefix = result['prefix']! as String; + final key = result['key']! as String; + final value = result['value']! as String; + + final cache = globalCache[prefix] ??= {}; + cache[key] = _decode(value) as Object; + } + } on DatabaseException catch (error) { + _log.warning( + 'Error fetching all values from the SQLite persistence.', + error, + ); + } + } + + @override + Future clear() async { + try { + await _database.rawDelete( + ''' +DELETE FROM preferences +WHERE prefix = ? +''', + [prefix], + ); + cache.clear(); + } on DatabaseException catch (error) { + _log.warning( + 'Error clearing the SQLite persistence.', + error, + ); + + return false; + } + + return true; + } + + @override + Future reload() async { + try { + final fromSystem = {}; + + final results = await _database.rawQuery( + ''' +SELECT key, value +FROM preferences +WHERE prefix = ? +''', + [prefix], + ); + for (final result in results) { + final key = result['key']! as String; + final value = result['value']! as String; + + fromSystem[key] = _decode(value) as Object; + } + + cache + ..clear() + ..addAll(fromSystem); + } on DatabaseException catch (error) { + _log.warning( + 'Error reloading the SQLite persistence.', + error, + ); + } + } + + @override + Future remove(String key) async { + try { + await _database.rawDelete( + ''' +DELETE FROM preferences +WHERE + prefix = ? + AND key = ? +''', + [prefix, key], + ); + + cache.remove(key); + } on DatabaseException catch (error) { + _log.warning( + 'Error removing the value from the SQLite persistence.', + error, + ); + return false; + } + + return true; + } + + @override + Future setValue(String key, Object value) async { + final serialized = _encode(value); + + 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( + 'preferences', + { + 'prefix': prefix, + 'key': key, + 'value': serialized, + }, + where: 'prefix = ? AND key = ?', + whereArgs: [prefix, key], + ) + ..rawInsert( + ''' +INSERT INTO preferences (prefix, key, value) +SELECT ?, ?, ? +WHERE (SELECT changes() = 0) +''', + [prefix, key, serialized], + ); + await batch.commit(noResult: true); + + cache[key] = value; + } on DatabaseException catch (error) { + _log.warning( + 'Error updating the storage value.', + error, + ); + + return false; + } + + return true; + } + + static dynamic _decode(String source) => json.decode( + source, + reviver: (key, value) { + switch (value) { + case List(): + return BuiltList.from(value); + case Map(): + return BuiltMap.from(value); + case _: + return value; + } + }, + ); + + static String _encode(dynamic object) => json.encode( + object, + toEncodable: (nonEncodable) { + switch (nonEncodable) { + case BuiltList(): + return nonEncodable.toList(); + case BuiltMap(): + return nonEncodable.toMap(); + case _: + return nonEncodable; + } + }, + ); +} 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/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); + }); + }); +} From b28dae63ff0db641decacd33909fa70b21afb9d8 Mon Sep 17 00:00:00 2001 From: Nikolas Rimikis Date: Mon, 19 Aug 2024 21:26:55 +0200 Subject: [PATCH 3/5] refactor(neon_framework): migrate to neon_storage Signed-off-by: Nikolas Rimikis --- packages/neon_framework/example/pubspec.lock | 15 +- .../example/pubspec_overrides.yaml | 4 +- packages/neon_framework/lib/neon.dart | 1 - .../lib/src/blocs/accounts.dart | 1 - .../lib/src/blocs/first_launch.dart | 1 - .../lib/src/blocs/push_notifications.dart | 1 - .../lib/src/models/app_implementation.dart | 1 - .../lib/src/platform/linux.dart | 6 +- .../neon_framework/lib/src/platform/web.dart | 6 +- .../utils/settings_export_helper.dart | 2 +- .../lib/src/storage/neon_cache_db.dart | 67 +++ .../lib/src/storage/neon_data_db.dart | 34 ++ .../lib/src/storage/persistence.dart | 73 ---- .../lib/src/storage/request_cache.dart | 250 ------------ .../lib/src/storage/settings_store.dart | 36 +- .../lib/src/storage/single_value_store.dart | 42 +- .../storage/sqlite_cookie_persistence.dart | 380 ------------------ .../lib/src/storage/sqlite_persistence.dart | 264 ------------ .../lib/src/storage/storage.dart | 7 + .../storage/{keys.dart => storage_keys.dart} | 11 +- .../lib/src/storage/storage_manager.dart | 37 +- .../neon_framework/lib/src/testing/mocks.dart | 4 - .../lib/src/utils/global_options.dart | 1 - .../lib/src/utils/push_utils.dart | 1 - packages/neon_framework/lib/storage.dart | 8 +- .../lib/src/testing/testing_account.dart | 2 + .../account_repository/pubspec_overrides.yaml | 4 +- .../dashboard_app/pubspec_overrides.yaml | 4 +- .../packages/files_app/pubspec_overrides.yaml | 4 +- .../neon_storage/pubspec_overrides.yaml | 2 +- .../packages/news_app/pubspec_overrides.yaml | 4 +- .../packages/notes_app/pubspec_overrides.yaml | 4 +- .../notifications_app/pubspec_overrides.yaml | 4 +- .../pubspec_overrides.yaml | 4 +- .../packages/talk_app/pubspec_overrides.yaml | 4 +- packages/neon_framework/pubspec.yaml | 10 +- .../neon_framework/pubspec_overrides.yaml | 4 +- .../test/options_collection_test.dart | 2 +- .../neon_framework/test/persistence_test.dart | 299 -------------- .../test/storage/settings_store_test.dart | 48 +++ .../single_value_store_test.dart} | 42 +- 41 files changed, 226 insertions(+), 1468 deletions(-) create mode 100644 packages/neon_framework/lib/src/storage/neon_cache_db.dart create mode 100644 packages/neon_framework/lib/src/storage/neon_data_db.dart delete mode 100644 packages/neon_framework/lib/src/storage/persistence.dart delete mode 100644 packages/neon_framework/lib/src/storage/request_cache.dart delete mode 100644 packages/neon_framework/lib/src/storage/sqlite_cookie_persistence.dart delete mode 100644 packages/neon_framework/lib/src/storage/sqlite_persistence.dart create mode 100644 packages/neon_framework/lib/src/storage/storage.dart rename packages/neon_framework/lib/src/storage/{keys.dart => storage_keys.dart} (77%) delete mode 100644 packages/neon_framework/test/persistence_test.dart create mode 100644 packages/neon_framework/test/storage/settings_store_test.dart rename packages/neon_framework/test/{storage_test.dart => storage/single_value_store_test.dart} (59%) 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..bb98bf8a5d5 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'; 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..0dafb6afecd --- /dev/null +++ b/packages/neon_framework/lib/src/storage/neon_cache_db.dart @@ -0,0 +1,67 @@ +import 'dart:async'; + +import 'package:cookie_store/cookie_store.dart'; +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(); + } + + /// 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; + } + + assertInitialized(); + + final persistence = SQLiteCookiePersistence( + accountID: accountID, + allowedBaseUri: serverURL, + ); + + return DefaultCookieStore(persistence); + } +} 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/persistence.dart b/packages/neon_framework/lib/src/storage/persistence.dart deleted file mode 100644 index 1a1311c17f8..00000000000 --- a/packages/neon_framework/lib/src/storage/persistence.dart +++ /dev/null @@ -1,73 +0,0 @@ -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); -} - -/// A key value persistence that caches read values to be accessed -/// synchronously. -/// -/// Mutating values is asynchronous. -abstract class CachedPersistence implements Persistence { - /// Fetches the latest values from the host platform. - /// - /// Use this method to observe modifications that were made in the background - /// like another isolate or native code while the app is already running. - Future reload(); - - /// The cache that holds all values. - /// - /// It is instantiated to the current state of the backing database and then - /// kept in sync via setter methods in this class. - /// - /// It is NOT guaranteed that this cache and the backing database will remain - /// in sync since the setter method might fail for any reason. - @protected - final Map cache = {}; - - @override - T? getValue(String key) => cache[key]; - - /// Saves a [value] to the cached storage. - /// - /// Use this method to cache type conversions of the value that do not change - /// the meaning of the actual value like a `BuiltList` to `List` conversion. - /// Changes will not be persisted to the backing storage and will be lost - /// when the app is restarted. - void setCache(String key, T value) => cache[key] = value; - - @override - List keys() => cache.keys.toList(); - - @override - bool containsKey(String key) => cache.containsKey(key); - - @override - Future clear(); - - @override - Future remove(String key); - - @override - Future setValue(String key, T value); -} diff --git a/packages/neon_framework/lib/src/storage/request_cache.dart b/packages/neon_framework/lib/src/storage/request_cache.dart deleted file mode 100644 index dd5c53be198..00000000000 --- a/packages/neon_framework/lib/src/storage/request_cache.dart +++ /dev/null @@ -1,250 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -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/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'; - -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(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); -} - -/// 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; - } - - 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); - } - }); - }, - ); - } - - @visibleForTesting - static Future onCreate(DatabaseExecutor db, [int? version]) async { - await db.execute(''' -CREATE TABLE "cache" ( - "account" TEXT NOT NULL, - "request_method" TEXT NOT NULL, - "request_url" TEXT NOT NULL, - "request_headers" TEXT NOT NULL, - "request_body" BLOB NOT NULL, - "response_status_code" INTEGER NOT NULL, - "response_headers" TEXT NOT NULL, - "response_body" BLOB NOT NULL, - PRIMARY KEY("account", "request_method", "request_url", "request_headers", "request_body") -); -'''); - } - - 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.', - ); - } - - return database; - } - - @override - Future get(String accountID, http.Request request) async { - List>? result; - try { - result = await _requireDatabase.rawQuery( - ''' -SELECT response_status_code, - response_headers, - response_body -FROM cache -WHERE account = ? - AND request_method = ? - AND request_url = ? - AND request_headers = ? - AND request_body = ? -''', - [ - accountID, - request.method.toUpperCase(), - request.url.toString(), - json.encode(sanitizeRequestHeaders(request.headers)), - request.bodyBytes, - ], - ); - } on DatabaseException catch (error, stackTrace) { - _log.severe( - 'Error while getting `$request` from cache.', - error, - stackTrace, - ); - } - - final row = result?.singleOrNull; - if (row == null) { - return null; - } - - return http.Response.bytes( - row['response_body']! as Uint8List, - row['response_status_code']! as int, - headers: (json.decode(row['response_headers']! as String) as Map).cast(), - ); - } - - @override - 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() - ..update( - 'cache', - { - 'account': accountID, - 'request_method': request.method.toUpperCase(), - 'request_url': request.url.toString(), - 'request_headers': encodedRequestHeaders, - 'request_body': request.bodyBytes, - 'response_status_code': response.statusCode, - 'response_headers': encodedResponseHeaders, - 'response_body': response.bodyBytes, - }, - where: 'account = ? AND request_method = ? AND request_url = ? AND request_headers = ? AND request_body = ?', - whereArgs: [ - accountID, - request.method.toUpperCase(), - request.url.toString(), - encodedRequestHeaders, - request.bodyBytes, - ], - ) - ..rawInsert( - ''' -INSERT INTO cache ( - account, - request_method, - request_url, - request_headers, - request_body, - response_status_code, - response_headers, - response_body -) -SELECT ?, ?, ?, ?, ?, ?, ?, ? -WHERE (SELECT changes() = 0) -''', - [ - accountID, - request.method.toUpperCase(), - request.url.toString(), - encodedRequestHeaders, - request.bodyBytes, - response.statusCode, - encodedResponseHeaders, - response.bodyBytes, - ], - ); - await batch.commit(noResult: true); - } on DatabaseException catch (error, stackTrace) { - _log.severe( - 'Error while setting `$request` in the cache.', - error, - stackTrace, - ); - } - } - - @override - Future updateHeaders(String accountID, http.Request request, Map headers) async { - try { - await _requireDatabase.update( - 'cache', - { - 'account': accountID, - 'response_headers': json.encode(headers), - }, - where: 'account = ? AND request_method = ? AND request_url = ? AND request_headers = ? AND request_body = ?', - whereArgs: [ - accountID, - request.method.toUpperCase(), - request.url.toString(), - json.encode(sanitizeRequestHeaders(request.headers)), - request.bodyBytes, - ], - ); - } on DatabaseException catch (error, stackTrace) { - _log.severe( - 'Error while updating headers at `$request`.', - error, - stackTrace, - ); - } - } - - /// Ensures header names are case-insensitive and removes headers that would potentially prevent caching. - Map sanitizeRequestHeaders(Map headers) { - return headers.map((key, value) => MapEntry(key.toLowerCase(), value)) - ..remove('authorization') - ..remove('cookie') - ..remove('user-agent'); - } -} 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/sqlite_cookie_persistence.dart b/packages/neon_framework/lib/src/storage/sqlite_cookie_persistence.dart deleted file mode 100644 index 0cc54189f32..00000000000 --- a/packages/neon_framework/lib/src/storage/sqlite_cookie_persistence.dart +++ /dev/null @@ -1,380 +0,0 @@ -import 'dart:async'; - -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; - -const String _unsupportedCookieManagementMessage = - 'The neon project does not plan to support individual cookie management.'; - -const String _lastAccessMaxDuration = "STRFTIME('%s', 'now', '-1 years')"; - -/// 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. - /// - /// Optionally [allowedBaseUri] can be used to restrict storage and loading of cookies to a certain domain and path. - SQLiteCookiePersistence({ - required this.accountID, - Uri? allowedBaseUri, - }) : allowedBaseUri = switch (allowedBaseUri) { - null => null, - Uri(:final path) when path.endsWith('/') => Uri( - host: allowedBaseUri.host, - path: path.substring(0, path.length - 1), - ), - _ => allowedBaseUri, - }; - - final _log = Logger('SQLiteCookiePersistence'); - - /// The allowed cookie base uri. - /// - /// When not null, only cookies domain and path matching this uri will be - /// persisted. - /// - /// See: - /// * domain matching [RFC6265 section 5.1.3](https://httpwg.org/specs/rfc6265.html#rfc.section.5.1.3) - /// * path matching [RFC6265 section 5.1.4](https://httpwg.org/specs/rfc6265.html#rfc.section.5.1.4) - final Uri? allowedBaseUri; - - /// The id of the requesting account. - final String accountID; - - @visibleForTesting - static Database? database; - - /// 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!; - } - - @override - Future> loadForRequest(Uri uri) async { - final requestHost = uri.host; - var requestPath = uri.path; - if (requestPath.isEmpty) { - requestPath = '/'; - } - - if (!_isAllowedUri(requestHost, requestPath)) { - return []; - } - - final isHttpRequest = isHttpUri(uri); - final isSecureRequest = isSecureUri(uri); - - // domain and patch matching is error prone in SQL; - // use the dart helpers for now. - try { - final results = await _requireDatabase.rawQuery( - ''' -SELECT "name", "domain", "path", "value" -FROM "cookies" -WHERE "account" = ? - AND ("domain" = ? OR "host-only-flag" = 0) - AND ("secure-only-flag" = 0 OR ?) - AND ("http-only-flag" = 0 OR ?) - AND "expiry-time" >= STRFTIME('%s') - AND "last-access-time" >= $_lastAccessMaxDuration -ORDER BY - length("path") DESC, - "creation-time" ASC -''', - [ - accountID, - requestHost, - if (isSecureRequest) 1 else 0, - if (isHttpRequest) 1 else 0, - ], - ); - - final list = []; - final batch = _requireDatabase.batch(); - - for (final result in results) { - final name = result['name']! as String; - final domain = result['domain']! as String; - final path = result['path']! as String; - final value = result['value']! as String; - - if (!isDomainMatch(requestHost, domain) || !isPathMatch(requestPath, path)) { - continue; - } - - final cookie = Cookie(name, value); - list.add(cookie); - - batch.rawUpdate( - ''' -UPDATE "cookies" -SET "last-access-time" = STRFTIME('%s') -WHERE "account" = ? - AND "name" = ? - AND "domain" = ? - AND "path" = ? -''', - [ - accountID, - name, - domain, - path, - ], - ); - } - - if (batch.length >= 1) { - await batch.commit(noResult: true); - } - - return list; - } on DatabaseException catch (error, stackTrace) { - _log.warning( - 'Error loading cookies.', - error, - stackTrace, - ); - } - - return []; - } - - @override - Future saveFromResponse(Set cookies, {required bool isHttpRequest}) async { - if (cookies.isEmpty) { - return true; - } - - final batch = _requireDatabase.batch(); - - for (final cookie in cookies) { - batch - ..update( - 'cookies', - { - '"name"': cookie.name, - '"value"': cookie.value, - '"expiry-time"': cookie.expiryTime.secondsSinceEpoch, - '"domain"': cookie.domain, - '"path"': cookie.path, - // DO NOT update the creation time - '"last-access-time"': cookie.lastAccessTime.secondsSinceEpoch, - '"persistent-flag"': cookie.persistentFlag ? 1 : 0, - '"host-only-flag"': cookie.hostOnlyFlag ? 1 : 0, - '"secure-only-flag"': cookie.secureOnlyFlag ? 1 : 0, - '"http-only-flag"': cookie.httpOnlyFlag ? 1 : 0, - }, - where: ''' -"account" = ? -AND "name" = ? -AND "domain" = ? -AND "path" = ? -AND ("http-only-flag" = 0 OR ? = 1) -''', - whereArgs: [ - accountID, - cookie.name, - cookie.domain, - cookie.path, - if (isHttpRequest) 1 else 0, - ], - ) - // Ignore already present records. - // This happens when the cookie was not updated because of the http-only-flag - ..rawInsert( - ''' -INSERT OR IGNORE INTO cookies ("account", "name", "value", "expiry-time", "domain", "path", "creation-time", "last-access-time", "persistent-flag", "host-only-flag", "secure-only-flag", "http-only-flag") -SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? -WHERE (SELECT changes() = 0) -''', - [ - accountID, - cookie.name, - cookie.value, - cookie.expiryTime.secondsSinceEpoch, - cookie.domain, - cookie.path, - cookie.creationTime.secondsSinceEpoch, - cookie.lastAccessTime.secondsSinceEpoch, - if (cookie.persistentFlag) 1 else 0, - if (cookie.hostOnlyFlag) 1 else 0, - if (cookie.secureOnlyFlag) 1 else 0, - if (cookie.httpOnlyFlag) 1 else 0, - ], - ); - } - - try { - await batch.commit(noResult: true); - } on DatabaseException catch (error, stackTrace) { - _log.warning( - 'Error persisting cookies.', - error, - stackTrace, - ); - - return false; - } - - return true; - } - - @override - Future endSession() async { - try { - await _requireDatabase.execute( - ''' -DELETE FROM cookies -WHERE - "account" = ? - AND ("persistent-flag" = 0 - OR "expiry-time" <= STRFTIME('%s')) -''', - [accountID], - ); - - return true; - } on DatabaseException catch (error, stackTrace) { - _log.warning( - 'Error removing session cookies.', - error, - stackTrace, - ); - - return false; - } - } - - /// Not implemented by this persistence. - @override - bool deleteWhere(bool Function(Cookie cookie) test) { - throw UnsupportedError(_unsupportedCookieManagementMessage); - } - - /// Not implemented by this persistence. - @override - bool deleteAll() { - throw UnsupportedError(_unsupportedCookieManagementMessage); - } - - /// This method is only meant for testing. - @visibleForTesting - @override - Future> loadAll() async { - final list = []; - - try { - final results = await _requireDatabase.rawQuery( - ''' -SELECT "name", "value" -FROM "cookies" -WHERE "account" = ? -''', - [accountID], - ); - - for (final result in results) { - final cookie = Cookie( - result['name']! as String, - result['value']! as String, - ); - - list.add(cookie); - } - } on DatabaseException catch (error, stackTrace) { - _log.warning( - 'Error loading cookies.', - error, - stackTrace, - ); - } - - return list; - } - - bool _isAllowedUri(String domain, String path) { - final allowedBaseUri = this.allowedBaseUri; - if (allowedBaseUri == null) { - return true; - } - - if (allowedBaseUri.host != domain) { - return false; - } - - if (allowedBaseUri.path.isNotEmpty && !isPathMatch(path, allowedBaseUri.path)) { - return false; - } - - return true; - } -} diff --git a/packages/neon_framework/lib/src/storage/sqlite_persistence.dart b/packages/neon_framework/lib/src/storage/sqlite_persistence.dart deleted file mode 100644 index 636a91bb4cb..00000000000 --- a/packages/neon_framework/lib/src/storage/sqlite_persistence.dart +++ /dev/null @@ -1,264 +0,0 @@ -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'; - -final _log = Logger('SQLitePersistence'); - -/// An SQLite backed cached persistence for preferences. -/// -/// There is only one cache backing all `SQLitePersistence` instances. -/// Use the [prefix] to separate different storages. -/// Keys within a storage must be unique. -/// -/// 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 = ''}); - - /// The prefix of this persistence. - /// - /// Keys within it must be unique. - final String prefix; - - @override - Map get cache => globalCache[prefix] ??= {}; - - @visibleForTesting - static final Map> globalCache = {}; - - @visibleForTesting - static Database? database; - - /// 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!; - } - - @visibleForTesting - static Future getAll() async { - try { - final results = await _requireDatabase.rawQuery(''' -SELECT prefix, key, value -FROM preferences -'''); - - globalCache.clear(); - for (final result in results) { - final prefix = result['prefix']! as String; - final key = result['key']! as String; - final value = result['value']! as String; - - final cache = globalCache[prefix] ??= {}; - cache[key] = _decode(value) as Object; - } - } on DatabaseException catch (error) { - _log.warning( - 'Error fetching all values from the SQLite persistence.', - error, - ); - } - } - - @override - Future clear() async { - try { - await _requireDatabase.rawDelete( - ''' -DELETE FROM preferences -WHERE prefix = ? -''', - [prefix], - ); - cache.clear(); - } on DatabaseException catch (error) { - _log.warning( - 'Error clearing the SQLite persistence.', - error, - ); - - return false; - } - - return true; - } - - @override - Future reload() async { - try { - final fromSystem = {}; - - final results = await _requireDatabase.rawQuery( - ''' -SELECT key, value -FROM preferences -WHERE prefix = ? -''', - [prefix], - ); - for (final result in results) { - final key = result['key']! as String; - final value = result['value']! as String; - - fromSystem[key] = _decode(value) as Object; - } - - cache - ..clear() - ..addAll(fromSystem); - } on DatabaseException catch (error) { - _log.warning( - 'Error reloading the SQLite persistence.', - error, - ); - } - } - - @override - Future remove(String key) async { - try { - await _requireDatabase.rawDelete( - ''' -DELETE FROM preferences -WHERE - prefix = ? - AND key = ? -''', - [prefix, key], - ); - - cache.remove(key); - } on DatabaseException catch (error) { - _log.warning( - 'Error removing the value from the SQLite persistence.', - error, - ); - return false; - } - - return true; - } - - @override - Future setValue(String key, Object value) async { - final serialized = _encode(value); - - try { - // UPSERT is only available since SQLite 3.24.0 (June 4, 2018). - // Using a manual solution from https://stackoverflow.com/a/38463024 - final batch = _requireDatabase.batch() - ..update( - 'preferences', - { - 'prefix': prefix, - 'key': key, - 'value': serialized, - }, - where: 'prefix = ? AND key = ?', - whereArgs: [prefix, key], - ) - ..rawInsert( - ''' -INSERT INTO preferences (prefix, key, value) -SELECT ?, ?, ? -WHERE (SELECT changes() = 0) -''', - [prefix, key, serialized], - ); - await batch.commit(noResult: true); - - cache[key] = value; - } on DatabaseException catch (error) { - _log.warning( - 'Error updating the storage value.', - error, - ); - - return false; - } - - return true; - } - - static dynamic _decode(String source) => json.decode( - source, - reviver: (key, value) { - switch (value) { - case List(): - return BuiltList.from(value); - case Map(): - return BuiltMap.from(value); - case _: - return value; - } - }, - ); - - static String _encode(dynamic object) => json.encode( - object, - toEncodable: (nonEncodable) { - switch (nonEncodable) { - case BuiltList(): - return nonEncodable.toList(); - case BuiltMap(): - return nonEncodable.toMap(); - case _: - return nonEncodable; - } - }, - ); -} 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..52a6425e519 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'; /// Neon storage that manages the storage backend. /// @@ -45,27 +39,23 @@ class NeonStorage { return; } - if (!kIsWeb) { - final requestCache = DefaultRequestCache(); - await requestCache.init(); - _requestCache = requestCache; + _neonCache = NeonCacheDB(); + _neonData = NeonDataDB(); - 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`. @@ -101,16 +91,9 @@ class NeonStorage { /// /// 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, - ); + _assertInitialized(); - return DefaultCookieStore(persistence); + return _neonCache.cookieStore(accountID: accountID, serverURL: serverURL); } void _assertInitialized() { diff --git a/packages/neon_framework/lib/src/testing/mocks.dart b/packages/neon_framework/lib/src/testing/mocks.dart index 299d7363e64..33dd474a9dd 100644 --- a/packages/neon_framework/lib/src/testing/mocks.dart +++ b/packages/neon_framework/lib/src/testing/mocks.dart @@ -89,10 +89,6 @@ class FakeNeonStorage extends Fake implements NeonStorage { 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..9da2f9daefd 100644 --- a/packages/neon_framework/lib/src/utils/push_utils.dart +++ b/packages/neon_framework/lib/src/utils/push_utils.dart @@ -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'; 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/testing/testing_account.dart b/packages/neon_framework/packages/account_repository/lib/src/testing/testing_account.dart index f9546990689..c4868cb8366 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 @@ -4,6 +4,7 @@ import 'package:account_repository/testing.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart' as http; import 'package:meta/meta.dart'; +import 'package:neon_framework/testing.dart' show MockNeonStorage; final _mockClient = http.MockClient((request) async { throw UnsupportedError('The fake account client can not be used in tests.'); @@ -19,6 +20,7 @@ Account createAccount({ http.Client? httpClient, }) { credentials ??= createCredentials(); + MockNeonStorage(); return Account((b) { b diff --git a/packages/neon_framework/packages/account_repository/pubspec_overrides.yaml b/packages/neon_framework/packages/account_repository/pubspec_overrides.yaml index 5c2f161f46d..4b435bdc1a4 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_framework,neon_http_client,neon_lints,neon_storage,nextcloud,sort_box dependency_overrides: cookie_store: path: ../../../cookie_store @@ -12,6 +12,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/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/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/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..2ef647cc9f4 100644 --- a/packages/neon_framework/pubspec.yaml +++ b/packages/neon_framework/pubspec.yaml @@ -50,10 +50,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 +68,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 afd49b4cef4..00000000000 --- a/packages/neon_framework/test/persistence_test.dart +++ /dev/null @@ -1,299 +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:sqflite_common_ffi/sqflite_ffi.dart'; -import 'package:universal_io/io.dart' show Cookie; - -void main() { - group('Persistences', () { - group('RequestCache', () { - 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('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'}); - }); - }); - - 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/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; From fef76771f241d4e14f88e0b9edf6d8b096ec4c29 Mon Sep 17 00:00:00 2001 From: Nikolas Rimikis Date: Sat, 21 Sep 2024 16:34:33 +0200 Subject: [PATCH 4/5] refactor(account_repository): manage cookie store in account_repository Signed-off-by: Nikolas Rimikis --- packages/neon_framework/lib/neon.dart | 1 + .../lib/src/storage/neon_cache_db.dart | 19 ---- .../lib/src/storage/storage_manager.dart | 10 -- .../neon_framework/lib/src/testing/mocks.dart | 3 - .../lib/src/utils/push_utils.dart | 3 +- .../lib/src/account_repository.dart | 7 +- .../lib/src/testing/testing_account.dart | 3 +- .../lib/src/utils/http_client_builder.dart | 15 ++- .../packages/account_repository/pubspec.yaml | 4 + .../test/account_repository_test.dart | 1 + .../test/utils/http_client_builder_test.dart | 100 +++++++++--------- packages/neon_framework/pubspec.yaml | 4 - 12 files changed, 77 insertions(+), 93 deletions(-) diff --git a/packages/neon_framework/lib/neon.dart b/packages/neon_framework/lib/neon.dart index bb98bf8a5d5..3deb474f6c5 100644 --- a/packages/neon_framework/lib/neon.dart +++ b/packages/neon_framework/lib/neon.dart @@ -69,6 +69,7 @@ Future runNeon({ 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/storage/neon_cache_db.dart b/packages/neon_framework/lib/src/storage/neon_cache_db.dart index 0dafb6afecd..720a62ea748 100644 --- a/packages/neon_framework/lib/src/storage/neon_cache_db.dart +++ b/packages/neon_framework/lib/src/storage/neon_cache_db.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:cookie_store/cookie_store.dart'; import 'package:flutter/foundation.dart'; import 'package:neon_storage/neon_sqlite.dart'; import 'package:neon_storage/neon_storage.dart'; @@ -46,22 +45,4 @@ final class NeonCacheDB extends MultiTableDatabase { return const SQLiteRequestCache(); } - - /// 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; - } - - assertInitialized(); - - final persistence = SQLiteCookiePersistence( - accountID: accountID, - allowedBaseUri: serverURL, - ); - - return DefaultCookieStore(persistence); - } } diff --git a/packages/neon_framework/lib/src/storage/storage_manager.dart b/packages/neon_framework/lib/src/storage/storage_manager.dart index 52a6425e519..294a2677481 100644 --- a/packages/neon_framework/lib/src/storage/storage_manager.dart +++ b/packages/neon_framework/lib/src/storage/storage_manager.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:cookie_store/cookie_store.dart'; import 'package:meta/meta.dart'; import 'package:neon_framework/src/storage/storage.dart'; @@ -87,15 +86,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}) { - _assertInitialized(); - - return _neonCache.cookieStore(accountID: accountID, serverURL: serverURL); - } - 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 33dd474a9dd..91eae2f296f 100644 --- a/packages/neon_framework/lib/src/testing/mocks.dart +++ b/packages/neon_framework/lib/src/testing/mocks.dart @@ -84,9 +84,6 @@ class FakeNeonStorage extends Fake implements NeonStorage { @override Null get requestCache => null; - - @override - Null cookieStore({required String accountID, required Uri serverURL}) => null; } class MockSettingsStore extends Mock implements SettingsStore {} diff --git a/packages/neon_framework/lib/src/utils/push_utils.dart b/packages/neon_framework/lib/src/utils/push_utils.dart index 9da2f9daefd..d15800a1c78 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; @@ -123,6 +123,7 @@ class PushUtils { userAgent: buildUserAgent(packageInfo), httpClient: http.Client(), storage: accountStorage, + enableCookieStore: !kIsWeb, ); await accountRepository.loadAccounts(); 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..14eed4140d9 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 @@ -79,13 +79,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 +137,7 @@ class AccountRepository { httpClient: _httpClient, userAgent: _userAgent, credentials: credentials, + enableCookieStore: _enableCookieStore, ); }); } @@ -238,6 +242,7 @@ class AccountRepository { credentials: credentials, userAgent: _userAgent, httpClient: _httpClient, + enableCookieStore: _enableCookieStore, ); try { 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 c4868cb8366..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 @@ -4,7 +4,6 @@ import 'package:account_repository/testing.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart' as http; import 'package:meta/meta.dart'; -import 'package:neon_framework/testing.dart' show MockNeonStorage; final _mockClient = http.MockClient((request) async { throw UnsupportedError('The fake account client can not be used in tests.'); @@ -20,7 +19,6 @@ Account createAccount({ http.Client? httpClient, }) { credentials ??= createCredentials(); - MockNeonStorage(); return Account((b) { b @@ -29,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..ea5b4c20e18 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,4 +1,5 @@ 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'; @@ -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..95f00ab6d55 100644 --- a/packages/neon_framework/packages/account_repository/pubspec.yaml +++ b/packages/neon_framework/packages/account_repository/pubspec.yaml @@ -10,6 +10,10 @@ environment: 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 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..445629deff4 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 @@ -76,6 +76,7 @@ void main() { userAgent: 'userAgent', httpClient: _FakeClient(), storage: storage, + enableCookieStore: false, ); }); 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/pubspec.yaml b/packages/neon_framework/pubspec.yaml index 2ef647cc9f4..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. From b7d8a426148e099ec41e70885dfb13f00ffe1e19 Mon Sep 17 00:00:00 2001 From: Nikolas Rimikis Date: Fri, 13 Sep 2024 09:42:33 +0200 Subject: [PATCH 5/5] refactor(account_repository): migrate to neon_storage Signed-off-by: Nikolas Rimikis --- packages/neon_framework/lib/neon.dart | 12 +- .../lib/src/storage/storage_manager.dart | 10 +- .../lib/src/utils/push_utils.dart | 11 +- .../lib/src/account_repository.dart | 21 +- .../lib/src/account_storage.dart | 205 +++++++++++++++--- .../lib/src/utils/http_client_builder.dart | 2 +- .../packages/account_repository/pubspec.yaml | 13 +- .../account_repository/pubspec_overrides.yaml | 6 +- .../test/account_repository_test.dart | 25 ++- .../test/account_storage_test.dart | 149 +++++++------ 10 files changed, 309 insertions(+), 145 deletions(-) diff --git a/packages/neon_framework/lib/neon.dart b/packages/neon_framework/lib/neon.dart index 3deb474f6c5..00ae554edd2 100644 --- a/packages/neon_framework/lib/neon.dart +++ b/packages/neon_framework/lib/neon.dart @@ -52,7 +52,12 @@ Future runNeon({ tz.setLocalLocation(location); } - await NeonStorage().init(); + final accountStorage = AccountStorage(); + await NeonStorage().init( + dataTables: [ + accountStorage, + ], + ); final packageInfo = await PackageInfo.fromPlatform(); @@ -60,11 +65,6 @@ 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(), diff --git a/packages/neon_framework/lib/src/storage/storage_manager.dart b/packages/neon_framework/lib/src/storage/storage_manager.dart index 294a2677481..d76f926429c 100644 --- a/packages/neon_framework/lib/src/storage/storage_manager.dart +++ b/packages/neon_framework/lib/src/storage/storage_manager.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:meta/meta.dart'; import 'package:neon_framework/src/storage/storage.dart'; +import 'package:neon_storage/neon_sqlite.dart'; /// Neon storage that manages the storage backend. /// @@ -33,13 +34,16 @@ 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; } - _neonCache = NeonCacheDB(); - _neonData = NeonDataDB(); + _neonCache = NeonCacheDB(tables: cacheTables); + _neonData = NeonDataDB(tables: dataTables); await _neonCache.init(); await _neonData.init(); diff --git a/packages/neon_framework/lib/src/utils/push_utils.dart b/packages/neon_framework/lib/src/utils/push_utils.dart index d15800a1c78..bbb0b56100a 100644 --- a/packages/neon_framework/lib/src/utils/push_utils.dart +++ b/packages/neon_framework/lib/src/utils/push_utils.dart @@ -94,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; @@ -115,10 +120,6 @@ 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(), 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 14eed4140d9..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'; @@ -272,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), ]); } @@ -293,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)); @@ -329,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/utils/http_client_builder.dart b/packages/neon_framework/packages/account_repository/lib/src/utils/http_client_builder.dart index ea5b4c20e18..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,8 +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]. diff --git a/packages/neon_framework/packages/account_repository/pubspec.yaml b/packages/neon_framework/packages/account_repository/pubspec.yaml index 95f00ab6d55..d45190a32e1 100644 --- a/packages/neon_framework/packages/account_repository/pubspec.yaml +++ b/packages/neon_framework/packages/account_repository/pubspec.yaml @@ -5,7 +5,6 @@ publish_to: none environment: sdk: ^3.0.0 - flutter: ^3.22.0 dependencies: built_collection: ^5.1.1 @@ -17,24 +16,24 @@ dependencies: 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 4b435bdc1a4..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,neon_storage,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,8 +6,6 @@ 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: @@ -16,5 +14,3 @@ dependency_overrides: 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 445629deff4..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(() { @@ -468,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 => {}); }); @@ -487,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); }); }); @@ -496,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(); @@ -519,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 { @@ -533,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 { @@ -554,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); }); }); @@ -596,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), + ); }); }); });