From 5960b586c8572e8470f523301ffd9ae258915241 Mon Sep 17 00:00:00 2001 From: Fynn Petersen-Frey Date: Wed, 25 Sep 2024 17:15:59 +0200 Subject: [PATCH 1/3] refactor(mobile): DB repository for asset, backup, sync service --- mobile/analysis_options.yaml | 14 +- mobile/lib/interfaces/album.interface.dart | 27 +- mobile/lib/interfaces/asset.interface.dart | 48 ++- mobile/lib/interfaces/backup.interface.dart | 10 + mobile/lib/interfaces/etag.interface.dart | 13 + .../lib/interfaces/exif_info.interface.dart | 2 + mobile/lib/interfaces/user.interface.dart | 18 +- mobile/lib/providers/asset.provider.dart | 22 +- .../lib/providers/backup/backup.provider.dart | 9 +- .../backup/manual_upload.provider.dart | 10 +- .../repositories/activity_api.repository.dart | 4 +- mobile/lib/repositories/album.repository.dart | 83 +++-- .../repositories/album_api.repository.dart | 6 +- ...epository.dart => apibase.repository.dart} | 2 +- mobile/lib/repositories/asset.repository.dart | 213 +++++++++--- .../repositories/asset_api.repository.dart | 4 +- .../lib/repositories/backup.repository.dart | 33 +- .../lib/repositories/database.repository.dart | 13 + mobile/lib/repositories/etag.repository.dart | 30 ++ .../repositories/exif_info.repository.dart | 23 +- .../repositories/partner_api.repository.dart | 4 +- .../repositories/person_api.repository.dart | 4 +- mobile/lib/repositories/user.repository.dart | 53 ++- .../lib/repositories/user_api.repository.dart | 4 +- mobile/lib/services/album.service.dart | 12 +- mobile/lib/services/asset.service.dart | 72 ++-- mobile/lib/services/background.service.dart | 70 ++-- mobile/lib/services/backup.service.dart | 25 +- .../services/backup_verification.service.dart | 6 +- mobile/lib/services/sync.service.dart | 319 ++++++++---------- .../modules/shared/sync_service_test.dart | 88 ++++- mobile/test/repository.mocks.dart | 6 + mobile/test/services/album.service_test.dart | 2 +- 33 files changed, 810 insertions(+), 439 deletions(-) create mode 100644 mobile/lib/interfaces/etag.interface.dart rename mobile/lib/repositories/{base_api.repository.dart => apibase.repository.dart} (88%) create mode 100644 mobile/lib/repositories/database.repository.dart create mode 100644 mobile/lib/repositories/etag.repository.dart diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 6a7d7a6b4df89..82f849b951cde 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -64,19 +64,19 @@ custom_lint: allowed: # required / wanted - lib/entities/*.entity.dart - - lib/repositories/{album,asset,backup,exif_info,user}.repository.dart - # acceptable exceptions for the time being + - lib/repositories/{album,asset,backup,etag,exif_info,user}.repository.dart + # acceptable exceptions for the time being (until Isar is fully replaced) - integration_test/test_utils/general_helper.dart - lib/main.dart + - lib/pages/common/album_asset_selection.page.dart - lib/routing/router.dart + - lib/services/immich_logger.service.dart # not really a service... more a util - lib/utils/{db,migration,renderlist_generator}.dart + - lib/widgets/asset_grid/asset_grid_data_structure.dart - test/**.dart - # refactor to make the providers and services testable - - lib/pages/common/album_asset_selection.page.dart + # refactor the remaining providers - lib/providers/{archive,asset,authentication,db,favorite,partner,trash,user}.provider.dart - - lib/providers/{album/album,album/shared_album,asset_viewer/asset_stack,asset_viewer/render_list,backup/backup,backup/manual_upload,search/all_motion_photos,search/recently_added_asset}.provider.dart - - lib/services/{asset,background,backup,immich_logger,sync}.service.dart - - lib/widgets/asset_grid/asset_grid_data_structure.dart + - lib/providers/{album/album,album/shared_album,asset_viewer/asset_stack,asset_viewer/render_list,backup/backup,search/all_motion_photos,search/recently_added_asset}.provider.dart - import_rule_openapi: message: openapi must only be used through ApiRepositories diff --git a/mobile/lib/interfaces/album.interface.dart b/mobile/lib/interfaces/album.interface.dart index c2ba650b6f407..5d4c9f593848d 100644 --- a/mobile/lib/interfaces/album.interface.dart +++ b/mobile/lib/interfaces/album.interface.dart @@ -3,19 +3,40 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; abstract interface class IAlbumRepository { - Future count({bool? local}); Future create(Album album); - Future getById(int id); + + Future get(int id); + Future getByName( String name, { bool? shared, bool? remote, }); + + Future> getAll({ + bool? shared, + bool? remote, + int? ownerId, + AlbumSort? sortBy, + }); + Future update(Album album); + Future delete(int albumId); - Future> getAll({bool? shared}); + + Future deleteAllLocal(); + + Future count({bool? local}); + + Future addUsers(Album album, List users); + Future removeUsers(Album album, List users); + Future addAssets(Album album, List assets); + Future removeAssets(Album album, List assets); + Future recalculateMetadata(Album album); } + +enum AlbumSort { remoteId, localId } diff --git a/mobile/lib/interfaces/asset.interface.dart b/mobile/lib/interfaces/asset.interface.dart index 0d2dcfa1b5b35..0d09f717ce97a 100644 --- a/mobile/lib/interfaces/asset.interface.dart +++ b/mobile/lib/interfaces/asset.interface.dart @@ -1,27 +1,61 @@ import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/device_asset.entity.dart'; -import 'package:immich_mobile/entities/user.entity.dart'; abstract interface class IAssetRepository { Future getByRemoteId(String id); - Future> getAllByRemoteId(Iterable ids); - Future> getByAlbum(Album album, {User? notOwnedBy}); - Future deleteById(List ids); + + Future getByOwnerIdChecksum(int ownerId, String checksum); + + Future> getAllByRemoteId( + Iterable ids, { + AssetState? state, + }); + + Future> getAllByOwnerIdChecksum( + List ids, + List checksums, + ); + Future> getAll({ required int ownerId, - bool? remote, - int limit = 100, + AssetState? state, + AssetSort? sortBy, + int? limit, + }); + + Future> getAllLocal(); + + Future> getByAlbum( + Album album, { + Iterable notOwnedBy = const [], + int? ownerId, + AssetState? state, + AssetSort? sortBy, }); + + Future update(Asset asset); + Future> updateAll(List assets); + Future deleteAllByRemoteId(List ids, {AssetState? state}); + + Future deleteById(List ids); + Future> getMatches({ required List assets, required int ownerId, - bool? remote, + AssetState? state, int limit = 100, }); Future> getDeviceAssetsById(List ids); + Future upsertDeviceAssets(List deviceAssets); + + Future upsertDuplicatedAssets(Iterable duplicatedAssets); + + Future> getAllDuplicatedAssetIds(); } + +enum AssetSort { checksum, ownerIdChecksum } diff --git a/mobile/lib/interfaces/backup.interface.dart b/mobile/lib/interfaces/backup.interface.dart index e343a9d39019f..e8f5a73c90239 100644 --- a/mobile/lib/interfaces/backup.interface.dart +++ b/mobile/lib/interfaces/backup.interface.dart @@ -1,5 +1,15 @@ import 'package:immich_mobile/entities/backup_album.entity.dart'; abstract interface class IBackupRepository { + Future> getAll({BackupAlbumSort? sort}); + Future> getIdsBySelection(BackupSelection backup); + + Future> getAllBySelection(BackupSelection backup); + + Future updateAll(List backupAlbums); + + Future deleteAll(List ids); } + +enum BackupAlbumSort { id } diff --git a/mobile/lib/interfaces/etag.interface.dart b/mobile/lib/interfaces/etag.interface.dart new file mode 100644 index 0000000000000..ea3b1059d1b16 --- /dev/null +++ b/mobile/lib/interfaces/etag.interface.dart @@ -0,0 +1,13 @@ +import 'package:immich_mobile/entities/etag.entity.dart'; + +abstract interface class IETagRepository { + Future get(int id); + + Future getById(String id); + + Future> getAllIds(); + + Future upsertAll(List etags); + + Future deleteByIds(List ids); +} diff --git a/mobile/lib/interfaces/exif_info.interface.dart b/mobile/lib/interfaces/exif_info.interface.dart index fa8ca08f9d55f..b908511658e17 100644 --- a/mobile/lib/interfaces/exif_info.interface.dart +++ b/mobile/lib/interfaces/exif_info.interface.dart @@ -5,5 +5,7 @@ abstract interface class IExifInfoRepository { Future update(ExifInfo exifInfo); + Future> updateAll(List exifInfos); + Future delete(int id); } diff --git a/mobile/lib/interfaces/user.interface.dart b/mobile/lib/interfaces/user.interface.dart index 828a7b2398a50..6a2a7cf356e7b 100644 --- a/mobile/lib/interfaces/user.interface.dart +++ b/mobile/lib/interfaces/user.interface.dart @@ -1,8 +1,22 @@ import 'package:immich_mobile/entities/user.entity.dart'; abstract interface class IUserRepository { - Future> getByIds(List ids); Future get(String id); - Future> getAll({bool self = true}); + + Future> getByIds(List ids); + + Future> getAll({bool self = true, UserSort? sort}); + + /// Returns all users whose assets can be accessed (self+partners) + Future> getAllAccessible(); + + Future> upsertAll(List users); + Future update(User user); + + Future deleteById(List ids); + + Future me(); } + +enum UserSort { id } diff --git a/mobile/lib/providers/asset.provider.dart b/mobile/lib/providers/asset.provider.dart index a2c3987aa8165..c7e75df79b27f 100644 --- a/mobile/lib/providers/asset.provider.dart +++ b/mobile/lib/providers/asset.provider.dart @@ -275,28 +275,14 @@ class AssetNotifier extends StateNotifier { return isSuccess ? remote.toList() : []; } - Future toggleFavorite(List assets, [bool? status]) async { + Future toggleFavorite(List assets, [bool? status]) { status ??= !assets.every((a) => a.isFavorite); - final newAssets = await _assetService.changeFavoriteStatus(assets, status); - for (Asset? newAsset in newAssets) { - if (newAsset == null) { - log.severe("Change favorite status failed for asset"); - continue; - } - } + return _assetService.changeFavoriteStatus(assets, status); } - Future toggleArchive(List assets, [bool? status]) async { + Future toggleArchive(List assets, [bool? status]) { status ??= !assets.every((a) => a.isArchived); - final newAssets = await _assetService.changeArchiveStatus(assets, status); - int i = 0; - for (Asset oldAsset in assets) { - final newAsset = newAssets[i++]; - if (newAsset == null) { - log.severe("Change archive status failed for asset ${oldAsset.id}"); - continue; - } - } + return _assetService.changeArchiveStatus(assets, status); } } diff --git a/mobile/lib/providers/backup/backup.provider.dart b/mobile/lib/providers/backup/backup.provider.dart index 0885f35f77998..dc6d2f7cc89cb 100644 --- a/mobile/lib/providers/backup/backup.provider.dart +++ b/mobile/lib/providers/backup/backup.provider.dart @@ -7,6 +7,7 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; +import 'package:immich_mobile/interfaces/backup.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart'; import 'package:immich_mobile/models/backup/available_album.model.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; @@ -17,6 +18,7 @@ import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; +import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/backup.service.dart'; @@ -45,6 +47,7 @@ class BackupNotifier extends StateNotifier { this._db, this._albumMediaRepository, this._fileMediaRepository, + this._backupRepository, this.ref, ) : super( BackUpState( @@ -95,6 +98,7 @@ class BackupNotifier extends StateNotifier { final Isar _db; final IAlbumMediaRepository _albumMediaRepository; final IFileMediaRepository _fileMediaRepository; + final IBackupRepository _backupRepository; final Ref ref; /// @@ -255,9 +259,9 @@ class BackupNotifier extends StateNotifier { state = state.copyWith(availableAlbums: availableAlbums); final List excludedBackupAlbums = - await _backupService.excludedAlbumsQuery().findAll(); + await _backupRepository.getAllBySelection(BackupSelection.exclude); final List selectedBackupAlbums = - await _backupService.selectedAlbumsQuery().findAll(); + await _backupRepository.getAllBySelection(BackupSelection.select); final Set selectedAlbums = {}; for (final BackupAlbum ba in selectedBackupAlbums) { @@ -767,6 +771,7 @@ final backupProvider = ref.watch(dbProvider), ref.watch(albumMediaRepositoryProvider), ref.watch(fileMediaRepositoryProvider), + ref.watch(backupRepositoryProvider), ref, ); }); diff --git a/mobile/lib/providers/backup/manual_upload.provider.dart b/mobile/lib/providers/backup/manual_upload.provider.dart index 0cf159bfddb1c..192126f0859c7 100644 --- a/mobile/lib/providers/backup/manual_upload.provider.dart +++ b/mobile/lib/providers/backup/manual_upload.provider.dart @@ -6,8 +6,10 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/widgets.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; +import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; @@ -25,7 +27,6 @@ import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/services/local_notification.service.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; @@ -36,6 +37,7 @@ final manualUploadProvider = ref.watch(localNotificationService), ref.watch(backupProvider.notifier), ref.watch(backupServiceProvider), + ref.watch(backupRepositoryProvider), ref, ); }); @@ -45,12 +47,14 @@ class ManualUploadNotifier extends StateNotifier { final LocalNotificationService _localNotificationService; final BackupNotifier _backupProvider; final BackupService _backupService; + final BackupRepository _backupRepository; final Ref ref; ManualUploadNotifier( this._localNotificationService, this._backupProvider, this._backupService, + this._backupRepository, this.ref, ) : super( ManualUploadState( @@ -206,9 +210,9 @@ class ManualUploadNotifier extends StateNotifier { } final selectedBackupAlbums = - _backupService.selectedAlbumsQuery().findAllSync(); + await _backupRepository.getAllBySelection(BackupSelection.select); final excludedBackupAlbums = - _backupService.excludedAlbumsQuery().findAllSync(); + await _backupRepository.getAllBySelection(BackupSelection.exclude); // Get candidates from selected albums and excluded albums Set candidates = diff --git a/mobile/lib/repositories/activity_api.repository.dart b/mobile/lib/repositories/activity_api.repository.dart index 0b1b4d99f36df..6930f4e685191 100644 --- a/mobile/lib/repositories/activity_api.repository.dart +++ b/mobile/lib/repositories/activity_api.repository.dart @@ -3,14 +3,14 @@ import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/activity_api.interface.dart'; import 'package:immich_mobile/models/activities/activity.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:immich_mobile/repositories/apibase.repository.dart'; import 'package:openapi/api.dart'; final activityApiRepositoryProvider = Provider( (ref) => ActivityApiRepository(ref.watch(apiServiceProvider).activitiesApi), ); -class ActivityApiRepository extends BaseApiRepository +class ActivityApiRepository extends ApiBaseRepository implements IActivityApiRepository { final ActivitiesApi _api; diff --git a/mobile/lib/repositories/album.repository.dart b/mobile/lib/repositories/album.repository.dart index 08c939aa6ca87..105bcef56e558 100644 --- a/mobile/lib/repositories/album.repository.dart +++ b/mobile/lib/repositories/album.repository.dart @@ -4,32 +4,37 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/album.interface.dart'; import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; import 'package:isar/isar.dart'; final albumRepositoryProvider = Provider((ref) => AlbumRepository(ref.watch(dbProvider))); -class AlbumRepository implements IAlbumRepository { - final Isar _db; - - AlbumRepository( - this._db, - ); +class AlbumRepository extends DataBaseRepository implements IAlbumRepository { + AlbumRepository(super.db); @override Future count({bool? local}) { - if (local == true) return _db.albums.where().localIdIsNotNull().count(); - if (local == false) return _db.albums.where().remoteIdIsNotNull().count(); - return _db.albums.count(); + final baseQuery = db.albums.where(); + final QueryBuilder query; + switch (local) { + case null: + query = baseQuery.noOp(); + case true: + query = baseQuery.localIdIsNotNull(); + case false: + query = baseQuery.remoteIdIsNotNull(); + } + return query.count(); } @override Future create(Album album) => - _db.writeTxn(() => _db.albums.store(album)); + db.writeTxn(() => db.albums.store(album)); @override Future getByName(String name, {bool? shared, bool? remote}) { - var query = _db.albums.filter().nameEqualTo(name); + var query = db.albums.filter().nameEqualTo(name); if (shared != null) { query = query.sharedEqualTo(shared); } @@ -43,36 +48,62 @@ class AlbumRepository implements IAlbumRepository { @override Future update(Album album) => - _db.writeTxn(() => _db.albums.store(album)); + db.writeTxn(() => db.albums.store(album)); @override Future delete(int albumId) => - _db.writeTxn(() => _db.albums.delete(albumId)); + db.writeTxn(() => db.albums.delete(albumId)); @override - Future> getAll({bool? shared}) { - final baseQuery = _db.albums.filter(); - QueryBuilder? query; + Future> getAll({ + bool? shared, + bool? remote, + int? ownerId, + AlbumSort? sortBy, + }) { + final baseQuery = db.albums.where(); + final QueryBuilder afterWhere; + if (remote == null) { + afterWhere = baseQuery.noOp(); + } else if (remote) { + afterWhere = baseQuery.remoteIdIsNotNull(); + } else { + afterWhere = baseQuery.localIdIsNotNull(); + } + QueryBuilder filterQuery = + afterWhere.filter().noOp(); if (shared != null) { - query = baseQuery.sharedEqualTo(true); + filterQuery = filterQuery.sharedEqualTo(true); + } + if (ownerId != null) { + filterQuery = filterQuery.owner((q) => q.isarIdEqualTo(ownerId)); + } + final QueryBuilder query; + switch (sortBy) { + case null: + query = filterQuery.noOp(); + case AlbumSort.remoteId: + query = filterQuery.sortByRemoteId(); + case AlbumSort.localId: + query = filterQuery.sortByLocalId(); } - return query?.findAll() ?? _db.albums.where().findAll(); + return query.findAll(); } @override - Future getById(int id) => _db.albums.get(id); + Future get(int id) => db.albums.get(id); @override Future removeUsers(Album album, List users) => - _db.writeTxn(() => album.sharedUsers.update(unlink: users)); + db.writeTxn(() => album.sharedUsers.update(unlink: users)); @override Future addAssets(Album album, List assets) => - _db.writeTxn(() => album.assets.update(link: assets)); + db.writeTxn(() => album.assets.update(link: assets)); @override Future removeAssets(Album album, List assets) => - _db.writeTxn(() => album.assets.update(unlink: assets)); + db.writeTxn(() => album.assets.update(unlink: assets)); @override Future recalculateMetadata(Album album) async { @@ -82,4 +113,12 @@ class AlbumRepository implements IAlbumRepository { await album.assets.filter().updatedAtProperty().max(); return album; } + + @override + Future addUsers(Album album, List users) => + db.writeTxn(() => album.sharedUsers.update(link: users)); + + @override + Future deleteAllLocal() => + db.albums.where().localIdIsNotNull().deleteAll(); } diff --git a/mobile/lib/repositories/album_api.repository.dart b/mobile/lib/repositories/album_api.repository.dart index 0e27e44684a67..bab5000078a2e 100644 --- a/mobile/lib/repositories/album_api.repository.dart +++ b/mobile/lib/repositories/album_api.repository.dart @@ -4,14 +4,14 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/album_api.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:immich_mobile/repositories/apibase.repository.dart'; import 'package:openapi/api.dart'; final albumApiRepositoryProvider = Provider( (ref) => AlbumApiRepository(ref.watch(apiServiceProvider).albumsApi), ); -class AlbumApiRepository extends BaseApiRepository +class AlbumApiRepository extends ApiBaseRepository implements IAlbumApiRepository { final AlbumsApi _api; @@ -26,7 +26,7 @@ class AlbumApiRepository extends BaseApiRepository @override Future> getAll({bool? shared}) async { final dtos = await checkNull(_api.getAllAlbums(shared: shared)); - return dtos.map(_toAlbum).toList().cast(); + return dtos.map(_toAlbum).toList(); } @override diff --git a/mobile/lib/repositories/base_api.repository.dart b/mobile/lib/repositories/apibase.repository.dart similarity index 88% rename from mobile/lib/repositories/base_api.repository.dart rename to mobile/lib/repositories/apibase.repository.dart index 418cba84f886c..17a110d619aad 100644 --- a/mobile/lib/repositories/base_api.repository.dart +++ b/mobile/lib/repositories/apibase.repository.dart @@ -1,7 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:immich_mobile/constants/errors.dart'; -abstract class BaseApiRepository { +abstract class ApiBaseRepository { @protected Future checkNull(Future future) async { final response = await future; diff --git a/mobile/lib/repositories/asset.repository.dart b/mobile/lib/repositories/asset.repository.dart index 087344302a417..1069eb98f29b6 100644 --- a/mobile/lib/repositories/asset.repository.dart +++ b/mobile/lib/repositories/asset.repository.dart @@ -5,78 +5,145 @@ import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/android_device_asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/device_asset.entity.dart'; +import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; +import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; -import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; import 'package:isar/isar.dart'; final assetRepositoryProvider = Provider((ref) => AssetRepository(ref.watch(dbProvider))); -class AssetRepository implements IAssetRepository { - final Isar _db; - - AssetRepository( - this._db, - ); +class AssetRepository extends DataBaseRepository implements IAssetRepository { + AssetRepository(super.db); @override - Future> getByAlbum(Album album, {User? notOwnedBy}) { + Future> getByAlbum( + Album album, { + Iterable notOwnedBy = const [], + int? ownerId, + AssetState? state, + AssetSort? sortBy, + }) { var query = album.assets.filter(); - if (notOwnedBy != null) { - query = query.not().ownerIdEqualTo(notOwnedBy.isarId); + if (notOwnedBy.length == 1) { + query = query.not().ownerIdEqualTo(notOwnedBy.first); + } else if (notOwnedBy.isNotEmpty) { + query = + query.not().anyOf(notOwnedBy, (q, int id) => q.ownerIdEqualTo(id)); + } + if (ownerId != null) { + query = query.ownerIdEqualTo(ownerId); + } + + switch (state) { + case null: + break; + case AssetState.local: + query = query.remoteIdIsNull(); + case AssetState.remote: + query = query.localIdIsNull(); + case AssetState.merged: + query = query.localIdIsNotNull().remoteIdIsNotNull(); + } + + final QueryBuilder sortedQuery; + + switch (sortBy) { + case null: + sortedQuery = query.noOp(); + case AssetSort.checksum: + sortedQuery = query.sortByChecksum(); + case AssetSort.ownerIdChecksum: + sortedQuery = query.sortByOwnerId().thenByChecksum(); } - return query.findAll(); + + return sortedQuery.findAll(); } @override - Future deleteById(List ids) => - _db.writeTxn(() => _db.assets.deleteAll(ids)); + Future deleteById(List ids) => db.writeTxn(() async { + await db.assets.deleteAll(ids); + await db.exifInfos.deleteAll(ids); + }); @override - Future getByRemoteId(String id) => _db.assets.getByRemoteId(id); + Future getByRemoteId(String id) => db.assets.getByRemoteId(id); @override - Future> getAllByRemoteId(Iterable ids) => - _db.assets.getAllByRemoteId(ids); + Future> getAllByRemoteId( + Iterable ids, { + AssetState? state, + }) => + _getAllByRemoteIdImpl(ids, state).findAll(); + + QueryBuilder _getAllByRemoteIdImpl( + Iterable ids, + AssetState? state, + ) { + final query = db.assets.remote(ids).filter(); + switch (state) { + case null: + return query.noOp(); + case AssetState.local: + return query.remoteIdIsNull(); + case AssetState.remote: + return query.localIdIsNull(); + case AssetState.merged: + return query.localIdIsNotEmpty().remoteIdIsNotNull(); + } + } @override Future> getAll({ required int ownerId, - bool? remote, - int limit = 100, + AssetState? state, + AssetSort? sortBy, + int? limit, }) { - if (remote == null) { - return _db.assets - .where() - .ownerIdEqualToAnyChecksum(ownerId) - .limit(limit) - .findAll(); + final baseQuery = db.assets.where(); + final QueryBuilder filteredQuery; + switch (state) { + case null: + filteredQuery = baseQuery.ownerIdEqualToAnyChecksum(ownerId).noOp(); + case AssetState.local: + filteredQuery = baseQuery + .remoteIdIsNull() + .filter() + .localIdIsNotNull() + .ownerIdEqualTo(ownerId); + case AssetState.remote: + filteredQuery = baseQuery + .localIdIsNull() + .filter() + .remoteIdIsNotNull() + .ownerIdEqualTo(ownerId); + case AssetState.merged: + filteredQuery = baseQuery + .ownerIdEqualToAnyChecksum(ownerId) + .filter() + .remoteIdIsNotNull() + .localIdIsNotNull(); } - final QueryBuilder query; - if (remote) { - query = _db.assets - .where() - .localIdIsNull() - .filter() - .remoteIdIsNotNull() - .ownerIdEqualTo(ownerId); - } else { - query = _db.assets - .where() - .remoteIdIsNull() - .filter() - .localIdIsNotNull() - .ownerIdEqualTo(ownerId); + + final QueryBuilder query; + switch (sortBy) { + case null: + query = filteredQuery.noOp(); + case AssetSort.checksum: + query = filteredQuery.sortByChecksum(); + case AssetSort.ownerIdChecksum: + query = filteredQuery.sortByOwnerId().thenByChecksum(); } - return query.limit(limit).findAll(); + return limit == null ? query.findAll() : query.limit(limit).findAll(); } @override Future> updateAll(List assets) async { - await _db.writeTxn(() => _db.assets.putAll(assets)); + await db.writeTxn(() => db.assets.putAll(assets)); return assets; } @@ -84,16 +151,20 @@ class AssetRepository implements IAssetRepository { Future> getMatches({ required List assets, required int ownerId, - bool? remote, + AssetState? state, int limit = 100, }) { + final baseQuery = db.assets.where(); final QueryBuilder query; - if (remote == null) { - query = _db.assets.filter().remoteIdIsNotNull().or().localIdIsNotNull(); - } else if (remote) { - query = _db.assets.where().localIdIsNull().filter().remoteIdIsNotNull(); - } else { - query = _db.assets.where().remoteIdIsNull().filter().localIdIsNotNull(); + switch (state) { + case null: + query = baseQuery.noOp(); + case AssetState.local: + query = baseQuery.remoteIdIsNull().filter().localIdIsNotNull(); + case AssetState.remote: + query = baseQuery.localIdIsNull().filter().remoteIdIsNotNull(); + case AssetState.merged: + query = baseQuery.localIdIsNotNull().filter().remoteIdIsNotNull(); } return _getMatchesImpl(query, ownerId, assets, limit); } @@ -101,16 +172,52 @@ class AssetRepository implements IAssetRepository { @override Future> getDeviceAssetsById(List ids) => Platform.isAndroid - ? _db.androidDeviceAssets.getAll(ids.cast()) - : _db.iOSDeviceAssets.getAllById(ids.cast()); + ? db.androidDeviceAssets.getAll(ids.cast()) + : db.iOSDeviceAssets.getAllById(ids.cast()); @override Future upsertDeviceAssets(List deviceAssets) => - _db.writeTxn( + db.writeTxn( () => Platform.isAndroid - ? _db.androidDeviceAssets.putAll(deviceAssets.cast()) - : _db.iOSDeviceAssets.putAll(deviceAssets.cast()), + ? db.androidDeviceAssets.putAll(deviceAssets.cast()) + : db.iOSDeviceAssets.putAll(deviceAssets.cast()), ); + + @override + Future update(Asset asset) async { + await asset.put(db); + return asset; + } + + @override + Future upsertDuplicatedAssets(Iterable duplicatedAssets) => + db.writeTxn( + () => db.duplicatedAssets + .putAll(duplicatedAssets.map(DuplicatedAsset.new).toList()), + ); + + @override + Future> getAllDuplicatedAssetIds() => + db.duplicatedAssets.where().idProperty().findAll(); + + @override + Future getByOwnerIdChecksum(int ownerId, String checksum) => + db.assets.getByOwnerIdChecksum(ownerId, checksum); + + @override + Future> getAllByOwnerIdChecksum( + List ids, + List checksums, + ) => + db.assets.getAllByOwnerIdChecksum(ids, checksums); + + @override + Future> getAllLocal() => + db.assets.where().localIdIsNotNull().findAll(); + + @override + Future deleteAllByRemoteId(List ids, {AssetState? state}) => + _getAllByRemoteIdImpl(ids, state).deleteAll(); } Future> _getMatchesImpl( diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index eb796f6c6b5d2..a28abe1eaf440 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -2,7 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/interfaces/asset_api.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:immich_mobile/repositories/apibase.repository.dart'; import 'package:openapi/api.dart'; final assetApiRepositoryProvider = Provider( @@ -12,7 +12,7 @@ final assetApiRepositoryProvider = Provider( ), ); -class AssetApiRepository extends BaseApiRepository +class AssetApiRepository extends ApiBaseRepository implements IAssetApiRepository { final AssetsApi _api; final SearchApi _searchApi; diff --git a/mobile/lib/repositories/backup.repository.dart b/mobile/lib/repositories/backup.repository.dart index c9d93f787769b..994c14fd19cd1 100644 --- a/mobile/lib/repositories/backup.repository.dart +++ b/mobile/lib/repositories/backup.repository.dart @@ -2,19 +2,40 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/interfaces/backup.interface.dart'; import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; import 'package:isar/isar.dart'; final backupRepositoryProvider = Provider((ref) => BackupRepository(ref.watch(dbProvider))); -class BackupRepository implements IBackupRepository { - final Isar _db; +class BackupRepository extends DataBaseRepository implements IBackupRepository { + BackupRepository(super.db); - BackupRepository( - this._db, - ); + @override + Future> getAll({BackupAlbumSort? sort}) { + final baseQuery = db.backupAlbums.where(); + final QueryBuilder query; + switch (sort) { + case null: + query = baseQuery.noOp(); + case BackupAlbumSort.id: + query = baseQuery.sortById(); + } + return query.findAll(); + } @override Future> getIdsBySelection(BackupSelection backup) => - _db.backupAlbums.filter().selectionEqualTo(backup).idProperty().findAll(); + db.backupAlbums.filter().selectionEqualTo(backup).idProperty().findAll(); + + @override + Future> getAllBySelection(BackupSelection backup) => + db.backupAlbums.filter().selectionEqualTo(backup).findAll(); + + @override + Future deleteAll(List ids) => db.backupAlbums.deleteAll(ids); + + @override + Future updateAll(List backupAlbums) => + db.backupAlbums.putAll(backupAlbums); } diff --git a/mobile/lib/repositories/database.repository.dart b/mobile/lib/repositories/database.repository.dart new file mode 100644 index 0000000000000..f7a085ff7491d --- /dev/null +++ b/mobile/lib/repositories/database.repository.dart @@ -0,0 +1,13 @@ +import 'package:isar/isar.dart'; + +abstract class DataBaseRepository { + final Isar db; + DataBaseRepository(this.db); +} + +extension Asd on QueryBuilder { + QueryBuilder noOp() { + // ignore: invalid_use_of_protected_member + return QueryBuilder.apply(this, (query) => query); + } +} diff --git a/mobile/lib/repositories/etag.repository.dart b/mobile/lib/repositories/etag.repository.dart new file mode 100644 index 0000000000000..3b1f22318396c --- /dev/null +++ b/mobile/lib/repositories/etag.repository.dart @@ -0,0 +1,30 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/etag.entity.dart'; +import 'package:immich_mobile/interfaces/etag.interface.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; +import 'package:isar/isar.dart'; + +final etagRepositoryProvider = + Provider((ref) => ETagRepository(ref.watch(dbProvider))); + +class ETagRepository extends DataBaseRepository implements IETagRepository { + ETagRepository(super.db); + + @override + Future> getAllIds() => db.eTags.where().idProperty().findAll(); + + @override + Future get(int id) => db.eTags.get(id); + + @override + Future upsertAll(List etags) => + db.writeTxn(() => db.eTags.putAll(etags)); + + @override + Future deleteByIds(List ids) => + db.writeTxn(() => db.eTags.deleteAllById(ids)); + + @override + Future getById(String id) => db.eTags.getById(id); +} diff --git a/mobile/lib/repositories/exif_info.repository.dart b/mobile/lib/repositories/exif_info.repository.dart index a165e98bdbfe3..80393ce8842bb 100644 --- a/mobile/lib/repositories/exif_info.repository.dart +++ b/mobile/lib/repositories/exif_info.repository.dart @@ -2,27 +2,30 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/interfaces/exif_info.interface.dart'; import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:isar/isar.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; final exifInfoRepositoryProvider = Provider((ref) => ExifInfoRepository(ref.watch(dbProvider))); -class ExifInfoRepository implements IExifInfoRepository { - final Isar _db; - - ExifInfoRepository( - this._db, - ); +class ExifInfoRepository extends DataBaseRepository + implements IExifInfoRepository { + ExifInfoRepository(super.db); @override - Future delete(int id) => _db.exifInfos.delete(id); + Future delete(int id) => db.exifInfos.delete(id); @override - Future get(int id) => _db.exifInfos.get(id); + Future get(int id) => db.exifInfos.get(id); @override Future update(ExifInfo exifInfo) async { - await _db.writeTxn(() => _db.exifInfos.put(exifInfo)); + await db.writeTxn(() => db.exifInfos.put(exifInfo)); return exifInfo; } + + @override + Future> updateAll(List exifInfos) async { + await db.writeTxn(() => db.exifInfos.putAll(exifInfos)); + return exifInfos; + } } diff --git a/mobile/lib/repositories/partner_api.repository.dart b/mobile/lib/repositories/partner_api.repository.dart index 3419a2bc77244..caaa882bfb7c4 100644 --- a/mobile/lib/repositories/partner_api.repository.dart +++ b/mobile/lib/repositories/partner_api.repository.dart @@ -2,7 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/partner_api.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:immich_mobile/repositories/apibase.repository.dart'; import 'package:openapi/api.dart'; final partnerApiRepositoryProvider = Provider( @@ -11,7 +11,7 @@ final partnerApiRepositoryProvider = Provider( ), ); -class PartnerApiRepository extends BaseApiRepository +class PartnerApiRepository extends ApiBaseRepository implements IPartnerApiRepository { final PartnersApi _api; diff --git a/mobile/lib/repositories/person_api.repository.dart b/mobile/lib/repositories/person_api.repository.dart index 8071c33dc2cea..f7d0a2e2e7602 100644 --- a/mobile/lib/repositories/person_api.repository.dart +++ b/mobile/lib/repositories/person_api.repository.dart @@ -1,14 +1,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/interfaces/person_api.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:immich_mobile/repositories/apibase.repository.dart'; import 'package:openapi/api.dart'; final personApiRepositoryProvider = Provider( (ref) => PersonApiRepository(ref.watch(apiServiceProvider).peopleApi), ); -class PersonApiRepository extends BaseApiRepository +class PersonApiRepository extends ApiBaseRepository implements IPersonApiRepository { final PeopleApi _api; diff --git a/mobile/lib/repositories/user.repository.dart b/mobile/lib/repositories/user.repository.dart index 796b1f421b863..83bab12758e95 100644 --- a/mobile/lib/repositories/user.repository.dart +++ b/mobile/lib/repositories/user.repository.dart @@ -3,37 +3,62 @@ import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/user.interface.dart'; import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; import 'package:isar/isar.dart'; final userRepositoryProvider = Provider((ref) => UserRepository(ref.watch(dbProvider))); -class UserRepository implements IUserRepository { - final Isar _db; - - UserRepository( - this._db, - ); +class UserRepository extends DataBaseRepository implements IUserRepository { + UserRepository(super.db); @override Future> getByIds(List ids) async => - (await _db.users.getAllById(ids)).cast(); + (await db.users.getAllById(ids)).nonNulls.toList(); @override - Future get(String id) => _db.users.getById(id); + Future get(String id) => db.users.getById(id); @override - Future> getAll({bool self = true}) { - if (self) { - return _db.users.where().findAll(); - } + Future> getAll({bool self = true, UserSort? sort}) { + final baseQuery = db.users.where(); final int userId = Store.get(StoreKey.currentUser).isarId; - return _db.users.where().isarIdNotEqualTo(userId).findAll(); + final QueryBuilder afterWhere = + self ? baseQuery.noOp() : baseQuery.isarIdNotEqualTo(userId); + final QueryBuilder query; + switch (sort) { + case null: + query = afterWhere.noOp(); + case UserSort.id: + query = afterWhere.sortById(); + } + return query.findAll(); } @override Future update(User user) async { - await _db.writeTxn(() => _db.users.put(user)); + await db.writeTxn(() => db.users.put(user)); return user; } + + @override + Future me() => Future.value(Store.get(StoreKey.currentUser)); + + @override + Future deleteById(List ids) => + db.writeTxn(() => db.users.deleteAll(ids)); + + @override + Future> upsertAll(List users) async { + await db.writeTxn(() => db.users.putAll(users)); + return users; + } + + @override + Future> getAllAccessible() => db.users + .filter() + .isPartnerSharedWithEqualTo(true) + .or() + .isarIdEqualTo(Store.get(StoreKey.currentUser).isarId) + .findAll(); } diff --git a/mobile/lib/repositories/user_api.repository.dart b/mobile/lib/repositories/user_api.repository.dart index ffc50ae4c3e6c..bd76c50148c84 100644 --- a/mobile/lib/repositories/user_api.repository.dart +++ b/mobile/lib/repositories/user_api.repository.dart @@ -5,7 +5,7 @@ import 'package:http/http.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/user_api.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:immich_mobile/repositories/apibase.repository.dart'; import 'package:openapi/api.dart'; final userApiRepositoryProvider = Provider( @@ -14,7 +14,7 @@ final userApiRepositoryProvider = Provider( ), ); -class UserApiRepository extends BaseApiRepository +class UserApiRepository extends ApiBaseRepository implements IUserApiRepository { final UsersApi _api; diff --git a/mobile/lib/services/album.service.dart b/mobile/lib/services/album.service.dart index dd021e698e094..561c83d94a101 100644 --- a/mobile/lib/services/album.service.dart +++ b/mobile/lib/services/album.service.dart @@ -244,7 +244,7 @@ class AlbumService { List add = const [], List remove = const [], }) async { - final album = await _albumRepository.getById(albumId); + final album = await _albumRepository.get(albumId); if (album == null) return; await _albumRepository.addAssets(album, add); await _albumRepository.removeAssets(album, remove); @@ -285,20 +285,20 @@ class AlbumService { Future deleteAlbum(Album album) async { try { - final user = Store.get(StoreKey.currentUser); - if (album.owner.value?.isarId == user.isarId) { + final userId = Store.get(StoreKey.currentUser).isarId; + if (album.owner.value?.isarId == userId) { await _albumApiRepository.delete(album.remoteId!); } if (album.shared) { final foreignAssets = - await _assetRepository.getByAlbum(album, notOwnedBy: user); + await _assetRepository.getByAlbum(album, notOwnedBy: [userId]); await _albumRepository.delete(album.id); final List albums = await _albumRepository.getAll(shared: true); final List existing = []; for (Album album in albums) { existing.addAll( - await _assetRepository.getByAlbum(album, notOwnedBy: user), + await _assetRepository.getByAlbum(album, notOwnedBy: [userId]), ); } final List idsToRemove = @@ -357,7 +357,7 @@ class AlbumService { album.sharedUsers.remove(user); await _albumRepository.removeUsers(album, [user]); - final a = await _albumRepository.getById(album.id); + final a = await _albumRepository.get(album.id); // trigger watcher await _albumRepository.update(a!); diff --git a/mobile/lib/services/asset.service.dart b/mobile/lib/services/asset.service.dart index 262040026e6a2..0c43cb9c9d7be 100644 --- a/mobile/lib/services/asset.service.dart +++ b/mobile/lib/services/asset.service.dart @@ -1,27 +1,30 @@ -// ignore_for_file: null_argument_to_non_null_type - import 'dart:async'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/etag.entity.dart'; -import 'package:immich_mobile/entities/exif_info.entity.dart'; +import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset_api.interface.dart'; +import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/interfaces/etag.interface.dart'; import 'package:immich_mobile/interfaces/exif_info.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/asset_api.repository.dart'; +import 'package:immich_mobile/repositories/backup.repository.dart'; +import 'package:immich_mobile/repositories/etag.repository.dart'; import 'package:immich_mobile/repositories/exif_info.repository.dart'; +import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/backup.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:openapi/api.dart'; @@ -29,48 +32,54 @@ import 'package:openapi/api.dart'; final assetServiceProvider = Provider( (ref) => AssetService( ref.watch(assetApiRepositoryProvider), + ref.watch(assetRepositoryProvider), ref.watch(exifInfoRepositoryProvider), + ref.watch(userRepositoryProvider), + ref.watch(etagRepositoryProvider), + ref.watch(backupRepositoryProvider), ref.watch(apiServiceProvider), ref.watch(syncServiceProvider), ref.watch(userServiceProvider), ref.watch(backupServiceProvider), ref.watch(albumServiceProvider), - ref.watch(dbProvider), ), ); class AssetService { final IAssetApiRepository _assetApiRepository; + final IAssetRepository _assetRepository; final IExifInfoRepository _exifInfoRepository; + final IUserRepository _userRepository; + final IETagRepository _etagRepository; + final IBackupRepository _backupRepository; final ApiService _apiService; final SyncService _syncService; final UserService _userService; final BackupService _backupService; final AlbumService _albumService; final log = Logger('AssetService'); - final Isar _db; AssetService( this._assetApiRepository, + this._assetRepository, this._exifInfoRepository, + this._userRepository, + this._etagRepository, + this._backupRepository, this._apiService, this._syncService, this._userService, this._backupService, this._albumService, - this._db, ); /// Checks the server for updated assets and updates the local database if /// required. Returns `true` if there were any changes. Future refreshRemoteAssets() async { - final syncedUserIds = await _db.eTags.where().idProperty().findAll(); + final syncedUserIds = await _etagRepository.getAllIds(); final List syncedUsers = syncedUserIds.isEmpty ? [] - : await _db.users - .where() - .anyOf(syncedUserIds, (q, id) => q.idEqualTo(id)) - .findAll(); + : await _userRepository.getByIds(syncedUserIds); final Stopwatch sw = Stopwatch()..start(); final bool changes = await _syncService.syncRemoteAssetsToDb( users: syncedUsers, @@ -175,7 +184,7 @@ class AssetService { /// Loads the exif information from the database. If there is none, loads /// the exif info from the server (remote assets only) Future loadExif(Asset a) async { - a.exifInfo ??= await _db.exifInfos.get(a.id); + a.exifInfo ??= await _exifInfoRepository.get(a.id); // fileSize is always filled on the server but not set on client if (a.exifInfo?.fileSize == null) { if (a.isRemote) { @@ -185,7 +194,7 @@ class AssetService { a.exifInfo = newExif; if (newExif != a.exifInfo) { if (a.isInDb) { - _db.writeTxn(() => a.put(_db)); + _assetRepository.update(a); } else { debugPrint("[loadExif] parameter Asset is not from DB!"); } @@ -214,7 +223,7 @@ class AssetService { ); } - Future> changeFavoriteStatus( + Future> changeFavoriteStatus( List assets, bool isFavorite, ) async { @@ -230,11 +239,11 @@ class AssetService { return assets; } catch (error, stack) { log.severe("Error while changing favorite status", error, stack); - return Future.value(null); + return []; } } - Future> changeArchiveStatus( + Future> changeArchiveStatus( List assets, bool isArchived, ) async { @@ -250,11 +259,11 @@ class AssetService { return assets; } catch (error, stack) { log.severe("Error while changing archive status", error, stack); - return Future.value(null); + return []; } } - Future> changeDateTime( + Future?> changeDateTime( List assets, String updatedDt, ) async { @@ -278,7 +287,7 @@ class AssetService { } } - Future> changeLocation( + Future?> changeLocation( List assets, LatLng location, ) async { @@ -307,10 +316,10 @@ class AssetService { Future syncUploadedAssetToAlbums() async { try { - final [selectedAlbums, excludedAlbums] = await Future.wait([ - _backupService.selectedAlbumsQuery().findAll(), - _backupService.excludedAlbumsQuery().findAll(), - ]); + final selectedAlbums = + await _backupRepository.getAllBySelection(BackupSelection.select); + final excludedAlbums = + await _backupRepository.getAllBySelection(BackupSelection.exclude); final candidates = await _backupService.buildUploadCandidates( selectedAlbums, @@ -319,12 +328,11 @@ class AssetService { ); await refreshRemoteAssets(); - final remoteAssets = await _db.assets - .where() - .localIdIsNotNull() - .filter() - .remoteIdIsNotNull() - .findAll(); + final owner = await _userRepository.me(); + final remoteAssets = await _assetRepository.getAll( + ownerId: owner.isarId, + state: AssetState.merged, + ); /// Map Map> assetToAlbums = {}; diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index d06bc86d4871b..dafc45739d79c 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -9,6 +9,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/backup.interface.dart'; import 'package:immich_mobile/main.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; @@ -17,6 +18,8 @@ import 'package:immich_mobile/repositories/album_api.repository.dart'; import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; +import 'package:immich_mobile/repositories/etag.repository.dart'; +import 'package:immich_mobile/repositories/exif_info.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/repositories/partner_api.repository.dart'; import 'package:immich_mobile/repositories/user.repository.dart'; @@ -37,7 +40,6 @@ import 'package:immich_mobile/services/user.service.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; -import 'package:isar/isar.dart'; import 'package:path_provider_ios/path_provider_ios.dart'; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; @@ -356,7 +358,7 @@ class BackgroundService { } Future _onAssetsChanged() async { - final Isar db = await loadDb(); + final db = await loadDb(); HttpOverrides.global = HttpSSLCertOverride(); ApiService apiService = ApiService(); @@ -365,7 +367,9 @@ class BackgroundService { AppSettingsService settingsService = AppSettingsService(); AlbumRepository albumRepository = AlbumRepository(db); AssetRepository assetRepository = AssetRepository(db); - BackupRepository backupAlbumRepository = BackupRepository(db); + BackupRepository backupRepository = BackupRepository(db); + ExifInfoRepository exifInfoRepository = ExifInfoRepository(db); + ETagRepository eTagRepository = ETagRepository(db); AlbumMediaRepository albumMediaRepository = AlbumMediaRepository(); FileMediaRepository fileMediaRepository = FileMediaRepository(); UserRepository userRepository = UserRepository(db); @@ -380,11 +384,15 @@ class BackgroundService { EntityService entityService = EntityService(assetRepository, userRepository); SyncService syncSerive = SyncService( - db, hashService, entityService, albumMediaRepository, albumApiRepository, + albumRepository, + assetRepository, + exifInfoRepository, + userRepository, + eTagRepository, ); UserService userService = UserService( partnerApiRepository, @@ -398,21 +406,23 @@ class BackgroundService { entityService, albumRepository, assetRepository, - backupAlbumRepository, + backupRepository, albumMediaRepository, albumApiRepository, ); BackupService backupService = BackupService( apiService, - db, settingService, albumService, albumMediaRepository, fileMediaRepository, + assetRepository, ); - final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync(); - final excludedAlbums = backupService.excludedAlbumsQuery().findAllSync(); + final selectedAlbums = + await backupRepository.getAllBySelection(BackupSelection.select); + final excludedAlbums = + await backupRepository.getAllBySelection(BackupSelection.exclude); if (selectedAlbums.isEmpty) { return true; } @@ -430,28 +440,28 @@ class BackgroundService { await Store.delete(StoreKey.backupFailedSince); final backupAlbums = [...selectedAlbums, ...excludedAlbums]; backupAlbums.sortBy((e) => e.id); - db.writeTxnSync(() { - final dbAlbums = db.backupAlbums.where().sortById().findAllSync(); - final List toDelete = []; - final List toUpsert = []; - // stores the most recent `lastBackup` per album but always keeps the `selection` from the most recent DB state - diffSortedListsSync( - dbAlbums, - backupAlbums, - compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id), - both: (BackupAlbum a, BackupAlbum b) { - a.lastBackup = a.lastBackup.isAfter(b.lastBackup) - ? a.lastBackup - : b.lastBackup; - toUpsert.add(a); - return true; - }, - onlyFirst: (BackupAlbum a) => toUpsert.add(a), - onlySecond: (BackupAlbum b) => toDelete.add(b.isarId), - ); - db.backupAlbums.deleteAllSync(toDelete); - db.backupAlbums.putAllSync(toUpsert); - }); + + final dbAlbums = + await backupRepository.getAll(sort: BackupAlbumSort.id); + final List toDelete = []; + final List toUpsert = []; + // stores the most recent `lastBackup` per album but always keeps the `selection` from the most recent DB state + diffSortedListsSync( + dbAlbums, + backupAlbums, + compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id), + both: (BackupAlbum a, BackupAlbum b) { + a.lastBackup = a.lastBackup.isAfter(b.lastBackup) + ? a.lastBackup + : b.lastBackup; + toUpsert.add(a); + return true; + }, + onlyFirst: (BackupAlbum a) => toUpsert.add(a), + onlySecond: (BackupAlbum b) => toDelete.add(b.isarId), + ); + await backupRepository.deleteAll(toDelete); + await backupRepository.updateAll(toUpsert); } else if (Store.tryGet(StoreKey.backupFailedSince) == null) { Store.put(StoreKey.backupFailedSince, DateTime.now()); return false; diff --git a/mobile/lib/services/backup.service.dart b/mobile/lib/services/backup.service.dart index 19d731d773d75..921fe3ca82f7b 100644 --- a/mobile/lib/services/backup.service.dart +++ b/mobile/lib/services/backup.service.dart @@ -9,9 +9,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; -import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; @@ -19,13 +19,12 @@ import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; import 'package:path/path.dart' as p; @@ -35,31 +34,31 @@ import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; final backupServiceProvider = Provider( (ref) => BackupService( ref.watch(apiServiceProvider), - ref.watch(dbProvider), ref.watch(appSettingsServiceProvider), ref.watch(albumServiceProvider), ref.watch(albumMediaRepositoryProvider), ref.watch(fileMediaRepositoryProvider), + ref.watch(assetRepositoryProvider), ), ); class BackupService { final httpClient = http.Client(); final ApiService _apiService; - final Isar _db; final Logger _log = Logger("BackupService"); final AppSettingsService _appSetting; final AlbumService _albumService; final IAlbumMediaRepository _albumMediaRepository; final IFileMediaRepository _fileMediaRepository; + final IAssetRepository _assetRepository; BackupService( this._apiService, - this._db, this._appSetting, this._albumService, this._albumMediaRepository, this._fileMediaRepository, + this._assetRepository, ); Future?> getDeviceBackupAsset() async { @@ -74,23 +73,15 @@ class BackupService { } Future _saveDuplicatedAssetIds(List deviceAssetIds) { - final duplicates = deviceAssetIds.map((id) => DuplicatedAsset(id)).toList(); - return _db.writeTxn(() => _db.duplicatedAssets.putAll(duplicates)); + return _assetRepository.upsertDuplicatedAssets(deviceAssetIds); } /// Get duplicated asset id from database Future> getDuplicatedAssetIds() async { - final duplicates = await _db.duplicatedAssets.where().findAll(); - return duplicates.map((e) => e.id).toSet(); + final duplicates = await _assetRepository.getAllDuplicatedAssetIds(); + return duplicates.toSet(); } - QueryBuilder - selectedAlbumsQuery() => - _db.backupAlbums.filter().selectionEqualTo(BackupSelection.select); - QueryBuilder - excludedAlbumsQuery() => - _db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude); - /// Returns all assets newer than the last successful backup per album /// if `useTimeFilter` is set to true, all assets will be returned Future> buildUploadCandidates( diff --git a/mobile/lib/services/backup_verification.service.dart b/mobile/lib/services/backup_verification.service.dart index da9d8da1649e4..82cfb8347a975 100644 --- a/mobile/lib/services/backup_verification.service.dart +++ b/mobile/lib/services/backup_verification.service.dart @@ -34,19 +34,19 @@ class BackupVerificationService { final owner = Store.get(StoreKey.currentUser).isarId; final List onlyLocal = await _assetRepository.getAll( ownerId: owner, - remote: false, + state: AssetState.local, limit: limit, ); final List remoteMatches = await _assetRepository.getMatches( assets: onlyLocal, ownerId: owner, - remote: true, + state: AssetState.remote, limit: limit, ); final List localMatches = await _assetRepository.getMatches( assets: remoteMatches, ownerId: owner, - remote: false, + state: AssetState.local, limit: limit, ); diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart index c3f927fc93937..3c502e26af435 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -5,48 +5,66 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; -import 'package:immich_mobile/entities/exif_info.entity.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/album.interface.dart'; import 'package:immich_mobile/interfaces/album_api.interface.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/etag.interface.dart'; +import 'package:immich_mobile/interfaces/exif_info.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; +import 'package:immich_mobile/repositories/album.repository.dart'; import 'package:immich_mobile/repositories/album_api.repository.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; +import 'package:immich_mobile/repositories/etag.repository.dart'; +import 'package:immich_mobile/repositories/exif_info.repository.dart'; +import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/hash.service.dart'; import 'package:immich_mobile/utils/async_mutex.dart'; import 'package:immich_mobile/extensions/collection_extensions.dart'; import 'package:immich_mobile/utils/datetime_comparison.dart'; import 'package:immich_mobile/utils/diff.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; final syncServiceProvider = Provider( (ref) => SyncService( - ref.watch(dbProvider), ref.watch(hashServiceProvider), ref.watch(entityServiceProvider), ref.watch(albumMediaRepositoryProvider), ref.watch(albumApiRepositoryProvider), + ref.watch(albumRepositoryProvider), + ref.watch(assetRepositoryProvider), + ref.watch(exifInfoRepositoryProvider), + ref.watch(userRepositoryProvider), + ref.watch(etagRepositoryProvider), ), ); class SyncService { - final Isar _db; final HashService _hashService; final EntityService _entityService; final IAlbumMediaRepository _albumMediaRepository; final IAlbumApiRepository _albumApiRepository; + final IAlbumRepository _albumRepository; + final IAssetRepository _assetRepository; + final IExifInfoRepository _exifInfoRepository; + final IUserRepository _userRepository; + final IETagRepository _eTagRepository; final AsyncMutex _lock = AsyncMutex(); final Logger _log = Logger('SyncService'); SyncService( - this._db, this._hashService, this._entityService, this._albumMediaRepository, this._albumApiRepository, + this._albumRepository, + this._assetRepository, + this._exifInfoRepository, + this._userRepository, + this._eTagRepository, ); // public methods: @@ -119,7 +137,7 @@ class SyncService { /// Returns `true`if there were any changes Future _syncUsersFromServer(List users) async { users.sortBy((u) => u.id); - final dbUsers = await _db.users.where().sortById().findAll(); + final dbUsers = await _userRepository.getAll(); assert(dbUsers.isSortedBy((u) => u.id), "dbUsers not sorted!"); final List toDelete = []; final List toUpsert = []; @@ -141,10 +159,8 @@ class SyncService { onlySecond: (User b) => toDelete.add(b.isarId), ); if (changes) { - await _db.writeTxn(() async { - await _db.users.deleteAll(toDelete); - await _db.users.putAll(toUpsert); - }); + await _userRepository.deleteById(toDelete); + await _userRepository.upsertAll(toUpsert); } return changes; } @@ -152,15 +168,15 @@ class SyncService { /// Syncs a new asset to the db. Returns `true` if successful Future _syncNewAssetToDb(Asset a) async { final Asset? inDb = - await _db.assets.getByOwnerIdChecksum(a.ownerId, a.checksum); + await _assetRepository.getByOwnerIdChecksum(a.ownerId, a.checksum); if (inDb != null) { // unify local/remote assets by replacing the // local-only asset in the DB with a local&remote asset a = inDb.updatedCopy(a); } try { - await _db.writeTxn(() => a.put(_db)); - } on IsarError catch (e) { + await _assetRepository.update(a); + } catch (e) { _log.severe("Failed to put new asset into db", e); return false; } @@ -175,9 +191,9 @@ class SyncService { DateTime since, ) getChangedAssets, ) async { - final currentUser = Store.get(StoreKey.currentUser); + final currentUser = await _userRepository.me(); final DateTime? since = - _db.eTags.getSync(currentUser.isarId)?.time?.toUtc(); + (await _eTagRepository.get(currentUser.isarId))?.time?.toUtc(); if (since == null) return null; final DateTime now = DateTime.now(); final (toUpsert, toDelete) = await getChangedAssets(users, since); @@ -198,32 +214,28 @@ class SyncService { return true; } return false; - } on IsarError catch (e) { + } catch (e) { _log.severe("Failed to sync remote assets to db", e); } return null; } /// Deletes remote-only assets, updates merged assets to be local-only - Future handleRemoteAssetRemoval(List idsToDelete) { - return _db.writeTxn(() async { - final idsToRemove = await _db.assets - .remote(idsToDelete) - .filter() - .localIdIsNull() - .idProperty() - .findAll(); - await _db.assets.deleteAll(idsToRemove); - await _db.exifInfos.deleteAll(idsToRemove); - final onlyLocal = await _db.assets.remote(idsToDelete).findAll(); - if (onlyLocal.isNotEmpty) { - for (final Asset a in onlyLocal) { - a.remoteId = null; - a.isTrashed = false; - } - await _db.assets.putAll(onlyLocal); - } - }); + Future handleRemoteAssetRemoval(List idsToDelete) async { + await _assetRepository.deleteAllByRemoteId( + idsToDelete, + state: AssetState.remote, + ); + final merged = await _assetRepository.getAllByRemoteId( + idsToDelete, + state: AssetState.merged, + ); + if (merged.isEmpty) return; + for (final Asset asset in merged) { + asset.remoteId = null; + asset.isTrashed = false; + } + await _assetRepository.updateAll(merged); } /// Syncs assets by loading and comparing all assets from the server. @@ -237,12 +249,7 @@ class SyncService { return false; } await _syncUsersFromServer(serverUsers); - final List users = await _db.users - .filter() - .isPartnerSharedWithEqualTo(true) - .or() - .isarIdEqualTo(Store.get(StoreKey.currentUser).isarId) - .findAll(); + final List users = await _userRepository.getAllAccessible(); bool changes = false; for (User u in users) { changes |= await _syncRemoteAssetsForUser(u, loadAssets); @@ -259,11 +266,10 @@ class SyncService { if (remote == null) { return false; } - final List inDb = await _db.assets - .where() - .ownerIdEqualToAnyChecksum(user.isarId) - .sortByChecksum() - .findAll(); + final List inDb = await _assetRepository.getAll( + ownerId: user.isarId, + sortBy: AssetSort.checksum, + ); assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!"); remote.sort(Asset.compareByChecksum); @@ -278,9 +284,9 @@ class SyncService { } final idsToDelete = toRemove.map((e) => e.id).toList(); try { - await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete)); + await _assetRepository.deleteById(idsToDelete); await upsertAssetsWithExif(toAdd + toUpdate); - } on IsarError catch (e) { + } catch (e) { _log.severe("Failed to sync remote assets to db", e); } await _updateUserAssetsETag([user], now); @@ -289,12 +295,12 @@ class SyncService { Future _updateUserAssetsETag(List users, DateTime time) { final etags = users.map((u) => ETag(id: u.id, time: time)).toList(); - return _db.writeTxn(() => _db.eTags.putAll(etags)); + return _eTagRepository.upsertAll(etags); } Future _clearUserAssetsETag(List users) { final ids = users.map((u) => u.id).toList(); - return _db.writeTxn(() => _db.eTags.deleteAllById(ids)); + return _eTagRepository.deleteByIds(ids); } /// Syncs remote albums to the database @@ -305,15 +311,13 @@ class SyncService { ) async { remoteAlbums.sortBy((e) => e.remoteId!); - final baseQuery = _db.albums.where().remoteIdIsNotNull().filter(); - final QueryBuilder query; - if (isShared) { - query = baseQuery.sharedEqualTo(true); - } else { - final User me = Store.get(StoreKey.currentUser); - query = baseQuery.owner((q) => q.isarIdEqualTo(me.isarId)); - } - final List dbAlbums = await query.sortByRemoteId().findAll(); + final User me = await _userRepository.me(); + final List dbAlbums = await _albumRepository.getAll( + remote: true, + shared: isShared ? true : null, + ownerId: isShared ? null : me.isarId, + sortBy: AlbumSort.remoteId, + ); assert(dbAlbums.isSortedBy((e) => e.remoteId!), "dbAlbums not sorted!"); final List toDelete = []; @@ -333,10 +337,7 @@ class SyncService { if (isShared && toDelete.isNotEmpty) { final List idsToRemove = sharedAssetsToRemove(toDelete, existing); if (idsToRemove.isNotEmpty) { - await _db.writeTxn(() async { - await _db.assets.deleteAll(idsToRemove); - await _db.exifInfos.deleteAll(idsToRemove); - }); + await _assetRepository.deleteById(idsToRemove); } } else { assert(toDelete.isEmpty); @@ -360,8 +361,11 @@ class SyncService { // i.e. it will always be null. Save it here. final originalDto = dto; dto = await _albumApiRepository.get(dto.remoteId!); - final assetsInDb = - await album.assets.filter().sortByOwnerId().thenByChecksum().findAll(); + + final assetsInDb = await _assetRepository.getByAlbum( + album, + sortBy: AssetSort.ownerIdChecksum, + ); assert(assetsInDb.isSorted(Asset.compareByOwnerChecksum), "inDb unsorted!"); final List assetsOnRemote = dto.remoteAssets.toList(); assetsOnRemote.sort(Asset.compareByOwnerChecksum); @@ -391,7 +395,7 @@ class SyncService { final (existingInDb, updated) = await _linkWithExistingFromDb(toAdd); await upsertAssetsWithExif(updated); final assetsToLink = existingInDb + updated; - final usersToLink = (await _db.users.getAllById(userIdsToAdd)).cast(); + final usersToLink = await _userRepository.getByIds(userIdsToAdd); album.name = dto.name; album.shared = dto.shared; @@ -402,32 +406,31 @@ class SyncService { album.lastModifiedAssetTimestamp = originalDto.lastModifiedAssetTimestamp; album.shared = dto.shared; album.activityEnabled = dto.activityEnabled; - if (album.thumbnail.value?.remoteId != dto.remoteThumbnailAssetId) { - album.thumbnail.value = await _db.assets - .where() - .remoteIdEqualTo(dto.remoteThumbnailAssetId) - .findFirst(); + final remoteThumbnailAssetId = dto.remoteThumbnailAssetId; + if (remoteThumbnailAssetId != null && + album.thumbnail.value?.remoteId != remoteThumbnailAssetId) { + album.thumbnail.value = + await _assetRepository.getByRemoteId(remoteThumbnailAssetId); } // write & commit all changes to DB try { - await _db.writeTxn(() async { - await _db.assets.putAll(toUpdate); - await album.thumbnail.save(); - await album.sharedUsers - .update(link: usersToLink, unlink: usersToUnlink); - await album.assets.update(link: assetsToLink, unlink: toUnlink.cast()); - await _db.albums.put(album); - }); + await _assetRepository.updateAll(toUpdate); + await _albumRepository.addUsers(album, usersToLink); + await _albumRepository.removeUsers(album, usersToUnlink); + await _albumRepository.addAssets(album, assetsToLink); + await _albumRepository.removeAssets(album, toUnlink); + await _albumRepository.recalculateMetadata(album); + await _albumRepository.update(album); _log.info("Synced changes of remote album ${album.name} to DB"); - } on IsarError catch (e) { + } catch (e) { _log.severe("Failed to sync remote album to database", e); } if (album.shared || dto.shared) { - final userId = Store.get(StoreKey.currentUser).isarId; + final userId = (await _userRepository.me()).isarId; final foreign = - await album.assets.filter().not().ownerIdEqualTo(userId).findAll(); + await _assetRepository.getByAlbum(album, notOwnedBy: [userId]); existing.addAll(foreign); // delete assets in DB unless they belong to this user or part of some other shared album @@ -456,7 +459,7 @@ class SyncService { await upsertAssetsWithExif(updated); await _entityService.fillAlbumWithDatabaseEntities(album); - await _db.writeTxn(() => _db.albums.store(album)); + await _albumRepository.create(album); } else { _log.warning( "Failed to add album from server: assetCount ${album.remoteAssetCount} != " @@ -474,27 +477,18 @@ class SyncService { _log.info("Removing local album $album from DB"); // delete assets in DB unless they are remote or part of some other album deleteCandidates.addAll( - await album.assets.filter().remoteIdIsNull().findAll(), + await _assetRepository.getByAlbum(album, state: AssetState.local), ); } else if (album.shared) { - final User user = Store.get(StoreKey.currentUser); // delete assets in DB unless they belong to this user or are part of some other shared album or belong to a partner - final userIds = await _db.users - .filter() - .isPartnerSharedWithEqualTo(true) - .isarIdProperty() - .findAll(); - userIds.add(user.isarId); - final orphanedAssets = await album.assets - .filter() - .not() - .anyOf(userIds, (q, int id) => q.ownerIdEqualTo(id)) - .findAll(); + final userIds = + (await _userRepository.getAllAccessible()).map((user) => user.isarId); + final orphanedAssets = + await _assetRepository.getByAlbum(album, notOwnedBy: userIds); deleteCandidates.addAll(orphanedAssets); } try { - final bool ok = await _db.writeTxn(() => _db.albums.delete(album.id)); - assert(ok); + await _albumRepository.delete(album.id); _log.info("Removed local album $album from DB"); } catch (e) { _log.severe("Failed to remove local album $album from DB", e); @@ -509,7 +503,7 @@ class SyncService { ]) async { onDevice.sort((a, b) => a.id.compareTo(b.id)); final inDb = - await _db.albums.where().localIdIsNotNull().sortByLocalId().findAll(); + await _albumRepository.getAll(remote: false, sortBy: AlbumSort.localId); final List deleteCandidates = []; final List existing = []; assert(inDb.isSorted((a, b) => a.localId!.compareTo(b.localId!)), "sort!"); @@ -536,11 +530,8 @@ class SyncService { "${toDelete.length} assets to delete, ${toUpdate.length} to update", ); if (toDelete.isNotEmpty || toUpdate.isNotEmpty) { - await _db.writeTxn(() async { - await _db.assets.deleteAll(toDelete); - await _db.exifInfos.deleteAll(toDelete); - await _db.assets.putAll(toUpdate); - }); + await _assetRepository.deleteById(toDelete); + await _assetRepository.updateAll(toUpdate); _log.info( "Removed ${toDelete.length} and updated ${toUpdate.length} local assets from DB", ); @@ -570,13 +561,13 @@ class SyncService { await _syncDeviceAlbumFast(deviceAlbum, dbAlbum)) { return true; } - // general case, e.g. some assets have been deleted or there are excluded albums on iOS - final inDb = await dbAlbum.assets - .filter() - .ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId) - .sortByChecksum() - .findAll(); + final inDb = await _assetRepository.getByAlbum( + dbAlbum, + ownerId: (await _userRepository.me()).isarId, + sortBy: AssetSort.checksum, + ); + assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!"); final int assetCountOnDevice = await _albumMediaRepository.getAssetCount(deviceAlbum.localId!); @@ -597,15 +588,14 @@ class SyncService { "Only excluded assets in local album ${deviceAlbum.name} changed. Stopping sync.", ); if (assetCountOnDevice != - _db.eTags.getByIdSync(deviceAlbum.eTagKeyAssetCount)?.assetCount) { - await _db.writeTxn( - () => _db.eTags.put( - ETag( - id: deviceAlbum.eTagKeyAssetCount, - assetCount: assetCountOnDevice, - ), + (await _eTagRepository.getById(deviceAlbum.eTagKeyAssetCount)) + ?.assetCount) { + await _eTagRepository.upsertAll([ + ETag( + id: deviceAlbum.eTagKeyAssetCount, + assetCount: assetCountOnDevice, ), - ); + ]); } return false; } @@ -625,23 +615,16 @@ class SyncService { dbAlbum.thumbnail.value = null; } try { - await _db.writeTxn(() async { - await _db.assets.putAll(updated); - await _db.assets.putAll(toUpdate); - await dbAlbum.assets - .update(link: existingInDb + updated, unlink: toDelete); - await _db.albums.put(dbAlbum); - dbAlbum.thumbnail.value ??= await dbAlbum.assets.filter().findFirst(); - await dbAlbum.thumbnail.save(); - await _db.eTags.put( - ETag( - id: deviceAlbum.eTagKeyAssetCount, - assetCount: assetCountOnDevice, - ), - ); - }); + await _assetRepository.updateAll(updated + toUpdate); + await _albumRepository.addAssets(dbAlbum, existingInDb + updated); + await _albumRepository.removeAssets(dbAlbum, toDelete); + await _albumRepository.recalculateMetadata(dbAlbum); + await _albumRepository.update(dbAlbum); + await _eTagRepository.upsertAll([ + ETag(id: deviceAlbum.eTagKeyAssetCount, assetCount: assetCountOnDevice), + ]); _log.info("Synced changes of local album ${deviceAlbum.name} to DB"); - } on IsarError catch (e) { + } catch (e) { _log.severe("Failed to update synced album ${deviceAlbum.name} in DB", e); } @@ -657,7 +640,8 @@ class SyncService { final int totalOnDevice = await _albumMediaRepository.getAssetCount(deviceAlbum.localId!); final int lastKnownTotal = - (await _db.eTags.getById(deviceAlbum.eTagKeyAssetCount))?.assetCount ?? + (await _eTagRepository.getById(deviceAlbum.eTagKeyAssetCount)) + ?.assetCount ?? 0; if (totalOnDevice <= lastKnownTotal) { return false; @@ -675,16 +659,15 @@ class SyncService { _removeDuplicates(newAssets); final (existingInDb, updated) = await _linkWithExistingFromDb(newAssets); try { - await _db.writeTxn(() async { - await _db.assets.putAll(updated); - await dbAlbum.assets.update(link: existingInDb + updated); - await _db.albums.put(dbAlbum); - await _db.eTags.put( - ETag(id: deviceAlbum.eTagKeyAssetCount, assetCount: totalOnDevice), - ); - }); + await _assetRepository.updateAll(updated); + await _albumRepository.addAssets(dbAlbum, existingInDb + updated); + await _albumRepository.recalculateMetadata(dbAlbum); + await _albumRepository.update(dbAlbum); + await _eTagRepository.upsertAll( + [ETag(id: deviceAlbum.eTagKeyAssetCount, assetCount: totalOnDevice)], + ); _log.info("Fast synced local album ${deviceAlbum.name} to DB"); - } on IsarError catch (e) { + } catch (e) { _log.severe( "Failed to fast sync local album ${deviceAlbum.name} to DB", e, @@ -719,9 +702,9 @@ class SyncService { final thumb = existingInDb.firstOrNull ?? updated.firstOrNull; album.thumbnail.value = thumb; try { - await _db.writeTxn(() => _db.albums.store(album)); + await _albumRepository.create(album); _log.info("Added a new local album to DB: ${album.name}"); - } on IsarError catch (e) { + } catch (e) { _log.severe("Failed to add new local album ${album.name} to DB", e); } } @@ -732,7 +715,7 @@ class SyncService { ) async { if (assets.isEmpty) return ([].cast(), [].cast()); - final List inDb = await _db.assets.getAllByOwnerIdChecksum( + final List inDb = await _assetRepository.getAllByOwnerIdChecksum( assets.map((a) => a.ownerId).toInt64List(), assets.map((a) => a.checksum).toList(growable: false), ); @@ -746,7 +729,7 @@ class SyncService { } if (b.canUpdate(assets[i])) { final updated = b.updatedCopy(assets[i]); - assert(updated.id != Isar.autoIncrement); + assert(updated.isInDb); toUpsert.add(updated); } else { existing.add(b); @@ -758,24 +741,20 @@ class SyncService { /// Inserts or updates the assets in the database with their ExifInfo (if any) Future upsertAssetsWithExif(List assets) async { - if (assets.isEmpty) { - return; - } - final exifInfos = assets.map((e) => e.exifInfo).whereNotNull().toList(); + if (assets.isEmpty) return; + final exifInfos = assets.map((e) => e.exifInfo).nonNulls.toList(); try { - await _db.writeTxn(() async { - await _db.assets.putAll(assets); - for (final Asset added in assets) { - added.exifInfo?.id = added.id; - } - await _db.exifInfos.putAll(exifInfos); - }); + await _assetRepository.updateAll(assets); + for (final Asset added in assets) { + added.exifInfo?.id = added.id; + } + await _exifInfoRepository.updateAll(exifInfos); _log.info("Upserted ${assets.length} assets into the DB"); - } on IsarError catch (e) { + } catch (e) { _log.severe("Failed to upsert ${assets.length} assets into the DB", e); // give details on the errors assets.sort(Asset.compareByOwnerChecksum); - final inDb = await _db.assets.getAllByOwnerIdChecksum( + final inDb = await _assetRepository.getAllByOwnerIdChecksum( assets.map((e) => e.ownerId).toInt64List(), assets.map((e) => e.checksum).toList(growable: false), ); @@ -783,7 +762,7 @@ class SyncService { final Asset a = assets[i]; final Asset? b = inDb[i]; if (b == null) { - if (a.id != Isar.autoIncrement) { + if (!a.isInDb) { _log.warning( "Trying to update an asset that does not exist in DB:\n$a", ); @@ -827,20 +806,18 @@ class SyncService { return deviceAlbum.name != dbAlbum.name || !deviceAlbum.modifiedAt.isAtSameMomentAs(dbAlbum.modifiedAt) || await _albumMediaRepository.getAssetCount(deviceAlbum.localId!) != - (await _db.eTags.getById(deviceAlbum.eTagKeyAssetCount)) + (await _eTagRepository.getById(deviceAlbum.eTagKeyAssetCount)) ?.assetCount; } Future _removeAllLocalAlbumsAndAssets() async { try { - final assets = await _db.assets.where().localIdIsNotNull().findAll(); + final assets = await _assetRepository.getAllLocal(); final (toDelete, toUpdate) = _handleAssetRemoval(assets, [], remote: false); - await _db.writeTxn(() async { - await _db.assets.deleteAll(toDelete); - await _db.assets.putAll(toUpdate); - await _db.albums.where().localIdIsNotNull().deleteAll(); - }); + await _assetRepository.deleteById(toDelete); + await _assetRepository.updateAll(toUpdate); + await _albumRepository.deleteAllLocal(); return true; } catch (e) { _log.severe("Failed to remove all local albums and assets", e); diff --git a/mobile/test/modules/shared/sync_service_test.dart b/mobile/test/modules/shared/sync_service_test.dart index 8520d89b435e5..076c5718481c6 100644 --- a/mobile/test/modules/shared/sync_service_test.dart +++ b/mobile/test/modules/shared/sync_service_test.dart @@ -1,17 +1,20 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/services/immich_logger.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; -import 'package:isar/isar.dart'; +import 'package:mocktail/mocktail.dart'; import '../../repository.mocks.dart'; import '../../service.mocks.dart'; import '../../test_utils.dart'; void main() { + int assetIdCounter = 0; Asset makeAsset({ required String checksum, String? localId, @@ -20,6 +23,7 @@ void main() { }) { final DateTime date = DateTime(2000); return Asset( + id: assetIdCounter++, checksum: checksum, localId: localId, remoteId: remoteId, @@ -37,9 +41,13 @@ void main() { } group('Test SyncService grouped', () { - late final Isar db; final MockHashService hs = MockHashService(); final MockEntityService entityService = MockEntityService(); + final MockAlbumRepository albumRepository = MockAlbumRepository(); + final MockAssetRepository assetRepository = MockAssetRepository(); + final MockExifInfoRepository exifInfoRepository = MockExifInfoRepository(); + final MockUserRepository userRepository = MockUserRepository(); + final MockETagRepository eTagRepository = MockETagRepository(); final MockAlbumMediaRepository albumMediaRepository = MockAlbumMediaRepository(); final MockAlbumApiRepository albumApiRepository = MockAlbumApiRepository(); @@ -53,7 +61,7 @@ void main() { late SyncService s; setUpAll(() async { WidgetsFlutterBinding.ensureInitialized(); - db = await TestUtils.initIsar(); + final db = await TestUtils.initIsar(); ImmichLogger(); db.writeTxnSync(() => db.clearSync()); Store.init(db); @@ -67,17 +75,37 @@ void main() { makeAsset(checksum: "e", localId: "3"), ]; setUp(() { - db.writeTxnSync(() { - db.assets.clearSync(); - db.assets.putAllSync(initialAssets); - }); s = SyncService( - db, hs, entityService, albumMediaRepository, albumApiRepository, + albumRepository, + assetRepository, + exifInfoRepository, + userRepository, + eTagRepository, ); + when(() => eTagRepository.get(owner.isarId)) + .thenAnswer((_) async => ETag(id: owner.id, time: DateTime.now())); + when(() => eTagRepository.deleteByIds(["1"])).thenAnswer((_) async {}); + when(() => eTagRepository.upsertAll(any())).thenAnswer((_) async {}); + when(() => userRepository.me()).thenAnswer((_) async => owner); + when(() => userRepository.getAll()).thenAnswer((_) async => [owner]); + when(() => userRepository.getAllAccessible()) + .thenAnswer((_) async => [owner]); + when( + () => assetRepository.getAll( + ownerId: owner.isarId, + sortBy: AssetSort.checksum, + ), + ).thenAnswer((_) async => initialAssets); + when(() => assetRepository.getAllByOwnerIdChecksum(any(), any())) + .thenAnswer((_) async => [initialAssets[3], null, null]); + when(() => assetRepository.updateAll(any())).thenAnswer((_) async => []); + when(() => assetRepository.deleteById(any())).thenAnswer((_) async {}); + when(() => exifInfoRepository.updateAll(any())) + .thenAnswer((_) async => []); }); test('test inserting existing assets', () async { final List remoteAssets = [ @@ -85,7 +113,6 @@ void main() { makeAsset(checksum: "b", remoteId: "2-1"), makeAsset(checksum: "c", remoteId: "1-1"), ]; - expect(db.assets.countSync(), 5); final bool c1 = await s.syncRemoteAssetsToDb( users: [owner], getChangedAssets: _failDiff, @@ -93,7 +120,7 @@ void main() { refreshUsers: () => [owner], ); expect(c1, isFalse); - expect(db.assets.countSync(), 5); + verifyNever(() => assetRepository.updateAll(any())); }); test('test inserting new assets', () async { @@ -105,7 +132,6 @@ void main() { makeAsset(checksum: "f", remoteId: "1-4"), makeAsset(checksum: "g", remoteId: "3-1"), ]; - expect(db.assets.countSync(), 5); final bool c1 = await s.syncRemoteAssetsToDb( users: [owner], getChangedAssets: _failDiff, @@ -113,7 +139,11 @@ void main() { refreshUsers: () => [owner], ); expect(c1, isTrue); - expect(db.assets.countSync(), 7); + final updatedAsset = initialAssets[3].updatedCopy(remoteAssets[3]); + verify( + () => assetRepository + .updateAll([remoteAssets[4], remoteAssets[5], updatedAsset]), + ); }); test('test syncing duplicate assets', () async { @@ -125,7 +155,6 @@ void main() { makeAsset(checksum: "i", remoteId: "2-1c"), makeAsset(checksum: "j", remoteId: "2-1d"), ]; - expect(db.assets.countSync(), 5); final bool c1 = await s.syncRemoteAssetsToDb( users: [owner], getChangedAssets: _failDiff, @@ -133,7 +162,12 @@ void main() { refreshUsers: () => [owner], ); expect(c1, isTrue); - expect(db.assets.countSync(), 8); + when( + () => assetRepository.getAll( + ownerId: owner.isarId, + sortBy: AssetSort.checksum, + ), + ).thenAnswer((_) async => remoteAssets); final bool c2 = await s.syncRemoteAssetsToDb( users: [owner], getChangedAssets: _failDiff, @@ -141,7 +175,13 @@ void main() { refreshUsers: () => [owner], ); expect(c2, isFalse); - expect(db.assets.countSync(), 8); + final currentState = [...remoteAssets]; + when( + () => assetRepository.getAll( + ownerId: owner.isarId, + sortBy: AssetSort.checksum, + ), + ).thenAnswer((_) async => currentState); remoteAssets.removeAt(4); final bool c3 = await s.syncRemoteAssetsToDb( users: [owner], @@ -150,7 +190,6 @@ void main() { refreshUsers: () => [owner], ); expect(c3, isTrue); - expect(db.assets.countSync(), 7); remoteAssets.add(makeAsset(checksum: "k", remoteId: "2-1e")); remoteAssets.add(makeAsset(checksum: "l", remoteId: "2-2")); final bool c4 = await s.syncRemoteAssetsToDb( @@ -160,10 +199,21 @@ void main() { refreshUsers: () => [owner], ); expect(c4, isTrue); - expect(db.assets.countSync(), 9); }); test('test efficient sync', () async { + when( + () => assetRepository.deleteAllByRemoteId( + [initialAssets[1].remoteId!, initialAssets[2].remoteId!], + state: AssetState.remote, + ), + ).thenAnswer((_) async {}); + when( + () => assetRepository + .getAllByRemoteId(["2-1", "1-1"], state: AssetState.merged), + ).thenAnswer((_) async => [initialAssets[2]]); + when(() => assetRepository.getAllByOwnerIdChecksum(any(), any())) + .thenAnswer((_) async => [initialAssets[0], null, null]); //afg final List toUpsert = [ makeAsset(checksum: "a", remoteId: "0-1"), // changed makeAsset(checksum: "f", remoteId: "0-2"), // new @@ -171,6 +221,8 @@ void main() { ]; toUpsert[0].isFavorite = true; final List toDelete = ["2-1", "1-1"]; + final expected = [...toUpsert]; + expected[0].id = initialAssets[0].id; final bool c = await s.syncRemoteAssetsToDb( users: [owner], getChangedAssets: (user, since) async => (toUpsert, toDelete), @@ -178,7 +230,7 @@ void main() { refreshUsers: () => throw Exception(), ); expect(c, isTrue); - expect(db.assets.countSync(), 6); + verify(() => assetRepository.updateAll(expected)); }); }); } diff --git a/mobile/test/repository.mocks.dart b/mobile/test/repository.mocks.dart index 6e220e85a2dc2..c76a003eec2a0 100644 --- a/mobile/test/repository.mocks.dart +++ b/mobile/test/repository.mocks.dart @@ -4,6 +4,8 @@ import 'package:immich_mobile/interfaces/album_media.interface.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset_media.interface.dart'; import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/interfaces/etag.interface.dart'; +import 'package:immich_mobile/interfaces/exif_info.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart'; import 'package:immich_mobile/interfaces/user.interface.dart'; import 'package:mocktail/mocktail.dart'; @@ -16,6 +18,10 @@ class MockUserRepository extends Mock implements IUserRepository {} class MockBackupRepository extends Mock implements IBackupRepository {} +class MockExifInfoRepository extends Mock implements IExifInfoRepository {} + +class MockETagRepository extends Mock implements IETagRepository {} + class MockAlbumMediaRepository extends Mock implements IAlbumMediaRepository {} class MockAssetMediaRepository extends Mock implements IAssetMediaRepository {} diff --git a/mobile/test/services/album.service_test.dart b/mobile/test/services/album.service_test.dart index b2c2ec4427ab4..56bb2b40205a8 100644 --- a/mobile/test/services/album.service_test.dart +++ b/mobile/test/services/album.service_test.dart @@ -144,7 +144,7 @@ void main() { ), ); when( - () => albumRepository.getById(AlbumStub.oneAsset.id), + () => albumRepository.get(AlbumStub.oneAsset.id), ).thenAnswer((_) async => AlbumStub.oneAsset); when( () => albumRepository.addAssets(AlbumStub.oneAsset, [AssetStub.image2]), From 87622b60fba12547030bdc4eafe117d2cb3145c6 Mon Sep 17 00:00:00 2001 From: Fynn Petersen-Frey Date: Fri, 27 Sep 2024 13:51:13 +0200 Subject: [PATCH 2/3] review feedback --- mobile/analysis_options.yaml | 2 +- mobile/lib/interfaces/album.interface.dart | 3 +- mobile/lib/interfaces/asset.interface.dart | 3 +- mobile/lib/interfaces/backup.interface.dart | 3 +- mobile/lib/interfaces/database.interface.dart | 3 + mobile/lib/interfaces/etag.interface.dart | 3 +- .../lib/interfaces/exif_info.interface.dart | 3 +- mobile/lib/interfaces/user.interface.dart | 3 +- .../repositories/activity_api.repository.dart | 4 +- mobile/lib/repositories/album.repository.dart | 21 ++-- .../repositories/album_api.repository.dart | 5 +- ...se.repository.dart => api.repository.dart} | 4 +- mobile/lib/repositories/asset.repository.dart | 16 ++- .../repositories/asset_api.repository.dart | 5 +- .../lib/repositories/backup.repository.dart | 7 +- .../lib/repositories/database.repository.dart | 18 ++- mobile/lib/repositories/etag.repository.dart | 7 +- .../repositories/exif_info.repository.dart | 8 +- .../repositories/partner_api.repository.dart | 4 +- .../repositories/person_api.repository.dart | 4 +- mobile/lib/repositories/user.repository.dart | 9 +- .../lib/repositories/user_api.repository.dart | 5 +- mobile/lib/services/album.service.dart | 17 +-- mobile/lib/services/asset.service.dart | 2 +- mobile/lib/services/backup.service.dart | 7 +- mobile/lib/services/hash.service.dart | 4 +- mobile/lib/services/stack.service.dart | 3 +- mobile/lib/services/sync.service.dart | 117 ++++++++++-------- .../modules/shared/sync_service_test.dart | 6 + mobile/test/services/album.service_test.dart | 7 ++ 30 files changed, 176 insertions(+), 127 deletions(-) create mode 100644 mobile/lib/interfaces/database.interface.dart rename mobile/lib/repositories/{apibase.repository.dart => api.repository.dart} (71%) diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 82f849b951cde..80514f1603b0d 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -64,7 +64,7 @@ custom_lint: allowed: # required / wanted - lib/entities/*.entity.dart - - lib/repositories/{album,asset,backup,etag,exif_info,user}.repository.dart + - lib/repositories/{album,asset,backup,database,etag,exif_info,user}.repository.dart # acceptable exceptions for the time being (until Isar is fully replaced) - integration_test/test_utils/general_helper.dart - lib/main.dart diff --git a/mobile/lib/interfaces/album.interface.dart b/mobile/lib/interfaces/album.interface.dart index 5d4c9f593848d..ba188f127009a 100644 --- a/mobile/lib/interfaces/album.interface.dart +++ b/mobile/lib/interfaces/album.interface.dart @@ -1,8 +1,9 @@ import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/database.interface.dart'; -abstract interface class IAlbumRepository { +abstract interface class IAlbumRepository implements IDatabaseRepository { Future create(Album album); Future get(int id); diff --git a/mobile/lib/interfaces/asset.interface.dart b/mobile/lib/interfaces/asset.interface.dart index 0d09f717ce97a..5aec594eb1148 100644 --- a/mobile/lib/interfaces/asset.interface.dart +++ b/mobile/lib/interfaces/asset.interface.dart @@ -1,8 +1,9 @@ import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/device_asset.entity.dart'; +import 'package:immich_mobile/interfaces/database.interface.dart'; -abstract interface class IAssetRepository { +abstract interface class IAssetRepository implements IDatabaseRepository { Future getByRemoteId(String id); Future getByOwnerIdChecksum(int ownerId, String checksum); diff --git a/mobile/lib/interfaces/backup.interface.dart b/mobile/lib/interfaces/backup.interface.dart index e8f5a73c90239..c32199a58f476 100644 --- a/mobile/lib/interfaces/backup.interface.dart +++ b/mobile/lib/interfaces/backup.interface.dart @@ -1,6 +1,7 @@ import 'package:immich_mobile/entities/backup_album.entity.dart'; +import 'package:immich_mobile/interfaces/database.interface.dart'; -abstract interface class IBackupRepository { +abstract interface class IBackupRepository implements IDatabaseRepository { Future> getAll({BackupAlbumSort? sort}); Future> getIdsBySelection(BackupSelection backup); diff --git a/mobile/lib/interfaces/database.interface.dart b/mobile/lib/interfaces/database.interface.dart new file mode 100644 index 0000000000000..5645d15c47beb --- /dev/null +++ b/mobile/lib/interfaces/database.interface.dart @@ -0,0 +1,3 @@ +abstract interface class IDatabaseRepository { + Future transaction(Future Function() callback); +} diff --git a/mobile/lib/interfaces/etag.interface.dart b/mobile/lib/interfaces/etag.interface.dart index ea3b1059d1b16..e567235d1bb2d 100644 --- a/mobile/lib/interfaces/etag.interface.dart +++ b/mobile/lib/interfaces/etag.interface.dart @@ -1,6 +1,7 @@ import 'package:immich_mobile/entities/etag.entity.dart'; +import 'package:immich_mobile/interfaces/database.interface.dart'; -abstract interface class IETagRepository { +abstract interface class IETagRepository implements IDatabaseRepository { Future get(int id); Future getById(String id); diff --git a/mobile/lib/interfaces/exif_info.interface.dart b/mobile/lib/interfaces/exif_info.interface.dart index b908511658e17..86608c26d0cf8 100644 --- a/mobile/lib/interfaces/exif_info.interface.dart +++ b/mobile/lib/interfaces/exif_info.interface.dart @@ -1,6 +1,7 @@ import 'package:immich_mobile/entities/exif_info.entity.dart'; +import 'package:immich_mobile/interfaces/database.interface.dart'; -abstract interface class IExifInfoRepository { +abstract interface class IExifInfoRepository implements IDatabaseRepository { Future get(int id); Future update(ExifInfo exifInfo); diff --git a/mobile/lib/interfaces/user.interface.dart b/mobile/lib/interfaces/user.interface.dart index 6a2a7cf356e7b..cfe26b42545a3 100644 --- a/mobile/lib/interfaces/user.interface.dart +++ b/mobile/lib/interfaces/user.interface.dart @@ -1,6 +1,7 @@ import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/database.interface.dart'; -abstract interface class IUserRepository { +abstract interface class IUserRepository implements IDatabaseRepository { Future get(String id); Future> getByIds(List ids); diff --git a/mobile/lib/repositories/activity_api.repository.dart b/mobile/lib/repositories/activity_api.repository.dart index 6930f4e685191..8da3759709327 100644 --- a/mobile/lib/repositories/activity_api.repository.dart +++ b/mobile/lib/repositories/activity_api.repository.dart @@ -3,14 +3,14 @@ import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/activity_api.interface.dart'; import 'package:immich_mobile/models/activities/activity.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/repositories/apibase.repository.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; import 'package:openapi/api.dart'; final activityApiRepositoryProvider = Provider( (ref) => ActivityApiRepository(ref.watch(apiServiceProvider).activitiesApi), ); -class ActivityApiRepository extends ApiBaseRepository +class ActivityApiRepository extends ApiRepository implements IActivityApiRepository { final ActivitiesApi _api; diff --git a/mobile/lib/repositories/album.repository.dart b/mobile/lib/repositories/album.repository.dart index 105bcef56e558..35f5cae32722c 100644 --- a/mobile/lib/repositories/album.repository.dart +++ b/mobile/lib/repositories/album.repository.dart @@ -10,7 +10,7 @@ import 'package:isar/isar.dart'; final albumRepositoryProvider = Provider((ref) => AlbumRepository(ref.watch(dbProvider))); -class AlbumRepository extends DataBaseRepository implements IAlbumRepository { +class AlbumRepository extends DatabaseRepository implements IAlbumRepository { AlbumRepository(super.db); @override @@ -29,8 +29,7 @@ class AlbumRepository extends DataBaseRepository implements IAlbumRepository { } @override - Future create(Album album) => - db.writeTxn(() => db.albums.store(album)); + Future create(Album album) => txn(() => db.albums.store(album)); @override Future getByName(String name, {bool? shared, bool? remote}) { @@ -47,12 +46,10 @@ class AlbumRepository extends DataBaseRepository implements IAlbumRepository { } @override - Future update(Album album) => - db.writeTxn(() => db.albums.store(album)); + Future update(Album album) => txn(() => db.albums.store(album)); @override - Future delete(int albumId) => - db.writeTxn(() => db.albums.delete(albumId)); + Future delete(int albumId) => txn(() => db.albums.delete(albumId)); @override Future> getAll({ @@ -95,15 +92,15 @@ class AlbumRepository extends DataBaseRepository implements IAlbumRepository { @override Future removeUsers(Album album, List users) => - db.writeTxn(() => album.sharedUsers.update(unlink: users)); + txn(() => album.sharedUsers.update(unlink: users)); @override Future addAssets(Album album, List assets) => - db.writeTxn(() => album.assets.update(link: assets)); + txn(() => album.assets.update(link: assets)); @override Future removeAssets(Album album, List assets) => - db.writeTxn(() => album.assets.update(unlink: assets)); + txn(() => album.assets.update(unlink: assets)); @override Future recalculateMetadata(Album album) async { @@ -116,9 +113,9 @@ class AlbumRepository extends DataBaseRepository implements IAlbumRepository { @override Future addUsers(Album album, List users) => - db.writeTxn(() => album.sharedUsers.update(link: users)); + txn(() => album.sharedUsers.update(link: users)); @override Future deleteAllLocal() => - db.albums.where().localIdIsNotNull().deleteAll(); + txn(() => db.albums.where().localIdIsNotNull().deleteAll()); } diff --git a/mobile/lib/repositories/album_api.repository.dart b/mobile/lib/repositories/album_api.repository.dart index bab5000078a2e..5d0b56dc7882a 100644 --- a/mobile/lib/repositories/album_api.repository.dart +++ b/mobile/lib/repositories/album_api.repository.dart @@ -4,15 +4,14 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/album_api.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/repositories/apibase.repository.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; import 'package:openapi/api.dart'; final albumApiRepositoryProvider = Provider( (ref) => AlbumApiRepository(ref.watch(apiServiceProvider).albumsApi), ); -class AlbumApiRepository extends ApiBaseRepository - implements IAlbumApiRepository { +class AlbumApiRepository extends ApiRepository implements IAlbumApiRepository { final AlbumsApi _api; AlbumApiRepository(this._api); diff --git a/mobile/lib/repositories/apibase.repository.dart b/mobile/lib/repositories/api.repository.dart similarity index 71% rename from mobile/lib/repositories/apibase.repository.dart rename to mobile/lib/repositories/api.repository.dart index 17a110d619aad..b454c77f9b7da 100644 --- a/mobile/lib/repositories/apibase.repository.dart +++ b/mobile/lib/repositories/api.repository.dart @@ -1,8 +1,6 @@ -import 'package:flutter/foundation.dart'; import 'package:immich_mobile/constants/errors.dart'; -abstract class ApiBaseRepository { - @protected +abstract class ApiRepository { Future checkNull(Future future) async { final response = await future; if (response == null) throw NoResponseDtoError(); diff --git a/mobile/lib/repositories/asset.repository.dart b/mobile/lib/repositories/asset.repository.dart index 1069eb98f29b6..eaaafd3045a39 100644 --- a/mobile/lib/repositories/asset.repository.dart +++ b/mobile/lib/repositories/asset.repository.dart @@ -16,7 +16,7 @@ import 'package:isar/isar.dart'; final assetRepositoryProvider = Provider((ref) => AssetRepository(ref.watch(dbProvider))); -class AssetRepository extends DataBaseRepository implements IAssetRepository { +class AssetRepository extends DatabaseRepository implements IAssetRepository { AssetRepository(super.db); @override @@ -64,7 +64,7 @@ class AssetRepository extends DataBaseRepository implements IAssetRepository { } @override - Future deleteById(List ids) => db.writeTxn(() async { + Future deleteById(List ids) => txn(() async { await db.assets.deleteAll(ids); await db.exifInfos.deleteAll(ids); }); @@ -143,7 +143,7 @@ class AssetRepository extends DataBaseRepository implements IAssetRepository { @override Future> updateAll(List assets) async { - await db.writeTxn(() => db.assets.putAll(assets)); + await txn(() => db.assets.putAll(assets)); return assets; } @@ -176,8 +176,7 @@ class AssetRepository extends DataBaseRepository implements IAssetRepository { : db.iOSDeviceAssets.getAllById(ids.cast()); @override - Future upsertDeviceAssets(List deviceAssets) => - db.writeTxn( + Future upsertDeviceAssets(List deviceAssets) => txn( () => Platform.isAndroid ? db.androidDeviceAssets.putAll(deviceAssets.cast()) : db.iOSDeviceAssets.putAll(deviceAssets.cast()), @@ -185,13 +184,12 @@ class AssetRepository extends DataBaseRepository implements IAssetRepository { @override Future update(Asset asset) async { - await asset.put(db); + await txn(() => asset.put(db)); return asset; } @override - Future upsertDuplicatedAssets(Iterable duplicatedAssets) => - db.writeTxn( + Future upsertDuplicatedAssets(Iterable duplicatedAssets) => txn( () => db.duplicatedAssets .putAll(duplicatedAssets.map(DuplicatedAsset.new).toList()), ); @@ -217,7 +215,7 @@ class AssetRepository extends DataBaseRepository implements IAssetRepository { @override Future deleteAllByRemoteId(List ids, {AssetState? state}) => - _getAllByRemoteIdImpl(ids, state).deleteAll(); + txn(() => _getAllByRemoteIdImpl(ids, state).deleteAll()); } Future> _getMatchesImpl( diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index a28abe1eaf440..54d57c4dfccf6 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -2,7 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/interfaces/asset_api.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/repositories/apibase.repository.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; import 'package:openapi/api.dart'; final assetApiRepositoryProvider = Provider( @@ -12,8 +12,7 @@ final assetApiRepositoryProvider = Provider( ), ); -class AssetApiRepository extends ApiBaseRepository - implements IAssetApiRepository { +class AssetApiRepository extends ApiRepository implements IAssetApiRepository { final AssetsApi _api; final SearchApi _searchApi; diff --git a/mobile/lib/repositories/backup.repository.dart b/mobile/lib/repositories/backup.repository.dart index 994c14fd19cd1..61997ff23ae22 100644 --- a/mobile/lib/repositories/backup.repository.dart +++ b/mobile/lib/repositories/backup.repository.dart @@ -8,7 +8,7 @@ import 'package:isar/isar.dart'; final backupRepositoryProvider = Provider((ref) => BackupRepository(ref.watch(dbProvider))); -class BackupRepository extends DataBaseRepository implements IBackupRepository { +class BackupRepository extends DatabaseRepository implements IBackupRepository { BackupRepository(super.db); @override @@ -33,9 +33,10 @@ class BackupRepository extends DataBaseRepository implements IBackupRepository { db.backupAlbums.filter().selectionEqualTo(backup).findAll(); @override - Future deleteAll(List ids) => db.backupAlbums.deleteAll(ids); + Future deleteAll(List ids) => + txn(() => db.backupAlbums.deleteAll(ids)); @override Future updateAll(List backupAlbums) => - db.backupAlbums.putAll(backupAlbums); + txn(() => db.backupAlbums.putAll(backupAlbums)); } diff --git a/mobile/lib/repositories/database.repository.dart b/mobile/lib/repositories/database.repository.dart index f7a085ff7491d..17c4df0ae54c7 100644 --- a/mobile/lib/repositories/database.repository.dart +++ b/mobile/lib/repositories/database.repository.dart @@ -1,8 +1,22 @@ +import 'dart:async'; + +import 'package:immich_mobile/interfaces/database.interface.dart'; import 'package:isar/isar.dart'; -abstract class DataBaseRepository { +const Symbol _zoneTxn = #zoneTxn; + +abstract class DatabaseRepository implements IDatabaseRepository { final Isar db; - DataBaseRepository(this.db); + DatabaseRepository(this.db); + + bool get inTxn => Zone.current[_zoneTxn] != null; + + Future txn(Future Function() callback) => + inTxn ? callback() : transaction(callback); + + @override + Future transaction(Future Function() callback) => + db.writeTxn(callback); } extension Asd on QueryBuilder { diff --git a/mobile/lib/repositories/etag.repository.dart b/mobile/lib/repositories/etag.repository.dart index 3b1f22318396c..9921b69f5ec0a 100644 --- a/mobile/lib/repositories/etag.repository.dart +++ b/mobile/lib/repositories/etag.repository.dart @@ -8,7 +8,7 @@ import 'package:isar/isar.dart'; final etagRepositoryProvider = Provider((ref) => ETagRepository(ref.watch(dbProvider))); -class ETagRepository extends DataBaseRepository implements IETagRepository { +class ETagRepository extends DatabaseRepository implements IETagRepository { ETagRepository(super.db); @override @@ -18,12 +18,11 @@ class ETagRepository extends DataBaseRepository implements IETagRepository { Future get(int id) => db.eTags.get(id); @override - Future upsertAll(List etags) => - db.writeTxn(() => db.eTags.putAll(etags)); + Future upsertAll(List etags) => txn(() => db.eTags.putAll(etags)); @override Future deleteByIds(List ids) => - db.writeTxn(() => db.eTags.deleteAllById(ids)); + txn(() => db.eTags.deleteAllById(ids)); @override Future getById(String id) => db.eTags.getById(id); diff --git a/mobile/lib/repositories/exif_info.repository.dart b/mobile/lib/repositories/exif_info.repository.dart index 80393ce8842bb..3ddb50104bea2 100644 --- a/mobile/lib/repositories/exif_info.repository.dart +++ b/mobile/lib/repositories/exif_info.repository.dart @@ -7,25 +7,25 @@ import 'package:immich_mobile/repositories/database.repository.dart'; final exifInfoRepositoryProvider = Provider((ref) => ExifInfoRepository(ref.watch(dbProvider))); -class ExifInfoRepository extends DataBaseRepository +class ExifInfoRepository extends DatabaseRepository implements IExifInfoRepository { ExifInfoRepository(super.db); @override - Future delete(int id) => db.exifInfos.delete(id); + Future delete(int id) => txn(() => db.exifInfos.delete(id)); @override Future get(int id) => db.exifInfos.get(id); @override Future update(ExifInfo exifInfo) async { - await db.writeTxn(() => db.exifInfos.put(exifInfo)); + await txn(() => db.exifInfos.put(exifInfo)); return exifInfo; } @override Future> updateAll(List exifInfos) async { - await db.writeTxn(() => db.exifInfos.putAll(exifInfos)); + await txn(() => db.exifInfos.putAll(exifInfos)); return exifInfos; } } diff --git a/mobile/lib/repositories/partner_api.repository.dart b/mobile/lib/repositories/partner_api.repository.dart index caaa882bfb7c4..0b3d164ca3523 100644 --- a/mobile/lib/repositories/partner_api.repository.dart +++ b/mobile/lib/repositories/partner_api.repository.dart @@ -2,7 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/partner_api.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/repositories/apibase.repository.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; import 'package:openapi/api.dart'; final partnerApiRepositoryProvider = Provider( @@ -11,7 +11,7 @@ final partnerApiRepositoryProvider = Provider( ), ); -class PartnerApiRepository extends ApiBaseRepository +class PartnerApiRepository extends ApiRepository implements IPartnerApiRepository { final PartnersApi _api; diff --git a/mobile/lib/repositories/person_api.repository.dart b/mobile/lib/repositories/person_api.repository.dart index f7d0a2e2e7602..d324a03edbef8 100644 --- a/mobile/lib/repositories/person_api.repository.dart +++ b/mobile/lib/repositories/person_api.repository.dart @@ -1,14 +1,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/interfaces/person_api.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/repositories/apibase.repository.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; import 'package:openapi/api.dart'; final personApiRepositoryProvider = Provider( (ref) => PersonApiRepository(ref.watch(apiServiceProvider).peopleApi), ); -class PersonApiRepository extends ApiBaseRepository +class PersonApiRepository extends ApiRepository implements IPersonApiRepository { final PeopleApi _api; diff --git a/mobile/lib/repositories/user.repository.dart b/mobile/lib/repositories/user.repository.dart index 83bab12758e95..5d0b7d15ac39d 100644 --- a/mobile/lib/repositories/user.repository.dart +++ b/mobile/lib/repositories/user.repository.dart @@ -9,7 +9,7 @@ import 'package:isar/isar.dart'; final userRepositoryProvider = Provider((ref) => UserRepository(ref.watch(dbProvider))); -class UserRepository extends DataBaseRepository implements IUserRepository { +class UserRepository extends DatabaseRepository implements IUserRepository { UserRepository(super.db); @override @@ -37,7 +37,7 @@ class UserRepository extends DataBaseRepository implements IUserRepository { @override Future update(User user) async { - await db.writeTxn(() => db.users.put(user)); + await txn(() => db.users.put(user)); return user; } @@ -45,12 +45,11 @@ class UserRepository extends DataBaseRepository implements IUserRepository { Future me() => Future.value(Store.get(StoreKey.currentUser)); @override - Future deleteById(List ids) => - db.writeTxn(() => db.users.deleteAll(ids)); + Future deleteById(List ids) => txn(() => db.users.deleteAll(ids)); @override Future> upsertAll(List users) async { - await db.writeTxn(() => db.users.putAll(users)); + await txn(() => db.users.putAll(users)); return users; } diff --git a/mobile/lib/repositories/user_api.repository.dart b/mobile/lib/repositories/user_api.repository.dart index bd76c50148c84..9641c4e0e610e 100644 --- a/mobile/lib/repositories/user_api.repository.dart +++ b/mobile/lib/repositories/user_api.repository.dart @@ -5,7 +5,7 @@ import 'package:http/http.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/user_api.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/repositories/apibase.repository.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; import 'package:openapi/api.dart'; final userApiRepositoryProvider = Provider( @@ -14,8 +14,7 @@ final userApiRepositoryProvider = Provider( ), ); -class UserApiRepository extends ApiBaseRepository - implements IUserApiRepository { +class UserApiRepository extends ApiRepository implements IUserApiRepository { final UsersApi _api; UserApiRepository(this._api); diff --git a/mobile/lib/services/album.service.dart b/mobile/lib/services/album.service.dart index 561c83d94a101..091049edb59f1 100644 --- a/mobile/lib/services/album.service.dart +++ b/mobile/lib/services/album.service.dart @@ -243,14 +243,15 @@ class AlbumService { int albumId, { List add = const [], List remove = const [], - }) async { - final album = await _albumRepository.get(albumId); - if (album == null) return; - await _albumRepository.addAssets(album, add); - await _albumRepository.removeAssets(album, remove); - await _albumRepository.recalculateMetadata(album); - await _albumRepository.update(album); - } + }) => + _albumRepository.transaction(() async { + final album = await _albumRepository.get(albumId); + if (album == null) return; + await _albumRepository.addAssets(album, add); + await _albumRepository.removeAssets(album, remove); + await _albumRepository.recalculateMetadata(album); + await _albumRepository.update(album); + }); Future addAdditionalUserToAlbum( List sharedUserIds, diff --git a/mobile/lib/services/asset.service.dart b/mobile/lib/services/asset.service.dart index 0c43cb9c9d7be..b2cad4dc828eb 100644 --- a/mobile/lib/services/asset.service.dart +++ b/mobile/lib/services/asset.service.dart @@ -194,7 +194,7 @@ class AssetService { a.exifInfo = newExif; if (newExif != a.exifInfo) { if (a.isInDb) { - _assetRepository.update(a); + _assetRepository.transaction(() => _assetRepository.update(a)); } else { debugPrint("[loadExif] parameter Asset is not from DB!"); } diff --git a/mobile/lib/services/backup.service.dart b/mobile/lib/services/backup.service.dart index 921fe3ca82f7b..20179a709ac14 100644 --- a/mobile/lib/services/backup.service.dart +++ b/mobile/lib/services/backup.service.dart @@ -72,9 +72,10 @@ class BackupService { } } - Future _saveDuplicatedAssetIds(List deviceAssetIds) { - return _assetRepository.upsertDuplicatedAssets(deviceAssetIds); - } + Future _saveDuplicatedAssetIds(List deviceAssetIds) => + _assetRepository.transaction( + () => _assetRepository.upsertDuplicatedAssets(deviceAssetIds), + ); /// Get duplicated asset id from database Future> getDuplicatedAssetIds() async { diff --git a/mobile/lib/services/hash.service.dart b/mobile/lib/services/hash.service.dart index 3827e421e6108..bb19340d2ff35 100644 --- a/mobile/lib/services/hash.service.dart +++ b/mobile/lib/services/hash.service.dart @@ -130,7 +130,9 @@ class HashService { final validHashes = anyNull ? toAdd.where((e) => e.hash.length == 20).toList(growable: false) : toAdd; - await _assetRepository.upsertDeviceAssets(validHashes); + + await _assetRepository + .transaction(() => _assetRepository.upsertDeviceAssets(validHashes)); _log.fine("Hashed ${validHashes.length}/${toHash.length} assets"); } diff --git a/mobile/lib/services/stack.service.dart b/mobile/lib/services/stack.service.dart index 8bff21fef61a6..1ca56ff2791cd 100644 --- a/mobile/lib/services/stack.service.dart +++ b/mobile/lib/services/stack.service.dart @@ -61,7 +61,8 @@ class StackService { removeAssets.add(asset); } - await _assetRepository.updateAll(removeAssets); + await _assetRepository + .transaction(() => _assetRepository.updateAll(removeAssets)); } catch (error) { debugPrint("Error while deleting stack: $error"); } diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart index 3c502e26af435..e4934086d014e 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -159,8 +159,10 @@ class SyncService { onlySecond: (User b) => toDelete.add(b.isarId), ); if (changes) { - await _userRepository.deleteById(toDelete); - await _userRepository.upsertAll(toUpsert); + await _userRepository.transaction(() async { + await _userRepository.deleteById(toDelete); + await _userRepository.upsertAll(toUpsert); + }); } return changes; } @@ -221,21 +223,23 @@ class SyncService { } /// Deletes remote-only assets, updates merged assets to be local-only - Future handleRemoteAssetRemoval(List idsToDelete) async { - await _assetRepository.deleteAllByRemoteId( - idsToDelete, - state: AssetState.remote, - ); - final merged = await _assetRepository.getAllByRemoteId( - idsToDelete, - state: AssetState.merged, - ); - if (merged.isEmpty) return; - for (final Asset asset in merged) { - asset.remoteId = null; - asset.isTrashed = false; - } - await _assetRepository.updateAll(merged); + Future handleRemoteAssetRemoval(List idsToDelete) { + return _assetRepository.transaction(() async { + await _assetRepository.deleteAllByRemoteId( + idsToDelete, + state: AssetState.remote, + ); + final merged = await _assetRepository.getAllByRemoteId( + idsToDelete, + state: AssetState.merged, + ); + if (merged.isEmpty) return; + for (final Asset asset in merged) { + asset.remoteId = null; + asset.isTrashed = false; + } + await _assetRepository.updateAll(merged); + }); } /// Syncs assets by loading and comparing all assets from the server. @@ -415,13 +419,15 @@ class SyncService { // write & commit all changes to DB try { - await _assetRepository.updateAll(toUpdate); - await _albumRepository.addUsers(album, usersToLink); - await _albumRepository.removeUsers(album, usersToUnlink); - await _albumRepository.addAssets(album, assetsToLink); - await _albumRepository.removeAssets(album, toUnlink); - await _albumRepository.recalculateMetadata(album); - await _albumRepository.update(album); + await _assetRepository.transaction(() async { + await _assetRepository.updateAll(toUpdate); + await _albumRepository.addUsers(album, usersToLink); + await _albumRepository.removeUsers(album, usersToUnlink); + await _albumRepository.addAssets(album, assetsToLink); + await _albumRepository.removeAssets(album, toUnlink); + await _albumRepository.recalculateMetadata(album); + await _albumRepository.update(album); + }); _log.info("Synced changes of remote album ${album.name} to DB"); } catch (e) { _log.severe("Failed to sync remote album to database", e); @@ -530,8 +536,10 @@ class SyncService { "${toDelete.length} assets to delete, ${toUpdate.length} to update", ); if (toDelete.isNotEmpty || toUpdate.isNotEmpty) { - await _assetRepository.deleteById(toDelete); - await _assetRepository.updateAll(toUpdate); + await _assetRepository.transaction(() async { + await _assetRepository.deleteById(toDelete); + await _assetRepository.updateAll(toUpdate); + }); _log.info( "Removed ${toDelete.length} and updated ${toUpdate.length} local assets from DB", ); @@ -615,14 +623,19 @@ class SyncService { dbAlbum.thumbnail.value = null; } try { - await _assetRepository.updateAll(updated + toUpdate); - await _albumRepository.addAssets(dbAlbum, existingInDb + updated); - await _albumRepository.removeAssets(dbAlbum, toDelete); - await _albumRepository.recalculateMetadata(dbAlbum); - await _albumRepository.update(dbAlbum); - await _eTagRepository.upsertAll([ - ETag(id: deviceAlbum.eTagKeyAssetCount, assetCount: assetCountOnDevice), - ]); + await _assetRepository.transaction(() async { + await _assetRepository.updateAll(updated + toUpdate); + await _albumRepository.addAssets(dbAlbum, existingInDb + updated); + await _albumRepository.removeAssets(dbAlbum, toDelete); + await _albumRepository.recalculateMetadata(dbAlbum); + await _albumRepository.update(dbAlbum); + await _eTagRepository.upsertAll([ + ETag( + id: deviceAlbum.eTagKeyAssetCount, + assetCount: assetCountOnDevice, + ), + ]); + }); _log.info("Synced changes of local album ${deviceAlbum.name} to DB"); } catch (e) { _log.severe("Failed to update synced album ${deviceAlbum.name} in DB", e); @@ -659,13 +672,15 @@ class SyncService { _removeDuplicates(newAssets); final (existingInDb, updated) = await _linkWithExistingFromDb(newAssets); try { - await _assetRepository.updateAll(updated); - await _albumRepository.addAssets(dbAlbum, existingInDb + updated); - await _albumRepository.recalculateMetadata(dbAlbum); - await _albumRepository.update(dbAlbum); - await _eTagRepository.upsertAll( - [ETag(id: deviceAlbum.eTagKeyAssetCount, assetCount: totalOnDevice)], - ); + await _assetRepository.transaction(() async { + await _assetRepository.updateAll(updated); + await _albumRepository.addAssets(dbAlbum, existingInDb + updated); + await _albumRepository.recalculateMetadata(dbAlbum); + await _albumRepository.update(dbAlbum); + await _eTagRepository.upsertAll( + [ETag(id: deviceAlbum.eTagKeyAssetCount, assetCount: totalOnDevice)], + ); + }); _log.info("Fast synced local album ${deviceAlbum.name} to DB"); } catch (e) { _log.severe( @@ -744,11 +759,13 @@ class SyncService { if (assets.isEmpty) return; final exifInfos = assets.map((e) => e.exifInfo).nonNulls.toList(); try { - await _assetRepository.updateAll(assets); - for (final Asset added in assets) { - added.exifInfo?.id = added.id; - } - await _exifInfoRepository.updateAll(exifInfos); + await _assetRepository.transaction(() async { + await _assetRepository.updateAll(assets); + for (final Asset added in assets) { + added.exifInfo?.id = added.id; + } + await _exifInfoRepository.updateAll(exifInfos); + }); _log.info("Upserted ${assets.length} assets into the DB"); } catch (e) { _log.severe("Failed to upsert ${assets.length} assets into the DB", e); @@ -815,9 +832,11 @@ class SyncService { final assets = await _assetRepository.getAllLocal(); final (toDelete, toUpdate) = _handleAssetRemoval(assets, [], remote: false); - await _assetRepository.deleteById(toDelete); - await _assetRepository.updateAll(toUpdate); - await _albumRepository.deleteAllLocal(); + await _assetRepository.transaction(() async { + await _assetRepository.deleteById(toDelete); + await _assetRepository.updateAll(toUpdate); + await _albumRepository.deleteAllLocal(); + }); return true; } catch (e) { _log.severe("Failed to remove all local albums and assets", e); diff --git a/mobile/test/modules/shared/sync_service_test.dart b/mobile/test/modules/shared/sync_service_test.dart index 076c5718481c6..b92d36f0820bf 100644 --- a/mobile/test/modules/shared/sync_service_test.dart +++ b/mobile/test/modules/shared/sync_service_test.dart @@ -106,6 +106,12 @@ void main() { when(() => assetRepository.deleteById(any())).thenAnswer((_) async {}); when(() => exifInfoRepository.updateAll(any())) .thenAnswer((_) async => []); + when(() => assetRepository.transaction(any())).thenAnswer( + (call) => (call.positionalArguments.first as Function).call(), + ); + when(() => assetRepository.transaction(any())).thenAnswer( + (call) => (call.positionalArguments.first as Function).call(), + ); }); test('test inserting existing assets', () async { final List remoteAssets = [ diff --git a/mobile/test/services/album.service_test.dart b/mobile/test/services/album.service_test.dart index 56bb2b40205a8..fb46dceed592f 100644 --- a/mobile/test/services/album.service_test.dart +++ b/mobile/test/services/album.service_test.dart @@ -29,6 +29,13 @@ void main() { albumMediaRepository = MockAlbumMediaRepository(); albumApiRepository = MockAlbumApiRepository(); + when(() => albumRepository.transaction(any())).thenAnswer( + (call) => (call.positionalArguments.first as Function).call(), + ); + when(() => assetRepository.transaction(any())).thenAnswer( + (call) => (call.positionalArguments.first as Function).call(), + ); + sut = AlbumService( userService, syncService, From 48591f8e49aa4c005405ff86b641d49fef34e346 Mon Sep 17 00:00:00 2001 From: Fynn Petersen-Frey Date: Mon, 30 Sep 2024 16:09:59 +0200 Subject: [PATCH 3/3] fix bug found by Alex --- mobile/lib/interfaces/user.interface.dart | 2 +- mobile/lib/repositories/database.repository.dart | 1 + mobile/lib/repositories/user.repository.dart | 4 ++-- mobile/lib/services/sync.service.dart | 2 +- mobile/test/modules/shared/sync_service_test.dart | 4 +++- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/mobile/lib/interfaces/user.interface.dart b/mobile/lib/interfaces/user.interface.dart index cfe26b42545a3..e6175a7dc9906 100644 --- a/mobile/lib/interfaces/user.interface.dart +++ b/mobile/lib/interfaces/user.interface.dart @@ -6,7 +6,7 @@ abstract interface class IUserRepository implements IDatabaseRepository { Future> getByIds(List ids); - Future> getAll({bool self = true, UserSort? sort}); + Future> getAll({bool self = true, UserSort? sortBy}); /// Returns all users whose assets can be accessed (self+partners) Future> getAllAccessible(); diff --git a/mobile/lib/repositories/database.repository.dart b/mobile/lib/repositories/database.repository.dart index 17c4df0ae54c7..f9ee1426bbee7 100644 --- a/mobile/lib/repositories/database.repository.dart +++ b/mobile/lib/repositories/database.repository.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:immich_mobile/interfaces/database.interface.dart'; import 'package:isar/isar.dart'; +/// copied from Isar; needed to check if an async transaction is already active const Symbol _zoneTxn = #zoneTxn; abstract class DatabaseRepository implements IDatabaseRepository { diff --git a/mobile/lib/repositories/user.repository.dart b/mobile/lib/repositories/user.repository.dart index 5d0b7d15ac39d..fb4df84fe7c4a 100644 --- a/mobile/lib/repositories/user.repository.dart +++ b/mobile/lib/repositories/user.repository.dart @@ -20,13 +20,13 @@ class UserRepository extends DatabaseRepository implements IUserRepository { Future get(String id) => db.users.getById(id); @override - Future> getAll({bool self = true, UserSort? sort}) { + Future> getAll({bool self = true, UserSort? sortBy}) { final baseQuery = db.users.where(); final int userId = Store.get(StoreKey.currentUser).isarId; final QueryBuilder afterWhere = self ? baseQuery.noOp() : baseQuery.isarIdNotEqualTo(userId); final QueryBuilder query; - switch (sort) { + switch (sortBy) { case null: query = afterWhere.noOp(); case UserSort.id: diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart index e4934086d014e..e23c2d1b1b216 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -137,7 +137,7 @@ class SyncService { /// Returns `true`if there were any changes Future _syncUsersFromServer(List users) async { users.sortBy((u) => u.id); - final dbUsers = await _userRepository.getAll(); + final dbUsers = await _userRepository.getAll(sortBy: UserSort.id); assert(dbUsers.isSortedBy((u) => u.id), "dbUsers not sorted!"); final List toDelete = []; final List toUpsert = []; diff --git a/mobile/test/modules/shared/sync_service_test.dart b/mobile/test/modules/shared/sync_service_test.dart index b92d36f0820bf..c85487c7d04da 100644 --- a/mobile/test/modules/shared/sync_service_test.dart +++ b/mobile/test/modules/shared/sync_service_test.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; import 'package:immich_mobile/services/immich_logger.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:mocktail/mocktail.dart'; @@ -91,7 +92,8 @@ void main() { when(() => eTagRepository.deleteByIds(["1"])).thenAnswer((_) async {}); when(() => eTagRepository.upsertAll(any())).thenAnswer((_) async {}); when(() => userRepository.me()).thenAnswer((_) async => owner); - when(() => userRepository.getAll()).thenAnswer((_) async => [owner]); + when(() => userRepository.getAll(sortBy: UserSort.id)) + .thenAnswer((_) async => [owner]); when(() => userRepository.getAllAccessible()) .thenAnswer((_) async => [owner]); when(