diff --git a/i18n/en.json b/i18n/en.json index dba0caf393..01b5144210 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2133,6 +2133,11 @@ "share_assets_selected": "{count} selected", "share_dialog_preparing": "Preparing...", "share_link": "Share Link", + "share_quality_original": "Original", + "share_quality_original_subtitle": "Send the full-quality original file", + "share_quality_preview": "Preview (JPEG)", + "share_quality_preview_subtitle": "Send a smaller, compressed JPEG", + "share_quality_title": "Share as", "shared": "Shared", "shared_album_activities_input_disable": "Comment is disabled", "shared_album_activity_remove_content": "Do you want to delete this activity?", diff --git a/mobile/lib/domain/utils/share_asset.dart b/mobile/lib/domain/utils/share_asset.dart new file mode 100644 index 0000000000..ae63fd5ecd --- /dev/null +++ b/mobile/lib/domain/utils/share_asset.dart @@ -0,0 +1,138 @@ +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; + +/// The quality at which an asset is shared. +enum ShareAssetQuality { + /// Share the full original file. Read from the device when available, + /// otherwise downloaded from the server. + original, + + /// Share the server-generated JPEG preview. Smaller and faster to send, and + /// never exposes the original (e.g. RAW) file. + preview, +} + +/// Where the bytes of a shared asset are obtained from. +enum ShareSourceKind { + /// Read the original file directly from the device. + localFile, + + /// Download the full original file from the server. + remoteOriginal, + + /// Download the server-generated JPEG preview from the server. + remotePreview, +} + +/// Resolved instruction describing how a single asset should be shared. +/// +/// This is a plain value object produced by [resolveShareSource] so that the +/// decision of *what* to share can be unit-tested independently from the side +/// effects of actually reading/downloading and handing the file to the OS share +/// sheet. +class ShareSource { + final ShareSourceKind kind; + + /// Device asset id, set when [kind] is [ShareSourceKind.localFile]. + final String? localId; + + /// Server asset id, set when [kind] requires a download. + final String? remoteId; + + const ShareSource._({required this.kind, this.localId, this.remoteId}); + + const ShareSource.localFile(String id) : this._(kind: ShareSourceKind.localFile, localId: id); + + const ShareSource.remoteOriginal(String id) : this._(kind: ShareSourceKind.remoteOriginal, remoteId: id); + + const ShareSource.remotePreview(String id) : this._(kind: ShareSourceKind.remotePreview, remoteId: id); + + /// Whether the file is read from the device instead of downloaded. + bool get isLocal => kind == ShareSourceKind.localFile; + + /// Whether the JPEG preview (rather than the original) is shared. + bool get isPreview => kind == ShareSourceKind.remotePreview; + + /// Whether the file has to be fetched from the server. + bool get requiresDownload => kind != ShareSourceKind.localFile; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ShareSource && other.kind == kind && other.localId == localId && other.remoteId == remoteId); + + @override + int get hashCode => Object.hash(kind, localId, remoteId); + + @override + String toString() => 'ShareSource(kind: $kind, localId: $localId, remoteId: $remoteId)'; +} + +/// Whether [asset] can be shared as a JPEG preview. +/// +/// The preview is generated and stored by the server, so it only exists for +/// assets that have a remote copy. It is a still JPEG, so it only makes sense +/// for images - sharing a still frame of a video would be surprising. +bool canShareAsPreview(BaseAsset asset) => asset.isImage && asset.remoteId != null; + +/// Whether offering a quality choice is meaningful for [assets]. +/// +/// The choice only matters when at least one asset can actually provide a JPEG +/// preview; otherwise sharing always falls back to the original and the picker +/// would be a no-op. +bool shouldOfferShareQualityChoice(Iterable assets) => assets.any(canShareAsPreview); + +/// Resolves how [asset] should be shared at the requested [quality]. +/// +/// Handles all three asset states - local-only, remote-only and merged - and +/// degrades gracefully when the requested quality is not available: +/// +/// * [ShareAssetQuality.preview] needs a remote image. For videos or +/// local-only assets it is not available, so the original is shared instead. +/// * [ShareAssetQuality.original] prefers the on-device file, but server-side +/// edits only exist remotely, so an edited asset must be downloaded. +/// +/// Returns `null` when the asset can neither be read locally nor downloaded. +ShareSource? resolveShareSource(BaseAsset asset, ShareAssetQuality quality) { + final localId = asset.localId; + final remoteId = asset.remoteId; + + if (quality == ShareAssetQuality.preview && canShareAsPreview(asset)) { + // canShareAsPreview guarantees a non-null remoteId. + return ShareSource.remotePreview(remoteId!); + } + + // Original quality. + // The on-device file is the true original, but an edited asset only carries + // its edits on the server, so prefer the remote copy in that case. + if (localId != null && !asset.isEdited) { + return ShareSource.localFile(localId); + } + + if (remoteId != null) { + return ShareSource.remoteOriginal(remoteId); + } + + // Local-only asset flagged as edited: there is no remote to download from, so + // fall back to the local file. + if (localId != null) { + return ShareSource.localFile(localId); + } + + return null; +} + +/// Builds the filename to use for the shared file. +/// +/// Path separators are stripped to keep the name safe to write to a temporary +/// directory. Previews are always JPEG, so the extension is normalized to +/// `.jpg` (the original might be e.g. a `.CR2`/`.dng` RAW file). +String shareFilename(BaseAsset asset, ShareSource source) { + final sanitized = asset.name.replaceAll(RegExp(r'[\\/]'), '_'); + if (!source.isPreview) { + return sanitized; + } + + final dotIndex = sanitized.lastIndexOf('.'); + final base = dotIndex > 0 ? sanitized.substring(0, dotIndex) : sanitized; + return '$base.jpg'; +} diff --git a/mobile/lib/presentation/widgets/action_buttons/share_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/share_action_button.widget.dart index 7bc5dacb16..020116c34d 100644 --- a/mobile/lib/presentation/widgets/action_buttons/share_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/share_action_button.widget.dart @@ -6,9 +6,11 @@ import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/utils/share_asset.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/share_quality_picker.widget.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -60,6 +62,19 @@ class ShareActionButton extends ConsumerWidget { return; } + // Let the user pick the quality, but only when at least one asset can + // actually be shared as a JPEG preview - otherwise the original is the only + // option and the picker would be a pointless extra tap. + var quality = ShareAssetQuality.original; + final assets = ref.read(actionProvider.notifier).getShareableAssets(source); + if (shouldOfferShareQualityChoice(assets)) { + final selected = await showShareQualityPicker(context); + if (selected == null || !context.mounted) { + return; + } + quality = selected; + } + final cancelCompleter = Completer(); final progress = ValueNotifier(null); final preparingDialog = _SharePreparingDialog(progress: progress); @@ -71,6 +86,7 @@ class ShareActionButton extends ConsumerWidget { .shareAssets( source, context, + quality: quality, cancelCompleter: cancelCompleter, onAssetDownloadProgress: (value) => progress.value = value, ) diff --git a/mobile/lib/presentation/widgets/action_buttons/share_quality_picker.widget.dart b/mobile/lib/presentation/widgets/action_buttons/share_quality_picker.widget.dart new file mode 100644 index 0000000000..7f69a1a3e1 --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/share_quality_picker.widget.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/domain/utils/share_asset.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; + +/// Shows a bottom sheet letting the user pick the quality used for sharing. +/// +/// Resolves to the chosen [ShareAssetQuality], or `null` when the sheet is +/// dismissed without making a choice (sharing should then be aborted). +Future showShareQualityPicker(BuildContext context) { + return showModalBottomSheet( + context: context, + useRootNavigator: false, + builder: (_) => const _ShareQualityPicker(), + ); +} + +class _ShareQualityPicker extends StatelessWidget { + const _ShareQualityPicker(); + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'share_quality_title'.t(context: context), + style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), + ), + ), + ), + ListTile( + leading: const Icon(Icons.high_quality_outlined), + title: Text('share_quality_original'.t(context: context)), + subtitle: Text('share_quality_original_subtitle'.t(context: context)), + onTap: () => Navigator.of(context).pop(ShareAssetQuality.original), + ), + ListTile( + leading: const Icon(Icons.image_outlined), + title: Text('share_quality_preview'.t(context: context)), + subtitle: Text('share_quality_preview_subtitle'.t(context: context)), + onTap: () => Navigator.of(context).pop(ShareAssetQuality.preview), + ), + const SizedBox(height: 8), + ], + ), + ); + } +} diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 452217bfd6..977c95910a 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -8,6 +8,7 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset_edit.model.dart'; import 'package:immich_mobile/domain/services/asset.service.dart'; +import 'package:immich_mobile/domain/utils/share_asset.dart'; import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart'; @@ -461,9 +462,13 @@ class ActionNotifier extends Notifier { } } + /// The assets that a share action would operate on for the given [source]. + List getShareableAssets(ActionSource source) => _getAssets(source).toList(growable: false); + Future shareAssets( ActionSource source, BuildContext context, { + ShareAssetQuality quality = ShareAssetQuality.original, Completer? cancelCompleter, void Function(double progress)? onAssetDownloadProgress, }) async { @@ -473,6 +478,7 @@ class ActionNotifier extends Notifier { await _service.shareAssets( ids, context, + quality: quality, cancelCompleter: cancelCompleter, onAssetDownloadProgress: onAssetDownloadProgress, ); diff --git a/mobile/lib/repositories/asset_media.repository.dart b/mobile/lib/repositories/asset_media.repository.dart index e86a372768..217c0130b8 100644 --- a/mobile/lib/repositories/asset_media.repository.dart +++ b/mobile/lib/repositories/asset_media.repository.dart @@ -7,6 +7,7 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/utils/share_asset.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; @@ -108,6 +109,7 @@ class AssetMediaRepository { Future shareAssets( List assets, BuildContext context, { + ShareAssetQuality quality = ShareAssetQuality.original, Completer? cancelCompleter, void Function(double progress)? onAssetDownloadProgress, }) async { @@ -136,13 +138,16 @@ class AssetMediaRepository { return 0; } - final localId = (asset is LocalAsset) - ? asset.id - : asset is RemoteAsset - ? asset.localId - : null; - if (localId != null && !asset.isEdited) { - File? f = await AssetEntity(id: localId, width: 1, height: 1, typeInt: 0).originFile; + final source = resolveShareSource(asset, quality); + if (source == null) { + _log.warning("Asset has no shareable source: $asset"); + processedAssets++; + updateProgress(); + continue; + } + + if (source.isLocal) { + File? f = await AssetEntity(id: source.localId!, width: 1, height: 1, typeInt: 0).originFile; downloadedXFiles.add(XFile(f!.path)); processedAssets++; updateProgress(); @@ -150,19 +155,16 @@ class AssetMediaRepository { tempFiles.add(f); } } else { - final remoteId = (asset is RemoteAsset) ? asset.id : asset.remoteId; - if (remoteId == null) { - _log.warning("Asset has no remote ID for sharing: $asset"); - processedAssets++; - updateProgress(); - continue; - } + final remoteId = source.remoteId!; final taskId = 'share-$remoteId-${DateTime.now().microsecondsSinceEpoch}'; - final sanitizedFilename = asset.name.replaceAll(RegExp(r'[\\/]'), '_'); + final sanitizedFilename = shareFilename(asset, source); + final url = source.isPreview + ? getPreviewUrlForRemoteId(remoteId, edited: asset.isEdited) + : getOriginalUrlForRemoteId(remoteId, edited: asset.isEdited); final task = DownloadTask( taskId: taskId, - url: getOriginalUrlForRemoteId(remoteId, edited: asset.isEdited), + url: url, headers: ApiService.getRequestHeaders(), filename: sanitizedFilename, baseDirectory: BaseDirectory.temporary, diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index b22c6680a4..6751c660b8 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset_edit.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/services/tag.service.dart'; +import 'package:immich_mobile/domain/utils/share_asset.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; @@ -272,12 +273,14 @@ class ActionService { Future shareAssets( List assets, BuildContext context, { + ShareAssetQuality quality = ShareAssetQuality.original, Completer? cancelCompleter, void Function(double progress)? onAssetDownloadProgress, }) { return _assetMediaRepository.shareAssets( assets, context, + quality: quality, cancelCompleter: cancelCompleter, onAssetDownloadProgress: onAssetDownloadProgress, ); diff --git a/mobile/lib/utils/image_url_builder.dart b/mobile/lib/utils/image_url_builder.dart index c562049b1d..3322c59dbe 100644 --- a/mobile/lib/utils/image_url_builder.dart +++ b/mobile/lib/utils/image_url_builder.dart @@ -16,6 +16,10 @@ String getThumbnailUrlForRemoteId( return thumbhash != null ? '$url&c=${Uri.encodeComponent(thumbhash)}' : url; } +String getPreviewUrlForRemoteId(final String id, {bool edited = true}) { + return getThumbnailUrlForRemoteId(id, type: AssetMediaSize.preview, edited: edited); +} + String getPlaybackUrlForRemoteId(final String id) { return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/video/playback?'; } diff --git a/mobile/test/domain/utils/share_asset_test.dart b/mobile/test/domain/utils/share_asset_test.dart new file mode 100644 index 0000000000..a6d9d00b3f --- /dev/null +++ b/mobile/test/domain/utils/share_asset_test.dart @@ -0,0 +1,185 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/utils/share_asset.dart'; + +LocalAsset _local({ + String id = 'local-1', + String? remoteId, + AssetType type = AssetType.image, + bool isEdited = false, + String name = 'photo.jpg', +}) { + return LocalAsset( + id: id, + remoteId: remoteId, + name: name, + type: type, + createdAt: DateTime(2025), + updatedAt: DateTime(2025), + playbackStyle: type == AssetType.video ? AssetPlaybackStyle.video : AssetPlaybackStyle.image, + isEdited: isEdited, + ); +} + +RemoteAsset _remote({ + String id = 'remote-1', + String? localId, + AssetType type = AssetType.image, + bool isEdited = false, + String name = 'photo.jpg', +}) { + return RemoteAsset( + id: id, + localId: localId, + name: name, + ownerId: 'owner-1', + checksum: 'checksum-1', + type: type, + createdAt: DateTime(2025), + updatedAt: DateTime(2025), + isEdited: isEdited, + ); +} + +void main() { + group('canShareAsPreview', () { + test('true for a remote-only image', () { + expect(canShareAsPreview(_remote()), isTrue); + }); + + test('true for a merged image regardless of which model carries it', () { + expect(canShareAsPreview(_remote(localId: 'local-1')), isTrue); + expect(canShareAsPreview(_local(remoteId: 'remote-1')), isTrue); + }); + + test('false for a local-only image (no server preview exists)', () { + expect(canShareAsPreview(_local()), isFalse); + }); + + test('false for videos even when remote (preview is a still JPEG)', () { + expect(canShareAsPreview(_remote(type: AssetType.video)), isFalse); + expect(canShareAsPreview(_remote(localId: 'local-1', type: AssetType.video)), isFalse); + }); + }); + + group('shouldOfferShareQualityChoice', () { + test('false for an empty selection', () { + expect(shouldOfferShareQualityChoice(const []), isFalse); + }); + + test('false when nothing can provide a preview', () { + expect(shouldOfferShareQualityChoice([_local(), _remote(type: AssetType.video)]), isFalse); + }); + + test('true when at least one asset can provide a preview', () { + expect(shouldOfferShareQualityChoice([_local(), _remote()]), isTrue); + }); + }); + + group('resolveShareSource - local-only', () { + final asset = _local(); + + test('original reads the local file', () { + expect(resolveShareSource(asset, ShareAssetQuality.original), const ShareSource.localFile('local-1')); + }); + + test('preview falls back to the local file (no remote preview available)', () { + expect(resolveShareSource(asset, ShareAssetQuality.preview), const ShareSource.localFile('local-1')); + }); + + test('edited local-only asset still falls back to the local file', () { + final edited = _local(isEdited: true); + expect(resolveShareSource(edited, ShareAssetQuality.original), const ShareSource.localFile('local-1')); + expect(resolveShareSource(edited, ShareAssetQuality.preview), const ShareSource.localFile('local-1')); + }); + }); + + group('resolveShareSource - remote-only', () { + final asset = _remote(); + + test('original downloads the original', () { + expect(resolveShareSource(asset, ShareAssetQuality.original), const ShareSource.remoteOriginal('remote-1')); + }); + + test('preview downloads the preview', () { + expect(resolveShareSource(asset, ShareAssetQuality.preview), const ShareSource.remotePreview('remote-1')); + }); + + test('edited remote video downloads the original even when preview is requested', () { + final video = _remote(type: AssetType.video, isEdited: true); + expect(resolveShareSource(video, ShareAssetQuality.preview), const ShareSource.remoteOriginal('remote-1')); + }); + }); + + group('resolveShareSource - merged', () { + final mergedFromRemote = _remote(localId: 'local-1'); + final mergedFromLocal = _local(remoteId: 'remote-1'); + + test('original prefers the local file when not edited', () { + expect(resolveShareSource(mergedFromRemote, ShareAssetQuality.original), const ShareSource.localFile('local-1')); + expect(resolveShareSource(mergedFromLocal, ShareAssetQuality.original), const ShareSource.localFile('local-1')); + }); + + test('preview downloads the preview from the server', () { + expect(resolveShareSource(mergedFromRemote, ShareAssetQuality.preview), const ShareSource.remotePreview('remote-1')); + expect(resolveShareSource(mergedFromLocal, ShareAssetQuality.preview), const ShareSource.remotePreview('remote-1')); + }); + + test('edited asset downloads the original instead of using the stale local file', () { + final edited = _remote(localId: 'local-1', isEdited: true); + expect(resolveShareSource(edited, ShareAssetQuality.original), const ShareSource.remoteOriginal('remote-1')); + }); + + test('edited asset can still share the (edited) preview', () { + final edited = _remote(localId: 'local-1', isEdited: true); + expect(resolveShareSource(edited, ShareAssetQuality.preview), const ShareSource.remotePreview('remote-1')); + }); + + test('video uses the local file even when preview is requested', () { + final video = _remote(localId: 'local-1', type: AssetType.video); + expect(resolveShareSource(video, ShareAssetQuality.preview), const ShareSource.localFile('local-1')); + }); + }); + + group('ShareSource helpers', () { + test('expose the right flags', () { + const local = ShareSource.localFile('a'); + const original = ShareSource.remoteOriginal('b'); + const preview = ShareSource.remotePreview('c'); + + expect(local.isLocal, isTrue); + expect(local.requiresDownload, isFalse); + expect(local.isPreview, isFalse); + + expect(original.requiresDownload, isTrue); + expect(original.isPreview, isFalse); + + expect(preview.requiresDownload, isTrue); + expect(preview.isPreview, isTrue); + }); + }); + + group('shareFilename', () { + test('keeps the original filename for non-preview sources', () { + final asset = _remote(name: 'IMG_0001.HEIC'); + expect(shareFilename(asset, const ShareSource.remoteOriginal('remote-1')), 'IMG_0001.HEIC'); + expect(shareFilename(asset, const ShareSource.localFile('local-1')), 'IMG_0001.HEIC'); + }); + + test('normalizes the extension to .jpg for preview sources', () { + final raw = _remote(name: 'IMG_0001.CR2'); + expect(shareFilename(raw, const ShareSource.remotePreview('remote-1')), 'IMG_0001.jpg'); + }); + + test('appends .jpg when the preview asset has no extension', () { + final asset = _remote(name: 'no_extension'); + expect(shareFilename(asset, const ShareSource.remotePreview('remote-1')), 'no_extension.jpg'); + }); + + test('sanitizes path separators in the filename', () { + final asset = _remote(name: 'sub/dir\\file.png'); + expect(shareFilename(asset, const ShareSource.remoteOriginal('remote-1')), 'sub_dir_file.png'); + expect(shareFilename(asset, const ShareSource.remotePreview('remote-1')), 'sub_dir_file.jpg'); + }); + }); +}