diff --git a/app/lib/app_db.dart b/app/lib/app_db.dart index aeb24340..395c4550 100644 --- a/app/lib/app_db.dart +++ b/app/lib/app_db.dart @@ -8,6 +8,7 @@ import 'package:nc_photos/ci_string.dart'; import 'package:nc_photos/entity/album.dart'; import 'package:nc_photos/entity/album/upgrader.dart'; import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/mobile/platform.dart' if (dart.library.html) 'package:nc_photos/web/platform.dart' as platform; import 'package:nc_photos/num_extension.dart'; @@ -33,13 +34,19 @@ class AppDb { /// This function guarantees that: /// 1) Database is always closed after [fn] exits, even with an error /// 2) Only at most 1 database instance being opened at any time - Future use(FutureOr Function(Database db) fn) async { + Future use(Transaction Function(Database db) transactionBuilder, + FutureOr Function(Transaction transaction) fn) async { // make sure only one client is opening the db - return await _lock.synchronized(() async { + return await platform.Lock.synchronized(k.appDbLockId, () async { final db = await _open(); + Transaction? transaction; try { - return await fn(db); + transaction = transactionBuilder(db); + return await fn(transaction); } finally { + if (transaction != null) { + await transaction.completed; + } db.close(); } }); diff --git a/app/lib/entity/album.dart b/app/lib/entity/album.dart index c5d98755..62ac854e 100644 --- a/app/lib/entity/album.dart +++ b/app/lib/entity/album.dart @@ -398,34 +398,36 @@ class AlbumAppDbDataSource implements AlbumDataSource { @override get(Account account, File albumFile) { _log.info("[get] ${albumFile.path}"); - return appDb.use((db) async { - final transaction = db.transaction(AppDb.albumStoreName, idbModeReadOnly); - final store = transaction.objectStore(AppDb.albumStoreName); - final index = store.index(AppDbAlbumEntry.indexName); - final path = AppDbAlbumEntry.toPathFromFile(account, albumFile); - final range = KeyRange.bound([path, 0], [path, int_util.int32Max]); - final List results = await index.getAll(range); - if (results.isNotEmpty == true) { - final entries = results.map((e) => - AppDbAlbumEntry.fromJson(e.cast(), account)); - if (entries.length > 1) { - final items = entries.map((e) { - _log.info("[get] ${e.path}[${e.index}]"); - return AlbumStaticProvider.of(e.album).items; - }).reduce((value, element) => value + element); - return entries.first.album.copyWith( - lastUpdated: OrNull(null), - provider: AlbumStaticProvider.of(entries.first.album).copyWith( - items: items, - ), - ); + return appDb.use( + (db) => db.transaction(AppDb.albumStoreName, idbModeReadOnly), + (transaction) async { + final store = transaction.objectStore(AppDb.albumStoreName); + final index = store.index(AppDbAlbumEntry.indexName); + final path = AppDbAlbumEntry.toPathFromFile(account, albumFile); + final range = KeyRange.bound([path, 0], [path, int_util.int32Max]); + final List results = await index.getAll(range); + if (results.isNotEmpty == true) { + final entries = results.map((e) => + AppDbAlbumEntry.fromJson(e.cast(), account)); + if (entries.length > 1) { + final items = entries.map((e) { + _log.info("[get] ${e.path}[${e.index}]"); + return AlbumStaticProvider.of(e.album).items; + }).reduce((value, element) => value + element); + return entries.first.album.copyWith( + lastUpdated: OrNull(null), + provider: AlbumStaticProvider.of(entries.first.album).copyWith( + items: items, + ), + ); + } else { + return entries.first.album; + } } else { - return entries.first.album; + throw CacheNotFoundException("No entry: $path"); } - } else { - throw CacheNotFoundException("No entry: $path"); - } - }); + }, + ); } @override @@ -437,12 +439,13 @@ class AlbumAppDbDataSource implements AlbumDataSource { @override update(Account account, Album album) { _log.info("[update] ${album.albumFile!.path}"); - return appDb.use((db) async { - final transaction = - db.transaction(AppDb.albumStoreName, idbModeReadWrite); - final store = transaction.objectStore(AppDb.albumStoreName); - await _cacheAlbum(store, account, album); - }); + return appDb.use( + (db) => db.transaction(AppDb.albumStoreName, idbModeReadWrite), + (transaction) async { + final store = transaction.objectStore(AppDb.albumStoreName); + await _cacheAlbum(store, account, album); + }, + ); } @override @@ -492,43 +495,45 @@ class AlbumCachedDataSource implements AlbumDataSource { @override cleanUp(Account account, String rootDir, List albumFiles) async { - appDb.use((db) async { - final transaction = - db.transaction(AppDb.albumStoreName, idbModeReadWrite); - final store = transaction.objectStore(AppDb.albumStoreName); - final index = store.index(AppDbAlbumEntry.indexName); - final rootPath = AppDbAlbumEntry.toPath(account, rootDir); - final range = KeyRange.bound( - ["$rootPath/", 0], ["$rootPath/\uffff", int_util.int32Max]); - final danglingKeys = await index - // get all albums for this account - .openKeyCursor(range: range, autoAdvance: true) - .map((cursor) => Tuple2((cursor.key as List)[0], cursor.primaryKey)) - // and pick the dangling ones - .where((pair) => !albumFiles.any( - (f) => pair.item1 == AppDbAlbumEntry.toPathFromFile(account, f))) - // map to primary keys - .map((pair) => pair.item2) - .toList(); - for (final k in danglingKeys) { - _log.fine("[cleanUp] Removing albumStore entry: $k"); - try { - await store.delete(k); - } catch (e, stackTrace) { - _log.shout( - "[cleanUp] Failed removing albumStore entry", e, stackTrace); + appDb.use( + (db) => db.transaction(AppDb.albumStoreName, idbModeReadWrite), + (transaction) async { + final store = transaction.objectStore(AppDb.albumStoreName); + final index = store.index(AppDbAlbumEntry.indexName); + final rootPath = AppDbAlbumEntry.toPath(account, rootDir); + final range = KeyRange.bound( + ["$rootPath/", 0], ["$rootPath/\uffff", int_util.int32Max]); + final danglingKeys = await index + // get all albums for this account + .openKeyCursor(range: range, autoAdvance: true) + .map((cursor) => Tuple2((cursor.key as List)[0], cursor.primaryKey)) + // and pick the dangling ones + .where((pair) => !albumFiles.any((f) => + pair.item1 == AppDbAlbumEntry.toPathFromFile(account, f))) + // map to primary keys + .map((pair) => pair.item2) + .toList(); + for (final k in danglingKeys) { + _log.fine("[cleanUp] Removing albumStore entry: $k"); + try { + await store.delete(k); + } catch (e, stackTrace) { + _log.shout( + "[cleanUp] Failed removing albumStore entry", e, stackTrace); + } } - } - }); + }, + ); } Future _cacheResult(Account account, Album result) { - return appDb.use((db) async { - final transaction = - db.transaction(AppDb.albumStoreName, idbModeReadWrite); - final store = transaction.objectStore(AppDb.albumStoreName); - await _cacheAlbum(store, account, result); - }); + return appDb.use( + (db) => db.transaction(AppDb.albumStoreName, idbModeReadWrite), + (transaction) async { + final store = transaction.objectStore(AppDb.albumStoreName); + await _cacheAlbum(store, account, result); + }, + ); } final AppDb appDb; diff --git a/app/lib/entity/file/data_source.dart b/app/lib/entity/file/data_source.dart index 37d23f88..4ac5a6fc 100644 --- a/app/lib/entity/file/data_source.dart +++ b/app/lib/entity/file/data_source.dart @@ -269,31 +269,34 @@ class FileAppDbDataSource implements FileDataSource { @override list(Account account, File dir) { _log.info("[list] ${dir.path}"); - return appDb.use((db) async { - final transaction = db.transaction( - [AppDb.dirStoreName, AppDb.file2StoreName], idbModeReadOnly); - final fileStore = transaction.objectStore(AppDb.file2StoreName); - final dirStore = transaction.objectStore(AppDb.dirStoreName); - final dirItem = await dirStore - .getObject(AppDbDirEntry.toPrimaryKeyForDir(account, dir)) as Map?; - if (dirItem == null) { - throw CacheNotFoundException("No entry: ${dir.path}"); - } - final dirEntry = AppDbDirEntry.fromJson(dirItem.cast()); - final entries = await Future.wait(dirEntry.children.map((c) async { - final fileItem = await fileStore - .getObject(AppDbFile2Entry.toPrimaryKey(account, c)) as Map?; - if (fileItem == null) { - _log.warning( - "[list] Missing file ($c) in db for dir: ${logFilename(dir.path)}"); - throw CacheNotFoundException("No entry for dir child: $c"); + return appDb.use( + (db) => db.transaction( + [AppDb.dirStoreName, AppDb.file2StoreName], idbModeReadOnly), + (transaction) async { + final fileStore = transaction.objectStore(AppDb.file2StoreName); + final dirStore = transaction.objectStore(AppDb.dirStoreName); + final dirItem = await dirStore + .getObject(AppDbDirEntry.toPrimaryKeyForDir(account, dir)) as Map?; + if (dirItem == null) { + throw CacheNotFoundException("No entry: ${dir.path}"); } - return AppDbFile2Entry.fromJson(fileItem.cast()); - })); - // we need to add dir to match the remote query - return [dirEntry.dir] + - entries.map((e) => e.file).where((f) => _validateFile(f)).toList(); - }); + final dirEntry = + AppDbDirEntry.fromJson(dirItem.cast()); + final entries = await Future.wait(dirEntry.children.map((c) async { + final fileItem = await fileStore + .getObject(AppDbFile2Entry.toPrimaryKey(account, c)) as Map?; + if (fileItem == null) { + _log.warning( + "[list] Missing file ($c) in db for dir: ${logFilename(dir.path)}"); + throw CacheNotFoundException("No entry for dir child: $c"); + } + return AppDbFile2Entry.fromJson(fileItem.cast()); + })); + // we need to add dir to match the remote query + return [dirEntry.dir] + + entries.map((e) => e.file).where((f) => _validateFile(f)).toList(); + }, + ); } @override @@ -307,19 +310,21 @@ class FileAppDbDataSource implements FileDataSource { Future> listByDate( Account account, int fromEpochMs, int toEpochMs) async { _log.info("[listByDate] [$fromEpochMs, $toEpochMs]"); - final items = await appDb.use((db) async { - final transaction = db.transaction(AppDb.file2StoreName, idbModeReadOnly); - final fileStore = transaction.objectStore(AppDb.file2StoreName); - final dateTimeEpochMsIndex = - fileStore.index(AppDbFile2Entry.dateTimeEpochMsIndexName); - final range = KeyRange.bound( - AppDbFile2Entry.toDateTimeEpochMsIndexKey(account, fromEpochMs), - AppDbFile2Entry.toDateTimeEpochMsIndexKey(account, toEpochMs), - false, - true, - ); - return await dateTimeEpochMsIndex.getAll(range); - }); + final items = await appDb.use( + (db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly), + (transaction) async { + final fileStore = transaction.objectStore(AppDb.file2StoreName); + final dateTimeEpochMsIndex = + fileStore.index(AppDbFile2Entry.dateTimeEpochMsIndexName); + final range = KeyRange.bound( + AppDbFile2Entry.toDateTimeEpochMsIndexKey(account, fromEpochMs), + AppDbFile2Entry.toDateTimeEpochMsIndexKey(account, toEpochMs), + false, + true, + ); + return await dateTimeEpochMsIndex.getAll(range); + }, + ); return items .cast() .map((i) => AppDbFile2Entry.fromJson(i.cast())) @@ -357,21 +362,21 @@ class FileAppDbDataSource implements FileDataSource { bool? favorite, }) { _log.info("[updateProperty] ${f.path}"); - return appDb.use((db) async { - final transaction = - db.transaction(AppDb.file2StoreName, idbModeReadWrite); - - // update file store - final newFile = f.copyWith( - metadata: metadata, - isArchived: isArchived, - overrideDateTime: overrideDateTime, - isFavorite: favorite, - ); - final fileStore = transaction.objectStore(AppDb.file2StoreName); - await fileStore.put(AppDbFile2Entry.fromFile(account, newFile).toJson(), - AppDbFile2Entry.toPrimaryKeyForFile(account, newFile)); - }); + return appDb.use( + (db) => db.transaction(AppDb.file2StoreName, idbModeReadWrite), + (transaction) async { + // update file store + final newFile = f.copyWith( + metadata: metadata, + isArchived: isArchived, + overrideDateTime: overrideDateTime, + isFavorite: favorite, + ); + final fileStore = transaction.objectStore(AppDb.file2StoreName); + await fileStore.put(AppDbFile2Entry.fromFile(account, newFile).toJson(), + AppDbFile2Entry.toPrimaryKeyForFile(account, newFile)); + }, + ); } @override @@ -577,20 +582,22 @@ class FileForwardCacheManager { } Future _cacheDir(Account account, File dir) async { - final dirItems = await appDb.use((db) async { - final transaction = db.transaction(AppDb.dirStoreName, idbModeReadOnly); - final store = transaction.objectStore(AppDb.dirStoreName); - final dirItem = await store - .getObject(AppDbDirEntry.toPrimaryKeyForDir(account, dir)) as Map?; - if (dirItem == null) { - return null; - } - final range = KeyRange.bound( - AppDbDirEntry.toPrimaryLowerKeyForSubDirs(account, dir), - AppDbDirEntry.toPrimaryUpperKeyForSubDirs(account, dir), - ); - return [dirItem] + (await store.getAll(range)).cast(); - }); + final dirItems = await appDb.use( + (db) => db.transaction(AppDb.dirStoreName, idbModeReadOnly), + (transaction) async { + final store = transaction.objectStore(AppDb.dirStoreName); + final dirItem = await store + .getObject(AppDbDirEntry.toPrimaryKeyForDir(account, dir)) as Map?; + if (dirItem == null) { + return null; + } + final range = KeyRange.bound( + AppDbDirEntry.toPrimaryLowerKeyForSubDirs(account, dir), + AppDbDirEntry.toPrimaryUpperKeyForSubDirs(account, dir), + ); + return [dirItem] + (await store.getAll(range)).cast(); + }, + ); if (dirItems == null) { // no cache return; @@ -606,12 +613,14 @@ class FileForwardCacheManager { // cache files final fileIds = dirs.map((e) => e.children).fold>( [], (previousValue, element) => previousValue + element); - final fileItems = await appDb.use((db) async { - final transaction = db.transaction(AppDb.file2StoreName, idbModeReadOnly); - final store = transaction.objectStore(AppDb.file2StoreName); - return await Future.wait(fileIds.map( - (id) => store.getObject(AppDbFile2Entry.toPrimaryKey(account, id)))); - }); + final fileItems = await appDb.use( + (db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly), + (transaction) async { + final store = transaction.objectStore(AppDb.file2StoreName); + return await Future.wait(fileIds.map((id) => + store.getObject(AppDbFile2Entry.toPrimaryKey(account, id)))); + }, + ); final files = fileItems .cast() .whereType() diff --git a/app/lib/entity/file/file_cache_manager.dart b/app/lib/entity/file/file_cache_manager.dart index 0f15f6b0..8a8b14d2 100644 --- a/app/lib/entity/file/file_cache_manager.dart +++ b/app/lib/entity/file/file_cache_manager.dart @@ -124,27 +124,30 @@ class FileCacheUpdater { } Future _cacheRemote(Account account, File dir, List remote) { - return appDb.use((db) async { - final transaction = db.transaction( - [AppDb.dirStoreName, AppDb.file2StoreName], idbModeReadWrite); - final dirStore = transaction.objectStore(AppDb.dirStoreName); - final fileStore = transaction.objectStore(AppDb.file2StoreName); + return appDb.use( + (db) => db.transaction( + [AppDb.dirStoreName, AppDb.file2StoreName], idbModeReadWrite), + (transaction) async { + final dirStore = transaction.objectStore(AppDb.dirStoreName); + final fileStore = transaction.objectStore(AppDb.file2StoreName); - // add files to db - await Future.wait(remote.map((f) => fileStore.put( - AppDbFile2Entry.fromFile(account, f).toJson(), - AppDbFile2Entry.toPrimaryKeyForFile(account, f)))); + // add files to db + await Future.wait(remote.map((f) => fileStore.put( + AppDbFile2Entry.fromFile(account, f).toJson(), + AppDbFile2Entry.toPrimaryKeyForFile(account, f)))); - // results from remote also contain the dir itself - final resultGroup = - remote.groupListsBy((f) => f.compareServerIdentity(dir)); - final remoteDir = resultGroup[true]!.first; - final remoteChildren = resultGroup[false] ?? []; - // add dir to db - await dirStore.put( - AppDbDirEntry.fromFiles(account, remoteDir, remoteChildren).toJson(), - AppDbDirEntry.toPrimaryKeyForDir(account, remoteDir)); - }); + // results from remote also contain the dir itself + final resultGroup = + remote.groupListsBy((f) => f.compareServerIdentity(dir)); + final remoteDir = resultGroup[true]!.first; + final remoteChildren = resultGroup[false] ?? []; + // add dir to db + await dirStore.put( + AppDbDirEntry.fromFiles(account, remoteDir, remoteChildren) + .toJson(), + AppDbDirEntry.toPrimaryKeyForDir(account, remoteDir)); + }, + ); } /// Remove extra entries from local cache based on remote contents @@ -159,27 +162,29 @@ class FileCacheUpdater { _log.info( "[_cleanUpCache] Removed: ${removed.map((f) => f.path).toReadableString()}"); - await appDb.use((db) async { - final transaction = db.transaction( - [AppDb.dirStoreName, AppDb.file2StoreName], idbModeReadWrite); - final dirStore = transaction.objectStore(AppDb.dirStoreName); - final fileStore = transaction.objectStore(AppDb.file2StoreName); - for (final f in removed) { - try { - if (f.isCollection == true) { - await _removeDirFromAppDb(account, f, - dirStore: dirStore, fileStore: fileStore); - } else { - await _removeFileFromAppDb(account, f, fileStore: fileStore); + await appDb.use( + (db) => db.transaction( + [AppDb.dirStoreName, AppDb.file2StoreName], idbModeReadWrite), + (transaction) async { + final dirStore = transaction.objectStore(AppDb.dirStoreName); + final fileStore = transaction.objectStore(AppDb.file2StoreName); + for (final f in removed) { + try { + if (f.isCollection == true) { + await _removeDirFromAppDb(account, f, + dirStore: dirStore, fileStore: fileStore); + } else { + await _removeFileFromAppDb(account, f, fileStore: fileStore); + } + } catch (e, stackTrace) { + _log.shout( + "[_cleanUpCache] Failed while removing file: ${logFilename(f.path)}", + e, + stackTrace); } - } catch (e, stackTrace) { - _log.shout( - "[_cleanUpCache] Failed while removing file: ${logFilename(f.path)}", - e, - stackTrace); } - } - }); + }, + ); } final AppDb appDb; @@ -198,23 +203,28 @@ class FileCacheRemover { /// If [f] is a file, the file will be removed from file2Store, but no changes /// to dirStore. Future call(Account account, File f) async { - await appDb.use((db) async { - if (f.isCollection != false) { - // removing dir is basically a superset of removing file, so we'll treat - // unspecified file as dir - final transaction = db.transaction( - [AppDb.dirStoreName, AppDb.file2StoreName], idbModeReadWrite); - final dirStore = transaction.objectStore(AppDb.dirStoreName); - final fileStore = transaction.objectStore(AppDb.file2StoreName); - await _removeDirFromAppDb(account, f, - dirStore: dirStore, fileStore: fileStore); - } else { - final transaction = - db.transaction(AppDb.file2StoreName, idbModeReadWrite); - final fileStore = transaction.objectStore(AppDb.file2StoreName); - await _removeFileFromAppDb(account, f, fileStore: fileStore); - } - }); + if (f.isCollection != false) { + // removing dir is basically a superset of removing file, so we'll treat + // unspecified file as dir + await appDb.use( + (db) => db.transaction( + [AppDb.dirStoreName, AppDb.file2StoreName], idbModeReadWrite), + (transaction) async { + final dirStore = transaction.objectStore(AppDb.dirStoreName); + final fileStore = transaction.objectStore(AppDb.file2StoreName); + await _removeDirFromAppDb(account, f, + dirStore: dirStore, fileStore: fileStore); + }, + ); + } else { + await appDb.use( + (db) => db.transaction(AppDb.file2StoreName, idbModeReadWrite), + (transaction) async { + final fileStore = transaction.objectStore(AppDb.file2StoreName); + await _removeFileFromAppDb(account, f, fileStore: fileStore); + }, + ); + } } final AppDb appDb; diff --git a/app/lib/k.dart b/app/lib/k.dart index a1a5f964..a92ed075 100644 --- a/app/lib/k.dart +++ b/app/lib/k.dart @@ -33,3 +33,7 @@ const photoLargeSize = 1080; /// Size of the cover photos const coverSize = 512; + +/// AppDb lock ID +const appDbLockId = 1; + diff --git a/app/lib/mobile/lock.dart b/app/lib/mobile/lock.dart new file mode 100644 index 00000000..e8352e59 --- /dev/null +++ b/app/lib/mobile/lock.dart @@ -0,0 +1,24 @@ +import 'package:nc_photos/platform/k.dart' as platform_k; +import 'package:nc_photos_plugin/nc_photos_plugin.dart' as plugin; + +class Lock { + static Future synchronized(int lockId, Future Function() fn) async { + if (platform_k.isAndroid) { + return _synchronizedAndroid(lockId, fn); + } else { + throw UnimplementedError(); + } + } + + static Future _synchronizedAndroid( + int lockId, Future Function() fn) async { + while (!await plugin.Lock.tryLock(lockId)) { + await Future.delayed(const Duration(milliseconds: 50)); + } + try { + return await fn(); + } finally { + await plugin.Lock.unlock(lockId); + } + } +} diff --git a/app/lib/mobile/platform.dart b/app/lib/mobile/platform.dart index 29cf634f..d102f48f 100644 --- a/app/lib/mobile/platform.dart +++ b/app/lib/mobile/platform.dart @@ -2,4 +2,5 @@ export 'db_util.dart'; export 'download.dart'; export 'file_saver.dart'; export 'google_gps_map.dart'; +export 'lock.dart'; export 'universal_storage.dart'; diff --git a/app/lib/use_case/cache_favorite.dart b/app/lib/use_case/cache_favorite.dart index 377ab9ff..bf84419a 100644 --- a/app/lib/use_case/cache_favorite.dart +++ b/app/lib/use_case/cache_favorite.dart @@ -37,35 +37,36 @@ class CacheFavorite { if (newFavorites.isEmpty && removedFavorites.isEmpty) { return; } - await _c.appDb.use((db) async { - final transaction = - db.transaction(AppDb.file2StoreName, idbModeReadWrite); - final fileStore = transaction.objectStore(AppDb.file2StoreName); - await Future.wait(newFavorites.map((f) async { - _log.info("[call] New favorite: ${f.path}"); - try { - await fileStore.put(AppDbFile2Entry.fromFile(account, f).toJson(), - AppDbFile2Entry.toPrimaryKeyForFile(account, f)); - } catch (e, stackTrace) { - _log.shout( - "[call] Failed while writing new favorite to AppDb: ${logFilename(f.path)}", - e, - stackTrace); - } - })); - await Future.wait(removedFavorites.map((f) async { - _log.info("[call] Remove favorite: ${f.path}"); - try { - await fileStore.put(AppDbFile2Entry.fromFile(account, f).toJson(), - AppDbFile2Entry.toPrimaryKeyForFile(account, f)); - } catch (e, stackTrace) { - _log.shout( - "[call] Failed while writing removed favorite to AppDb: ${logFilename(f.path)}", - e, - stackTrace); - } - })); - }); + await _c.appDb.use( + (db) => db.transaction(AppDb.file2StoreName, idbModeReadWrite), + (transaction) async { + final fileStore = transaction.objectStore(AppDb.file2StoreName); + await Future.wait(newFavorites.map((f) async { + _log.info("[call] New favorite: ${f.path}"); + try { + await fileStore.put(AppDbFile2Entry.fromFile(account, f).toJson(), + AppDbFile2Entry.toPrimaryKeyForFile(account, f)); + } catch (e, stackTrace) { + _log.shout( + "[call] Failed while writing new favorite to AppDb: ${logFilename(f.path)}", + e, + stackTrace); + } + })); + await Future.wait(removedFavorites.map((f) async { + _log.info("[call] Remove favorite: ${f.path}"); + try { + await fileStore.put(AppDbFile2Entry.fromFile(account, f).toJson(), + AppDbFile2Entry.toPrimaryKeyForFile(account, f)); + } catch (e, stackTrace) { + _log.shout( + "[call] Failed while writing removed favorite to AppDb: ${logFilename(f.path)}", + e, + stackTrace); + } + })); + }, + ); KiwiContainer() .resolve() diff --git a/app/lib/use_case/compat/v37.dart b/app/lib/use_case/compat/v37.dart index d35bd926..64cc58cf 100644 --- a/app/lib/use_case/compat/v37.dart +++ b/app/lib/use_case/compat/v37.dart @@ -12,14 +12,15 @@ class CompatV37 { static Future setAppDbMigrationFlag(AppDb appDb) async { _log.info("[setAppDbMigrationFlag] Set db flag"); try { - await appDb.use((db) async { - final transaction = - db.transaction(AppDb.metaStoreName, idbModeReadWrite); - final metaStore = transaction.objectStore(AppDb.metaStoreName); - await metaStore - .put(const AppDbMetaEntryCompatV37(false).toEntry().toJson()); - await transaction.completed; - }); + await appDb.use( + (db) => db.transaction(AppDb.metaStoreName, idbModeReadWrite), + (transaction) async { + final metaStore = transaction.objectStore(AppDb.metaStoreName); + await metaStore + .put(const AppDbMetaEntryCompatV37(false).toEntry().toJson()); + await transaction.completed; + }, + ); } catch (e, stackTrace) { _log.shout( "[setAppDbMigrationFlag] Failed while setting db flag, drop db instead", @@ -30,11 +31,13 @@ class CompatV37 { } static Future isAppDbNeedMigration(AppDb appDb) async { - final dbItem = await appDb.use((db) async { - final transaction = db.transaction(AppDb.metaStoreName, idbModeReadOnly); - final metaStore = transaction.objectStore(AppDb.metaStoreName); - return await metaStore.getObject(AppDbMetaEntryCompatV37.key) as Map?; - }); + final dbItem = await appDb.use( + (db) => db.transaction(AppDb.metaStoreName, idbModeReadOnly), + (transaction) async { + final metaStore = transaction.objectStore(AppDb.metaStoreName); + return await metaStore.getObject(AppDbMetaEntryCompatV37.key) as Map?; + }, + ); if (dbItem == null) { return false; } @@ -51,51 +54,53 @@ class CompatV37 { static Future migrateAppDb(AppDb appDb) async { _log.info("[migrateAppDb] Migrate AppDb"); try { - await appDb.use((db) async { - final transaction = db.transaction( + await appDb.use( + (db) => db.transaction( [AppDb.file2StoreName, AppDb.dirStoreName, AppDb.metaStoreName], - idbModeReadWrite); - final noMediaFiles = <_NoMediaFile>[]; - try { - final fileStore = transaction.objectStore(AppDb.file2StoreName); - final dirStore = transaction.objectStore(AppDb.dirStoreName); - // scan the db to see which dirs contain a no media marker - await for (final c in fileStore.openCursor()) { - final item = c.value as Map; - final strippedPath = item["strippedPath"] as String; - if (file_util.isNoMediaMarkerPath(strippedPath)) { - noMediaFiles.add(_NoMediaFile( - item["server"], - item["userId"], - path_lib - .dirname(item["strippedPath"]) - .run((p) => p == "." ? "" : p), - item["file"]["fileId"], - )); + idbModeReadWrite), + (transaction) async { + final noMediaFiles = <_NoMediaFile>[]; + try { + final fileStore = transaction.objectStore(AppDb.file2StoreName); + final dirStore = transaction.objectStore(AppDb.dirStoreName); + // scan the db to see which dirs contain a no media marker + await for (final c in fileStore.openCursor()) { + final item = c.value as Map; + final strippedPath = item["strippedPath"] as String; + if (file_util.isNoMediaMarkerPath(strippedPath)) { + noMediaFiles.add(_NoMediaFile( + item["server"], + item["userId"], + path_lib + .dirname(item["strippedPath"]) + .run((p) => p == "." ? "" : p), + item["file"]["fileId"], + )); + } + c.next(); } - c.next(); - } - // sort to make sure parent dirs are always in front of sub dirs - noMediaFiles - .sort((a, b) => a.strippedDirPath.compareTo(b.strippedDirPath)); - _log.info( - "[migrateAppDb] nomedia dirs: ${noMediaFiles.toReadableString()}"); + // sort to make sure parent dirs are always in front of sub dirs + noMediaFiles + .sort((a, b) => a.strippedDirPath.compareTo(b.strippedDirPath)); + _log.info( + "[migrateAppDb] nomedia dirs: ${noMediaFiles.toReadableString()}"); - if (noMediaFiles.isNotEmpty) { - await _migrateAppDbFileStore(appDb, noMediaFiles, - fileStore: fileStore); - await _migrateAppDbDirStore(appDb, noMediaFiles, - dirStore: dirStore); - } + if (noMediaFiles.isNotEmpty) { + await _migrateAppDbFileStore(appDb, noMediaFiles, + fileStore: fileStore); + await _migrateAppDbDirStore(appDb, noMediaFiles, + dirStore: dirStore); + } - final metaStore = transaction.objectStore(AppDb.metaStoreName); - await metaStore - .put(const AppDbMetaEntryCompatV37(true).toEntry().toJson()); - } catch (_) { - transaction.abort(); - rethrow; - } - }); + final metaStore = transaction.objectStore(AppDb.metaStoreName); + await metaStore + .put(const AppDbMetaEntryCompatV37(true).toEntry().toJson()); + } catch (_) { + transaction.abort(); + rethrow; + } + }, + ); } catch (e, stackTrace) { _log.shout("[migrateAppDb] Failed while migrating, drop db instead", e, stackTrace); diff --git a/app/lib/use_case/db_compat/v5.dart b/app/lib/use_case/db_compat/v5.dart index 7c9d1d22..d0434c2b 100644 --- a/app/lib/use_case/db_compat/v5.dart +++ b/app/lib/use_case/db_compat/v5.dart @@ -7,11 +7,13 @@ import 'package:nc_photos/object_extension.dart'; class DbCompatV5 { static Future isNeedMigration(AppDb appDb) async { - final dbItem = await appDb.use((db) async { - final transaction = db.transaction(AppDb.metaStoreName, idbModeReadOnly); - final metaStore = transaction.objectStore(AppDb.metaStoreName); - return await metaStore.getObject(AppDbMetaEntryDbCompatV5.key) as Map?; - }); + final dbItem = await appDb.use( + (db) => db.transaction(AppDb.metaStoreName, idbModeReadOnly), + (transaction) async { + final metaStore = transaction.objectStore(AppDb.metaStoreName); + return await metaStore.getObject(AppDbMetaEntryDbCompatV5.key) as Map?; + }, + ); if (dbItem == null) { return false; } @@ -28,36 +30,38 @@ class DbCompatV5 { static Future migrate(AppDb appDb) async { _log.info("[migrate] Migrate AppDb"); try { - await appDb.use((db) async { - final transaction = db.transaction( - [AppDb.file2StoreName, AppDb.metaStoreName], idbModeReadWrite); - try { - final fileStore = transaction.objectStore(AppDb.file2StoreName); - await for (final c in fileStore.openCursor()) { - final item = c.value as Map; - // migrate file entry: add bestDateTime - final fileEntry = item.cast().run((json) { - final f = File.fromJson(json["file"].cast()); - return AppDbFile2Entry( - json["server"], - (json["userId"] as String).toCi(), - json["strippedPath"], - f.bestDateTime.millisecondsSinceEpoch, - File.fromJson(json["file"].cast()), - ); - }); - await c.update(fileEntry.toJson()); + await appDb.use( + (db) => db.transaction( + [AppDb.file2StoreName, AppDb.metaStoreName], idbModeReadWrite), + (transaction) async { + try { + final fileStore = transaction.objectStore(AppDb.file2StoreName); + await for (final c in fileStore.openCursor()) { + final item = c.value as Map; + // migrate file entry: add bestDateTime + final fileEntry = item.cast().run((json) { + final f = File.fromJson(json["file"].cast()); + return AppDbFile2Entry( + json["server"], + (json["userId"] as String).toCi(), + json["strippedPath"], + f.bestDateTime.millisecondsSinceEpoch, + File.fromJson(json["file"].cast()), + ); + }); + await c.update(fileEntry.toJson()); - c.next(); + c.next(); + } + final metaStore = transaction.objectStore(AppDb.metaStoreName); + await metaStore + .put(const AppDbMetaEntryDbCompatV5(true).toEntry().toJson()); + } catch (_) { + transaction.abort(); + rethrow; } - final metaStore = transaction.objectStore(AppDb.metaStoreName); - await metaStore - .put(const AppDbMetaEntryDbCompatV5(true).toEntry().toJson()); - } catch (_) { - transaction.abort(); - rethrow; - } - }); + }, + ); } catch (e, stackTrace) { _log.shout( "[migrate] Failed while migrating, drop db instead", e, stackTrace); diff --git a/app/lib/use_case/find_file.dart b/app/lib/use_case/find_file.dart index 1d842635..43dc743a 100644 --- a/app/lib/use_case/find_file.dart +++ b/app/lib/use_case/find_file.dart @@ -19,12 +19,14 @@ class FindFile { List fileIds, { void Function(int fileId)? onFileNotFound, }) async { - final dbItems = await _c.appDb.use((db) async { - final transaction = db.transaction(AppDb.file2StoreName, idbModeReadOnly); - final fileStore = transaction.objectStore(AppDb.file2StoreName); - return await Future.wait(fileIds.map((id) => - fileStore.getObject(AppDbFile2Entry.toPrimaryKey(account, id)))); - }); + final dbItems = await _c.appDb.use( + (db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly), + (transaction) async { + final fileStore = transaction.objectStore(AppDb.file2StoreName); + return await Future.wait(fileIds.map((id) => + fileStore.getObject(AppDbFile2Entry.toPrimaryKey(account, id)))); + }, + ); final files = []; for (final pair in zip([fileIds, dbItems])) { final dbItem = pair[1] as Map?; diff --git a/app/lib/use_case/list_favorite_offline.dart b/app/lib/use_case/list_favorite_offline.dart index 7843579d..f5a0b8da 100644 --- a/app/lib/use_case/list_favorite_offline.dart +++ b/app/lib/use_case/list_favorite_offline.dart @@ -18,24 +18,26 @@ class ListFavoriteOffline { final rootDirs = account.roots .map((r) => File(path: file_util.unstripPath(account, r))) .toList(); - return _c.appDb.use((db) async { - final transaction = db.transaction(AppDb.file2StoreName, idbModeReadOnly); - final fileStore = transaction.objectStore(AppDb.file2StoreName); - final fileIsFavoriteIndex = - fileStore.index(AppDbFile2Entry.fileIsFavoriteIndexName); - return await fileIsFavoriteIndex - .openCursor( - key: AppDbFile2Entry.toFileIsFavoriteIndexKey(account, true), - autoAdvance: true, - ) - .map((c) => AppDbFile2Entry.fromJson( - (c.value as Map).cast())) - .map((e) => e.file) - .where((f) => - file_util.isSupportedFormat(f) && - rootDirs.any((r) => file_util.isOrUnderDir(f, r))) - .toList(); - }); + return _c.appDb.use( + (db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly), + (transaction) async { + final fileStore = transaction.objectStore(AppDb.file2StoreName); + final fileIsFavoriteIndex = + fileStore.index(AppDbFile2Entry.fileIsFavoriteIndexName); + return await fileIsFavoriteIndex + .openCursor( + key: AppDbFile2Entry.toFileIsFavoriteIndexKey(account, true), + autoAdvance: true, + ) + .map((c) => AppDbFile2Entry.fromJson( + (c.value as Map).cast())) + .map((e) => e.file) + .where((f) => + file_util.isSupportedFormat(f) && + rootDirs.any((r) => file_util.isOrUnderDir(f, r))) + .toList(); + }, + ); } final DiContainer _c; diff --git a/app/lib/use_case/populate_person.dart b/app/lib/use_case/populate_person.dart index 3eccb38b..fb02589f 100644 --- a/app/lib/use_case/populate_person.dart +++ b/app/lib/use_case/populate_person.dart @@ -11,20 +11,22 @@ class PopulatePerson { /// Return a list of files of the faces Future> call(Account account, List faces) async { - return await appDb.use((db) async { - final transaction = db.transaction(AppDb.file2StoreName, idbModeReadOnly); - final store = transaction.objectStore(AppDb.file2StoreName); - final products = []; - for (final f in faces) { - try { - products.add(await _populateOne(account, f, fileStore: store)); - } catch (e, stackTrace) { - _log.severe("[call] Failed populating file of face: ${f.fileId}", e, - stackTrace); + return await appDb.use( + (db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly), + (transaction) async { + final store = transaction.objectStore(AppDb.file2StoreName); + final products = []; + for (final f in faces) { + try { + products.add(await _populateOne(account, f, fileStore: store)); + } catch (e, stackTrace) { + _log.severe("[call] Failed populating file of face: ${f.fileId}", e, + stackTrace); + } } - } - return products; - }); + return products; + }, + ); } Future _populateOne( diff --git a/app/lib/use_case/resync_album.dart b/app/lib/use_case/resync_album.dart index b90ba39f..52b16149 100644 --- a/app/lib/use_case/resync_album.dart +++ b/app/lib/use_case/resync_album.dart @@ -20,17 +20,19 @@ class ResyncAlbum { final items = AlbumStaticProvider.of(album).items; final fileIds = items.whereType().map((i) => i.file.fileId!).toList(); - final dbItems = Map.fromEntries(await appDb.use((db) async { - final transaction = db.transaction(AppDb.file2StoreName, idbModeReadOnly); - final store = transaction.objectStore(AppDb.file2StoreName); - return await Future.wait(fileIds.map( - (id) async => MapEntry( - id, - await store.getObject(AppDbFile2Entry.toPrimaryKey(account, id)) - as Map?, - ), - )); - })); + final dbItems = Map.fromEntries(await appDb.use( + (db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly), + (transaction) async { + final store = transaction.objectStore(AppDb.file2StoreName); + return await Future.wait(fileIds.map( + (id) async => MapEntry( + id, + await store.getObject(AppDbFile2Entry.toPrimaryKey(account, id)) + as Map?, + ), + )); + }, + )); return items.map((i) { if (i is AlbumFileItem) { try { diff --git a/app/lib/use_case/scan_dir_offline.dart b/app/lib/use_case/scan_dir_offline.dart index f94e11a2..1b289a30 100644 --- a/app/lib/use_case/scan_dir_offline.dart +++ b/app/lib/use_case/scan_dir_offline.dart @@ -12,22 +12,25 @@ class ScanDirOffline { /// List all files under a dir recursively from the local DB Future> call(Account account, File root) async { - return await _c.appDb.use((db) async { - final transaction = db.transaction(AppDb.file2StoreName, idbModeReadOnly); - final store = transaction.objectStore(AppDb.file2StoreName); - final index = store.index(AppDbFile2Entry.strippedPathIndexName); - final range = KeyRange.bound( - AppDbFile2Entry.toStrippedPathIndexLowerKeyForDir(account, root), - AppDbFile2Entry.toStrippedPathIndexUpperKeyForDir(account, root), - ); - return await index - .openCursor(range: range, autoAdvance: true) - .map((c) => c.value) - .cast() - .map((e) => AppDbFile2Entry.fromJson(e.cast()).file) - .where((f) => file_util.isSupportedFormat(f)) - .toList(); - }); + return await _c.appDb.use( + (db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly), + (transaction) async { + final store = transaction.objectStore(AppDb.file2StoreName); + final index = store.index(AppDbFile2Entry.strippedPathIndexName); + final range = KeyRange.bound( + AppDbFile2Entry.toStrippedPathIndexLowerKeyForDir(account, root), + AppDbFile2Entry.toStrippedPathIndexUpperKeyForDir(account, root), + ); + return await index + .openCursor(range: range, autoAdvance: true) + .map((c) => c.value) + .cast() + .map( + (e) => AppDbFile2Entry.fromJson(e.cast()).file) + .where((f) => file_util.isSupportedFormat(f)) + .toList(); + }, + ); } final DiContainer _c; diff --git a/app/lib/web/lock.dart b/app/lib/web/lock.dart new file mode 100644 index 00000000..450c7f43 --- /dev/null +++ b/app/lib/web/lock.dart @@ -0,0 +1,9 @@ +import 'package:synchronized/synchronized.dart' as dart; + +// Isolates are not supported on web +class Lock { + static Future synchronized(int lockId, Future Function() fn) => + (_locks[lockId] ??= dart.Lock(reentrant: true)).synchronized(fn); + + static final _locks = {}; +} diff --git a/app/lib/web/platform.dart b/app/lib/web/platform.dart index 29cf634f..d102f48f 100644 --- a/app/lib/web/platform.dart +++ b/app/lib/web/platform.dart @@ -2,4 +2,5 @@ export 'db_util.dart'; export 'download.dart'; export 'file_saver.dart'; export 'google_gps_map.dart'; +export 'lock.dart'; export 'universal_storage.dart'; diff --git a/app/test/mock_type.dart b/app/test/mock_type.dart index 11b1ddad..cec8bfe0 100644 --- a/app/test/mock_type.dart +++ b/app/test/mock_type.dart @@ -100,7 +100,8 @@ class MockAppDb implements AppDb { } @override - Future use(FutureOr Function(Database db) fn) async { + Future use(Transaction Function(Database db) transactionBuilder, + FutureOr Function(Transaction transaction) fn) async { final db = await _dbFactory.open( "test.db", version: 1, @@ -110,9 +111,14 @@ class MockAppDb implements AppDb { }, ); + Transaction? transaction; try { - return await fn(db); + transaction = transactionBuilder(db); + return await fn(transaction); } finally { + if (transaction != null) { + await transaction.completed; + } db.close(); } } diff --git a/app/test/test_util.dart b/app/test/test_util.dart index 6079d1fe..e43afcc8 100644 --- a/app/test/test_util.dart +++ b/app/test/test_util.dart @@ -341,36 +341,43 @@ Sharee buildSharee({ Future fillAppDb( AppDb appDb, Account account, Iterable files) async { - await appDb.use((db) async { - final transaction = db.transaction(AppDb.file2StoreName, idbModeReadWrite); - final file2Store = transaction.objectStore(AppDb.file2StoreName); - for (final f in files) { - await file2Store.put(AppDbFile2Entry.fromFile(account, f).toJson(), - AppDbFile2Entry.toPrimaryKeyForFile(account, f)); - } - }); + await appDb.use( + (db) => db.transaction(AppDb.file2StoreName, idbModeReadWrite), + (transaction) async { + final file2Store = transaction.objectStore(AppDb.file2StoreName); + for (final f in files) { + await file2Store.put(AppDbFile2Entry.fromFile(account, f).toJson(), + AppDbFile2Entry.toPrimaryKeyForFile(account, f)); + } + }, + ); } Future fillAppDbDir( AppDb appDb, Account account, File dir, List children) async { - await appDb.use((db) async { - final transaction = db.transaction(AppDb.dirStoreName, idbModeReadWrite); - final dirStore = transaction.objectStore(AppDb.dirStoreName); - await dirStore.put(AppDbDirEntry.fromFiles(account, dir, children).toJson(), - AppDbDirEntry.toPrimaryKeyForDir(account, dir)); - }); + await appDb.use( + (db) => db.transaction(AppDb.dirStoreName, idbModeReadWrite), + (transaction) async { + final dirStore = transaction.objectStore(AppDb.dirStoreName); + await dirStore.put( + AppDbDirEntry.fromFiles(account, dir, children).toJson(), + AppDbDirEntry.toPrimaryKeyForDir(account, dir)); + }, + ); } Future> listAppDb( AppDb appDb, String storeName, T Function(JsonObj) transform) { - return appDb.use((db) async { - final transaction = db.transaction(storeName, idbModeReadOnly); - final store = transaction.objectStore(storeName); - return await store - .openCursor(autoAdvance: true) - .map((c) => c.value) - .cast() - .map((e) => transform(e.cast())) - .toList(); - }); + return appDb.use( + (db) => db.transaction(storeName, idbModeReadOnly), + (transaction) async { + final store = transaction.objectStore(storeName); + return await store + .openCursor(autoAdvance: true) + .map((c) => c.value) + .cast() + .map((e) => transform(e.cast())) + .toList(); + }, + ); } diff --git a/app/test/use_case/compat/v37_test.dart b/app/test/use_case/compat/v37_test.dart index 4691d4b9..fa5b1dd7 100644 --- a/app/test/use_case/compat/v37_test.dart +++ b/app/test/use_case/compat/v37_test.dart @@ -29,12 +29,14 @@ void main() { /// Expect: true Future _isAppDbNeedMigrationEntryFalse() async { final appDb = MockAppDb(); - await appDb.use((db) async { - final transaction = db.transaction(AppDb.metaStoreName, idbModeReadWrite); - final metaStore = transaction.objectStore(AppDb.metaStoreName); - const entry = AppDbMetaEntryCompatV37(false); - await metaStore.put(entry.toEntry().toJson()); - }); + await appDb.use( + (db) => db.transaction(AppDb.metaStoreName, idbModeReadWrite), + (transaction) async { + final metaStore = transaction.objectStore(AppDb.metaStoreName); + const entry = AppDbMetaEntryCompatV37(false); + await metaStore.put(entry.toEntry().toJson()); + }, + ); expect(await CompatV37.isAppDbNeedMigration(appDb), true); } @@ -44,12 +46,14 @@ Future _isAppDbNeedMigrationEntryFalse() async { /// Expect: false Future _isAppDbNeedMigrationEntryTrue() async { final appDb = MockAppDb(); - await appDb.use((db) async { - final transaction = db.transaction(AppDb.metaStoreName, idbModeReadWrite); - final metaStore = transaction.objectStore(AppDb.metaStoreName); - const entry = AppDbMetaEntryCompatV37(true); - await metaStore.put(entry.toEntry().toJson()); - }); + await appDb.use( + (db) => db.transaction(AppDb.metaStoreName, idbModeReadWrite), + (transaction) async { + final metaStore = transaction.objectStore(AppDb.metaStoreName); + const entry = AppDbMetaEntryCompatV37(true); + await metaStore.put(entry.toEntry().toJson()); + }, + ); expect(await CompatV37.isAppDbNeedMigration(appDb), false); } @@ -59,12 +63,14 @@ Future _isAppDbNeedMigrationEntryTrue() async { /// Expect: false Future _isAppDbNeedMigrationWithoutEntry() async { final appDb = MockAppDb(); - await appDb.use((db) async { - final transaction = db.transaction(AppDb.metaStoreName, idbModeReadWrite); - final metaStore = transaction.objectStore(AppDb.metaStoreName); - const entry = AppDbMetaEntryCompatV37(true); - await metaStore.put(entry.toEntry().toJson()); - }); + await appDb.use( + (db) => db.transaction(AppDb.metaStoreName, idbModeReadWrite), + (transaction) async { + final metaStore = transaction.objectStore(AppDb.metaStoreName); + const entry = AppDbMetaEntryCompatV37(true); + await metaStore.put(entry.toEntry().toJson()); + }, + ); expect(await CompatV37.isAppDbNeedMigration(appDb), false); } @@ -81,11 +87,9 @@ Future _migrateAppDbWithoutNomedia() async { ..addJpeg("admin/dir1/test2.jpg")) .build(); final appDb = MockAppDb(); - await appDb.use((db) async { - await util.fillAppDb(appDb, account, files); - await util.fillAppDbDir(appDb, account, files[0], files.slice(1, 3)); - await util.fillAppDbDir(appDb, account, files[2], [files[3]]); - }); + await util.fillAppDb(appDb, account, files); + await util.fillAppDbDir(appDb, account, files[0], files.slice(1, 3)); + await util.fillAppDbDir(appDb, account, files[2], [files[3]]); await CompatV37.migrateAppDb(appDb); final fileObjs = await util.listAppDb( @@ -113,11 +117,9 @@ Future _migrateAppDb() async { ..addJpeg("admin/dir1/test2.jpg")) .build(); final appDb = MockAppDb(); - await appDb.use((db) async { - await util.fillAppDb(appDb, account, files); - await util.fillAppDbDir(appDb, account, files[0], files.slice(1, 3)); - await util.fillAppDbDir(appDb, account, files[2], files.slice(3, 5)); - }); + await util.fillAppDb(appDb, account, files); + await util.fillAppDbDir(appDb, account, files[0], files.slice(1, 3)); + await util.fillAppDbDir(appDb, account, files[2], files.slice(3, 5)); await CompatV37.migrateAppDb(appDb); final fileObjs = await util.listAppDb( @@ -147,12 +149,10 @@ Future _migrateAppDbNestedDir() async { ..addJpeg("admin/dir1/dir1-1/test3.jpg")) .build(); final appDb = MockAppDb(); - await appDb.use((db) async { - await util.fillAppDb(appDb, account, files); - await util.fillAppDbDir(appDb, account, files[0], files.slice(1, 3)); - await util.fillAppDbDir(appDb, account, files[2], files.slice(3, 6)); - await util.fillAppDbDir(appDb, account, files[5], [files[6]]); - }); + await util.fillAppDb(appDb, account, files); + await util.fillAppDbDir(appDb, account, files[0], files.slice(1, 3)); + await util.fillAppDbDir(appDb, account, files[2], files.slice(3, 6)); + await util.fillAppDbDir(appDb, account, files[5], [files[6]]); await CompatV37.migrateAppDb(appDb); final fileObjs = await util.listAppDb( @@ -183,12 +183,10 @@ Future _migrateAppDbNestedMarker() async { ..addJpeg("admin/dir1/dir1-1/test3.jpg")) .build(); final appDb = MockAppDb(); - await appDb.use((db) async { - await util.fillAppDb(appDb, account, files); - await util.fillAppDbDir(appDb, account, files[0], files.slice(1, 3)); - await util.fillAppDbDir(appDb, account, files[2], files.slice(3, 6)); - await util.fillAppDbDir(appDb, account, files[5], files.slice(6, 8)); - }); + await util.fillAppDb(appDb, account, files); + await util.fillAppDbDir(appDb, account, files[0], files.slice(1, 3)); + await util.fillAppDbDir(appDb, account, files[2], files.slice(3, 6)); + await util.fillAppDbDir(appDb, account, files[5], files.slice(6, 8)); await CompatV37.migrateAppDb(appDb); final fileObjs = await util.listAppDb( @@ -216,11 +214,9 @@ Future _migrateAppDbRoot() async { ..addJpeg("admin/dir1/test2.jpg")) .build(); final appDb = MockAppDb(); - await appDb.use((db) async { - await util.fillAppDb(appDb, account, files); - await util.fillAppDbDir(appDb, account, files[0], files.slice(1, 4)); - await util.fillAppDbDir(appDb, account, files[3], [files[4]]); - }); + await util.fillAppDb(appDb, account, files); + await util.fillAppDbDir(appDb, account, files[0], files.slice(1, 4)); + await util.fillAppDbDir(appDb, account, files[3], [files[4]]); await CompatV37.migrateAppDb(appDb); final objs = await util.listAppDb( diff --git a/app/test/use_case/db_compat/v5_test.dart b/app/test/use_case/db_compat/v5_test.dart index 5dbb9b33..9d30a77d 100644 --- a/app/test/use_case/db_compat/v5_test.dart +++ b/app/test/use_case/db_compat/v5_test.dart @@ -13,39 +13,42 @@ void main() { group("isNeedMigration", () { test("w/ meta entry == false", () async { final appDb = MockAppDb(); - await appDb.use((db) async { - final transaction = - db.transaction(AppDb.metaStoreName, idbModeReadWrite); - final metaStore = transaction.objectStore(AppDb.metaStoreName); - const entry = AppDbMetaEntryDbCompatV5(false); - await metaStore.put(entry.toEntry().toJson()); - }); + await appDb.use( + (db) => db.transaction(AppDb.metaStoreName, idbModeReadWrite), + (transaction) async { + final metaStore = transaction.objectStore(AppDb.metaStoreName); + const entry = AppDbMetaEntryDbCompatV5(false); + await metaStore.put(entry.toEntry().toJson()); + }, + ); expect(await DbCompatV5.isNeedMigration(appDb), true); }); test("w/ meta entry == true", () async { final appDb = MockAppDb(); - await appDb.use((db) async { - final transaction = - db.transaction(AppDb.metaStoreName, idbModeReadWrite); - final metaStore = transaction.objectStore(AppDb.metaStoreName); - const entry = AppDbMetaEntryDbCompatV5(true); - await metaStore.put(entry.toEntry().toJson()); - }); + await appDb.use( + (db) => db.transaction(AppDb.metaStoreName, idbModeReadWrite), + (transaction) async { + final metaStore = transaction.objectStore(AppDb.metaStoreName); + const entry = AppDbMetaEntryDbCompatV5(true); + await metaStore.put(entry.toEntry().toJson()); + }, + ); expect(await DbCompatV5.isNeedMigration(appDb), false); }); test("w/o meta entry", () async { final appDb = MockAppDb(); - await appDb.use((db) async { - final transaction = - db.transaction(AppDb.metaStoreName, idbModeReadWrite); - final metaStore = transaction.objectStore(AppDb.metaStoreName); - const entry = AppDbMetaEntryDbCompatV5(true); - await metaStore.put(entry.toEntry().toJson()); - }); + await appDb.use( + (db) => db.transaction(AppDb.metaStoreName, idbModeReadWrite), + (transaction) async { + final metaStore = transaction.objectStore(AppDb.metaStoreName); + const entry = AppDbMetaEntryDbCompatV5(true); + await metaStore.put(entry.toEntry().toJson()); + }, + ); expect(await DbCompatV5.isNeedMigration(appDb), false); }); @@ -60,17 +63,18 @@ void main() { )) .build(); final appDb = MockAppDb(); - await appDb.use((db) async { - final transaction = - db.transaction(AppDb.file2StoreName, idbModeReadWrite); - final fileStore = transaction.objectStore(AppDb.file2StoreName); - await fileStore.put({ - "server": account.url, - "userId": account.username.toCaseInsensitiveString(), - "strippedPath": files[0].strippedPathWithEmpty, - "file": files[0].toJson(), - }, "${account.url}/${account.username.toCaseInsensitiveString()}/${files[0].fileId}"); - }); + await appDb.use( + (db) => db.transaction(AppDb.file2StoreName, idbModeReadWrite), + (transaction) async { + final fileStore = transaction.objectStore(AppDb.file2StoreName); + await fileStore.put({ + "server": account.url, + "userId": account.username.toCaseInsensitiveString(), + "strippedPath": files[0].strippedPathWithEmpty, + "file": files[0].toJson(), + }, "${account.url}/${account.username.toCaseInsensitiveString()}/${files[0].fileId}"); + }, + ); await DbCompatV5.migrate(appDb); final objs = diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/LockChannelHandler.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/LockChannelHandler.kt new file mode 100644 index 00000000..d5fb4d40 --- /dev/null +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/LockChannelHandler.kt @@ -0,0 +1,69 @@ +package com.nkming.nc_photos.plugin + +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +/* + * Platform-side lock mechanism + * + * Method channel always run on the main thread, so this is safe even when + * called from different isolates + * + * Methods: + * Try acquiring an lock. Return true if successful, false if acquired by others. + * fun tryLock(lockId: Int): Boolean + * + * Unlock a previously acquired lock. Unlocking twice is an error. + * fun unlock(lockId: Int): Unit + */ +class LockChannelHandler : MethodChannel.MethodCallHandler { + companion object { + const val CHANNEL = "${K.LIB_ID}/lock" + + private val locks = mutableMapOf() + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "tryLock" -> { + try { + tryLock(call.argument("lockId")!!, result) + } catch (e: Throwable) { + result.error("systemException", e.toString(), null) + } + } + "unlock" -> { + try { + unlock(call.argument("lockId")!!, result) + } catch (e: Throwable) { + result.error("systemException", e.toString(), null) + } + } + else -> { + result.notImplemented() + } + } + } + + private fun tryLock(lockId: Int, result: MethodChannel.Result) { + if (locks[lockId] != true) { + locks[lockId] = true + result.success(true) + } else { + result.success(false) + } + } + + private fun unlock(lockId: Int, result: MethodChannel.Result) { + if (locks[lockId] == true) { + locks[lockId] = false + result.success(null) + } else { + result.error( + "notLockedException", + "Cannot unlock without first locking", + null + ) + } + } +} diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NcPhotosPlugin.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NcPhotosPlugin.kt index f573bd27..500256dd 100644 --- a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NcPhotosPlugin.kt +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NcPhotosPlugin.kt @@ -8,6 +8,11 @@ class NcPhotosPlugin : FlutterPlugin { override fun onAttachedToEngine( @NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding ) { + lockChannel = MethodChannel( + flutterPluginBinding.binaryMessenger, LockChannelHandler.CHANNEL + ) + lockChannel.setMethodCallHandler(LockChannelHandler()) + notificationChannel = MethodChannel( flutterPluginBinding.binaryMessenger, NotificationChannelHandler.CHANNEL @@ -22,8 +27,10 @@ class NcPhotosPlugin : FlutterPlugin { override fun onDetachedFromEngine( @NonNull binding: FlutterPlugin.FlutterPluginBinding ) { + lockChannel.setMethodCallHandler(null) notificationChannel.setMethodCallHandler(null) } + private lateinit var lockChannel: MethodChannel private lateinit var notificationChannel: MethodChannel } diff --git a/plugin/lib/nc_photos_plugin.dart b/plugin/lib/nc_photos_plugin.dart index a023cf4c..8a4c6862 100644 --- a/plugin/lib/nc_photos_plugin.dart +++ b/plugin/lib/nc_photos_plugin.dart @@ -2,6 +2,21 @@ import 'dart:async'; import 'package:flutter/services.dart'; +class Lock { + static Future tryLock(int lockId) async { + return (await _channel.invokeMethod("tryLock", { + "lockId": lockId, + }))!; + } + + static Future unlock(int lockId) => + _channel.invokeMethod("unlock", { + "lockId": lockId, + }); + + static const _channel = MethodChannel("com.nkming.nc_photos.plugin/lock"); +} + class Notification { static Future notifyDownloadSuccessful(List fileUris, List mimeTypes, int? notificationId) =>