mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	feature(mobile): sync assets, albums & users to local database on device (#1759)
* feature(mobile): sync assets, albums & users to local database on device * try to fix tests * move DB sync operations to new SyncService * clear db on user logout * fix reason for endless loading timeline * fix error when deleting album * fix thumbnail of device albums * add a few comments * fix Hive box not open in album service when loading local assets * adjust tests to int IDs * fix bug: show all albums when Recent is selected * update generated api * reworked Recents album isAll handling * guard against wrongly interleaved sync operations * fix: timeline asset ordering (sort asset state by created at) * fix: sort assets in albums by created at
This commit is contained in:
		
				
					committed by
					
						 GitHub
						GitHub
					
				
			
			
				
	
			
			
			
						parent
						
							8f11529a75
						
					
				
				
					commit
					8708867c1c
				
			| @@ -142,6 +142,7 @@ | ||||
|   "library_page_sharing": "Sharing", | ||||
|   "library_page_sort_created": "Most recently created", | ||||
|   "library_page_sort_title": "Album title", | ||||
|   "library_page_device_albums": "Albums on Device", | ||||
|   "login_form_button_text": "Login", | ||||
|   "login_form_email_hint": "youremail@email.com", | ||||
|   "login_form_endpoint_hint": "http://your-server-ip:port/api", | ||||
|   | ||||
| @@ -19,8 +19,12 @@ import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.pr | ||||
| import 'package:immich_mobile/modules/settings/providers/notification_permission.provider.dart'; | ||||
| import 'package:immich_mobile/routing/router.dart'; | ||||
| import 'package:immich_mobile/routing/tab_navigation_observer.dart'; | ||||
| import 'package:immich_mobile/shared/models/album.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/shared/models/exif_info.dart'; | ||||
| import 'package:immich_mobile/shared/models/immich_logger_message.model.dart'; | ||||
| import 'package:immich_mobile/shared/models/store.dart'; | ||||
| import 'package:immich_mobile/shared/models/user.dart'; | ||||
| import 'package:immich_mobile/shared/providers/app_state.provider.dart'; | ||||
| import 'package:immich_mobile/shared/providers/asset.provider.dart'; | ||||
| import 'package:immich_mobile/shared/providers/db.provider.dart'; | ||||
| @@ -42,6 +46,7 @@ void main() async { | ||||
|   await initApp(); | ||||
|   final db = await loadDb(); | ||||
|   await migrateHiveToStoreIfNecessary(); | ||||
|   await migrateJsonCacheIfNecessary(); | ||||
|   runApp(getMainWidget(db)); | ||||
| } | ||||
|  | ||||
| @@ -93,7 +98,13 @@ Future<void> initApp() async { | ||||
| Future<Isar> loadDb() async { | ||||
|   final dir = await getApplicationDocumentsDirectory(); | ||||
|   Isar db = await Isar.open( | ||||
|     [StoreValueSchema], | ||||
|     [ | ||||
|       StoreValueSchema, | ||||
|       ExifInfoSchema, | ||||
|       AssetSchema, | ||||
|       AlbumSchema, | ||||
|       UserSchema, | ||||
|     ], | ||||
|     directory: dir.path, | ||||
|     maxSizeMiB: 256, | ||||
|   ); | ||||
|   | ||||
| @@ -1,37 +1,43 @@ | ||||
| import 'package:collection/collection.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/modules/album/services/album.service.dart'; | ||||
| import 'package:immich_mobile/modules/album/services/album_cache.service.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/shared/models/album.dart'; | ||||
| import 'package:immich_mobile/shared/models/store.dart'; | ||||
| import 'package:immich_mobile/shared/models/user.dart'; | ||||
| import 'package:immich_mobile/shared/providers/db.provider.dart'; | ||||
| import 'package:isar/isar.dart'; | ||||
|  | ||||
| class AlbumNotifier extends StateNotifier<List<Album>> { | ||||
|   AlbumNotifier(this._albumService, this._albumCacheService) : super([]); | ||||
|   AlbumNotifier(this._albumService, this._db) : super([]); | ||||
|   final AlbumService _albumService; | ||||
|   final AlbumCacheService _albumCacheService; | ||||
|  | ||||
|   void _cacheState() { | ||||
|     _albumCacheService.put(state); | ||||
|   } | ||||
|   final Isar _db; | ||||
|  | ||||
|   Future<void> getAllAlbums() async { | ||||
|     if (await _albumCacheService.isValid() && state.isEmpty) { | ||||
|       final albums = await _albumCacheService.get(); | ||||
|       if (albums != null) { | ||||
|     final User me = Store.get(StoreKey.currentUser); | ||||
|     List<Album> albums = await _db.albums | ||||
|         .filter() | ||||
|         .owner((q) => q.isarIdEqualTo(me.isarId)) | ||||
|         .findAll(); | ||||
|     if (!const ListEquality().equals(albums, state)) { | ||||
|       state = albums; | ||||
|     } | ||||
|     await Future.wait([ | ||||
|       _albumService.refreshDeviceAlbums(), | ||||
|       _albumService.refreshRemoteAlbums(isShared: false), | ||||
|     ]); | ||||
|     albums = await _db.albums | ||||
|         .filter() | ||||
|         .owner((q) => q.isarIdEqualTo(me.isarId)) | ||||
|         .findAll(); | ||||
|     if (!const ListEquality().equals(albums, state)) { | ||||
|       state = albums; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|     final albums = await _albumService.getAlbums(isShared: false); | ||||
|  | ||||
|     if (albums != null) { | ||||
|       state = albums; | ||||
|       _cacheState(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   void deleteAlbum(Album album) { | ||||
|   Future<bool> deleteAlbum(Album album) async { | ||||
|     state = state.where((a) => a.id != album.id).toList(); | ||||
|     _cacheState(); | ||||
|     return _albumService.deleteAlbum(album); | ||||
|   } | ||||
|  | ||||
|   Future<Album?> createAlbum( | ||||
| @@ -39,20 +45,16 @@ class AlbumNotifier extends StateNotifier<List<Album>> { | ||||
|     Set<Asset> assets, | ||||
|   ) async { | ||||
|     Album? album = await _albumService.createAlbum(albumTitle, assets, []); | ||||
|  | ||||
|     if (album != null) { | ||||
|       state = [...state, album]; | ||||
|       _cacheState(); | ||||
|  | ||||
|       return album; | ||||
|     } | ||||
|     return null; | ||||
|     return album; | ||||
|   } | ||||
| } | ||||
|  | ||||
| final albumProvider = StateNotifierProvider<AlbumNotifier, List<Album>>((ref) { | ||||
|   return AlbumNotifier( | ||||
|     ref.watch(albumServiceProvider), | ||||
|     ref.watch(albumCacheServiceProvider), | ||||
|     ref.watch(dbProvider), | ||||
|   ); | ||||
| }); | ||||
|   | ||||
| @@ -58,7 +58,7 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> { | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void addNewAssets(List<Asset> assets) { | ||||
|   void addNewAssets(Iterable<Asset> assets) { | ||||
|     state = state.copyWith( | ||||
|       selectedNewAssetsForAlbum: { | ||||
|         ...state.selectedNewAssetsForAlbum, | ||||
|   | ||||
| @@ -1,21 +1,18 @@ | ||||
| import 'package:collection/collection.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/modules/album/services/album.service.dart'; | ||||
| import 'package:immich_mobile/modules/album/services/album_cache.service.dart'; | ||||
| import 'package:immich_mobile/shared/models/album.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/shared/models/user.dart'; | ||||
| import 'package:immich_mobile/shared/providers/db.provider.dart'; | ||||
| import 'package:isar/isar.dart'; | ||||
|  | ||||
| class SharedAlbumNotifier extends StateNotifier<List<Album>> { | ||||
|   SharedAlbumNotifier(this._albumService, this._sharedAlbumCacheService) | ||||
|       : super([]); | ||||
|   SharedAlbumNotifier(this._albumService, this._db) : super([]); | ||||
|  | ||||
|   final AlbumService _albumService; | ||||
|   final SharedAlbumCacheService _sharedAlbumCacheService; | ||||
|  | ||||
|   void _cacheState() { | ||||
|     _sharedAlbumCacheService.put(state); | ||||
|   } | ||||
|   final Isar _db; | ||||
|  | ||||
|   Future<Album?> createSharedAlbum( | ||||
|     String albumName, | ||||
| @@ -23,7 +20,7 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> { | ||||
|     Iterable<User> sharedUsers, | ||||
|   ) async { | ||||
|     try { | ||||
|       var newAlbum = await _albumService.createAlbum( | ||||
|       final Album? newAlbum = await _albumService.createAlbum( | ||||
|         albumName, | ||||
|         assets, | ||||
|         sharedUsers, | ||||
| @@ -31,61 +28,44 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> { | ||||
|  | ||||
|       if (newAlbum != null) { | ||||
|         state = [...state, newAlbum]; | ||||
|         _cacheState(); | ||||
|       } | ||||
|  | ||||
|         return newAlbum; | ||||
|       } | ||||
|     } catch (e) { | ||||
|       debugPrint("Error createSharedAlbum  ${e.toString()}"); | ||||
|  | ||||
|       return null; | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   Future<void> getAllSharedAlbums() async { | ||||
|     if (await _sharedAlbumCacheService.isValid() && state.isEmpty) { | ||||
|       final albums = await _sharedAlbumCacheService.get(); | ||||
|       if (albums != null) { | ||||
|     var albums = await _db.albums.filter().sharedEqualTo(true).findAll(); | ||||
|     if (!const ListEquality().equals(albums, state)) { | ||||
|       state = albums; | ||||
|     } | ||||
|     await _albumService.refreshRemoteAlbums(isShared: true); | ||||
|     albums = await _db.albums.filter().sharedEqualTo(true).findAll(); | ||||
|     if (!const ListEquality().equals(albums, state)) { | ||||
|       state = albums; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|     List<Album>? sharedAlbums = await _albumService.getAlbums(isShared: true); | ||||
|  | ||||
|     if (sharedAlbums != null) { | ||||
|       state = sharedAlbums; | ||||
|       _cacheState(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   void deleteAlbum(Album album) { | ||||
|   Future<bool> deleteAlbum(Album album) { | ||||
|     state = state.where((a) => a.id != album.id).toList(); | ||||
|     _cacheState(); | ||||
|     return _albumService.deleteAlbum(album); | ||||
|   } | ||||
|  | ||||
|   Future<bool> leaveAlbum(Album album) async { | ||||
|     var res = await _albumService.leaveAlbum(album); | ||||
|  | ||||
|     if (res) { | ||||
|       state = state.where((a) => a.id != album.id).toList(); | ||||
|       _cacheState(); | ||||
|       await deleteAlbum(album); | ||||
|       return true; | ||||
|     } else { | ||||
|       return false; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<bool> removeAssetFromAlbum( | ||||
|     Album album, | ||||
|     Iterable<Asset> assets, | ||||
|   ) async { | ||||
|     var res = await _albumService.removeAssetFromAlbum(album, assets); | ||||
|  | ||||
|     if (res) { | ||||
|       return true; | ||||
|     } else { | ||||
|       return false; | ||||
|     } | ||||
|   Future<bool> removeAssetFromAlbum(Album album, Iterable<Asset> assets) { | ||||
|     return _albumService.removeAssetFromAlbum(album, assets); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @@ -93,13 +73,15 @@ final sharedAlbumProvider = | ||||
|     StateNotifierProvider<SharedAlbumNotifier, List<Album>>((ref) { | ||||
|   return SharedAlbumNotifier( | ||||
|     ref.watch(albumServiceProvider), | ||||
|     ref.watch(sharedAlbumCacheServiceProvider), | ||||
|     ref.watch(dbProvider), | ||||
|   ); | ||||
| }); | ||||
|  | ||||
| final sharedAlbumDetailProvider = | ||||
|     FutureProvider.autoDispose.family<Album?, String>((ref, albumId) async { | ||||
|     FutureProvider.autoDispose.family<Album?, int>((ref, albumId) async { | ||||
|   final AlbumService sharedAlbumService = ref.watch(albumServiceProvider); | ||||
|  | ||||
|   return await sharedAlbumService.getAlbumDetail(albumId); | ||||
|   final Album? a = await sharedAlbumService.getAlbumDetail(albumId); | ||||
|   await a?.loadSortedAssets(); | ||||
|   return a; | ||||
| }); | ||||
|   | ||||
| @@ -3,8 +3,8 @@ import 'package:immich_mobile/shared/models/user.dart'; | ||||
| import 'package:immich_mobile/shared/services/user.service.dart'; | ||||
|  | ||||
| final suggestedSharedUsersProvider = | ||||
|     FutureProvider.autoDispose<List<User>>((ref) async { | ||||
|     FutureProvider.autoDispose<List<User>>((ref) { | ||||
|   UserService userService = ref.watch(userServiceProvider); | ||||
|  | ||||
|   return await userService.getAllUsers(isAll: false) ?? []; | ||||
|   return userService.getUsersInDb(); | ||||
| }); | ||||
|   | ||||
| @@ -1,34 +1,129 @@ | ||||
| import 'dart:async'; | ||||
|  | ||||
| import 'package:collection/collection.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:hive_flutter/hive_flutter.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/constants/hive_box.dart'; | ||||
| import 'package:immich_mobile/modules/backup/background_service/background.service.dart'; | ||||
| import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart'; | ||||
| import 'package:immich_mobile/shared/models/album.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/shared/models/store.dart'; | ||||
| import 'package:immich_mobile/shared/models/user.dart'; | ||||
| import 'package:immich_mobile/shared/providers/api.provider.dart'; | ||||
| import 'package:immich_mobile/shared/providers/db.provider.dart'; | ||||
| import 'package:immich_mobile/shared/services/api.service.dart'; | ||||
| import 'package:immich_mobile/shared/services/sync.service.dart'; | ||||
| import 'package:immich_mobile/shared/services/user.service.dart'; | ||||
| import 'package:isar/isar.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:photo_manager/photo_manager.dart'; | ||||
|  | ||||
| final albumServiceProvider = Provider( | ||||
|   (ref) => AlbumService( | ||||
|     ref.watch(apiServiceProvider), | ||||
|     ref.watch(userServiceProvider), | ||||
|     ref.watch(backgroundServiceProvider), | ||||
|     ref.watch(syncServiceProvider), | ||||
|     ref.watch(dbProvider), | ||||
|   ), | ||||
| ); | ||||
|  | ||||
| class AlbumService { | ||||
|   final ApiService _apiService; | ||||
|   final UserService _userService; | ||||
|   final BackgroundService _backgroundService; | ||||
|   final SyncService _syncService; | ||||
|   final Isar _db; | ||||
|   Completer<bool> _localCompleter = Completer()..complete(false); | ||||
|   Completer<bool> _remoteCompleter = Completer()..complete(false); | ||||
|  | ||||
|   AlbumService(this._apiService); | ||||
|   AlbumService( | ||||
|     this._apiService, | ||||
|     this._userService, | ||||
|     this._backgroundService, | ||||
|     this._syncService, | ||||
|     this._db, | ||||
|   ); | ||||
|  | ||||
|   Future<List<Album>?> getAlbums({required bool isShared}) async { | ||||
|     try { | ||||
|       final dto = await _apiService.albumApi | ||||
|           .getAllAlbums(shared: isShared ? isShared : null); | ||||
|       return dto?.map(Album.remote).toList(); | ||||
|     } catch (e) { | ||||
|       debugPrint("Error getAllSharedAlbum  ${e.toString()}"); | ||||
|       return null; | ||||
|   /// Checks all selected device albums for changes of albums and their assets | ||||
|   /// Updates the local database and returns `true` if there were any changes | ||||
|   Future<bool> refreshDeviceAlbums() async { | ||||
|     if (!_localCompleter.isCompleted) { | ||||
|       // guard against concurrent calls | ||||
|       return _localCompleter.future; | ||||
|     } | ||||
|     _localCompleter = Completer(); | ||||
|     final Stopwatch sw = Stopwatch()..start(); | ||||
|     bool changes = false; | ||||
|     try { | ||||
|       if (!await _backgroundService.hasAccess) { | ||||
|         return false; | ||||
|       } | ||||
|       final HiveBackupAlbums? infos = | ||||
|           (await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox)) | ||||
|               .get(backupInfoKey); | ||||
|       if (infos == null) { | ||||
|         return false; | ||||
|       } | ||||
|       final List<AssetPathEntity> onDevice = | ||||
|           await PhotoManager.getAssetPathList( | ||||
|         hasAll: true, | ||||
|         filterOption: FilterOptionGroup(containsPathModified: true), | ||||
|       ); | ||||
|       if (infos.excludedAlbumsIds.isNotEmpty) { | ||||
|         // remove all excluded albums | ||||
|         onDevice.removeWhere((e) => infos.excludedAlbumsIds.contains(e.id)); | ||||
|       } | ||||
|       final hasAll = infos.selectedAlbumIds | ||||
|           .map((id) => onDevice.firstWhereOrNull((a) => a.id == id)) | ||||
|           .whereNotNull() | ||||
|           .any((a) => a.isAll); | ||||
|       if (hasAll) { | ||||
|         // remove the virtual "Recents" album and keep and individual albums | ||||
|         onDevice.removeWhere((e) => e.isAll); | ||||
|       } else { | ||||
|         // keep only the explicitly selected albums | ||||
|         onDevice.removeWhere((e) => !infos.selectedAlbumIds.contains(e.id)); | ||||
|       } | ||||
|       changes = await _syncService.syncLocalAlbumAssetsToDb(onDevice); | ||||
|     } finally { | ||||
|       _localCompleter.complete(changes); | ||||
|     } | ||||
|     debugPrint("refreshDeviceAlbums took ${sw.elapsedMilliseconds}ms"); | ||||
|     return changes; | ||||
|   } | ||||
|  | ||||
|   /// Checks remote albums (owned if `isShared` is false) for changes, | ||||
|   /// updates the local database and returns `true` if there were any changes | ||||
|   Future<bool> refreshRemoteAlbums({required bool isShared}) async { | ||||
|     if (!_remoteCompleter.isCompleted) { | ||||
|       // guard against concurrent calls | ||||
|       return _remoteCompleter.future; | ||||
|     } | ||||
|     _remoteCompleter = Completer(); | ||||
|     final Stopwatch sw = Stopwatch()..start(); | ||||
|     bool changes = false; | ||||
|     try { | ||||
|       await _userService.refreshUsers(); | ||||
|       final List<AlbumResponseDto>? serverAlbums = await _apiService.albumApi | ||||
|           .getAllAlbums(shared: isShared ? true : null); | ||||
|       if (serverAlbums == null) { | ||||
|         return false; | ||||
|       } | ||||
|       changes = await _syncService.syncRemoteAlbumsToDb( | ||||
|         serverAlbums, | ||||
|         isShared: isShared, | ||||
|         loadDetails: (dto) async => dto.assetCount == dto.assets.length | ||||
|             ? dto | ||||
|             : (await _apiService.albumApi.getAlbumInfo(dto.id)) ?? dto, | ||||
|       ); | ||||
|     } finally { | ||||
|       _remoteCompleter.complete(changes); | ||||
|     } | ||||
|     debugPrint("refreshRemoteAlbums took ${sw.elapsedMilliseconds}ms"); | ||||
|     return changes; | ||||
|   } | ||||
|  | ||||
|   Future<Album?> createAlbum( | ||||
| @@ -37,56 +132,51 @@ class AlbumService { | ||||
|     Iterable<User> sharedUsers = const [], | ||||
|   ]) async { | ||||
|     try { | ||||
|       final dto = await _apiService.albumApi.createAlbum( | ||||
|       AlbumResponseDto? remote = await _apiService.albumApi.createAlbum( | ||||
|         CreateAlbumDto( | ||||
|           albumName: albumName, | ||||
|           assetIds: assets.map((asset) => asset.remoteId!).toList(), | ||||
|           sharedWithUserIds: sharedUsers.map((e) => e.id).toList(), | ||||
|         ), | ||||
|       ); | ||||
|       return dto != null ? Album.remote(dto) : null; | ||||
|       if (remote != null) { | ||||
|         Album album = await Album.remote(remote); | ||||
|         await _db.writeTxn(() => _db.albums.store(album)); | ||||
|         return album; | ||||
|       } | ||||
|     } catch (e) { | ||||
|       debugPrint("Error createSharedAlbum  ${e.toString()}"); | ||||
|       return null; | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   /* | ||||
|    * Creates names like Untitled, Untitled (1), Untitled (2), ... | ||||
|    */ | ||||
|   String _getNextAlbumName(List<Album>? albums) { | ||||
|   Future<String> _getNextAlbumName() async { | ||||
|     const baseName = "Untitled"; | ||||
|  | ||||
|     if (albums != null) { | ||||
|       for (int round = 0; round < albums.length; round++) { | ||||
|     for (int round = 0;; round++) { | ||||
|       final proposedName = "$baseName${round == 0 ? "" : " ($round)"}"; | ||||
|  | ||||
|         if (albums.where((a) => a.name == proposedName).isEmpty) { | ||||
|       if (null == | ||||
|           await _db.albums.filter().nameEqualTo(proposedName).findFirst()) { | ||||
|         return proposedName; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|     return baseName; | ||||
|   } | ||||
|  | ||||
|   Future<Album?> createAlbumWithGeneratedName( | ||||
|     Iterable<Asset> assets, | ||||
|   ) async { | ||||
|     return createAlbum( | ||||
|       _getNextAlbumName(await getAlbums(isShared: false)), | ||||
|       await _getNextAlbumName(), | ||||
|       assets, | ||||
|       [], | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Future<Album?> getAlbumDetail(String albumId) async { | ||||
|     try { | ||||
|       final dto = await _apiService.albumApi.getAlbumInfo(albumId); | ||||
|       return dto != null ? Album.remote(dto) : null; | ||||
|     } catch (e) { | ||||
|       debugPrint('Error [getAlbumDetail] ${e.toString()}'); | ||||
|       return null; | ||||
|     } | ||||
|   Future<Album?> getAlbumDetail(int albumId) { | ||||
|     return _db.albums.get(albumId); | ||||
|   } | ||||
|  | ||||
|   Future<AddAssetsResponseDto?> addAdditionalAssetToAlbum( | ||||
| @@ -98,6 +188,10 @@ class AlbumService { | ||||
|         album.remoteId!, | ||||
|         AddAssetsDto(assetIds: assets.map((asset) => asset.remoteId!).toList()), | ||||
|       ); | ||||
|       if (result != null && result.successfullyAdded > 0) { | ||||
|         album.assets.addAll(assets); | ||||
|         await _db.writeTxn(() => album.assets.save()); | ||||
|       } | ||||
|       return result; | ||||
|     } catch (e) { | ||||
|       debugPrint("Error addAdditionalAssetToAlbum  ${e.toString()}"); | ||||
| @@ -110,26 +204,53 @@ class AlbumService { | ||||
|     Album album, | ||||
|   ) async { | ||||
|     try { | ||||
|       var result = await _apiService.albumApi.addUsersToAlbum( | ||||
|       final result = await _apiService.albumApi.addUsersToAlbum( | ||||
|         album.remoteId!, | ||||
|         AddUsersDto(sharedUserIds: sharedUserIds), | ||||
|       ); | ||||
|  | ||||
|       return result != null; | ||||
|       if (result != null) { | ||||
|         album.sharedUsers | ||||
|             .addAll((await _db.users.getAllById(sharedUserIds)).cast()); | ||||
|         await _db.writeTxn(() => album.sharedUsers.save()); | ||||
|         return true; | ||||
|       } | ||||
|     } catch (e) { | ||||
|       debugPrint("Error addAdditionalUserToAlbum  ${e.toString()}"); | ||||
|       return false; | ||||
|     } | ||||
|     return false; | ||||
|   } | ||||
|  | ||||
|   Future<bool> deleteAlbum(Album album) async { | ||||
|     try { | ||||
|       final userId = Store.get<User>(StoreKey.currentUser)!.isarId; | ||||
|       if (album.owner.value?.isarId == userId) { | ||||
|         await _apiService.albumApi.deleteAlbum(album.remoteId!); | ||||
|       } | ||||
|       if (album.shared) { | ||||
|         final foreignAssets = | ||||
|             await album.assets.filter().not().ownerIdEqualTo(userId).findAll(); | ||||
|         await _db.writeTxn(() => _db.albums.delete(album.id)); | ||||
|         final List<Album> albums = | ||||
|             await _db.albums.filter().sharedEqualTo(true).findAll(); | ||||
|         final List<Asset> existing = []; | ||||
|         for (Album a in albums) { | ||||
|           existing.addAll( | ||||
|             await a.assets.filter().not().ownerIdEqualTo(userId).findAll(), | ||||
|           ); | ||||
|         } | ||||
|         final List<int> idsToRemove = | ||||
|             _syncService.sharedAssetsToRemove(foreignAssets, existing); | ||||
|         if (idsToRemove.isNotEmpty) { | ||||
|           await _db.writeTxn(() => _db.assets.deleteAll(idsToRemove)); | ||||
|         } | ||||
|       } else { | ||||
|         await _db.writeTxn(() => _db.albums.delete(album.id)); | ||||
|       } | ||||
|       return true; | ||||
|     } catch (e) { | ||||
|       debugPrint("Error deleteAlbum  ${e.toString()}"); | ||||
|       return false; | ||||
|     } | ||||
|     return false; | ||||
|   } | ||||
|  | ||||
|   Future<bool> leaveAlbum(Album album) async { | ||||
| @@ -153,6 +274,8 @@ class AlbumService { | ||||
|           assetIds: assets.map((e) => e.remoteId!).toList(growable: false), | ||||
|         ), | ||||
|       ); | ||||
|       album.assets.removeAll(assets); | ||||
|       await _db.writeTxn(() => album.assets.update(unlink: assets)); | ||||
|  | ||||
|       return true; | ||||
|     } catch (e) { | ||||
| @@ -173,6 +296,7 @@ class AlbumService { | ||||
|         ), | ||||
|       ); | ||||
|       album.name = newAlbumTitle; | ||||
|       await _db.writeTxn(() => _db.albums.put(album)); | ||||
|  | ||||
|       return true; | ||||
|     } catch (e) { | ||||
|   | ||||
| @@ -1,46 +1,23 @@ | ||||
| import 'package:collection/collection.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/shared/models/album.dart'; | ||||
| import 'package:immich_mobile/shared/services/json_cache.dart'; | ||||
|  | ||||
| class BaseAlbumCacheService extends JsonCache<List<Album>> { | ||||
|   BaseAlbumCacheService(super.cacheFileName); | ||||
| @Deprecated("only kept to remove its files after migration") | ||||
| class _BaseAlbumCacheService extends JsonCache<List<Album>> { | ||||
|   _BaseAlbumCacheService(super.cacheFileName); | ||||
|  | ||||
|   @override | ||||
|   void put(List<Album> data) { | ||||
|     putRawData(data.map((e) => e.toJson()).toList()); | ||||
|   } | ||||
|   void put(List<Album> data) {} | ||||
|  | ||||
|   @override | ||||
|   Future<List<Album>?> get() async { | ||||
|     try { | ||||
|       final mapList = await readRawData() as List<dynamic>; | ||||
|  | ||||
|       final responseData = | ||||
|           mapList.map((e) => Album.fromJson(e)).whereNotNull().toList(); | ||||
|  | ||||
|       return responseData; | ||||
|     } catch (e) { | ||||
|       await invalidate(); | ||||
|       debugPrint(e.toString()); | ||||
|       return null; | ||||
|     } | ||||
|   } | ||||
|   Future<List<Album>?> get() => Future.value(null); | ||||
| } | ||||
|  | ||||
| class AlbumCacheService extends BaseAlbumCacheService { | ||||
| @Deprecated("only kept to remove its files after migration") | ||||
| class AlbumCacheService extends _BaseAlbumCacheService { | ||||
|   AlbumCacheService() : super("album_cache"); | ||||
| } | ||||
|  | ||||
| class SharedAlbumCacheService extends BaseAlbumCacheService { | ||||
| @Deprecated("only kept to remove its files after migration") | ||||
| class SharedAlbumCacheService extends _BaseAlbumCacheService { | ||||
|   SharedAlbumCacheService() : super("shared_album_cache"); | ||||
| } | ||||
|  | ||||
| final albumCacheServiceProvider = Provider( | ||||
|   (ref) => AlbumCacheService(), | ||||
| ); | ||||
|  | ||||
| final sharedAlbumCacheServiceProvider = Provider( | ||||
|   (ref) => SharedAlbumCacheService(), | ||||
| ); | ||||
|   | ||||
| @@ -25,7 +25,7 @@ class AddToAlbumBottomSheet extends HookConsumerWidget { | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final albums = ref.watch(albumProvider); | ||||
|     final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList(); | ||||
|     final albumService = ref.watch(albumServiceProvider); | ||||
|     final sharedAlbums = ref.watch(sharedAlbumProvider); | ||||
|  | ||||
|   | ||||
| @@ -1,11 +1,7 @@ | ||||
| import 'package:cached_network_image/cached_network_image.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:hive/hive.dart'; | ||||
| import 'package:immich_mobile/constants/hive_box.dart'; | ||||
| import 'package:immich_mobile/shared/models/album.dart'; | ||||
| import 'package:immich_mobile/utils/image_url_builder.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:immich_mobile/shared/ui/immich_image.dart'; | ||||
|  | ||||
| class AlbumThumbnailCard extends StatelessWidget { | ||||
|   final Function()? onTap; | ||||
| @@ -20,7 +16,6 @@ class AlbumThumbnailCard extends StatelessWidget { | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     var box = Hive.box(userInfoBox); | ||||
|     var isDarkMode = Theme.of(context).brightness == Brightness.dark; | ||||
|     return LayoutBuilder( | ||||
|       builder: (context, constraints) { | ||||
| @@ -42,21 +37,11 @@ class AlbumThumbnailCard extends StatelessWidget { | ||||
|           ); | ||||
|         } | ||||
|  | ||||
|         buildAlbumThumbnail() { | ||||
|           return CachedNetworkImage( | ||||
|         buildAlbumThumbnail() => ImmichImage( | ||||
|               album.thumbnail.value, | ||||
|               width: cardSize, | ||||
|               height: cardSize, | ||||
|             fit: BoxFit.cover, | ||||
|             fadeInDuration: const Duration(milliseconds: 200), | ||||
|             imageUrl: getAlbumThumbnailUrl( | ||||
|               album, | ||||
|               type: ThumbnailFormat.JPEG, | ||||
|             ), | ||||
|             httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, | ||||
|             cacheKey: | ||||
|                 getAlbumThumbNailCacheKey(album, type: ThumbnailFormat.JPEG), | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|         return GestureDetector( | ||||
|           onTap: onTap, | ||||
| @@ -72,7 +57,7 @@ class AlbumThumbnailCard extends StatelessWidget { | ||||
|                       height: cardSize, | ||||
|                       child: ClipRRect( | ||||
|                         borderRadius: BorderRadius.circular(20), | ||||
|                         child: album.albumThumbnailAssetId == null | ||||
|                         child: album.thumbnail.value == null | ||||
|                             ? buildEmptyThumbnail() | ||||
|                             : buildAlbumThumbnail(), | ||||
|                       ), | ||||
|   | ||||
| @@ -68,7 +68,7 @@ class AlbumThumbnailListTile extends StatelessWidget { | ||||
|           children: [ | ||||
|             ClipRRect( | ||||
|               borderRadius: BorderRadius.circular(8), | ||||
|               child: album.albumThumbnailAssetId == null | ||||
|               child: album.thumbnail.value == null | ||||
|                   ? buildEmptyThumbnail() | ||||
|                   : buildAlbumThumbnail(), | ||||
|             ), | ||||
|   | ||||
| @@ -7,7 +7,6 @@ import 'package:immich_mobile/modules/album/providers/album.provider.dart'; | ||||
| import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart'; | ||||
| import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart'; | ||||
| import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; | ||||
| import 'package:immich_mobile/modules/album/services/album.service.dart'; | ||||
| import 'package:immich_mobile/routing/router.dart'; | ||||
| import 'package:immich_mobile/shared/models/album.dart'; | ||||
| import 'package:immich_mobile/shared/ui/immich_toast.dart'; | ||||
| @@ -35,19 +34,18 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget { | ||||
|     void onDeleteAlbumPressed() async { | ||||
|       ImmichLoadingOverlayController.appLoader.show(); | ||||
|  | ||||
|       bool isSuccess = await ref.watch(albumServiceProvider).deleteAlbum(album); | ||||
|  | ||||
|       if (isSuccess) { | ||||
|       final bool success; | ||||
|       if (album.shared) { | ||||
|           ref.watch(sharedAlbumProvider.notifier).deleteAlbum(album); | ||||
|         success = | ||||
|             await ref.watch(sharedAlbumProvider.notifier).deleteAlbum(album); | ||||
|         AutoRouter.of(context) | ||||
|             .navigate(const TabControllerRoute(children: [SharingRoute()])); | ||||
|       } else { | ||||
|           ref.watch(albumProvider.notifier).deleteAlbum(album); | ||||
|         success = await ref.watch(albumProvider.notifier).deleteAlbum(album); | ||||
|         AutoRouter.of(context) | ||||
|             .navigate(const TabControllerRoute(children: [LibraryRoute()])); | ||||
|       } | ||||
|       } else { | ||||
|       if (!success) { | ||||
|         ImmichToast.show( | ||||
|           context: context, | ||||
|           msg: "album_viewer_appbar_share_err_delete".tr(), | ||||
| @@ -208,6 +206,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget { | ||||
|           : null, | ||||
|       centerTitle: false, | ||||
|       actions: [ | ||||
|         if (album.isRemote) | ||||
|           IconButton( | ||||
|             splashRadius: 25, | ||||
|             onPressed: buildBottomSheet, | ||||
|   | ||||
| @@ -2,7 +2,6 @@ import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart'; | ||||
| import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; | ||||
| import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart'; | ||||
| import 'package:immich_mobile/routing/router.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| @@ -22,7 +21,6 @@ class AlbumViewerThumbnail extends HookConsumerWidget { | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     var deviceId = ref.watch(authenticationProvider).deviceId; | ||||
|     final selectedAssetsInAlbumViewer = | ||||
|         ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer; | ||||
|     final isMultiSelectionEnable = | ||||
| @@ -88,7 +86,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget { | ||||
|         bottom: 5, | ||||
|         child: Icon( | ||||
|           asset.isRemote | ||||
|               ? (deviceId == asset.deviceId | ||||
|               ? (asset.isLocal | ||||
|                   ? Icons.cloud_done_outlined | ||||
|                   : Icons.cloud_outlined) | ||||
|               : Icons.cloud_off_outlined, | ||||
|   | ||||
| @@ -25,7 +25,7 @@ import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegat | ||||
| import 'package:immich_mobile/shared/views/immich_loading_overlay.dart'; | ||||
|  | ||||
| class AlbumViewerPage extends HookConsumerWidget { | ||||
|   final String albumId; | ||||
|   final int albumId; | ||||
|  | ||||
|   const AlbumViewerPage({Key? key, required this.albumId}) : super(key: key); | ||||
|  | ||||
| @@ -101,7 +101,7 @@ class AlbumViewerPage extends HookConsumerWidget { | ||||
|     Widget buildTitle(Album album) { | ||||
|       return Padding( | ||||
|         padding: const EdgeInsets.only(left: 8, right: 8, top: 16), | ||||
|         child: userId == album.ownerId | ||||
|         child: userId == album.ownerId && album.isRemote | ||||
|             ? AlbumViewerEditableTitle( | ||||
|                 album: album, | ||||
|                 titleFocusNode: titleFocusNode, | ||||
| @@ -122,8 +122,9 @@ class AlbumViewerPage extends HookConsumerWidget { | ||||
|     Widget buildAlbumDateRange(Album album) { | ||||
|       final DateTime startDate = album.assets.first.fileCreatedAt; | ||||
|       final DateTime endDate = album.assets.last.fileCreatedAt; //Need default. | ||||
|       final String startDateText = | ||||
|           (startDate.year == endDate.year ? DateFormat.MMMd() : DateFormat.yMMMd()) | ||||
|       final String startDateText = (startDate.year == endDate.year | ||||
|               ? DateFormat.MMMd() | ||||
|               : DateFormat.yMMMd()) | ||||
|           .format(startDate); | ||||
|       final String endDateText = DateFormat.yMMMd().format(endDate); | ||||
|  | ||||
| @@ -188,7 +189,7 @@ class AlbumViewerPage extends HookConsumerWidget { | ||||
|       final bool showStorageIndicator = | ||||
|           appSettingService.getSetting(AppSettingsEnum.storageIndicator); | ||||
|  | ||||
|       if (album.assets.isNotEmpty) { | ||||
|       if (album.sortedAssets.isNotEmpty) { | ||||
|         return SliverPadding( | ||||
|           padding: const EdgeInsets.only(top: 10.0), | ||||
|           sliver: SliverGrid( | ||||
| @@ -201,8 +202,8 @@ class AlbumViewerPage extends HookConsumerWidget { | ||||
|             delegate: SliverChildBuilderDelegate( | ||||
|               (BuildContext context, int index) { | ||||
|                 return AlbumViewerThumbnail( | ||||
|                   asset: album.assets[index], | ||||
|                   assetList: album.assets, | ||||
|                   asset: album.sortedAssets[index], | ||||
|                   assetList: album.sortedAssets, | ||||
|                   showStorageIndicator: showStorageIndicator, | ||||
|                 ); | ||||
|               }, | ||||
| @@ -267,6 +268,7 @@ class AlbumViewerPage extends HookConsumerWidget { | ||||
|               controller: scrollController, | ||||
|               slivers: [ | ||||
|                 buildHeader(album), | ||||
|                 if (album.isRemote) | ||||
|                   SliverPersistentHeader( | ||||
|                     pinned: true, | ||||
|                     delegate: ImmichSliverPersistentAppBarDelegate( | ||||
|   | ||||
| @@ -44,9 +44,13 @@ class LibraryPage extends HookConsumerWidget { | ||||
|  | ||||
|     List<Album> sortedAlbums() { | ||||
|       if (selectedAlbumSortOrder.value == 0) { | ||||
|         return albums.sortedBy((album) => album.createdAt).reversed.toList(); | ||||
|         return albums | ||||
|             .where((a) => a.isRemote) | ||||
|             .sortedBy((album) => album.createdAt) | ||||
|             .reversed | ||||
|             .toList(); | ||||
|       } | ||||
|       return albums.sortedBy((album) => album.name); | ||||
|       return albums.where((a) => a.isRemote).sortedBy((album) => album.name); | ||||
|     } | ||||
|  | ||||
|     Widget buildSortButton() { | ||||
| @@ -194,6 +198,8 @@ class LibraryPage extends HookConsumerWidget { | ||||
|  | ||||
|     final sorted = sortedAlbums(); | ||||
|  | ||||
|     final local = albums.where((a) => a.isLocal).toList(); | ||||
|  | ||||
|     return Scaffold( | ||||
|       appBar: buildAppBar(), | ||||
|       body: CustomScrollView( | ||||
| @@ -270,6 +276,47 @@ class LibraryPage extends HookConsumerWidget { | ||||
|               ), | ||||
|             ), | ||||
|           ), | ||||
|           SliverToBoxAdapter( | ||||
|             child: Padding( | ||||
|               padding: const EdgeInsets.only( | ||||
|                 top: 12.0, | ||||
|                 left: 12.0, | ||||
|                 right: 12.0, | ||||
|                 bottom: 20.0, | ||||
|               ), | ||||
|               child: Row( | ||||
|                 mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|                 children: [ | ||||
|                   const Text( | ||||
|                     'library_page_device_albums', | ||||
|                     style: TextStyle(fontWeight: FontWeight.bold), | ||||
|                   ).tr(), | ||||
|                 ], | ||||
|               ), | ||||
|             ), | ||||
|           ), | ||||
|           SliverPadding( | ||||
|             padding: const EdgeInsets.all(12.0), | ||||
|             sliver: SliverGrid( | ||||
|               gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( | ||||
|                 maxCrossAxisExtent: 250, | ||||
|                 mainAxisSpacing: 12, | ||||
|                 crossAxisSpacing: 12, | ||||
|                 childAspectRatio: .7, | ||||
|               ), | ||||
|               delegate: SliverChildBuilderDelegate( | ||||
|                 childCount: local.length, | ||||
|                 (context, index) => AlbumThumbnailCard( | ||||
|                   album: local[index], | ||||
|                   onTap: () => AutoRouter.of(context).push( | ||||
|                     AlbumViewerRoute( | ||||
|                       albumId: local[index].id, | ||||
|                     ), | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   | ||||
| @@ -1,23 +1,19 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:cached_network_image/cached_network_image.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:hive/hive.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/constants/hive_box.dart'; | ||||
| import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; | ||||
| import 'package:immich_mobile/modules/album/ui/sharing_sliver_appbar.dart'; | ||||
| import 'package:immich_mobile/routing/router.dart'; | ||||
| import 'package:immich_mobile/shared/models/album.dart'; | ||||
| import 'package:immich_mobile/utils/image_url_builder.dart'; | ||||
| import 'package:immich_mobile/shared/ui/immich_image.dart'; | ||||
|  | ||||
| class SharingPage extends HookConsumerWidget { | ||||
|   const SharingPage({Key? key}) : super(key: key); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     var box = Hive.box(userInfoBox); | ||||
|     final List<Album> sharedAlbums = ref.watch(sharedAlbumProvider); | ||||
|  | ||||
|     useEffect( | ||||
| @@ -39,16 +35,10 @@ class SharingPage extends HookConsumerWidget { | ||||
|                   const EdgeInsets.symmetric(vertical: 12, horizontal: 12), | ||||
|               leading: ClipRRect( | ||||
|                 borderRadius: BorderRadius.circular(8), | ||||
|                 child: CachedNetworkImage( | ||||
|                 child: ImmichImage( | ||||
|                   album.thumbnail.value, | ||||
|                   width: 60, | ||||
|                   height: 60, | ||||
|                   fit: BoxFit.cover, | ||||
|                   imageUrl: getAlbumThumbnailUrl(album), | ||||
|                   cacheKey: getAlbumThumbNailCacheKey(album), | ||||
|                   httpHeaders: { | ||||
|                     "Authorization": "Bearer ${box.get(accessTokenKey)}" | ||||
|                   }, | ||||
|                   fadeInDuration: const Duration(milliseconds: 200), | ||||
|                 ), | ||||
|               ), | ||||
|               title: Text( | ||||
|   | ||||
| @@ -14,10 +14,14 @@ class ExifBottomSheet extends HookConsumerWidget { | ||||
|   const ExifBottomSheet({Key? key, required this.assetDetail}) | ||||
|       : super(key: key); | ||||
|  | ||||
|   bool get showMap => assetDetail.latitude != null && assetDetail.longitude != null; | ||||
|   bool get showMap => | ||||
|       assetDetail.exifInfo?.latitude != null && | ||||
|       assetDetail.exifInfo?.longitude != null; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final ExifInfo? exifInfo = assetDetail.exifInfo; | ||||
|  | ||||
|     buildMap() { | ||||
|       return Padding( | ||||
|         padding: const EdgeInsets.symmetric(vertical: 16.0), | ||||
| @@ -33,8 +37,8 @@ class ExifBottomSheet extends HookConsumerWidget { | ||||
|                 options: MapOptions( | ||||
|                   interactiveFlags: InteractiveFlag.none, | ||||
|                   center: LatLng( | ||||
|                     assetDetail.latitude ?? 0, | ||||
|                     assetDetail.longitude ?? 0, | ||||
|                     exifInfo?.latitude ?? 0, | ||||
|                     exifInfo?.longitude ?? 0, | ||||
|                   ), | ||||
|                   zoom: 16.0, | ||||
|                 ), | ||||
| @@ -55,8 +59,8 @@ class ExifBottomSheet extends HookConsumerWidget { | ||||
|                       Marker( | ||||
|                         anchorPos: AnchorPos.align(AnchorAlign.top), | ||||
|                         point: LatLng( | ||||
|                           assetDetail.latitude ?? 0, | ||||
|                           assetDetail.longitude ?? 0, | ||||
|                           exifInfo?.latitude ?? 0, | ||||
|                           exifInfo?.longitude ?? 0, | ||||
|                         ), | ||||
|                         builder: (ctx) => const Image( | ||||
|                           image: AssetImage('assets/location-pin.png'), | ||||
| @@ -74,8 +78,6 @@ class ExifBottomSheet extends HookConsumerWidget { | ||||
|  | ||||
|     final textColor = Theme.of(context).primaryColor; | ||||
|  | ||||
|     ExifInfo? exifInfo = assetDetail.exifInfo; | ||||
|  | ||||
|     buildLocationText() { | ||||
|       return Text( | ||||
|         "${exifInfo?.city}, ${exifInfo?.state}", | ||||
| @@ -134,7 +136,7 @@ class ExifBottomSheet extends HookConsumerWidget { | ||||
|                   exifInfo.state != null) | ||||
|                 buildLocationText(), | ||||
|               Text( | ||||
|                 "${assetDetail.latitude!.toStringAsFixed(4)}, ${assetDetail.longitude!.toStringAsFixed(4)}", | ||||
|                 "${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo.longitude!.toStringAsFixed(4)}", | ||||
|                 style: const TextStyle(fontSize: 12), | ||||
|               ) | ||||
|             ], | ||||
|   | ||||
| @@ -75,15 +75,11 @@ class GalleryViewerPage extends HookConsumerWidget { | ||||
|       ref.watch(favoriteProvider.notifier).toggleFavorite(asset); | ||||
|     } | ||||
|  | ||||
|     getAssetExif() async { | ||||
|       if (assetList[indexOfAsset.value].isRemote) { | ||||
|     void getAssetExif() async { | ||||
|       assetDetail = assetList[indexOfAsset.value]; | ||||
|       assetDetail = await ref | ||||
|           .watch(assetServiceProvider) | ||||
|             .getAssetById(assetList[indexOfAsset.value].id); | ||||
|       } else { | ||||
|         // TODO local exif parsing? | ||||
|         assetDetail = assetList[indexOfAsset.value]; | ||||
|       } | ||||
|           .loadExif(assetList[indexOfAsset.value]); | ||||
|     } | ||||
|  | ||||
|     /// Thumbnail image of a remote asset. Required asset.isRemote | ||||
|   | ||||
| @@ -2,7 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/shared/providers/asset.provider.dart'; | ||||
|  | ||||
| class FavoriteSelectionNotifier extends StateNotifier<Set<String>> { | ||||
| class FavoriteSelectionNotifier extends StateNotifier<Set<int>> { | ||||
|   FavoriteSelectionNotifier(this.assetsState, this.assetNotifier) : super({}) { | ||||
|     state = assetsState.allAssets | ||||
|         .where((asset) => asset.isFavorite) | ||||
| @@ -13,7 +13,7 @@ class FavoriteSelectionNotifier extends StateNotifier<Set<String>> { | ||||
|   final AssetsState assetsState; | ||||
|   final AssetNotifier assetNotifier; | ||||
|  | ||||
|   void _setFavoriteForAssetId(String id, bool favorite) { | ||||
|   void _setFavoriteForAssetId(int id, bool favorite) { | ||||
|     if (!favorite) { | ||||
|       state = state.difference({id}); | ||||
|     } else { | ||||
| @@ -21,7 +21,7 @@ class FavoriteSelectionNotifier extends StateNotifier<Set<String>> { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   bool _isFavorite(String id) { | ||||
|   bool _isFavorite(int id) { | ||||
|     return state.contains(id); | ||||
|   } | ||||
|  | ||||
| @@ -38,8 +38,8 @@ class FavoriteSelectionNotifier extends StateNotifier<Set<String>> { | ||||
|  | ||||
|   Future<void> addToFavorites(Iterable<Asset> assets) { | ||||
|     state = state.union(assets.map((a) => a.id).toSet()); | ||||
|     final futures = assets.map((a) => | ||||
|         assetNotifier.toggleFavorite( | ||||
|     final futures = assets.map( | ||||
|       (a) => assetNotifier.toggleFavorite( | ||||
|         a, | ||||
|         true, | ||||
|       ), | ||||
| @@ -50,7 +50,7 @@ class FavoriteSelectionNotifier extends StateNotifier<Set<String>> { | ||||
| } | ||||
|  | ||||
| final favoriteProvider = | ||||
|     StateNotifierProvider<FavoriteSelectionNotifier, Set<String>>((ref) { | ||||
|     StateNotifierProvider<FavoriteSelectionNotifier, Set<int>>((ref) { | ||||
|   return FavoriteSelectionNotifier( | ||||
|     ref.watch(assetProvider), | ||||
|     ref.watch(assetProvider.notifier), | ||||
|   | ||||
| @@ -23,7 +23,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> { | ||||
|       ItemPositionsListener.create(); | ||||
|  | ||||
|   bool _scrolling = false; | ||||
|   final Set<String> _selectedAssets = HashSet(); | ||||
|   final Set<int> _selectedAssets = HashSet(); | ||||
|  | ||||
|   Set<Asset> _getSelectedAssets() { | ||||
|     return _selectedAssets | ||||
|   | ||||
| @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart'; | ||||
| import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; | ||||
| import 'package:immich_mobile/routing/router.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/shared/ui/immich_image.dart'; | ||||
| @@ -32,8 +31,6 @@ class ThumbnailImage extends HookConsumerWidget { | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     var deviceId = ref.watch(authenticationProvider).deviceId; | ||||
|  | ||||
|     Widget buildSelectionIcon(Asset asset) { | ||||
|       if (isSelected) { | ||||
|         return Icon( | ||||
| @@ -103,7 +100,7 @@ class ThumbnailImage extends HookConsumerWidget { | ||||
|                 bottom: 5, | ||||
|                 child: Icon( | ||||
|                   asset.isRemote | ||||
|                       ? (deviceId == asset.deviceId | ||||
|                       ? (asset.isLocal | ||||
|                           ? Icons.cloud_done_outlined | ||||
|                           : Icons.cloud_outlined) | ||||
|                       : Icons.cloud_off_outlined, | ||||
|   | ||||
| @@ -38,7 +38,7 @@ class HomePage extends HookConsumerWidget { | ||||
|     final selectionEnabledHook = useState(false); | ||||
|  | ||||
|     final selection = useState(<Asset>{}); | ||||
|     final albums = ref.watch(albumProvider); | ||||
|     final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList(); | ||||
|     final sharedAlbums = ref.watch(sharedAlbumProvider); | ||||
|     final albumService = ref.watch(albumServiceProvider); | ||||
|  | ||||
|   | ||||
| @@ -3,15 +3,15 @@ import 'package:flutter/services.dart'; | ||||
| import 'package:hive/hive.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/constants/hive_box.dart'; | ||||
| import 'package:immich_mobile/modules/album/services/album_cache.service.dart'; | ||||
| import 'package:immich_mobile/shared/models/store.dart'; | ||||
| import 'package:immich_mobile/shared/services/asset_cache.service.dart'; | ||||
| import 'package:immich_mobile/modules/login/models/authentication_state.model.dart'; | ||||
| import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart'; | ||||
| import 'package:immich_mobile/modules/backup/services/backup.service.dart'; | ||||
| import 'package:immich_mobile/shared/models/user.dart'; | ||||
| import 'package:immich_mobile/shared/providers/api.provider.dart'; | ||||
| import 'package:immich_mobile/shared/services/api.service.dart'; | ||||
| import 'package:immich_mobile/shared/services/device_info.service.dart'; | ||||
| import 'package:immich_mobile/utils/hash.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
|  | ||||
| class AuthenticationNotifier extends StateNotifier<AuthenticationState> { | ||||
| @@ -19,9 +19,6 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> { | ||||
|     this._deviceInfoService, | ||||
|     this._backupService, | ||||
|     this._apiService, | ||||
|     this._assetCacheService, | ||||
|     this._albumCacheService, | ||||
|     this._sharedAlbumCacheService, | ||||
|   ) : super( | ||||
|           AuthenticationState( | ||||
|             deviceId: "", | ||||
| @@ -48,9 +45,6 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> { | ||||
|   final DeviceInfoService _deviceInfoService; | ||||
|   final BackupService _backupService; | ||||
|   final ApiService _apiService; | ||||
|   final AssetCacheService _assetCacheService; | ||||
|   final AlbumCacheService _albumCacheService; | ||||
|   final SharedAlbumCacheService _sharedAlbumCacheService; | ||||
|  | ||||
|   Future<bool> login( | ||||
|     String email, | ||||
| @@ -98,9 +92,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> { | ||||
|         Hive.box(userInfoBox).delete(accessTokenKey), | ||||
|         Store.delete(StoreKey.assetETag), | ||||
|         Store.delete(StoreKey.userRemoteId), | ||||
|         _assetCacheService.invalidate(), | ||||
|         _albumCacheService.invalidate(), | ||||
|         _sharedAlbumCacheService.invalidate(), | ||||
|         Store.delete(StoreKey.currentUser), | ||||
|         Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).delete(savedLoginInfoKey) | ||||
|       ]); | ||||
|  | ||||
| @@ -160,7 +152,10 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> { | ||||
|       var deviceInfo = await _deviceInfoService.getDeviceInfo(); | ||||
|       userInfoHiveBox.put(deviceIdKey, deviceInfo["deviceId"]); | ||||
|       userInfoHiveBox.put(accessTokenKey, accessToken); | ||||
|       Store.put(StoreKey.deviceId, deviceInfo["deviceId"]); | ||||
|       Store.put(StoreKey.deviceIdHash, fastHash(deviceInfo["deviceId"])); | ||||
|       Store.put(StoreKey.userRemoteId, userResponseDto.id); | ||||
|       Store.put(StoreKey.currentUser, User.fromDto(userResponseDto)); | ||||
|  | ||||
|       state = state.copyWith( | ||||
|         isAuthenticated: true, | ||||
| @@ -218,8 +213,5 @@ final authenticationProvider = | ||||
|     ref.watch(deviceInfoServiceProvider), | ||||
|     ref.watch(backupServiceProvider), | ||||
|     ref.watch(apiServiceProvider), | ||||
|     ref.watch(assetCacheServiceProvider), | ||||
|     ref.watch(albumCacheServiceProvider), | ||||
|     ref.watch(sharedAlbumCacheServiceProvider), | ||||
|   ); | ||||
| }); | ||||
|   | ||||
| @@ -1,8 +1,5 @@ | ||||
| import 'dart:convert'; | ||||
|  | ||||
| import 'package:collection/collection.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
|  | ||||
| class SearchResultPageState { | ||||
|   final bool isLoading; | ||||
| @@ -31,34 +28,6 @@ class SearchResultPageState { | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Map<String, dynamic> toMap() { | ||||
|     return { | ||||
|       'isLoading': isLoading, | ||||
|       'isSuccess': isSuccess, | ||||
|       'isError': isError, | ||||
|       'searchResult': searchResult.map((x) => x.toJson()).toList(), | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   factory SearchResultPageState.fromMap(Map<String, dynamic> map) { | ||||
|     return SearchResultPageState( | ||||
|       isLoading: map['isLoading'] ?? false, | ||||
|       isSuccess: map['isSuccess'] ?? false, | ||||
|       isError: map['isError'] ?? false, | ||||
|       searchResult: List.from( | ||||
|         map['searchResult'] | ||||
|             .map(AssetResponseDto.fromJson) | ||||
|             .where((e) => e != null) | ||||
|             .map(Asset.remote), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   String toJson() => json.encode(toMap()); | ||||
|  | ||||
|   factory SearchResultPageState.fromJson(String source) => | ||||
|       SearchResultPageState.fromMap(json.decode(source)); | ||||
|  | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'SearchresultPageState(isLoading: $isLoading, isSuccess: $isSuccess, isError: $isError, searchResult: $searchResult)'; | ||||
|   | ||||
| @@ -2,19 +2,23 @@ import 'package:flutter/material.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/shared/providers/api.provider.dart'; | ||||
| import 'package:immich_mobile/shared/providers/db.provider.dart'; | ||||
| import 'package:immich_mobile/shared/services/api.service.dart'; | ||||
| import 'package:isar/isar.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
|  | ||||
| final searchServiceProvider = Provider( | ||||
|   (ref) => SearchService( | ||||
|     ref.watch(apiServiceProvider), | ||||
|     ref.watch(dbProvider), | ||||
|   ), | ||||
| ); | ||||
|  | ||||
| class SearchService { | ||||
|   final ApiService _apiService; | ||||
|   final Isar _db; | ||||
|  | ||||
|   SearchService(this._apiService); | ||||
|   SearchService(this._apiService, this._db); | ||||
|  | ||||
|   Future<List<String>?> getUserSuggestedSearchTerms() async { | ||||
|     try { | ||||
| @@ -26,13 +30,15 @@ class SearchService { | ||||
|   } | ||||
|  | ||||
|   Future<List<Asset>?> searchAsset(String searchTerm) async { | ||||
|     // TODO search in local DB: 1. when offline, 2. to find local assets | ||||
|     try { | ||||
|       final List<AssetResponseDto>? results = await _apiService.assetApi | ||||
|           .searchAsset(SearchAssetDto(searchTerm: searchTerm)); | ||||
|       if (results == null) { | ||||
|         return null; | ||||
|       } | ||||
|       return results.map((e) => Asset.remote(e)).toList(); | ||||
|       // TODO local DB might be out of date; add assets not yet in DB? | ||||
|       return _db.assets.getAllByRemoteId(results.map((e) => e.id)); | ||||
|     } catch (e) { | ||||
|       debugPrint("[ERROR] [searchAsset] ${e.toString()}"); | ||||
|       return null; | ||||
|   | ||||
| @@ -698,7 +698,7 @@ class SelectUserForSharingRoute extends PageRouteInfo<void> { | ||||
| class AlbumViewerRoute extends PageRouteInfo<AlbumViewerRouteArgs> { | ||||
|   AlbumViewerRoute({ | ||||
|     Key? key, | ||||
|     required String albumId, | ||||
|     required int albumId, | ||||
|   }) : super( | ||||
|           AlbumViewerRoute.name, | ||||
|           path: '/album-viewer-page', | ||||
| @@ -719,7 +719,7 @@ class AlbumViewerRouteArgs { | ||||
|  | ||||
|   final Key? key; | ||||
|  | ||||
|   final String albumId; | ||||
|   final int albumId; | ||||
|  | ||||
|   @override | ||||
|   String toString() { | ||||
|   | ||||
| @@ -1,132 +1,153 @@ | ||||
| import 'package:flutter/cupertino.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/shared/models/store.dart'; | ||||
| import 'package:immich_mobile/shared/models/user.dart'; | ||||
| import 'package:isar/isar.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:photo_manager/photo_manager.dart'; | ||||
|  | ||||
| part 'album.g.dart'; | ||||
|  | ||||
| @Collection(inheritance: false) | ||||
| class Album { | ||||
|   Album.remote(AlbumResponseDto dto) | ||||
|       : remoteId = dto.id, | ||||
|         name = dto.albumName, | ||||
|         createdAt = DateTime.parse(dto.createdAt), | ||||
|         // TODO add modifiedAt to server | ||||
|         modifiedAt = DateTime.parse(dto.createdAt), | ||||
|         shared = dto.shared, | ||||
|         ownerId = dto.ownerId, | ||||
|         albumThumbnailAssetId = dto.albumThumbnailAssetId, | ||||
|         assetCount = dto.assetCount, | ||||
|         sharedUsers = dto.sharedUsers.map((e) => User.fromDto(e)).toList(), | ||||
|         assets = dto.assets.map(Asset.remote).toList(); | ||||
|  | ||||
|   @protected | ||||
|   Album({ | ||||
|     this.remoteId, | ||||
|     this.localId, | ||||
|     required this.name, | ||||
|     required this.ownerId, | ||||
|     required this.createdAt, | ||||
|     required this.modifiedAt, | ||||
|     required this.shared, | ||||
|     required this.assetCount, | ||||
|     this.albumThumbnailAssetId, | ||||
|     this.sharedUsers = const [], | ||||
|     this.assets = const [], | ||||
|   }); | ||||
|  | ||||
|   Id id = Isar.autoIncrement; | ||||
|   @Index(unique: false, replace: false, type: IndexType.hash) | ||||
|   String? remoteId; | ||||
|   @Index(unique: false, replace: false, type: IndexType.hash) | ||||
|   String? localId; | ||||
|   String name; | ||||
|   String ownerId; | ||||
|   DateTime createdAt; | ||||
|   DateTime modifiedAt; | ||||
|   bool shared; | ||||
|   String? albumThumbnailAssetId; | ||||
|   int assetCount; | ||||
|   List<User> sharedUsers = const []; | ||||
|   List<Asset> assets = const []; | ||||
|   final IsarLink<User> owner = IsarLink<User>(); | ||||
|   final IsarLink<Asset> thumbnail = IsarLink<Asset>(); | ||||
|   final IsarLinks<User> sharedUsers = IsarLinks<User>(); | ||||
|   final IsarLinks<Asset> assets = IsarLinks<Asset>(); | ||||
|  | ||||
|   List<Asset> _sortedAssets = []; | ||||
|  | ||||
|   @ignore | ||||
|   List<Asset> get sortedAssets => _sortedAssets; | ||||
|  | ||||
|   @ignore | ||||
|   bool get isRemote => remoteId != null; | ||||
|  | ||||
|   @ignore | ||||
|   bool get isLocal => localId != null; | ||||
|  | ||||
|   String get id => isRemote ? remoteId! : localId!; | ||||
|   @ignore | ||||
|   int get assetCount => assets.length; | ||||
|  | ||||
|   @ignore | ||||
|   String? get ownerId => owner.value?.id; | ||||
|  | ||||
|   Future<void> loadSortedAssets() async { | ||||
|     _sortedAssets = await assets.filter().sortByFileCreatedAt().findAll(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(other) { | ||||
|     if (other is! Album) return false; | ||||
|     return remoteId == other.remoteId && | ||||
|     return id == other.id && | ||||
|         remoteId == other.remoteId && | ||||
|         localId == other.localId && | ||||
|         name == other.name && | ||||
|         createdAt == other.createdAt && | ||||
|         modifiedAt == other.modifiedAt && | ||||
|         shared == other.shared && | ||||
|         ownerId == other.ownerId && | ||||
|         albumThumbnailAssetId == other.albumThumbnailAssetId; | ||||
|         owner.value == other.owner.value && | ||||
|         thumbnail.value == other.thumbnail.value && | ||||
|         sharedUsers.length == other.sharedUsers.length && | ||||
|         assets.length == other.assets.length; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   @ignore | ||||
|   int get hashCode => | ||||
|       id.hashCode ^ | ||||
|       remoteId.hashCode ^ | ||||
|       localId.hashCode ^ | ||||
|       name.hashCode ^ | ||||
|       createdAt.hashCode ^ | ||||
|       modifiedAt.hashCode ^ | ||||
|       shared.hashCode ^ | ||||
|       ownerId.hashCode ^ | ||||
|       albumThumbnailAssetId.hashCode; | ||||
|       owner.value.hashCode ^ | ||||
|       thumbnail.value.hashCode ^ | ||||
|       sharedUsers.length.hashCode ^ | ||||
|       assets.length.hashCode; | ||||
|  | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
|     json["remoteId"] = remoteId; | ||||
|     json["localId"] = localId; | ||||
|     json["name"] = name; | ||||
|     json["ownerId"] = ownerId; | ||||
|     json["createdAt"] = createdAt.millisecondsSinceEpoch; | ||||
|     json["modifiedAt"] = modifiedAt.millisecondsSinceEpoch; | ||||
|     json["shared"] = shared; | ||||
|     json["albumThumbnailAssetId"] = albumThumbnailAssetId; | ||||
|     json["assetCount"] = assetCount; | ||||
|     json["sharedUsers"] = sharedUsers; | ||||
|     json["assets"] = assets; | ||||
|     return json; | ||||
|   } | ||||
|  | ||||
|   static Album? fromJson(dynamic value) { | ||||
|     if (value is Map) { | ||||
|       final json = value.cast<String, dynamic>(); | ||||
|       return Album( | ||||
|         remoteId: json["remoteId"], | ||||
|         localId: json["localId"], | ||||
|         name: json["name"], | ||||
|         ownerId: json["ownerId"], | ||||
|         createdAt: DateTime.fromMillisecondsSinceEpoch( | ||||
|           json["createdAt"], | ||||
|           isUtc: true, | ||||
|         ), | ||||
|         modifiedAt: DateTime.fromMillisecondsSinceEpoch( | ||||
|           json["modifiedAt"], | ||||
|           isUtc: true, | ||||
|         ), | ||||
|         shared: json["shared"], | ||||
|         albumThumbnailAssetId: json["albumThumbnailAssetId"], | ||||
|         assetCount: json["assetCount"], | ||||
|         sharedUsers: _listFromJson<User>(json["sharedUsers"], User.fromJson), | ||||
|         assets: _listFromJson<Asset>(json["assets"], Asset.fromJson), | ||||
|   static Album local(AssetPathEntity ape) { | ||||
|     final Album a = Album( | ||||
|       name: ape.name, | ||||
|       createdAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(), | ||||
|       modifiedAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(), | ||||
|       shared: false, | ||||
|     ); | ||||
|     a.owner.value = Store.get(StoreKey.currentUser); | ||||
|     a.localId = ape.id; | ||||
|     return a; | ||||
|   } | ||||
|     return null; | ||||
|  | ||||
|   static Future<Album> remote(AlbumResponseDto dto) async { | ||||
|     final Isar db = Isar.getInstance()!; | ||||
|     final Album a = Album( | ||||
|       remoteId: dto.id, | ||||
|       name: dto.albumName, | ||||
|       createdAt: DateTime.parse(dto.createdAt), | ||||
|       modifiedAt: DateTime.parse(dto.updatedAt), | ||||
|       shared: dto.shared, | ||||
|     ); | ||||
|     a.owner.value = await db.users.getById(dto.ownerId); | ||||
|     if (dto.albumThumbnailAssetId != null) { | ||||
|       a.thumbnail.value = await db.assets | ||||
|           .where() | ||||
|           .remoteIdEqualTo(dto.albumThumbnailAssetId) | ||||
|           .findFirst(); | ||||
|     } | ||||
|     if (dto.sharedUsers.isNotEmpty) { | ||||
|       final users = await db.users | ||||
|           .getAllById(dto.sharedUsers.map((e) => e.id).toList(growable: false)); | ||||
|       a.sharedUsers.addAll(users.cast()); | ||||
|     } | ||||
|     if (dto.assets.isNotEmpty) { | ||||
|       final assets = | ||||
|           await db.assets.getAllByRemoteId(dto.assets.map((e) => e.id)); | ||||
|       a.assets.addAll(assets); | ||||
|     } | ||||
|     return a; | ||||
|   } | ||||
| } | ||||
|  | ||||
| List<T> _listFromJson<T>( | ||||
|   dynamic json, | ||||
|   T? Function(dynamic) fromJson, | ||||
| ) { | ||||
|   final result = <T>[]; | ||||
|   if (json is List && json.isNotEmpty) { | ||||
|     for (final entry in json) { | ||||
|       final value = fromJson(entry); | ||||
|       if (value != null) { | ||||
|         result.add(value); | ||||
| extension AssetsHelper on IsarCollection<Album> { | ||||
|   Future<void> store(Album a) async { | ||||
|     await put(a); | ||||
|     await a.owner.save(); | ||||
|     await a.thumbnail.save(); | ||||
|     await a.sharedUsers.save(); | ||||
|     await a.assets.save(); | ||||
|   } | ||||
|     } | ||||
|   } | ||||
|   return result; | ||||
| } | ||||
|  | ||||
| extension AssetPathEntityHelper on AssetPathEntity { | ||||
|   Future<List<Asset>> getAssets({ | ||||
|     int start = 0, | ||||
|     int end = 0x7fffffffffffffff, | ||||
|   }) async { | ||||
|     final assetEntities = await getAssetListRange(start: start, end: end); | ||||
|     return assetEntities.map(Asset.local).toList(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| extension AlbumResponseDtoHelper on AlbumResponseDto { | ||||
|   List<Asset> getAssets() => assets.map(Asset.remote).toList(); | ||||
| } | ||||
|   | ||||
							
								
								
									
										1391
									
								
								mobile/lib/shared/models/album.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1391
									
								
								mobile/lib/shared/models/album.g.dart
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,60 +1,65 @@ | ||||
| import 'package:hive/hive.dart'; | ||||
| import 'package:immich_mobile/constants/hive_box.dart'; | ||||
| import 'package:immich_mobile/shared/models/exif_info.dart'; | ||||
| import 'package:immich_mobile/shared/models/store.dart'; | ||||
| import 'package:immich_mobile/shared/models/user.dart'; | ||||
| import 'package:immich_mobile/utils/hash.dart'; | ||||
| import 'package:isar/isar.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:photo_manager/photo_manager.dart'; | ||||
| import 'package:immich_mobile/utils/builtin_extensions.dart'; | ||||
| import 'package:path/path.dart' as p; | ||||
|  | ||||
| part 'asset.g.dart'; | ||||
|  | ||||
| /// Asset (online or local) | ||||
| @Collection(inheritance: false) | ||||
| class Asset { | ||||
|   Asset.remote(AssetResponseDto remote) | ||||
|       : remoteId = remote.id, | ||||
|         fileCreatedAt = DateTime.parse(remote.fileCreatedAt), | ||||
|         fileModifiedAt = DateTime.parse(remote.fileModifiedAt), | ||||
|         isLocal = false, | ||||
|         fileCreatedAt = DateTime.parse(remote.fileCreatedAt).toUtc(), | ||||
|         fileModifiedAt = DateTime.parse(remote.fileModifiedAt).toUtc(), | ||||
|         updatedAt = DateTime.parse(remote.updatedAt).toUtc(), | ||||
|         durationInSeconds = remote.duration.toDuration().inSeconds, | ||||
|         fileName = p.basename(remote.originalPath), | ||||
|         height = remote.exifInfo?.exifImageHeight?.toInt(), | ||||
|         width = remote.exifInfo?.exifImageWidth?.toInt(), | ||||
|         livePhotoVideoId = remote.livePhotoVideoId, | ||||
|         deviceAssetId = remote.deviceAssetId, | ||||
|         deviceId = remote.deviceId, | ||||
|         ownerId = remote.ownerId, | ||||
|         latitude = remote.exifInfo?.latitude?.toDouble(), | ||||
|         longitude = remote.exifInfo?.longitude?.toDouble(), | ||||
|         localId = remote.deviceAssetId, | ||||
|         deviceId = fastHash(remote.deviceId), | ||||
|         ownerId = fastHash(remote.ownerId), | ||||
|         exifInfo = | ||||
|             remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null, | ||||
|         isFavorite = remote.isFavorite; | ||||
|  | ||||
|   Asset.local(AssetEntity local, String owner) | ||||
|   Asset.local(AssetEntity local) | ||||
|       : localId = local.id, | ||||
|         latitude = local.latitude, | ||||
|         longitude = local.longitude, | ||||
|         isLocal = true, | ||||
|         durationInSeconds = local.duration, | ||||
|         height = local.height, | ||||
|         width = local.width, | ||||
|         fileName = local.title!, | ||||
|         deviceAssetId = local.id, | ||||
|         deviceId = Hive.box(userInfoBox).get(deviceIdKey), | ||||
|         ownerId = owner, | ||||
|         deviceId = Store.get(StoreKey.deviceIdHash), | ||||
|         ownerId = Store.get<User>(StoreKey.currentUser)!.isarId, | ||||
|         fileModifiedAt = local.modifiedDateTime.toUtc(), | ||||
|         updatedAt = local.modifiedDateTime.toUtc(), | ||||
|         isFavorite = local.isFavorite, | ||||
|         fileCreatedAt = local.createDateTime.toUtc() { | ||||
|     if (fileCreatedAt.year == 1970) { | ||||
|       fileCreatedAt = fileModifiedAt; | ||||
|     } | ||||
|     if (local.latitude != null) { | ||||
|       exifInfo = ExifInfo(lat: local.latitude, long: local.longitude); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Asset({ | ||||
|     this.localId, | ||||
|     this.remoteId, | ||||
|     required this.deviceAssetId, | ||||
|     required this.localId, | ||||
|     required this.deviceId, | ||||
|     required this.ownerId, | ||||
|     required this.fileCreatedAt, | ||||
|     required this.fileModifiedAt, | ||||
|     this.latitude, | ||||
|     this.longitude, | ||||
|     required this.updatedAt, | ||||
|     required this.durationInSeconds, | ||||
|     this.width, | ||||
|     this.height, | ||||
| @@ -62,21 +67,22 @@ class Asset { | ||||
|     this.livePhotoVideoId, | ||||
|     this.exifInfo, | ||||
|     required this.isFavorite, | ||||
|     required this.isLocal, | ||||
|   }); | ||||
|  | ||||
|   @ignore | ||||
|   AssetEntity? _local; | ||||
|  | ||||
|   @ignore | ||||
|   AssetEntity? get local { | ||||
|     if (isLocal && _local == null) { | ||||
|       _local = AssetEntity( | ||||
|         id: localId!.toString(), | ||||
|         id: localId.toString(), | ||||
|         typeInt: isImage ? 1 : 2, | ||||
|         width: width!, | ||||
|         height: height!, | ||||
|         duration: durationInSeconds, | ||||
|         createDateSecond: fileCreatedAt.millisecondsSinceEpoch ~/ 1000, | ||||
|         latitude: latitude, | ||||
|         longitude: longitude, | ||||
|         modifiedDateSecond: fileModifiedAt.millisecondsSinceEpoch ~/ 1000, | ||||
|         title: fileName, | ||||
|       ); | ||||
| @@ -84,110 +90,136 @@ class Asset { | ||||
|     return _local; | ||||
|   } | ||||
|  | ||||
|   String? localId; | ||||
|   Id id = Isar.autoIncrement; | ||||
|  | ||||
|   @Index(unique: false, replace: false, type: IndexType.hash) | ||||
|   String? remoteId; | ||||
|  | ||||
|   String deviceAssetId; | ||||
|   @Index( | ||||
|     unique: true, | ||||
|     replace: false, | ||||
|     type: IndexType.hash, | ||||
|     composite: [CompositeIndex('deviceId')], | ||||
|   ) | ||||
|   String localId; | ||||
|  | ||||
|   String deviceId; | ||||
|   int deviceId; | ||||
|  | ||||
|   String ownerId; | ||||
|   int ownerId; | ||||
|  | ||||
|   DateTime fileCreatedAt; | ||||
|  | ||||
|   DateTime fileModifiedAt; | ||||
|  | ||||
|   double? latitude; | ||||
|  | ||||
|   double? longitude; | ||||
|   DateTime updatedAt; | ||||
|  | ||||
|   int durationInSeconds; | ||||
|  | ||||
|   int? width; | ||||
|   short? width; | ||||
|  | ||||
|   int? height; | ||||
|   short? height; | ||||
|  | ||||
|   String fileName; | ||||
|  | ||||
|   String? livePhotoVideoId; | ||||
|  | ||||
|   ExifInfo? exifInfo; | ||||
|  | ||||
|   bool isFavorite; | ||||
|  | ||||
|   String get id => isLocal ? localId.toString() : remoteId!; | ||||
|   bool isLocal; | ||||
|  | ||||
|   @ignore | ||||
|   ExifInfo? exifInfo; | ||||
|  | ||||
|   @ignore | ||||
|   bool get isInDb => id != Isar.autoIncrement; | ||||
|  | ||||
|   @ignore | ||||
|   String get name => p.withoutExtension(fileName); | ||||
|  | ||||
|   @ignore | ||||
|   bool get isRemote => remoteId != null; | ||||
|  | ||||
|   bool get isLocal => localId != null; | ||||
|  | ||||
|   @ignore | ||||
|   bool get isImage => durationInSeconds == 0; | ||||
|  | ||||
|   @ignore | ||||
|   Duration get duration => Duration(seconds: durationInSeconds); | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(other) { | ||||
|     if (other is! Asset) return false; | ||||
|     return id == other.id && isLocal == other.isLocal; | ||||
|     return id == other.id; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   @ignore | ||||
|   int get hashCode => id.hashCode; | ||||
|  | ||||
|   // methods below are only required for caching as JSON | ||||
|   bool updateFromAssetEntity(AssetEntity ae) { | ||||
|     // TODO check more fields; | ||||
|     // width and height are most important because local assets require these | ||||
|     final bool hasChanges = | ||||
|         isLocal == false || width != ae.width || height != ae.height; | ||||
|     if (hasChanges) { | ||||
|       isLocal = true; | ||||
|       width = ae.width; | ||||
|       height = ae.height; | ||||
|     } | ||||
|     return hasChanges; | ||||
|   } | ||||
|  | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
|     json["localId"] = localId; | ||||
|     json["remoteId"] = remoteId; | ||||
|     json["deviceAssetId"] = deviceAssetId; | ||||
|     json["deviceId"] = deviceId; | ||||
|     json["ownerId"] = ownerId; | ||||
|     json["fileCreatedAt"] = fileCreatedAt.millisecondsSinceEpoch; | ||||
|     json["fileModifiedAt"] = fileModifiedAt.millisecondsSinceEpoch; | ||||
|     json["latitude"] = latitude; | ||||
|     json["longitude"] = longitude; | ||||
|     json["durationInSeconds"] = durationInSeconds; | ||||
|     json["width"] = width; | ||||
|     json["height"] = height; | ||||
|     json["fileName"] = fileName; | ||||
|     json["livePhotoVideoId"] = livePhotoVideoId; | ||||
|     json["isFavorite"] = isFavorite; | ||||
|   Asset withUpdatesFromDto(AssetResponseDto dto) => | ||||
|       Asset.remote(dto).updateFromDb(this); | ||||
|  | ||||
|   Asset updateFromDb(Asset a) { | ||||
|     assert(localId == a.localId); | ||||
|     assert(deviceId == a.deviceId); | ||||
|     id = a.id; | ||||
|     isLocal |= a.isLocal; | ||||
|     remoteId ??= a.remoteId; | ||||
|     width ??= a.width; | ||||
|     height ??= a.height; | ||||
|     exifInfo ??= a.exifInfo; | ||||
|     exifInfo?.id = id; | ||||
|     return this; | ||||
|   } | ||||
|  | ||||
|   Future<void> put(Isar db) async { | ||||
|     await db.assets.put(this); | ||||
|     if (exifInfo != null) { | ||||
|       json["exifInfo"] = exifInfo!.toJson(); | ||||
|       exifInfo!.id = id; | ||||
|       await db.exifInfos.put(exifInfo!); | ||||
|     } | ||||
|     return json; | ||||
|   } | ||||
|  | ||||
|   static Asset? fromJson(dynamic value) { | ||||
|     if (value is Map) { | ||||
|       final json = value.cast<String, dynamic>(); | ||||
|       return Asset( | ||||
|         localId: json["localId"], | ||||
|         remoteId: json["remoteId"], | ||||
|         deviceAssetId: json["deviceAssetId"], | ||||
|         deviceId: json["deviceId"], | ||||
|         ownerId: json["ownerId"], | ||||
|         fileCreatedAt: | ||||
|             DateTime.fromMillisecondsSinceEpoch(json["fileCreatedAt"], isUtc: true), | ||||
|         fileModifiedAt: DateTime.fromMillisecondsSinceEpoch( | ||||
|           json["fileModifiedAt"], | ||||
|           isUtc: true, | ||||
|         ), | ||||
|         latitude: json["latitude"], | ||||
|         longitude: json["longitude"], | ||||
|         durationInSeconds: json["durationInSeconds"], | ||||
|         width: json["width"], | ||||
|         height: json["height"], | ||||
|         fileName: json["fileName"], | ||||
|         livePhotoVideoId: json["livePhotoVideoId"], | ||||
|         exifInfo: ExifInfo.fromJson(json["exifInfo"]), | ||||
|         isFavorite: json["isFavorite"], | ||||
|   static int compareByDeviceIdLocalId(Asset a, Asset b) { | ||||
|     final int order = a.deviceId.compareTo(b.deviceId); | ||||
|     return order == 0 ? a.localId.compareTo(b.localId) : order; | ||||
|   } | ||||
|  | ||||
|   static int compareById(Asset a, Asset b) => a.id.compareTo(b.id); | ||||
|  | ||||
|   static int compareByLocalId(Asset a, Asset b) => | ||||
|       a.localId.compareTo(b.localId); | ||||
| } | ||||
|  | ||||
| extension AssetsHelper on IsarCollection<Asset> { | ||||
|   Future<int> deleteAllByRemoteId(Iterable<String> ids) => | ||||
|       ids.isEmpty ? Future.value(0) : _remote(ids).deleteAll(); | ||||
|   Future<int> deleteAllByLocalId(Iterable<String> ids) => | ||||
|       ids.isEmpty ? Future.value(0) : _local(ids).deleteAll(); | ||||
|   Future<List<Asset>> getAllByRemoteId(Iterable<String> ids) => | ||||
|       ids.isEmpty ? Future.value([]) : _remote(ids).findAll(); | ||||
|   Future<List<Asset>> getAllByLocalId(Iterable<String> ids) => | ||||
|       ids.isEmpty ? Future.value([]) : _local(ids).findAll(); | ||||
|  | ||||
|   QueryBuilder<Asset, Asset, QAfterWhereClause> _remote(Iterable<String> ids) => | ||||
|       where().anyOf(ids, (q, String e) => q.remoteIdEqualTo(e)); | ||||
|   QueryBuilder<Asset, Asset, QAfterWhereClause> _local(Iterable<String> ids) { | ||||
|     return where().anyOf( | ||||
|       ids, | ||||
|       (q, String e) => | ||||
|           q.localIdDeviceIdEqualTo(e, Store.get(StoreKey.deviceIdHash)), | ||||
|     ); | ||||
|   } | ||||
|     return null; | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										2244
									
								
								mobile/lib/shared/models/asset.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2244
									
								
								mobile/lib/shared/models/asset.g.dart
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,86 +1,93 @@ | ||||
| import 'package:isar/isar.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:immich_mobile/utils/builtin_extensions.dart'; | ||||
|  | ||||
| part 'exif_info.g.dart'; | ||||
|  | ||||
| /// Exif information 1:1 relation with Asset | ||||
| @Collection(inheritance: false) | ||||
| class ExifInfo { | ||||
|   Id? id; | ||||
|   int? fileSize; | ||||
|   String? make; | ||||
|   String? model; | ||||
|   String? orientation; | ||||
|   String? lensModel; | ||||
|   double? fNumber; | ||||
|   double? focalLength; | ||||
|   int? iso; | ||||
|   double? exposureTime; | ||||
|   String? lens; | ||||
|   float? f; | ||||
|   float? mm; | ||||
|   short? iso; | ||||
|   float? exposureSeconds; | ||||
|   float? lat; | ||||
|   float? long; | ||||
|   String? city; | ||||
|   String? state; | ||||
|   String? country; | ||||
|  | ||||
|   @ignore | ||||
|   String get exposureTime { | ||||
|     if (exposureSeconds == null) { | ||||
|       return ""; | ||||
|     } else if (exposureSeconds! < 1) { | ||||
|       return "1/${(1.0 / exposureSeconds!).round()} s"; | ||||
|     } else { | ||||
|       return "${exposureSeconds!.toStringAsFixed(1)} s"; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @ignore | ||||
|   String get fNumber => f != null ? f!.toStringAsFixed(1) : ""; | ||||
|  | ||||
|   @ignore | ||||
|   String get focalLength => mm != null ? mm!.toStringAsFixed(1) : ""; | ||||
|  | ||||
|   @ignore | ||||
|   double? get latitude => lat; | ||||
|  | ||||
|   @ignore | ||||
|   double? get longitude => long; | ||||
|  | ||||
|   ExifInfo.fromDto(ExifResponseDto dto) | ||||
|       : fileSize = dto.fileSizeInByte, | ||||
|         make = dto.make, | ||||
|         model = dto.model, | ||||
|         orientation = dto.orientation, | ||||
|         lensModel = dto.lensModel, | ||||
|         fNumber = dto.fNumber?.toDouble(), | ||||
|         focalLength = dto.focalLength?.toDouble(), | ||||
|         lens = dto.lensModel, | ||||
|         f = dto.fNumber?.toDouble(), | ||||
|         mm = dto.focalLength?.toDouble(), | ||||
|         iso = dto.iso?.toInt(), | ||||
|         exposureTime = dto.exposureTime?.toDouble(), | ||||
|         exposureSeconds = _exposureTimeToSeconds(dto.exposureTime), | ||||
|         lat = dto.latitude?.toDouble(), | ||||
|         long = dto.longitude?.toDouble(), | ||||
|         city = dto.city, | ||||
|         state = dto.state, | ||||
|         country = dto.country; | ||||
|  | ||||
|   // stuff below is only required for caching as JSON | ||||
|  | ||||
|   ExifInfo( | ||||
|   ExifInfo({ | ||||
|     this.fileSize, | ||||
|     this.make, | ||||
|     this.model, | ||||
|     this.orientation, | ||||
|     this.lensModel, | ||||
|     this.fNumber, | ||||
|     this.focalLength, | ||||
|     this.lens, | ||||
|     this.f, | ||||
|     this.mm, | ||||
|     this.iso, | ||||
|     this.exposureTime, | ||||
|     this.exposureSeconds, | ||||
|     this.lat, | ||||
|     this.long, | ||||
|     this.city, | ||||
|     this.state, | ||||
|     this.country, | ||||
|   ); | ||||
|   }); | ||||
| } | ||||
|  | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
|     json["fileSize"] = fileSize; | ||||
|     json["make"] = make; | ||||
|     json["model"] = model; | ||||
|     json["orientation"] = orientation; | ||||
|     json["lensModel"] = lensModel; | ||||
|     json["fNumber"] = fNumber; | ||||
|     json["focalLength"] = focalLength; | ||||
|     json["iso"] = iso; | ||||
|     json["exposureTime"] = exposureTime; | ||||
|     json["city"] = city; | ||||
|     json["state"] = state; | ||||
|     json["country"] = country; | ||||
|     return json; | ||||
|   } | ||||
|  | ||||
|   static ExifInfo? fromJson(dynamic value) { | ||||
|     if (value is Map) { | ||||
|       final json = value.cast<String, dynamic>(); | ||||
|       return ExifInfo( | ||||
|         json["fileSize"], | ||||
|         json["make"], | ||||
|         json["model"], | ||||
|         json["orientation"], | ||||
|         json["lensModel"], | ||||
|         json["fNumber"], | ||||
|         json["focalLength"], | ||||
|         json["iso"], | ||||
|         json["exposureTime"], | ||||
|         json["city"], | ||||
|         json["state"], | ||||
|         json["country"], | ||||
|       ); | ||||
|     } | ||||
| double? _exposureTimeToSeconds(String? s) { | ||||
|   if (s == null) { | ||||
|     return null; | ||||
|   } | ||||
|   double? value = double.tryParse(s); | ||||
|   if (value != null) { | ||||
|     return value; | ||||
|   } | ||||
|   final parts = s.split("/"); | ||||
|   if (parts.length == 2) { | ||||
|     return parts[0].toDouble() / parts[1].toDouble(); | ||||
|   } | ||||
|   return null; | ||||
| } | ||||
|   | ||||
							
								
								
									
										2304
									
								
								mobile/lib/shared/models/exif_info.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2304
									
								
								mobile/lib/shared/models/exif_info.g.dart
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,3 +1,4 @@ | ||||
| import 'package:immich_mobile/shared/models/user.dart'; | ||||
| import 'package:isar/isar.dart'; | ||||
| import 'dart:convert'; | ||||
|  | ||||
| @@ -25,26 +26,28 @@ class Store { | ||||
|  | ||||
|   /// Returns the stored value for the given key, or the default value if null | ||||
|   static T? get<T>(StoreKey key, [T? defaultValue]) => | ||||
|       _cache[key._id] ?? defaultValue; | ||||
|       _cache[key.id] ?? defaultValue; | ||||
|  | ||||
|   /// Stores the value synchronously in the cache and asynchronously in the DB | ||||
|   static Future<void> put<T>(StoreKey key, T value) { | ||||
|     _cache[key._id] = value; | ||||
|     return _db.writeTxn(() => _db.storeValues.put(StoreValue._of(value, key))); | ||||
|     _cache[key.id] = value; | ||||
|     return _db.writeTxn( | ||||
|       () async => _db.storeValues.put(await StoreValue._of(value, key)), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   /// Removes the value synchronously from the cache and asynchronously from the DB | ||||
|   static Future<void> delete(StoreKey key) { | ||||
|     _cache[key._id] = null; | ||||
|     return _db.writeTxn(() => _db.storeValues.delete(key._id)); | ||||
|     _cache[key.id] = null; | ||||
|     return _db.writeTxn(() => _db.storeValues.delete(key.id)); | ||||
|   } | ||||
|  | ||||
|   /// Fills the cache with the values from the DB | ||||
|   static _populateCache() { | ||||
|     for (StoreKey key in StoreKey.values) { | ||||
|       final StoreValue? value = _db.storeValues.getSync(key._id); | ||||
|       final StoreValue? value = _db.storeValues.getSync(key.id); | ||||
|       if (value != null) { | ||||
|         _cache[key._id] = value._extract(key); | ||||
|         _cache[key.id] = value._extract(key); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| @@ -67,17 +70,22 @@ class StoreValue { | ||||
|   int? intValue; | ||||
|   String? strValue; | ||||
|  | ||||
|   T? _extract<T>(StoreKey key) => key._isInt | ||||
|       ? intValue | ||||
|       : (key._fromJson != null | ||||
|           ? key._fromJson!(json.decode(strValue!)) | ||||
|   T? _extract<T>(StoreKey key) => key.isInt | ||||
|       ? (key.fromDb == null ? intValue : key.fromDb!.call(Store._db, intValue!)) | ||||
|       : (key.fromJson != null | ||||
|           ? key.fromJson!(json.decode(strValue!)) | ||||
|           : strValue); | ||||
|   static StoreValue _of(dynamic value, StoreKey key) => StoreValue( | ||||
|         key._id, | ||||
|         intValue: key._isInt ? value : null, | ||||
|         strValue: key._isInt | ||||
|   static Future<StoreValue> _of(dynamic value, StoreKey key) async => | ||||
|       StoreValue( | ||||
|         key.id, | ||||
|         intValue: key.isInt | ||||
|             ? (key.toDb == null | ||||
|                 ? value | ||||
|                 : await key.toDb!.call(Store._db, value)) | ||||
|             : null, | ||||
|         strValue: key.isInt | ||||
|             ? null | ||||
|             : (key._fromJson == null ? value : json.encode(value.toJson())), | ||||
|             : (key.fromJson == null ? value : json.encode(value.toJson())), | ||||
|       ); | ||||
| } | ||||
|  | ||||
| @@ -86,11 +94,28 @@ class StoreValue { | ||||
| enum StoreKey { | ||||
|   userRemoteId(0), | ||||
|   assetETag(1), | ||||
|   currentUser(2, isInt: true, fromDb: _getUser, toDb: _toUser), | ||||
|   deviceIdHash(3, isInt: true), | ||||
|   deviceId(4), | ||||
|   ; | ||||
|  | ||||
|   const StoreKey( | ||||
|     this.id, { | ||||
|     this.isInt = false, | ||||
|     this.fromDb, | ||||
|     this.toDb, | ||||
|     // ignore: unused_element | ||||
|   const StoreKey(this._id, [this._isInt = false, this._fromJson]); | ||||
|   final int _id; | ||||
|   final bool _isInt; | ||||
|   final Function(dynamic)? _fromJson; | ||||
|     this.fromJson, | ||||
|   }); | ||||
|   final int id; | ||||
|   final bool isInt; | ||||
|   final dynamic Function(Isar, int)? fromDb; | ||||
|   final Future<int> Function(Isar, dynamic)? toDb; | ||||
|   final Function(dynamic)? fromJson; | ||||
| } | ||||
|  | ||||
| User? _getUser(Isar db, int i) => db.users.getSync(i); | ||||
| Future<int> _toUser(Isar db, dynamic u) { | ||||
|   User user = (u as User); | ||||
|   return db.users.put(user); | ||||
| } | ||||
|   | ||||
| @@ -1,94 +1,63 @@ | ||||
| import 'package:immich_mobile/shared/models/album.dart'; | ||||
| import 'package:immich_mobile/utils/hash.dart'; | ||||
| import 'package:isar/isar.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
|  | ||||
| part 'user.g.dart'; | ||||
|  | ||||
| @Collection(inheritance: false) | ||||
| class User { | ||||
|   User({ | ||||
|     required this.id, | ||||
|     required this.updatedAt, | ||||
|     required this.email, | ||||
|     required this.firstName, | ||||
|     required this.lastName, | ||||
|     required this.profileImagePath, | ||||
|     required this.isAdmin, | ||||
|     required this.oauthId, | ||||
|   }); | ||||
|  | ||||
|   Id get isarId => fastHash(id); | ||||
|  | ||||
|   User.fromDto(UserResponseDto dto) | ||||
|       : id = dto.id, | ||||
|         updatedAt = dto.updatedAt != null | ||||
|             ? DateTime.parse(dto.updatedAt!).toUtc() | ||||
|             : DateTime.now().toUtc(), | ||||
|         email = dto.email, | ||||
|         firstName = dto.firstName, | ||||
|         lastName = dto.lastName, | ||||
|         profileImagePath = dto.profileImagePath, | ||||
|         isAdmin = dto.isAdmin, | ||||
|         oauthId = dto.oauthId; | ||||
|         isAdmin = dto.isAdmin; | ||||
|  | ||||
|   @Index(unique: true, replace: false, type: IndexType.hash) | ||||
|   String id; | ||||
|   DateTime updatedAt; | ||||
|   String email; | ||||
|   String firstName; | ||||
|   String lastName; | ||||
|   String profileImagePath; | ||||
|   bool isAdmin; | ||||
|   String oauthId; | ||||
|   @Backlink(to: 'owner') | ||||
|   final IsarLinks<Album> albums = IsarLinks<Album>(); | ||||
|   @Backlink(to: 'sharedUsers') | ||||
|   final IsarLinks<Album> sharedAlbums = IsarLinks<Album>(); | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(other) { | ||||
|     if (other is! User) return false; | ||||
|     return id == other.id && | ||||
|         updatedAt == other.updatedAt && | ||||
|         email == other.email && | ||||
|         firstName == other.firstName && | ||||
|         lastName == other.lastName && | ||||
|         profileImagePath == other.profileImagePath && | ||||
|         isAdmin == other.isAdmin && | ||||
|         oauthId == other.oauthId; | ||||
|         isAdmin == other.isAdmin; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   @ignore | ||||
|   int get hashCode => | ||||
|       id.hashCode ^ | ||||
|       updatedAt.hashCode ^ | ||||
|       email.hashCode ^ | ||||
|       firstName.hashCode ^ | ||||
|       lastName.hashCode ^ | ||||
|       profileImagePath.hashCode ^ | ||||
|       isAdmin.hashCode ^ | ||||
|       oauthId.hashCode; | ||||
|  | ||||
|   UserResponseDto toDto() { | ||||
|     return UserResponseDto( | ||||
|       id: id, | ||||
|       email: email, | ||||
|       firstName: firstName, | ||||
|       lastName: lastName, | ||||
|       profileImagePath: profileImagePath, | ||||
|       createdAt: '', | ||||
|       isAdmin: isAdmin, | ||||
|       shouldChangePassword: false, | ||||
|       oauthId: oauthId, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
|     json["id"] = id; | ||||
|     json["email"] = email; | ||||
|     json["firstName"] = firstName; | ||||
|     json["lastName"] = lastName; | ||||
|     json["profileImagePath"] = profileImagePath; | ||||
|     json["isAdmin"] = isAdmin; | ||||
|     json["oauthId"] = oauthId; | ||||
|     return json; | ||||
|   } | ||||
|  | ||||
|   static User? fromJson(dynamic value) { | ||||
|     if (value is Map) { | ||||
|       final json = value.cast<String, dynamic>(); | ||||
|       return User( | ||||
|         id: json["id"], | ||||
|         email: json["email"], | ||||
|         firstName: json["firstName"], | ||||
|         lastName: json["lastName"], | ||||
|         profileImagePath: json["profileImagePath"], | ||||
|         isAdmin: json["isAdmin"], | ||||
|         oauthId: json["oauthId"], | ||||
|       ); | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
|       isAdmin.hashCode; | ||||
| } | ||||
|   | ||||
							
								
								
									
										1338
									
								
								mobile/lib/shared/models/user.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1338
									
								
								mobile/lib/shared/models/user.g.dart
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,20 +1,19 @@ | ||||
| import 'dart:collection'; | ||||
|  | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:hive/hive.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/constants/hive_box.dart'; | ||||
| import 'package:immich_mobile/modules/album/services/album.service.dart'; | ||||
| import 'package:immich_mobile/shared/models/album.dart'; | ||||
| import 'package:immich_mobile/shared/models/exif_info.dart'; | ||||
| import 'package:immich_mobile/shared/models/store.dart'; | ||||
| import 'package:immich_mobile/shared/models/user.dart'; | ||||
| import 'package:immich_mobile/shared/providers/db.provider.dart'; | ||||
| import 'package:immich_mobile/shared/services/asset.service.dart'; | ||||
| import 'package:immich_mobile/shared/services/asset_cache.service.dart'; | ||||
| import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart'; | ||||
| import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; | ||||
| import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/shared/services/device_info.service.dart'; | ||||
| import 'package:collection/collection.dart'; | ||||
| import 'package:immich_mobile/utils/tuple.dart'; | ||||
| import 'package:intl/intl.dart'; | ||||
| import 'package:isar/isar.dart'; | ||||
| import 'package:logging/logging.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:photo_manager/photo_manager.dart'; | ||||
| @@ -50,50 +49,36 @@ class AssetsState { | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _CombineAssetsComputeParameters { | ||||
|   final Iterable<Asset> local; | ||||
|   final Iterable<Asset> remote; | ||||
|   final String deviceId; | ||||
|  | ||||
|   _CombineAssetsComputeParameters(this.local, this.remote, this.deviceId); | ||||
| } | ||||
|  | ||||
| class AssetNotifier extends StateNotifier<AssetsState> { | ||||
|   final AssetService _assetService; | ||||
|   final AssetCacheService _assetCacheService; | ||||
|   final AppSettingsService _settingsService; | ||||
|   final AlbumService _albumService; | ||||
|   final Isar _db; | ||||
|   final log = Logger('AssetNotifier'); | ||||
|   final DeviceInfoService _deviceInfoService = DeviceInfoService(); | ||||
|   bool _getAllAssetInProgress = false; | ||||
|   bool _deleteInProgress = false; | ||||
|  | ||||
|   AssetNotifier( | ||||
|     this._assetService, | ||||
|     this._assetCacheService, | ||||
|     this._settingsService, | ||||
|     this._albumService, | ||||
|     this._db, | ||||
|   ) : super(AssetsState.fromAssetList([])); | ||||
|  | ||||
|   Future<void> _updateAssetsState( | ||||
|     List<Asset> newAssetList, { | ||||
|     bool cache = true, | ||||
|   }) async { | ||||
|     if (cache) { | ||||
|       _assetCacheService.put(newAssetList); | ||||
|     } | ||||
|  | ||||
|   Future<void> _updateAssetsState(List<Asset> newAssetList) async { | ||||
|     final layout = AssetGridLayoutParameters( | ||||
|       _settingsService.getSetting(AppSettingsEnum.tilesPerRow), | ||||
|       _settingsService.getSetting(AppSettingsEnum.dynamicLayout), | ||||
|       GroupAssetsBy.values[_settingsService.getSetting(AppSettingsEnum.groupAssetsBy)], | ||||
|       GroupAssetsBy | ||||
|           .values[_settingsService.getSetting(AppSettingsEnum.groupAssetsBy)], | ||||
|     ); | ||||
|  | ||||
|     state = await AssetsState.fromAssetList(newAssetList) | ||||
|         .withRenderDataStructure(layout); | ||||
|   } | ||||
|  | ||||
|   // Just a little helper to trigger a rebuild of the state object | ||||
|   Future<void> rebuildAssetGridDataStructure() async { | ||||
|     await _updateAssetsState(state.allAssets, cache: false); | ||||
|     await _updateAssetsState(state.allAssets); | ||||
|   } | ||||
|  | ||||
|   getAllAsset() async { | ||||
| @@ -104,127 +89,102 @@ class AssetNotifier extends StateNotifier<AssetsState> { | ||||
|     final stopwatch = Stopwatch(); | ||||
|     try { | ||||
|       _getAllAssetInProgress = true; | ||||
|       bool isCacheValid = await _assetCacheService.isValid(); | ||||
|       final User me = Store.get(StoreKey.currentUser); | ||||
|       final int cachedCount = | ||||
|           await _db.assets.filter().ownerIdEqualTo(me.isarId).count(); | ||||
|       stopwatch.start(); | ||||
|       if (isCacheValid && state.allAssets.isEmpty) { | ||||
|         final List<Asset>? cachedData = await _assetCacheService.get(); | ||||
|         if (cachedData == null) { | ||||
|           isCacheValid = false; | ||||
|           log.warning("Cached asset data is invalid, fetching new data"); | ||||
|         } else { | ||||
|           await _updateAssetsState(cachedData, cache: false); | ||||
|       if (cachedCount > 0 && cachedCount != state.allAssets.length) { | ||||
|         await _updateAssetsState(await _getUserAssets(me.isarId)); | ||||
|         log.info( | ||||
|             "Reading assets ${state.allAssets.length} from cache: ${stopwatch.elapsedMilliseconds}ms", | ||||
|           "Reading assets ${state.allAssets.length} from DB: ${stopwatch.elapsedMilliseconds}ms", | ||||
|         ); | ||||
|         } | ||||
|         stopwatch.reset(); | ||||
|       } | ||||
|       final localTask = _assetService.getLocalAssets(urgent: !isCacheValid); | ||||
|       final remoteTask = _assetService.getRemoteAssets( | ||||
|         etag: isCacheValid ? Store.get(StoreKey.assetETag) : null, | ||||
|       ); | ||||
|  | ||||
|       int remoteBegin = state.allAssets.indexWhere((a) => a.isRemote); | ||||
|       remoteBegin = remoteBegin == -1 ? state.allAssets.length : remoteBegin; | ||||
|  | ||||
|       final List<Asset> currentLocal = state.allAssets.slice(0, remoteBegin); | ||||
|  | ||||
|       final Pair<List<Asset>?, String?> remoteResult = await remoteTask; | ||||
|       List<Asset>? newRemote = remoteResult.first; | ||||
|       List<Asset>? newLocal = await localTask; | ||||
|       final bool newRemote = await _assetService.refreshRemoteAssets(); | ||||
|       final bool newLocal = await _albumService.refreshDeviceAlbums(); | ||||
|       log.info("Load assets: ${stopwatch.elapsedMilliseconds}ms"); | ||||
|       stopwatch.reset(); | ||||
|       if (newRemote == null && | ||||
|           (newLocal == null || currentLocal.equals(newLocal))) { | ||||
|       if (!newRemote && !newLocal) { | ||||
|         log.info("state is already up-to-date"); | ||||
|         return; | ||||
|       } | ||||
|       newRemote ??= state.allAssets.slice(remoteBegin); | ||||
|       newLocal ??= []; | ||||
|  | ||||
|       final combinedAssets = await _combineLocalAndRemoteAssets( | ||||
|         local: newLocal, | ||||
|         remote: newRemote, | ||||
|       ); | ||||
|       await _updateAssetsState(combinedAssets); | ||||
|  | ||||
|       log.info("Combining assets: ${stopwatch.elapsedMilliseconds}ms"); | ||||
|  | ||||
|       Store.put(StoreKey.assetETag, remoteResult.second); | ||||
|       stopwatch.reset(); | ||||
|       final assets = await _getUserAssets(me.isarId); | ||||
|       if (!const ListEquality().equals(assets, state.allAssets)) { | ||||
|         log.info("setting new asset state"); | ||||
|         await _updateAssetsState(assets); | ||||
|       } | ||||
|     } finally { | ||||
|       _getAllAssetInProgress = false; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   static Future<List<Asset>> _computeCombine( | ||||
|     _CombineAssetsComputeParameters data, | ||||
|   ) async { | ||||
|     var local = data.local; | ||||
|     var remote = data.remote; | ||||
|     final deviceId = data.deviceId; | ||||
|   Future<List<Asset>> _getUserAssets(int userId) => _db.assets | ||||
|       .filter() | ||||
|       .ownerIdEqualTo(userId) | ||||
|       .sortByFileCreatedAtDesc() | ||||
|       .findAll(); | ||||
|  | ||||
|     final List<Asset> assets = []; | ||||
|     if (remote.isNotEmpty && local.isNotEmpty) { | ||||
|       final Set<String> existingIds = remote | ||||
|           .where((e) => e.deviceId == deviceId) | ||||
|           .map((e) => e.deviceAssetId) | ||||
|           .toSet(); | ||||
|       local = local.where((e) => !existingIds.contains(e.id)); | ||||
|     } | ||||
|     assets.addAll(local); | ||||
|     // the order (first all local, then remote assets) is important! | ||||
|     assets.addAll(remote); | ||||
|     return assets; | ||||
|   Future<void> clearAllAsset() { | ||||
|     state = AssetsState.empty(); | ||||
|     return _db.writeTxn(() async { | ||||
|       await _db.assets.clear(); | ||||
|       await _db.exifInfos.clear(); | ||||
|       await _db.albums.clear(); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   Future<List<Asset>> _combineLocalAndRemoteAssets({ | ||||
|     required Iterable<Asset> local, | ||||
|     required List<Asset> remote, | ||||
|   }) async { | ||||
|     final String deviceId = Hive.box(userInfoBox).get(deviceIdKey); | ||||
|     return await compute( | ||||
|       _computeCombine, | ||||
|       _CombineAssetsComputeParameters(local, remote, deviceId), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   clearAllAsset() { | ||||
|     _updateAssetsState([]); | ||||
|   } | ||||
|  | ||||
|   void onNewAssetUploaded(Asset newAsset) { | ||||
|   Future<void> onNewAssetUploaded(Asset newAsset) async { | ||||
|     final int i = state.allAssets.indexWhere( | ||||
|       (a) => | ||||
|           a.isRemote || | ||||
|           (a.id == newAsset.deviceAssetId && a.deviceId == newAsset.deviceId), | ||||
|           (a.localId == newAsset.localId && a.deviceId == newAsset.deviceId), | ||||
|     ); | ||||
|  | ||||
|     if (i == -1 || state.allAssets[i].deviceAssetId != newAsset.deviceAssetId) { | ||||
|       _updateAssetsState([...state.allAssets, newAsset]); | ||||
|     if (i == -1 || | ||||
|         state.allAssets[i].localId != newAsset.localId || | ||||
|         state.allAssets[i].deviceId != newAsset.deviceId) { | ||||
|       await _updateAssetsState([...state.allAssets, newAsset]); | ||||
|     } else { | ||||
|       // unify local/remote assets by replacing the | ||||
|       // local-only asset in the DB with a local&remote asset | ||||
|       final Asset? inDb = await _db.assets | ||||
|           .where() | ||||
|           .localIdDeviceIdEqualTo(newAsset.localId, newAsset.deviceId) | ||||
|           .findFirst(); | ||||
|       if (inDb != null) { | ||||
|         newAsset.id = inDb.id; | ||||
|         newAsset.isLocal = inDb.isLocal; | ||||
|       } | ||||
|  | ||||
|       // order is important to keep all local-only assets at the beginning! | ||||
|       _updateAssetsState([ | ||||
|       await _updateAssetsState([ | ||||
|         ...state.allAssets.slice(0, i), | ||||
|         ...state.allAssets.slice(i + 1), | ||||
|         newAsset, | ||||
|       ]); | ||||
|       // TODO here is a place to unify local/remote assets by replacing the | ||||
|       // local-only asset in the state with a local&remote asset | ||||
|     } | ||||
|     try { | ||||
|       await _db.writeTxn(() => newAsset.put(_db)); | ||||
|     } on IsarError catch (e) { | ||||
|       debugPrint(e.toString()); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   deleteAssets(Set<Asset> deleteAssets) async { | ||||
|   Future<void> deleteAssets(Set<Asset> deleteAssets) async { | ||||
|     _deleteInProgress = true; | ||||
|     try { | ||||
|       _updateAssetsState( | ||||
|         state.allAssets.whereNot(deleteAssets.contains).toList(), | ||||
|       ); | ||||
|       final localDeleted = await _deleteLocalAssets(deleteAssets); | ||||
|       final remoteDeleted = await _deleteRemoteAssets(deleteAssets); | ||||
|       final Set<String> deleted = HashSet(); | ||||
|       deleted.addAll(localDeleted); | ||||
|       deleted.addAll(remoteDeleted); | ||||
|       if (deleted.isNotEmpty) { | ||||
|         _updateAssetsState( | ||||
|           state.allAssets.where((a) => !deleted.contains(a.id)).toList(), | ||||
|         ); | ||||
|       if (localDeleted.isNotEmpty || remoteDeleted.isNotEmpty) { | ||||
|         final dbIds = deleteAssets.map((e) => e.id).toList(); | ||||
|         await _db.writeTxn(() async { | ||||
|           await _db.exifInfos.deleteAll(dbIds); | ||||
|           await _db.assets.deleteAll(dbIds); | ||||
|         }); | ||||
|       } | ||||
|     } finally { | ||||
|       _deleteInProgress = false; | ||||
| @@ -232,16 +192,15 @@ class AssetNotifier extends StateNotifier<AssetsState> { | ||||
|   } | ||||
|  | ||||
|   Future<List<String>> _deleteLocalAssets(Set<Asset> assetsToDelete) async { | ||||
|     var deviceInfo = await _deviceInfoService.getDeviceInfo(); | ||||
|     var deviceId = deviceInfo["deviceId"]; | ||||
|     final int deviceId = Store.get(StoreKey.deviceIdHash); | ||||
|     final List<String> local = []; | ||||
|     // Delete asset from device | ||||
|     for (final Asset asset in assetsToDelete) { | ||||
|       if (asset.isLocal) { | ||||
|         local.add(asset.localId!); | ||||
|         local.add(asset.localId); | ||||
|       } else if (asset.deviceId == deviceId) { | ||||
|         // Delete asset on device if it is still present | ||||
|         var localAsset = await AssetEntity.fromId(asset.deviceAssetId); | ||||
|         var localAsset = await AssetEntity.fromId(asset.localId); | ||||
|         if (localAsset != null) { | ||||
|           local.add(localAsset.id); | ||||
|         } | ||||
| @@ -249,7 +208,7 @@ class AssetNotifier extends StateNotifier<AssetsState> { | ||||
|     } | ||||
|     if (local.isNotEmpty) { | ||||
|       try { | ||||
|         return await PhotoManager.editor.deleteWithIds(local); | ||||
|         await PhotoManager.editor.deleteWithIds(local); | ||||
|       } catch (e, stack) { | ||||
|         log.severe("Failed to delete asset from device", e, stack); | ||||
|       } | ||||
| @@ -289,8 +248,9 @@ class AssetNotifier extends StateNotifier<AssetsState> { | ||||
| final assetProvider = StateNotifierProvider<AssetNotifier, AssetsState>((ref) { | ||||
|   return AssetNotifier( | ||||
|     ref.watch(assetServiceProvider), | ||||
|     ref.watch(assetCacheServiceProvider), | ||||
|     ref.watch(appSettingsServiceProvider), | ||||
|     ref.watch(albumServiceProvider), | ||||
|     ref.watch(dbProvider), | ||||
|   ); | ||||
| }); | ||||
|  | ||||
|   | ||||
| @@ -28,9 +28,13 @@ class ApiService { | ||||
|       debugPrint("Cannot init ApiServer endpoint, userInfoBox not open yet."); | ||||
|     } | ||||
|   } | ||||
|   String? _authToken; | ||||
|  | ||||
|   setEndpoint(String endpoint) { | ||||
|     _apiClient = ApiClient(basePath: endpoint); | ||||
|     if (_authToken != null) { | ||||
|       setAccessToken(_authToken!); | ||||
|     } | ||||
|     userApi = UserApi(_apiClient); | ||||
|     authenticationApi = AuthenticationApi(_apiClient); | ||||
|     oAuthApi = OAuthApi(_apiClient); | ||||
| @@ -94,6 +98,9 @@ class ApiService { | ||||
|   } | ||||
|  | ||||
|   setAccessToken(String accessToken) { | ||||
|     _authToken = accessToken; | ||||
|     _apiClient.addDefaultHeader('Authorization', 'Bearer $accessToken'); | ||||
|   } | ||||
|  | ||||
|   ApiClient get apiClient => _apiClient; | ||||
| } | ||||
|   | ||||
| @@ -1,99 +1,82 @@ | ||||
| import 'dart:async'; | ||||
|  | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:hive/hive.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/constants/hive_box.dart'; | ||||
| import 'package:immich_mobile/modules/backup/background_service/background.service.dart'; | ||||
| import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart'; | ||||
| import 'package:immich_mobile/modules/backup/services/backup.service.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/shared/models/exif_info.dart'; | ||||
| import 'package:immich_mobile/shared/models/store.dart'; | ||||
| import 'package:immich_mobile/shared/models/user.dart'; | ||||
| import 'package:immich_mobile/shared/providers/api.provider.dart'; | ||||
| import 'package:immich_mobile/shared/providers/db.provider.dart'; | ||||
| import 'package:immich_mobile/shared/services/api.service.dart'; | ||||
| import 'package:immich_mobile/shared/services/sync.service.dart'; | ||||
| import 'package:immich_mobile/utils/openapi_extensions.dart'; | ||||
| import 'package:immich_mobile/utils/tuple.dart'; | ||||
| import 'package:isar/isar.dart'; | ||||
| import 'package:logging/logging.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
|  | ||||
| final assetServiceProvider = Provider( | ||||
|   (ref) => AssetService( | ||||
|     ref.watch(apiServiceProvider), | ||||
|     ref.watch(backupServiceProvider), | ||||
|     ref.watch(backgroundServiceProvider), | ||||
|     ref.watch(syncServiceProvider), | ||||
|     ref.watch(dbProvider), | ||||
|   ), | ||||
| ); | ||||
|  | ||||
| class AssetService { | ||||
|   final ApiService _apiService; | ||||
|   final BackupService _backupService; | ||||
|   final BackgroundService _backgroundService; | ||||
|   final SyncService _syncService; | ||||
|   final log = Logger('AssetService'); | ||||
|   final Isar _db; | ||||
|  | ||||
|   AssetService(this._apiService, this._backupService, this._backgroundService); | ||||
|   AssetService( | ||||
|     this._apiService, | ||||
|     this._syncService, | ||||
|     this._db, | ||||
|   ); | ||||
|  | ||||
|   /// Checks the server for updated assets and updates the local database if | ||||
|   /// required. Returns `true` if there were any changes. | ||||
|   Future<bool> refreshRemoteAssets() async { | ||||
|     final Stopwatch sw = Stopwatch()..start(); | ||||
|     final int numOwnedRemoteAssets = await _db.assets | ||||
|         .where() | ||||
|         .remoteIdIsNotNull() | ||||
|         .filter() | ||||
|         .ownerIdEqualTo(Store.get<User>(StoreKey.currentUser)!.isarId) | ||||
|         .count(); | ||||
|     final List<AssetResponseDto>? dtos = | ||||
|         await _getRemoteAssets(hasCache: numOwnedRemoteAssets > 0); | ||||
|     if (dtos == null) { | ||||
|       debugPrint("refreshRemoteAssets fast took ${sw.elapsedMilliseconds}ms"); | ||||
|       return false; | ||||
|     } | ||||
|     final bool changes = await _syncService | ||||
|         .syncRemoteAssetsToDb(dtos.map(Asset.remote).toList()); | ||||
|     debugPrint("refreshRemoteAssets full took ${sw.elapsedMilliseconds}ms"); | ||||
|     return changes; | ||||
|   } | ||||
|  | ||||
|   /// Returns `null` if the server state did not change, else list of assets | ||||
|   Future<Pair<List<Asset>?, String?>> getRemoteAssets({String? etag}) async { | ||||
|   Future<List<AssetResponseDto>?> _getRemoteAssets({ | ||||
|     required bool hasCache, | ||||
|   }) async { | ||||
|     try { | ||||
|       // temporary fix for race condition that the _apiService | ||||
|       // get called before accessToken is set | ||||
|       var userInfoHiveBox = await Hive.openBox(userInfoBox); | ||||
|       var accessToken = userInfoHiveBox.get(accessTokenKey); | ||||
|       _apiService.setAccessToken(accessToken); | ||||
|  | ||||
|       final etag = hasCache ? Store.get(StoreKey.assetETag) : null; | ||||
|       final Pair<List<AssetResponseDto>, String?>? remote = | ||||
|           await _apiService.assetApi.getAllAssetsWithETag(eTag: etag); | ||||
|       if (remote == null) { | ||||
|         return Pair(null, etag); | ||||
|         return null; | ||||
|       } | ||||
|       return Pair( | ||||
|         remote.first.map(Asset.remote).toList(growable: false), | ||||
|         remote.second, | ||||
|       ); | ||||
|       if (remote.second != null && remote.second != etag) { | ||||
|         Store.put(StoreKey.assetETag, remote.second); | ||||
|       } | ||||
|       return remote.first; | ||||
|     } catch (e, stack) { | ||||
|       log.severe('Error while getting remote assets', e, stack); | ||||
|       debugPrint("[ERROR] [getRemoteAssets] $e"); | ||||
|       return Pair(null, etag); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /// if [urgent] is `true`, do not block by waiting on the background service | ||||
|   /// to finish running. Returns `null` instead after a timeout. | ||||
|   Future<List<Asset>?> getLocalAssets({bool urgent = false}) async { | ||||
|     try { | ||||
|       final Future<bool> hasAccess = urgent | ||||
|           ? _backgroundService.hasAccess | ||||
|               .timeout(const Duration(milliseconds: 250)) | ||||
|           : _backgroundService.hasAccess; | ||||
|       if (!await hasAccess) { | ||||
|         throw Exception("Error [getAllAsset] failed to gain access"); | ||||
|       } | ||||
|       final box = await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox); | ||||
|       final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey); | ||||
|       final String userId = Store.get(StoreKey.userRemoteId); | ||||
|       if (backupAlbumInfo != null) { | ||||
|         return (await _backupService | ||||
|                 .buildUploadCandidates(backupAlbumInfo.deepCopy())) | ||||
|             .map((e) => Asset.local(e, userId)) | ||||
|             .toList(growable: false); | ||||
|       } | ||||
|     } catch (e, stackTrace) { | ||||
|       log.severe('Error while getting local assets', e, stackTrace); | ||||
|       debugPrint("Error [_getLocalAssets] ${e.toString()}"); | ||||
|     } | ||||
|       return null; | ||||
|     } | ||||
|  | ||||
|   Future<Asset?> getAssetById(String assetId) async { | ||||
|     try { | ||||
|       final dto = await _apiService.assetApi.getAssetById(assetId); | ||||
|       if (dto != null) { | ||||
|         return Asset.remote(dto); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       debugPrint("Error [getAssetById]  ${e.toString()}"); | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   Future<List<DeleteAssetResponseDto>?> deleteAssets( | ||||
| @@ -114,6 +97,28 @@ class AssetService { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /// Loads the exif information from the database. If there is none, loads | ||||
|   /// the exif info from the server (remote assets only) | ||||
|   Future<Asset> loadExif(Asset a) async { | ||||
|     a.exifInfo ??= await _db.exifInfos.get(a.id); | ||||
|     if (a.exifInfo?.iso == null) { | ||||
|       if (a.isRemote) { | ||||
|         final dto = await _apiService.assetApi.getAssetById(a.remoteId!); | ||||
|         if (dto != null && dto.exifInfo != null) { | ||||
|           a = a.withUpdatesFromDto(dto); | ||||
|           if (a.isInDb) { | ||||
|             _db.writeTxn(() => a.put(_db)); | ||||
|           } else { | ||||
|             debugPrint("[loadExif] parameter Asset is not from DB!"); | ||||
|           } | ||||
|         } | ||||
|       } else { | ||||
|         // TODO implement local exif info parsing | ||||
|       } | ||||
|     } | ||||
|     return a; | ||||
|   } | ||||
|  | ||||
|   Future<Asset?> updateAsset( | ||||
|     Asset asset, | ||||
|     UpdateAssetDto updateAssetDto, | ||||
|   | ||||
| @@ -1,41 +1,13 @@ | ||||
| import 'package:collection/collection.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/shared/services/json_cache.dart'; | ||||
|  | ||||
| @Deprecated("only kept to remove its files after migration") | ||||
| class AssetCacheService extends JsonCache<List<Asset>> { | ||||
|   AssetCacheService() : super("asset_cache"); | ||||
|  | ||||
|   static Future<List<Map<String, dynamic>>> _computeSerialize( | ||||
|     List<Asset> assets, | ||||
|   ) async { | ||||
|     return assets.map((e) => e.toJson()).toList(); | ||||
|   } | ||||
|   @override | ||||
|   void put(List<Asset> data) {} | ||||
|  | ||||
|   @override | ||||
|   void put(List<Asset> data) async { | ||||
|     putRawData(await compute(_computeSerialize, data)); | ||||
|   } | ||||
|  | ||||
|   static Future<List<Asset>> _computeEncode(List<dynamic> data) async { | ||||
|     return data.map((e) => Asset.fromJson(e)).whereNotNull().toList(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Future<List<Asset>?> get() async { | ||||
|     try { | ||||
|       final mapList = await readRawData() as List<dynamic>; | ||||
|       final responseData = await compute(_computeEncode, mapList); | ||||
|       return responseData; | ||||
|     } catch (e) { | ||||
|       debugPrint(e.toString()); | ||||
|       await invalidate(); | ||||
|       return null; | ||||
|     } | ||||
|   } | ||||
|   Future<List<Asset>?> get() => Future.value(null); | ||||
| } | ||||
|  | ||||
| final assetCacheServiceProvider = Provider( | ||||
|   (ref) => AssetCacheService(), | ||||
| ); | ||||
|   | ||||
| @@ -1,9 +1,8 @@ | ||||
| import 'dart:convert'; | ||||
| import 'dart:io'; | ||||
|  | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:path_provider/path_provider.dart'; | ||||
|  | ||||
| @Deprecated("only kept to remove its files after migration") | ||||
| abstract class JsonCache<T> { | ||||
|   final String cacheFileName; | ||||
|  | ||||
| @@ -32,33 +31,6 @@ abstract class JsonCache<T> { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   static Future<String> _computeEncodeJson(dynamic toEncode) async { | ||||
|     return json.encode(toEncode); | ||||
|   } | ||||
|  | ||||
|   Future<void> putRawData(dynamic data) async { | ||||
|     final jsonString = await compute(_computeEncodeJson, data); | ||||
|  | ||||
|     final file = await _getCacheFile(); | ||||
|  | ||||
|     if (!await file.exists()) { | ||||
|       await file.create(); | ||||
|     } | ||||
|  | ||||
|     await file.writeAsString(jsonString); | ||||
|   } | ||||
|  | ||||
|   static Future<dynamic> _computeDecodeJson(String jsonString) async { | ||||
|     return json.decode(jsonString); | ||||
|   } | ||||
|  | ||||
|   Future<dynamic> readRawData() async { | ||||
|     final file = await _getCacheFile(); | ||||
|     final data = await file.readAsString(); | ||||
|  | ||||
|     return await compute(_computeDecodeJson, data); | ||||
|   } | ||||
|  | ||||
|   void put(T data); | ||||
|   Future<T?> get(); | ||||
| } | ||||
|   | ||||
							
								
								
									
										558
									
								
								mobile/lib/shared/services/sync.service.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										558
									
								
								mobile/lib/shared/services/sync.service.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,558 @@ | ||||
| import 'dart:async'; | ||||
|  | ||||
| import 'package:collection/collection.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/shared/models/album.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/shared/models/exif_info.dart'; | ||||
| import 'package:immich_mobile/shared/models/store.dart'; | ||||
| import 'package:immich_mobile/shared/models/user.dart'; | ||||
| import 'package:immich_mobile/shared/providers/db.provider.dart'; | ||||
| import 'package:immich_mobile/utils/async_mutex.dart'; | ||||
| import 'package:immich_mobile/utils/diff.dart'; | ||||
| import 'package:immich_mobile/utils/tuple.dart'; | ||||
| import 'package:isar/isar.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:photo_manager/photo_manager.dart'; | ||||
|  | ||||
| final syncServiceProvider = | ||||
|     Provider((ref) => SyncService(ref.watch(dbProvider))); | ||||
|  | ||||
| class SyncService { | ||||
|   final Isar _db; | ||||
|   final AsyncMutex _lock = AsyncMutex(); | ||||
|  | ||||
|   SyncService(this._db); | ||||
|  | ||||
|   // public methods: | ||||
|  | ||||
|   /// Syncs users from the server to the local database | ||||
|   /// Returns `true`if there were any changes | ||||
|   Future<bool> syncUsersFromServer(List<User> users) async { | ||||
|     users.sortBy((u) => u.id); | ||||
|     final dbUsers = await _db.users.where().sortById().findAll(); | ||||
|     final List<int> toDelete = []; | ||||
|     final List<User> toUpsert = []; | ||||
|     final changes = diffSortedListsSync( | ||||
|       users, | ||||
|       dbUsers, | ||||
|       compare: (User a, User b) => a.id.compareTo(b.id), | ||||
|       both: (User a, User b) { | ||||
|         if (a.updatedAt != b.updatedAt) { | ||||
|           toUpsert.add(a); | ||||
|           return true; | ||||
|         } | ||||
|         return false; | ||||
|       }, | ||||
|       onlyFirst: (User a) => toUpsert.add(a), | ||||
|       onlySecond: (User b) => toDelete.add(b.isarId), | ||||
|     ); | ||||
|     if (changes) { | ||||
|       await _db.writeTxn(() async { | ||||
|         await _db.users.deleteAll(toDelete); | ||||
|         await _db.users.putAll(toUpsert); | ||||
|       }); | ||||
|     } | ||||
|     return changes; | ||||
|   } | ||||
|  | ||||
|   /// Syncs remote assets owned by the logged-in user to the DB | ||||
|   /// Returns `true` if there were any changes | ||||
|   Future<bool> syncRemoteAssetsToDb(List<Asset> remote) => | ||||
|       _lock.run(() => _syncRemoteAssetsToDb(remote)); | ||||
|  | ||||
|   /// Syncs remote albums to the database | ||||
|   /// returns `true` if there were any changes | ||||
|   Future<bool> syncRemoteAlbumsToDb( | ||||
|     List<AlbumResponseDto> remote, { | ||||
|     required bool isShared, | ||||
|     required FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails, | ||||
|   }) => | ||||
|       _lock.run(() => _syncRemoteAlbumsToDb(remote, isShared, loadDetails)); | ||||
|  | ||||
|   /// Syncs all device albums and their assets to the database | ||||
|   /// Returns `true` if there were any changes | ||||
|   Future<bool> syncLocalAlbumAssetsToDb(List<AssetPathEntity> onDevice) => | ||||
|       _lock.run(() => _syncLocalAlbumAssetsToDb(onDevice)); | ||||
|  | ||||
|   /// returns all Asset IDs that are not contained in the existing list | ||||
|   List<int> sharedAssetsToRemove( | ||||
|     List<Asset> deleteCandidates, | ||||
|     List<Asset> existing, | ||||
|   ) { | ||||
|     if (deleteCandidates.isEmpty) { | ||||
|       return []; | ||||
|     } | ||||
|     deleteCandidates.sort(Asset.compareById); | ||||
|     existing.sort(Asset.compareById); | ||||
|     return _diffAssets(existing, deleteCandidates, compare: Asset.compareById) | ||||
|         .third | ||||
|         .map((e) => e.id) | ||||
|         .toList(); | ||||
|   } | ||||
|  | ||||
|   // private methods: | ||||
|  | ||||
|   /// Syncs remote assets to the databas | ||||
|   /// returns `true` if there were any changes | ||||
|   Future<bool> _syncRemoteAssetsToDb(List<Asset> remote) async { | ||||
|     final User user = Store.get(StoreKey.currentUser); | ||||
|     final List<Asset> inDb = await _db.assets | ||||
|         .filter() | ||||
|         .ownerIdEqualTo(user.isarId) | ||||
|         .sortByDeviceId() | ||||
|         .thenByLocalId() | ||||
|         .findAll(); | ||||
|     remote.sort(Asset.compareByDeviceIdLocalId); | ||||
|     final diff = _diffAssets(remote, inDb, remote: true); | ||||
|     if (diff.first.isEmpty && diff.second.isEmpty && diff.third.isEmpty) { | ||||
|       return false; | ||||
|     } | ||||
|     final idsToDelete = diff.third.map((e) => e.id).toList(); | ||||
|     try { | ||||
|       await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete)); | ||||
|       await _upsertAssetsWithExif(diff.first + diff.second); | ||||
|     } on IsarError catch (e) { | ||||
|       debugPrint(e.toString()); | ||||
|     } | ||||
|     return true; | ||||
|   } | ||||
|  | ||||
|   /// Syncs remote albums to the database | ||||
|   /// returns `true` if there were any changes | ||||
|   Future<bool> _syncRemoteAlbumsToDb( | ||||
|     List<AlbumResponseDto> remote, | ||||
|     bool isShared, | ||||
|     FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails, | ||||
|   ) async { | ||||
|     remote.sortBy((e) => e.id); | ||||
|  | ||||
|     final baseQuery = _db.albums.where().remoteIdIsNotNull().filter(); | ||||
|     final QueryBuilder<Album, Album, QAfterFilterCondition> query; | ||||
|     if (isShared) { | ||||
|       query = baseQuery.sharedEqualTo(true); | ||||
|     } else { | ||||
|       final User me = Store.get(StoreKey.currentUser); | ||||
|       query = baseQuery.owner((q) => q.isarIdEqualTo(me.isarId)); | ||||
|     } | ||||
|     final List<Album> dbAlbums = await query.sortByRemoteId().findAll(); | ||||
|  | ||||
|     final List<Asset> toDelete = []; | ||||
|     final List<Asset> existing = []; | ||||
|  | ||||
|     final bool changes = await diffSortedLists( | ||||
|       remote, | ||||
|       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), | ||||
|     ); | ||||
|  | ||||
|     if (isShared && toDelete.isNotEmpty) { | ||||
|       final List<int> idsToRemove = sharedAssetsToRemove(toDelete, existing); | ||||
|       if (idsToRemove.isNotEmpty) { | ||||
|         await _db.writeTxn(() => _db.assets.deleteAll(idsToRemove)); | ||||
|       } | ||||
|     } else { | ||||
|       assert(toDelete.isEmpty); | ||||
|     } | ||||
|     return changes; | ||||
|   } | ||||
|  | ||||
|   /// syncs albums from the server to the local database (does not support | ||||
|   /// syncing changes from local back to server) | ||||
|   /// accumulates | ||||
|   Future<bool> _syncRemoteAlbum( | ||||
|     AlbumResponseDto dto, | ||||
|     Album album, | ||||
|     List<Asset> deleteCandidates, | ||||
|     List<Asset> existing, | ||||
|     FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails, | ||||
|   ) async { | ||||
|     if (!_hasAlbumResponseDtoChanged(dto, album)) { | ||||
|       return false; | ||||
|     } | ||||
|     dto = await loadDetails(dto); | ||||
|     if (dto.assetCount != dto.assets.length) { | ||||
|       return false; | ||||
|     } | ||||
|     final assetsInDb = | ||||
|         await album.assets.filter().sortByDeviceId().thenByLocalId().findAll(); | ||||
|     final List<Asset> assetsOnRemote = dto.getAssets(); | ||||
|     assetsOnRemote.sort(Asset.compareByDeviceIdLocalId); | ||||
|     final d = _diffAssets(assetsOnRemote, assetsInDb); | ||||
|     final List<Asset> toAdd = d.first, toUpdate = d.second, toUnlink = d.third; | ||||
|  | ||||
|     // update shared users | ||||
|     final List<User> sharedUsers = album.sharedUsers.toList(growable: false); | ||||
|     sharedUsers.sort((a, b) => a.id.compareTo(b.id)); | ||||
|     dto.sharedUsers.sort((a, b) => a.id.compareTo(b.id)); | ||||
|     final List<String> userIdsToAdd = []; | ||||
|     final List<User> usersToUnlink = []; | ||||
|     diffSortedListsSync( | ||||
|       dto.sharedUsers, | ||||
|       sharedUsers, | ||||
|       compare: (UserResponseDto a, User b) => a.id.compareTo(b.id), | ||||
|       both: (a, b) => false, | ||||
|       onlyFirst: (UserResponseDto a) => userIdsToAdd.add(a.id), | ||||
|       onlySecond: (User a) => usersToUnlink.add(a), | ||||
|     ); | ||||
|  | ||||
|     // for shared album: put missing album assets into local DB | ||||
|     final resultPair = await _linkWithExistingFromDb(toAdd); | ||||
|     await _upsertAssetsWithExif(resultPair.second); | ||||
|     final assetsToLink = resultPair.first + resultPair.second; | ||||
|     final usersToLink = (await _db.users.getAllById(userIdsToAdd)).cast<User>(); | ||||
|  | ||||
|     album.name = dto.albumName; | ||||
|     album.shared = dto.shared; | ||||
|     album.modifiedAt = DateTime.parse(dto.updatedAt).toUtc(); | ||||
|     if (album.thumbnail.value?.remoteId != dto.albumThumbnailAssetId) { | ||||
|       album.thumbnail.value = await _db.assets | ||||
|           .where() | ||||
|           .remoteIdEqualTo(dto.albumThumbnailAssetId) | ||||
|           .findFirst(); | ||||
|     } | ||||
|  | ||||
|     // write & commit all changes to DB | ||||
|     try { | ||||
|       await _db.writeTxn(() async { | ||||
|         await _db.assets.putAll(toUpdate); | ||||
|         await album.thumbnail.save(); | ||||
|         await album.sharedUsers | ||||
|             .update(link: usersToLink, unlink: usersToUnlink); | ||||
|         await album.assets.update(link: assetsToLink, unlink: toUnlink.cast()); | ||||
|         await _db.albums.put(album); | ||||
|       }); | ||||
|     } on IsarError catch (e) { | ||||
|       debugPrint(e.toString()); | ||||
|     } | ||||
|  | ||||
|     if (album.shared || dto.shared) { | ||||
|       final userId = Store.get<User>(StoreKey.currentUser)!.isarId; | ||||
|       final foreign = | ||||
|           await album.assets.filter().not().ownerIdEqualTo(userId).findAll(); | ||||
|       existing.addAll(foreign); | ||||
|  | ||||
|       // delete assets in DB unless they belong to this user or part of some other shared album | ||||
|       deleteCandidates.addAll(toUnlink.where((a) => a.ownerId != userId)); | ||||
|     } | ||||
|  | ||||
|     return true; | ||||
|   } | ||||
|  | ||||
|   /// Adds a remote album to the database while making sure to add any foreign | ||||
|   /// (shared) assets to the database beforehand | ||||
|   /// accumulates assets already existing in the database | ||||
|   Future<void> _addAlbumFromServer( | ||||
|     AlbumResponseDto dto, | ||||
|     List<Asset> existing, | ||||
|     FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails, | ||||
|   ) async { | ||||
|     if (dto.assetCount != dto.assets.length) { | ||||
|       dto = await loadDetails(dto); | ||||
|     } | ||||
|     if (dto.assetCount == dto.assets.length) { | ||||
|       // in case an album contains assets not yet present in local DB: | ||||
|       // put missing album assets into local DB | ||||
|       final result = await _linkWithExistingFromDb(dto.getAssets()); | ||||
|       existing.addAll(result.first); | ||||
|       await _upsertAssetsWithExif(result.second); | ||||
|  | ||||
|       final Album a = await Album.remote(dto); | ||||
|       await _db.writeTxn(() => _db.albums.store(a)); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /// Accumulates all suitable album assets to the `deleteCandidates` and | ||||
|   /// removes the album from the database. | ||||
|   Future<void> _removeAlbumFromDb( | ||||
|     Album album, | ||||
|     List<Asset> deleteCandidates, | ||||
|   ) async { | ||||
|     if (album.isLocal) { | ||||
|       // delete assets in DB unless they are remote or part of some other album | ||||
|       deleteCandidates.addAll( | ||||
|         await album.assets.filter().remoteIdIsNull().findAll(), | ||||
|       ); | ||||
|     } else if (album.shared) { | ||||
|       final User user = Store.get(StoreKey.currentUser); | ||||
|       // delete assets in DB unless they belong to this user or are part of some other shared album | ||||
|       deleteCandidates.addAll( | ||||
|         await album.assets.filter().not().ownerIdEqualTo(user.isarId).findAll(), | ||||
|       ); | ||||
|     } | ||||
|     final bool ok = await _db.writeTxn(() => _db.albums.delete(album.id)); | ||||
|     assert(ok); | ||||
|   } | ||||
|  | ||||
|   /// Syncs all device albums and their assets to the database | ||||
|   /// Returns `true` if there were any changes | ||||
|   Future<bool> _syncLocalAlbumAssetsToDb(List<AssetPathEntity> onDevice) async { | ||||
|     onDevice.sort((a, b) => a.id.compareTo(b.id)); | ||||
|     final List<Album> inDb = | ||||
|         await _db.albums.where().localIdIsNotNull().sortByLocalId().findAll(); | ||||
|     final List<Asset> deleteCandidates = []; | ||||
|     final List<Asset> existing = []; | ||||
|     final bool anyChanges = await diffSortedLists( | ||||
|       onDevice, | ||||
|       inDb, | ||||
|       compare: (AssetPathEntity a, Album b) => a.id.compareTo(b.localId!), | ||||
|       both: (AssetPathEntity ape, Album album) => | ||||
|           _syncAlbumInDbAndOnDevice(ape, album, deleteCandidates, existing), | ||||
|       onlyFirst: (AssetPathEntity ape) => _addAlbumFromDevice(ape, existing), | ||||
|       onlySecond: (Album a) => _removeAlbumFromDb(a, deleteCandidates), | ||||
|     ); | ||||
|     final pair = _handleAssetRemoval(deleteCandidates, existing); | ||||
|     if (pair.first.isNotEmpty || pair.second.isNotEmpty) { | ||||
|       await _db.writeTxn(() async { | ||||
|         await _db.assets.deleteAll(pair.first); | ||||
|         await _db.assets.putAll(pair.second); | ||||
|       }); | ||||
|     } | ||||
|     return anyChanges; | ||||
|   } | ||||
|  | ||||
|   /// Syncs the device album to the album in the database | ||||
|   /// returns `true` if there were any changes | ||||
|   /// Accumulates asset candidates to delete and those already existing in DB | ||||
|   Future<bool> _syncAlbumInDbAndOnDevice( | ||||
|     AssetPathEntity ape, | ||||
|     Album album, | ||||
|     List<Asset> deleteCandidates, | ||||
|     List<Asset> existing, [ | ||||
|     bool forceRefresh = false, | ||||
|   ]) async { | ||||
|     if (!forceRefresh && !await _hasAssetPathEntityChanged(ape, album)) { | ||||
|       return false; | ||||
|     } | ||||
|     if (!forceRefresh && await _syncDeviceAlbumFast(ape, album)) { | ||||
|       return true; | ||||
|     } | ||||
|  | ||||
|     // general case, e.g. some assets have been deleted | ||||
|     final inDb = await album.assets.filter().sortByLocalId().findAll(); | ||||
|     final List<Asset> onDevice = await ape.getAssets(); | ||||
|     onDevice.sort(Asset.compareByLocalId); | ||||
|     final d = _diffAssets(onDevice, inDb, compare: Asset.compareByLocalId); | ||||
|     final List<Asset> toAdd = d.first, toUpdate = d.second, toDelete = d.third; | ||||
|     final result = await _linkWithExistingFromDb(toAdd); | ||||
|     deleteCandidates.addAll(toDelete); | ||||
|     existing.addAll(result.first); | ||||
|     album.name = ape.name; | ||||
|     album.modifiedAt = ape.lastModified!; | ||||
|     if (album.thumbnail.value != null && | ||||
|         toDelete.contains(album.thumbnail.value)) { | ||||
|       album.thumbnail.value = null; | ||||
|     } | ||||
|     try { | ||||
|       await _db.writeTxn(() async { | ||||
|         await _db.assets.putAll(result.second); | ||||
|         await _db.assets.putAll(toUpdate); | ||||
|         await album.assets | ||||
|             .update(link: result.first + result.second, unlink: toDelete); | ||||
|         await _db.albums.put(album); | ||||
|         album.thumbnail.value ??= await album.assets.filter().findFirst(); | ||||
|         await album.thumbnail.save(); | ||||
|       }); | ||||
|     } on IsarError catch (e) { | ||||
|       debugPrint(e.toString()); | ||||
|     } | ||||
|  | ||||
|     return true; | ||||
|   } | ||||
|  | ||||
|   /// fast path for common case: only new assets were added to device album | ||||
|   /// returns `true` if successfull, else `false` | ||||
|   Future<bool> _syncDeviceAlbumFast(AssetPathEntity ape, Album album) async { | ||||
|     final int totalOnDevice = await ape.assetCountAsync; | ||||
|     final AssetPathEntity? modified = totalOnDevice > album.assetCount | ||||
|         ? await ape.fetchPathProperties( | ||||
|             filterOptionGroup: FilterOptionGroup( | ||||
|               updateTimeCond: DateTimeCond( | ||||
|                 min: album.modifiedAt.add(const Duration(seconds: 1)), | ||||
|                 max: ape.lastModified!, | ||||
|               ), | ||||
|             ), | ||||
|           ) | ||||
|         : null; | ||||
|     if (modified == null) { | ||||
|       return false; | ||||
|     } | ||||
|     final List<Asset> newAssets = await modified.getAssets(); | ||||
|     if (totalOnDevice != album.assets.length + newAssets.length) { | ||||
|       return false; | ||||
|     } | ||||
|     album.modifiedAt = ape.lastModified!.toUtc(); | ||||
|     final result = await _linkWithExistingFromDb(newAssets); | ||||
|     try { | ||||
|       await _db.writeTxn(() async { | ||||
|         await _db.assets.putAll(result.second); | ||||
|         await album.assets.update(link: result.first + result.second); | ||||
|         await _db.albums.put(album); | ||||
|       }); | ||||
|     } on IsarError catch (e) { | ||||
|       debugPrint(e.toString()); | ||||
|     } | ||||
|  | ||||
|     return true; | ||||
|   } | ||||
|  | ||||
|   /// Adds a new album from the device to the database and Accumulates all | ||||
|   /// assets already existing in the database to the list of `existing` assets | ||||
|   Future<void> _addAlbumFromDevice( | ||||
|     AssetPathEntity ape, | ||||
|     List<Asset> existing, | ||||
|   ) async { | ||||
|     final Album a = Album.local(ape); | ||||
|     final result = await _linkWithExistingFromDb(await ape.getAssets()); | ||||
|     await _upsertAssetsWithExif(result.second); | ||||
|     existing.addAll(result.first); | ||||
|     a.assets.addAll(result.first); | ||||
|     a.assets.addAll(result.second); | ||||
|     final thumb = result.first.firstOrNull ?? result.second.firstOrNull; | ||||
|     a.thumbnail.value = thumb; | ||||
|     try { | ||||
|       await _db.writeTxn(() => _db.albums.store(a)); | ||||
|     } on IsarError catch (e) { | ||||
|       debugPrint(e.toString()); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /// Returns a tuple (existing, updated) | ||||
|   Future<Pair<List<Asset>, List<Asset>>> _linkWithExistingFromDb( | ||||
|     List<Asset> assets, | ||||
|   ) async { | ||||
|     if (assets.isEmpty) { | ||||
|       return const Pair([], []); | ||||
|     } | ||||
|     final List<Asset> inDb = await _db.assets | ||||
|         .where() | ||||
|         .anyOf( | ||||
|           assets, | ||||
|           (q, Asset e) => q.localIdDeviceIdEqualTo(e.localId, e.deviceId), | ||||
|         ) | ||||
|         .sortByDeviceId() | ||||
|         .thenByLocalId() | ||||
|         .findAll(); | ||||
|     assets.sort(Asset.compareByDeviceIdLocalId); | ||||
|     final List<Asset> existing = [], toUpsert = []; | ||||
|     diffSortedListsSync( | ||||
|       inDb, | ||||
|       assets, | ||||
|       compare: Asset.compareByDeviceIdLocalId, | ||||
|       both: (Asset a, Asset b) { | ||||
|         if ((a.isLocal || !b.isLocal) && | ||||
|             (a.isRemote || !b.isRemote) && | ||||
|             a.updatedAt == b.updatedAt) { | ||||
|           existing.add(a); | ||||
|           return false; | ||||
|         } else { | ||||
|           toUpsert.add(b.updateFromDb(a)); | ||||
|           return true; | ||||
|         } | ||||
|       }, | ||||
|       onlyFirst: (Asset a) => throw Exception("programming error"), | ||||
|       onlySecond: (Asset b) => toUpsert.add(b), | ||||
|     ); | ||||
|     return Pair(existing, toUpsert); | ||||
|   } | ||||
|  | ||||
|   /// Inserts or updates the assets in the database with their ExifInfo (if any) | ||||
|   Future<void> _upsertAssetsWithExif(List<Asset> assets) async { | ||||
|     if (assets.isEmpty) { | ||||
|       return; | ||||
|     } | ||||
|     final exifInfos = assets.map((e) => e.exifInfo).whereNotNull().toList(); | ||||
|     try { | ||||
|       await _db.writeTxn(() async { | ||||
|         await _db.assets.putAll(assets); | ||||
|         for (final Asset added in assets) { | ||||
|           added.exifInfo?.id = added.id; | ||||
|         } | ||||
|         await _db.exifInfos.putAll(exifInfos); | ||||
|       }); | ||||
|     } on IsarError catch (e) { | ||||
|       debugPrint(e.toString()); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| /// Returns a triple(toAdd, toUpdate, toRemove) | ||||
| Triple<List<Asset>, List<Asset>, List<Asset>> _diffAssets( | ||||
|   List<Asset> assets, | ||||
|   List<Asset> inDb, { | ||||
|   bool? remote, | ||||
|   int Function(Asset, Asset) compare = Asset.compareByDeviceIdLocalId, | ||||
| }) { | ||||
|   final List<Asset> toAdd = []; | ||||
|   final List<Asset> toUpdate = []; | ||||
|   final List<Asset> toRemove = []; | ||||
|   diffSortedListsSync( | ||||
|     inDb, | ||||
|     assets, | ||||
|     compare: compare, | ||||
|     both: (Asset a, Asset b) { | ||||
|       if (a.updatedAt.isBefore(b.updatedAt) || | ||||
|           (!a.isLocal && b.isLocal) || | ||||
|           (!a.isRemote && b.isRemote)) { | ||||
|         toUpdate.add(b.updateFromDb(a)); | ||||
|         debugPrint("both"); | ||||
|         return true; | ||||
|       } | ||||
|       return false; | ||||
|     }, | ||||
|     onlyFirst: (Asset a) { | ||||
|       if (remote == true && a.isLocal) { | ||||
|         if (a.remoteId != null) { | ||||
|           a.remoteId = null; | ||||
|           toUpdate.add(a); | ||||
|         } | ||||
|       } else if (remote == false && a.isRemote) { | ||||
|         if (a.isLocal) { | ||||
|           a.isLocal = false; | ||||
|           toUpdate.add(a); | ||||
|         } | ||||
|       } else { | ||||
|         toRemove.add(a); | ||||
|       } | ||||
|     }, | ||||
|     onlySecond: (Asset b) => toAdd.add(b), | ||||
|   ); | ||||
|   return Triple(toAdd, toUpdate, toRemove); | ||||
| } | ||||
|  | ||||
| /// returns a tuple (toDelete toUpdate) when assets are to be deleted | ||||
| Pair<List<int>, List<Asset>> _handleAssetRemoval( | ||||
|   List<Asset> deleteCandidates, | ||||
|   List<Asset> existing, | ||||
| ) { | ||||
|   if (deleteCandidates.isEmpty) { | ||||
|     return const Pair([], []); | ||||
|   } | ||||
|   deleteCandidates.sort(Asset.compareById); | ||||
|   existing.sort(Asset.compareById); | ||||
|   final triple = | ||||
|       _diffAssets(existing, deleteCandidates, compare: Asset.compareById); | ||||
|   return Pair(triple.third.map((e) => e.id).toList(), triple.second); | ||||
| } | ||||
|  | ||||
| /// returns `true` if the albums differ on the surface | ||||
| Future<bool> _hasAssetPathEntityChanged(AssetPathEntity a, Album b) async { | ||||
|   return a.name != b.name || | ||||
|       a.lastModified != b.modifiedAt || | ||||
|       await a.assetCountAsync != b.assetCount; | ||||
| } | ||||
|  | ||||
| /// 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 || | ||||
|       DateTime.parse(dto.updatedAt).toUtc() != a.modifiedAt.toUtc(); | ||||
| } | ||||
| @@ -3,24 +3,32 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:http/http.dart'; | ||||
| import 'package:http_parser/http_parser.dart'; | ||||
| import 'package:image_picker/image_picker.dart'; | ||||
| import 'package:immich_mobile/shared/models/store.dart'; | ||||
| import 'package:immich_mobile/shared/models/user.dart'; | ||||
| import 'package:immich_mobile/shared/providers/api.provider.dart'; | ||||
| import 'package:immich_mobile/shared/providers/db.provider.dart'; | ||||
| import 'package:immich_mobile/shared/services/api.service.dart'; | ||||
| import 'package:immich_mobile/shared/services/sync.service.dart'; | ||||
| import 'package:immich_mobile/utils/files_helper.dart'; | ||||
| import 'package:isar/isar.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
|  | ||||
| final userServiceProvider = Provider( | ||||
|   (ref) => UserService( | ||||
|     ref.watch(apiServiceProvider), | ||||
|     ref.watch(dbProvider), | ||||
|     ref.watch(syncServiceProvider), | ||||
|   ), | ||||
| ); | ||||
|  | ||||
| class UserService { | ||||
|   final ApiService _apiService; | ||||
|   final Isar _db; | ||||
|   final SyncService _syncService; | ||||
|  | ||||
|   UserService(this._apiService); | ||||
|   UserService(this._apiService, this._db, this._syncService); | ||||
|  | ||||
|   Future<List<User>?> getAllUsers({required bool isAll}) async { | ||||
|   Future<List<User>?> _getAllUsers({required bool isAll}) async { | ||||
|     try { | ||||
|       final dto = await _apiService.userApi.getAllUsers(isAll); | ||||
|       return dto?.map(User.fromDto).toList(); | ||||
| @@ -30,6 +38,14 @@ class UserService { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<List<User>> getUsersInDb({bool self = false}) async { | ||||
|     if (self) { | ||||
|       return _db.users.where().findAll(); | ||||
|     } | ||||
|     final int userId = Store.get<User>(StoreKey.currentUser)!.isarId; | ||||
|     return _db.users.where().isarIdNotEqualTo(userId).findAll(); | ||||
|   } | ||||
|  | ||||
|   Future<CreateProfileImageResponseDto?> uploadProfileImage(XFile image) async { | ||||
|     try { | ||||
|       var mimeType = FileHelper.getMimeType(image.path); | ||||
| @@ -50,4 +66,12 @@ class UserService { | ||||
|       return null; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<bool> refreshUsers() async { | ||||
|     final List<User>? users = await _getAllUsers(isAll: true); | ||||
|     if (users == null) { | ||||
|       return false; | ||||
|     } | ||||
|     return _syncService.syncUsersFromServer(users); | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										16
									
								
								mobile/lib/utils/async_mutex.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								mobile/lib/utils/async_mutex.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| import 'dart:async'; | ||||
|  | ||||
| /// Async mutex to guarantee actions are performed sequentially and do not interleave | ||||
| class AsyncMutex { | ||||
|   Future _running = Future.value(null); | ||||
|  | ||||
|   /// Execute [operation] exclusively, after any currently running operations. | ||||
|   /// Returns a [Future] with the result of the [operation]. | ||||
|   Future<T> run<T>(Future<T> Function() operation) { | ||||
|     final completer = Completer<T>(); | ||||
|     _running.whenComplete(() { | ||||
|       completer.complete(Future<T>.sync(operation)); | ||||
|     }); | ||||
|     return _running = completer.future; | ||||
|   } | ||||
| } | ||||
| @@ -5,7 +5,11 @@ extension DurationExtension on String { | ||||
|     return Duration(hours: parts[0], minutes: parts[1], seconds: parts[2]); | ||||
|   } | ||||
|  | ||||
|   double? toDouble() { | ||||
|     return double.tryParse(this); | ||||
|   double toDouble() { | ||||
|     return double.parse(this); | ||||
|   } | ||||
|  | ||||
|   int toInt() { | ||||
|     return int.parse(this); | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										71
									
								
								mobile/lib/utils/diff.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								mobile/lib/utils/diff.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,71 @@ | ||||
| import 'dart:async'; | ||||
|  | ||||
| /// Efficiently compares two sorted lists in O(n), calling the given callback | ||||
| /// for each item. | ||||
| /// Return `true` if there are any differences found, else `false` | ||||
| Future<bool> diffSortedLists<A, B>( | ||||
|   List<A> la, | ||||
|   List<B> lb, { | ||||
|   required int Function(A a, B b) compare, | ||||
|   required FutureOr<bool> Function(A a, B b) both, | ||||
|   required FutureOr<void> Function(A a) onlyFirst, | ||||
|   required FutureOr<void> Function(B b) onlySecond, | ||||
| }) async { | ||||
|   bool diff = false; | ||||
|   int i = 0, j = 0; | ||||
|   for (; i < la.length && j < lb.length;) { | ||||
|     final int order = compare(la[i], lb[j]); | ||||
|     if (order == 0) { | ||||
|       diff |= await both(la[i++], lb[j++]); | ||||
|     } else if (order < 0) { | ||||
|       await onlyFirst(la[i++]); | ||||
|       diff = true; | ||||
|     } else if (order > 0) { | ||||
|       await onlySecond(lb[j++]); | ||||
|       diff = true; | ||||
|     } | ||||
|   } | ||||
|   diff |= i < la.length || j < lb.length; | ||||
|   for (; i < la.length; i++) { | ||||
|     await onlyFirst(la[i]); | ||||
|   } | ||||
|   for (; j < lb.length; j++) { | ||||
|     await onlySecond(lb[j]); | ||||
|   } | ||||
|   return diff; | ||||
| } | ||||
|  | ||||
| /// Efficiently compares two sorted lists in O(n), calling the given callback | ||||
| /// for each item. | ||||
| /// Return `true` if there are any differences found, else `false` | ||||
| bool diffSortedListsSync<A, B>( | ||||
|   List<A> la, | ||||
|   List<B> lb, { | ||||
|   required int Function(A a, B b) compare, | ||||
|   required bool Function(A a, B b) both, | ||||
|   required void Function(A a) onlyFirst, | ||||
|   required void Function(B b) onlySecond, | ||||
| }) { | ||||
|   bool diff = false; | ||||
|   int i = 0, j = 0; | ||||
|   for (; i < la.length && j < lb.length;) { | ||||
|     final int order = compare(la[i], lb[j]); | ||||
|     if (order == 0) { | ||||
|       diff |= both(la[i++], lb[j++]); | ||||
|     } else if (order < 0) { | ||||
|       onlyFirst(la[i++]); | ||||
|       diff = true; | ||||
|     } else if (order > 0) { | ||||
|       onlySecond(lb[j++]); | ||||
|       diff = true; | ||||
|     } | ||||
|   } | ||||
|   diff |= i < la.length || j < lb.length; | ||||
|   for (; i < la.length; i++) { | ||||
|     onlyFirst(la[i]); | ||||
|   } | ||||
|   for (; j < lb.length; j++) { | ||||
|     onlySecond(lb[j]); | ||||
|   } | ||||
|   return diff; | ||||
| } | ||||
							
								
								
									
										15
									
								
								mobile/lib/utils/hash.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								mobile/lib/utils/hash.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| /// FNV-1a 64bit hash algorithm optimized for Dart Strings | ||||
| int fastHash(String string) { | ||||
|   var hash = 0xcbf29ce484222325; | ||||
|  | ||||
|   var i = 0; | ||||
|   while (i < string.length) { | ||||
|     final codeUnit = string.codeUnitAt(i++); | ||||
|     hash ^= codeUnit >> 8; | ||||
|     hash *= 0x100000001b3; | ||||
|     hash ^= codeUnit & 0xFF; | ||||
|     hash *= 0x100000001b3; | ||||
|   } | ||||
|  | ||||
|   return hash; | ||||
| } | ||||
| @@ -31,20 +31,20 @@ String getAlbumThumbnailUrl( | ||||
|   final Album album, { | ||||
|   ThumbnailFormat type = ThumbnailFormat.WEBP, | ||||
| }) { | ||||
|   if (album.albumThumbnailAssetId == null) { | ||||
|   if (album.thumbnail.value?.remoteId == null) { | ||||
|     return ''; | ||||
|   } | ||||
|   return _getThumbnailUrl(album.albumThumbnailAssetId!, type: type); | ||||
|   return _getThumbnailUrl(album.thumbnail.value!.remoteId!, type: type); | ||||
| } | ||||
|  | ||||
| String getAlbumThumbNailCacheKey( | ||||
|   final Album album, { | ||||
|   ThumbnailFormat type = ThumbnailFormat.WEBP, | ||||
| }) { | ||||
|   if (album.albumThumbnailAssetId == null) { | ||||
|   if (album.thumbnail.value?.remoteId == null) { | ||||
|     return ''; | ||||
|   } | ||||
|   return _getThumbnailCacheKey(album.albumThumbnailAssetId!, type); | ||||
|   return _getThumbnailCacheKey(album.thumbnail.value!.remoteId!, type); | ||||
| } | ||||
|  | ||||
| String getImageUrl(final Asset asset) { | ||||
|   | ||||
| @@ -1,7 +1,11 @@ | ||||
| // ignore_for_file: deprecated_member_use_from_same_package | ||||
|  | ||||
| import 'package:flutter/cupertino.dart'; | ||||
| import 'package:hive/hive.dart'; | ||||
| import 'package:immich_mobile/constants/hive_box.dart'; | ||||
| import 'package:immich_mobile/modules/album/services/album_cache.service.dart'; | ||||
| import 'package:immich_mobile/shared/models/store.dart'; | ||||
| import 'package:immich_mobile/shared/services/asset_cache.service.dart'; | ||||
|  | ||||
| Future<void> migrateHiveToStoreIfNecessary() async { | ||||
|   try { | ||||
| @@ -22,3 +26,9 @@ _migrateSingleKey(Box box, String hiveKey, StoreKey key) async { | ||||
|     await box.delete(hiveKey); | ||||
|   } | ||||
| } | ||||
|  | ||||
| Future<void> migrateJsonCacheIfNecessary() async { | ||||
|   await AlbumCacheService().invalidate(); | ||||
|   await SharedAlbumCacheService().invalidate(); | ||||
|   await AssetCacheService().invalidate(); | ||||
| } | ||||
|   | ||||
| @@ -6,3 +6,13 @@ class Pair<T1, T2> { | ||||
|  | ||||
|   const Pair(this.first, this.second); | ||||
| } | ||||
|  | ||||
| /// An immutable triple or 3-tuple | ||||
| /// TODO replace with Record once Dart 2.19 is available | ||||
| class Triple<T1, T2, T3> { | ||||
|   final T1 first; | ||||
|   final T2 second; | ||||
|   final T3 third; | ||||
|  | ||||
|   const Triple(this.first, this.second, this.third); | ||||
| } | ||||
|   | ||||
							
								
								
									
										1
									
								
								mobile/openapi/doc/UserResponseDto.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1
									
								
								mobile/openapi/doc/UserResponseDto.md
									
									
									
										generated
									
									
									
								
							| @@ -17,6 +17,7 @@ Name | Type | Description | Notes | ||||
| **shouldChangePassword** | **bool** |  |  | ||||
| **isAdmin** | **bool** |  |  | ||||
| **deletedAt** | [**DateTime**](DateTime.md) |  | [optional]  | ||||
| **updatedAt** | **String** |  | [optional]  | ||||
| **oauthId** | **String** |  |  | ||||
| 
 | ||||
| [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) | ||||
|   | ||||
							
								
								
									
										19
									
								
								mobile/openapi/lib/model/user_response_dto.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										19
									
								
								mobile/openapi/lib/model/user_response_dto.dart
									
									
									
										generated
									
									
									
								
							| @@ -22,6 +22,7 @@ class UserResponseDto { | ||||
|     required this.shouldChangePassword, | ||||
|     required this.isAdmin, | ||||
|     this.deletedAt, | ||||
|     this.updatedAt, | ||||
|     required this.oauthId, | ||||
|   }); | ||||
| 
 | ||||
| @@ -49,6 +50,14 @@ class UserResponseDto { | ||||
|   /// | ||||
|   DateTime? deletedAt; | ||||
| 
 | ||||
|   /// | ||||
|   /// Please note: This property should have been non-nullable! Since the specification file | ||||
|   /// does not include a default value (using the "default:" property), however, the generated | ||||
|   /// source code must fall back to having a nullable type. | ||||
|   /// Consider adding a "default:" property in the specification file to hide this note. | ||||
|   /// | ||||
|   String? updatedAt; | ||||
| 
 | ||||
|   String oauthId; | ||||
| 
 | ||||
|   @override | ||||
| @@ -62,6 +71,7 @@ class UserResponseDto { | ||||
|      other.shouldChangePassword == shouldChangePassword && | ||||
|      other.isAdmin == isAdmin && | ||||
|      other.deletedAt == deletedAt && | ||||
|      other.updatedAt == updatedAt && | ||||
|      other.oauthId == oauthId; | ||||
| 
 | ||||
|   @override | ||||
| @@ -76,10 +86,11 @@ class UserResponseDto { | ||||
|     (shouldChangePassword.hashCode) + | ||||
|     (isAdmin.hashCode) + | ||||
|     (deletedAt == null ? 0 : deletedAt!.hashCode) + | ||||
|     (updatedAt == null ? 0 : updatedAt!.hashCode) + | ||||
|     (oauthId.hashCode); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() => 'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, createdAt=$createdAt, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, deletedAt=$deletedAt, oauthId=$oauthId]'; | ||||
|   String toString() => 'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, createdAt=$createdAt, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, deletedAt=$deletedAt, updatedAt=$updatedAt, oauthId=$oauthId]'; | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
| @@ -95,6 +106,11 @@ class UserResponseDto { | ||||
|       json[r'deletedAt'] = this.deletedAt!.toUtc().toIso8601String(); | ||||
|     } else { | ||||
|       // json[r'deletedAt'] = null; | ||||
|     } | ||||
|     if (this.updatedAt != null) { | ||||
|       json[r'updatedAt'] = this.updatedAt; | ||||
|     } else { | ||||
|       // json[r'updatedAt'] = null; | ||||
|     } | ||||
|       json[r'oauthId'] = this.oauthId; | ||||
|     return json; | ||||
| @@ -128,6 +144,7 @@ class UserResponseDto { | ||||
|         shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!, | ||||
|         isAdmin: mapValueOfType<bool>(json, r'isAdmin')!, | ||||
|         deletedAt: mapDateTime(json, r'deletedAt', ''), | ||||
|         updatedAt: mapValueOfType<String>(json, r'updatedAt'), | ||||
|         oauthId: mapValueOfType<String>(json, r'oauthId')!, | ||||
|       ); | ||||
|     } | ||||
|   | ||||
							
								
								
									
										5
									
								
								mobile/openapi/test/user_response_dto_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										5
									
								
								mobile/openapi/test/user_response_dto_test.dart
									
									
									
										generated
									
									
									
								
							| @@ -61,6 +61,11 @@ void main() { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // String updatedAt | ||||
|     test('to test the property `updatedAt`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // String oauthId | ||||
|     test('to test the property `oauthId`', () async { | ||||
|       // TODO | ||||
|   | ||||
| @@ -13,14 +13,16 @@ void main() { | ||||
|  | ||||
|     testAssets.add( | ||||
|       Asset( | ||||
|         deviceAssetId: '$i', | ||||
|         deviceId: '', | ||||
|         ownerId: '', | ||||
|         localId: '$i', | ||||
|         deviceId: 1, | ||||
|         ownerId: 1, | ||||
|         fileCreatedAt: date, | ||||
|         fileModifiedAt: date, | ||||
|         updatedAt: date, | ||||
|         durationInSeconds: 0, | ||||
|         fileName: '', | ||||
|         isFavorite: false, | ||||
|         isLocal: false, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|   | ||||
							
								
								
									
										50
									
								
								mobile/test/diff_test.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								mobile/test/diff_test.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,50 @@ | ||||
| import 'package:flutter_test/flutter_test.dart'; | ||||
| import 'package:immich_mobile/utils/diff.dart'; | ||||
|  | ||||
| void main() { | ||||
|   final List<int> listA = [1, 2, 3, 4, 6]; | ||||
|   final List<int> listB = [1, 3, 5, 7]; | ||||
|  | ||||
|   group('Test grouped', () { | ||||
|     test('test partial overlap', () async { | ||||
|       final List<int> onlyInA = []; | ||||
|       final List<int> onlyInB = []; | ||||
|       final List<int> inBoth = []; | ||||
|       final changes = await diffSortedLists( | ||||
|         listA, | ||||
|         listB, | ||||
|         compare: (int a, int b) => a.compareTo(b), | ||||
|         both: (int a, int b) { | ||||
|           inBoth.add(b); | ||||
|           return false; | ||||
|         }, | ||||
|         onlyFirst: (int a) => onlyInA.add(a), | ||||
|         onlySecond: (int b) => onlyInB.add(b), | ||||
|       ); | ||||
|       expect(changes, true); | ||||
|       expect(onlyInA, [2, 4, 6]); | ||||
|       expect(onlyInB, [5, 7]); | ||||
|       expect(inBoth, [1, 3]); | ||||
|     }); | ||||
|     test('test partial overlap sync', () { | ||||
|       final List<int> onlyInA = []; | ||||
|       final List<int> onlyInB = []; | ||||
|       final List<int> inBoth = []; | ||||
|       final changes = diffSortedListsSync( | ||||
|         listA, | ||||
|         listB, | ||||
|         compare: (int a, int b) => a.compareTo(b), | ||||
|         both: (int a, int b) { | ||||
|           inBoth.add(b); | ||||
|           return false; | ||||
|         }, | ||||
|         onlyFirst: (int a) => onlyInA.add(a), | ||||
|         onlySecond: (int b) => onlyInB.add(b), | ||||
|       ); | ||||
|       expect(changes, true); | ||||
|       expect(onlyInA, [2, 4, 6]); | ||||
|       expect(onlyInB, [5, 7]); | ||||
|       expect(inBoth, [1, 3]); | ||||
|     }); | ||||
|   }); | ||||
| } | ||||
| @@ -12,75 +12,81 @@ import 'package:mockito/mockito.dart'; | ||||
| ]) | ||||
| import 'favorite_provider_test.mocks.dart'; | ||||
|  | ||||
| Asset _getTestAsset(String id, bool favorite) { | ||||
|   return Asset( | ||||
|     remoteId: id, | ||||
|     deviceAssetId: '', | ||||
|     deviceId: '', | ||||
|     ownerId: '', | ||||
| Asset _getTestAsset(int id, bool favorite) { | ||||
|   final Asset a = Asset( | ||||
|     remoteId: id.toString(), | ||||
|     localId: id.toString(), | ||||
|     deviceId: 1, | ||||
|     ownerId: 1, | ||||
|     fileCreatedAt: DateTime.now(), | ||||
|     fileModifiedAt: DateTime.now(), | ||||
|     updatedAt: DateTime.now(), | ||||
|     isLocal: false, | ||||
|     durationInSeconds: 0, | ||||
|     fileName: '', | ||||
|     isFavorite: favorite, | ||||
|   ); | ||||
|   a.id = id; | ||||
|   return a; | ||||
| } | ||||
|  | ||||
| void main() { | ||||
|   group("Test favoriteProvider", () { | ||||
|  | ||||
|     late MockAssetsState assetsState; | ||||
|     late MockAssetNotifier assetNotifier; | ||||
|     late ProviderContainer container; | ||||
|     late StateNotifierProvider<FavoriteSelectionNotifier, Set<String>> testFavoritesProvider; | ||||
|     late StateNotifierProvider<FavoriteSelectionNotifier, Set<int>> | ||||
|         testFavoritesProvider; | ||||
|  | ||||
|     setUp(() { | ||||
|     setUp( | ||||
|       () { | ||||
|         assetsState = MockAssetsState(); | ||||
|         assetNotifier = MockAssetNotifier(); | ||||
|         container = ProviderContainer(); | ||||
|  | ||||
|         testFavoritesProvider = | ||||
|           StateNotifierProvider<FavoriteSelectionNotifier, Set<String>>((ref) { | ||||
|             StateNotifierProvider<FavoriteSelectionNotifier, Set<int>>((ref) { | ||||
|           return FavoriteSelectionNotifier( | ||||
|             assetsState, | ||||
|             assetNotifier, | ||||
|           ); | ||||
|         }); | ||||
|     },); | ||||
|       }, | ||||
|     ); | ||||
|  | ||||
|     test("Empty favorites provider", () { | ||||
|       when(assetsState.allAssets).thenReturn([]); | ||||
|       expect(<String>{}, container.read(testFavoritesProvider)); | ||||
|       expect(<int>{}, container.read(testFavoritesProvider)); | ||||
|     }); | ||||
|  | ||||
|     test("Non-empty favorites provider", () { | ||||
|       when(assetsState.allAssets).thenReturn([ | ||||
|         _getTestAsset("001", false), | ||||
|         _getTestAsset("002", true), | ||||
|         _getTestAsset("003", false), | ||||
|         _getTestAsset("004", false), | ||||
|         _getTestAsset("005", true), | ||||
|         _getTestAsset(1, false), | ||||
|         _getTestAsset(2, true), | ||||
|         _getTestAsset(3, false), | ||||
|         _getTestAsset(4, false), | ||||
|         _getTestAsset(5, true), | ||||
|       ]); | ||||
|  | ||||
|       expect(<String>{"002", "005"}, container.read(testFavoritesProvider)); | ||||
|       expect(<int>{2, 5}, container.read(testFavoritesProvider)); | ||||
|     }); | ||||
|  | ||||
|     test("Toggle favorite", () { | ||||
|       when(assetNotifier.toggleFavorite(null, false)) | ||||
|           .thenAnswer((_) async => false); | ||||
|  | ||||
|       final testAsset1 = _getTestAsset("001", false); | ||||
|       final testAsset2 = _getTestAsset("002", true); | ||||
|       final testAsset1 = _getTestAsset(1, false); | ||||
|       final testAsset2 = _getTestAsset(2, true); | ||||
|  | ||||
|       when(assetsState.allAssets).thenReturn([testAsset1, testAsset2]); | ||||
|  | ||||
|       expect(<String>{"002"}, container.read(testFavoritesProvider)); | ||||
|       expect(<int>{2}, container.read(testFavoritesProvider)); | ||||
|  | ||||
|       container.read(testFavoritesProvider.notifier).toggleFavorite(testAsset2); | ||||
|       expect(<String>{}, container.read(testFavoritesProvider)); | ||||
|       expect(<int>{}, container.read(testFavoritesProvider)); | ||||
|  | ||||
|       container.read(testFavoritesProvider.notifier).toggleFavorite(testAsset1); | ||||
|       expect(<String>{"001"}, container.read(testFavoritesProvider)); | ||||
|       expect(<int>{1}, container.read(testFavoritesProvider)); | ||||
|     }); | ||||
|  | ||||
|     test("Add favorites", () { | ||||
| @@ -89,16 +95,16 @@ void main() { | ||||
|  | ||||
|       when(assetsState.allAssets).thenReturn([]); | ||||
|  | ||||
|       expect(<String>{}, container.read(testFavoritesProvider)); | ||||
|       expect(<int>{}, container.read(testFavoritesProvider)); | ||||
|  | ||||
|       container.read(testFavoritesProvider.notifier).addToFavorites( | ||||
|         [ | ||||
|           _getTestAsset("001", false), | ||||
|           _getTestAsset("002", false), | ||||
|           _getTestAsset(1, false), | ||||
|           _getTestAsset(2, false), | ||||
|         ], | ||||
|       ); | ||||
|  | ||||
|       expect(<String>{"001", "002"}, container.read(testFavoritesProvider)); | ||||
|       expect(<int>{1, 2}, container.read(testFavoritesProvider)); | ||||
|     }); | ||||
|   }); | ||||
| } | ||||
|   | ||||
| @@ -187,7 +187,7 @@ class MockAssetNotifier extends _i1.Mock implements _i2.AssetNotifier { | ||||
|         returnValueForMissingStub: _i5.Future<void>.value(), | ||||
|       ) as _i5.Future<void>); | ||||
|   @override | ||||
|   void onNewAssetUploaded(_i4.Asset? newAsset) => super.noSuchMethod( | ||||
|   Future<void> onNewAssetUploaded(_i4.Asset? newAsset) => super.noSuchMethod( | ||||
|         Invocation.method( | ||||
|           #onNewAssetUploaded, | ||||
|           [newAsset], | ||||
| @@ -195,7 +195,7 @@ class MockAssetNotifier extends _i1.Mock implements _i2.AssetNotifier { | ||||
|         returnValueForMissingStub: null, | ||||
|       ); | ||||
|   @override | ||||
|   dynamic deleteAssets(Set<_i4.Asset>? deleteAssets) => super.noSuchMethod( | ||||
|   Future<void> deleteAssets(Set<_i4.Asset> deleteAssets) => super.noSuchMethod( | ||||
|         Invocation.method( | ||||
|           #deleteAssets, | ||||
|           [deleteAssets], | ||||
|   | ||||
| @@ -101,6 +101,7 @@ describe('User', () => { | ||||
|               shouldChangePassword: true, | ||||
|               profileImagePath: '', | ||||
|               deletedAt: null, | ||||
|               updatedAt: expect.anything(), | ||||
|               oauthId: '', | ||||
|             }, | ||||
|             { | ||||
| @@ -113,6 +114,7 @@ describe('User', () => { | ||||
|               shouldChangePassword: true, | ||||
|               profileImagePath: '', | ||||
|               deletedAt: null, | ||||
|               updatedAt: expect.anything(), | ||||
|               oauthId: '', | ||||
|             }, | ||||
|           ]), | ||||
|   | ||||
| @@ -3583,6 +3583,9 @@ | ||||
|             "format": "date-time", | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "updatedAt": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "oauthId": { | ||||
|             "type": "string" | ||||
|           } | ||||
|   | ||||
| @@ -10,6 +10,7 @@ export class UserResponseDto { | ||||
|   shouldChangePassword!: boolean; | ||||
|   isAdmin!: boolean; | ||||
|   deletedAt?: Date; | ||||
|   updatedAt?: string; | ||||
|   oauthId!: string; | ||||
| } | ||||
|  | ||||
| @@ -24,6 +25,7 @@ export function mapUser(entity: UserEntity): UserResponseDto { | ||||
|     shouldChangePassword: entity.shouldChangePassword, | ||||
|     isAdmin: entity.isAdmin, | ||||
|     deletedAt: entity.deletedAt, | ||||
|     updatedAt: entity.updatedAt, | ||||
|     oauthId: entity.oauthId, | ||||
|   }; | ||||
| } | ||||
|   | ||||
| @@ -100,6 +100,7 @@ const adminUserResponse = Object.freeze({ | ||||
|   shouldChangePassword: false, | ||||
|   profileImagePath: '', | ||||
|   createdAt: '2021-01-01', | ||||
|   updatedAt: '2021-01-01', | ||||
| }); | ||||
|  | ||||
| describe(UserService.name, () => { | ||||
| @@ -162,6 +163,7 @@ describe(UserService.name, () => { | ||||
|           shouldChangePassword: false, | ||||
|           profileImagePath: '', | ||||
|           createdAt: '2021-01-01', | ||||
|           updatedAt: '2021-01-01', | ||||
|         }, | ||||
|       ]); | ||||
|     }); | ||||
|   | ||||
							
								
								
									
										6
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										6
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							| @@ -2406,6 +2406,12 @@ export interface UserResponseDto { | ||||
|      * @memberof UserResponseDto | ||||
|      */ | ||||
|     'deletedAt'?: string; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof UserResponseDto | ||||
|      */ | ||||
|     'updatedAt'?: string; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|   | ||||
		Reference in New Issue
	
	Block a user