diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 3286a9a8f6..2783e8f1d1 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -46,21 +46,46 @@ custom_lint: - avoid_public_notifier_properties: false - avoid_manual_providers_as_generated_provider_dependency: false - unsupported_provider_value: false - - photo_manager: - exclude: + - import_rule_photo_manager: + message: photo_manager must only be used in MediaRepositories + restrict: package:photo_manager + allowed: # required / wanted - - album_media.repository.dart - - asset_media.repository.dart - - file_media.repository.dart - # acceptable exceptions for the time being - - asset.entity.dart # to provide local AssetEntity for now - - immich_local_image_provider.dart # accesses thumbnails via PhotoManager - - immich_local_thumbnail_provider.dart # accesses thumbnails via PhotoManager - # refactor to make the providers and services testable - - backup.provider.dart # uses only PMProgressHandler - - manual_upload.provider.dart # uses only PMProgressHandler - - background.service.dart # uses only PMProgressHandler - - backup.service.dart # uses only PMProgressHandler + - 'lib/repositories/{album,asset,file}_media.repository.dart' + # acceptable exceptions for the time being + - lib/entities/asset.entity.dart # to provide local AssetEntity for now + - lib/providers/image/immich_local_{image,thumbnail}_provider.dart # accesses thumbnails via PhotoManager + # refactor to make the providers and services testable + - lib/providers/backup/{backup,manual_upload}.provider.dart # uses only PMProgressHandler + - lib/services/{background,backup}.service.dart # uses only PMProgressHandler + - import_rule_openapi: + message: openapi must only be used through ApiRepositories + restrict: package:openapi + allowed: + # requried / wanted + - lib/repositories/album_api.repository.dart + # acceptable exceptions for the time being + - lib/entities/{album,asset,exif_info,user}.entity.dart # to convert DTOs to entities + - lib/utils/{image_url_builder,openapi_patching}.dart # utils are fine + - test/modules/utils/openapi_patching_test.dart # filename is self-explanatory... + # refactor + - lib/models/activities/activity.model.dart + - lib/models/map/map_marker.model.dart + - lib/models/search/search_filter.model.dart + - lib/models/server_info/server_{config,disk_info,features,version}.model.dart + - lib/models/shared_link/shared_link.model.dart + - lib/pages/search/search_input.page.dart + - lib/providers/asset_viewer/asset_people.provider.dart + - lib/providers/authentication.provider.dart + - lib/providers/image/immich_remote_{image,thumbnail}_provider.dart + - lib/providers/map/map_state.provider.dart + - lib/providers/search/{people,search,search_filter}.provider.dart + - lib/providers/websocket.provider.dart + - lib/routing/auth_guard.dart + - lib/services/{activity,api,asset,asset_description,backup,memory,oauth,partner,person,search,shared_link,stack,trash,user}.service.dart + - lib/widgets/album/album_thumbnail_listtile.dart + - lib/widgets/forms/login/login_form.dart + - lib/widgets/search/search_filter/{camera_picker,location_picker,people_picker}.dart dart_code_metrics: metrics: diff --git a/mobile/immich_lint/lib/immich_mobile_immich_lint.dart b/mobile/immich_lint/lib/immich_mobile_immich_lint.dart index 31922ecc24..65f3fc18f3 100644 --- a/mobile/immich_lint/lib/immich_mobile_immich_lint.dart +++ b/mobile/immich_lint/lib/immich_mobile_immich_lint.dart @@ -1,36 +1,61 @@ -import 'dart:collection'; - import 'package:analyzer/error/listener.dart'; import 'package:analyzer/error/error.dart' show ErrorSeverity; import 'package:custom_lint_builder/custom_lint_builder.dart'; +// ignore: depend_on_referenced_packages +import 'package:glob/glob.dart'; PluginBase createPlugin() => ImmichLinter(); class ImmichLinter extends PluginBase { @override - List getLintRules(CustomLintConfigs configs) => [ - PhotoManagerRule(configs.rules[PhotoManagerRule._code.name]), - ]; -} - -class PhotoManagerRule extends DartLintRule { - PhotoManagerRule(LintOptions? options) : super(code: _code) { - final excludeOption = options?.json["exclude"]; - if (excludeOption is String) { - _excludePaths.add(excludeOption); - } else if (excludeOption is List) { - _excludePaths.addAll(excludeOption.map((option) => option)); + List getLintRules(CustomLintConfigs configs) { + final List rules = []; + for (final entry in configs.rules.entries) { + if (entry.value.enabled && entry.key.startsWith("import_rule_")) { + final code = makeCode(entry.key, entry.value); + final allowedPaths = getStrings(entry.value, "allowed"); + final forbiddenPaths = getStrings(entry.value, "forbidden"); + final restrict = getStrings(entry.value, "restrict"); + rules.add(ImportRule(code, buildGlob(allowedPaths), + buildGlob(forbiddenPaths), restrict)); + } } + return rules; } - final Set _excludePaths = HashSet(); + static makeCode(String name, LintOptions options) => LintCode( + name: name, + problemMessage: options.json["message"] as String, + errorSeverity: ErrorSeverity.WARNING, + ); - static const _code = LintCode( - name: 'photo_manager', - problemMessage: - 'photo_manager library must only be used in MediaRepository', - errorSeverity: ErrorSeverity.WARNING, - ); + static List getStrings(LintOptions options, String field) { + final List result = []; + final excludeOption = options.json[field]; + if (excludeOption is String) { + result.add(excludeOption); + } else if (excludeOption is List) { + result.addAll(excludeOption.map((option) => option)); + } + return result; + } + + Glob? buildGlob(List globs) { + if (globs.isEmpty) return null; + if (globs.length == 1) return Glob(globs[0], caseSensitive: true); + return Glob("{${globs.join(",")}}", caseSensitive: true); + } +} + +// ignore: must_be_immutable +class ImportRule extends DartLintRule { + ImportRule(LintCode code, this._allowed, this._forbidden, this._restrict) + : super(code: code); + + final Glob? _allowed; + final Glob? _forbidden; + final List _restrict; + int _rootOffset = -1; @override void run( @@ -38,11 +63,23 @@ class PhotoManagerRule extends DartLintRule { ErrorReporter reporter, CustomLintContext context, ) { - if (_excludePaths.contains(resolver.source.shortName)) return; + if (_rootOffset == -1) { + const project = "/immich/mobile/"; + _rootOffset = resolver.path.indexOf(project) + project.length; + } + final path = resolver.path.substring(_rootOffset); + + if ((_allowed != null && _allowed!.matches(path)) && + (_forbidden == null || !_forbidden!.matches(path))) return; context.registry.addImportDirective((node) { - if (node.uri.stringValue?.startsWith("package:photo_manager") == true) { - reporter.atNode(node, code); + final uri = node.uri.stringValue; + if (uri == null) return; + for (final restricted in _restrict) { + if (uri.startsWith(restricted) == true) { + reporter.atNode(node, code); + return; + } } }); } diff --git a/mobile/immich_lint/pubspec.lock b/mobile/immich_lint/pubspec.lock index 83bb229e82..6b7a4c99c5 100644 --- a/mobile/immich_lint/pubspec.lock +++ b/mobile/immich_lint/pubspec.lock @@ -159,7 +159,7 @@ packages: source: hosted version: "2.4.4" glob: - dependency: transitive + dependency: "direct main" description: name: glob sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" diff --git a/mobile/immich_lint/pubspec.yaml b/mobile/immich_lint/pubspec.yaml index e10c665c57..78298f451e 100644 --- a/mobile/immich_lint/pubspec.yaml +++ b/mobile/immich_lint/pubspec.yaml @@ -8,6 +8,7 @@ dependencies: analyzer: ^6.8.0 analyzer_plugin: ^0.11.3 custom_lint_builder: ^0.6.4 + glob: ^2.1.2 dev_dependencies: lints: ^4.0.0 diff --git a/mobile/lib/entities/album.entity.dart b/mobile/lib/entities/album.entity.dart index 1914336cf7..6331c4b9f0 100644 --- a/mobile/lib/entities/album.entity.dart +++ b/mobile/lib/entities/album.entity.dart @@ -3,6 +3,8 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/utils/datetime_comparison.dart'; import 'package:isar/isar.dart'; +// ignore: implementation_imports +import 'package:isar/src/common/isar_links_common.dart'; import 'package:openapi/api.dart'; part 'album.entity.g.dart'; @@ -23,6 +25,7 @@ class Album { required this.activityEnabled, }); + // fields stored in DB Id id = Isar.autoIncrement; @Index(unique: false, replace: false, type: IndexType.hash) String? remoteId; @@ -41,9 +44,17 @@ class Album { final IsarLinks sharedUsers = IsarLinks(); final IsarLinks assets = IsarLinks(); + // transient fields @ignore bool isAll = false; + @ignore + String? remoteThumbnailAssetId; + + @ignore + int remoteAssetCount = 0; + + // getters @ignore bool get isRemote => remoteId != null; @@ -74,6 +85,18 @@ class Album { @ignore String get eTagKeyAssetCount => "device-album-$localId-asset-count"; + // the following getter are needed because Isar links do not make data + // accessible in an object freshly created (not loaded from DB) + + @ignore + Iterable get remoteUsers => sharedUsers.isEmpty + ? (sharedUsers as IsarLinksCommon).addedObjects + : sharedUsers; + + @ignore + Iterable get remoteAssets => + assets.isEmpty ? (assets as IsarLinksCommon).addedObjects : assets; + @override bool operator ==(other) { if (other is! Album) return false; @@ -129,6 +152,7 @@ class Album { endDate: dto.endDate, activityEnabled: dto.isActivityEnabled, ); + a.remoteAssetCount = dto.assetCount; a.owner.value = await db.users.getById(dto.ownerId); if (dto.albumThumbnailAssetId != null) { a.thumbnail.value = await db.assets @@ -164,7 +188,3 @@ extension AssetsHelper on IsarCollection { return a; } } - -extension AlbumResponseDtoHelper on AlbumResponseDto { - List getAssets() => assets.map(Asset.remote).toList(); -} diff --git a/mobile/lib/interfaces/album_api.interface.dart b/mobile/lib/interfaces/album_api.interface.dart new file mode 100644 index 0000000000..33b589841f --- /dev/null +++ b/mobile/lib/interfaces/album_api.interface.dart @@ -0,0 +1,40 @@ +import 'package:immich_mobile/entities/album.entity.dart'; + +abstract interface class IAlbumApiRepository { + Future get(String id); + + Future> getAll({bool? shared}); + + Future create( + String name, { + required Iterable assetIds, + Iterable sharedUserIds = const [], + }); + + Future update( + String albumId, { + String? name, + String? thumbnailAssetId, + String? description, + bool? activityEnabled, + }); + + Future delete(String albumId); + + Future<({List added, List duplicates})> addAssets( + String albumId, + Iterable assetIds, + ); + + Future<({List removed, List failed})> removeAssets( + String albumId, + Iterable assetIds, + ); + + Future addUsers( + String albumId, + Iterable userIds, + ); + + Future removeUser(String albumId, {required String userId}); +} diff --git a/mobile/lib/interfaces/asset.interface.dart b/mobile/lib/interfaces/asset.interface.dart index 46425ba617..2574e52112 100644 --- a/mobile/lib/interfaces/asset.interface.dart +++ b/mobile/lib/interfaces/asset.interface.dart @@ -3,6 +3,8 @@ import 'package:immich_mobile/entities/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); } diff --git a/mobile/lib/interfaces/user.interface.dart b/mobile/lib/interfaces/user.interface.dart index d9841a1187..4e847ea022 100644 --- a/mobile/lib/interfaces/user.interface.dart +++ b/mobile/lib/interfaces/user.interface.dart @@ -2,4 +2,5 @@ import 'package:immich_mobile/entities/user.entity.dart'; abstract interface class IUserRepository { Future> getByIds(List ids); + Future get(String id); } diff --git a/mobile/lib/repositories/album_api.repository.dart b/mobile/lib/repositories/album_api.repository.dart new file mode 100644 index 0000000000..6b7865f8e4 --- /dev/null +++ b/mobile/lib/repositories/album_api.repository.dart @@ -0,0 +1,172 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/errors.dart'; +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/album_api.interface.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:openapi/api.dart'; + +final albumApiRepositoryProvider = Provider( + (ref) => AlbumApiRepository(ref.watch(apiServiceProvider).albumsApi), +); + +class AlbumApiRepository implements IAlbumApiRepository { + final AlbumsApi _api; + + AlbumApiRepository(this._api); + + @override + Future get(String id) async { + final dto = await _checkNull(_api.getAlbumInfo(id)); + return _toAlbum(dto); + } + + @override + Future> getAll({bool? shared}) async { + final dtos = await _checkNull(_api.getAllAlbums(shared: shared)); + return dtos.map(_toAlbum).toList().cast(); + } + + @override + Future create( + String name, { + required Iterable assetIds, + Iterable sharedUserIds = const [], + }) async { + final users = sharedUserIds.map( + (id) => AlbumUserCreateDto(userId: id, role: AlbumUserRole.editor), + ); + final responseDto = await _checkNull( + _api.createAlbum( + CreateAlbumDto( + albumName: name, + assetIds: assetIds.toList(), + albumUsers: users.toList(), + ), + ), + ); + return _toAlbum(responseDto); + } + + @override + Future update( + String albumId, { + String? name, + String? thumbnailAssetId, + String? description, + bool? activityEnabled, + }) async { + final response = await _checkNull( + _api.updateAlbumInfo( + albumId, + UpdateAlbumDto( + albumName: name, + albumThumbnailAssetId: thumbnailAssetId, + description: description, + isActivityEnabled: activityEnabled, + ), + ), + ); + return _toAlbum(response); + } + + @override + Future delete(String albumId) { + return _api.deleteAlbum(albumId); + } + + @override + Future<({List added, List duplicates})> addAssets( + String albumId, + Iterable assetIds, + ) async { + final response = await _checkNull( + _api.addAssetsToAlbum( + albumId, + BulkIdsDto(ids: assetIds.toList()), + ), + ); + + final List added = []; + final List duplicates = []; + + for (final result in response) { + if (result.success) { + added.add(result.id); + } else if (result.error == BulkIdResponseDtoErrorEnum.duplicate) { + duplicates.add(result.id); + } + } + return (added: added, duplicates: duplicates); + } + + @override + Future<({List removed, List failed})> removeAssets( + String albumId, + Iterable assetIds, + ) async { + final response = await _checkNull( + _api.removeAssetFromAlbum( + albumId, + BulkIdsDto(ids: assetIds.toList()), + ), + ); + final List removed = [], failed = []; + for (final dto in response) { + if (dto.success) { + removed.add(dto.id); + } else { + failed.add(dto.id); + } + } + return (removed: removed, failed: failed); + } + + @override + Future addUsers(String albumId, Iterable userIds) async { + final albumUsers = + userIds.map((userId) => AlbumUserAddDto(userId: userId)).toList(); + final response = await _checkNull( + _api.addUsersToAlbum( + albumId, + AddUsersDto(albumUsers: albumUsers), + ), + ); + return _toAlbum(response); + } + + @override + Future removeUser(String albumId, {required String userId}) { + return _api.removeUserFromAlbum(albumId, userId); + } + + static Future _checkNull(Future future) async { + final response = await future; + if (response == null) throw NoResponseDtoError(); + return response; + } + + static Album _toAlbum(AlbumResponseDto dto) { + final Album album = Album( + remoteId: dto.id, + name: dto.albumName, + createdAt: dto.createdAt, + modifiedAt: dto.updatedAt, + lastModifiedAssetTimestamp: dto.lastModifiedAssetTimestamp, + shared: dto.shared, + startDate: dto.startDate, + endDate: dto.endDate, + activityEnabled: dto.isActivityEnabled, + ); + album.remoteAssetCount = dto.assetCount; + album.owner.value = User.fromSimpleUserDto(dto.owner); + album.remoteThumbnailAssetId = dto.albumThumbnailAssetId; + final users = dto.albumUsers + .map((albumUser) => User.fromSimpleUserDto(albumUser.user)); + album.sharedUsers.addAll(users); + final assets = dto.assets.map(Asset.remote).toList(); + album.assets.addAll(assets); + return album; + } +} diff --git a/mobile/lib/repositories/asset.repository.dart b/mobile/lib/repositories/asset.repository.dart index ea05feab38..8ec028f728 100644 --- a/mobile/lib/repositories/asset.repository.dart +++ b/mobile/lib/repositories/asset.repository.dart @@ -28,4 +28,11 @@ class AssetRepository implements IAssetRepository { @override Future deleteById(List ids) => _db.writeTxn(() => _db.assets.deleteAll(ids)); + + @override + Future getByRemoteId(String id) => _db.assets.getByRemoteId(id); + + @override + Future> getAllByRemoteId(Iterable ids) => + _db.assets.getAllByRemoteId(ids); } diff --git a/mobile/lib/repositories/user.repository.dart b/mobile/lib/repositories/user.repository.dart index cd87eb17ec..b05af9a57f 100644 --- a/mobile/lib/repositories/user.repository.dart +++ b/mobile/lib/repositories/user.repository.dart @@ -17,4 +17,7 @@ class UserRepository implements IUserRepository { @override Future> getByIds(List ids) async => (await _db.users.getAllById(ids)).cast(); + + @override + Future get(String id) => _db.users.getById(id); } diff --git a/mobile/lib/services/album.service.dart b/mobile/lib/services/album.service.dart index 104c3827cb..dd021e698e 100644 --- a/mobile/lib/services/album.service.dart +++ b/mobile/lib/services/album.service.dart @@ -6,63 +6,61 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.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/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/backup.interface.dart'; -import 'package:immich_mobile/interfaces/user.interface.dart'; import 'package:immich_mobile/models/albums/album_add_asset_response.model.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/repositories/album.repository.dart'; +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/user.repository.dart'; -import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; final albumServiceProvider = Provider( (ref) => AlbumService( - ref.watch(apiServiceProvider), ref.watch(userServiceProvider), ref.watch(syncServiceProvider), + ref.watch(entityServiceProvider), ref.watch(albumRepositoryProvider), ref.watch(assetRepositoryProvider), - ref.watch(userRepositoryProvider), ref.watch(backupRepositoryProvider), ref.watch(albumMediaRepositoryProvider), + ref.watch(albumApiRepositoryProvider), ), ); class AlbumService { - final ApiService _apiService; final UserService _userService; final SyncService _syncService; + final EntityService _entityService; final IAlbumRepository _albumRepository; final IAssetRepository _assetRepository; - final IUserRepository _userRepository; final IBackupRepository _backupAlbumRepository; final IAlbumMediaRepository _albumMediaRepository; + final IAlbumApiRepository _albumApiRepository; final Logger _log = Logger('AlbumService'); Completer _localCompleter = Completer()..complete(false); Completer _remoteCompleter = Completer()..complete(false); AlbumService( - this._apiService, this._userService, this._syncService, + this._entityService, this._albumRepository, this._assetRepository, - this._userRepository, this._backupAlbumRepository, this._albumMediaRepository, + this._albumApiRepository, ); /// Checks all selected device albums for changes of albums and their assets @@ -164,17 +162,11 @@ class AlbumService { bool changes = false; try { await _userService.refreshUsers(); - final List? serverAlbums = await _apiService.albumsApi - .getAllAlbums(shared: isShared ? true : null); - if (serverAlbums == null) { - return false; - } + final List serverAlbums = + await _albumApiRepository.getAll(shared: isShared ? true : null); changes = await _syncService.syncRemoteAlbumsToDb( serverAlbums, isShared: isShared, - loadDetails: (dto) async => dto.assetCount == dto.assets.length - ? dto - : (await _apiService.albumsApi.getAlbumInfo(dto.id)) ?? dto, ); } finally { _remoteCompleter.complete(changes); @@ -188,30 +180,13 @@ class AlbumService { Iterable assets, [ Iterable sharedUsers = const [], ]) async { - try { - AlbumResponseDto? remote = await _apiService.albumsApi.createAlbum( - CreateAlbumDto( - albumName: albumName, - assetIds: assets.map((asset) => asset.remoteId!).toList(), - albumUsers: sharedUsers - .map( - (e) => AlbumUserCreateDto( - userId: e.id, - role: AlbumUserRole.editor, - ), - ) - .toList(), - ), - ); - if (remote != null) { - final Album album = await Album.remote(remote); - await _albumRepository.create(album); - return album; - } - } catch (e) { - debugPrint("Error createSharedAlbum ${e.toString()}"); - } - return null; + final Album album = await _albumApiRepository.create( + albumName, + assetIds: assets.map((asset) => asset.remoteId!), + sharedUserIds: sharedUsers.map((user) => user.id), + ); + await _entityService.fillAlbumWithDatabaseEntities(album); + return _albumRepository.create(album); } /* @@ -243,32 +218,21 @@ class AlbumService { Album album, ) async { try { - var response = await _apiService.albumsApi.addAssetsToAlbum( + final result = await _albumApiRepository.addAssets( album.remoteId!, - BulkIdsDto(ids: assets.map((asset) => asset.remoteId!).toList()), + assets.map((asset) => asset.remoteId!), ); - if (response != null) { - List successAssets = []; - List duplicatedAssets = []; + final List addedAssets = result.added + .map((id) => assets.firstWhere((asset) => asset.remoteId == id)) + .toList(); - for (final result in response) { - if (result.success) { - successAssets - .add(assets.firstWhere((asset) => asset.remoteId == result.id)); - } else if (!result.success && - result.error == BulkIdResponseDtoErrorEnum.duplicate) { - duplicatedAssets.add(result.id); - } - } + await _updateAssets(album.id, add: addedAssets); - await _updateAssets(album.id, add: successAssets); - - return AlbumAddAssetsResponse( - alreadyInAlbum: duplicatedAssets, - successfullyAdded: successAssets.length, - ); - } + return AlbumAddAssetsResponse( + alreadyInAlbum: result.duplicates, + successfullyAdded: addedAssets.length, + ); } catch (e) { debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}"); } @@ -293,20 +257,11 @@ class AlbumService { Album album, ) async { try { - final List albumUsers = sharedUserIds - .map((userId) => AlbumUserAddDto(userId: userId)) - .toList(); - - final result = await _apiService.albumsApi.addUsersToAlbum( - album.remoteId!, - AddUsersDto(albumUsers: albumUsers), - ); - if (result != null) { - album.sharedUsers.addAll(await _userRepository.getByIds(sharedUserIds)); - album.shared = result.shared; - await _albumRepository.update(album); - return true; - } + final updatedAlbum = + await _albumApiRepository.addUsers(album.remoteId!, sharedUserIds); + await _entityService.fillAlbumWithDatabaseEntities(updatedAlbum); + await _albumRepository.update(updatedAlbum); + return true; } catch (e) { debugPrint("Error addAdditionalUserToAlbum ${e.toString()}"); } @@ -315,15 +270,13 @@ class AlbumService { Future setActivityEnabled(Album album, bool enabled) async { try { - final result = await _apiService.albumsApi.updateAlbumInfo( + final updatedAlbum = await _albumApiRepository.update( album.remoteId!, - UpdateAlbumDto(isActivityEnabled: enabled), + activityEnabled: enabled, ); - if (result != null) { - album.activityEnabled = enabled; - await _albumRepository.update(album); - return true; - } + await _entityService.fillAlbumWithDatabaseEntities(updatedAlbum); + await _albumRepository.update(updatedAlbum); + return true; } catch (e) { debugPrint("Error setActivityEnabled ${e.toString()}"); } @@ -334,7 +287,7 @@ class AlbumService { try { final user = Store.get(StoreKey.currentUser); if (album.owner.value?.isarId == user.isarId) { - await _apiService.albumsApi.deleteAlbum(album.remoteId!); + await _albumApiRepository.delete(album.remoteId!); } if (album.shared) { final foreignAssets = @@ -365,7 +318,7 @@ class AlbumService { Future leaveAlbum(Album album) async { try { - await _apiService.albumsApi.removeUserFromAlbum(album.remoteId!, "me"); + await _albumApiRepository.removeUser(album.remoteId!, userId: "me"); return true; } catch (e) { debugPrint("Error leaveAlbum ${e.toString()}"); @@ -378,21 +331,14 @@ class AlbumService { Iterable assets, ) async { try { - final response = await _apiService.albumsApi.removeAssetFromAlbum( + final result = await _albumApiRepository.removeAssets( album.remoteId!, - BulkIdsDto( - ids: assets.map((asset) => asset.remoteId!).toList(), - ), + assets.map((asset) => asset.remoteId!), ); - if (response != null) { - final toRemove = response.every((e) => e.success) - ? assets - : response - .where((e) => e.success) - .map((e) => assets.firstWhere((a) => a.remoteId == e.id)); - await _updateAssets(album.id, remove: toRemove.toList()); - return true; - } + final toRemove = result.removed + .map((id) => assets.firstWhere((asset) => asset.remoteId == id)); + await _updateAssets(album.id, remove: toRemove.toList()); + return true; } catch (e) { debugPrint("Error removeAssetFromAlbum ${e.toString()}"); } @@ -404,9 +350,9 @@ class AlbumService { User user, ) async { try { - await _apiService.albumsApi.removeUserFromAlbum( + await _albumApiRepository.removeUser( album.remoteId!, - user.id, + userId: user.id, ); album.sharedUsers.remove(user); @@ -427,15 +373,12 @@ class AlbumService { String newAlbumTitle, ) async { try { - await _apiService.albumsApi.updateAlbumInfo( + album = await _albumApiRepository.update( album.remoteId!, - UpdateAlbumDto( - albumName: newAlbumTitle, - ), + name: newAlbumTitle, ); - album.name = newAlbumTitle; + await _entityService.fillAlbumWithDatabaseEntities(album); await _albumRepository.update(album); - return true; } catch (e) { debugPrint("Error changeTitleAlbum ${e.toString()}"); @@ -456,12 +399,8 @@ class AlbumService { for (final albumName in albumNames) { Album? album = await getAlbumByName(albumName, true); album ??= await createAlbum(albumName, []); - if (album != null && album.remoteId != null) { - await _apiService.albumsApi.addAssetsToAlbum( - album.remoteId!, - BulkIdsDto(ids: assetIds), - ); + await _albumApiRepository.addAssets(album.remoteId!, assetIds); } } } diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index f10abd7297..09030a621b 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -13,12 +13,14 @@ 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'; import 'package:immich_mobile/repositories/album.repository.dart'; +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/file_media.repository.dart'; import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:immich_mobile/services/album.service.dart'; +import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/hash.service.dart'; import 'package:immich_mobile/services/localization.service.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; @@ -363,23 +365,33 @@ class BackgroundService { PartnerService partnerService = PartnerService(apiService, db); AlbumRepository albumRepository = AlbumRepository(db); AssetRepository assetRepository = AssetRepository(db); - UserRepository userRepository = UserRepository(db); BackupRepository backupAlbumRepository = BackupRepository(db); AlbumMediaRepository albumMediaRepository = AlbumMediaRepository(); FileMediaRepository fileMediaRepository = FileMediaRepository(); + UserRepository userRepository = UserRepository(db); + AlbumApiRepository albumApiRepository = + AlbumApiRepository(apiService.albumsApi); HashService hashService = HashService(db, this, albumMediaRepository); - SyncService syncSerive = SyncService(db, hashService, albumMediaRepository); + EntityService entityService = + EntityService(assetRepository, userRepository); + SyncService syncSerive = SyncService( + db, + hashService, + entityService, + albumMediaRepository, + albumApiRepository, + ); UserService userService = UserService(apiService, db, syncSerive, partnerService); AlbumService albumService = AlbumService( - apiService, userService, syncSerive, + entityService, albumRepository, assetRepository, - userRepository, backupAlbumRepository, albumMediaRepository, + albumApiRepository, ); BackupService backupService = BackupService( apiService, diff --git a/mobile/lib/services/entity.service.dart b/mobile/lib/services/entity.service.dart new file mode 100644 index 0000000000..8297620bc7 --- /dev/null +++ b/mobile/lib/services/entity.service.dart @@ -0,0 +1,52 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; +import 'package:immich_mobile/repositories/user.repository.dart'; + +class EntityService { + final IAssetRepository _assetRepository; + final IUserRepository _userRepository; + EntityService( + this._assetRepository, + this._userRepository, + ); + + Future fillAlbumWithDatabaseEntities(Album album) async { + final ownerId = album.ownerId; + if (ownerId != null) { + // replace owner with user from database + album.owner.value = await _userRepository.get(ownerId); + } + final thumbnailAssetId = + album.remoteThumbnailAssetId ?? album.thumbnail.value?.remoteId; + if (thumbnailAssetId != null) { + // set thumbnail with asset from database + album.thumbnail.value = + await _assetRepository.getByRemoteId(thumbnailAssetId); + } + if (album.remoteUsers.isNotEmpty) { + // replace all users with users from database + final users = await _userRepository + .getByIds(album.remoteUsers.map((user) => user.id).toList()); + album.sharedUsers.clear(); + album.sharedUsers.addAll(users); + } + if (album.remoteAssets.isNotEmpty) { + // replace all assets with assets from database + final assets = await _assetRepository + .getAllByRemoteId(album.remoteAssets.map((asset) => asset.remoteId!)); + album.assets.clear(); + album.assets.addAll(assets); + } + return album; + } +} + +final entityServiceProvider = Provider( + (ref) => EntityService( + ref.watch(assetRepositoryProvider), + ref.watch(userRepositoryProvider), + ), +); diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart index 84764b7641..c3f927fc93 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -8,9 +8,12 @@ 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_api.interface.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/album_api.repository.dart'; import 'package:immich_mobile/repositories/album_media.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'; @@ -18,24 +21,33 @@ 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'; -import 'package:openapi/api.dart'; final syncServiceProvider = Provider( (ref) => SyncService( ref.watch(dbProvider), ref.watch(hashServiceProvider), + ref.watch(entityServiceProvider), ref.watch(albumMediaRepositoryProvider), + ref.watch(albumApiRepositoryProvider), ), ); class SyncService { final Isar _db; final HashService _hashService; + final EntityService _entityService; final IAlbumMediaRepository _albumMediaRepository; + final IAlbumApiRepository _albumApiRepository; final AsyncMutex _lock = AsyncMutex(); final Logger _log = Logger('SyncService'); - SyncService(this._db, this._hashService, this._albumMediaRepository); + SyncService( + this._db, + this._hashService, + this._entityService, + this._albumMediaRepository, + this._albumApiRepository, + ); // public methods: @@ -65,11 +77,10 @@ class SyncService { /// Syncs remote albums to the database /// returns `true` if there were any changes Future syncRemoteAlbumsToDb( - List remote, { + List remote, { required bool isShared, - required FutureOr Function(AlbumResponseDto) loadDetails, }) => - _lock.run(() => _syncRemoteAlbumsToDb(remote, isShared, loadDetails)); + _lock.run(() => _syncRemoteAlbumsToDb(remote, isShared)); /// Syncs all device albums and their assets to the database /// Returns `true` if there were any changes @@ -289,11 +300,10 @@ class SyncService { /// Syncs remote albums to the database /// returns `true` if there were any changes Future _syncRemoteAlbumsToDb( - List remote, + List remoteAlbums, bool isShared, - FutureOr Function(AlbumResponseDto) loadDetails, ) async { - remote.sortBy((e) => e.id); + remoteAlbums.sortBy((e) => e.remoteId!); final baseQuery = _db.albums.where().remoteIdIsNotNull().filter(); final QueryBuilder query; @@ -310,14 +320,14 @@ class SyncService { final List existing = []; final bool changes = await diffSortedLists( - remote, + remoteAlbums, dbAlbums, - compare: (AlbumResponseDto a, Album b) => a.id.compareTo(b.remoteId!), - both: (AlbumResponseDto a, Album b) => - _syncRemoteAlbum(a, b, toDelete, existing, loadDetails), - onlyFirst: (AlbumResponseDto a) => - _addAlbumFromServer(a, existing, loadDetails), - onlySecond: (Album a) => _removeAlbumFromDb(a, toDelete), + compare: (remoteAlbum, dbAlbum) => + remoteAlbum.remoteId!.compareTo(dbAlbum.remoteId!), + both: (remoteAlbum, dbAlbum) => + _syncRemoteAlbum(remoteAlbum, dbAlbum, toDelete, existing), + onlyFirst: (remoteAlbum) => _addAlbumFromServer(remoteAlbum, existing), + onlySecond: (dbAlbum) => _removeAlbumFromDb(dbAlbum, toDelete), ); if (isShared && toDelete.isNotEmpty) { @@ -338,26 +348,22 @@ class SyncService { /// syncing changes from local back to server) /// accumulates Future _syncRemoteAlbum( - AlbumResponseDto dto, + Album dto, Album album, List deleteCandidates, List existing, - FutureOr Function(AlbumResponseDto) loadDetails, ) async { - if (!_hasAlbumResponseDtoChanged(dto, album)) { + if (!_hasRemoteAlbumChanged(dto, album)) { return false; } // loadDetails (/api/album/:id) will not include lastModifiedAssetTimestamp, // i.e. it will always be null. Save it here. final originalDto = dto; - dto = await loadDetails(dto); - if (dto.assetCount != dto.assets.length) { - return false; - } + dto = await _albumApiRepository.get(dto.remoteId!); final assetsInDb = await album.assets.filter().sortByOwnerId().thenByChecksum().findAll(); assert(assetsInDb.isSorted(Asset.compareByOwnerChecksum), "inDb unsorted!"); - final List assetsOnRemote = dto.getAssets(); + final List assetsOnRemote = dto.remoteAssets.toList(); assetsOnRemote.sort(Asset.compareByOwnerChecksum); final (toAdd, toUpdate, toUnlink) = _diffAssets( assetsOnRemote, @@ -368,15 +374,16 @@ class SyncService { // update shared users final List sharedUsers = album.sharedUsers.toList(growable: false); sharedUsers.sort((a, b) => a.id.compareTo(b.id)); - dto.albumUsers.sort((a, b) => a.user.id.compareTo(b.user.id)); + final List users = dto.remoteUsers.toList() + ..sort((a, b) => a.id.compareTo(b.id)); final List userIdsToAdd = []; final List usersToUnlink = []; diffSortedListsSync( - dto.albumUsers, + users, sharedUsers, - compare: (AlbumUserResponseDto a, User b) => a.user.id.compareTo(b.id), + compare: (User a, User b) => a.id.compareTo(b.id), both: (a, b) => false, - onlyFirst: (AlbumUserResponseDto a) => userIdsToAdd.add(a.user.id), + onlyFirst: (User a) => userIdsToAdd.add(a.id), onlySecond: (User a) => usersToUnlink.add(a), ); @@ -386,19 +393,19 @@ class SyncService { final assetsToLink = existingInDb + updated; final usersToLink = (await _db.users.getAllById(userIdsToAdd)).cast(); - album.name = dto.albumName; + album.name = dto.name; album.shared = dto.shared; album.createdAt = dto.createdAt; - album.modifiedAt = dto.updatedAt; + album.modifiedAt = dto.modifiedAt; album.startDate = dto.startDate; album.endDate = dto.endDate; album.lastModifiedAssetTimestamp = originalDto.lastModifiedAssetTimestamp; album.shared = dto.shared; - album.activityEnabled = dto.isActivityEnabled; - if (album.thumbnail.value?.remoteId != dto.albumThumbnailAssetId) { + album.activityEnabled = dto.activityEnabled; + if (album.thumbnail.value?.remoteId != dto.remoteThumbnailAssetId) { album.thumbnail.value = await _db.assets .where() - .remoteIdEqualTo(dto.albumThumbnailAssetId) + .remoteIdEqualTo(dto.remoteThumbnailAssetId) .findFirst(); } @@ -434,27 +441,26 @@ class SyncService { /// (shared) assets to the database beforehand /// accumulates assets already existing in the database Future _addAlbumFromServer( - AlbumResponseDto dto, + Album album, List existing, - FutureOr Function(AlbumResponseDto) loadDetails, ) async { - if (dto.assetCount != dto.assets.length) { - dto = await loadDetails(dto); + if (album.remoteAssetCount != album.remoteAssets.length) { + album = await _albumApiRepository.get(album.remoteId!); } - if (dto.assetCount == dto.assets.length) { + if (album.remoteAssetCount == album.remoteAssets.length) { // in case an album contains assets not yet present in local DB: // put missing album assets into local DB final (existingInDb, updated) = - await _linkWithExistingFromDb(dto.getAssets()); + await _linkWithExistingFromDb(album.remoteAssets.toList()); existing.addAll(existingInDb); await upsertAssetsWithExif(updated); - final Album a = await Album.remote(dto); - await _db.writeTxn(() => _db.albums.store(a)); + await _entityService.fillAlbumWithDatabaseEntities(album); + await _db.writeTxn(() => _db.albums.store(album)); } else { _log.warning( - "Failed to add album from server: assetCount ${dto.assetCount} != " - "asset array length ${dto.assets.length} for album ${dto.albumName}"); + "Failed to add album from server: assetCount ${album.remoteAssetCount} != " + "asset array length ${album.remoteAssets.length} for album ${album.name}"); } } @@ -919,17 +925,17 @@ class SyncService { } /// returns `true` if the albums differ on the surface -bool _hasAlbumResponseDtoChanged(AlbumResponseDto dto, Album a) { - return dto.assetCount != a.assetCount || - dto.albumName != a.name || - dto.albumThumbnailAssetId != a.thumbnail.value?.remoteId || - dto.shared != a.shared || - dto.albumUsers.length != a.sharedUsers.length || - !dto.updatedAt.isAtSameMomentAs(a.modifiedAt) || - !isAtSameMomentAs(dto.startDate, a.startDate) || - !isAtSameMomentAs(dto.endDate, a.endDate) || +bool _hasRemoteAlbumChanged(Album remoteAlbum, Album dbAlbum) { + return remoteAlbum.remoteAssetCount != dbAlbum.assetCount || + remoteAlbum.name != dbAlbum.name || + remoteAlbum.remoteThumbnailAssetId != dbAlbum.thumbnail.value?.remoteId || + remoteAlbum.shared != dbAlbum.shared || + remoteAlbum.remoteUsers.length != dbAlbum.sharedUsers.length || + !remoteAlbum.modifiedAt.isAtSameMomentAs(dbAlbum.modifiedAt) || + !isAtSameMomentAs(remoteAlbum.startDate, dbAlbum.startDate) || + !isAtSameMomentAs(remoteAlbum.endDate, dbAlbum.endDate) || !isAtSameMomentAs( - dto.lastModifiedAssetTimestamp, - a.lastModifiedAssetTimestamp, + remoteAlbum.lastModifiedAssetTimestamp, + dbAlbum.lastModifiedAssetTimestamp, ); } diff --git a/mobile/test/modules/shared/sync_service_test.dart b/mobile/test/modules/shared/sync_service_test.dart index 5ae0fb3c52..8520d89b43 100644 --- a/mobile/test/modules/shared/sync_service_test.dart +++ b/mobile/test/modules/shared/sync_service_test.dart @@ -39,8 +39,10 @@ void main() { group('Test SyncService grouped', () { late final Isar db; final MockHashService hs = MockHashService(); + final MockEntityService entityService = MockEntityService(); final MockAlbumMediaRepository albumMediaRepository = MockAlbumMediaRepository(); + final MockAlbumApiRepository albumApiRepository = MockAlbumApiRepository(); final owner = User( id: "1", updatedAt: DateTime.now(), @@ -48,6 +50,7 @@ void main() { name: "first last", isAdmin: false, ); + late SyncService s; setUpAll(() async { WidgetsFlutterBinding.ensureInitialized(); db = await TestUtils.initIsar(); @@ -68,9 +71,15 @@ void main() { db.assets.clearSync(); db.assets.putAllSync(initialAssets); }); + s = SyncService( + db, + hs, + entityService, + albumMediaRepository, + albumApiRepository, + ); }); test('test inserting existing assets', () async { - SyncService s = SyncService(db, hs, albumMediaRepository); final List remoteAssets = [ makeAsset(checksum: "a", remoteId: "0-1"), makeAsset(checksum: "b", remoteId: "2-1"), @@ -88,7 +97,6 @@ void main() { }); test('test inserting new assets', () async { - SyncService s = SyncService(db, hs, albumMediaRepository); final List remoteAssets = [ makeAsset(checksum: "a", remoteId: "0-1"), makeAsset(checksum: "b", remoteId: "2-1"), @@ -109,7 +117,6 @@ void main() { }); test('test syncing duplicate assets', () async { - SyncService s = SyncService(db, hs, albumMediaRepository); final List remoteAssets = [ makeAsset(checksum: "a", remoteId: "0-1"), makeAsset(checksum: "b", remoteId: "1-1"), @@ -157,7 +164,6 @@ void main() { }); test('test efficient sync', () async { - SyncService s = SyncService(db, hs, albumMediaRepository); final List toUpsert = [ makeAsset(checksum: "a", remoteId: "0-1"), // changed makeAsset(checksum: "f", remoteId: "0-2"), // new diff --git a/mobile/test/repository.mocks.dart b/mobile/test/repository.mocks.dart index 798f6f420a..6e220e85a2 100644 --- a/mobile/test/repository.mocks.dart +++ b/mobile/test/repository.mocks.dart @@ -1,4 +1,5 @@ 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/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset_media.interface.dart'; @@ -20,3 +21,5 @@ class MockAlbumMediaRepository extends Mock implements IAlbumMediaRepository {} class MockAssetMediaRepository extends Mock implements IAssetMediaRepository {} class MockFileMediaRepository extends Mock implements IFileMediaRepository {} + +class MockAlbumApiRepository extends Mock implements IAlbumApiRepository {} diff --git a/mobile/test/service.mocks.dart b/mobile/test/service.mocks.dart index bd5e8bee23..de49a98cc4 100644 --- a/mobile/test/service.mocks.dart +++ b/mobile/test/service.mocks.dart @@ -1,4 +1,5 @@ import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/hash.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; @@ -11,3 +12,5 @@ class MockUserService extends Mock implements UserService {} class MockSyncService extends Mock implements SyncService {} class MockHashService extends Mock implements HashService {} + +class MockEntityService extends Mock implements EntityService {} diff --git a/mobile/test/services/album.service_test.dart b/mobile/test/services/album.service_test.dart index 47f9c005a7..b2c2ec4427 100644 --- a/mobile/test/services/album.service_test.dart +++ b/mobile/test/services/album.service_test.dart @@ -3,39 +3,41 @@ import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:mocktail/mocktail.dart'; import '../fixtures/album.stub.dart'; +import '../fixtures/asset.stub.dart'; +import '../fixtures/user.stub.dart'; import '../repository.mocks.dart'; import '../service.mocks.dart'; void main() { late AlbumService sut; - late MockApiService apiService; late MockUserService userService; late MockSyncService syncService; + late MockEntityService entityService; late MockAlbumRepository albumRepository; late MockAssetRepository assetRepository; - late MockUserRepository userRepository; late MockBackupRepository backupRepository; late MockAlbumMediaRepository albumMediaRepository; + late MockAlbumApiRepository albumApiRepository; setUp(() { - apiService = MockApiService(); userService = MockUserService(); syncService = MockSyncService(); + entityService = MockEntityService(); albumRepository = MockAlbumRepository(); assetRepository = MockAssetRepository(); - userRepository = MockUserRepository(); backupRepository = MockBackupRepository(); albumMediaRepository = MockAlbumMediaRepository(); + albumApiRepository = MockAlbumApiRepository(); sut = AlbumService( - apiService, userService, syncService, + entityService, albumRepository, assetRepository, - userRepository, backupRepository, albumMediaRepository, + albumApiRepository, ); }); @@ -70,4 +72,125 @@ void main() { verifyNoMoreInteractions(syncService); }); }); + group('refreshRemoteAlbums', () { + test('isShared: false', () async { + when(() => userService.refreshUsers()).thenAnswer((_) async => true); + when(() => albumApiRepository.getAll(shared: null)) + .thenAnswer((_) async => [AlbumStub.oneAsset, AlbumStub.twoAsset]); + when( + () => syncService.syncRemoteAlbumsToDb( + [AlbumStub.oneAsset, AlbumStub.twoAsset], + isShared: false, + ), + ).thenAnswer((_) async => true); + final result = await sut.refreshRemoteAlbums(isShared: false); + expect(result, true); + verify(() => userService.refreshUsers()).called(1); + verify(() => albumApiRepository.getAll(shared: null)).called(1); + verify( + () => syncService.syncRemoteAlbumsToDb( + [AlbumStub.oneAsset, AlbumStub.twoAsset], + isShared: false, + ), + ).called(1); + verifyNoMoreInteractions(userService); + verifyNoMoreInteractions(albumApiRepository); + verifyNoMoreInteractions(syncService); + }); + }); + + group('createAlbum', () { + test('shared with assets', () async { + when( + () => albumApiRepository.create( + "name", + assetIds: any(named: "assetIds"), + sharedUserIds: any(named: "sharedUserIds"), + ), + ).thenAnswer((_) async => AlbumStub.oneAsset); + + when( + () => entityService.fillAlbumWithDatabaseEntities(AlbumStub.oneAsset), + ).thenAnswer((_) async => AlbumStub.oneAsset); + + when( + () => albumRepository.create(AlbumStub.oneAsset), + ).thenAnswer((_) async => AlbumStub.twoAsset); + + final result = + await sut.createAlbum("name", [AssetStub.image1], [UserStub.user1]); + expect(result, AlbumStub.twoAsset); + verify( + () => albumApiRepository.create( + "name", + assetIds: [AssetStub.image1.remoteId!], + sharedUserIds: [UserStub.user1.id], + ), + ).called(1); + verify( + () => entityService.fillAlbumWithDatabaseEntities(AlbumStub.oneAsset), + ).called(1); + }); + }); + + group('addAdditionalAssetToAlbum', () { + test('one added, one duplicate', () async { + when( + () => albumApiRepository.addAssets(AlbumStub.oneAsset.remoteId!, any()), + ).thenAnswer( + (_) async => ( + added: [AssetStub.image2.remoteId!], + duplicates: [AssetStub.image1.remoteId!] + ), + ); + when( + () => albumRepository.getById(AlbumStub.oneAsset.id), + ).thenAnswer((_) async => AlbumStub.oneAsset); + when( + () => albumRepository.addAssets(AlbumStub.oneAsset, [AssetStub.image2]), + ).thenAnswer((_) async {}); + when( + () => albumRepository.removeAssets(AlbumStub.oneAsset, []), + ).thenAnswer((_) async {}); + when( + () => albumRepository.recalculateMetadata(AlbumStub.oneAsset), + ).thenAnswer((_) async => AlbumStub.oneAsset); + when( + () => albumRepository.update(AlbumStub.oneAsset), + ).thenAnswer((_) async => AlbumStub.oneAsset); + + final result = await sut.addAdditionalAssetToAlbum( + [AssetStub.image1, AssetStub.image2], + AlbumStub.oneAsset, + ); + + expect(result != null, true); + expect(result!.alreadyInAlbum, [AssetStub.image1.remoteId!]); + expect(result.successfullyAdded, 1); + }); + }); + + group('addAdditionalUserToAlbum', () { + test('one added', () async { + when( + () => + albumApiRepository.addUsers(AlbumStub.emptyAlbum.remoteId!, any()), + ).thenAnswer( + (_) async => AlbumStub.sharedWithUser, + ); + when( + () => entityService + .fillAlbumWithDatabaseEntities(AlbumStub.sharedWithUser), + ).thenAnswer((_) async => AlbumStub.sharedWithUser); + when( + () => albumRepository.update(AlbumStub.sharedWithUser), + ).thenAnswer((_) async => AlbumStub.sharedWithUser); + + final result = await sut.addAdditionalUserToAlbum( + [UserStub.user2.id], + AlbumStub.emptyAlbum, + ); + expect(result, true); + }); + }); } diff --git a/mobile/test/services/entity.service_test.dart b/mobile/test/services/entity.service_test.dart new file mode 100644 index 0000000000..8c8b49a7e0 --- /dev/null +++ b/mobile/test/services/entity.service_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/services/entity.service.dart'; +import 'package:mocktail/mocktail.dart'; +import '../fixtures/asset.stub.dart'; +import '../fixtures/user.stub.dart'; +import '../repository.mocks.dart'; + +void main() { + late EntityService sut; + late MockAssetRepository assetRepository; + late MockUserRepository userRepository; + + setUp(() { + assetRepository = MockAssetRepository(); + userRepository = MockUserRepository(); + sut = EntityService(assetRepository, userRepository); + }); + + group('fillAlbumWithDatabaseEntities', () { + test('remote album with owner, thumbnail, sharedUsers and assets', + () async { + final Album album = Album( + name: "album-with-two-assets-and-two-users", + localId: "album-with-two-assets-and-two-users-local", + remoteId: "album-with-two-assets-and-two-users-remote", + createdAt: DateTime(2001), + modifiedAt: DateTime(2010), + shared: true, + activityEnabled: true, + startDate: DateTime(2019), + endDate: DateTime(2020), + ) + ..remoteThumbnailAssetId = AssetStub.image1.remoteId + ..assets.addAll([AssetStub.image1, AssetStub.image1]) + ..owner.value = UserStub.user1 + ..sharedUsers.addAll([UserStub.admin, UserStub.admin]); + + when(() => userRepository.get(album.ownerId!)) + .thenAnswer((_) async => UserStub.admin); + + when(() => assetRepository.getByRemoteId(AssetStub.image1.remoteId!)) + .thenAnswer((_) async => AssetStub.image1); + + when(() => userRepository.getByIds(any())) + .thenAnswer((_) async => [UserStub.user1, UserStub.user2]); + + when(() => assetRepository.getAllByRemoteId(any())) + .thenAnswer((_) async => [AssetStub.image1, AssetStub.image2]); + + await sut.fillAlbumWithDatabaseEntities(album); + expect(album.owner.value, UserStub.admin); + expect(album.thumbnail.value, AssetStub.image1); + expect(album.remoteUsers.toSet(), {UserStub.user1, UserStub.user2}); + expect(album.remoteAssets.toSet(), {AssetStub.image1, AssetStub.image2}); + }); + + test('remote album without any info', () async { + makeEmptyAlbum() => Album( + name: "album-without-info", + localId: "album-without-info-local", + remoteId: "album-without-info-remote", + createdAt: DateTime(2001), + modifiedAt: DateTime(2010), + shared: false, + activityEnabled: false, + ); + + final album = makeEmptyAlbum(); + await sut.fillAlbumWithDatabaseEntities(album); + verifyNoMoreInteractions(assetRepository); + verifyNoMoreInteractions(userRepository); + expect(album, makeEmptyAlbum()); + }); + }); +}