diff --git a/mobile/lib/interfaces/user_api.interface.dart b/mobile/lib/domain/interfaces/user_api.repository.dart similarity index 59% rename from mobile/lib/interfaces/user_api.interface.dart rename to mobile/lib/domain/interfaces/user_api.repository.dart index ebbbac8291..9889654a52 100644 --- a/mobile/lib/interfaces/user_api.interface.dart +++ b/mobile/lib/domain/interfaces/user_api.repository.dart @@ -3,8 +3,12 @@ import 'dart:typed_data'; import 'package:immich_mobile/domain/models/user.model.dart'; abstract interface class IUserApiRepository { + Future getMyUser(); + Future> getAll(); - Future<({String profileImagePath})> createProfileImage({ + + /// Saves the [data] in the server and uses it as the current users profile image + Future createProfileImage({ required String name, required Uint8List data, }); diff --git a/mobile/lib/domain/services/user.service.dart b/mobile/lib/domain/services/user.service.dart new file mode 100644 index 0000000000..48a6f5777d --- /dev/null +++ b/mobile/lib/domain/services/user.service.dart @@ -0,0 +1,64 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:immich_mobile/domain/interfaces/user.interface.dart'; +import 'package:immich_mobile/domain/interfaces/user_api.repository.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/models/user.model.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; +import 'package:logging/logging.dart'; + +class UserService { + final Logger _log = Logger("UserService"); + final IUserRepository _userRepository; + final IUserApiRepository _userApiRepository; + final StoreService _storeService; + + UserService({ + required IUserRepository userRepository, + required IUserApiRepository userApiRepository, + required StoreService storeService, + }) : _userRepository = userRepository, + _userApiRepository = userApiRepository, + _storeService = storeService; + + UserDto getMyUser() { + return _storeService.get(StoreKey.currentUser); + } + + UserDto? tryGetMyUser() { + return _storeService.tryGet(StoreKey.currentUser); + } + + Stream watchMyUser() { + return _storeService.watch(StoreKey.currentUser); + } + + Future refreshMyUser() async { + final user = await _userApiRepository.getMyUser(); + if (user == null) return null; + await _storeService.put(StoreKey.currentUser, user); + await _userRepository.update(user); + return user; + } + + Future createProfileImage(String name, Uint8List image) async { + try { + return await _userApiRepository.createProfileImage( + name: name, + data: image, + ); + } catch (e) { + _log.warning("Failed to upload profile image", e); + return null; + } + } + + Future> getAll() async { + return await _userRepository.getAll(); + } + + Future deleteAll() { + return _userRepository.deleteAll(); + } +} diff --git a/mobile/lib/infrastructure/repositories/api.repository.dart b/mobile/lib/infrastructure/repositories/api.repository.dart new file mode 100644 index 0000000000..56c64c5512 --- /dev/null +++ b/mobile/lib/infrastructure/repositories/api.repository.dart @@ -0,0 +1,11 @@ +import 'package:immich_mobile/constants/errors.dart'; + +class ApiRepository { + const ApiRepository(); + + Future checkNull(Future future) async { + final response = await future; + if (response == null) throw NoResponseDtoError(); + return response; + } +} diff --git a/mobile/lib/repositories/user_api.repository.dart b/mobile/lib/infrastructure/repositories/user_api.repository.dart similarity index 55% rename from mobile/lib/repositories/user_api.repository.dart rename to mobile/lib/infrastructure/repositories/user_api.repository.dart index 1b72186c7e..2623e8c264 100644 --- a/mobile/lib/repositories/user_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/user_api.repository.dart @@ -1,41 +1,41 @@ import 'dart:typed_data'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart'; +import 'package:immich_mobile/domain/interfaces/user_api.repository.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; +import 'package:immich_mobile/infrastructure/repositories/api.repository.dart'; import 'package:immich_mobile/infrastructure/utils/user.converter.dart'; -import 'package:immich_mobile/interfaces/user_api.interface.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/repositories/api.repository.dart'; import 'package:openapi/api.dart'; -final userApiRepositoryProvider = Provider( - (ref) => UserApiRepository( - ref.watch(apiServiceProvider).usersApi, - ), -); - class UserApiRepository extends ApiRepository implements IUserApiRepository { final UsersApi _api; + const UserApiRepository(this._api); - UserApiRepository(this._api); + @override + Future getMyUser() async { + final (adminDto, preferenceDto) = + await (_api.getMyUser(), _api.getMyPreferences()).wait; + if (adminDto == null) return null; + + return UserConverter.fromAdminDto(adminDto, preferenceDto); + } + + @override + Future createProfileImage({ + required String name, + required Uint8List data, + }) async { + final res = await checkNull( + _api.createProfileImage( + MultipartFile.fromBytes('file', data, filename: name), + ), + ); + return res.profileImagePath; + } @override Future> getAll() async { final dto = await checkNull(_api.searchUsers()); return dto.map(UserConverter.fromSimpleUserDto).toList(); } - - @override - Future<({String profileImagePath})> createProfileImage({ - required String name, - required Uint8List data, - }) async { - final response = await checkNull( - _api.createProfileImage( - MultipartFile.fromBytes('file', data, filename: name), - ), - ); - return (profileImagePath: response.profileImagePath); - } } diff --git a/mobile/lib/providers/album/suggested_shared_users.provider.dart b/mobile/lib/providers/album/suggested_shared_users.provider.dart index 75b33a9fbe..3c8dcb6733 100644 --- a/mobile/lib/providers/album/suggested_shared_users.provider.dart +++ b/mobile/lib/providers/album/suggested_shared_users.provider.dart @@ -1,7 +1,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; +import 'package:immich_mobile/domain/services/user.service.dart'; +import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/services/user.service.dart'; final otherUsersProvider = FutureProvider.autoDispose>((ref) async { diff --git a/mobile/lib/providers/asset.provider.dart b/mobile/lib/providers/asset.provider.dart index 53fe0338ce..fef0f7b420 100644 --- a/mobile/lib/providers/asset.provider.dart +++ b/mobile/lib/providers/asset.provider.dart @@ -1,15 +1,16 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/user.service.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; import 'package:immich_mobile/providers/memory.provider.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/asset.service.dart'; import 'package:immich_mobile/services/etag.service.dart'; import 'package:immich_mobile/services/exif.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; -import 'package:immich_mobile/services/user.service.dart'; import 'package:logging/logging.dart'; final assetProvider = StateNotifierProvider((ref) { @@ -59,7 +60,7 @@ class AssetNotifier extends StateNotifier { await clearAllAssets(); log.info("Manual refresh requested, cleared assets and albums from db"); } - final users = await _userService.getUsersFromServer(); + final users = await _syncService.getUsersFromServer(); bool changedUsers = false; if (users != null) { changedUsers = await _syncService.syncUsersFromServer(users); @@ -86,7 +87,7 @@ class AssetNotifier extends StateNotifier { _assetService.clearTable(), _exifService.clearTable(), _albumService.clearTable(), - _userService.clearTable(), + _userService.deleteAll(), _etagService.clearTable(), ]); } diff --git a/mobile/lib/providers/infrastructure/user.provider.dart b/mobile/lib/providers/infrastructure/user.provider.dart index 6070cc6c35..e6eaac06b6 100644 --- a/mobile/lib/providers/infrastructure/user.provider.dart +++ b/mobile/lib/providers/infrastructure/user.provider.dart @@ -1,7 +1,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/interfaces/user.interface.dart'; +import 'package:immich_mobile/domain/interfaces/user_api.repository.dart'; +import 'package:immich_mobile/domain/services/user.service.dart'; import 'package:immich_mobile/infrastructure/repositories/user.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/store.provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'user.provider.g.dart'; @@ -9,3 +14,14 @@ part 'user.provider.g.dart'; @Riverpod(keepAlive: true) IUserRepository userRepository(Ref ref) => IsarUserRepository(ref.watch(isarProvider)); + +@Riverpod(keepAlive: true) +IUserApiRepository userApiRepository(Ref ref) => + UserApiRepository(ref.watch(apiServiceProvider).usersApi); + +@Riverpod(keepAlive: true) +UserService userService(Ref ref) => UserService( + userRepository: ref.watch(userRepositoryProvider), + userApiRepository: ref.watch(userApiRepositoryProvider), + storeService: ref.watch(storeServiceProvider), + ); diff --git a/mobile/lib/providers/infrastructure/user.provider.g.dart b/mobile/lib/providers/infrastructure/user.provider.g.dart index 4ff3481412..fdb4223ee8 100644 --- a/mobile/lib/providers/infrastructure/user.provider.g.dart +++ b/mobile/lib/providers/infrastructure/user.provider.g.dart @@ -23,5 +23,38 @@ final userRepositoryProvider = Provider.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef UserRepositoryRef = ProviderRef; +String _$userApiRepositoryHash() => r'6b19f2c99fb83162a5ceb91adb8589eaae01bc92'; + +/// See also [userApiRepository]. +@ProviderFor(userApiRepository) +final userApiRepositoryProvider = Provider.internal( + userApiRepository, + name: r'userApiRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$userApiRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef UserApiRepositoryRef = ProviderRef; +String _$userServiceHash() => r'4a0873357b7115b4d6bfa8e89b847c0b74ce0d93'; + +/// See also [userService]. +@ProviderFor(userService) +final userServiceProvider = Provider.internal( + userService, + name: r'userServiceProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$userServiceHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef UserServiceRef = ProviderRef; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/mobile/lib/providers/upload_profile_image.provider.dart b/mobile/lib/providers/upload_profile_image.provider.dart index 83f30e5018..10aa645654 100644 --- a/mobile/lib/providers/upload_profile_image.provider.dart +++ b/mobile/lib/providers/upload_profile_image.provider.dart @@ -3,8 +3,8 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image_picker/image_picker.dart'; - -import 'package:immich_mobile/services/user.service.dart'; +import 'package:immich_mobile/domain/services/user.service.dart'; +import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; enum UploadProfileStatus { idle, @@ -72,7 +72,7 @@ class UploadProfileImageState { class UploadProfileImageNotifier extends StateNotifier { - UploadProfileImageNotifier(this._userSErvice) + UploadProfileImageNotifier(this._userService) : super( UploadProfileImageState( profileImagePath: '', @@ -80,18 +80,21 @@ class UploadProfileImageNotifier ), ); - final UserService _userSErvice; + final UserService _userService; Future upload(XFile file) async { state = state.copyWith(status: UploadProfileStatus.loading); - var res = await _userSErvice.uploadProfileImage(file); + var profileImagePath = await _userService.createProfileImage( + file.name, + await file.readAsBytes(), + ); - if (res != null) { + if (profileImagePath != null) { debugPrint("Successfully upload profile image"); state = state.copyWith( status: UploadProfileStatus.success, - profileImagePath: res.profileImagePath, + profileImagePath: profileImagePath, ); return true; } diff --git a/mobile/lib/services/album.service.dart b/mobile/lib/services/album.service.dart index 057bec307c..d3fe7674d5 100644 --- a/mobile/lib/services/album.service.dart +++ b/mobile/lib/services/album.service.dart @@ -28,12 +28,10 @@ import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/backup.repository.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'; final albumServiceProvider = Provider( (ref) => AlbumService( - ref.watch(userServiceProvider), ref.watch(syncServiceProvider), ref.watch(entityServiceProvider), ref.watch(albumRepositoryProvider), @@ -45,7 +43,6 @@ final albumServiceProvider = Provider( ); class AlbumService { - final UserService _userService; final SyncService _syncService; final EntityService _entityService; final IAlbumRepository _albumRepository; @@ -58,7 +55,6 @@ class AlbumService { Completer _remoteCompleter = Completer()..complete(false); AlbumService( - this._userService, this._syncService, this._entityService, this._albumRepository, @@ -171,7 +167,7 @@ class AlbumService { final Stopwatch sw = Stopwatch()..start(); bool changes = false; try { - final users = await _userService.getUsersFromServer(); + final users = await _syncService.getUsersFromServer(); if (users != null) { await _syncService.syncUsersFromServer(users); } diff --git a/mobile/lib/services/asset.service.dart b/mobile/lib/services/asset.service.dart index f61fdfbaeb..ff3e908ac3 100644 --- a/mobile/lib/services/asset.service.dart +++ b/mobile/lib/services/asset.service.dart @@ -20,7 +20,8 @@ import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/infrastructure/exif.provider.dart'; import 'package:immich_mobile/providers/infrastructure/store.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/user.provider.dart' + hide userServiceProvider; import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/asset_api.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; @@ -30,7 +31,6 @@ 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:logging/logging.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:openapi/api.dart'; @@ -45,7 +45,6 @@ final assetServiceProvider = Provider( ref.watch(backupAlbumRepositoryProvider), ref.watch(apiServiceProvider), ref.watch(syncServiceProvider), - ref.watch(userServiceProvider), ref.watch(backupServiceProvider), ref.watch(albumServiceProvider), ref.watch(storeServiceProvider), @@ -62,7 +61,6 @@ class AssetService { final IBackupAlbumRepository _backupRepository; final ApiService _apiService; final SyncService _syncService; - final UserService _userService; final BackupService _backupService; final AlbumService _albumService; final StoreService _storeService; @@ -78,7 +76,6 @@ class AssetService { this._backupRepository, this._apiService, this._syncService, - this._userService, this._backupService, this._albumService, this._storeService, @@ -97,7 +94,6 @@ class AssetService { users: syncedUsers, getChangedAssets: _getRemoteAssetChanges, loadAssets: _getRemoteAssets, - refreshUsers: _userService.getUsersFromServer, ); debugPrint("refreshRemoteAssets full took ${sw.elapsedMilliseconds}ms"); return changes; diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index ad281e4a0b..d69f282103 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -13,12 +13,14 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/interfaces/exif.interface.dart'; import 'package:immich_mobile/domain/interfaces/user.interface.dart'; +import 'package:immich_mobile/domain/interfaces/user_api.repository.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/exif.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/user.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart'; import 'package:immich_mobile/interfaces/backup_album.interface.dart'; import 'package:immich_mobile/interfaces/partner.interface.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; @@ -39,7 +41,6 @@ import 'package:immich_mobile/repositories/network.repository.dart'; import 'package:immich_mobile/repositories/partner.repository.dart'; import 'package:immich_mobile/repositories/partner_api.repository.dart'; import 'package:immich_mobile/repositories/permission.repository.dart'; -import 'package:immich_mobile/repositories/user_api.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'; @@ -50,7 +51,6 @@ import 'package:immich_mobile/services/hash.service.dart'; import 'package:immich_mobile/services/localization.service.dart'; import 'package:immich_mobile/services/network.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; -import 'package:immich_mobile/services/user.service.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/diff.dart'; @@ -390,7 +390,7 @@ class BackgroundService { FileMediaRepository fileMediaRepository = FileMediaRepository(); AssetMediaRepository assetMediaRepository = AssetMediaRepository(); IUserRepository userRepository = IsarUserRepository(db); - UserApiRepository userApiRepository = + IUserApiRepository userApiRepository = UserApiRepository(apiService.usersApi); AlbumApiRepository albumApiRepository = AlbumApiRepository(apiService.albumsApi); @@ -413,14 +413,10 @@ class BackgroundService { userRepository, StoreService.I, eTagRepository, - ); - UserService userService = UserService( partnerApiRepository, userApiRepository, - userRepository, ); AlbumService albumService = AlbumService( - userService, syncSerive, entityService, albumRepository, diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart index b3edb17c89..a598941f81 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -4,6 +4,7 @@ import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/interfaces/exif.interface.dart'; import 'package:immich_mobile/domain/interfaces/user.interface.dart'; +import 'package:immich_mobile/domain/interfaces/user_api.repository.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; @@ -17,6 +18,7 @@ import 'package:immich_mobile/interfaces/album_media.interface.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/etag.interface.dart'; import 'package:immich_mobile/interfaces/partner.interface.dart'; +import 'package:immich_mobile/interfaces/partner_api.interface.dart'; import 'package:immich_mobile/providers/infrastructure/exif.provider.dart'; import 'package:immich_mobile/providers/infrastructure/store.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; @@ -26,6 +28,7 @@ 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/partner.repository.dart'; +import 'package:immich_mobile/repositories/partner_api.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'; @@ -46,6 +49,8 @@ final syncServiceProvider = Provider( ref.watch(userRepositoryProvider), ref.watch(storeServiceProvider), ref.watch(etagRepositoryProvider), + ref.watch(partnerApiRepositoryProvider), + ref.watch(userApiRepositoryProvider), ), ); @@ -61,6 +66,8 @@ class SyncService { final IPartnerRepository _partnerRepository; final StoreService _storeService; final IETagRepository _eTagRepository; + final IPartnerApiRepository _partnerApiRepository; + final IUserApiRepository _userApiRepository; final AsyncMutex _lock = AsyncMutex(); final Logger _log = Logger('SyncService'); @@ -76,6 +83,8 @@ class SyncService { this._userRepository, this._storeService, this._eTagRepository, + this._partnerApiRepository, + this._userApiRepository, ); // public methods: @@ -95,12 +104,11 @@ class SyncService { ) getChangedAssets, required FutureOr?> Function(UserDto user, DateTime until) loadAssets, - required FutureOr?> Function() refreshUsers, }) => _lock.run( () async => await _syncRemoteAssetChanges(users, getChangedAssets) ?? - await _syncRemoteAssetsFull(refreshUsers, loadAssets), + await _syncRemoteAssetsFull(getUsersFromServer, loadAssets), ); /// Syncs remote albums to the database @@ -853,6 +861,61 @@ class SyncService { return false; } } + + Future?> getUsersFromServer() async { + List? users; + try { + users = await _userApiRepository.getAll(); + } catch (e) { + _log.warning("Failed to fetch users", e); + users = null; + } + final List sharedBy = + await _partnerApiRepository.getAll(Direction.sharedByMe); + final List sharedWith = + await _partnerApiRepository.getAll(Direction.sharedWithMe); + + if (users == null) { + _log.warning("Failed to refresh users"); + return null; + } + + users.sortBy((u) => u.uid); + sharedBy.sortBy((u) => u.uid); + sharedWith.sortBy((u) => u.uid); + + final updatedSharedBy = []; + + diffSortedListsSync( + users, + sharedBy, + compare: (UserDto a, UserDto b) => a.uid.compareTo(b.uid), + both: (UserDto a, UserDto b) { + updatedSharedBy.add(a.copyWith(isPartnerSharedBy: true)); + return true; + }, + onlyFirst: (UserDto a) => updatedSharedBy.add(a), + onlySecond: (UserDto b) => updatedSharedBy.add(b), + ); + + final updatedSharedWith = []; + + diffSortedListsSync( + updatedSharedBy, + sharedWith, + compare: (UserDto a, UserDto b) => a.uid.compareTo(b.uid), + both: (UserDto a, UserDto b) { + updatedSharedWith.add( + a.copyWith(inTimeline: b.inTimeline, isPartnerSharedWith: true), + ); + return true; + }, + onlyFirst: (UserDto a) => updatedSharedWith.add(a), + onlySecond: (UserDto b) => updatedSharedWith.add(b), + ); + + return updatedSharedWith; + } } /// Returns a triple(toAdd, toUpdate, toRemove) diff --git a/mobile/lib/services/user.service.dart b/mobile/lib/services/user.service.dart deleted file mode 100644 index 3cb2022aa3..0000000000 --- a/mobile/lib/services/user.service.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:image_picker/image_picker.dart'; -import 'package:immich_mobile/domain/interfaces/user.interface.dart'; -import 'package:immich_mobile/domain/models/user.model.dart'; -import 'package:immich_mobile/interfaces/partner_api.interface.dart'; -import 'package:immich_mobile/interfaces/user_api.interface.dart'; -import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; -import 'package:immich_mobile/repositories/partner_api.repository.dart'; -import 'package:immich_mobile/repositories/user_api.repository.dart'; -import 'package:immich_mobile/utils/diff.dart'; -import 'package:logging/logging.dart'; - -final userServiceProvider = Provider( - (ref) => UserService( - ref.watch(partnerApiRepositoryProvider), - ref.watch(userApiRepositoryProvider), - ref.watch(userRepositoryProvider), - ), -); - -class UserService { - final IPartnerApiRepository _partnerApiRepository; - final IUserApiRepository _userApiRepository; - final IUserRepository _userRepository; - final Logger _log = Logger("UserService"); - - UserService( - this._partnerApiRepository, - this._userApiRepository, - this._userRepository, - ); - - Future<({String profileImagePath})?> uploadProfileImage(XFile image) async { - try { - return await _userApiRepository.createProfileImage( - name: image.name, - data: await image.readAsBytes(), - ); - } catch (e) { - _log.warning("Failed to upload profile image", e); - return null; - } - } - - Future> getAll() async { - return await _userRepository.getAll(); - } - - Future?> getUsersFromServer() async { - List? users; - try { - users = await _userApiRepository.getAll(); - } catch (e) { - _log.warning("Failed to fetch users", e); - users = null; - } - final List sharedBy = - await _partnerApiRepository.getAll(Direction.sharedByMe); - final List sharedWith = - await _partnerApiRepository.getAll(Direction.sharedWithMe); - - if (users == null) { - _log.warning("Failed to refresh users"); - return null; - } - - users.sortBy((u) => u.uid); - sharedBy.sortBy((u) => u.uid); - sharedWith.sortBy((u) => u.uid); - - final updatedSharedBy = []; - - diffSortedListsSync( - users, - sharedBy, - compare: (UserDto a, UserDto b) => a.uid.compareTo(b.uid), - both: (UserDto a, UserDto b) { - updatedSharedBy.add(a.copyWith(isPartnerSharedBy: true)); - return true; - }, - onlyFirst: (UserDto a) => updatedSharedBy.add(a), - onlySecond: (UserDto b) => updatedSharedBy.add(b), - ); - - final updatedSharedWith = []; - - diffSortedListsSync( - updatedSharedBy, - sharedWith, - compare: (UserDto a, UserDto b) => a.uid.compareTo(b.uid), - both: (UserDto a, UserDto b) { - updatedSharedWith.add( - a.copyWith(inTimeline: b.inTimeline, isPartnerSharedWith: true), - ); - return true; - }, - onlyFirst: (UserDto a) => updatedSharedWith.add(a), - onlySecond: (UserDto b) => updatedSharedWith.add(b), - ); - - return updatedSharedWith; - } - - Future clearTable() { - return _userRepository.deleteAll(); - } -} diff --git a/mobile/lib/utils/provider_utils.dart b/mobile/lib/utils/provider_utils.dart index 9a2a441a3e..bf18d24213 100644 --- a/mobile/lib/utils/provider_utils.dart +++ b/mobile/lib/utils/provider_utils.dart @@ -1,11 +1,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; import 'package:immich_mobile/repositories/activity_api.repository.dart'; import 'package:immich_mobile/repositories/album_api.repository.dart'; import 'package:immich_mobile/repositories/asset_api.repository.dart'; import 'package:immich_mobile/repositories/partner_api.repository.dart'; import 'package:immich_mobile/repositories/person_api.repository.dart'; -import 'package:immich_mobile/repositories/user_api.repository.dart'; +import 'package:immich_mobile/repositories/timeline.repository.dart'; void invalidateAllApiRepositoryProviders(WidgetRef ref) { ref.invalidate(userApiRepositoryProvider); @@ -14,5 +14,5 @@ void invalidateAllApiRepositoryProviders(WidgetRef ref) { ref.invalidate(albumApiRepositoryProvider); ref.invalidate(personApiRepositoryProvider); ref.invalidate(assetApiRepositoryProvider); - ref.invalidate(timelineUsersIdsProvider); + ref.invalidate(timelineRepositoryProvider); } diff --git a/mobile/test/domain/service.mock.dart b/mobile/test/domain/service.mock.dart new file mode 100644 index 0000000000..53a173fc28 --- /dev/null +++ b/mobile/test/domain/service.mock.dart @@ -0,0 +1,7 @@ +import 'package:immich_mobile/domain/services/store.service.dart'; +import 'package:immich_mobile/domain/services/user.service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockStoreService extends Mock implements StoreService {} + +class MockUserService extends Mock implements UserService {} diff --git a/mobile/test/domain/services/user_service_test.dart b/mobile/test/domain/services/user_service_test.dart new file mode 100644 index 0000000000..512f5cb4a2 --- /dev/null +++ b/mobile/test/domain/services/user_service_test.dart @@ -0,0 +1,133 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/interfaces/user.interface.dart'; +import 'package:immich_mobile/domain/interfaces/user_api.repository.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; +import 'package:immich_mobile/domain/services/user.service.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../fixtures/user.stub.dart'; +import '../../infrastructure/repository.mock.dart'; +import '../service.mock.dart'; + +void main() { + late UserService sut; + late IUserRepository mockUserRepo; + late IUserApiRepository mockUserApiRepo; + late StoreService mockStoreService; + + setUp(() { + mockUserRepo = MockUserRepository(); + mockUserApiRepo = MockUserApiRepository(); + mockStoreService = MockStoreService(); + sut = UserService( + userRepository: mockUserRepo, + userApiRepository: mockUserApiRepo, + storeService: mockStoreService, + ); + }); + + group('getMyUser', () { + test('should return user from store', () { + when(() => mockStoreService.get(StoreKey.currentUser)) + .thenReturn(UserStub.admin); + final result = sut.getMyUser(); + expect(result, UserStub.admin); + }); + + test('should handle user not found scenario', () { + when(() => mockStoreService.get(StoreKey.currentUser)) + .thenThrow(Exception('User not found')); + + expect(() => sut.getMyUser(), throwsA(isA())); + }); + }); + + group('tryGetMyUser', () { + test('should return user from store', () { + when(() => mockStoreService.tryGet(StoreKey.currentUser)) + .thenReturn(UserStub.admin); + final result = sut.tryGetMyUser(); + expect(result, UserStub.admin); + }); + + test('should return null if user not found', () { + when(() => mockStoreService.tryGet(StoreKey.currentUser)) + .thenReturn(null); + final result = sut.tryGetMyUser(); + expect(result, isNull); + }); + }); + + group('watchMyUser', () { + test('should return user stream from store', () { + when(() => mockStoreService.watch(StoreKey.currentUser)) + .thenAnswer((_) => Stream.value(UserStub.admin)); + final result = sut.watchMyUser(); + expect(result, emits(UserStub.admin)); + }); + + test('should return an empty stream if user not found', () { + when(() => mockStoreService.watch(StoreKey.currentUser)) + .thenAnswer((_) => const Stream.empty()); + final result = sut.watchMyUser(); + expect(result, emitsInOrder([])); + }); + }); + + group('refreshMyUser', () { + test('should return user from api and store it', () async { + when(() => mockUserApiRepo.getMyUser()) + .thenAnswer((_) async => UserStub.admin); + when(() => mockStoreService.put(StoreKey.currentUser, UserStub.admin)) + .thenAnswer((_) async => true); + when(() => mockUserRepo.update(UserStub.admin)) + .thenAnswer((_) async => UserStub.admin); + + final result = await sut.refreshMyUser(); + verify(() => mockStoreService.put(StoreKey.currentUser, UserStub.admin)) + .called(1); + verify(() => mockUserRepo.update(UserStub.admin)).called(1); + expect(result, UserStub.admin); + }); + + test('should return null if user not found', () async { + when(() => mockUserApiRepo.getMyUser()).thenAnswer((_) async => null); + + final result = await sut.refreshMyUser(); + verifyNever( + () => mockStoreService.put(StoreKey.currentUser, UserStub.admin), + ); + verifyNever(() => mockUserRepo.update(UserStub.admin)); + expect(result, isNull); + }); + }); + + group('createProfileImage', () { + test('should return profile image path', () async { + when( + () => mockUserApiRepo.createProfileImage( + name: 'profile.jpg', + data: Uint8List(0), + ), + ).thenAnswer((_) async => 'profile.jpg'); + + final result = await sut.createProfileImage('profile.jpg', Uint8List(0)); + expect(result, 'profile.jpg'); + }); + + test('should return null if profile image creation fails', () async { + when( + () => mockUserApiRepo.createProfileImage( + name: 'profile.jpg', + data: Uint8List(0), + ), + ).thenThrow(Exception('Failed to create profile image')); + + final result = await sut.createProfileImage('profile.jpg', Uint8List(0)); + expect(result, isNull); + }); + }); +} diff --git a/mobile/test/infrastructure/repository.mock.dart b/mobile/test/infrastructure/repository.mock.dart index 3e33fdac0a..ccae209e4a 100644 --- a/mobile/test/infrastructure/repository.mock.dart +++ b/mobile/test/infrastructure/repository.mock.dart @@ -1,7 +1,14 @@ import 'package:immich_mobile/domain/interfaces/log.interface.dart'; import 'package:immich_mobile/domain/interfaces/store.interface.dart'; +import 'package:immich_mobile/domain/interfaces/user.interface.dart'; +import 'package:immich_mobile/domain/interfaces/user_api.repository.dart'; import 'package:mocktail/mocktail.dart'; class MockStoreRepository extends Mock implements IStoreRepository {} class MockLogRepository extends Mock implements ILogRepository {} + +class MockUserRepository extends Mock implements IUserRepository {} + +// API Repos +class MockUserApiRepository extends Mock implements IUserApiRepository {} diff --git a/mobile/test/modules/shared/sync_service_test.dart b/mobile/test/modules/shared/sync_service_test.dart index 577b5a0389..f74c815997 100644 --- a/mobile/test/modules/shared/sync_service_test.dart +++ b/mobile/test/modules/shared/sync_service_test.dart @@ -11,9 +11,11 @@ import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/log.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/partner_api.interface.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:mocktail/mocktail.dart'; +import '../../infrastructure/repository.mock.dart'; import '../../repository.mocks.dart'; import '../../service.mocks.dart'; import '../../test_utils.dart'; @@ -56,6 +58,9 @@ void main() { final MockAlbumMediaRepository albumMediaRepository = MockAlbumMediaRepository(); final MockAlbumApiRepository albumApiRepository = MockAlbumApiRepository(); + final MockPartnerApiRepository partnerApiRepository = + MockPartnerApiRepository(); + final MockUserApiRepository userApiRepository = MockUserApiRepository(); final MockPartnerRepository partnerRepository = MockPartnerRepository(); final owner = UserDto( @@ -98,6 +103,8 @@ void main() { userRepository, StoreService.I, eTagRepository, + partnerApiRepository, + userApiRepository, ); when(() => eTagRepository.get(owner.id)) .thenAnswer((_) async => ETag(id: owner.uid, time: DateTime.now())); @@ -125,6 +132,10 @@ void main() { when(() => assetRepository.transaction(any())).thenAnswer( (call) => (call.positionalArguments.first as Function).call(), ); + when(() => userApiRepository.getAll()).thenAnswer((_) async => [owner]); + registerFallbackValue(Direction.sharedByMe); + when(() => partnerApiRepository.getAll(any())) + .thenAnswer((_) async => []); }); test('test inserting existing assets', () async { final List remoteAssets = [ @@ -136,7 +147,6 @@ void main() { users: [owner], getChangedAssets: _failDiff, loadAssets: (u, d) => remoteAssets, - refreshUsers: () => [owner], ); expect(c1, isFalse); verifyNever(() => assetRepository.updateAll(any())); @@ -155,7 +165,6 @@ void main() { users: [owner], getChangedAssets: _failDiff, loadAssets: (u, d) => remoteAssets, - refreshUsers: () => [owner], ); expect(c1, isTrue); final updatedAsset = initialAssets[3].updatedCopy(remoteAssets[3]); @@ -178,7 +187,6 @@ void main() { users: [owner], getChangedAssets: _failDiff, loadAssets: (u, d) => remoteAssets, - refreshUsers: () => [owner], ); expect(c1, isTrue); when( @@ -191,7 +199,6 @@ void main() { users: [owner], getChangedAssets: _failDiff, loadAssets: (u, d) => remoteAssets, - refreshUsers: () => [owner], ); expect(c2, isFalse); final currentState = [...remoteAssets]; @@ -206,7 +213,6 @@ void main() { users: [owner], getChangedAssets: _failDiff, loadAssets: (u, d) => remoteAssets, - refreshUsers: () => [owner], ); expect(c3, isTrue); remoteAssets.add(makeAsset(checksum: "k", remoteId: "2-1e")); @@ -215,7 +221,6 @@ void main() { users: [owner], getChangedAssets: _failDiff, loadAssets: (u, d) => remoteAssets, - refreshUsers: () => [owner], ); expect(c4, isTrue); }); @@ -246,7 +251,6 @@ void main() { users: [owner], getChangedAssets: (user, since) async => (toUpsert, toDelete), loadAssets: (user, date) => throw Exception(), - refreshUsers: () => throw Exception(), ); expect(c, isTrue); verify(() => assetRepository.updateAll(expected)); diff --git a/mobile/test/repository.mocks.dart b/mobile/test/repository.mocks.dart index 8672297f1f..e4e99ffcb8 100644 --- a/mobile/test/repository.mocks.dart +++ b/mobile/test/repository.mocks.dart @@ -1,5 +1,4 @@ import 'package:immich_mobile/domain/interfaces/exif.interface.dart'; -import 'package:immich_mobile/domain/interfaces/user.interface.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'; @@ -10,6 +9,7 @@ import 'package:immich_mobile/interfaces/auth_api.interface.dart'; import 'package:immich_mobile/interfaces/backup_album.interface.dart'; import 'package:immich_mobile/interfaces/etag.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart'; +import 'package:immich_mobile/interfaces/partner_api.interface.dart'; import 'package:immich_mobile/interfaces/partner.interface.dart'; import 'package:mocktail/mocktail.dart'; @@ -17,8 +17,6 @@ class MockAlbumRepository extends Mock implements IAlbumRepository {} class MockAssetRepository extends Mock implements IAssetRepository {} -class MockUserRepository extends Mock implements IUserRepository {} - class MockBackupRepository extends Mock implements IBackupAlbumRepository {} class MockExifInfoRepository extends Mock implements IExifInfoRepository {} @@ -37,4 +35,6 @@ class MockAuthApiRepository extends Mock implements IAuthApiRepository {} class MockAuthRepository extends Mock implements IAuthRepository {} +class MockPartnerApiRepository extends Mock implements IPartnerApiRepository {} + class MockPartnerRepository extends Mock implements IPartnerRepository {} diff --git a/mobile/test/service.mocks.dart b/mobile/test/service.mocks.dart index cc9d657e9e..33c325b105 100644 --- a/mobile/test/service.mocks.dart +++ b/mobile/test/service.mocks.dart @@ -3,14 +3,11 @@ import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/hash.service.dart'; import 'package:immich_mobile/services/network.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; -import 'package:immich_mobile/services/user.service.dart'; import 'package:mocktail/mocktail.dart'; import 'package:openapi/api.dart'; class MockApiService extends Mock implements ApiService {} -class MockUserService extends Mock implements UserService {} - class MockSyncService extends Mock implements SyncService {} class MockHashService extends Mock implements HashService {} diff --git a/mobile/test/services/album.service_test.dart b/mobile/test/services/album.service_test.dart index 5460faaa23..993456ad99 100644 --- a/mobile/test/services/album.service_test.dart +++ b/mobile/test/services/album.service_test.dart @@ -3,6 +3,7 @@ import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:mocktail/mocktail.dart'; +import '../domain/service.mock.dart'; import '../fixtures/album.stub.dart'; import '../fixtures/asset.stub.dart'; import '../fixtures/user.stub.dart'; @@ -38,7 +39,6 @@ void main() { ); sut = AlbumService( - userService, syncService, entityService, albumRepository, @@ -84,7 +84,7 @@ void main() { group('refreshRemoteAlbums', () { test('is working', () async { - when(() => userService.getUsersFromServer()).thenAnswer((_) async => []); + when(() => syncService.getUsersFromServer()).thenAnswer((_) async => []); when(() => syncService.syncUsersFromServer(any())) .thenAnswer((_) async => true); when(() => albumApiRepository.getAll(shared: true)) @@ -102,7 +102,7 @@ void main() { ).thenAnswer((_) async => true); final result = await sut.refreshRemoteAlbums(); expect(result, true); - verify(() => userService.getUsersFromServer()).called(1); + verify(() => syncService.getUsersFromServer()).called(1); verify(() => syncService.syncUsersFromServer([])).called(1); verify(() => albumApiRepository.getAll(shared: true)).called(1); verify(() => albumApiRepository.getAll(shared: null)).called(1); diff --git a/mobile/test/services/entity.service_test.dart b/mobile/test/services/entity.service_test.dart index 4d2e4f75dc..fd99a4faee 100644 --- a/mobile/test/services/entity.service_test.dart +++ b/mobile/test/services/entity.service_test.dart @@ -6,6 +6,7 @@ import 'package:mocktail/mocktail.dart'; import '../fixtures/asset.stub.dart'; import '../fixtures/user.stub.dart'; +import '../infrastructure/repository.mock.dart'; import '../repository.mocks.dart'; void main() {