Compare commits

..

1 Commits

Author SHA1 Message Date
Claude 2feee111fd feat(mobile): choose JPEG preview or original quality when sharing
Add a mechanism to share either the server-generated JPEG preview or the
full original file. When the selection contains at least one asset that can
provide a preview (a remote image), a bottom sheet lets the user pick the
quality; otherwise sharing falls back to the original as before.

The share-source resolution is extracted into pure, unit-tested business
logic that handles all three asset states (local, remote, merged):
- preview requires a remote image; videos and local-only assets fall back
  to the original
- original prefers the on-device file, but edited assets are downloaded
  since edits only exist on the server
- preview filenames are normalized to a .jpg extension

Add unit tests covering the resolution, gating and filename helpers.
2026-05-30 18:39:01 +00:00
12 changed files with 431 additions and 28 deletions
+5
View File
@@ -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?",
+138
View File
@@ -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<BaseAsset> 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';
}
@@ -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<void>();
final progress = ValueNotifier<double?>(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,
)
@@ -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<ShareAssetQuality?> showShareQualityPicker(BuildContext context) {
return showModalBottomSheet<ShareAssetQuality>(
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),
],
),
);
}
}
@@ -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<void> {
}
}
/// The assets that a share action would operate on for the given [source].
List<BaseAsset> getShareableAssets(ActionSource source) => _getAssets(source).toList(growable: false);
Future<ActionResult> shareAssets(
ActionSource source,
BuildContext context, {
ShareAssetQuality quality = ShareAssetQuality.original,
Completer<void>? cancelCompleter,
void Function(double progress)? onAssetDownloadProgress,
}) async {
@@ -473,6 +478,7 @@ class ActionNotifier extends Notifier<void> {
await _service.shareAssets(
ids,
context,
quality: quality,
cancelCompleter: cancelCompleter,
onAssetDownloadProgress: onAssetDownloadProgress,
);
@@ -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<int> shareAssets(
List<BaseAsset> assets,
BuildContext context, {
ShareAssetQuality quality = ShareAssetQuality.original,
Completer<void>? 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,
+3
View File
@@ -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<int> shareAssets(
List<BaseAsset> assets,
BuildContext context, {
ShareAssetQuality quality = ShareAssetQuality.original,
Completer<void>? cancelCompleter,
void Function(double progress)? onAssetDownloadProgress,
}) {
return _assetMediaRepository.shareAssets(
assets,
context,
quality: quality,
cancelCompleter: cancelCompleter,
onAssetDownloadProgress: onAssetDownloadProgress,
);
+4
View File
@@ -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?';
}
@@ -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');
});
});
}
-9
View File
@@ -152,13 +152,4 @@ export const Route = {
// queues
queues: () => '/admin/queues',
viewQueue: ({ name }: { name: QueueName }) => `/admin/queues/${asQueueSlug(name)}`,
// continue helper for ensuring same-origin URLs
continue: (url: string | null, fallback: string) => {
if (!url || !url.startsWith('/') || url.startsWith('//')) {
return fallback;
}
return url;
},
};
+1 -2
View File
@@ -8,8 +8,7 @@ import type { PageLoad } from './$types';
export const load = (async ({ parent, url }) => {
await parent();
const continueUrl = Route.continue(url.searchParams.get('continue'), Route.photos());
const continueUrl = url.searchParams.get('continue') || Route.photos();
if (authManager.authenticated) {
redirect(307, continueUrl);
}
+1 -1
View File
@@ -30,7 +30,7 @@
await new Promise((resolve) => setTimeout(resolve, 1000));
await goto(Route.continue(data.continueUrl, Route.photos()));
await goto(data.continueUrl);
} catch (error) {
handleError(error, $t('wrong_pin_code'));
isBadPinCode = true;