diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 83364415b5..62a61a8f46 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -189,6 +189,10 @@ "home_page_building_timeline": "Building the timeline", "home_page_delete_err_partner": "Can not delete partner assets, skipping", "home_page_favorite_err_local": "Can not favorite local assets yet, skipping", + "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", + "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", + "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", + "asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping", "home_page_favorite_err_partner": "Can not favorite partner assets yet, skipping", "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose backup album(s) so that the timeline can populate photos and videos in the album(s).", "home_page_share_err_local": "Can not share local assets via link, skipping", diff --git a/mobile/lib/extensions/collection_extensions.dart b/mobile/lib/extensions/collection_extensions.dart index 283726ede3..9f08ba3efb 100644 --- a/mobile/lib/extensions/collection_extensions.dart +++ b/mobile/lib/extensions/collection_extensions.dart @@ -1,6 +1,8 @@ import 'dart:typed_data'; import 'package:collection/collection.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/models/user.dart'; extension ListExtension on List { List uniqueConsecutive({ @@ -39,3 +41,58 @@ extension IntListExtension on Iterable { return list; } } + +extension AssetListExtension on Iterable { + /// Returns the assets that are already available in the Immich server + Iterable remoteOnly({ + void Function()? errorCallback, + }) { + final bool onlyRemote = every((e) => e.isRemote); + if (!onlyRemote) { + if (errorCallback != null) errorCallback(); + return where((a) => a.isRemote); + } + return this; + } + + /// Returns the assets that are owned by the user passed to the [owner] param + /// If [owner] is null, an empty list is returned + Iterable ownedOnly( + User? owner, { + void Function()? errorCallback, + }) { + if (owner == null) return []; + final userId = owner.isarId; + final bool onlyOwned = every((e) => e.ownerId == userId); + if (!onlyOwned) { + if (errorCallback != null) errorCallback(); + return where((a) => a.ownerId == userId); + } + return this; + } + + /// Returns the assets that are present on a file system which has write permission + /// This filters out assets on readOnly external library to which we cannot perform any write operation + Iterable writableOnly({ + void Function()? errorCallback, + }) { + final bool onlyWritable = every((e) => !e.isReadOnly); + if (!onlyWritable) { + if (errorCallback != null) errorCallback(); + return where((a) => !a.isReadOnly); + } + return this; + } + + /// Filters out offline assets and returns those that are still accessible by the Immich server + Iterable nonOfflineOnly({ + void Function()? errorCallback, + }) { + final bool onlyLive = every((e) => !e.isOffline); + if (!onlyLive) { + if (errorCallback != null) errorCallback(); + return where((a) => !a.isOffline); + } + return this; + } +} 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 8c63c91616..e560bcb73b 100644 --- a/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart +++ b/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart @@ -156,7 +156,7 @@ class ExifBottomSheet extends HookConsumerWidget { buildLocation() { // Guard no lat/lng if (!hasCoordinates()) { - return asset.isRemote + return asset.isRemote && !asset.isReadOnly ? ListTile( minLeadingWidth: 0, contentPadding: const EdgeInsets.all(0), @@ -194,7 +194,7 @@ class ExifBottomSheet extends HookConsumerWidget { fontWeight: FontWeight.w600, ), ).tr(), - if (asset.isRemote) + if (asset.isRemote && !asset.isReadOnly) IconButton( onPressed: () => handleEditLocation( ref, @@ -251,7 +251,7 @@ class ExifBottomSheet extends HookConsumerWidget { fontSize: 14, ), ), - if (asset.isRemote) + if (asset.isRemote && !asset.isReadOnly) IconButton( onPressed: () => handleEditDateTime( ref, diff --git a/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart b/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart index 14f8645787..abdfa427c8 100644 --- a/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart +++ b/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart @@ -166,7 +166,8 @@ class TopControlAppBar extends HookConsumerWidget { if (asset.isRemote && isOwner) buildFavoriteButton(a), if (asset.livePhotoVideoId != null) buildLivePhotoButton(), if (asset.isLocal && !asset.isRemote) buildUploadButton(), - if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(), + if (asset.isRemote && !asset.isLocal && !asset.isOffline && isOwner) + buildDownloadButton(), if (asset.isRemote && (isOwner || isPartner)) buildAddToAlbumButtom(), if (album != null && album.shared) buildActivitiesButton(), buildMoreInfoButton(), diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index b0bd2d2ac2..4cb2dd032d 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -187,8 +187,8 @@ class GalleryViewerPage extends HookConsumerWidget { void showInfo() { showModalBottomSheet( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(15.0), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(15.0)), ), barrierColor: Colors.transparent, backgroundColor: Colors.transparent, @@ -220,6 +220,16 @@ class GalleryViewerPage extends HookConsumerWidget { } void handleDelete(Asset deleteAsset) async { + // Cannot delete readOnly / external assets. They are handled through library offline jobs + if (asset().isReadOnly) { + ImmichToast.show( + durationInSecond: 1, + context: context, + msg: 'asset_action_delete_err_read_only'.tr(), + gravity: ToastGravity.BOTTOM, + ); + return; + } Future onDelete(bool force) async { final isDeleted = await ref.read(assetProvider.notifier).deleteAssets( {deleteAsset}, @@ -319,11 +329,20 @@ class GalleryViewerPage extends HookConsumerWidget { } shareAsset() { - ref.watch(imageViewerStateProvider.notifier).shareAsset(asset(), context); + if (asset().isOffline) { + ImmichToast.show( + durationInSecond: 1, + context: context, + msg: 'asset_action_share_err_offline'.tr(), + gravity: ToastGravity.BOTTOM, + ); + return; + } + ref.read(imageViewerStateProvider.notifier).shareAsset(asset(), context); } handleArchive(Asset asset) { - ref.watch(assetProvider.notifier).toggleArchive([asset]); + ref.read(assetProvider.notifier).toggleArchive([asset]); if (isParent) { context.popRoute(); return; @@ -346,6 +365,26 @@ class GalleryViewerPage extends HookConsumerWidget { ); } + handleDownload() { + if (asset().isLocal) { + return; + } + if (asset().isOffline) { + ImmichToast.show( + durationInSecond: 1, + context: context, + msg: 'asset_action_share_err_offline'.tr(), + gravity: ToastGravity.BOTTOM, + ); + return; + } + + ref.read(imageViewerStateProvider.notifier).downloadAsset( + asset(), + context, + ); + } + handleActivities() { if (album != null && album.shared && album.remoteId != null) { context.pushRoute(const ActivitiesRoute()); @@ -371,12 +410,11 @@ class GalleryViewerPage extends HookConsumerWidget { asset().isLocal ? () => handleUpload(asset()) : null, onDownloadPressed: asset().isLocal ? null - : () => ref - .watch(imageViewerStateProvider.notifier) - .downloadAsset( - asset(), - context, - ), + : () => + ref.read(imageViewerStateProvider.notifier).downloadAsset( + asset(), + context, + ), onToggleMotionVideo: (() { isPlayingMotionVideo.value = !isPlayingMotionVideo.value; }), @@ -641,13 +679,7 @@ class GalleryViewerPage extends HookConsumerWidget { if (isOwner) (_) => handleArchive(asset()), if (isOwner && stack.isNotEmpty) (_) => showStackActionItems(), if (isOwner) (_) => handleDelete(asset()), - if (!isOwner) - (_) => asset().isLocal - ? null - : ref.watch(imageViewerStateProvider.notifier).downloadAsset( - asset(), - context, - ), + if (!isOwner) (_) => handleDownload(), ]; return IgnorePointer( diff --git a/mobile/lib/shared/models/asset.dart b/mobile/lib/shared/models/asset.dart index 8fa90c7806..0ed69dea75 100644 --- a/mobile/lib/shared/models/asset.dart +++ b/mobile/lib/shared/models/asset.dart @@ -32,6 +32,8 @@ class Asset { isFavorite = remote.isFavorite, isArchived = remote.isArchived, isTrashed = remote.isTrashed, + isReadOnly = remote.isReadOnly, + isOffline = remote.isOffline, stackParentId = remote.stackParentId, stackCount = remote.stackCount; @@ -49,6 +51,8 @@ class Asset { isFavorite = local.isFavorite, isArchived = false, isTrashed = false, + isReadOnly = false, + isOffline = false, stackCount = 0, fileCreatedAt = local.createDateTime { if (fileCreatedAt.year == 1970) { @@ -77,11 +81,13 @@ class Asset { required this.fileName, this.livePhotoVideoId, this.exifInfo, - required this.isFavorite, - required this.isArchived, - required this.isTrashed, + this.isFavorite = false, + this.isArchived = false, + this.isTrashed = false, this.stackParentId, - required this.stackCount, + this.stackCount = 0, + this.isReadOnly = false, + this.isOffline = false, }); @ignore @@ -148,6 +154,10 @@ class Asset { bool isTrashed; + bool isReadOnly; + + bool isOffline; + @ignore ExifInfo? exifInfo; @@ -256,6 +266,8 @@ class Asset { isFavorite != a.isFavorite || isArchived != a.isArchived || isTrashed != a.isTrashed || + isReadOnly != a.isReadOnly || + isOffline != a.isOffline || a.exifInfo?.latitude != exifInfo?.latitude || a.exifInfo?.longitude != exifInfo?.longitude || // no local stack count or different count from remote @@ -288,6 +300,7 @@ class Asset { exifInfo: exifInfo ?? a.exifInfo?.copyWith(id: id), ); } else { + // TODO: Revisit this and remove all bool field assignments return a._copyWith( id: id, remoteId: remoteId, @@ -297,6 +310,8 @@ class Asset { isFavorite: isFavorite, isArchived: isArchived, isTrashed: isTrashed, + isReadOnly: isReadOnly, + isOffline: isOffline, ); } } else { @@ -314,6 +329,8 @@ class Asset { isFavorite: a.isFavorite, isArchived: a.isArchived, isTrashed: a.isTrashed, + isReadOnly: a.isReadOnly, + isOffline: a.isOffline, exifInfo: a.exifInfo?.copyWith(id: id) ?? exifInfo, ); } else { @@ -346,6 +363,8 @@ class Asset { bool? isFavorite, bool? isArchived, bool? isTrashed, + bool? isReadOnly, + bool? isOffline, ExifInfo? exifInfo, String? stackParentId, int? stackCount, @@ -368,6 +387,8 @@ class Asset { isFavorite: isFavorite ?? this.isFavorite, isArchived: isArchived ?? this.isArchived, isTrashed: isTrashed ?? this.isTrashed, + isReadOnly: isReadOnly ?? this.isReadOnly, + isOffline: isOffline ?? this.isOffline, exifInfo: exifInfo ?? this.exifInfo, stackParentId: stackParentId ?? this.stackParentId, stackCount: stackCount ?? this.stackCount, @@ -426,7 +447,9 @@ class Asset { "width": ${width ?? "N/A"}, "height": ${height ?? "N/A"}, "isArchived": $isArchived, - "isTrashed": $isTrashed + "isTrashed": $isTrashed, + "isReadOnly": $isReadOnly, + "isOffline": $isOffline, }"""; } } diff --git a/mobile/lib/shared/models/asset.g.dart b/mobile/lib/shared/models/asset.g.dart index f589ba0c67..d845b5353a 100644 Binary files a/mobile/lib/shared/models/asset.g.dart and b/mobile/lib/shared/models/asset.g.dart differ diff --git a/mobile/lib/shared/ui/asset_grid/multiselect_grid.dart b/mobile/lib/shared/ui/asset_grid/multiselect_grid.dart index e3db2ab166..c0b479f2e8 100644 --- a/mobile/lib/shared/ui/asset_grid/multiselect_grid.dart +++ b/mobile/lib/shared/ui/asset_grid/multiselect_grid.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/collection_extensions.dart'; import 'package:immich_mobile/modules/album/providers/album.provider.dart'; import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart'; @@ -108,52 +109,33 @@ class MultiselectGrid extends HookConsumerWidget { ) : null; - Iterable remoteOnly( - Iterable assets, { - void Function()? errorCallback, - }) { - final bool onlyRemote = assets.every((e) => e.isRemote); - if (!onlyRemote) { - if (errorCallback != null) errorCallback(); - return assets.where((a) => a.isRemote); - } - return assets; - } - - Iterable ownedOnly( - Iterable assets, { - void Function()? errorCallback, - }) { - if (currentUser == null) return []; - final userId = currentUser.isarId; - final bool onlyOwned = assets.every((e) => e.ownerId == userId); - if (!onlyOwned) { - if (errorCallback != null) errorCallback(); - return assets.where((a) => a.ownerId == userId); - } - return assets; - } - Iterable ownedRemoteSelection({ String? localErrorMessage, String? ownerErrorMessage, }) { final assets = selection.value; - return remoteOnly( - ownedOnly(assets, errorCallback: errorBuilder(ownerErrorMessage)), - errorCallback: errorBuilder(localErrorMessage), - ); + return assets + .remoteOnly(errorCallback: errorBuilder(ownerErrorMessage)) + .ownedOnly( + currentUser, + errorCallback: errorBuilder(localErrorMessage), + ); } - Iterable remoteSelection({String? errorMessage}) => remoteOnly( - selection.value, + Iterable remoteSelection({String? errorMessage}) => + selection.value.remoteOnly( errorCallback: errorBuilder(errorMessage), ); void onShareAssets(bool shareLocal) { processing.value = true; if (shareLocal) { - handleShareAssets(ref, context, selection.value.toList()); + // Share = Download + Send to OS specific share sheet + // Filter offline assets since we cannot fetch their original file + final liveAssets = selection.value.nonOfflineOnly( + errorCallback: errorBuilder('asset_action_share_err_offline'.tr()), + ); + handleShareAssets(ref, context, liveAssets); } else { final ids = remoteSelection(errorMessage: "home_page_share_err_local".tr()) @@ -199,10 +181,17 @@ class MultiselectGrid extends HookConsumerWidget { try { final trashEnabled = ref.read(serverInfoProvider.select((v) => v.serverFeatures.trash)); - final toDelete = ownedOnly( - selection.value, - errorCallback: errorBuilder('home_page_delete_err_partner'.tr()), - ).toList(); + final toDelete = selection.value + .ownedOnly( + currentUser, + errorCallback: errorBuilder('home_page_delete_err_partner'.tr()), + ) + // Cannot delete readOnly / external assets. They are handled through library offline jobs + .writableOnly( + errorCallback: + errorBuilder('asset_action_delete_err_read_only'.tr()), + ) + .toList(); await ref .read(assetProvider.notifier) .deleteAssets(toDelete, force: !trashEnabled); @@ -331,6 +320,11 @@ class MultiselectGrid extends HookConsumerWidget { final remoteAssets = ownedRemoteSelection( localErrorMessage: 'home_page_favorite_err_local'.tr(), ownerErrorMessage: 'home_page_favorite_err_partner'.tr(), + ).writableOnly( + // Assume readOnly assets to be present in a read-only mount. So do not write sidecar + errorCallback: errorBuilder( + 'multiselect_grid_edit_date_time_err_read_only'.tr(), + ), ); if (remoteAssets.isNotEmpty) { handleEditDateTime(ref, context, remoteAssets.toList()); @@ -345,6 +339,11 @@ class MultiselectGrid extends HookConsumerWidget { final remoteAssets = ownedRemoteSelection( localErrorMessage: 'home_page_favorite_err_local'.tr(), ownerErrorMessage: 'home_page_favorite_err_partner'.tr(), + ).writableOnly( + // Assume readOnly assets to be present in a read-only mount. So do not write sidecar + errorCallback: errorBuilder( + 'multiselect_grid_edit_gps_err_read_only'.tr(), + ), ); if (remoteAssets.isNotEmpty) { handleEditLocation(ref, context, remoteAssets.toList()); diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index 2356c73143..695f936bee 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -13,6 +13,8 @@ Future migrateDatabaseIfNeeded(Isar db) async { await _migrateTo(db, 3); case 3: await _migrateTo(db, 4); + case 4: + await _migrateTo(db, 5); } } diff --git a/mobile/lib/utils/selection_handlers.dart b/mobile/lib/utils/selection_handlers.dart index 2cda733881..0be6e77d11 100644 --- a/mobile/lib/utils/selection_handlers.dart +++ b/mobile/lib/utils/selection_handlers.dart @@ -17,7 +17,7 @@ import 'package:latlong2/latlong.dart'; void handleShareAssets( WidgetRef ref, BuildContext context, - List selection, + Iterable selection, ) { showDialog( context: context,