mirror of
https://github.com/immich-app/immich.git
synced 2026-07-28 14:47:30 -07:00
428 lines
14 KiB
Dart
428 lines
14 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:background_downloader/background_downloader.dart';
|
|
import 'package:device_info_plus/device_info_plus.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:immich_mobile/constants/constants.dart';
|
|
import 'package:immich_mobile/constants/enums.dart';
|
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
|
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
|
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
|
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
|
import 'package:immich_mobile/services/api.service.dart';
|
|
import 'package:immich_mobile/utils/image_url_builder.dart';
|
|
import 'package:logging/logging.dart';
|
|
import 'package:openapi/api.dart';
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:photo_manager/photo_manager.dart';
|
|
import 'package:share_plus/share_plus.dart';
|
|
|
|
typedef _ShareFile = ({File file, FileSystemEntity? tempEntity, String displayName});
|
|
|
|
final assetMediaRepositoryProvider = Provider(
|
|
(ref) => AssetMediaRepository(ref.watch(nativeSyncApiProvider), ref.watch(storageRepositoryProvider)),
|
|
);
|
|
|
|
class AssetMediaRepository {
|
|
final NativeSyncApi _nativeSyncApi;
|
|
final StorageRepository _storageRepository;
|
|
static final Logger _log = Logger("AssetMediaRepository");
|
|
|
|
const AssetMediaRepository(this._nativeSyncApi, this._storageRepository);
|
|
|
|
Future<bool> _androidSupportsTrash() async {
|
|
if (Platform.isAndroid) {
|
|
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
|
|
AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
|
|
int sdkVersion = androidInfo.version.sdkInt;
|
|
return sdkVersion >= 31;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
Future<List<String>> deleteAll(List<String> ids) async {
|
|
if (CurrentPlatform.isAndroid) {
|
|
if (await _androidSupportsTrash()) {
|
|
return PhotoManager.editor.android.moveToTrash(
|
|
ids.map((e) => AssetEntity(id: e, width: 1, height: 1, typeInt: 0)).toList(),
|
|
);
|
|
} else {
|
|
return PhotoManager.editor.deleteWithIds(ids);
|
|
}
|
|
}
|
|
return PhotoManager.editor.deleteWithIds(ids);
|
|
}
|
|
|
|
Future<bool> _restoreFromTrashById(String mediaId, int type) async {
|
|
try {
|
|
return await _nativeSyncApi.restoreFromTrashById(mediaId, type);
|
|
} catch (e, s) {
|
|
_log.warning('Error restore file from trash by Id', e, s);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<List<String>> restoreAssetsFromTrash(Iterable<LocalAsset> assets) async {
|
|
final restoredIds = <String>[];
|
|
for (final asset in assets) {
|
|
_log.info("Restoring from trash, localId: ${asset.id}, checksum: ${asset.checksum}");
|
|
final result = await _restoreFromTrashById(asset.id, asset.type.index);
|
|
if (result) {
|
|
restoredIds.add(asset.id);
|
|
}
|
|
}
|
|
return restoredIds;
|
|
}
|
|
|
|
Future<AssetEntity?> get(String id) async {
|
|
final entity = await AssetEntity.fromId(id);
|
|
return entity;
|
|
}
|
|
|
|
Future<String?> getOriginalFilename(String id) async {
|
|
final entity = await AssetEntity.fromId(id);
|
|
if (entity == null) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
// titleAsync gets the correct original filename for some assets on iOS
|
|
// otherwise using the `entity.title` would return a random GUID
|
|
final originalFilename = await entity.titleAsync;
|
|
// treat empty filename as missing
|
|
return originalFilename.isNotEmpty ? originalFilename : null;
|
|
} catch (e) {
|
|
_log.warning("Failed to get original filename for asset: $id. Error: $e");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Deletes temporary entities in parallel, removing download task directories recursively
|
|
@visibleForTesting
|
|
static Future<void> cleanupShareTempFiles(List<FileSystemEntity> tempEntities) async {
|
|
await Future.wait(
|
|
tempEntities.map((entity) async {
|
|
try {
|
|
if (entity is Directory) {
|
|
await entity.delete(recursive: true);
|
|
} else {
|
|
await entity.delete();
|
|
}
|
|
} catch (e) {
|
|
_log.warning("Failed to delete temporary file: ${entity.path}", e);
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
static final RegExp _pathSeparators = RegExp(r'[\\/]');
|
|
|
|
static String _sanitizeFilename(String filename) {
|
|
return filename.replaceAll(_pathSeparators, '_');
|
|
}
|
|
|
|
@visibleForTesting
|
|
static String getOriginalShareFilename(BaseAsset asset) {
|
|
final hasUsableName = asset.name.replaceAll(_pathSeparators, '').isNotEmpty;
|
|
return hasUsableName ? _sanitizeFilename(asset.name) : _shareFallbackName(asset);
|
|
}
|
|
|
|
static String _shareFallbackName(BaseAsset asset) => asset.remoteId ?? asset.localId ?? 'asset';
|
|
|
|
static String _getPreviewFilename(BaseAsset asset) {
|
|
final sanitizedFilename = _sanitizeFilename(asset.name);
|
|
final baseName = p.basenameWithoutExtension(sanitizedFilename);
|
|
return '${baseName.isEmpty ? _shareFallbackName(asset) : baseName}-preview.jpg';
|
|
}
|
|
|
|
static String _shareDisplayName(BaseAsset asset, ShareAssetType fileType) =>
|
|
switch (asset.isVideo ? ShareAssetType.original : fileType) {
|
|
ShareAssetType.original => getOriginalShareFilename(asset),
|
|
ShareAssetType.preview => _getPreviewFilename(asset),
|
|
};
|
|
|
|
@visibleForTesting
|
|
static String getOrdinalShareDisplayName(String displayName, int occurrence) {
|
|
if (occurrence <= 0) {
|
|
return displayName;
|
|
}
|
|
return '${p.basenameWithoutExtension(displayName)} ($occurrence)${p.extension(displayName)}';
|
|
}
|
|
|
|
@visibleForTesting
|
|
static Map<String, int> countShareDisplayNames(List<BaseAsset> assets, ShareAssetType fileType) {
|
|
final counts = <String, int>{};
|
|
for (final asset in assets) {
|
|
final name = _shareDisplayName(asset, fileType);
|
|
counts[name] = (counts[name] ?? 0) + 1;
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
bool _isCancelled(Completer<void>? cancelCompleter) => cancelCompleter?.isCompleted ?? false;
|
|
|
|
Future<_ShareFile?> _getLocalOriginalShareFile(BaseAsset asset, String localId, int occurrence) async {
|
|
final file = await _storageRepository.getFileForAsset(localId);
|
|
if (file == null) {
|
|
_log.warning("Local original file not found for sharing: $asset");
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
file: file,
|
|
tempEntity: CurrentPlatform.isIOS ? file : null,
|
|
displayName: getOrdinalShareDisplayName(getOriginalShareFilename(asset), occurrence),
|
|
);
|
|
}
|
|
|
|
@visibleForTesting
|
|
static DownloadTask buildShareDownloadTask({
|
|
required String taskId,
|
|
required String url,
|
|
required Map<String, String> headers,
|
|
required String displayName,
|
|
}) {
|
|
return DownloadTask(
|
|
taskId: taskId,
|
|
url: url,
|
|
headers: headers,
|
|
// receiving apps show the shared file's own name, so duplicates get an ordinal suffix in the display name
|
|
filename: displayName,
|
|
directory: taskId,
|
|
baseDirectory: BaseDirectory.temporary,
|
|
group: kShareDownloadGroup,
|
|
updates: Updates.statusAndProgress,
|
|
);
|
|
}
|
|
|
|
Future<_ShareFile?> _downloadRemoteShareFile({
|
|
required String taskId,
|
|
required String url,
|
|
required String displayName,
|
|
Completer<void>? cancelCompleter,
|
|
required void Function(double progress) onProgress,
|
|
}) async {
|
|
final task = buildShareDownloadTask(
|
|
taskId: taskId,
|
|
url: url,
|
|
headers: ApiService.getRequestHeaders(),
|
|
displayName: displayName,
|
|
);
|
|
final downloader = FileDownloader();
|
|
final statusUpdate = await downloader.download(
|
|
task,
|
|
onProgress: (value) {
|
|
if (_isCancelled(cancelCompleter)) {
|
|
unawaited(downloader.cancelTaskWithId(taskId));
|
|
return;
|
|
}
|
|
onProgress(value);
|
|
},
|
|
);
|
|
|
|
if (_isCancelled(cancelCompleter)) {
|
|
return null;
|
|
}
|
|
|
|
if (statusUpdate.status == TaskStatus.complete) {
|
|
final file = File(await task.filePath());
|
|
return (file: file, tempEntity: file.parent, displayName: displayName);
|
|
}
|
|
|
|
_log.severe("Download for $displayName failed with status ${statusUpdate.status}", statusUpdate.exception);
|
|
return null;
|
|
}
|
|
|
|
Future<_ShareFile?> _getRemoteOriginalShareFile(
|
|
BaseAsset asset,
|
|
String remoteId, {
|
|
required int occurrence,
|
|
Completer<void>? cancelCompleter,
|
|
required void Function(double progress) onProgress,
|
|
}) {
|
|
return _downloadRemoteShareFile(
|
|
taskId: 'share-original-$remoteId-${DateTime.now().microsecondsSinceEpoch}',
|
|
url: getOriginalUrlForRemoteId(remoteId, edited: asset.isEdited),
|
|
displayName: getOrdinalShareDisplayName(getOriginalShareFilename(asset), occurrence),
|
|
cancelCompleter: cancelCompleter,
|
|
onProgress: onProgress,
|
|
);
|
|
}
|
|
|
|
Future<_ShareFile?> _getRemotePreviewShareFile(
|
|
BaseAsset asset,
|
|
String remoteId, {
|
|
required int occurrence,
|
|
Completer<void>? cancelCompleter,
|
|
required void Function(double progress) onProgress,
|
|
}) {
|
|
return _downloadRemoteShareFile(
|
|
taskId: 'share-preview-$remoteId-${DateTime.now().microsecondsSinceEpoch}',
|
|
url: getThumbnailUrlForRemoteId(remoteId, type: AssetMediaSize.preview, edited: asset.isEdited),
|
|
displayName: getOrdinalShareDisplayName(_getPreviewFilename(asset), occurrence),
|
|
cancelCompleter: cancelCompleter,
|
|
onProgress: onProgress,
|
|
);
|
|
}
|
|
|
|
Future<_ShareFile?> _getOriginalShareFile(
|
|
BaseAsset asset, {
|
|
required int occurrence,
|
|
Completer<void>? cancelCompleter,
|
|
required void Function(double progress) onProgress,
|
|
}) {
|
|
final localId = asset.localId;
|
|
if (localId != null && !asset.isEdited) {
|
|
return _getLocalOriginalShareFile(asset, localId, occurrence);
|
|
}
|
|
|
|
final remoteId = asset.remoteId;
|
|
if (remoteId == null) {
|
|
_log.warning("Asset has no remote ID for sharing: $asset");
|
|
return Future.value(null);
|
|
}
|
|
|
|
return _getRemoteOriginalShareFile(
|
|
asset,
|
|
remoteId,
|
|
occurrence: occurrence,
|
|
cancelCompleter: cancelCompleter,
|
|
onProgress: onProgress,
|
|
);
|
|
}
|
|
|
|
Future<_ShareFile?> _getPreviewShareFile(
|
|
BaseAsset asset, {
|
|
required int occurrence,
|
|
Completer<void>? cancelCompleter,
|
|
required void Function(double progress) onProgress,
|
|
}) async {
|
|
final remoteId = asset.remoteId;
|
|
if (remoteId != null) {
|
|
final remotePreview = await _getRemotePreviewShareFile(
|
|
asset,
|
|
remoteId,
|
|
occurrence: occurrence,
|
|
cancelCompleter: cancelCompleter,
|
|
onProgress: onProgress,
|
|
);
|
|
if (remotePreview != null || asset.isEdited) {
|
|
return remotePreview;
|
|
}
|
|
}
|
|
|
|
final localId = asset.localId;
|
|
if (localId != null) {
|
|
return _getLocalOriginalShareFile(asset, localId, occurrence);
|
|
}
|
|
|
|
_log.warning("Asset has no local or remote ID for preview sharing: $asset");
|
|
return null;
|
|
}
|
|
|
|
Future<int> shareAssets(
|
|
List<BaseAsset> assets,
|
|
BuildContext context, {
|
|
ShareAssetType fileType = ShareAssetType.original,
|
|
Completer<void>? cancelCompleter,
|
|
void Function(double progress)? onAssetDownloadProgress,
|
|
}) async {
|
|
final downloadedXFiles = <XFile>[];
|
|
final tempFiles = <FileSystemEntity>[];
|
|
final totalAssets = assets.length;
|
|
var processedAssets = 0;
|
|
|
|
void updateProgress([double currentAssetProgress = 0.0]) {
|
|
if (totalAssets <= 0) {
|
|
onAssetDownloadProgress?.call(1.0);
|
|
return;
|
|
}
|
|
|
|
final normalizedAssetProgress = currentAssetProgress.clamp(0.0, 1.0);
|
|
final overallProgress = ((processedAssets + normalizedAssetProgress) / totalAssets).clamp(0.0, 1.0);
|
|
onAssetDownloadProgress?.call(overallProgress);
|
|
}
|
|
|
|
updateProgress();
|
|
|
|
final displayNameCounts = countShareDisplayNames(assets, fileType);
|
|
final displayNameOccurrences = <String, int>{};
|
|
|
|
for (final asset in assets) {
|
|
if (_isCancelled(cancelCompleter)) {
|
|
await cleanupShareTempFiles(tempFiles);
|
|
return 0;
|
|
}
|
|
|
|
final effectiveFileType = asset.isVideo ? ShareAssetType.original : fileType;
|
|
final displayName = _shareDisplayName(asset, fileType);
|
|
final occurrence = displayNameCounts[displayName]! > 1
|
|
? displayNameOccurrences.update(displayName, (count) => count + 1, ifAbsent: () => 1)
|
|
: 0;
|
|
|
|
final shareFile = switch (effectiveFileType) {
|
|
ShareAssetType.original => await _getOriginalShareFile(
|
|
asset,
|
|
occurrence: occurrence,
|
|
cancelCompleter: cancelCompleter,
|
|
onProgress: updateProgress,
|
|
),
|
|
ShareAssetType.preview => await _getPreviewShareFile(
|
|
asset,
|
|
occurrence: occurrence,
|
|
cancelCompleter: cancelCompleter,
|
|
onProgress: updateProgress,
|
|
),
|
|
};
|
|
|
|
if (_isCancelled(cancelCompleter)) {
|
|
await cleanupShareTempFiles(tempFiles);
|
|
return 0;
|
|
}
|
|
|
|
if (shareFile == null) {
|
|
processedAssets++;
|
|
updateProgress();
|
|
continue;
|
|
}
|
|
|
|
downloadedXFiles.add(XFile(shareFile.file.path, name: shareFile.displayName));
|
|
final tempEntity = shareFile.tempEntity;
|
|
if (tempEntity != null) {
|
|
tempFiles.add(tempEntity);
|
|
}
|
|
processedAssets++;
|
|
updateProgress();
|
|
}
|
|
|
|
if (downloadedXFiles.isEmpty) {
|
|
_log.warning("No asset can be retrieved for share");
|
|
return 0;
|
|
}
|
|
|
|
if (_isCancelled(cancelCompleter)) {
|
|
await cleanupShareTempFiles(tempFiles);
|
|
return 0;
|
|
}
|
|
|
|
// we dont want to await the share result since the
|
|
// "preparing" dialog will not disappear until
|
|
final size = context.sizeData;
|
|
unawaited(
|
|
Share.shareXFiles(
|
|
downloadedXFiles,
|
|
sharePositionOrigin: Rect.fromPoints(Offset.zero, Offset(size.width / 3, size.height)),
|
|
).then((result) async {
|
|
await cleanupShareTempFiles(tempFiles);
|
|
}),
|
|
);
|
|
|
|
return downloadedXFiles.length;
|
|
}
|
|
}
|