From 8708867c1ca54ba47e9cb196662694882a150f65 Mon Sep 17 00:00:00 2001 From: Fynn Petersen-Frey Date: Fri, 3 Mar 2023 23:38:30 +0100 Subject: [PATCH] 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 --- mobile/assets/i18n/en-US.json | 1 + mobile/lib/main.dart | 13 +- .../album/providers/album.provider.dart | 54 +- .../providers/asset_selection.provider.dart | 2 +- .../providers/shared_album.provider.dart | 68 +-- .../suggested_shared_users.provider.dart | 4 +- .../modules/album/services/album.service.dart | 196 ++++-- .../album/services/album_cache.service.dart | 41 +- .../album/ui/add_to_album_bottom_sheet.dart | 2 +- .../album/ui/album_thumbnail_card.dart | 29 +- .../album/ui/album_thumbnail_listtile.dart | 2 +- .../modules/album/ui/album_viewer_appbar.dart | 35 +- .../album/ui/album_viewer_thumbnail.dart | 4 +- .../album/views/album_viewer_page.dart | 36 +- .../lib/modules/album/views/library_page.dart | 51 +- .../lib/modules/album/views/sharing_page.dart | 16 +- .../asset_viewer/ui/exif_bottom_sheet.dart | 18 +- .../asset_viewer/views/gallery_viewer.dart | 14 +- .../favorite/providers/favorite_provider.dart | 24 +- .../home/ui/asset_grid/immich_asset_grid.dart | 2 +- .../home/ui/asset_grid/thumbnail_image.dart | 5 +- mobile/lib/modules/home/views/home_page.dart | 2 +- .../providers/authentication.provider.dart | 20 +- .../search_result_page_state.model.dart | 31 - .../search/services/search.service.dart | 10 +- mobile/lib/routing/router.gr.dart | 4 +- mobile/lib/shared/models/album.dart | 179 +++--- mobile/lib/shared/models/album.g.dart | Bin 0 -> 39228 bytes mobile/lib/shared/models/asset.dart | 202 ++++--- mobile/lib/shared/models/asset.g.dart | Bin 0 -> 64357 bytes mobile/lib/shared/models/exif_info.dart | 117 ++-- mobile/lib/shared/models/exif_info.g.dart | Bin 0 -> 64691 bytes mobile/lib/shared/models/store.dart | 67 ++- mobile/lib/shared/models/user.dart | 79 +-- mobile/lib/shared/models/user.g.dart | Bin 0 -> 37560 bytes .../lib/shared/providers/asset.provider.dart | 204 +++---- mobile/lib/shared/services/api.service.dart | 7 + mobile/lib/shared/services/asset.service.dart | 131 ++-- .../shared/services/asset_cache.service.dart | 36 +- mobile/lib/shared/services/json_cache.dart | 30 +- mobile/lib/shared/services/sync.service.dart | 558 ++++++++++++++++++ mobile/lib/shared/services/user.service.dart | 28 +- mobile/lib/utils/async_mutex.dart | 16 + mobile/lib/utils/builtin_extensions.dart | 8 +- mobile/lib/utils/diff.dart | 71 +++ mobile/lib/utils/hash.dart | 15 + mobile/lib/utils/image_url_builder.dart | 8 +- mobile/lib/utils/migration.dart | 10 + mobile/lib/utils/tuple.dart | 10 + mobile/openapi/doc/UserResponseDto.md | Bin 737 -> 781 bytes .../openapi/lib/model/user_response_dto.dart | Bin 6187 -> 6884 bytes .../openapi/test/user_response_dto_test.dart | Bin 1500 -> 1603 bytes .../test/asset_grid_data_structure_test.dart | 8 +- mobile/test/diff_test.dart | 50 ++ mobile/test/favorite_provider_test.dart | 78 +-- mobile/test/favorite_provider_test.mocks.dart | 4 +- server/apps/immich/test/user.e2e-spec.ts | 2 + server/immich-openapi-specs.json | 3 + .../user/response-dto/user-response.dto.ts | 2 + .../libs/domain/src/user/user.service.spec.ts | 2 + web/src/api/open-api/api.ts | 6 + 61 files changed, 1723 insertions(+), 892 deletions(-) create mode 100644 mobile/lib/shared/models/album.g.dart create mode 100644 mobile/lib/shared/models/asset.g.dart create mode 100644 mobile/lib/shared/models/exif_info.g.dart create mode 100644 mobile/lib/shared/models/user.g.dart create mode 100644 mobile/lib/shared/services/sync.service.dart create mode 100644 mobile/lib/utils/async_mutex.dart create mode 100644 mobile/lib/utils/diff.dart create mode 100644 mobile/lib/utils/hash.dart create mode 100644 mobile/test/diff_test.dart diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index f9851b7460..bf5d3e6ad5 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -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", diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 9fcab0d881..0b50df1196 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -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 initApp() async { Future loadDb() async { final dir = await getApplicationDocumentsDirectory(); Isar db = await Isar.open( - [StoreValueSchema], + [ + StoreValueSchema, + ExifInfoSchema, + AssetSchema, + AlbumSchema, + UserSchema, + ], directory: dir.path, maxSizeMiB: 256, ); diff --git a/mobile/lib/modules/album/providers/album.provider.dart b/mobile/lib/modules/album/providers/album.provider.dart index 4c695faa40..6011a7a9e9 100644 --- a/mobile/lib/modules/album/providers/album.provider.dart +++ b/mobile/lib/modules/album/providers/album.provider.dart @@ -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> { - 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 getAllAlbums() async { - if (await _albumCacheService.isValid() && state.isEmpty) { - final albums = await _albumCacheService.get(); - if (albums != null) { - state = albums; - } - } - - final albums = await _albumService.getAlbums(isShared: false); - - if (albums != null) { + final User me = Store.get(StoreKey.currentUser); + List 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; - _cacheState(); } } - void deleteAlbum(Album album) { + Future deleteAlbum(Album album) async { state = state.where((a) => a.id != album.id).toList(); - _cacheState(); + return _albumService.deleteAlbum(album); } Future createAlbum( @@ -39,20 +45,16 @@ class AlbumNotifier extends StateNotifier> { Set 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>((ref) { return AlbumNotifier( ref.watch(albumServiceProvider), - ref.watch(albumCacheServiceProvider), + ref.watch(dbProvider), ); }); diff --git a/mobile/lib/modules/album/providers/asset_selection.provider.dart b/mobile/lib/modules/album/providers/asset_selection.provider.dart index 927ab331fd..6dfc2a462e 100644 --- a/mobile/lib/modules/album/providers/asset_selection.provider.dart +++ b/mobile/lib/modules/album/providers/asset_selection.provider.dart @@ -58,7 +58,7 @@ class AssetSelectionNotifier extends StateNotifier { ); } - void addNewAssets(List assets) { + void addNewAssets(Iterable assets) { state = state.copyWith( selectedNewAssetsForAlbum: { ...state.selectedNewAssetsForAlbum, diff --git a/mobile/lib/modules/album/providers/shared_album.provider.dart b/mobile/lib/modules/album/providers/shared_album.provider.dart index ab950955bc..a1c83a95c2 100644 --- a/mobile/lib/modules/album/providers/shared_album.provider.dart +++ b/mobile/lib/modules/album/providers/shared_album.provider.dart @@ -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> { - 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 createSharedAlbum( String albumName, @@ -23,7 +20,7 @@ class SharedAlbumNotifier extends StateNotifier> { Iterable 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> { if (newAlbum != null) { state = [...state, newAlbum]; - _cacheState(); + return newAlbum; } - - return newAlbum; } catch (e) { debugPrint("Error createSharedAlbum ${e.toString()}"); - - return null; } + return null; } Future getAllSharedAlbums() async { - if (await _sharedAlbumCacheService.isValid() && state.isEmpty) { - final albums = await _sharedAlbumCacheService.get(); - if (albums != null) { - state = albums; - } + var albums = await _db.albums.filter().sharedEqualTo(true).findAll(); + if (!const ListEquality().equals(albums, state)) { + state = albums; } - - List? sharedAlbums = await _albumService.getAlbums(isShared: true); - - if (sharedAlbums != null) { - state = sharedAlbums; - _cacheState(); + await _albumService.refreshRemoteAlbums(isShared: true); + albums = await _db.albums.filter().sharedEqualTo(true).findAll(); + if (!const ListEquality().equals(albums, state)) { + state = albums; } } - void deleteAlbum(Album album) { + Future deleteAlbum(Album album) { state = state.where((a) => a.id != album.id).toList(); - _cacheState(); + return _albumService.deleteAlbum(album); } Future 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 removeAssetFromAlbum( - Album album, - Iterable assets, - ) async { - var res = await _albumService.removeAssetFromAlbum(album, assets); - - if (res) { - return true; - } else { - return false; - } + Future removeAssetFromAlbum(Album album, Iterable assets) { + return _albumService.removeAssetFromAlbum(album, assets); } } @@ -93,13 +73,15 @@ final sharedAlbumProvider = StateNotifierProvider>((ref) { return SharedAlbumNotifier( ref.watch(albumServiceProvider), - ref.watch(sharedAlbumCacheServiceProvider), + ref.watch(dbProvider), ); }); final sharedAlbumDetailProvider = - FutureProvider.autoDispose.family((ref, albumId) async { + FutureProvider.autoDispose.family((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; }); diff --git a/mobile/lib/modules/album/providers/suggested_shared_users.provider.dart b/mobile/lib/modules/album/providers/suggested_shared_users.provider.dart index 487bbcda80..552afa9e99 100644 --- a/mobile/lib/modules/album/providers/suggested_shared_users.provider.dart +++ b/mobile/lib/modules/album/providers/suggested_shared_users.provider.dart @@ -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>((ref) async { + FutureProvider.autoDispose>((ref) { UserService userService = ref.watch(userServiceProvider); - return await userService.getAllUsers(isAll: false) ?? []; + return userService.getUsersInDb(); }); diff --git a/mobile/lib/modules/album/services/album.service.dart b/mobile/lib/modules/album/services/album.service.dart index 9e4cf8ee90..594bc163a9 100644 --- a/mobile/lib/modules/album/services/album.service.dart +++ b/mobile/lib/modules/album/services/album.service.dart @@ -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 _localCompleter = Completer()..complete(false); + Completer _remoteCompleter = Completer()..complete(false); - AlbumService(this._apiService); + AlbumService( + this._apiService, + this._userService, + this._backgroundService, + this._syncService, + this._db, + ); - Future?> 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 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(hiveBackupInfoBox)) + .get(backupInfoKey); + if (infos == null) { + return false; + } + final List 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 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? 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 createAlbum( @@ -37,56 +132,51 @@ class AlbumService { Iterable 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? albums) { + Future _getNextAlbumName() async { const baseName = "Untitled"; + for (int round = 0;; round++) { + final proposedName = "$baseName${round == 0 ? "" : " ($round)"}"; - if (albums != null) { - for (int round = 0; round < albums.length; round++) { - final proposedName = "$baseName${round == 0 ? "" : " ($round)"}"; - - if (albums.where((a) => a.name == proposedName).isEmpty) { - return proposedName; - } + if (null == + await _db.albums.filter().nameEqualTo(proposedName).findFirst()) { + return proposedName; } } - return baseName; } Future createAlbumWithGeneratedName( Iterable assets, ) async { return createAlbum( - _getNextAlbumName(await getAlbums(isShared: false)), + await _getNextAlbumName(), assets, [], ); } - Future 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 getAlbumDetail(int albumId) { + return _db.albums.get(albumId); } Future 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 deleteAlbum(Album album) async { try { - await _apiService.albumApi.deleteAlbum(album.remoteId!); + final userId = Store.get(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 albums = + await _db.albums.filter().sharedEqualTo(true).findAll(); + final List existing = []; + for (Album a in albums) { + existing.addAll( + await a.assets.filter().not().ownerIdEqualTo(userId).findAll(), + ); + } + final List 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 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) { diff --git a/mobile/lib/modules/album/services/album_cache.service.dart b/mobile/lib/modules/album/services/album_cache.service.dart index c665d1420b..5e3815aabf 100644 --- a/mobile/lib/modules/album/services/album_cache.service.dart +++ b/mobile/lib/modules/album/services/album_cache.service.dart @@ -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> { - BaseAlbumCacheService(super.cacheFileName); +@Deprecated("only kept to remove its files after migration") +class _BaseAlbumCacheService extends JsonCache> { + _BaseAlbumCacheService(super.cacheFileName); @override - void put(List data) { - putRawData(data.map((e) => e.toJson()).toList()); - } + void put(List data) {} @override - Future?> get() async { - try { - final mapList = await readRawData() as List; - - final responseData = - mapList.map((e) => Album.fromJson(e)).whereNotNull().toList(); - - return responseData; - } catch (e) { - await invalidate(); - debugPrint(e.toString()); - return null; - } - } + Future?> 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(), -); diff --git a/mobile/lib/modules/album/ui/add_to_album_bottom_sheet.dart b/mobile/lib/modules/album/ui/add_to_album_bottom_sheet.dart index 617010ae77..263a9d46de 100644 --- a/mobile/lib/modules/album/ui/add_to_album_bottom_sheet.dart +++ b/mobile/lib/modules/album/ui/add_to_album_bottom_sheet.dart @@ -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); diff --git a/mobile/lib/modules/album/ui/album_thumbnail_card.dart b/mobile/lib/modules/album/ui/album_thumbnail_card.dart index d567a313e3..6c87519b2c 100644 --- a/mobile/lib/modules/album/ui/album_thumbnail_card.dart +++ b/mobile/lib/modules/album/ui/album_thumbnail_card.dart @@ -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( - 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), - ); - } + buildAlbumThumbnail() => ImmichImage( + album.thumbnail.value, + width: cardSize, + height: cardSize, + ); 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(), ), diff --git a/mobile/lib/modules/album/ui/album_thumbnail_listtile.dart b/mobile/lib/modules/album/ui/album_thumbnail_listtile.dart index d00609ad60..a07791e5f7 100644 --- a/mobile/lib/modules/album/ui/album_thumbnail_listtile.dart +++ b/mobile/lib/modules/album/ui/album_thumbnail_listtile.dart @@ -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(), ), diff --git a/mobile/lib/modules/album/ui/album_viewer_appbar.dart b/mobile/lib/modules/album/ui/album_viewer_appbar.dart index 71e36eff78..4a5de40061 100644 --- a/mobile/lib/modules/album/ui/album_viewer_appbar.dart +++ b/mobile/lib/modules/album/ui/album_viewer_appbar.dart @@ -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) { - if (album.shared) { - ref.watch(sharedAlbumProvider.notifier).deleteAlbum(album); - AutoRouter.of(context) - .navigate(const TabControllerRoute(children: [SharingRoute()])); - } else { - ref.watch(albumProvider.notifier).deleteAlbum(album); - AutoRouter.of(context) - .navigate(const TabControllerRoute(children: [LibraryRoute()])); - } + final bool success; + if (album.shared) { + success = + await ref.watch(sharedAlbumProvider.notifier).deleteAlbum(album); + AutoRouter.of(context) + .navigate(const TabControllerRoute(children: [SharingRoute()])); } else { + success = await ref.watch(albumProvider.notifier).deleteAlbum(album); + AutoRouter.of(context) + .navigate(const TabControllerRoute(children: [LibraryRoute()])); + } + if (!success) { ImmichToast.show( context: context, msg: "album_viewer_appbar_share_err_delete".tr(), @@ -208,11 +206,12 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget { : null, centerTitle: false, actions: [ - IconButton( - splashRadius: 25, - onPressed: buildBottomSheet, - icon: const Icon(Icons.more_horiz_rounded), - ), + if (album.isRemote) + IconButton( + splashRadius: 25, + onPressed: buildBottomSheet, + icon: const Icon(Icons.more_horiz_rounded), + ), ], ); } diff --git a/mobile/lib/modules/album/ui/album_viewer_thumbnail.dart b/mobile/lib/modules/album/ui/album_viewer_thumbnail.dart index df3fc5ac0a..1a6a614b40 100644 --- a/mobile/lib/modules/album/ui/album_viewer_thumbnail.dart +++ b/mobile/lib/modules/album/ui/album_viewer_thumbnail.dart @@ -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, diff --git a/mobile/lib/modules/album/views/album_viewer_page.dart b/mobile/lib/modules/album/views/album_viewer_page.dart index aa7d1c9fd0..748fcec8e4 100644 --- a/mobile/lib/modules/album/views/album_viewer_page.dart +++ b/mobile/lib/modules/album/views/album_viewer_page.dart @@ -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,9 +122,10 @@ 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()) - .format(startDate); + final String startDateText = (startDate.year == endDate.year + ? DateFormat.MMMd() + : DateFormat.yMMMd()) + .format(startDate); final String endDateText = DateFormat.yMMMd().format(endDate); return Padding( @@ -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,17 +268,18 @@ class AlbumViewerPage extends HookConsumerWidget { controller: scrollController, slivers: [ buildHeader(album), - SliverPersistentHeader( - pinned: true, - delegate: ImmichSliverPersistentAppBarDelegate( - minHeight: 50, - maxHeight: 50, - child: Container( - color: Theme.of(context).scaffoldBackgroundColor, - child: buildControlButton(album), + if (album.isRemote) + SliverPersistentHeader( + pinned: true, + delegate: ImmichSliverPersistentAppBarDelegate( + minHeight: 50, + maxHeight: 50, + child: Container( + color: Theme.of(context).scaffoldBackgroundColor, + child: buildControlButton(album), + ), ), ), - ), SliverSafeArea( sliver: buildImageGrid(album), ), diff --git a/mobile/lib/modules/album/views/library_page.dart b/mobile/lib/modules/album/views/library_page.dart index 1ddccd6c0b..eb790a1068 100644 --- a/mobile/lib/modules/album/views/library_page.dart +++ b/mobile/lib/modules/album/views/library_page.dart @@ -44,9 +44,13 @@ class LibraryPage extends HookConsumerWidget { List 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, + ), + ), + ), + ), + ), + ), ], ), ); diff --git a/mobile/lib/modules/album/views/sharing_page.dart b/mobile/lib/modules/album/views/sharing_page.dart index f3d71676e8..7b1de07f99 100644 --- a/mobile/lib/modules/album/views/sharing_page.dart +++ b/mobile/lib/modules/album/views/sharing_page.dart @@ -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 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( diff --git a/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart b/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart index 0f0e391510..cd18b2fa48 100644 --- a/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart +++ b/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart @@ -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), ) ], diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index c0700727eb..5127728c31 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -75,15 +75,11 @@ class GalleryViewerPage extends HookConsumerWidget { ref.watch(favoriteProvider.notifier).toggleFavorite(asset); } - getAssetExif() async { - if (assetList[indexOfAsset.value].isRemote) { - assetDetail = await ref - .watch(assetServiceProvider) - .getAssetById(assetList[indexOfAsset.value].id); - } else { - // TODO local exif parsing? - assetDetail = assetList[indexOfAsset.value]; - } + void getAssetExif() async { + assetDetail = assetList[indexOfAsset.value]; + assetDetail = await ref + .watch(assetServiceProvider) + .loadExif(assetList[indexOfAsset.value]); } /// Thumbnail image of a remote asset. Required asset.isRemote diff --git a/mobile/lib/modules/favorite/providers/favorite_provider.dart b/mobile/lib/modules/favorite/providers/favorite_provider.dart index af63a7de51..53b024d526 100644 --- a/mobile/lib/modules/favorite/providers/favorite_provider.dart +++ b/mobile/lib/modules/favorite/providers/favorite_provider.dart @@ -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> { +class FavoriteSelectionNotifier extends StateNotifier> { FavoriteSelectionNotifier(this.assetsState, this.assetNotifier) : super({}) { state = assetsState.allAssets .where((asset) => asset.isFavorite) @@ -13,7 +13,7 @@ class FavoriteSelectionNotifier extends StateNotifier> { 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> { } } - bool _isFavorite(String id) { + bool _isFavorite(int id) { return state.contains(id); } @@ -38,22 +38,22 @@ class FavoriteSelectionNotifier extends StateNotifier> { Future addToFavorites(Iterable assets) { state = state.union(assets.map((a) => a.id).toSet()); - final futures = assets.map((a) => - assetNotifier.toggleFavorite( - a, - true, - ), - ); + final futures = assets.map( + (a) => assetNotifier.toggleFavorite( + a, + true, + ), + ); return Future.wait(futures); } } final favoriteProvider = - StateNotifierProvider>((ref) { + StateNotifierProvider>((ref) { return FavoriteSelectionNotifier( - ref.watch(assetProvider), - ref.watch(assetProvider.notifier), + ref.watch(assetProvider), + ref.watch(assetProvider.notifier), ); }); diff --git a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart index c2e3df2027..1c6eb5a04d 100644 --- a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart +++ b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart @@ -23,7 +23,7 @@ class ImmichAssetGridState extends State { ItemPositionsListener.create(); bool _scrolling = false; - final Set _selectedAssets = HashSet(); + final Set _selectedAssets = HashSet(); Set _getSelectedAssets() { return _selectedAssets diff --git a/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart b/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart index e2d6f54b7d..ab7183d4dc 100644 --- a/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart +++ b/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart @@ -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, diff --git a/mobile/lib/modules/home/views/home_page.dart b/mobile/lib/modules/home/views/home_page.dart index ca3bc4b2d6..663ab47d64 100644 --- a/mobile/lib/modules/home/views/home_page.dart +++ b/mobile/lib/modules/home/views/home_page.dart @@ -38,7 +38,7 @@ class HomePage extends HookConsumerWidget { final selectionEnabledHook = useState(false); final selection = useState({}); - 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); diff --git a/mobile/lib/modules/login/providers/authentication.provider.dart b/mobile/lib/modules/login/providers/authentication.provider.dart index 1cb760d214..7237d23cc6 100644 --- a/mobile/lib/modules/login/providers/authentication.provider.dart +++ b/mobile/lib/modules/login/providers/authentication.provider.dart @@ -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 { @@ -19,9 +19,6 @@ class AuthenticationNotifier extends StateNotifier { this._deviceInfoService, this._backupService, this._apiService, - this._assetCacheService, - this._albumCacheService, - this._sharedAlbumCacheService, ) : super( AuthenticationState( deviceId: "", @@ -48,9 +45,6 @@ class AuthenticationNotifier extends StateNotifier { final DeviceInfoService _deviceInfoService; final BackupService _backupService; final ApiService _apiService; - final AssetCacheService _assetCacheService; - final AlbumCacheService _albumCacheService; - final SharedAlbumCacheService _sharedAlbumCacheService; Future login( String email, @@ -98,9 +92,7 @@ class AuthenticationNotifier extends StateNotifier { 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(hiveLoginInfoBox).delete(savedLoginInfoKey) ]); @@ -160,7 +152,10 @@ class AuthenticationNotifier extends StateNotifier { 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), ); }); diff --git a/mobile/lib/modules/search/models/search_result_page_state.model.dart b/mobile/lib/modules/search/models/search_result_page_state.model.dart index 75cd9e8ad1..6db10616f7 100644 --- a/mobile/lib/modules/search/models/search_result_page_state.model.dart +++ b/mobile/lib/modules/search/models/search_result_page_state.model.dart @@ -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 toMap() { - return { - 'isLoading': isLoading, - 'isSuccess': isSuccess, - 'isError': isError, - 'searchResult': searchResult.map((x) => x.toJson()).toList(), - }; - } - - factory SearchResultPageState.fromMap(Map 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)'; diff --git a/mobile/lib/modules/search/services/search.service.dart b/mobile/lib/modules/search/services/search.service.dart index 8b1ea602c1..ae4fe55eca 100644 --- a/mobile/lib/modules/search/services/search.service.dart +++ b/mobile/lib/modules/search/services/search.service.dart @@ -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?> getUserSuggestedSearchTerms() async { try { @@ -26,13 +30,15 @@ class SearchService { } Future?> searchAsset(String searchTerm) async { + // TODO search in local DB: 1. when offline, 2. to find local assets try { final List? 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; diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 158a302edd..091c0dc6b4 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -698,7 +698,7 @@ class SelectUserForSharingRoute extends PageRouteInfo { class AlbumViewerRoute extends PageRouteInfo { 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() { diff --git a/mobile/lib/shared/models/album.dart b/mobile/lib/shared/models/album.dart index 21d2079c50..47040b623f 100644 --- a/mobile/lib/shared/models/album.dart +++ b/mobile/lib/shared/models/album.dart @@ -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 sharedUsers = const []; - List assets = const []; + final IsarLink owner = IsarLink(); + final IsarLink thumbnail = IsarLink(); + final IsarLinks sharedUsers = IsarLinks(); + final IsarLinks assets = IsarLinks(); + List _sortedAssets = []; + + @ignore + List 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 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 toJson() { - final json = {}; - 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 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; } - static Album? fromJson(dynamic value) { - if (value is Map) { - final json = value.cast(); - 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(json["sharedUsers"], User.fromJson), - assets: _listFromJson(json["assets"], Asset.fromJson), - ); + static Future 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(); } - return null; + 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 _listFromJson( - dynamic json, - T? Function(dynamic) fromJson, -) { - final result = []; - 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 { + Future 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> 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 getAssets() => assets.map(Asset.remote).toList(); } diff --git a/mobile/lib/shared/models/album.g.dart b/mobile/lib/shared/models/album.g.dart new file mode 100644 index 0000000000000000000000000000000000000000..e483907a24819391a7e736435c31df419c058fdd GIT binary patch literal 39228 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 literal 0 HcmV?d00001 diff --git a/mobile/lib/shared/models/asset.dart b/mobile/lib/shared/models/asset.dart index af6f43b57c..bd0b797cab 100644 --- a/mobile/lib/shared/models/asset.dart +++ b/mobile/lib/shared/models/asset.dart @@ -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(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 - - Map toJson() { - final json = {}; - 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; - if (exifInfo != null) { - json["exifInfo"] = exifInfo!.toJson(); + 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 json; + return hasChanges; } - static Asset? fromJson(dynamic value) { - if (value is Map) { - final json = value.cast(); - 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"], - ); + 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 put(Isar db) async { + await db.assets.put(this); + if (exifInfo != null) { + exifInfo!.id = id; + await db.exifInfos.put(exifInfo!); } - return null; + } + + 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 { + Future deleteAllByRemoteId(Iterable ids) => + ids.isEmpty ? Future.value(0) : _remote(ids).deleteAll(); + Future deleteAllByLocalId(Iterable ids) => + ids.isEmpty ? Future.value(0) : _local(ids).deleteAll(); + Future> getAllByRemoteId(Iterable ids) => + ids.isEmpty ? Future.value([]) : _remote(ids).findAll(); + Future> getAllByLocalId(Iterable ids) => + ids.isEmpty ? Future.value([]) : _local(ids).findAll(); + + QueryBuilder _remote(Iterable ids) => + where().anyOf(ids, (q, String e) => q.remoteIdEqualTo(e)); + QueryBuilder _local(Iterable ids) { + return where().anyOf( + ids, + (q, String e) => + q.localIdDeviceIdEqualTo(e, Store.get(StoreKey.deviceIdHash)), + ); } } diff --git a/mobile/lib/shared/models/asset.g.dart b/mobile/lib/shared/models/asset.g.dart new file mode 100644 index 0000000000000000000000000000000000000000..704769031d1eb6b1f9a24984cc6de06a82aecf6a GIT binary patch literal 64357 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 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 toJson() { - final json = {}; - 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(); - 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; } diff --git a/mobile/lib/shared/models/exif_info.g.dart b/mobile/lib/shared/models/exif_info.g.dart new file mode 100644 index 0000000000000000000000000000000000000000..228e40b70bcedd05fd7864f5f18007269af9a437 GIT binary patch literal 64691 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 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 put(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 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(StoreKey key) => key._isInt - ? intValue - : (key._fromJson != null - ? key._fromJson!(json.decode(strValue!)) + T? _extract(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 _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), ; - // ignore: unused_element - const StoreKey(this._id, [this._isInt = false, this._fromJson]); - final int _id; - final bool _isInt; - final Function(dynamic)? _fromJson; + const StoreKey( + this.id, { + this.isInt = false, + this.fromDb, + this.toDb, + // ignore: unused_element + this.fromJson, + }); + final int id; + final bool isInt; + final dynamic Function(Isar, int)? fromDb; + final Future Function(Isar, dynamic)? toDb; + final Function(dynamic)? fromJson; +} + +User? _getUser(Isar db, int i) => db.users.getSync(i); +Future _toUser(Isar db, dynamic u) { + User user = (u as User); + return db.users.put(user); } diff --git a/mobile/lib/shared/models/user.dart b/mobile/lib/shared/models/user.dart index a248dd2479..49a47c10a8 100644 --- a/mobile/lib/shared/models/user.dart +++ b/mobile/lib/shared/models/user.dart @@ -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 albums = IsarLinks(); + @Backlink(to: 'sharedUsers') + final IsarLinks sharedAlbums = IsarLinks(); @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 toJson() { - final json = {}; - 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(); - 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; } diff --git a/mobile/lib/shared/models/user.g.dart b/mobile/lib/shared/models/user.g.dart new file mode 100644 index 0000000000000000000000000000000000000000..3ba4e0e998eaf9a304dd44051815d59072be7d04 GIT binary patch literal 37560 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 literal 0 HcmV?d00001 diff --git a/mobile/lib/shared/providers/asset.provider.dart b/mobile/lib/shared/providers/asset.provider.dart index 5805187c72..e8a8ee802b 100644 --- a/mobile/lib/shared/providers/asset.provider.dart +++ b/mobile/lib/shared/providers/asset.provider.dart @@ -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 local; - final Iterable remote; - final String deviceId; - - _CombineAssetsComputeParameters(this.local, this.remote, this.deviceId); -} - class AssetNotifier extends StateNotifier { 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 _updateAssetsState( - List newAssetList, { - bool cache = true, - }) async { - if (cache) { - _assetCacheService.put(newAssetList); - } - + Future _updateAssetsState(List 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 rebuildAssetGridDataStructure() async { - await _updateAssetsState(state.allAssets, cache: false); + await _updateAssetsState(state.allAssets); } getAllAsset() async { @@ -104,127 +89,102 @@ class AssetNotifier extends StateNotifier { 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? 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); - log.info( - "Reading assets ${state.allAssets.length} from cache: ${stopwatch.elapsedMilliseconds}ms", - ); - } + if (cachedCount > 0 && cachedCount != state.allAssets.length) { + await _updateAssetsState(await _getUserAssets(me.isarId)); + log.info( + "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 currentLocal = state.allAssets.slice(0, remoteBegin); - - final Pair?, String?> remoteResult = await remoteTask; - List? newRemote = remoteResult.first; - List? 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> _computeCombine( - _CombineAssetsComputeParameters data, - ) async { - var local = data.local; - var remote = data.remote; - final deviceId = data.deviceId; + Future> _getUserAssets(int userId) => _db.assets + .filter() + .ownerIdEqualTo(userId) + .sortByFileCreatedAtDesc() + .findAll(); - final List assets = []; - if (remote.isNotEmpty && local.isNotEmpty) { - final Set 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 clearAllAsset() { + state = AssetsState.empty(); + return _db.writeTxn(() async { + await _db.assets.clear(); + await _db.exifInfos.clear(); + await _db.albums.clear(); + }); } - Future> _combineLocalAndRemoteAssets({ - required Iterable local, - required List 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 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 deleteAssets) async { + Future deleteAssets(Set deleteAssets) async { _deleteInProgress = true; try { + _updateAssetsState( + state.allAssets.whereNot(deleteAssets.contains).toList(), + ); final localDeleted = await _deleteLocalAssets(deleteAssets); final remoteDeleted = await _deleteRemoteAssets(deleteAssets); - final Set 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 { } Future> _deleteLocalAssets(Set assetsToDelete) async { - var deviceInfo = await _deviceInfoService.getDeviceInfo(); - var deviceId = deviceInfo["deviceId"]; + final int deviceId = Store.get(StoreKey.deviceIdHash); final List 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 { } 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 { final assetProvider = StateNotifierProvider((ref) { return AssetNotifier( ref.watch(assetServiceProvider), - ref.watch(assetCacheServiceProvider), ref.watch(appSettingsServiceProvider), + ref.watch(albumServiceProvider), + ref.watch(dbProvider), ); }); diff --git a/mobile/lib/shared/services/api.service.dart b/mobile/lib/shared/services/api.service.dart index 8f8a7c4770..237c99a6e1 100644 --- a/mobile/lib/shared/services/api.service.dart +++ b/mobile/lib/shared/services/api.service.dart @@ -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; } diff --git a/mobile/lib/shared/services/asset.service.dart b/mobile/lib/shared/services/asset.service.dart index c9e04087fd..8ede38ca9e 100644 --- a/mobile/lib/shared/services/asset.service.dart +++ b/mobile/lib/shared/services/asset.service.dart @@ -1,101 +1,84 @@ 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 refreshRemoteAssets() async { + final Stopwatch sw = Stopwatch()..start(); + final int numOwnedRemoteAssets = await _db.assets + .where() + .remoteIdIsNotNull() + .filter() + .ownerIdEqualTo(Store.get(StoreKey.currentUser)!.isarId) + .count(); + final List? 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?, String?>> getRemoteAssets({String? etag}) async { + Future?> _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, 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); + return null; } } - /// if [urgent] is `true`, do not block by waiting on the background service - /// to finish running. Returns `null` instead after a timeout. - Future?> getLocalAssets({bool urgent = false}) async { - try { - final Future 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(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 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?> deleteAssets( Iterable deleteAssets, ) async { @@ -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 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 updateAsset( Asset asset, UpdateAssetDto updateAssetDto, diff --git a/mobile/lib/shared/services/asset_cache.service.dart b/mobile/lib/shared/services/asset_cache.service.dart index fede6b7ca6..759c61f0bc 100644 --- a/mobile/lib/shared/services/asset_cache.service.dart +++ b/mobile/lib/shared/services/asset_cache.service.dart @@ -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> { AssetCacheService() : super("asset_cache"); - static Future>> _computeSerialize( - List assets, - ) async { - return assets.map((e) => e.toJson()).toList(); - } + @override + void put(List data) {} @override - void put(List data) async { - putRawData(await compute(_computeSerialize, data)); - } - - static Future> _computeEncode(List data) async { - return data.map((e) => Asset.fromJson(e)).whereNotNull().toList(); - } - - @override - Future?> get() async { - try { - final mapList = await readRawData() as List; - final responseData = await compute(_computeEncode, mapList); - return responseData; - } catch (e) { - debugPrint(e.toString()); - await invalidate(); - return null; - } - } + Future?> get() => Future.value(null); } - -final assetCacheServiceProvider = Provider( - (ref) => AssetCacheService(), -); diff --git a/mobile/lib/shared/services/json_cache.dart b/mobile/lib/shared/services/json_cache.dart index d227660e77..d51b7cee6a 100644 --- a/mobile/lib/shared/services/json_cache.dart +++ b/mobile/lib/shared/services/json_cache.dart @@ -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 { final String cacheFileName; @@ -32,33 +31,6 @@ abstract class JsonCache { } } - static Future _computeEncodeJson(dynamic toEncode) async { - return json.encode(toEncode); - } - - Future 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 _computeDecodeJson(String jsonString) async { - return json.decode(jsonString); - } - - Future readRawData() async { - final file = await _getCacheFile(); - final data = await file.readAsString(); - - return await compute(_computeDecodeJson, data); - } - void put(T data); Future get(); } diff --git a/mobile/lib/shared/services/sync.service.dart b/mobile/lib/shared/services/sync.service.dart new file mode 100644 index 0000000000..57d565cfce --- /dev/null +++ b/mobile/lib/shared/services/sync.service.dart @@ -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 syncUsersFromServer(List users) async { + users.sortBy((u) => u.id); + final dbUsers = await _db.users.where().sortById().findAll(); + final List toDelete = []; + final List 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 syncRemoteAssetsToDb(List remote) => + _lock.run(() => _syncRemoteAssetsToDb(remote)); + + /// Syncs remote albums to the database + /// returns `true` if there were any changes + Future syncRemoteAlbumsToDb( + List remote, { + required bool isShared, + required FutureOr 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 syncLocalAlbumAssetsToDb(List onDevice) => + _lock.run(() => _syncLocalAlbumAssetsToDb(onDevice)); + + /// returns all Asset IDs that are not contained in the existing list + List sharedAssetsToRemove( + List deleteCandidates, + List 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 _syncRemoteAssetsToDb(List remote) async { + final User user = Store.get(StoreKey.currentUser); + final List 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 _syncRemoteAlbumsToDb( + List remote, + bool isShared, + FutureOr Function(AlbumResponseDto) loadDetails, + ) async { + remote.sortBy((e) => e.id); + + final baseQuery = _db.albums.where().remoteIdIsNotNull().filter(); + final QueryBuilder query; + if (isShared) { + query = baseQuery.sharedEqualTo(true); + } else { + final User me = Store.get(StoreKey.currentUser); + query = baseQuery.owner((q) => q.isarIdEqualTo(me.isarId)); + } + final List dbAlbums = await query.sortByRemoteId().findAll(); + + final List toDelete = []; + final List 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 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 _syncRemoteAlbum( + AlbumResponseDto dto, + Album album, + List deleteCandidates, + List existing, + FutureOr 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 assetsOnRemote = dto.getAssets(); + assetsOnRemote.sort(Asset.compareByDeviceIdLocalId); + final d = _diffAssets(assetsOnRemote, assetsInDb); + final List toAdd = d.first, toUpdate = d.second, toUnlink = d.third; + + // update shared users + final List 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 userIdsToAdd = []; + final List 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(); + + 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(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 _addAlbumFromServer( + AlbumResponseDto dto, + List existing, + FutureOr 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 _removeAlbumFromDb( + Album album, + List 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 _syncLocalAlbumAssetsToDb(List onDevice) async { + onDevice.sort((a, b) => a.id.compareTo(b.id)); + final List inDb = + await _db.albums.where().localIdIsNotNull().sortByLocalId().findAll(); + final List deleteCandidates = []; + final List 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 _syncAlbumInDbAndOnDevice( + AssetPathEntity ape, + Album album, + List deleteCandidates, + List 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 onDevice = await ape.getAssets(); + onDevice.sort(Asset.compareByLocalId); + final d = _diffAssets(onDevice, inDb, compare: Asset.compareByLocalId); + final List 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 _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 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 _addAlbumFromDevice( + AssetPathEntity ape, + List 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, List>> _linkWithExistingFromDb( + List assets, + ) async { + if (assets.isEmpty) { + return const Pair([], []); + } + final List 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 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 _upsertAssetsWithExif(List 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, List> _diffAssets( + List assets, + List inDb, { + bool? remote, + int Function(Asset, Asset) compare = Asset.compareByDeviceIdLocalId, +}) { + final List toAdd = []; + final List toUpdate = []; + final List 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> _handleAssetRemoval( + List deleteCandidates, + List 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 _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(); +} diff --git a/mobile/lib/shared/services/user.service.dart b/mobile/lib/shared/services/user.service.dart index 398ef2aceb..4684b30530 100644 --- a/mobile/lib/shared/services/user.service.dart +++ b/mobile/lib/shared/services/user.service.dart @@ -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?> getAllUsers({required bool isAll}) async { + Future?> _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> getUsersInDb({bool self = false}) async { + if (self) { + return _db.users.where().findAll(); + } + final int userId = Store.get(StoreKey.currentUser)!.isarId; + return _db.users.where().isarIdNotEqualTo(userId).findAll(); + } + Future uploadProfileImage(XFile image) async { try { var mimeType = FileHelper.getMimeType(image.path); @@ -50,4 +66,12 @@ class UserService { return null; } } + + Future refreshUsers() async { + final List? users = await _getAllUsers(isAll: true); + if (users == null) { + return false; + } + return _syncService.syncUsersFromServer(users); + } } diff --git a/mobile/lib/utils/async_mutex.dart b/mobile/lib/utils/async_mutex.dart new file mode 100644 index 0000000000..fe4b00b5e8 --- /dev/null +++ b/mobile/lib/utils/async_mutex.dart @@ -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 run(Future Function() operation) { + final completer = Completer(); + _running.whenComplete(() { + completer.complete(Future.sync(operation)); + }); + return _running = completer.future; + } +} diff --git a/mobile/lib/utils/builtin_extensions.dart b/mobile/lib/utils/builtin_extensions.dart index 76555f21b0..1bd3c9dc1b 100644 --- a/mobile/lib/utils/builtin_extensions.dart +++ b/mobile/lib/utils/builtin_extensions.dart @@ -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); } } diff --git a/mobile/lib/utils/diff.dart b/mobile/lib/utils/diff.dart new file mode 100644 index 0000000000..18e3843819 --- /dev/null +++ b/mobile/lib/utils/diff.dart @@ -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 diffSortedLists( + List la, + List lb, { + required int Function(A a, B b) compare, + required FutureOr Function(A a, B b) both, + required FutureOr Function(A a) onlyFirst, + required FutureOr 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( + List la, + List 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; +} diff --git a/mobile/lib/utils/hash.dart b/mobile/lib/utils/hash.dart new file mode 100644 index 0000000000..79bd637f4c --- /dev/null +++ b/mobile/lib/utils/hash.dart @@ -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; +} diff --git a/mobile/lib/utils/image_url_builder.dart b/mobile/lib/utils/image_url_builder.dart index f5591dd923..237247a9e0 100644 --- a/mobile/lib/utils/image_url_builder.dart +++ b/mobile/lib/utils/image_url_builder.dart @@ -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) { diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index d74dff9027..8d44b4f7a3 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -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 migrateHiveToStoreIfNecessary() async { try { @@ -22,3 +26,9 @@ _migrateSingleKey(Box box, String hiveKey, StoreKey key) async { await box.delete(hiveKey); } } + +Future migrateJsonCacheIfNecessary() async { + await AlbumCacheService().invalidate(); + await SharedAlbumCacheService().invalidate(); + await AssetCacheService().invalidate(); +} diff --git a/mobile/lib/utils/tuple.dart b/mobile/lib/utils/tuple.dart index 5473e9ce43..5663e03324 100644 --- a/mobile/lib/utils/tuple.dart +++ b/mobile/lib/utils/tuple.dart @@ -6,3 +6,13 @@ class Pair { const Pair(this.first, this.second); } + +/// An immutable triple or 3-tuple +/// TODO replace with Record once Dart 2.19 is available +class Triple { + final T1 first; + final T2 second; + final T3 third; + + const Triple(this.first, this.second, this.third); +} diff --git a/mobile/openapi/doc/UserResponseDto.md b/mobile/openapi/doc/UserResponseDto.md index f484c18ab0ec1a2deb724ff96f8c5ff2f3bd9dd7..8741e0dcc9c943d55573815a4831cbf243932428 100644 GIT binary patch delta 36 rcmaFJ+RL^fQQBAfe}^qDq$GRHFV1eX+L=B3*!faEuC zVR^_X2v@0KYYP!>WNT-XK@rX?&B;-)S1?epf~fewuE)p^S7ECHHcgx}oryOyO+f?h tfXQ#TdH0xtryMF!Uavq1=R0kh}{76G#q3t$4X_zk=Tv-1=2 E29%r;NB{r; diff --git a/mobile/openapi/test/user_response_dto_test.dart b/mobile/openapi/test/user_response_dto_test.dart index f1dda093cfc0e96cd8d19efb2d1a3ae09979149a..cfe0ad49f14dec36a5d063a09c71663100de65fb 100644 GIT binary patch delta 37 rcmcb^eVAuMEh}?rLCWNKW_3;=omi5Z;#e~I0gLYB4@~TnYgyv~1&|Gz delta 11 ScmX@ibBB9FE$iebtnmOIFa*Q^ diff --git a/mobile/test/asset_grid_data_structure_test.dart b/mobile/test/asset_grid_data_structure_test.dart index a5631b2c70..78c4200e21 100644 --- a/mobile/test/asset_grid_data_structure_test.dart +++ b/mobile/test/asset_grid_data_structure_test.dart @@ -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, ), ); } diff --git a/mobile/test/diff_test.dart b/mobile/test/diff_test.dart new file mode 100644 index 0000000000..286642245d --- /dev/null +++ b/mobile/test/diff_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/utils/diff.dart'; + +void main() { + final List listA = [1, 2, 3, 4, 6]; + final List listB = [1, 3, 5, 7]; + + group('Test grouped', () { + test('test partial overlap', () async { + final List onlyInA = []; + final List onlyInB = []; + final List 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 onlyInA = []; + final List onlyInB = []; + final List 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]); + }); + }); +} diff --git a/mobile/test/favorite_provider_test.dart b/mobile/test/favorite_provider_test.dart index 38a24a70d1..04d6f712f2 100644 --- a/mobile/test/favorite_provider_test.dart +++ b/mobile/test/favorite_provider_test.dart @@ -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> testFavoritesProvider; + late StateNotifierProvider> + testFavoritesProvider; - setUp(() { - assetsState = MockAssetsState(); - assetNotifier = MockAssetNotifier(); - container = ProviderContainer(); + setUp( + () { + assetsState = MockAssetsState(); + assetNotifier = MockAssetNotifier(); + container = ProviderContainer(); - testFavoritesProvider = - StateNotifierProvider>((ref) { - return FavoriteSelectionNotifier( - assetsState, - assetNotifier, - ); - }); - },); + testFavoritesProvider = + StateNotifierProvider>((ref) { + return FavoriteSelectionNotifier( + assetsState, + assetNotifier, + ); + }); + }, + ); test("Empty favorites provider", () { when(assetsState.allAssets).thenReturn([]); - expect({}, container.read(testFavoritesProvider)); + expect({}, 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({"002", "005"}, container.read(testFavoritesProvider)); + expect({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({"002"}, container.read(testFavoritesProvider)); + expect({2}, container.read(testFavoritesProvider)); container.read(testFavoritesProvider.notifier).toggleFavorite(testAsset2); - expect({}, container.read(testFavoritesProvider)); + expect({}, container.read(testFavoritesProvider)); container.read(testFavoritesProvider.notifier).toggleFavorite(testAsset1); - expect({"001"}, container.read(testFavoritesProvider)); + expect({1}, container.read(testFavoritesProvider)); }); test("Add favorites", () { @@ -89,16 +95,16 @@ void main() { when(assetsState.allAssets).thenReturn([]); - expect({}, container.read(testFavoritesProvider)); + expect({}, container.read(testFavoritesProvider)); container.read(testFavoritesProvider.notifier).addToFavorites( [ - _getTestAsset("001", false), - _getTestAsset("002", false), + _getTestAsset(1, false), + _getTestAsset(2, false), ], ); - expect({"001", "002"}, container.read(testFavoritesProvider)); + expect({1, 2}, container.read(testFavoritesProvider)); }); }); } diff --git a/mobile/test/favorite_provider_test.mocks.dart b/mobile/test/favorite_provider_test.mocks.dart index d79b009d4a..e70036512d 100644 --- a/mobile/test/favorite_provider_test.mocks.dart +++ b/mobile/test/favorite_provider_test.mocks.dart @@ -187,7 +187,7 @@ class MockAssetNotifier extends _i1.Mock implements _i2.AssetNotifier { returnValueForMissingStub: _i5.Future.value(), ) as _i5.Future); @override - void onNewAssetUploaded(_i4.Asset? newAsset) => super.noSuchMethod( + Future 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 deleteAssets(Set<_i4.Asset> deleteAssets) => super.noSuchMethod( Invocation.method( #deleteAssets, [deleteAssets], diff --git a/server/apps/immich/test/user.e2e-spec.ts b/server/apps/immich/test/user.e2e-spec.ts index 7446e3f373..bc1bc11a1d 100644 --- a/server/apps/immich/test/user.e2e-spec.ts +++ b/server/apps/immich/test/user.e2e-spec.ts @@ -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: '', }, ]), diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index ee8bc8d1d6..fdf2ac31ca 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -3583,6 +3583,9 @@ "format": "date-time", "type": "string" }, + "updatedAt": { + "type": "string" + }, "oauthId": { "type": "string" } diff --git a/server/libs/domain/src/user/response-dto/user-response.dto.ts b/server/libs/domain/src/user/response-dto/user-response.dto.ts index 3e17f38014..b83bc93fcb 100644 --- a/server/libs/domain/src/user/response-dto/user-response.dto.ts +++ b/server/libs/domain/src/user/response-dto/user-response.dto.ts @@ -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, }; } diff --git a/server/libs/domain/src/user/user.service.spec.ts b/server/libs/domain/src/user/user.service.spec.ts index bcc327b444..e169d87eca 100644 --- a/server/libs/domain/src/user/user.service.spec.ts +++ b/server/libs/domain/src/user/user.service.spec.ts @@ -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', }, ]); }); diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index a04d5cb817..586edb6df6 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -2406,6 +2406,12 @@ export interface UserResponseDto { * @memberof UserResponseDto */ 'deletedAt'?: string; + /** + * + * @type {string} + * @memberof UserResponseDto + */ + 'updatedAt'?: string; /** * * @type {string}