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 0000000000..e483907a24 Binary files /dev/null and b/mobile/lib/shared/models/album.g.dart differ 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 0000000000..704769031d Binary files /dev/null and b/mobile/lib/shared/models/asset.g.dart differ diff --git a/mobile/lib/shared/models/exif_info.dart b/mobile/lib/shared/models/exif_info.dart index a14d1ef95c..0fd20aaf34 100644 --- a/mobile/lib/shared/models/exif_info.dart +++ b/mobile/lib/shared/models/exif_info.dart @@ -1,86 +1,93 @@ +import 'package:isar/isar.dart'; import 'package:openapi/api.dart'; import 'package:immich_mobile/utils/builtin_extensions.dart'; +part 'exif_info.g.dart'; + +/// Exif information 1:1 relation with Asset +@Collection(inheritance: false) class ExifInfo { + Id? id; int? fileSize; String? make; String? model; - String? orientation; - String? lensModel; - double? fNumber; - double? focalLength; - int? iso; - double? exposureTime; + String? lens; + float? f; + float? mm; + short? iso; + float? exposureSeconds; + float? lat; + float? long; String? city; String? state; String? country; + @ignore + String get exposureTime { + if (exposureSeconds == null) { + return ""; + } else if (exposureSeconds! < 1) { + return "1/${(1.0 / exposureSeconds!).round()} s"; + } else { + return "${exposureSeconds!.toStringAsFixed(1)} s"; + } + } + + @ignore + String get fNumber => f != null ? f!.toStringAsFixed(1) : ""; + + @ignore + String get focalLength => mm != null ? mm!.toStringAsFixed(1) : ""; + + @ignore + double? get latitude => lat; + + @ignore + double? get longitude => long; + ExifInfo.fromDto(ExifResponseDto dto) : fileSize = dto.fileSizeInByte, make = dto.make, model = dto.model, - orientation = dto.orientation, - lensModel = dto.lensModel, - fNumber = dto.fNumber?.toDouble(), - focalLength = dto.focalLength?.toDouble(), + lens = dto.lensModel, + f = dto.fNumber?.toDouble(), + mm = dto.focalLength?.toDouble(), iso = dto.iso?.toInt(), - exposureTime = dto.exposureTime?.toDouble(), + exposureSeconds = _exposureTimeToSeconds(dto.exposureTime), + lat = dto.latitude?.toDouble(), + long = dto.longitude?.toDouble(), city = dto.city, state = dto.state, country = dto.country; - // stuff below is only required for caching as JSON - - ExifInfo( + ExifInfo({ this.fileSize, this.make, this.model, - this.orientation, - this.lensModel, - this.fNumber, - this.focalLength, + this.lens, + this.f, + this.mm, this.iso, - this.exposureTime, + this.exposureSeconds, + this.lat, + this.long, this.city, this.state, this.country, - ); + }); +} - Map 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 0000000000..228e40b70b Binary files /dev/null and b/mobile/lib/shared/models/exif_info.g.dart differ diff --git a/mobile/lib/shared/models/store.dart b/mobile/lib/shared/models/store.dart index 537ac5443b..a349e7d600 100644 --- a/mobile/lib/shared/models/store.dart +++ b/mobile/lib/shared/models/store.dart @@ -1,3 +1,4 @@ +import 'package:immich_mobile/shared/models/user.dart'; import 'package:isar/isar.dart'; import 'dart:convert'; @@ -25,26 +26,28 @@ class Store { /// Returns the stored value for the given key, or the default value if null static T? get(StoreKey key, [T? defaultValue]) => - _cache[key._id] ?? defaultValue; + _cache[key.id] ?? defaultValue; /// Stores the value synchronously in the cache and asynchronously in the DB static Future 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 0000000000..3ba4e0e998 Binary files /dev/null and b/mobile/lib/shared/models/user.g.dart differ 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 f484c18ab0..8741e0dcc9 100644 Binary files a/mobile/openapi/doc/UserResponseDto.md and b/mobile/openapi/doc/UserResponseDto.md differ diff --git a/mobile/openapi/lib/model/user_response_dto.dart b/mobile/openapi/lib/model/user_response_dto.dart index a9e80c593e..7d231d305b 100644 Binary files a/mobile/openapi/lib/model/user_response_dto.dart and b/mobile/openapi/lib/model/user_response_dto.dart differ diff --git a/mobile/openapi/test/user_response_dto_test.dart b/mobile/openapi/test/user_response_dto_test.dart index f1dda093cf..cfe0ad49f1 100644 Binary files a/mobile/openapi/test/user_response_dto_test.dart and b/mobile/openapi/test/user_response_dto_test.dart differ 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}